* 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 onto178ec000moved 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 #{1,2} `[CODE.MM]` heading, including one bearing the SAME milestone id 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 containaa7697fe(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. * fix(#2761): three review-lens corrections to the merge resolution Found by an adversarial pass over the re-homing, none caught by any gate. 1. planning-snapshot.cts — `phaseDirs` goes back to upstream's exact call, `listMilestonePhaseDirs(paths.phases, { cwd })`. The merge passed the resolved convention explicitly, on the belief it was a behaviour-preserving spelling of the lazy default. It is not. The lazy path resolves `resolvePhaseIdConvention(cwd, ws)` with this call's `ws`, which defaults to `null` — the PROJECT-only reading, no root fallback — while the `phaseIdConvention` field is resolved federated (workstream -> root). On a workstream repo whose ROOT opts into bracket and whose workstream config does not, those answers differ, so the explicit pass silently re-scoped `phaseDirs`. No test covered it in either direction (proven: reverting is green across all bracket + health-diagnostic + snapshot suites). PR-2 does not need that change, so it is dropped rather than kept untested. The federation guarantee is still delivered where it is observable — in the rules that read `snapshot.phaseIdConvention`. 2. planning-snapshot.cts — `buildRoadmapBracketIncoherencesField` decides file-readability BEFORE the convention gate, so `scope` means one thing on every repo. Ordered the other way, an absent ROADMAP.md reported COMPLETE on a legacy repo and UNREADABLE on a bracket one — the same "nothing to say" state wearing two scopes, which is exactly the non-answer/answer distinction ADR-3180 §8.1 gives `scope` to carry. No behaviour change (RULE_W021 does not read this scope); it removes an ambiguity a reader of that ADR would catch. 3. roadmap-disk-consistency.cts — `checkW006` reads `roadmapSentinelPhaseTokens` without its own scope guard. That is safe TODAY because one builder call produces it and `roadmapDeclaredPhases` from one `buildRoadmapPhaseVariants` result, so their scopes are yoked; the existing COMPLETE check covers both. Stated in a comment instead of left to co-location, since an UNREADABLE sentinel set degrades to "nothing is a sentinel", which ADDS W006 warnings rather than dropping them. * 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 #3446's `findMilestoneScopeHeadingLines` is defined as a MIRROR of the 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 in70b5c1a1(#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 #3480 or this branch; out of scope here. 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): thread convention into the #3511 membership seam — bracket dirs scope like their legacy twins Upstream #3511's isPhaseArtifact/scopeToPhase was structurally inert on bracket directories: 3+-letter codes (GSD.02-…) fell into the zero-token include-everything fail-safe (PROJECT_CODE_PREFIX_CAPTURE_RE_I strips {CODE}-, not {CODE}.), and short digit-bearing codes (A1.02-…) into the firstLetterPrefixed one — so every bracket dir read include-everything and a cross-phase stray could complete a bracket phase, the exact contamination #3511 shipped to prevent, still open on this PR's own convention. The seam now takes the same optional trailing convention its sibling primitives do (ADR-2121 additive shape; gated on BRACKET_DIR_TOKEN_RE, the one grammar owner shared with extractPhaseToken's bracket branch), through a candidate-comparison core shared with the legacy path so the two cannot drift. Threaded by the call sites that already hold the resolved convention: the completion chain (isPhaseComplete -> readVerificationStatus -> resolveVerificationFile, with a convention-aware RESOLUTION token and a deliberately convention-less command-argument token), buildStateFrontmatter, cmdStateSync, cmdStateValidate's S006/S007 scan, the planning snapshot, and roadmap analysis. Convention-less readers (uat, audit, init, gap-checker, phase-locator) keep the documented fail-safe — the follow-up slice. The phase-counting fixture now writes what cmdScaffold writes (phase-matched ${normalizePhaseName(phase)}-VERIFICATION.md) instead of a hardcoded 01-VERIFICATION.md that #3511 correctly reads as a stray; the stray shape is pinned deliberately in a new regression block (measured pre-fix: [3,3,3,2,67] with the stray counted as completion) alongside seam-level pins for BOTH fail-safe families, so neither the rename nor a one-branch patch can stand in for the fix. * fix(#2761): review round — gate the FILENAME reading on the convention too, ratify the M-NN twin-parity trade Two-leg review of the seam thread (adversarial + upstream-fit, then an adversarial re-verify of the fixes) found and this commit closes: - Major: the convention gated only the DIRECTORY reading. A milestone-qualified artifact name (GSD.02-01-VERIFICATION.md — the layout the read-tolerance suite models) derives zero legacy token segments, so FIX 2's token-less containment admitted it into ANY bracket dir. Qualified stems now compare on bracketQualifiedKey — exact key, or the dotted sub-phase continuation (the round-2 verify caught strict equality dropping the dot arm: over-exclusion, the dangerous direction) — while a well-formed stem naming a different phase or milestone is excluded. Malformed qualified-shaped stems keep the documented include-everything fail-safe. - Major, RATIFIED not changed: an M-NN-stem report (02-01-VERIFICATION.md in GSD.02-01-one) is excluded exactly as its legacy twin excludes the same file; migration-window artifact renaming belongs to the migrator slice (ADR-612). Pinned in tests with its legacy-twin control. - Minor: the counting fixture's verificationNameFor now routes through the production normalizePhaseName (padding has a single owner), and its docstring states honestly which artifact layout it models. - cmdRoadmapUpdatePlanProgress threads the convention into isPhaseComplete (ADR-3180 §7.4 read/write-path symmetry with the analyze site). All pins carry measured pre-fix values. Convention-less paths swept byte-identical (1,800 comparisons, 0 differences). * fix(#2761): mark the round-11 decoy fixture incomplete so it doesn't need a phase token The two round-11 BLOCKER tests (cmdMilestoneComplete / cmdStateUpdateProgress enumeration) pass a `not-a-declared-phase` decoy directory to writeProject as an implicitly-complete fixture entry. After threading convention through the #3511 membership seam, verificationNameFor derives the scaffolder's real verification filename from the directory's phase token — which the decoy, by design, does not carry. Mark the decoy incomplete instead: the assertions these tests pin (exclusion from would_archive.phases / stats.phases) don't depend on the decoy having a VERIFICATION file at all. 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> * 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 onto178ec000moved 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 containaa7697fe(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 in70b5c1a1(#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). * chore(#2761): give this slice its own changeset and document the membership scoping PR-2 (#2867) merged separately, so the completion-seam entry no longer belongs appended to its fragment. Restore .changeset/2761-bracket-read-tolerance.md to the merged version and carry this slice's entry in its own fragment with the correct pr: field, clearing changeset-lint's fail_pr_field_drift. Document the membership scoping in docs/CONFIGURATION.md, which the new Added fragment requires under lint:docs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(#4142): cite #4142 in the completion-seam changeset backlink The fragment's trailing backlink named #2761 — the delivered PR-2 sibling whose ratified scope excluded this completion seam — rather than #4142, the issue this PR actually closes. The PR body already carries the correct `Closes #4142` with `Refs #2761` for lineage; only the changeset disagreed. Backlinks are a convention rather than a machine-checked field, so changeset-lint passed on the wrong citation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAWrF68cHxW3Ek5CsGUeqJ * docs(#4142): name phase complete and workstream-inventory in the unthreaded-caller disclosure Round-4 review Major. The changeset's "still unthreaded" list named only the aggregate scans (uat, audit, init projections, gap-checker, phase-locator) and omitted two call sites that also stay on the include-everything fail-safe for bracket directories after this PR: - cmdPhaseComplete (src/phase.cts:3394,3405) — `phase complete`'s advisory UAT and VERIFICATION warning pre-scan calls scopeToPhase two-arg, so a bracket phase can still be warned about a cross-phase stray it does not own. - src/workstream-inventory.cts:663 — calls isPhaseComplete, which this PR made convention-aware, without resolving a convention to pass it. Because roadmap.cts's write path WAS threaded here on ADR-3180 section 7.4 read/write symmetry, leaving `phase complete` off both the code and the disclosure was the omission most likely to be read as covered by "the same protection #3511 already gives legacy directories". That claim is now scoped to the call sites this PR threads, in both the changeset and the shipped docs/CONFIGURATION.md cell, which carried the identical omission. Disclosure only — no production code changed. Threading these two remains follow-up-slice work alongside the epic's other convention-less readers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P959Zm5r2KhsMF83UzXDFJ * fix(#4142): scope phase completion verification by convention Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#4142): refresh macOS conformance tier --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
4731 lines
236 KiB
TypeScript
4731 lines
236 KiB
TypeScript
/**
|
||
* Phase — Phase CRUD, query, and lifecycle operations
|
||
*
|
||
* ADR-457 build-at-publish: the hand-written bin/lib/phase.cjs collapsed to
|
||
* a TypeScript source of truth, compiled by tsc to a gitignored .cjs at the
|
||
* same require() path. Behaviour preserved byte-for-behaviour; only types are added.
|
||
*
|
||
* Re-export shim note (issue #4 / ADR-3524):
|
||
* The phase lifecycle pure-computation helpers live in phase-lifecycle.cjs.
|
||
* cmdPhaseComplete uses
|
||
* deriveProgressFromRoadmap + clampPercent from that module to fix the
|
||
* non-idempotent Completed Phases blind-increment bug.
|
||
*
|
||
* The async mutation handlers (phaseAdd, phaseInsert, phaseRemove, phaseComplete)
|
||
* in phase-lifecycle.ts are I/O-bound and remain per-side per ADR-3524 Section 4.
|
||
* This file provides the CJS (sync) implementations of those handlers.
|
||
*/
|
||
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { execFileSync } from 'node:child_process';
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
|
||
import ioMod = require('./io.cjs');
|
||
const { output, error, ERROR_REASON, formatDiagnosticToken } = ioMod;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
import stateContract = require('./state-contract.cjs');
|
||
const { publishStateContract } = stateContract;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
|
||
import configLoaderMod = require('./config-loader.cjs');
|
||
const { loadConfig } = configLoaderMod;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
|
||
import coreUtilsMod = require('./core-utils.cjs');
|
||
// #2528: `extractCanonicalPlanId` used to exist here as a byte-identical second
|
||
// copy, and this PR had to patch BOTH with the same rewind rule — the exact
|
||
// generative-fix divergence CLAUDE.md warns about. Collapsed onto core-utils'
|
||
// copy, which was already the leaf owner, so there is no second surface left to
|
||
// drift and no parity test needed to police one.
|
||
const {
|
||
toPosixPath, generateSlugInternal, readSubdirectories, extractCanonicalPlanId,
|
||
findUnsummarizedPlans, normalizeLineEndings,
|
||
} = coreUtilsMod;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
|
||
import phaseIdMod = require('./phase-id.cjs');
|
||
const {
|
||
normalizePhaseName,
|
||
phaseMarkdownRegexSource,
|
||
comparePhaseNum,
|
||
matchPhaseDirs,
|
||
isSentinelPhaseId,
|
||
scopeToPhase,
|
||
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
||
OPTIONAL_PHASE_TAG_SOURCE,
|
||
PHASE_NUMBER_TOKEN_SOURCE,
|
||
} = phaseIdMod;
|
||
import { escapeRegex } from './pattern.cjs';
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
|
||
import phaseLocatorMod = require('./phase-locator.cjs');
|
||
const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs, listAllPhaseDirs } = phaseLocatorMod;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
|
||
import planningScopeMod = require('./planning-scope.cjs');
|
||
const { SCOPE } = planningScopeMod;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
|
||
import roadmapParserMod = require('./roadmap-parser.cjs');
|
||
const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, findMilestoneScopeHeadingLines } = roadmapParserMod;
|
||
// #4129: the single owner of "count the ROADMAP's milestone Complete rows"
|
||
// (pure computation, no I/O — no cycle on this path) for the intent-first
|
||
// progress counters the phase-complete transaction passes downstream.
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-lifecycle.cjs is an export= CommonJS module
|
||
import phaseLifecycleMod = require('./phase-lifecycle.cjs');
|
||
const { deriveProgressFromRoadmap: deriveProgressFromRoadmapForIntent, clampPercent: clampPercentForIntent } = phaseLifecycleMod;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
||
import planningWorkspace = require('./planning-workspace.cjs');
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
||
import frontmatterMod = require('./frontmatter.cjs');
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module
|
||
import stateMod = require('./state.cjs');
|
||
import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync, contentChangedAfterNormalize } from './shell-command-projection.cjs';
|
||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||
import { realClock } from './clock.cjs';
|
||
import { transitionCore } from './state-transition.cjs';
|
||
import { updateTableCell, deleteTableRow, escapeCell } from './markdown-table.cjs';
|
||
import { deleteSection, updateBullet } from './markdown-sectionizer.cjs';
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
|
||
import uatPredicate = require('./uat-predicate.cjs');
|
||
const { evaluateUatPassed } = uatPredicate;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
|
||
import verificationMod = require('./verification.cjs');
|
||
// #2572: the artifact↔disk core behind the `verify-summary` verb. `verify.cts`
|
||
// has no transitive import path back to `phase.cts`, so this edge introduces no
|
||
// cycle (the reverse edge, `state.cts → verify.cjs`, would).
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verify.cjs is an export= CommonJS module
|
||
import verifyMod = require('./verify.cjs');
|
||
const { readVerificationStatus } = verificationMod;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-dependency-graph.cjs is an export= CommonJS module
|
||
import planDependencyGraphMod = require('./plan-dependency-graph.cjs');
|
||
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSummaryFileBlocked } = planDependencyGraphMod;
|
||
|
||
// #612: `resolvePhaseIdConvention` selects the write-time milestone-scope
|
||
// guard's terminator vocabulary (see assertDescriptionPreservesMilestoneScope).
|
||
const {
|
||
planningDir, withPlanningLock, listAvailableWorkstreams,
|
||
peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason,
|
||
resolvePhaseIdConvention,
|
||
} = planningWorkspace;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module
|
||
import milestoneLockMod = require('./milestone-lock.cjs');
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
import planDocumentMod = require('./plan-document.cjs');
|
||
const { parsePlanDocument, planIdFromFile } = planDocumentMod;
|
||
const { extractFrontmatter } = frontmatterMod;
|
||
const {
|
||
readModifyWriteStateMd,
|
||
stateExtractField,
|
||
stateReplaceField,
|
||
syncAndPreserveStateMd,
|
||
withStateLock,
|
||
updatePerformanceMetricsSection,
|
||
} = stateMod;
|
||
|
||
// Any .md file with PLAN anywhere in the basename — diagnostic net
|
||
const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
|
||
const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
|
||
const looksLikePlanFile = (f: string): boolean =>
|
||
/\.md$/i.test(f) &&
|
||
/PLAN/i.test(f) &&
|
||
!PLAN_OUTLINE_RE.test(f) &&
|
||
!PLAN_PRE_BOUNCE_RE.test(f);
|
||
|
||
/**
|
||
* Scope an `updateTableCell` call to the `## Traceability` (or
|
||
* `## Traceability Status`) heading's own section — up to the next H1/H2
|
||
* heading — instead of handing it the WHOLE REQUIREMENTS.md content.
|
||
*
|
||
* F1 (#2245 review, BLOCKER): `updateTableCell` binds to the FIRST GFM table
|
||
* found in whatever text it is given. The shipped requirements template
|
||
* (gsd-core/templates/requirements.md) puts an `## Out of Scope` table
|
||
* (`| Feature | Reason |`, no `Status` column) BEFORE `## Traceability` — so
|
||
* an unscoped whole-file call targets the Out-of-Scope table instead, fails
|
||
* with `{ok:false, reason:'unknown column: Status'}`, and the real
|
||
* Traceability row is never flipped, while the checkbox surface still flips
|
||
* and the command reports success (the #2140 silent-divergence class one
|
||
* level deeper). Mirrors `editProgressHeadingSlice` below, which scopes
|
||
* `## Progress` writes to that heading's own slice for the same reason.
|
||
*
|
||
* Falls back to running `updateTableCell` against the whole `text` when no
|
||
* `## Traceability` heading exists — matching the previous (unscoped)
|
||
* behaviour for a REQUIREMENTS.md whose traceability table sits under some
|
||
* other heading, or with no heading at all (never worse than before this fix).
|
||
*/
|
||
function updateTraceabilityCell(
|
||
text: string,
|
||
match: (row: Record<string, string>, index: number) => boolean,
|
||
column: string,
|
||
newValue: string | ((current: string) => string),
|
||
): ReturnType<typeof updateTableCell> {
|
||
const headingMatch = text.match(/^##[ \t]+Traceability(?:[ \t]+Status)?\b/im);
|
||
if (!headingMatch || headingMatch.index === undefined) {
|
||
return updateTableCell(text, match, column, newValue);
|
||
}
|
||
const headingOffset = headingMatch.index;
|
||
const before = text.slice(0, headingOffset);
|
||
const fromHeading = text.slice(headingOffset);
|
||
const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
|
||
const scoped = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
|
||
const after = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
|
||
|
||
const result = updateTableCell(scoped, match, column, newValue);
|
||
if (!result.ok) return result;
|
||
return { ok: true, value: before + result.value + after };
|
||
}
|
||
|
||
/**
|
||
* Extract the MAJOR version segment from a version-ish string: "v1", "v1.3",
|
||
* "V1.0", and "1.0" all yield "1"; "v2" yields "2". Used (#2334 BLOCKER fix)
|
||
* to compare a `## v<N> ...` REQUIREMENTS.md heading against the current
|
||
* milestone's version at MAJOR-version granularity only — "v1" heading vs
|
||
* milestone "v1.3" is the SAME major version and must not be treated as a
|
||
* version mismatch. Returns null when `raw` has no leading digit run (not a
|
||
* version-shaped string), which the caller treats as "cannot resolve".
|
||
*/
|
||
function extractMajorVersion(raw: string): string | null {
|
||
const m = raw.trim().match(/^v?(\d+)/i);
|
||
return m ? m[1] : null;
|
||
}
|
||
|
||
function describeNonCanonicalPlans(dirFiles: string[], matchedFiles: string[]): string | null {
|
||
const matched = new Set(matchedFiles);
|
||
const offenders = dirFiles.filter((f) => looksLikePlanFile(f) && !matched.has(f));
|
||
if (offenders.length === 0) return null;
|
||
return (
|
||
`Found ${offenders.length} plan-shaped file(s) in this phase that don't match the canonical ` +
|
||
`naming convention "{padded_phase}-{NN}-PLAN.md" (or bare "PLAN.md") and were skipped: ` +
|
||
offenders.map((f) => `"${f}"`).join(', ') +
|
||
`. Rename to the canonical form (e.g. "01-01-PLAN.md") so the executor can detect them. ` +
|
||
`See agents/gsd-planner.md write_phase_prompt step for the full contract.`
|
||
);
|
||
}
|
||
|
||
|
||
interface PhaseListOptions {
|
||
type?: string;
|
||
phase?: string;
|
||
includeArchived?: boolean;
|
||
}
|
||
|
||
function cmdPhasesList(cwd: string, options: PhaseListOptions, raw: boolean): void {
|
||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||
const { type, phase, includeArchived } = options;
|
||
|
||
if (!fs.existsSync(phasesDir)) {
|
||
if (type) {
|
||
output({ files: [], count: 0 }, raw, '');
|
||
} else {
|
||
output({ directories: [], count: 0 }, raw, '');
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// #3185 (ADR-3180 Decision 1): only the ENUMERATION routes through the
|
||
// single owner. The two other modes below ask genuinely DIFFERENT
|
||
// questions and are exempt by documented reason, never by a file
|
||
// allowlist (ADR-3180 Decision 4a):
|
||
//
|
||
// --phase <n> locating ONE phase by token is phase LOCATION, a
|
||
// question src/phase-locator.cts already owns via
|
||
// findPhaseInternal/searchPhaseInDir. Scoping it to
|
||
// the current milestone would make an out-of-window
|
||
// phase report "Phase not found".
|
||
// --include-archived archived directories are BY DEFINITION from other
|
||
// milestones; filtering them through the CURRENT
|
||
// milestone window would return nothing at all.
|
||
//
|
||
// Generalizing #3183's rule ("a diagnostic about file NAMING wants the
|
||
// physical set; only a question about outstanding WORK wants the live
|
||
// set"): a LOOKUP wants the physical set; only "which phases belong to
|
||
// this milestone" wants the scoped set.
|
||
const archivedLabels: string[] = includeArchived
|
||
? getArchivedPhaseDirs(cwd).map((a) => `${a.name} [${a.milestone}]`)
|
||
: [];
|
||
|
||
let dirs: string[];
|
||
// #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
|
||
// can tell a genuinely-empty milestone from one it could not scope. Only
|
||
// the ENUMERATION path scopes anything; the LOOKUP path below has no
|
||
// enumeration to report a scope for.
|
||
let phaseScope: string | null = null;
|
||
if (phase) {
|
||
// LOOKUP (b): search the physical set, plus archived when asked.
|
||
const lookupPool = [...readSubdirectories(phasesDir, true), ...archivedLabels];
|
||
const normalized = normalizePhaseName(phase);
|
||
// The pool is #3185's (physical set + archived); the matcher is this
|
||
// PR's. `dirs` is deliberately not read here: on this base it is not
|
||
// assigned until the branch below picks a match.
|
||
const { matches } = matchPhaseDirs(lookupPool, normalized);
|
||
const match = matches[0];
|
||
if (!match) {
|
||
output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
|
||
return;
|
||
}
|
||
dirs = [match];
|
||
} else {
|
||
// ENUMERATION (a): milestone-scoped and sentinel-filtered, plus
|
||
// archived when asked (c).
|
||
const enumerated = listMilestonePhaseDirs(phasesDir, { cwd });
|
||
phaseScope = enumerated.scope;
|
||
dirs = [...enumerated.value, ...archivedLabels];
|
||
dirs.sort((a, b) => comparePhaseNum(a, b));
|
||
}
|
||
|
||
if (type) {
|
||
const files: string[] = [];
|
||
const warnings: string[] = [];
|
||
for (const dir of dirs) {
|
||
const dirPath = path.join(phasesDir, dir);
|
||
const dirFiles = fs.readdirSync(dirPath);
|
||
|
||
let filtered: string[];
|
||
if (type === 'plans') {
|
||
// #3183: this is a "what plan files physically exist" query (this
|
||
// IS the file-listing command), not a live-completion question, so
|
||
// it uses the single owner's allPlanFiles (root+nested, INCLUDING
|
||
// status: superseded) rather than a root-only readdirSync filter
|
||
// that also missed nested plans.
|
||
//
|
||
// #2893 (regression fix): `allPlanFiles` also carries
|
||
// `isRootPlanFile`'s loose `/PLAN/i` fallback (deliberately
|
||
// permissive for live-plan COUNTING elsewhere — see
|
||
// plan-count-single-owner.test.cjs). That fallback silently
|
||
// recognized a non-canonically-named file (e.g.
|
||
// `01-PLAN-01-foundation.md`) as "matched", which defeated this
|
||
// command's #2893 naming-convention diagnostic entirely (no
|
||
// warning, file listed as if valid). Intersect with the STRICT
|
||
// `isCanonicalPlanFile` predicate so this diagnostic — and the
|
||
// `files` list this command actually returns — only ever
|
||
// recognizes the canonical root/nested forms, exactly like the
|
||
// pre-#3183 behavior this feature was built and tested against.
|
||
filtered = scanPhasePlans(dirPath).allPlanFiles.filter(isCanonicalPlanFile);
|
||
const w = describeNonCanonicalPlans(dirFiles, filtered);
|
||
if (w) warnings.push(`${dir}: ${w}`);
|
||
} else if (type === 'summaries') {
|
||
filtered = scanPhasePlans(dirPath).summaryFiles;
|
||
} else {
|
||
filtered = dirFiles;
|
||
}
|
||
|
||
files.push(...filtered.sort());
|
||
}
|
||
|
||
const result: Record<string, unknown> = {
|
||
files,
|
||
count: files.length,
|
||
phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)*-?/, '') : null,
|
||
// #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
|
||
// can tell a genuinely-empty milestone from one it could not scope.
|
||
phase_scope: phaseScope,
|
||
};
|
||
if (warnings.length) result['warning'] = warnings.join(' | ');
|
||
output(result, raw, files.join('\n'));
|
||
return;
|
||
}
|
||
|
||
// #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
|
||
// can tell a genuinely-empty milestone from one it could not scope.
|
||
output({ directories: dirs, count: dirs.length, phase_scope: phaseScope }, raw, dirs.join('\n'));
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
error('Failed to list phases: ' + msg);
|
||
}
|
||
}
|
||
|
||
function cmdPhaseNextDecimal(cwd: string, basePhase: string, raw: boolean): void {
|
||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||
const normalized = normalizePhaseName(basePhase);
|
||
|
||
try {
|
||
let baseExists = false;
|
||
const decimalSet = new Set<number>();
|
||
|
||
if (fs.existsSync(phasesDir)) {
|
||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||
baseExists = matchPhaseDirs(dirs, normalized).matches.length > 0;
|
||
}
|
||
|
||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||
if (fs.existsSync(roadmapPath)) {
|
||
try {
|
||
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||
for (const n of scanExistingDecimalPhaseNumbers(phasesDir, roadmapContent, normalized)) {
|
||
decimalSet.add(n);
|
||
}
|
||
} catch {
|
||
// ROADMAP.md read failure is non-fatal — fall back to the directory-only
|
||
// scan (empty rawContent) so on-disk decimal directories are still counted.
|
||
for (const n of scanExistingDecimalPhaseNumbers(phasesDir, '', normalized)) {
|
||
decimalSet.add(n);
|
||
}
|
||
}
|
||
} else {
|
||
for (const n of scanExistingDecimalPhaseNumbers(phasesDir, '', normalized)) {
|
||
decimalSet.add(n);
|
||
}
|
||
}
|
||
|
||
const existingDecimals = Array.from(decimalSet)
|
||
.sort((a, b) => a - b)
|
||
.map((n) => `${normalized}.${n}`);
|
||
|
||
let nextDecimal: string;
|
||
if (decimalSet.size === 0) {
|
||
nextDecimal = `${normalized}.1`;
|
||
} else {
|
||
nextDecimal = `${normalized}.${Math.max(...decimalSet) + 1}`;
|
||
}
|
||
|
||
output(
|
||
{
|
||
found: baseExists,
|
||
base_phase: normalized,
|
||
next: nextDecimal,
|
||
existing: existingDecimals,
|
||
},
|
||
raw,
|
||
nextDecimal,
|
||
);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
error('Failed to calculate next decimal phase: ' + msg);
|
||
}
|
||
}
|
||
|
||
function getRoadmapModeForPhase(cwd: string, phaseNum: string): string | null {
|
||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||
if (!fs.existsSync(roadmapPath)) return null;
|
||
|
||
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||
const milestoneContent = extractCurrentMilestone(rawContent, cwd);
|
||
const fullContent = stripShippedMilestones(rawContent);
|
||
const escapedPhase = phaseMarkdownRegexSource(phaseNum);
|
||
const phaseHeader = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
|
||
|
||
for (const content of [milestoneContent, fullContent]) {
|
||
const headerMatch = content.match(phaseHeader);
|
||
if (!headerMatch || headerMatch.index === undefined) continue;
|
||
|
||
const sectionStart = headerMatch.index;
|
||
const rest = content.slice(sectionStart);
|
||
const nextHeader = rest.slice(headerMatch[0].length).match(/\n#{2,4}\s+Phase\s+\S/i);
|
||
const sectionEnd = nextHeader
|
||
? sectionStart + headerMatch[0].length + (nextHeader.index as number)
|
||
: content.length;
|
||
const section = content.slice(sectionStart, sectionEnd);
|
||
const modeMatch = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||
if (modeMatch) return modeMatch[1].trim().toLowerCase();
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function cmdPhaseMvpMode(cwd: string, args: string[], raw: boolean): void {
|
||
const phaseNum = args[0];
|
||
if (!phaseNum) {
|
||
error('Usage: phase.mvp-mode <phase-number> [--cli-flag]', ERROR_REASON.USAGE);
|
||
}
|
||
|
||
const cliFlagPresent = args.includes('--cli-flag');
|
||
const roadmapMode = getRoadmapModeForPhase(cwd, phaseNum);
|
||
const config = loadConfig(cwd);
|
||
const configMvpMode = Boolean(config.mvp_mode);
|
||
|
||
let active = false;
|
||
let source = 'none';
|
||
if (cliFlagPresent) {
|
||
active = true;
|
||
source = 'cli_flag';
|
||
} else if (roadmapMode === 'mvp') {
|
||
active = true;
|
||
source = 'roadmap';
|
||
} else if (configMvpMode) {
|
||
active = true;
|
||
source = 'config';
|
||
}
|
||
|
||
output(
|
||
{
|
||
active,
|
||
source,
|
||
roadmap_mode: roadmapMode,
|
||
config_mvp_mode: configMvpMode,
|
||
cli_flag_present: cliFlagPresent,
|
||
},
|
||
raw,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* `phase.tdd-applicable <plan-file> [--cli-flag]` (#4273, Phase 1 of epic
|
||
* #4272) — resolves whether the TDD RED/GREEN/REFACTOR gate applies to a
|
||
* given plan, in strict precedence order: an explicit `--cli-flag` wins over
|
||
* the plan's own `type: tdd` frontmatter, which wins over any task in the
|
||
* plan carrying `tdd="true"` (the #4265 mixed-mode shape), which wins over
|
||
* the project-wide `workflow.tdd_mode` config default. Mirrors
|
||
* `cmdPhaseMvpMode`'s precedence-cascade shape immediately above.
|
||
*/
|
||
function cmdPhaseTddApplicable(cwd: string, args: string[], raw: boolean): void {
|
||
const planPath = args[0];
|
||
if (!planPath) {
|
||
error('Usage: phase.tdd-applicable <plan-file> [--cli-flag]', ERROR_REASON.USAGE);
|
||
}
|
||
|
||
const resolvedPath = path.isAbsolute(planPath) ? planPath : path.join(cwd, planPath);
|
||
if (!fs.existsSync(resolvedPath)) {
|
||
error(`Plan file not found: ${planPath}`, ERROR_REASON.PHASE_NOT_FOUND);
|
||
}
|
||
|
||
const cliFlagPresent = args.includes('--cli-flag');
|
||
const content = fs.readFileSync(resolvedPath, 'utf-8');
|
||
const doc = parsePlanDocument(content, resolvedPath);
|
||
const planType = doc.type;
|
||
const taskTddAttribute = doc.tasks.some((t) => t.tdd === 'true');
|
||
const config = loadConfig(cwd);
|
||
const configTddMode = Boolean(config.tdd_mode);
|
||
|
||
let applicable = false;
|
||
let source = 'none';
|
||
if (cliFlagPresent) {
|
||
applicable = true;
|
||
source = 'cli_flag';
|
||
} else if (planType === 'tdd') {
|
||
applicable = true;
|
||
source = 'plan_frontmatter';
|
||
} else if (taskTddAttribute) {
|
||
applicable = true;
|
||
source = 'task_attribute';
|
||
} else if (configTddMode) {
|
||
applicable = true;
|
||
source = 'config';
|
||
}
|
||
|
||
output(
|
||
{
|
||
applicable,
|
||
source,
|
||
plan_type: planType,
|
||
config_tdd_mode: configTddMode,
|
||
cli_flag_present: cliFlagPresent,
|
||
},
|
||
raw,
|
||
);
|
||
}
|
||
|
||
function cmdFindPhase(cwd: string, phase: string, raw: boolean): void {
|
||
if (!phase) {
|
||
error('phase identifier required');
|
||
}
|
||
|
||
const planBase = planningDir(cwd);
|
||
const normalized = normalizePhaseName(phase);
|
||
const notFound = {
|
||
found: false,
|
||
directory: null,
|
||
phase_number: null,
|
||
phase_name: null,
|
||
plans: [],
|
||
summaries: [],
|
||
// #3218: scalar counts alongside the arrays above. Left `null` (not `0`)
|
||
// when the phase can't be resolved at all — a fabricated `0` here would
|
||
// read identically to "phase exists with zero plans", which is a real,
|
||
// distinct answer (see the `status: superseded` case below).
|
||
plan_count: null,
|
||
summary_count: null,
|
||
plan_count_all: null,
|
||
searched_directories: [] as string[],
|
||
};
|
||
|
||
const searchDirs: string[] = [];
|
||
const flatPhasesDir = path.join(planBase, 'phases');
|
||
if (fs.existsSync(flatPhasesDir)) searchDirs.push(flatPhasesDir);
|
||
try {
|
||
const milestonesDir = path.join(planBase, 'milestones');
|
||
const entries = fs
|
||
.readdirSync(milestonesDir, { withFileTypes: true })
|
||
.filter((e) => e.isDirectory() && /^v\d+.*-phases$/.test(e.name))
|
||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||
for (const e of entries) {
|
||
searchDirs.push(path.join(milestonesDir, e.name));
|
||
}
|
||
} catch {
|
||
/* no milestones dir */
|
||
}
|
||
|
||
notFound.searched_directories = searchDirs.map((searchDir) =>
|
||
toPosixPath(
|
||
path.join(path.relative(cwd, planBase), path.relative(planBase, searchDir)),
|
||
),
|
||
);
|
||
|
||
for (const searchDir of searchDirs) {
|
||
try {
|
||
const entries = fs.readdirSync(searchDir, { withFileTypes: true });
|
||
const dirs = entries
|
||
.filter((e) => e.isDirectory())
|
||
.map((e) => e.name)
|
||
.sort((a, b) => comparePhaseNum(a, b));
|
||
|
||
// #2237: fail loud when multiple directories match the same bare phase
|
||
// number — prevents cross-project file writes when unrelated projects
|
||
// share a .planning/phases/ tree.
|
||
// #2528: selection delegates to the canonical two-pass matcher (exact
|
||
// token match, then the bare-integer leading-digit-run fallback) shared
|
||
// with the locator and the phase-plan-index scan.
|
||
const { matches } = matchPhaseDirs(dirs, normalized);
|
||
if (matches.length === 0) continue;
|
||
if (matches.length > 1) {
|
||
output({
|
||
...notFound,
|
||
ambiguous_matches: matches,
|
||
warning: `Phase ${normalized} is ambiguous: ${matches.length} directories match (${matches.map(m => `"${m}"`).join(', ')}). Set a distinct project_code in .planning/config.json to scope resolution.`,
|
||
}, raw, '');
|
||
return;
|
||
}
|
||
const match = matches[0];
|
||
|
||
const dirMatch =
|
||
match.match(
|
||
new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')
|
||
) || match.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
|
||
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
||
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
||
|
||
const phaseDir = path.join(searchDir, match);
|
||
const phaseFiles = fs.readdirSync(phaseDir);
|
||
// #3183: canonical, live (superseded-excluded) plan/summary sets
|
||
// (root+nested) from the single owner, rather than a root-only
|
||
// isCanonicalPlanFile filter + hand-rolled summary filter.
|
||
//
|
||
// #2893 (regression fix): both `plans` and the naming-diagnostic
|
||
// "matched" set are further intersected with the STRICT
|
||
// `isCanonicalPlanFile` predicate — scanPhasePlans's own
|
||
// planFiles/allPlanFiles carry `isRootPlanFile`'s loose `/PLAN/i`
|
||
// fallback (deliberately permissive for live-plan COUNTING elsewhere),
|
||
// which silently recognized a non-canonically-named file (e.g.
|
||
// `01-PLAN-01-foundation.md`) as a valid plan here and defeated this
|
||
// command's #2893 naming-convention diagnostic (no warning, offender
|
||
// listed in `plans` as if valid).
|
||
const phaseScan = scanPhasePlans(phaseDir);
|
||
const plans = phaseScan.planFiles.filter(isCanonicalPlanFile).sort();
|
||
const summaries = phaseScan.summaryFiles.slice().sort();
|
||
// describeNonCanonicalPlans is a NAMING-CONVENTION diagnostic, unrelated
|
||
// to supersession — compare against allPlanFiles (every plan-shaped file
|
||
// the owner recognizes, canonical or not) rather than the live-only
|
||
// `plans`, so a superseded-but-canonically-named plan is not misreported
|
||
// as a naming violation.
|
||
const canonicalAllPlanFiles = phaseScan.allPlanFiles.filter(isCanonicalPlanFile);
|
||
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, canonicalAllPlanFiles);
|
||
|
||
const result: Record<string, unknown> = {
|
||
found: true,
|
||
directory: toPosixPath(
|
||
path.join(
|
||
path.relative(cwd, planBase),
|
||
path.relative(planBase, searchDir),
|
||
match,
|
||
),
|
||
),
|
||
phase_number: phaseNumber,
|
||
phase_name: phaseName,
|
||
plans,
|
||
summaries,
|
||
// #3218: scalar counts additive alongside `plans[]`/`summaries[]`,
|
||
// which stay unchanged for existing consumers. Naming mirrors
|
||
// `roadmap.analyze`'s `plan_count`/`summary_count` (live, i.e.
|
||
// status:superseded EXCLUDED — same set as `plans`/`summaries`
|
||
// above) so the two surfaces read alike. `plan_count_all` is the
|
||
// PHYSICAL count — every canonically-named plan file on disk,
|
||
// status:superseded INCLUDED, same set `planNamingWarning` above
|
||
// diffs against (`canonicalAllPlanFiles`). The `_all` suffix
|
||
// deliberately echoes `scanPhasePlans`'s own `allPlanFiles` field so
|
||
// a reader can trace the name back to its source rather than guess
|
||
// which of two similarly-named integers is the filtered one.
|
||
plan_count: plans.length,
|
||
summary_count: summaries.length,
|
||
plan_count_all: canonicalAllPlanFiles.length,
|
||
};
|
||
if (planNamingWarning) result['warning'] = planNamingWarning;
|
||
|
||
output(result, raw, result['directory']);
|
||
return;
|
||
} catch {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
output(notFound, raw, '');
|
||
}
|
||
|
||
interface RawPlan {
|
||
id: string;
|
||
declaredWave: number | null;
|
||
dependsOn: string[];
|
||
autonomous: boolean;
|
||
objective: string | null;
|
||
filesModified: string[];
|
||
filesDeleted: string[];
|
||
taskCount: number;
|
||
hasSummary: boolean;
|
||
/** #2830: true iff this plan's own SUMMARY declares `status: halted` (a designed stop). */
|
||
halted: boolean;
|
||
/** #1689: optional per-plan specialist executor hint (frontmatter `agent_hint:`). null when unset. */
|
||
agentHint: string | null;
|
||
}
|
||
|
||
/**
|
||
* Resolve a raw `depends_on` token to the `RawPlan.id` it refers to
|
||
* (case-folded exact match, falling back to canonical-id matching, falling
|
||
* back to the in-phase short-form plan number — #3897 rung 4). Returns
|
||
* `null` when the token does not resolve to any plan in this phase (a typo
|
||
* or a cross-phase reference) — every call site treats that as "ignore this
|
||
* edge", never a throw. Shared by `computeDependencyLevels`'s DAG-edge
|
||
* resolution and (#2830) the halt-propagation node resolution, so the two can
|
||
* never disagree about which token resolves to which plan. NOT used by the
|
||
* `depends_on` display mapping (#3785/N3) — that stays a passthrough by
|
||
* design; see the comment at its call site.
|
||
*
|
||
* `shortFormToId` (#3897 rung 4, ADR-3473 §8.9) is the third tier, consulted
|
||
* only when neither `planMap` nor `canonicalToId` resolves the token. It is
|
||
* optional so any caller that has not been threaded through yet (there are
|
||
* none left in this file) degrades to the pre-#3897 two-tier behavior rather
|
||
* than throwing on a missing argument.
|
||
*/
|
||
function resolveDependencyId(
|
||
dep: string,
|
||
planMap: Map<string, RawPlan>,
|
||
canonicalToId: Map<string, string>,
|
||
shortFormToId?: Map<string, string>,
|
||
): string | null {
|
||
const lower = dep.toLowerCase();
|
||
if (planMap.has(lower)) return (planMap.get(lower) as RawPlan).id;
|
||
if (canonicalToId.has(lower)) return canonicalToId.get(lower) as string;
|
||
return shortFormToId?.get(lower) ?? null;
|
||
}
|
||
|
||
// #3897 rung 4 (ADR-3473 §8.9) — builds the third depends_on resolution tier:
|
||
// a map from an in-phase BARE PLAN NUMBER (e.g. "01") to the plan id whose
|
||
// canonical id ends with that number. Recovered from the retired SDK lineage
|
||
// (sdk/src/query/phase.ts at 11918dcc3^) with ONE deliberate narrowing: the
|
||
// lost implementation indexed ANY trailing dash-segment of a canonical id,
|
||
// with no constraint that the segment be a plan NUMBER — so a phase
|
||
// containing both `09-FIX-auth-PLAN.md` and `09-GAP-auth-PLAN.md` (canonical
|
||
// id `09-FIX-auth`, trailing segment "auth") would silently bind
|
||
// `depends_on: ["auth"]` to whichever sorted first, fabricating a
|
||
// wave-affecting DAG edge with ZERO warning — a mis-resolved edge, which is
|
||
// worse than a dropped one (found in isolated correctness review, #3897).
|
||
// `docs/reference/plan-md.md` already documents this tier as resolving "the
|
||
// bare plan number", so requiring `/^\d+$/` on the trailing segment is a
|
||
// strict narrowing onto the tier's OWN documented contract, not a behavior
|
||
// change for any legitimate input. Do NOT restore the unconstrained
|
||
// lastDash-slice "to match the recovered original" — the original was wrong
|
||
// here; this rung deliberately departs from it in this one respect, and only
|
||
// this one. Everything else — the `lastDash` bound, first-write-wins,
|
||
// lowercasing — is kept exactly as recovered:
|
||
// - first write wins, deterministic because rawPlans is passed in sorted
|
||
// plan-file order (D4/T44) and this loop iterates in that same order;
|
||
// - a canonical id with no dash (`lastDash === -1` or `lastDash === 0`,
|
||
// e.g. "24" or "-01") or a trailing dash (`lastDash === canonical.length
|
||
// - 1`, e.g. "09-") is never indexed (D5).
|
||
// Exported so callers can build this map once and so tests assert against
|
||
// this REAL implementation rather than a hand-rolled copy that could
|
||
// silently disagree with it after a future change here (CLAUDE.md's
|
||
// generative-fix-divergence rule).
|
||
function buildShortFormToId(rawPlans: RawPlan[]): Map<string, string> {
|
||
const shortFormToId = new Map<string, string>();
|
||
for (const p of rawPlans) {
|
||
const canonical = extractCanonicalPlanId(p.id);
|
||
const lastDash = canonical.lastIndexOf('-');
|
||
if (lastDash > 0 && lastDash < canonical.length - 1) {
|
||
const shortForm = canonical.slice(lastDash + 1).toLowerCase();
|
||
if (/^\d+$/.test(shortForm) && !shortFormToId.has(shortForm)) {
|
||
shortFormToId.set(shortForm, p.id);
|
||
}
|
||
}
|
||
}
|
||
return shortFormToId;
|
||
}
|
||
|
||
// O(V + E). Assigns each in-phase plan its longest-path topological level over the
|
||
// in-phase dependsOn DAG (Kahn's algorithm). Returns { level: Map<id,number>, visited: number,
|
||
// order: string[] }. visited < rawPlans.length signals a dependency cycle. `order` (#2830) is
|
||
// the exact dequeue order this pass already produces — a valid topological order — passed to
|
||
// computeHaltPropagation as `precomputedOrder` so halt propagation does not re-run Kahn's
|
||
// algorithm a second time over the same graph.
|
||
//
|
||
// `shortFormToId` (#3897 rung 4, optional — see resolveDependencyId) is threaded through so a
|
||
// bare in-phase plan-number token (`depends_on: ["01"]`) resolves as a real DAG edge instead of
|
||
// being dropped and silently collapsing the dependent plan to wave 1 (D3).
|
||
function computeDependencyLevels(
|
||
rawPlans: RawPlan[],
|
||
planMap: Map<string, RawPlan>,
|
||
canonicalToId: Map<string, string>,
|
||
shortFormToId?: Map<string, string>,
|
||
): { level: Map<string, number>; visited: number; order: string[]; unresolved: Array<{ plan: string; token: string }> } {
|
||
const level = new Map<string, number>();
|
||
const inDeg = new Map<string, number>();
|
||
const adj = new Map<string, string[]>();
|
||
// #3427 / ADR-3473 §8.5: a depends_on token that resolves via NONE of the
|
||
// three tiers (planMap, canonicalToId, shortFormToId) is a dropped edge.
|
||
// Naming it here (rather than silently `continue`-ing past it) lets
|
||
// cmdPhasePlanIndex surface the token's own warning instead of
|
||
// manufacturing a wave-mismatch verdict from the resulting damaged graph
|
||
// (#3427).
|
||
const unresolved: Array<{ plan: string; token: string }> = [];
|
||
|
||
for (const p of rawPlans) {
|
||
if (!inDeg.has(p.id)) inDeg.set(p.id, 0);
|
||
if (!adj.has(p.id)) adj.set(p.id, []);
|
||
for (const dep of p.dependsOn) {
|
||
const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId, shortFormToId);
|
||
if (!resolvedDep) {
|
||
unresolved.push({ plan: p.id, token: String(dep) });
|
||
continue;
|
||
}
|
||
if (!adj.has(resolvedDep)) adj.set(resolvedDep, []);
|
||
(adj.get(resolvedDep) as string[]).push(p.id);
|
||
inDeg.set(p.id, (inDeg.get(p.id) ?? 0) + 1);
|
||
}
|
||
}
|
||
|
||
const queue: string[] = [];
|
||
for (const p of rawPlans) {
|
||
if ((inDeg.get(p.id) ?? 0) === 0) {
|
||
queue.push(p.id);
|
||
level.set(p.id, 0);
|
||
}
|
||
}
|
||
|
||
// Dequeue by head index (queue[head++]), NOT Array.shift(): shift() is O(n) per
|
||
// call in V8. Head-index dequeue is O(1) amortized -> O(V+E) overall. (#307)
|
||
let head = 0;
|
||
let visited = 0;
|
||
while (head < queue.length) {
|
||
const cur = queue[head++];
|
||
visited++;
|
||
const curLevel = level.get(cur) as number;
|
||
for (const dep of adj.get(cur) ?? []) {
|
||
const newLevel = curLevel + 1;
|
||
if (newLevel > (level.get(dep) ?? -1)) {
|
||
level.set(dep, newLevel);
|
||
}
|
||
inDeg.set(dep, (inDeg.get(dep) as number) - 1);
|
||
if (inDeg.get(dep) === 0) {
|
||
queue.push(dep);
|
||
}
|
||
}
|
||
}
|
||
|
||
return { level, visited, order: queue, unresolved };
|
||
}
|
||
|
||
function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
||
if (!phase) {
|
||
error('phase required for phase-plan-index');
|
||
}
|
||
|
||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||
const normalized = normalizePhaseName(phase);
|
||
|
||
let phaseDir: string | null = null;
|
||
let phaseDirName: string | null = null;
|
||
let ambiguousMatches: string[] | null = null;
|
||
try {
|
||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||
const dirs = entries
|
||
.filter((e) => e.isDirectory())
|
||
.map((e) => e.name)
|
||
.sort((a, b) => comparePhaseNum(a, b));
|
||
// #2528: selection delegates to the canonical two-pass matcher shared with
|
||
// the locator and the find-phase scan (this site previously first-matched
|
||
// with `.find()` and had no multi-match guard — the #2237 fail-loud rule
|
||
// now applies here too, so the three resolution paths cannot disagree).
|
||
const { matches } = matchPhaseDirs(dirs, normalized);
|
||
if (matches.length > 1) {
|
||
ambiguousMatches = matches;
|
||
} else if (matches.length === 1) {
|
||
phaseDir = path.join(phasesDir, matches[0]);
|
||
phaseDirName = matches[0];
|
||
}
|
||
} catch {
|
||
// phases dir doesn't exist
|
||
}
|
||
|
||
if (ambiguousMatches) {
|
||
output(
|
||
{
|
||
phase: normalized,
|
||
error: `Phase ${normalized} is ambiguous: ${ambiguousMatches.length} directories match (${ambiguousMatches.map((m) => `"${m}"`).join(', ')}).`,
|
||
ambiguous_matches: ambiguousMatches,
|
||
plans: [], waves: {}, incomplete: [], has_checkpoints: false,
|
||
},
|
||
raw,
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (!phaseDir) {
|
||
output(
|
||
{ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], runnable: [], has_checkpoints: false },
|
||
raw,
|
||
);
|
||
return;
|
||
}
|
||
void phaseDirName; // used only to set phaseDir above
|
||
|
||
// phaseFiles stays root-only readdirSync — it feeds only
|
||
// describeNonCanonicalPlans's near-miss naming diagnostic below, which is
|
||
// advisory text, not a counted/scheduled file set.
|
||
const phaseFiles = fs.readdirSync(phaseDir);
|
||
// #3183 (highest-severity site, ADR-3180 Decision 2): canonical LIVE
|
||
// plan/summary sets (root+nested, status: superseded EXCLUDED) from the
|
||
// single owner. This fixes two real bugs in the wave/dependency index this
|
||
// function builds: (1) a superseded plan used to still get scheduled into
|
||
// an execution wave, and (2) a phase using the #3139 nested `plans/`
|
||
// layout used to report ZERO plans (root-only readdirSync, no `plans/`
|
||
// join).
|
||
// #2893 (regression fix): intersected with the STRICT `isCanonicalPlanFile`
|
||
// predicate — scanPhasePlans's own planFiles/allPlanFiles carry
|
||
// `isRootPlanFile`'s loose `/PLAN/i` fallback (deliberately permissive for
|
||
// live-plan COUNTING elsewhere), which silently scheduled a
|
||
// non-canonically-named file (e.g. `01-PLAN-01-foundation.md`) into a wave
|
||
// here and defeated this command's #2893 naming-convention diagnostic (no
|
||
// warning). Restores the pre-#3183, tested behavior: only canonical
|
||
// root/nested filenames are ever counted or scheduled by this command.
|
||
const phaseScan = scanPhasePlans(phaseDir);
|
||
const planFiles = phaseScan.planFiles.filter(isCanonicalPlanFile).sort();
|
||
const summaryFiles = phaseScan.summaryFiles;
|
||
// describeNonCanonicalPlans is a NAMING-CONVENTION diagnostic, unrelated to
|
||
// supersession — compare against allPlanFiles (every plan-shaped file the
|
||
// owner recognizes, canonical or not) rather than the live-only planFiles,
|
||
// so a superseded-but-canonically-named plan is not misreported as a
|
||
// naming violation.
|
||
const planNamingWarning = describeNonCanonicalPlans(
|
||
phaseFiles,
|
||
phaseScan.allPlanFiles.filter(isCanonicalPlanFile),
|
||
);
|
||
|
||
// #3183: completion pairing via the canonical findUnsummarizedPlans
|
||
// (shares its `summaryCandidates` matching rule with countMatchedSummaries,
|
||
// and is layout-agnostic — it pairs a nested `plans/PLAN-01.md` with
|
||
// `plans/SUMMARY-01.md` correctly) instead of a bespoke ID-Set built from
|
||
// extractCanonicalPlanId, which only ever handled the root-canonical
|
||
// `-PLAN.md`/`-SUMMARY.md` naming form.
|
||
//
|
||
// #3345: the summary list is filtered through the SAME shared predicate
|
||
// scanPhasePlans filters its countable set with
|
||
// (plan-dependency-graph.cjs's isSummaryFileBlocked), so a SUMMARY declaring
|
||
// `status: blocked` reads as NO completion record here — has_summary false,
|
||
// the plan lands in `incomplete` — exactly matching the count side. Fail-open
|
||
// on a SUMMARY with no status key / unreadable file (filename fallback);
|
||
// `status: halted` stays summarized (#2830 designed stop). summaryFileByPlanId
|
||
// below still indexes EVERY summary on disk because the halted lookup is a
|
||
// file resolution for reading status, not a completion pairing.
|
||
const countableSummaryFiles = summaryFiles.filter(
|
||
(f) => !isSummaryFileBlocked(path.join(phaseDir, f)),
|
||
);
|
||
const unsummarizedPlanFiles = new Set(findUnsummarizedPlans(planFiles, countableSummaryFiles));
|
||
// #2830: reverse lookup from a completed plan's id (exact or canonical) to
|
||
// the actual summary filename, so a plan's own SUMMARY frontmatter can be
|
||
// read for its `status`. Shared builder (also used by phase-locator.cts's
|
||
// searchPhaseInDir) so the two can never disagree about which summary
|
||
// belongs to which plan. This is a FILE resolution for reading halted
|
||
// status, not a completion-count pairing rule, so it is unaffected by the
|
||
// #3183 pairing migration above.
|
||
const summaryFileByPlanId = buildSummaryFileIndex(summaryFiles, extractCanonicalPlanId);
|
||
|
||
// ── Pass 1: parse each plan file ─────────────────────────────────────────
|
||
|
||
const rawPlans: RawPlan[] = [];
|
||
|
||
for (const planFile of planFiles) {
|
||
const planId = planIdFromFile(planFile);
|
||
const planPath = path.join(phaseDir, planFile);
|
||
const content = fs.readFileSync(planPath, 'utf-8');
|
||
// #2790: plan-body parsing is owned by the shared Plan Document Module, so
|
||
// this command and the read-only `planning.inspect` query cannot drift on
|
||
// what a plan document says. planPath is still passed so a truncated
|
||
// PLAN.md names the file in the #1882 diagnostic.
|
||
const planDoc = parsePlanDocument(content, planPath);
|
||
|
||
const hasSummary = !unsummarizedPlanFiles.has(planFile);
|
||
|
||
// #2830: a plan can have a SUMMARY (hasSummary=true) and still be halted —
|
||
// a designed stop still writes a completion record, just one whose status
|
||
// says "halted" rather than "complete". Only look up the summary file
|
||
// when one exists; there is nothing to read otherwise.
|
||
const summaryFile =
|
||
summaryFileByPlanId.get(planId) ?? summaryFileByPlanId.get(extractCanonicalPlanId(planFile));
|
||
const halted = hasSummary && summaryFile !== undefined
|
||
? isSummaryFileHalted(path.join(phaseDir, summaryFile))
|
||
: false;
|
||
|
||
rawPlans.push({
|
||
id: planId,
|
||
declaredWave: planDoc.declaredWave,
|
||
dependsOn: planDoc.dependsOn,
|
||
autonomous: planDoc.autonomous,
|
||
objective: planDoc.objective,
|
||
filesModified: planDoc.filesModified,
|
||
filesDeleted: planDoc.filesDeleted,
|
||
agentHint: planDoc.agentHint,
|
||
taskCount: planDoc.taskCount,
|
||
hasSummary,
|
||
halted,
|
||
});
|
||
}
|
||
|
||
// ── Pass 2: topological level assignment via depends_on DAG ──────────────
|
||
|
||
const seenLower = new Map<string, string>();
|
||
for (const p of rawPlans) {
|
||
const lower = p.id.toLowerCase();
|
||
const existing = seenLower.get(lower);
|
||
if (existing !== undefined) {
|
||
error(
|
||
`depends_on index collision in phase ${normalized}: plan IDs '${existing}' and '${p.id}' are identical when case-folded. Rename one file to avoid ambiguous dependency resolution.`,
|
||
);
|
||
return;
|
||
}
|
||
seenLower.set(lower, p.id);
|
||
}
|
||
|
||
const planMap = new Map(rawPlans.map((p) => [p.id.toLowerCase(), p]));
|
||
const canonicalToId = new Map(
|
||
rawPlans.map((p) => [extractCanonicalPlanId(p.id).toLowerCase(), p.id]),
|
||
);
|
||
// #3897 rung 4 (ADR-3473 §8.9) — the third depends_on resolution tier.
|
||
// Resolves a bare in-phase plan-number short form (e.g. "01") to its owning
|
||
// plan id. In-phase only by construction (T49): the map is built from THIS
|
||
// phase's rawPlans alone, so a short form colliding with a different
|
||
// phase's plan can never be a candidate. See {@link buildShortFormToId}'s
|
||
// own comment for the numeric-only narrowing this rung applies on top of
|
||
// the recovered SDK-lineage algorithm.
|
||
const shortFormToId = buildShortFormToId(rawPlans);
|
||
|
||
const { level, visited, order, unresolved } = computeDependencyLevels(rawPlans, planMap, canonicalToId, shortFormToId);
|
||
|
||
if (visited < rawPlans.length) {
|
||
const cycleNodes = rawPlans.filter((p) => !level.has(p.id)).map((p) => p.id);
|
||
error(
|
||
`depends_on cycle detected in phase ${normalized} — cycle involves: ${cycleNodes.join(', ')}`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
// #2830: single shared halt-propagation pass, reusing the SAME id
|
||
// resolution (planMap/canonicalToId) AND the SAME topological order
|
||
// (`order`, computeDependencyLevels's own Kahn's-algorithm dequeue
|
||
// sequence) — passed as `precomputedOrder` so computeHaltPropagation does
|
||
// NOT run Kahn's algorithm a second time over this graph.
|
||
const haltNodes = rawPlans.map((p) => ({
|
||
id: p.id,
|
||
resolvedDependsOn: p.dependsOn
|
||
.map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId, shortFormToId))
|
||
.filter((id): id is string => id !== null),
|
||
halted: p.halted,
|
||
}));
|
||
const { blockedBy } = computeHaltPropagation(haltNodes, order);
|
||
|
||
// ── Pass 3: determine lowest bucket key and build output ─────────────────
|
||
|
||
const anyWaveZero = rawPlans.some((p) => p.declaredWave === 0);
|
||
const levelOffset = anyWaveZero ? 0 : 1;
|
||
|
||
const plans: Record<string, unknown>[] = [];
|
||
const waves: Record<string, string[]> = {};
|
||
const incomplete: string[] = [];
|
||
const runnable: string[] = [];
|
||
let hasCheckpoints = false;
|
||
const warnings: string[] = [];
|
||
|
||
// #3427 / ADR-3473 §8.5: name every dropped depends_on edge (plan AND
|
||
// token) rather than letting it silently collapse the plan to a DAG root.
|
||
// A plan with at least one unresolved token gets ITS OWN warning here and
|
||
// the wave-mismatch verdict below is suppressed for that plan ONLY — a
|
||
// plan with no dropped edges and a genuinely wrong `wave:` still warns
|
||
// (N3, D6, T25).
|
||
const plansWithUnresolvedTokens = new Set<string>();
|
||
for (const { plan, token } of unresolved) {
|
||
plansWithUnresolvedTokens.add(plan);
|
||
warnings.push(
|
||
`Plan ${plan}: depends_on token ${formatDiagnosticToken(token)} does not resolve to any plan in this phase — edge dropped, wave placement for this plan may be unreliable`,
|
||
);
|
||
}
|
||
|
||
for (const rawPlan of rawPlans) {
|
||
if (!rawPlan.autonomous) {
|
||
hasCheckpoints = true;
|
||
}
|
||
const blockedByIds = blockedBy.get(rawPlan.id) ?? [];
|
||
if (!rawPlan.hasSummary) {
|
||
incomplete.push(rawPlan.id);
|
||
// #2830: the runnable-only view — incomplete AND not transitively
|
||
// blocked by a halted upstream plan. Additive alongside `incomplete`,
|
||
// which keeps its existing "no SUMMARY yet" meaning unchanged.
|
||
if (blockedByIds.length === 0) {
|
||
runnable.push(rawPlan.id);
|
||
}
|
||
}
|
||
|
||
const computedWave = (level.get(rawPlan.id) ?? 0) + levelOffset;
|
||
const effectiveWave = computedWave;
|
||
// #3427 (D5/N3): suppress the wave-mismatch verdict for a plan that has
|
||
// at least one unresolved depends_on token — its own dropped-edge
|
||
// warning above already explains the degraded wave placement, so the
|
||
// mismatch here would blame the author for a DAG the tool itself
|
||
// couldn't build. A plan with NO unresolved tokens still gets a genuine
|
||
// mismatch reported (N3, T25) — the suppression is per-plan, never blanket.
|
||
if (
|
||
rawPlan.declaredWave !== null &&
|
||
rawPlan.declaredWave !== computedWave &&
|
||
!plansWithUnresolvedTokens.has(rawPlan.id)
|
||
) {
|
||
warnings.push(
|
||
`Plan ${rawPlan.id}: declared wave: ${rawPlan.declaredWave} but depends_on DAG places it in wave ${computedWave}`,
|
||
);
|
||
}
|
||
|
||
const plan: Record<string, unknown> = {
|
||
id: rawPlan.id,
|
||
wave: effectiveWave,
|
||
// DELIBERATELY not `resolveDependencyId`: the emitted field is a DISPLAY
|
||
// mapping, not the DAG resolution. It rewrites a dep only when it names a
|
||
// plan directly (planMap) and otherwise passes it through verbatim — a
|
||
// short canonical prefix like `24-01` stays `24-01` rather than becoming
|
||
// `24-01-auth-hardening`. #3785 pins that contract. Full resolution via
|
||
// canonicalToId is used for the wave DAG and #2830 halt propagation only;
|
||
// routing this line through it too silently changed the output shape.
|
||
depends_on: rawPlan.dependsOn.map((dep) => {
|
||
const lower = String(dep).toLowerCase();
|
||
return planMap.has(lower) ? (planMap.get(lower) as RawPlan).id : dep;
|
||
}),
|
||
autonomous: rawPlan.autonomous,
|
||
objective: rawPlan.objective,
|
||
files_modified: rawPlan.filesModified,
|
||
files_deleted: rawPlan.filesDeleted,
|
||
agent_hint: rawPlan.agentHint,
|
||
task_count: rawPlan.taskCount,
|
||
has_summary: rawPlan.hasSummary,
|
||
// #2830: additive fields — halted is this plan's OWN status; blocked_by
|
||
// names the halted plan(s) transitively upstream of it (empty when not
|
||
// blocked). Neither mutates has_summary/incomplete's existing meaning.
|
||
halted: rawPlan.halted,
|
||
blocked_by: blockedByIds,
|
||
};
|
||
|
||
plans.push(plan);
|
||
|
||
const waveKey = String(effectiveWave);
|
||
if (!waves[waveKey]) {
|
||
waves[waveKey] = [];
|
||
}
|
||
waves[waveKey].push(rawPlan.id);
|
||
}
|
||
|
||
const result: Record<string, unknown> = {
|
||
phase: normalized,
|
||
plans,
|
||
waves,
|
||
incomplete,
|
||
runnable,
|
||
has_checkpoints: hasCheckpoints,
|
||
};
|
||
if (planNamingWarning) result['warning'] = planNamingWarning;
|
||
if (warnings.length > 0) result['warnings'] = warnings;
|
||
|
||
output(result, raw);
|
||
}
|
||
|
||
// #2390 — phase.add title-shape heuristic. A description at or under this many
|
||
// characters, and with no sentence-ending punctuation followed by more text,
|
||
// reads as a short Title. Anything longer or multi-sentence reads as a Goal,
|
||
// not a Title. phase.add still writes the phase verbatim (it never mangles
|
||
// ROADMAP.md), but when the description looks goal-shaped the JSON result
|
||
// gains a `warning` key naming the gap, so the caller — or the orchestrating
|
||
// add-phase workflow — can split title vs. goal instead of the whole paragraph
|
||
// landing silently in the `### Phase N:` header.
|
||
const PHASE_ADD_TITLE_MAX_LEN = 80;
|
||
const PHASE_ADD_MULTI_SENTENCE_RE = /[.!?]['")\]]?\s+\S/;
|
||
|
||
function describeGoalShapedTitle(description: string): string | null {
|
||
const trimmed = description.trim();
|
||
const tooLong = trimmed.length > PHASE_ADD_TITLE_MAX_LEN;
|
||
const multiSentence = PHASE_ADD_MULTI_SENTENCE_RE.test(trimmed);
|
||
if (!tooLong && !multiSentence) return null;
|
||
const reasons = [
|
||
tooLong ? `${trimmed.length} chars (over the ${PHASE_ADD_TITLE_MAX_LEN}-char title threshold)` : null,
|
||
multiSentence ? 'multiple sentences' : null,
|
||
].filter(Boolean).join(', ');
|
||
return (
|
||
`description looks goal-shaped, not title-shaped (${reasons}). It was written verbatim ` +
|
||
`as the phase title; consider a short title with the detail moved to **Goal:**.`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* #3163: compute the byte offset in `rawContent` where a new `### Phase N:`
|
||
* entry should be inserted — at the end of the active phase list, scoped to the
|
||
* CURRENT MILESTONE so the entry can never land before a trailing `---` in
|
||
* shipped/history/backlog material (the file's last `---` on a long roadmap
|
||
* sits deep in archive). When no current milestone can be resolved (no
|
||
* STATE.md `milestone:` and no in-progress `🚧`/`🔄` marker), fall back to the
|
||
* legacy whole-file lastIndexOf('\n---') so simple no-milestone roadmaps keep
|
||
* their existing behavior.
|
||
*/
|
||
function phaseEntryInsertOffset(rawContent: string, cwd: string): number {
|
||
const ranges = currentMilestoneRawRanges(rawContent, cwd);
|
||
if (!ranges) {
|
||
const legacy = rawContent.lastIndexOf('\n---');
|
||
return legacy > 0 ? legacy : rawContent.length;
|
||
}
|
||
const window = rawContent.slice(ranges.primary.start, ranges.primary.end);
|
||
const lastSeparator = window.lastIndexOf('\n---');
|
||
return lastSeparator > 0 ? ranges.primary.start + lastSeparator : ranges.primary.end;
|
||
}
|
||
|
||
/**
|
||
* #3262 (write-time milestone-scope guard): the phase-creation and
|
||
* phase-insertion entry templates interpolate the caller's `description`
|
||
* verbatim into `### Phase N: ${description}`. A description embedding a
|
||
* level 1-3 heading that carries a milestone marker (version token,
|
||
* ✅/📋/🚧/🔄, or the word "Milestone") would splice a heading that TERMINATES
|
||
* the current milestone window (`computeMilestoneSectionEnd`) and silently
|
||
* drops every later phase out of the derived milestone phase set. Reject
|
||
* before any write or phase-directory creation — the fail-loud sibling of
|
||
* the edit-phase workflow's depends_on gate. The predicate itself
|
||
* (`findMilestoneScopeHeadingLines`) is fence-aware and Phase-heading-exempt,
|
||
* so ordinary descriptions and the phase's own numbered heading never trip it.
|
||
*
|
||
* #612: the predicate is convention-SELECTED, because the terminator
|
||
* vocabulary it mirrors is. On an opted-in bracket repo the ADR-canonical
|
||
* `## [GSD.09] Hidden` carries none of the markers listed above and yet
|
||
* terminates the window, so the blind call accepted the exact description the
|
||
* guard exists to reject — measured at this CLI seam, two `phase add` calls,
|
||
* the second phase silently outside the milestone phase set. Resolved through
|
||
* the same tolerant shape the read path uses (`planningDir` throws on a
|
||
* poisoned `GSD_PROJECT`/`GSD_WORKSTREAM` segment, and this guard runs BEFORE
|
||
* `loadConfig` and the ROADMAP existence check — an unresolvable convention
|
||
* must degrade to the pre-existing legacy vocabulary, never turn a rejection
|
||
* into a crash).
|
||
*/
|
||
function assertDescriptionPreservesMilestoneScope(cwd: string, description: string, command: string): void {
|
||
let convention: string | null = null;
|
||
try {
|
||
convention = resolvePhaseIdConvention(cwd);
|
||
} catch { /* unresolvable convention → treat as not-configured (base behaviour) */ }
|
||
const offending = findMilestoneScopeHeadingLines(description, convention);
|
||
if (offending.length === 0) return;
|
||
const markerList = convention === 'bracket'
|
||
? `(a vN.N version token, a ✅/📋/🚧/🔄 marker, the word "Milestone", or — under the bracket convention — a "[CODE.NN] Name" milestone heading)`
|
||
: `(a vN.N version token, a ✅/📋/🚧/🔄 marker, or the word "Milestone")`;
|
||
error(
|
||
`${command}: description contains a milestone-scoping heading line — writing it to ROADMAP.md would terminate ` +
|
||
`the current milestone window and silently drop later phases out of the milestone scope. ` +
|
||
`Offending line(s): ${offending.map((line) => JSON.stringify(line)).join(', ')}. ` +
|
||
`Rewrite the line so it is not a level 1-3 "#" heading carrying a milestone marker ` +
|
||
markerList + `.`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* #3849 — widen "used phase numbers" beyond this checkout. Every sibling git
|
||
* worktree carries its own `.planning/` on its own branch, so a phase minted
|
||
* there is invisible to the cwd-scoped sources (headers, bullets, on-disk
|
||
* dirs). Scan each sibling's phase-directory names (cheap — dir names alone
|
||
* caught the real incident) and its WHOLE ROADMAP.md headers (a row can exist
|
||
* before any directory does; milestone-scoping is wrong here because a number
|
||
* used under any milestone on another branch is still taken).
|
||
*
|
||
* #4225 — the horizon must track the ALLOCATION scope. When the allocation is
|
||
* workstream-scoped (`--ws`/`GSD_WORKSTREAM`, resolved into the env before
|
||
* dispatch), the sibling's copy of the SAME workstream is what carries that
|
||
* scope's independent numbering; the sibling's ROOT roadmap and phases/
|
||
* belong to a different numbering universe (docs/FEATURES.md §51 REQ-WS-01 —
|
||
* workstream state is isolated in `.planning/workstreams/{name}/`) and must
|
||
* not contribute. `planningDir(wt, ws)` reuses the canonical resolver, so the
|
||
* sibling scope matches the local scope's own resolution (env workstream plus
|
||
* env project segment) by construction; `ws === null` (no workstream active)
|
||
* keeps the #3849 root-scope horizon byte-for-byte.
|
||
*
|
||
* Widen, never refuse: a missing `.planning/`, an unreadable sibling, a
|
||
* non-git cwd, or an unavailable git binary each leave `used` untouched —
|
||
* allocation then behaves exactly as it did before this horizon existed.
|
||
* A sibling that simply lacks the active workstream's directory is the same
|
||
* fail-open case: it contributes nothing. Sentinels reuse the canonical
|
||
* `isSentinelPhaseId`; the dir pattern is the same one the on-disk scan uses,
|
||
* so decimal sub-phases (`411.1-foo`) are correctly not integers.
|
||
*/
|
||
function collectSiblingWorktreePhaseNums(cwd: string, used: Set<number>): void {
|
||
let porcelain: string;
|
||
try {
|
||
porcelain = execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
||
cwd,
|
||
encoding: 'utf-8',
|
||
// Same subprocess band as the other git call sites (smart-entry, check-command-router):
|
||
// inside the 5-30s git window, hidden console window on Windows, bounded buffer.
|
||
timeout: 10_000,
|
||
windowsHide: true,
|
||
maxBuffer: 4 * 1024 * 1024,
|
||
});
|
||
} catch {
|
||
return; // not a git repo / git unavailable — unchanged behavior
|
||
}
|
||
// #4225: the env workstream, read once with planningDir's own discriminator
|
||
// (`?? null` = deliberately no workstream — never re-derived per sibling).
|
||
// A poisoned value would already have thrown at the local `planningDir(cwd)`
|
||
// call every allocator makes before reaching this horizon; the per-sibling
|
||
// try/catch below still keeps any resolution failure fail-open.
|
||
const ws = process.env['GSD_WORKSTREAM'] ?? null;
|
||
const siblingPlanningDir = (wt: string): string => planningDir(wt, ws);
|
||
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
|
||
// Same header shape the allocators scan locally (#1729 tag tolerance).
|
||
const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
|
||
for (const line of porcelain.split('\n')) {
|
||
if (!line.startsWith('worktree ')) continue;
|
||
const wt = line.slice('worktree '.length).trim();
|
||
if (!wt || path.resolve(wt) === path.resolve(cwd)) continue;
|
||
try {
|
||
for (const entry of fs.readdirSync(path.join(siblingPlanningDir(wt), 'phases'))) {
|
||
const match = entry.match(dirNumPattern);
|
||
if (!match) continue;
|
||
const num = parseInt(match[1], 10);
|
||
if (!isSentinelPhaseId(num)) used.add(num);
|
||
}
|
||
} catch {
|
||
/* worktree has no .planning (or no copy of this scope) — normal, contributes nothing */
|
||
}
|
||
try {
|
||
const content = fs.readFileSync(path.join(siblingPlanningDir(wt), 'ROADMAP.md'), 'utf-8');
|
||
let m: RegExpExecArray | null;
|
||
headerPattern.lastIndex = 0;
|
||
while ((m = headerPattern.exec(content)) !== null) {
|
||
const num = parseInt(m[1], 10);
|
||
if (!isSentinelPhaseId(num)) used.add(num);
|
||
}
|
||
} catch {
|
||
/* no roadmap in that worktree (or scope) — normal, contributes nothing */
|
||
}
|
||
}
|
||
}
|
||
|
||
function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?: string): void {
|
||
if (!description) {
|
||
error('description required for phase add');
|
||
}
|
||
assertDescriptionPreservesMilestoneScope(cwd, description, 'phase add');
|
||
|
||
const config = loadConfig(cwd);
|
||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||
if (!fs.existsSync(roadmapPath)) {
|
||
error('ROADMAP.md not found');
|
||
}
|
||
|
||
const slug = generateSlugInternal(description) || '';
|
||
|
||
const { newPhaseId, dirName } = withPlanningLock(cwd, () => {
|
||
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||
const content = extractCurrentMilestone(rawContent, cwd);
|
||
|
||
const projectCode = (config.project_code as string) || '';
|
||
const prefix = projectCode ? `${projectCode}-` : '';
|
||
|
||
let _newPhaseId: number | string;
|
||
let _dirName: string;
|
||
|
||
if (customId || config.phase_naming === 'custom') {
|
||
_newPhaseId = customId || slug.toUpperCase();
|
||
if (!_newPhaseId) error('--id required when phase_naming is "custom"');
|
||
_dirName = `${prefix}${_newPhaseId}-${slug}`;
|
||
} else {
|
||
// Collect all phase numbers visible in the current-milestone content.
|
||
// Three sources are scanned so that a phase in ANY representation
|
||
// (section header, roadmap bullet, or on-disk directory) is counted:
|
||
|
||
// 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N:
|
||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||
const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
|
||
// 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants)
|
||
// The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk,
|
||
// or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11")
|
||
// are counted and cannot collide with a freshly-added phase. (#1229)
|
||
const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
|
||
|
||
const usedPhaseNums = new Set<number>();
|
||
let m: RegExpExecArray | null;
|
||
|
||
while ((m = headerPattern.exec(content)) !== null) {
|
||
const num = parseInt(m[1], 10);
|
||
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
||
if (!isSentinelPhaseId(num)) usedPhaseNums.add(num);
|
||
}
|
||
while ((m = bulletPattern.exec(content)) !== null) {
|
||
const num = parseInt(m[1], 10);
|
||
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
||
if (!isSentinelPhaseId(num)) usedPhaseNums.add(num);
|
||
}
|
||
|
||
// 3) On-disk phase directories (e.g. phases/11-foo/ with no header yet)
|
||
const phasesOnDisk = path.join(planningDir(cwd), 'phases');
|
||
if (fs.existsSync(phasesOnDisk)) {
|
||
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
|
||
for (const entry of fs.readdirSync(phasesOnDisk)) {
|
||
const match = entry.match(dirNumPattern);
|
||
if (!match) continue;
|
||
const num = parseInt(match[1], 10);
|
||
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
||
if (!isSentinelPhaseId(num)) usedPhaseNums.add(num);
|
||
}
|
||
}
|
||
|
||
// phase.add appends after the highest *used* number. Collecting numbers from
|
||
// section headers, roadmap bullets, AND on-disk dirs above is what prevents the
|
||
// #1229 collision (a bullet-only Phase N is now counted), so max+1 cannot reuse
|
||
// an existing number.
|
||
// 4) Sibling git worktrees (#3849) — same max+1, wider horizon: a number
|
||
// taken on another branch is still taken.
|
||
collectSiblingWorktreePhaseNums(cwd, usedPhaseNums);
|
||
const maxUsed = usedPhaseNums.size > 0 ? Math.max(...usedPhaseNums) : 0;
|
||
_newPhaseId = maxUsed + 1;
|
||
const paddedNum = String(_newPhaseId).padStart(2, '0');
|
||
_dirName = `${prefix}${paddedNum}-${slug}`;
|
||
}
|
||
|
||
const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
|
||
|
||
platformEnsureDir(dirPath);
|
||
platformWriteSync(path.join(dirPath, '.gitkeep'), '');
|
||
|
||
const dependsOn =
|
||
config.phase_naming === 'custom'
|
||
? ''
|
||
: `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
|
||
const phaseEntry =
|
||
`\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${formatGsdSlash('plan-phase', resolveRuntime(cwd)) as string} ${_newPhaseId} to break down)\n`;
|
||
|
||
const insertAt = phaseEntryInsertOffset(rawContent, cwd);
|
||
const updatedContent = rawContent.slice(0, insertAt) + phaseEntry + rawContent.slice(insertAt);
|
||
|
||
platformWriteSync(roadmapPath, updatedContent);
|
||
return { newPhaseId: _newPhaseId, dirName: _dirName };
|
||
});
|
||
|
||
const titleWarning = describeGoalShapedTitle(description);
|
||
|
||
const result: Record<string, unknown> = {
|
||
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
|
||
padded:
|
||
typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
|
||
name: description,
|
||
slug,
|
||
directory: toPosixPath(
|
||
path.join(path.relative(cwd, planningDir(cwd)), 'phases', dirName),
|
||
),
|
||
naming_mode: config.phase_naming,
|
||
};
|
||
if (titleWarning) result['warning'] = titleWarning;
|
||
|
||
output(result, raw, result['padded']);
|
||
// #3227 (design doc §40 row 26 / "Not-corruption" rule): every
|
||
// `publishStateContract` call site in this file is audited so a refreshed
|
||
// state.json `updated_at` always means something on disk actually moved —
|
||
// a stale-but-refreshed timestamp is worse than no refresh, because it
|
||
// reads as fresh to a downstream watcher. This site is unconditional
|
||
// because every reachable path either exits via `error()` (process.exit,
|
||
// never reaches here) or falls through to the unconditional
|
||
// `platformEnsureDir`/`platformWriteSync` pair above that always creates
|
||
// the phase directory and rewrites ROADMAP.md — there is no code path that
|
||
// reaches this line without having just written to disk. Best-effort —
|
||
// cannot throw, cannot change this command's exit code or output.
|
||
publishStateContract(cwd);
|
||
}
|
||
|
||
function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): void {
|
||
if (!Array.isArray(descriptions) || descriptions.length === 0) {
|
||
error('descriptions array required for phase add-batch');
|
||
}
|
||
// #3262: validate every description BEFORE the lock — the batch is
|
||
// all-or-nothing, so one offending description must reject the whole batch
|
||
// with no ROADMAP write and no phase directories created.
|
||
for (const description of descriptions) {
|
||
assertDescriptionPreservesMilestoneScope(cwd, description, 'phase add-batch');
|
||
}
|
||
const config = loadConfig(cwd);
|
||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||
if (!fs.existsSync(roadmapPath)) {
|
||
error('ROADMAP.md not found');
|
||
}
|
||
const projectCode = (config.project_code as string) || '';
|
||
const prefix = projectCode ? `${projectCode}-` : '';
|
||
|
||
const results = withPlanningLock(cwd, () => {
|
||
let rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||
const content = extractCurrentMilestone(rawContent, cwd);
|
||
let maxPhase = 0;
|
||
if (config.phase_naming !== 'custom') {
|
||
// Same three cwd-scoped sources as cmdPhaseAdd (#1229): headers, roadmap
|
||
// bullets, on-disk dirs. The bullet scan was missing here — a bullet-only
|
||
// `Phase N` row was invisible to batch allocation (#3849 secondary).
|
||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
|
||
const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
|
||
let m: RegExpExecArray | null;
|
||
while ((m = phasePattern.exec(content)) !== null) {
|
||
const num = parseInt(m[1], 10);
|
||
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
||
if (isSentinelPhaseId(num)) continue;
|
||
if (num > maxPhase) maxPhase = num;
|
||
}
|
||
while ((m = bulletPattern.exec(content)) !== null) {
|
||
const num = parseInt(m[1], 10);
|
||
if (isSentinelPhaseId(num)) continue;
|
||
if (num > maxPhase) maxPhase = num;
|
||
}
|
||
const phasesOnDisk = path.join(planningDir(cwd), 'phases');
|
||
if (fs.existsSync(phasesOnDisk)) {
|
||
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
|
||
for (const entry of fs.readdirSync(phasesOnDisk)) {
|
||
const match = entry.match(dirNumPattern);
|
||
if (!match) continue;
|
||
const num = parseInt(match[1], 10);
|
||
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
||
if (isSentinelPhaseId(num)) continue;
|
||
if (num > maxPhase) maxPhase = num;
|
||
}
|
||
}
|
||
// 4) Sibling git worktrees (#3849) — same max+1, wider horizon.
|
||
const siblingNums = new Set<number>();
|
||
collectSiblingWorktreePhaseNums(cwd, siblingNums);
|
||
for (const num of siblingNums) {
|
||
if (num > maxPhase) maxPhase = num;
|
||
}
|
||
}
|
||
const added: Record<string, unknown>[] = [];
|
||
for (const description of descriptions) {
|
||
const slug = generateSlugInternal(description) || '';
|
||
let newPhaseId: number | string;
|
||
let dirName: string;
|
||
if (config.phase_naming === 'custom') {
|
||
newPhaseId = slug.toUpperCase();
|
||
dirName = `${prefix}${newPhaseId}-${slug}`;
|
||
} else {
|
||
maxPhase += 1;
|
||
newPhaseId = maxPhase;
|
||
dirName = `${prefix}${String(newPhaseId).padStart(2, '0')}-${slug}`;
|
||
}
|
||
const dirPath = path.join(planningDir(cwd), 'phases', dirName);
|
||
platformEnsureDir(dirPath);
|
||
platformWriteSync(path.join(dirPath, '.gitkeep'), '');
|
||
const dependsOn =
|
||
config.phase_naming === 'custom'
|
||
? ''
|
||
: `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
|
||
const phaseEntry =
|
||
`\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${formatGsdSlash('plan-phase', resolveRuntime(cwd)) as string} ${newPhaseId} to break down)\n`;
|
||
const insertAt = phaseEntryInsertOffset(rawContent, cwd);
|
||
rawContent = rawContent.slice(0, insertAt) + phaseEntry + rawContent.slice(insertAt);
|
||
added.push({
|
||
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
|
||
padded:
|
||
typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
|
||
name: description,
|
||
slug,
|
||
directory: toPosixPath(
|
||
path.join(path.relative(cwd, planningDir(cwd)), 'phases', dirName),
|
||
),
|
||
naming_mode: config.phase_naming,
|
||
});
|
||
}
|
||
platformWriteSync(roadmapPath, rawContent);
|
||
return added;
|
||
});
|
||
output({ phases: results, count: results.length }, raw);
|
||
// #3227: unconditional here because `platformWriteSync(roadmapPath, rawContent)`
|
||
// above always rewrites ROADMAP.md for every description in the batch before
|
||
// this line is reached; the only refusal path is the `error('ROADMAP.md not
|
||
// found')` above, which terminates the process and never reaches here.
|
||
publishStateContract(cwd);
|
||
}
|
||
|
||
// #4569: scans all three representations of an existing decimal sub-phase
|
||
// under `base` — on-disk `phases/` directories, `### Phase BASE.N:` headings,
|
||
// and `- [ ] Phase BASE.N:` roadmap SUMMARY CHECKLIST bullets. A bullet-only
|
||
// roadmap with no heading yet and no on-disk directory yet must still be
|
||
// seen, or an allocator can silently reallocate an already-used decimal
|
||
// number. Shared by `cmdPhaseInsert`'s normalized-base scan and its
|
||
// sibling-allocation parent-base scan so the two never drift apart.
|
||
function scanExistingDecimalPhaseNumbers(phasesDir: string, rawContent: string, base: string): Set<number> {
|
||
const decimalSet = new Set<number>();
|
||
|
||
// #2245 audit: existsSync-guarded, mirroring cmdPhaseNextDecimal's identical
|
||
// scan above — a missing phasesDir (no decimal sub-phases yet) is the
|
||
// expected, silent case (empty decimalSet). A readdirSync failure once the
|
||
// dir is confirmed to EXIST is a genuine anomaly; swallowing it used to let
|
||
// `phase insert` proceed with an incomplete decimalSet and risk writing a
|
||
// decimal phase number that collides with an existing on-disk directory
|
||
// the scan simply never saw — surfaced loud instead, like the sibling.
|
||
//
|
||
// #4634 (lint-phase-enumeration-drift): routed through the canonical
|
||
// PHYSICAL-set owner (`listAllPhaseDirs`, phase-locator.cts) instead of a
|
||
// hand-rolled `readdirSync`. This scan — like its sibling `cmdPhaseNextDecimal`
|
||
// and its caller `cmdPhaseInsert` (both exempted in the drift guard for the
|
||
// same reason) — must see EVERY on-disk decimal sub-phase directory
|
||
// regardless of the current milestone window, so `listMilestonePhaseDirs`
|
||
// (windowed) is the wrong owner here; `includeSentinels: true` preserves this
|
||
// function's pre-existing behavior of never sentinel-filtering (the decimal
|
||
// regex below only ever matches `base.N`-shaped names, so sentinel inclusion
|
||
// is a no-op either way).
|
||
if (fs.existsSync(phasesDir)) {
|
||
const { value: dirs, scope } = listAllPhaseDirs(phasesDir, { includeSentinels: true });
|
||
if (scope === SCOPE.UNREADABLE) {
|
||
// The dir EXISTS but could not be read (EACCES/EIO) — a genuine anomaly,
|
||
// not the expected empty-decimalSet case above. Surfaced loud, matching
|
||
// this function's pre-migration `readdirSync` catch: swallowing it would
|
||
// let `phase insert` proceed with an incomplete decimalSet and collide
|
||
// with an existing on-disk decimal directory the scan never saw.
|
||
error(`Failed to scan phase directories for existing decimal phases: unable to read ${phasesDir}`);
|
||
}
|
||
const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(base)}\\.(\\d+)`);
|
||
for (const dir of dirs) {
|
||
const dm = dir.match(decimalPattern);
|
||
if (dm) decimalSet.add(parseInt(dm[1], 10));
|
||
}
|
||
}
|
||
|
||
const rmPhasePattern = new RegExp(
|
||
`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(base)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`,
|
||
'gi',
|
||
);
|
||
let rmMatch: RegExpExecArray | null;
|
||
while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
|
||
decimalSet.add(parseInt(rmMatch[1], 10));
|
||
}
|
||
|
||
const checklistDecimalPattern = new RegExp(
|
||
`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${phaseMarkdownRegexSource(base)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`,
|
||
'gi',
|
||
);
|
||
let clMatch: RegExpExecArray | null;
|
||
while ((clMatch = checklistDecimalPattern.exec(rawContent)) !== null) {
|
||
decimalSet.add(parseInt(clMatch[1], 10));
|
||
}
|
||
|
||
return decimalSet;
|
||
}
|
||
|
||
function cmdPhaseInsert(
|
||
cwd: string,
|
||
afterPhase: string,
|
||
description: string,
|
||
raw: boolean,
|
||
allocation: 'nested' | 'sibling' = 'nested',
|
||
): void {
|
||
if (!afterPhase || !description) {
|
||
error('after-phase and description required for phase insert');
|
||
}
|
||
assertDescriptionPreservesMilestoneScope(cwd, description, 'phase insert');
|
||
|
||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||
if (!fs.existsSync(roadmapPath)) {
|
||
error('ROADMAP.md not found');
|
||
}
|
||
|
||
const slug = generateSlugInternal(description) || '';
|
||
|
||
const { decimalPhase, dirName } = withPlanningLock(cwd, () => {
|
||
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||
const content = extractCurrentMilestone(rawContent, cwd);
|
||
|
||
const normalizedAfter = normalizePhaseName(afterPhase);
|
||
const afterPhaseEscaped = phaseMarkdownRegexSource(normalizedAfter);
|
||
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i');
|
||
const headingMatch = targetPattern.test(content);
|
||
|
||
const bulletPattern = new RegExp(
|
||
`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`,
|
||
'i',
|
||
);
|
||
const anyHeadingPattern = /#{2,4}\s*Phase\s+\d/i;
|
||
const roadmapHasHeadingPhases = anyHeadingPattern.test(content);
|
||
const isBulletStyle = !headingMatch && bulletPattern.test(content) && !roadmapHasHeadingPhases;
|
||
|
||
if (!headingMatch && !isBulletStyle) {
|
||
const checklistPattern = new RegExp(
|
||
`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`,
|
||
'i',
|
||
);
|
||
if (checklistPattern.test(content)) {
|
||
error(
|
||
`Phase ${afterPhase} exists in roadmap summary but is missing a detail section (### Phase ${afterPhase}: ...).`,
|
||
);
|
||
}
|
||
error(`Phase ${afterPhase} not found in ROADMAP.md`);
|
||
}
|
||
|
||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||
const normalizedBase = normalizePhaseName(afterPhase);
|
||
const decimalSet = scanExistingDecimalPhaseNumbers(phasesDir, rawContent, normalizedBase);
|
||
|
||
const nextDecimal = decimalSet.size === 0 ? 1 : Math.max(...decimalSet) + 1;
|
||
let _decimalPhase = `${normalizedBase}.${nextDecimal}`;
|
||
|
||
// #4569: sibling allocation joins afterPhase's PARENT level instead of nesting
|
||
// one level deeper under afterPhase itself. A top-level phase (no existing
|
||
// decimal segment) has no sibling level to join; nested is the only sensible
|
||
// allocation, so we silently fall back for that case.
|
||
const lastDotIndex = normalizedBase.lastIndexOf('.');
|
||
if (allocation === 'sibling' && lastDotIndex !== -1) {
|
||
const parentBase = normalizedBase.slice(0, lastDotIndex);
|
||
const siblingDecimalSet = scanExistingDecimalPhaseNumbers(phasesDir, rawContent, parentBase);
|
||
const siblingNextDecimal = siblingDecimalSet.size === 0 ? 1 : Math.max(...siblingDecimalSet) + 1;
|
||
_decimalPhase = `${parentBase}.${siblingNextDecimal}`;
|
||
}
|
||
const insertConfig = loadConfig(cwd);
|
||
const projectCode = (insertConfig.project_code as string) || '';
|
||
const pfx = projectCode ? `${projectCode}-` : '';
|
||
const _dirName = `${pfx}${_decimalPhase}-${slug}`;
|
||
const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
|
||
|
||
platformEnsureDir(dirPath);
|
||
platformWriteSync(path.join(dirPath, '.gitkeep'), '');
|
||
|
||
let updatedContent: string;
|
||
|
||
if (isBulletStyle) {
|
||
const boldBulletPattern = new RegExp(
|
||
`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`,
|
||
'i',
|
||
);
|
||
const useBold = boldBulletPattern.test(content);
|
||
const phaseLabel = useBold
|
||
? `**Phase ${_decimalPhase}: ${description}**`
|
||
: `Phase ${_decimalPhase}: ${description}`;
|
||
// #3413 review fix: bulletEntry stays hardcoded '\n'. The on-disk EOL
|
||
// is decided at write time by platformWriteSync's normalizeContent /
|
||
// _normalizeMd (shell-command-projection.cts), which unconditionally
|
||
// converts \r\n -> \n for any .md target — so whatever terminator is
|
||
// used here in memory is erased before the file is ever written, and
|
||
// templating it via detectEol(rawContent) was inert dead code. '\n'
|
||
// matches what platformWriteSync enforces anyway.
|
||
const bulletEntry = `\n- [ ] ${phaseLabel}`;
|
||
|
||
// #3413: was `[^\n]*`, which on CRLF content swallows the line's
|
||
// trailing \r into the match, shifting bulletLineEnd to land BETWEEN
|
||
// the \r and \n of the original CRLF pair — a pure splice-POSITION
|
||
// bug on the not-yet-write-normalized CRLF read (independent of the
|
||
// final on-disk EOL, which platformWriteSync always forces to LF for
|
||
// .md targets regardless). Widening to [^\r\n]* stops the match at the
|
||
// true line-content boundary so bulletLineEnd lands cleanly before the
|
||
// terminator.
|
||
const targetBulletPattern = new RegExp(
|
||
`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*)`,
|
||
'i',
|
||
);
|
||
const bulletMatchResult = rawContent.match(targetBulletPattern);
|
||
if (!bulletMatchResult) {
|
||
error(`Could not find Phase ${afterPhase} bullet line`);
|
||
}
|
||
|
||
const bulletLineEnd =
|
||
rawContent.indexOf(bulletMatchResult![0]) + bulletMatchResult![0].length;
|
||
const afterBullet = rawContent.slice(bulletLineEnd);
|
||
const nextBulletMatch = afterBullet.match(/\r?\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
|
||
|
||
let insertIdx: number;
|
||
if (nextBulletMatch) {
|
||
insertIdx = bulletLineEnd + (nextBulletMatch.index as number);
|
||
} else {
|
||
insertIdx = bulletLineEnd;
|
||
}
|
||
|
||
updatedContent =
|
||
rawContent.slice(0, insertIdx) + bulletEntry + rawContent.slice(insertIdx);
|
||
} else {
|
||
const phaseEntry =
|
||
`\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${formatGsdSlash('plan-phase', resolveRuntime(cwd)) as string} ${_decimalPhase} to break down)\n`;
|
||
|
||
const headerPattern = new RegExp(
|
||
`(#{2,4}\\s*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:[^\\n]*\\n)`,
|
||
'i',
|
||
);
|
||
const headerMatch = rawContent.match(headerPattern);
|
||
if (!headerMatch) {
|
||
error(`Could not find Phase ${afterPhase} header`);
|
||
}
|
||
|
||
const headerIdx = rawContent.indexOf(headerMatch![0]);
|
||
const afterHeader = rawContent.slice(headerIdx + headerMatch![0].length);
|
||
const nextPhaseMatch = afterHeader.match(/\r?\n#{2,4}\s+Phase\s+\d[\d.]*/i);
|
||
|
||
let insertIdx: number;
|
||
if (nextPhaseMatch) {
|
||
insertIdx = headerIdx + headerMatch![0].length + (nextPhaseMatch.index as number);
|
||
} else {
|
||
insertIdx = rawContent.length;
|
||
}
|
||
|
||
updatedContent =
|
||
rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
|
||
}
|
||
|
||
platformWriteSync(roadmapPath, updatedContent);
|
||
return { decimalPhase: _decimalPhase, dirName: _dirName };
|
||
});
|
||
|
||
const result = {
|
||
phase_number: decimalPhase,
|
||
after_phase: afterPhase,
|
||
name: description,
|
||
slug,
|
||
directory: toPosixPath(
|
||
path.join(path.relative(cwd, planningDir(cwd)), 'phases', dirName),
|
||
),
|
||
};
|
||
|
||
output(result, raw, decimalPhase);
|
||
// #3227: unconditional here because `platformWriteSync(roadmapPath, updatedContent)`
|
||
// above always rewrites ROADMAP.md with the inserted phase before this line is
|
||
// reached; every refusal along the way (bad args, missing ROADMAP.md, unresolved
|
||
// target bullet/header) exits via `error()`, which terminates the process.
|
||
publishStateContract(cwd);
|
||
}
|
||
|
||
interface RenameDirInfo {
|
||
dir: string;
|
||
prefix: string;
|
||
oldDecimal: number;
|
||
slug: string;
|
||
}
|
||
|
||
interface RenameIntInfo {
|
||
dir: string;
|
||
oldInt: number;
|
||
letter: string;
|
||
decimal: number | null;
|
||
slug: string;
|
||
}
|
||
|
||
function renameDecimalPhases(
|
||
phasesDir: string,
|
||
baseInt: number,
|
||
removedDecimal: number,
|
||
): { renamedDirs: { from: string; to: string }[]; renamedFiles: { from: string; to: string }[] } {
|
||
const renamedDirs: { from: string; to: string }[] = [];
|
||
const renamedFiles: { from: string; to: string }[] = [];
|
||
const decPattern = new RegExp(`^(0*${baseInt})\\.(\\d+)-(.+)$`);
|
||
const dirs = readSubdirectories(phasesDir, true);
|
||
const toRename: RenameDirInfo[] = dirs
|
||
.map((dir) => {
|
||
const m = dir.match(decPattern);
|
||
return m
|
||
? { dir, prefix: m[1], oldDecimal: parseInt(m[2], 10), slug: m[3] }
|
||
: null;
|
||
})
|
||
.filter((item): item is RenameDirInfo => item !== null && item.oldDecimal > removedDecimal)
|
||
.sort((a, b) => b.oldDecimal - a.oldDecimal);
|
||
|
||
for (const item of toRename) {
|
||
const newDecimal = item.oldDecimal - 1;
|
||
const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
|
||
const newPhaseId = `${baseInt}.${newDecimal}`;
|
||
const newDirName = `${item.prefix}.${newDecimal}-${item.slug}`;
|
||
retryRenameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
|
||
renamedDirs.push({ from: item.dir, to: newDirName });
|
||
for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
|
||
if (f.includes(oldPhaseId)) {
|
||
const newFileName = f.replace(oldPhaseId, newPhaseId);
|
||
retryRenameSync(
|
||
path.join(phasesDir, newDirName, f),
|
||
path.join(phasesDir, newDirName, newFileName),
|
||
);
|
||
renamedFiles.push({ from: f, to: newFileName });
|
||
}
|
||
}
|
||
}
|
||
return { renamedDirs, renamedFiles };
|
||
}
|
||
|
||
/**
|
||
* Find a free name to move an occupying file aside to, on collision, so the
|
||
* intended rename can proceed without destroying either file. Appends the
|
||
* literal `.orphaned` suffix to the whole existing filename (never `.md`,
|
||
* so no phase-directory scan predicate — all of which filter on
|
||
* `.endsWith('.md')` / `.endsWith('-VERIFICATION.md')` etc — can ever pick
|
||
* the displaced file back up as any phase's artifact). Falls back to a
|
||
* numeric discriminator (`.orphaned.2`, `.orphaned.3`, ...) if `.orphaned`
|
||
* itself is taken, bounded at 100 attempts so a pathological directory
|
||
* cannot loop forever; returns null if no free name is found within that
|
||
* bound, letting the caller fall back to skip-and-report.
|
||
*/
|
||
function findOrphanedDisplacementName(dir: string, fileName: string): string | null {
|
||
const base = `${fileName}.orphaned`;
|
||
if (!fs.existsSync(path.join(dir, base))) return base;
|
||
for (let n = 2; n <= 100; n++) {
|
||
const candidate = `${base}.${n}`;
|
||
if (!fs.existsSync(path.join(dir, candidate))) return candidate;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function renameIntegerPhases(
|
||
phasesDir: string,
|
||
removedInt: number,
|
||
): {
|
||
renamedDirs: { from: string; to: string }[];
|
||
renamedFiles: { from: string; to: string }[];
|
||
renamedFileCollisions: { from: string; to: string; displaced_to: string | null }[];
|
||
} {
|
||
const renamedDirs: { from: string; to: string }[] = [];
|
||
const renamedFiles: { from: string; to: string }[] = [];
|
||
const renamedFileCollisions: { from: string; to: string; displaced_to: string | null }[] = [];
|
||
const dirs = readSubdirectories(phasesDir, true);
|
||
const toRename: RenameIntInfo[] = dirs
|
||
.map((dir) => {
|
||
const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
|
||
if (!m) return null;
|
||
const dirInt = parseInt(m[1], 10);
|
||
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
||
return dirInt > removedInt && !isSentinelPhaseId(dirInt)
|
||
? {
|
||
dir,
|
||
oldInt: dirInt,
|
||
letter: m[2] ? m[2].toUpperCase() : '',
|
||
decimal: m[3] ? parseInt(m[3], 10) : null,
|
||
slug: m[4],
|
||
}
|
||
: null;
|
||
})
|
||
.filter((item): item is RenameIntInfo => item !== null)
|
||
.sort((a, b) =>
|
||
a.oldInt !== b.oldInt ? b.oldInt - a.oldInt : (b.decimal || 0) - (a.decimal || 0),
|
||
);
|
||
|
||
for (const item of toRename) {
|
||
const newInt = item.oldInt - 1;
|
||
const newPadded = String(newInt).padStart(2, '0');
|
||
const oldPadded = String(item.oldInt).padStart(2, '0');
|
||
const letterSuffix = item.letter || '';
|
||
const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : '';
|
||
const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
|
||
const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
|
||
const newDirName = `${newPrefix}-${item.slug}`;
|
||
// WARNING-3 (#3511 review): the directory match above accepts an
|
||
// UNPADDED leading number (`\d+`), so a supported rename can pair a
|
||
// 2-padded dir with an unpadded-numbered artifact — dir `9-slug` holding
|
||
// `9-VERIFICATION.md`. Renaming files by `f.startsWith(oldPrefix)` alone
|
||
// (oldPrefix always 2-padded) misses that file: it becomes desynced from
|
||
// its now-renamed directory and the phase reads `missing`. Try the
|
||
// UNPADDED old-prefix form as a fallback so such an artifact renames
|
||
// alongside its directory. A trailing-digit boundary check keeps the
|
||
// unpadded form from over-matching a DIFFERENT phase's file (unpadded
|
||
// prefix "1" must not match "10-…").
|
||
const oldPrefixUnpadded = `${item.oldInt}${letterSuffix}${decimalSuffix}`;
|
||
retryRenameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
|
||
renamedDirs.push({ from: item.dir, to: newDirName });
|
||
for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
|
||
let matchedPrefix: string | null = null;
|
||
if (f.startsWith(oldPrefix)) {
|
||
matchedPrefix = oldPrefix;
|
||
} else if (
|
||
oldPrefixUnpadded !== oldPrefix &&
|
||
f.startsWith(oldPrefixUnpadded) &&
|
||
// Token-boundary check: the character immediately after the unpadded
|
||
// prefix must be a separator (`-`, `.`) or end-of-name, not any
|
||
// non-digit. A bare `!/^\d/` test (prior form) let a LETTER through
|
||
// too, so unpadded prefix "2" wrongly matched "2FA-notes.md" (a
|
||
// wholly unrelated file whose name merely starts with the digit).
|
||
(f.length === oldPrefixUnpadded.length || /^[-.]/.test(f.slice(oldPrefixUnpadded.length)))
|
||
) {
|
||
matchedPrefix = oldPrefixUnpadded;
|
||
}
|
||
if (matchedPrefix) {
|
||
const newFileName = newPrefix + f.slice(matchedPrefix.length);
|
||
const destPath = path.join(phasesDir, newDirName, newFileName);
|
||
// Collision guard: the padded and unpadded prefix forms can both
|
||
// resolve to the SAME destination (e.g. `09-VERIFICATION.md` and
|
||
// `9-VERIFICATION.md` in one directory both target
|
||
// `08-VERIFICATION.md`), and a stray cross-phase file can already sit
|
||
// at the destination name (e.g. a leftover `08-VERIFICATION.md`
|
||
// belonging to a DIFFERENT phase, inside phase 9's directory).
|
||
// Renaming blindly over an existing target silently destroys
|
||
// whichever file loses; skipping the rename instead lets the stray
|
||
// outrank the phase's own renamed artifact once it lands at the
|
||
// canonical name. Neither is acceptable: move the OCCUPYING file
|
||
// aside first (never overwrite, never skip the real rename), then
|
||
// complete the intended rename so the phase's own artifact takes the
|
||
// canonical name. This also handles a target that was already
|
||
// claimed by an EARLIER file in this same pass, since that earlier
|
||
// rename already created it on disk.
|
||
if (fs.existsSync(destPath)) {
|
||
const displacedName = findOrphanedDisplacementName(
|
||
path.join(phasesDir, newDirName),
|
||
newFileName,
|
||
);
|
||
if (displacedName === null) {
|
||
// No free displacement name within the bounded search — fall
|
||
// back to skip-and-report rather than looping or overwriting.
|
||
renamedFileCollisions.push({ from: f, to: newFileName, displaced_to: null });
|
||
continue;
|
||
}
|
||
retryRenameSync(destPath, path.join(phasesDir, newDirName, displacedName));
|
||
retryRenameSync(path.join(phasesDir, newDirName, f), destPath);
|
||
renamedFiles.push({ from: f, to: newFileName });
|
||
renamedFileCollisions.push({ from: f, to: newFileName, displaced_to: displacedName });
|
||
continue;
|
||
}
|
||
retryRenameSync(path.join(phasesDir, newDirName, f), destPath);
|
||
renamedFiles.push({ from: f, to: newFileName });
|
||
}
|
||
}
|
||
}
|
||
return { renamedDirs, renamedFiles, renamedFileCollisions };
|
||
}
|
||
|
||
function decrementRoadmapPhaseNumber(raw: string, removedInt: number): string {
|
||
const num = parseInt(raw, 10);
|
||
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
||
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num)) return raw;
|
||
return String(num - 1);
|
||
}
|
||
|
||
function decrementRoadmapPhaseToken(raw: string, removedInt: number): string {
|
||
const match = String(raw).match(/^(\d+)(\.\d+)?$/);
|
||
if (!match) return raw;
|
||
const num = parseInt(match[1], 10);
|
||
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
||
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num)) return raw;
|
||
return `${num - 1}${match[2] || ''}`;
|
||
}
|
||
|
||
function decrementRoadmapPaddedPhaseNumber(raw: string, removedInt: number): string {
|
||
const num = parseInt(raw, 10);
|
||
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
||
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num)) return raw;
|
||
return String(num - 1).padStart(raw.length, '0');
|
||
}
|
||
|
||
/**
|
||
* Return the RAW text of the `dataRowIndex`-th data row line (0-based, in
|
||
* file order — header and delimiter rows excluded) of the FIRST GFM table
|
||
* found in `sectionText`, or `null` when the table or that row doesn't exist.
|
||
*
|
||
* F8 (#2245 review, nit) support helper: addresses a table row by its
|
||
* STRUCTURAL position rather than by matching its (possibly non-unique)
|
||
* trimmed cell content — see the Progress-ordinal renumber's padding-recovery
|
||
* use below for why content-matching is unsafe here (two rows with identical
|
||
* trimmed Phase text, or a row whose already-rewritten new value coincides
|
||
* with another row's pre-edit text, would otherwise resolve to the wrong line).
|
||
*/
|
||
function findDataRowLine(sectionText: string, dataRowIndex: number): string | null {
|
||
const lines = sectionText.split(/\r?\n/);
|
||
let headerIdx = -1;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const trimmed = lines[i].trim();
|
||
if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) {
|
||
headerIdx = i;
|
||
break;
|
||
}
|
||
}
|
||
if (headerIdx === -1) return null;
|
||
|
||
let seen = -1;
|
||
for (let i = headerIdx + 2; i < lines.length; i++) {
|
||
if (!lines[i].trim().startsWith('|')) break;
|
||
seen += 1;
|
||
if (seen === dataRowIndex) return lines[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// #3685: mirror requirementsUpdated's diff-tracking contract — the caller
|
||
// (cmdPhaseRemove) used to report `roadmap_updated: true` unconditionally,
|
||
// hardcoded regardless of whether this transform actually changed
|
||
// ROADMAP.md's content. Returning a real before/after comparison here lets
|
||
// the caller report accurately, the same fix #3685 applied to
|
||
// `cmdPhaseComplete` and #2640/#2974 already applied to this same function's
|
||
// sibling `stateUpdated` flag a few lines below in `cmdPhaseRemove`.
|
||
function updateRoadmapAfterPhaseRemoval(
|
||
roadmapPath: string,
|
||
targetPhase: string,
|
||
isDecimal: boolean,
|
||
removedInt: number,
|
||
cwd: string,
|
||
): boolean {
|
||
return withPlanningLock(cwd, () => {
|
||
const originalContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||
let content = originalContent;
|
||
const escaped = escapeRegex(targetPhase);
|
||
// #3572: ROADMAP headings and rows carry the normalized (zero-padded) form
|
||
// of a decimal id — `phase insert 1` writes `### Phase 01.1:` while the
|
||
// user's remove query is usually unpadded (`1.1`) — and integer headings
|
||
// legitimately appear both padded (`02`) and unpadded (`2`). A `0*` prefix
|
||
// makes the token padding-insensitive in both directions without widening
|
||
// to other ids: the token stays anchored between `Phase\s+`/line-start and
|
||
// `:`/whitespace/end, so `0*2` still never matches `Phase 12:`.
|
||
const padTolerant = `0*${escaped}`;
|
||
|
||
// SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
|
||
// detail section INCLUDING its own heading line. Migrated onto deleteSection
|
||
// (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
|
||
// tokenizeHeadings + this predicate, then splices out the range from that
|
||
// heading's own start through the next heading of the SAME-OR-HIGHER level —
|
||
// whatever that heading's text is. This fixes a data-loss bug in the prior
|
||
// hand-rolled regex, whose lookahead only recognised ANOTHER "Phase N:"
|
||
// heading as a stop boundary: removing the LAST phase in a roadmap left no
|
||
// such heading to stop at, so the lazy `[\s\S]*?` scan ran to EOF and swept
|
||
// away everything after it — including a trailing `## Progress` heading and
|
||
// its tracking table.
|
||
const phaseHeadingRe = new RegExp(
|
||
`^Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`,
|
||
'i',
|
||
);
|
||
content = deleteSection(
|
||
content,
|
||
(h) => h.level >= 2 && h.level <= 4 && phaseHeadingRe.test(h.text),
|
||
);
|
||
content = content.replace(
|
||
new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'),
|
||
'',
|
||
);
|
||
// ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
|
||
// for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
|
||
// of updateTableCell). Scoped to the `## Progress` section — mirroring
|
||
// deriveProgressFromRoadmap's read-side scoping (phase-lifecycle.cts) —
|
||
// so a same-numbered row in an earlier, unrelated table (e.g. a
|
||
// `| Phase | Requirements | Count |` table preceding `## Progress`,
|
||
// #2012) is never touched. Matches the row by its FIRST cell only: for an
|
||
// integer removal, a zero-pad-insensitive leading-integer comparison
|
||
// (`01.`, `1.`, `1 `, bare `1` all match phase 1; a decimal sub-phase
|
||
// cell like `2.5` never matches an integer removal); for a decimal
|
||
// removal, the exact decimal token. This replaces the prior regex's
|
||
// `\.?\s` requirement, which silently left a COMPACT unpadded row (e.g.
|
||
// `|2|0/2|Planned|-|`) undeleted — its closing `|` follows the digit with
|
||
// no whitespace to match (#2245 audit) — and which was also unscoped to
|
||
// any particular table.
|
||
const progressHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
|
||
if (progressHeadingMatch && progressHeadingMatch.index !== undefined) {
|
||
const headingOffset = progressHeadingMatch.index;
|
||
const before = content.slice(0, headingOffset);
|
||
const fromHeading = content.slice(headingOffset);
|
||
const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
|
||
const progressSection =
|
||
nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
|
||
const rest = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
|
||
|
||
const matchRemovedProgressRow = (row: Record<string, string>): boolean => {
|
||
const firstCellRaw = (Object.values(row)[0] ?? '').trim();
|
||
if (isDecimal) {
|
||
return new RegExp(`^${padTolerant}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
|
||
}
|
||
const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
|
||
if (!leadingMatch || leadingMatch[2]) return false;
|
||
return parseInt(leadingMatch[1], 10) === removedInt;
|
||
};
|
||
|
||
const deleteResult = deleteTableRow(progressSection, matchRemovedProgressRow);
|
||
if (deleteResult.ok) {
|
||
content = before + deleteResult.value + rest;
|
||
}
|
||
}
|
||
|
||
if (!isDecimal) {
|
||
// #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
|
||
// is re-emitted verbatim — a tagged later phase still gets renumbered.
|
||
content = content.replace(
|
||
/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\r\n]{0,200}\))?\s*:)/gi,
|
||
(_match, prefix: string, num: string, suffix: string) =>
|
||
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`,
|
||
);
|
||
content = content.replace(
|
||
/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi,
|
||
(_match, prefix: string, num: string, suffix: string) =>
|
||
`${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`,
|
||
);
|
||
// ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
|
||
// updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
|
||
// directly above). The prior whole-document regex
|
||
// `/(\|\s*)(\d+)(\.\s)/g` rewrote ANY `| N. ` cell anywhere in the
|
||
// file — including a same-shaped cell in an UNRELATED, earlier table
|
||
// (e.g. a `| Phase | Requirements | Count |` table, or a decoy table,
|
||
// preceding `## Progress`; #2245-class scoping defect, same family as
|
||
// the row-delete fix above). Scoped here to the `## Progress` section
|
||
// only, mirroring that same section-slice-then-splice-back pattern.
|
||
//
|
||
// Loops because updateTableCell only rewrites the FIRST matching row
|
||
// per call. `processedOrdinalRows` tracks by row INDEX (stable across
|
||
// iterations — this only edits cell content, it never inserts/deletes
|
||
// rows) so an already-decremented row's new value — which may still
|
||
// numerically exceed `removedInt` — is never re-selected and
|
||
// decremented a second time (matching on the row's CURRENT value alone,
|
||
// without this guard, would keep re-firing on each pass).
|
||
//
|
||
// `phaseCellShapeRe` is the exact digit+dot-space shape the old regex
|
||
// required: a decimal sub-phase ordinal like `2.5` (no whitespace
|
||
// between the dot and the next character) never matches it, so it is
|
||
// left untouched — identical decimal-safety to the prior behaviour.
|
||
//
|
||
// updateTableCell hands the callback the TRIMMED, UNESCAPED cell value
|
||
// only, so the row's original leading/trailing alignment padding is
|
||
// recovered by a narrow, anchored lookup within that row's OWN raw
|
||
// line — addressed by ROW INDEX (`matchedRowIndex`, via
|
||
// `findDataRowLine`), not by searching the whole section for content
|
||
// matching the trimmed value (F8 #2245 review: two rows with identical
|
||
// trimmed Phase text, or a row whose already-rewritten new value
|
||
// coincides with another row's pre-edit text, would otherwise resolve
|
||
// to the WRONG row's padding — the first/leftmost content match found).
|
||
// The lookup searches for `escapeCell(current)` (F3 #2245 review: the
|
||
// ESCAPED form, e.g. `Foo \| Bar`) — the raw line always carries the
|
||
// escaped form, so searching for the unescaped `current` would
|
||
// silently fail to find an escaped-pipe cell's own line — preserving
|
||
// every other byte of the row (ADR-2143 §7 byte-parity) while only the
|
||
// digits actually change.
|
||
const ordinalHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
|
||
if (ordinalHeadingMatch && ordinalHeadingMatch.index !== undefined) {
|
||
const ordinalHeadingOffset = ordinalHeadingMatch.index;
|
||
const ordinalBefore = content.slice(0, ordinalHeadingOffset);
|
||
const ordinalFromHeading = content.slice(ordinalHeadingOffset);
|
||
const ordinalNextHeadingOffset = ordinalFromHeading.search(/\n#{1,2}[ \t]/);
|
||
let ordinalSection =
|
||
ordinalNextHeadingOffset >= 0
|
||
? ordinalFromHeading.slice(0, ordinalNextHeadingOffset)
|
||
: ordinalFromHeading;
|
||
const ordinalRest =
|
||
ordinalNextHeadingOffset >= 0 ? ordinalFromHeading.slice(ordinalNextHeadingOffset) : '';
|
||
|
||
const phaseCellShapeRe = /^(\d+)(\.\s)/;
|
||
const processedOrdinalRows = new Set<number>();
|
||
let matchedRowIndex: number | null = null;
|
||
|
||
for (;;) {
|
||
matchedRowIndex = null;
|
||
const cellResult = updateTableCell(
|
||
ordinalSection,
|
||
(row, index) => {
|
||
if (processedOrdinalRows.has(index)) return false;
|
||
const m = phaseCellShapeRe.exec(row['Phase'] ?? '');
|
||
if (!m) return false;
|
||
const num = parseInt(m[1], 10);
|
||
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
||
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num)) return false;
|
||
processedOrdinalRows.add(index);
|
||
matchedRowIndex = index;
|
||
return true;
|
||
},
|
||
'Phase',
|
||
(current) => {
|
||
const m = phaseCellShapeRe.exec(current);
|
||
if (!m) return current;
|
||
const decremented = decrementRoadmapPhaseNumber(m[1], removedInt);
|
||
const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
|
||
const targetLine =
|
||
matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
|
||
const padMatch = targetLine
|
||
? new RegExp(`^[ \\t]*\\|(\\s*)${escapeRegex(escapeCell(current))}(\\s*)\\|`).exec(targetLine)
|
||
: null;
|
||
const leadPad = padMatch ? padMatch[1] : ' ';
|
||
const trailPad = padMatch ? padMatch[2] : ' ';
|
||
return `${leadPad}${escapeCell(newContent)}${trailPad}`;
|
||
},
|
||
);
|
||
if (!cellResult.ok) break;
|
||
ordinalSection = cellResult.value;
|
||
}
|
||
|
||
content = ordinalBefore + ordinalSection + ordinalRest;
|
||
}
|
||
content = content.replace(
|
||
/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g,
|
||
(_match, phaseNum: string, planNum: string) =>
|
||
`${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`,
|
||
);
|
||
content = content.replace(
|
||
/(\*\*Depends on\*\*\s*:\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi,
|
||
(_match, prefix: string, num: string) =>
|
||
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`,
|
||
);
|
||
content = content.replace(
|
||
/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi,
|
||
(_match, prefix: string, num: string) =>
|
||
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`,
|
||
);
|
||
}
|
||
|
||
platformWriteSync(roadmapPath, content);
|
||
// #3685 / #3691: compare NORMALIZED bytes (what platformWriteSync actually
|
||
// persists), not the raw pre-normalize `content` string, against the raw
|
||
// pre-mutation `originalContent` read above — a raw `!==` here reports a
|
||
// false `true` whenever this transform's regenerated output takes a
|
||
// different-but-equivalent shape than the already-normalized on-disk
|
||
// original (same normalization-order artifact #3685 fixed at
|
||
// cmdMilestoneComplete; see contentChangedAfterNormalize's own doc).
|
||
return contentChangedAfterNormalize(roadmapPath, originalContent, content);
|
||
});
|
||
}
|
||
|
||
interface PhaseRemoveOptions {
|
||
force?: boolean;
|
||
}
|
||
|
||
/**
|
||
* #3572: insert `fieldLine` at the start of STATE.md's BODY — immediately after
|
||
* the leading frontmatter block's closing `---` fence — so a body field never
|
||
* lands before the opening fence. The former whole-content prepend
|
||
* (`field + content`) put the line ABOVE the opening `---`, and
|
||
* syncStateFrontmatter then treated the scrambled fence structure as TWO
|
||
* frontmatter blocks, rebuilding a derived one on top of the original
|
||
* (milestone_name from a ROADMAP heading, total_phases counting the removed
|
||
* phase, a stray 'Total Phases: 0' between fences). A file with no leading
|
||
* frontmatter is all body: the field goes to content start, preserving the
|
||
* former behavior for that shape.
|
||
*/
|
||
function insertStateBodyFieldAtTop(content: string, fieldLine: string): string {
|
||
// Split AND join on bare '\n' so CRLF line endings stay attached to their
|
||
// own lines — each '\r' remains the tail of the line it terminated, where
|
||
// the trimmed fence compare still matches it. (#3572 review: splitting on
|
||
// '\n' but re-joining on a detected '\r\n' doubled every carriage return.)
|
||
const lines = content.split('\n');
|
||
if ((lines[0] ?? '').trim() === '---') {
|
||
const closeIdx = lines.findIndex((l: string, i: number) => i > 0 && l.trim() === '---');
|
||
if (closeIdx !== -1) {
|
||
lines.splice(closeIdx + 1, 0, '', fieldLine);
|
||
return lines.join('\n');
|
||
}
|
||
}
|
||
return fieldLine + '\n' + content;
|
||
}
|
||
|
||
function cmdPhaseRemove(
|
||
cwd: string,
|
||
targetPhase: string,
|
||
options: PhaseRemoveOptions,
|
||
raw: boolean,
|
||
): void {
|
||
if (!targetPhase) error('phase number required for phase remove');
|
||
|
||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||
|
||
if (!fs.existsSync(roadmapPath)) error('ROADMAP.md not found');
|
||
|
||
const normalized = normalizePhaseName(targetPhase);
|
||
const isDecimal = targetPhase.includes('.');
|
||
const force = options.force || false;
|
||
|
||
const subdirs = readSubdirectories(phasesDir, true);
|
||
// #2237/#2528: every other resolution path refuses to choose between multiple
|
||
// directories claiming one phase number. This one is the DESTRUCTIVE path, so
|
||
// taking `matches[0]` silently is strictly worse than anywhere else: it turns
|
||
// "resolve nothing" into "delete one of two candidates, unrecoverably, and
|
||
// renumber every phase after it". Refuse before any file is touched.
|
||
const { matches: phaseDirMatches } = matchPhaseDirs(subdirs, normalized);
|
||
if (phaseDirMatches.length > 1) {
|
||
output(
|
||
{
|
||
removed: null,
|
||
error:
|
||
`Phase ${normalized} is ambiguous: ${phaseDirMatches.length} directories match `
|
||
+ `(${phaseDirMatches.map((m) => `"${m}"`).join(', ')}). Refusing to remove any of them. `
|
||
+ 'Set a distinct project_code in .planning/config.json, or pass the full directory name.',
|
||
ambiguous_matches: phaseDirMatches,
|
||
directory_deleted: null,
|
||
renamed_directories: [],
|
||
renamed_files: [],
|
||
roadmap_updated: false,
|
||
state_updated: false,
|
||
},
|
||
raw,
|
||
);
|
||
return;
|
||
}
|
||
const targetDir = phaseDirMatches[0] || null;
|
||
|
||
if (targetDir && !force) {
|
||
// #3183: canonical summary set (root+nested) from the single owner —
|
||
// a root-only readdirSync filter left nested (#3139 layout) summaries
|
||
// invisible, letting a phase with completed nested work be deleted
|
||
// without --force.
|
||
const summaryCount = scanPhasePlans(path.join(phasesDir, targetDir)).summaryFiles.length;
|
||
if (summaryCount > 0) {
|
||
error(
|
||
`Phase ${targetPhase} has ${summaryCount} executed plan(s). Use --force to remove anyway.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (targetDir) fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true });
|
||
|
||
let renamedDirs: { from: string; to: string }[] = [];
|
||
let renamedFiles: { from: string; to: string }[] = [];
|
||
let renamedFileCollisions: { from: string; to: string; displaced_to: string | null }[] = [];
|
||
try {
|
||
if (isDecimal) {
|
||
const renamed = renameDecimalPhases(
|
||
phasesDir,
|
||
parseInt(normalized.split('.')[0], 10),
|
||
parseInt(normalized.split('.')[1], 10),
|
||
);
|
||
renamedDirs = renamed.renamedDirs;
|
||
renamedFiles = renamed.renamedFiles;
|
||
} else {
|
||
const renamed = renameIntegerPhases(phasesDir, parseInt(normalized, 10));
|
||
renamedDirs = renamed.renamedDirs;
|
||
renamedFiles = renamed.renamedFiles;
|
||
renamedFileCollisions = renamed.renamedFileCollisions;
|
||
}
|
||
} catch (e) {
|
||
// #2245 audit (was ERROR-HIDING): renameDecimalPhases/renameIntegerPhases
|
||
// rename subsequent phase directories ON DISK one at a time — a mid-loop
|
||
// failure leaves SOME directories already renumbered and others not, with
|
||
// no way to recover which (the callee's own renamedDirs/renamedFiles never
|
||
// reach this scope when it throws). Silently swallowing this and falling
|
||
// through to updateRoadmapAfterPhaseRemoval below used to rewrite
|
||
// ROADMAP.md's phase numbers assuming the ENTIRE renumbering succeeded,
|
||
// permanently desyncing ROADMAP.md from the actual (partially-renamed)
|
||
// on-disk directory names. Surface loud instead of compounding it.
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
error(`Failed to renumber phase directories after removing phase ${targetPhase}: ${msg}`);
|
||
}
|
||
|
||
const roadmapUpdated = updateRoadmapAfterPhaseRemoval(
|
||
roadmapPath,
|
||
targetPhase,
|
||
isDecimal,
|
||
parseInt(normalized, 10),
|
||
cwd,
|
||
);
|
||
|
||
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
||
let stateUpdated = false;
|
||
if (fs.existsSync(statePath)) {
|
||
// #2640: report whether STATE.md content actually changed, not just file
|
||
// existence (fs.existsSync was trivially true). Also ensure the body
|
||
// transform produces a diff so readModifyWriteStateMd's no-op guard
|
||
// (#948) doesn't skip the frontmatter resync — without that, the
|
||
// progress.* frontmatter block stays stale when the body has no
|
||
// 'Total Phases:' or 'of N' phrase.
|
||
stateUpdated = readModifyWriteStateMd(
|
||
statePath,
|
||
(stateContent: string) => {
|
||
let modified = stateContent;
|
||
const totalRaw = stateExtractField(modified, 'Total Phases');
|
||
if (totalRaw) {
|
||
// #3572 review: clamp at 0 — a stale 'Total Phases: 0' (e.g. written by
|
||
// an earlier remove whose dir-count was 0) must not decrement to -1 on
|
||
// the next removal.
|
||
modified =
|
||
stateReplaceField(
|
||
modified,
|
||
'Total Phases',
|
||
String(Math.max(0, parseInt(totalRaw, 10) - 1)),
|
||
) || modified;
|
||
}
|
||
const ofMatch = modified.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
|
||
if (ofMatch) {
|
||
modified = modified.replace(
|
||
/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i,
|
||
`$1${Math.max(0, parseInt(ofMatch[2], 10) - 1)}$3`,
|
||
);
|
||
}
|
||
// #2640: if neither body field was found, the transform is a no-op.
|
||
// readModifyWriteStateMd's no-op guard (#948) would then skip the
|
||
// frontmatter resync, leaving progress.* stale. Force a body diff
|
||
// ONLY when a phase directory was actually removed (targetDir !== null)
|
||
// so the guard passes and syncStateFrontmatter rebuilds the frontmatter
|
||
// from the post-deletion disk/ROADMAP state. Without the targetDir gate,
|
||
// a no-op removal (ROADMAP-only phase, no directory) would inject a
|
||
// spurious 'Total Phases:' line into a body that intentionally lacked one.
|
||
if (targetDir && modified === stateContent) {
|
||
// subdirs was read before the deletion; excluding the removed target
|
||
// gives the remaining count. Renumbering changes names but not count.
|
||
//
|
||
// #2528: exclude the directory that was ACTUALLY deleted, by identity,
|
||
// rather than re-deriving "which dir was the target" from the query.
|
||
// The two are not the same predicate here: `targetDir` comes from
|
||
// `matchPhaseDirs`, whose bare-integer fallback resolves digit-leading
|
||
// dirs (`05-80-20-cleanup` for query `5`) that `phaseTokenMatches`
|
||
// reports as non-matching — so a token re-derivation would count the
|
||
// just-deleted directory as still present and write a `Total Phases`
|
||
// one too high. Identity is also what the comment above already
|
||
// claims this filter does, and the block is gated on targetDir.
|
||
// (#3572 note: this body field counts DIRECTORIES on disk; the
|
||
// frontmatter progress.* block is rebuilt by syncStateFrontmatter
|
||
// from the post-removal ROADMAP — the two counts legitimately differ
|
||
// when phases exist in ROADMAP without directories.)
|
||
const remainingPhases = Math.max(0, subdirs.filter((d) => d !== targetDir).length);
|
||
if (totalRaw) {
|
||
modified =
|
||
stateReplaceField(modified, 'Total Phases', String(remainingPhases)) || modified;
|
||
} else {
|
||
// No 'Total Phases:' field in the body — insert one at the start of
|
||
// the BODY so the no-op guard sees a diff. #3572: the former
|
||
// whole-content prepend landed the line BEFORE the opening '---'
|
||
// fence and corrupted STATE.md into two frontmatter blocks.
|
||
// syncStateFrontmatter will still rebuild the frontmatter
|
||
// progress.* block from the real disk/ROADMAP count.
|
||
modified = insertStateBodyFieldAtTop(modified, `Total Phases: ${remainingPhases}`);
|
||
}
|
||
}
|
||
return modified;
|
||
},
|
||
cwd,
|
||
);
|
||
}
|
||
|
||
output(
|
||
{
|
||
removed: targetPhase,
|
||
directory_deleted: targetDir,
|
||
renamed_directories: renamedDirs,
|
||
renamed_files: renamedFiles,
|
||
renamed_file_collisions: renamedFileCollisions,
|
||
// #3685: mirror requirementsUpdated's diff-tracking contract — true only
|
||
// when updateRoadmapAfterPhaseRemoval's content diff detected a real
|
||
// change, not hardcoded regardless of whether ROADMAP.md's content
|
||
// actually changed.
|
||
roadmap_updated: roadmapUpdated,
|
||
state_updated: stateUpdated,
|
||
},
|
||
raw,
|
||
);
|
||
// #3227: unconditional here because `updateRoadmapAfterPhaseRemoval` above
|
||
// always rewrites ROADMAP.md before this line is reached; every refusal path
|
||
// (bad target, missing ROADMAP.md, --force-required, renumber failure) exits
|
||
// via `error()`, and the ambiguous-match case exits via an earlier `return`
|
||
// before any file is touched.
|
||
publishStateContract(cwd);
|
||
}
|
||
|
||
interface WriteSpec {
|
||
filePath: string;
|
||
before: string;
|
||
after: string;
|
||
}
|
||
|
||
/**
|
||
* #3227: returns the count of writes actually applied (entries whose
|
||
* `before` differed from `after` and were therefore written to disk) — the
|
||
* caller (`cmdPhaseComplete`) uses this as its publish-gate signal, since a
|
||
* re-run against an already-completed phase can produce a `writes[]` array
|
||
* where every entry is byte-identical to what's already on disk.
|
||
*/
|
||
function writePlanningFileSet(writes: WriteSpec[]): number {
|
||
const applied: WriteSpec[] = [];
|
||
try {
|
||
for (const write of writes) {
|
||
if (write.before === write.after) continue;
|
||
platformWriteSync(write.filePath, write.after);
|
||
applied.push(write);
|
||
}
|
||
} catch (err) {
|
||
for (const write of applied.reverse()) {
|
||
try {
|
||
platformWriteSync(write.filePath, write.before);
|
||
} catch (rollbackErr) {
|
||
const errObj = err as Error & { rollbackError?: unknown };
|
||
errObj.rollbackError = rollbackErr;
|
||
const rollbackMsg =
|
||
rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
||
errObj.message +=
|
||
`\nWARNING: rollback failed while restoring ${write.filePath} ` +
|
||
`(${rollbackMsg}). Planning files under .planning/ may be left in an ` +
|
||
`inconsistent, partially rolled back state. Inspect ROADMAP.md / REQUIREMENTS.md / ` +
|
||
`STATE.md before re-running phase complete.`;
|
||
break;
|
||
}
|
||
}
|
||
throw err;
|
||
}
|
||
return applied.length;
|
||
}
|
||
|
||
function phaseDisplayNameFromRoadmap(roadmapContent: string | null, phaseNum: string | null): string | null {
|
||
if (!roadmapContent || !phaseNum) return null;
|
||
const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
|
||
const heading = roadmapContent.match(new RegExp(`^#{2,4}\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:\\s*([^\\n]+)`, 'im'));
|
||
if (!heading) return null;
|
||
const name = heading[1].replace(/\(INSERTED\)/i, '').trim();
|
||
return name || null;
|
||
}
|
||
|
||
function phaseDisplayNameFromSlug(slug: string | null): string | null {
|
||
if (!slug) return null;
|
||
const name = slug.replace(/-/g, ' ').trim();
|
||
return name || null;
|
||
}
|
||
|
||
// ─── #3697: the `**Requirements**:` line under-selection detector ────────────
|
||
//
|
||
// EXTRACTED from cmdPhaseComplete (round 3, review finding Blocker 1). The
|
||
// detection logic below is a parser, so `RULESET.TESTS.property-based-testing`
|
||
// requires at least one fast-check property test over it — and that is not
|
||
// reachable while the logic is a closure inside a command that only a
|
||
// subprocess can invoke (every #3697 test spawns the CLI; 100 fc runs cannot).
|
||
// Extraction is therefore load-bearing, not tidying: it is what makes the
|
||
// property test and the 2048-boundary fixtures (Blocker 2) expressible at all.
|
||
//
|
||
// BEHAVIOUR IS UNCHANGED BY THE MOVE. The two tokenizations below stay
|
||
// deliberately DIFFERENT and are co-located so they cannot drift apart:
|
||
// * the SELECTOR strips `[` and `]` only, then splits on `[,\s]+`. Its output
|
||
// IS `citedReqIds` — the ledger-writing set — so widening it would change
|
||
// what phase-complete marks, which #3697 explicitly does not do.
|
||
// * the DETECTOR additionally shaves brackets/quotes/emphasis and trailing
|
||
// sentence punctuation, so it can see an operator or an ID that the
|
||
// selector's stricter shape filter rejects.
|
||
// The gap between them is not a defect: it is why `ADR-7)` is not selected
|
||
// while `ADR-7` is still nameable in a warning.
|
||
//
|
||
// The `**Requirements**: TBD` placeholder is what phase.add / -batch / -insert
|
||
// seed (three sites in this file — locate them by the literal
|
||
// `Requirements**: TBD`, never by line number: an earlier revision of this
|
||
// comment cited 833/920/1078, which had drifted to 1132/1237/1413 by round 3).
|
||
// The shipped comma-list template is `gsd-core/templates/roadmap.md:32`.
|
||
type RequirementsLineAnalysis = {
|
||
/** The ledger-writing set — byte-identical to the pre-extraction selector. */
|
||
citedReqIds: string[];
|
||
/** The detector's shaved tokens (see the tokenization note above). */
|
||
tokens: string[];
|
||
/** R1 — tokens that are THEMSELVES a range (`RANGE-01..RANGE-05`). */
|
||
rangeTokens: string[];
|
||
/** R2 — a bare operator with a selected, interior-implying ID either side. */
|
||
hasSpacedRange: boolean;
|
||
/** R2' — an operator GLUED to one endpoint (`RANGE-01 -RANGE-05`). */
|
||
hasGluedRangeFragment: boolean;
|
||
/** R3 — zero selection on a non-placeholder line, with ID-shaped residue. */
|
||
inertIdShaped: string[];
|
||
/**
|
||
* R3b — zero selection on a non-placeholder line that carries ANY content.
|
||
*
|
||
* This is #3697's AC-1b/AC-4 verbatim ("warn when `citedReqIds.length === 0`
|
||
* while the raw capture is non-empty and not `TBD`"), and it is deliberately
|
||
* NOT gated on ID-shaped residue the way R3 is. Round 4 measured the reason:
|
||
* every one of the fifteen #2334/#2339 negative-space fixtures is held
|
||
* silent by non-zero SELECTION or by `placeholderLed`, and not one of them by
|
||
* the ID-shape gate — so the gate was buying no negative space while costing
|
||
* the acceptance criterion. `Deferred`, `N/A`, `Pending`, `TBA` and `-` were
|
||
* silent because of it, while the docs, this census and the advice string all
|
||
* said they warned.
|
||
*/
|
||
zeroSelectionInert: boolean;
|
||
/**
|
||
* R4 — REQ-IDs the SELECTOR dropped because a delimiter was glued to them.
|
||
*
|
||
* `REQ-01; REQ-02` selects only `REQ-02`: the selector splits on `[,\s]+`,
|
||
* so `REQ-01;` keeps its semicolon and fails the anchored ID shape. This is
|
||
* #3697's own half-success failure mode — `requirements_updated: true` with
|
||
* a silently unmarked requirement — reached by one wrong delimiter.
|
||
*
|
||
* Round 4 review called this indistinguishable from a parenthesised
|
||
* citation, because `(ADR-7)` also shaves to a bare ID. At the RAW token
|
||
* level they are not: `REQ-01;` is shaved of a trailing DELIMITER,
|
||
* `ADR-7)` of a citation wrapper. This rule keys on that shave class and
|
||
* requires the token to sit outside any parenthetical, which is what keeps
|
||
* `(see ADR-7: section 3)` silent.
|
||
*/
|
||
delimiterDroppedIds: string[];
|
||
/** Tokens past the scan cap that could carry an ID — reported, never dropped. */
|
||
oversizedTokens: string[];
|
||
/** ID-shaped tokens the selector did not take. Reported as a fact; never routes. */
|
||
unselectedIdShaped: string[];
|
||
/** The line leads with `TBD` / `None`. */
|
||
placeholderLed: boolean;
|
||
/** R2's hits, as `[left, right]` endpoint pairs, so the channel below can ask
|
||
* about the endpoints the rule actually fired on. */
|
||
spacedRangePairs: Array<[string, string]>;
|
||
/**
|
||
* Nothing on the line was DEMONSTRABLY dropped: no rule that names a specific
|
||
* unselected ID fired, and any spaced range fired on endpoints the selector
|
||
* actually took.
|
||
*
|
||
* This is the shared precondition of both NON-assertive voices — the
|
||
* ambiguous range reading and the over-cap "not classified" report — and it
|
||
* is named once because they had drifted apart. Round 7 review, Minor 1:
|
||
* `rangeReadingOnly` carried the conjunction inline and omitted the cap,
|
||
* while the over-cap channel carried its own copy that excluded a spaced
|
||
* range wholesale. A line with a clean, fully-selected range beside an
|
||
* unexamined over-cap token satisfied neither guard as intended and reached
|
||
* the ambiguous voice.
|
||
*/
|
||
nothingDemonstrablyDropped: boolean;
|
||
/**
|
||
* The round-3 channel discriminator (review finding Major 3). True when the
|
||
* ONLY thing to report is a range *reading*: R2 fired, no other rule did, and
|
||
* every endpoint R2 fired on was actually selected. Nothing was dropped, so
|
||
* the line did not fail to parse and the warning must not claim it did.
|
||
*
|
||
* This is deliberately RULE-SCOPED rather than line-global. A line-global
|
||
* "was anything ID-shaped left unselected?" test reads correctly on the
|
||
* motivating example and misroutes as soon as the line carries an unrelated
|
||
* parenthesised citation: `RANGE-01, RANGE-02 — RANGE-05 deferred per
|
||
* (ADR-7)` has `(ADR-7)` outside the selector's bracket strip, so a global
|
||
* test calls it a drop and sends the line back to the assertive channel —
|
||
* reinstating exactly the false "could not be parsed" claim Major 3 is
|
||
* about, and contradicting #3697-4, which pins a parenthetical citation as
|
||
* NOT unparsed residue. Only the rules that fired may speak.
|
||
*/
|
||
rangeReadingOnly: boolean;
|
||
/** Any rule fired — the line warrants a warning. */
|
||
warn: boolean;
|
||
};
|
||
|
||
// A range operator, enumerated. CENSUS (round 3): the domain is "separator
|
||
// spellings an author can put between two REQ-IDs", which is open, so the
|
||
// enumeration draws a boundary rather than covering it. Reached: ASCII `..`+,
|
||
// the seven Unicode dashes that are the SAME operator at different codepoints
|
||
// (U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash, U+2013 en,
|
||
// U+2014 em, U+2015 horizontal bar, U+2212 minus) plus ASCII `-`, U+2026
|
||
// ellipsis, and the words `to`/`thru`/`through`. NOT reached, and the
|
||
// consequence is a silent under-selection — #3697's own defect — for that
|
||
// spelling: `→`, `~`, `..=`, `..<`, `until`, and `up to` (two tokens, so it
|
||
// cannot be one operator token at all). Those stay out deliberately: each is a
|
||
// symbol or word with an independent non-range use between two IDs, which is
|
||
// the over-warning class #2334 cost three rounds. The Unicode dashes DO carry
|
||
// the ASCII hyphen's date/sub-number collision — an earlier round-3 commit
|
||
// claimed they did not, and was wrong — so they take the strict arm with it;
|
||
// see the rule below.
|
||
const REQ_RANGE_DASHES = '\\u2010\\u2011\\u2012\\u2013\\u2014\\u2015\\u2212';
|
||
// EVERY DASH IS STRICT — one rule, whatever the codepoint. `PREFIX-\d+ <dash>
|
||
// \d+` is also a date (`FY-2026-08`) and a sub-numbered ID (`API-2-01`), and
|
||
// that ambiguity is a property of the SHAPE, not of which dash key was pressed.
|
||
// The design already chose strictness for ASCII `-` on exactly this trade: a
|
||
// bare-hyphen tight range must carry a full ID on BOTH sides. Until round 3 the
|
||
// other dashes sat in the loose arm, so `RANGE-01 (target FY-2026<en-dash>08)`
|
||
// warned while its all-ASCII twin — pinned silent by #3697-4 — did not. That
|
||
// inconsistency predates this PR for U+2013/U+2014; round 3 briefly widened it
|
||
// to five more codepoints before this commit closed it for all seven.
|
||
// The cost is symmetric and already accepted: `RANGE-01, RANGE-02<dash>05`
|
||
// goes silent, exactly as `RANGE-01, RANGE-02-05` already does today. A bare
|
||
// `RANGE-02<dash>05` still warns — it selects nothing, so R3 catches it.
|
||
// LOOSE stays loose: `..`, `…` and the word operators have no date or
|
||
// sub-number reading between two numbers, so they keep the numeric endpoint.
|
||
const REQ_RANGE_OP = `(?:\\.{2,}|\\u2026|[${REQ_RANGE_DASHES}]|-|to|thru|through)`;
|
||
const REQ_RANGE_OP_LOOSE = `(?:\\.{2,}|\\u2026|to|thru|through)`;
|
||
const REQ_RANGE_OP_SYMBOL = `(?:\\.{2,}|\\u2026|[${REQ_RANGE_DASHES}]|-)`;
|
||
const REQ_RANGE_TOKEN_RE = new RegExp(
|
||
`^([A-Z][A-Z0-9]*)-(?:\\d+)\\s*(?:${REQ_RANGE_OP_LOOSE}\\s*(?:\\1-)?|[-${REQ_RANGE_DASHES}]\\s*\\1-)\\d+$`,
|
||
'i',
|
||
);
|
||
const REQ_PURE_RANGE_OP_RE = new RegExp(`^${REQ_RANGE_OP}$`, 'i');
|
||
const REQ_GLUED_RANGE_LEAD_RE = new RegExp(`^${REQ_RANGE_OP_SYMBOL}([A-Z][A-Z0-9]*-\\d+)$`, 'i');
|
||
const REQ_GLUED_RANGE_TRAIL_RE = new RegExp(`^([A-Z][A-Z0-9]*-\\d+)${REQ_RANGE_OP}$`, 'i');
|
||
const REQ_ID_SUBSTRING_RE = /[A-Z][A-Z0-9]*-\d+/i;
|
||
const REQ_ID_SHAPE_RE = /^[A-Z][A-Z0-9]*-\d+$/i;
|
||
const REQ_ID_PARTS_RE = /^([A-Z][A-Z0-9]*)-(\d+)$/i;
|
||
// `LETTERS-\d+-\d+` — a date (`FY-2026-08`) or a sub-numbered ID (`API-2-01`).
|
||
// REQ_RANGE_TOKEN_RE's strict-dash arm exists precisely to keep this shape
|
||
// silent, because nothing at token level can tell the three readings apart.
|
||
// Round 4 review Minor 2: the skipped-text rider re-reported it through the
|
||
// side door — `REQ_ID_SUBSTRING_RE` is unanchored, so `FY-2026-08` matches as
|
||
// `FY-2026` and landed in `unselectedIdShaped`. Whenever any OTHER rule fired
|
||
// on a line carrying a date annotation, the warning then told the author to
|
||
// "check whether any of it is a requirement" about a date. Not a false
|
||
// warning — the line was warning anyway — but false CONTENT, and it is the
|
||
// #2334 voice.
|
||
// `PREFIX-<digits>-<digits>` — the shape the strict-dash range rule refuses to
|
||
// act on because it is equally a date (`FY-2026-08`) and a sub-numbered id
|
||
// (`API-2-01`). NO regex separates those: `API-2026-08` is a legal requirement
|
||
// id and `FY-26-08` is a date, and both filters that tried scored a miss in
|
||
// each direction under the pre-push review's continuation.
|
||
//
|
||
// So the rider stops adjudicating and starts DISCLOSING. Round 4 Minor 2's
|
||
// real complaint was that the rider told the author to check whether a DATE
|
||
// was a requirement; the fix is to name the ambiguity rather than to guess at
|
||
// it — which is the same thing the two warning voices already do about a
|
||
// range separator.
|
||
const REQ_AMBIGUOUS_NUMERIC_RE = /^[A-Z][A-Z0-9]*(?:-\d+){2,}$/i;
|
||
|
||
// The token-length cap. It bounds REQ_ID_SUBSTRING_RE, the one UNANCHORED
|
||
// regex here, which backtracks quadratically on a pathological token. Round 3
|
||
// review Nit 6 objected that the anchored regexes were left uncapped on the
|
||
// strength of a comment asserting they scan linearly; they are applied through
|
||
// the same cap now, so the claim is enforced rather than asserted. No real
|
||
// REQ-ID-carrying token approaches this bound.
|
||
const REQ_TOKEN_SCAN_LIMIT = 2048;
|
||
|
||
/**
|
||
* The Requirements-line warning KINDS, as a stable machine vocabulary (round 4
|
||
* review Major 3).
|
||
*
|
||
* Before this, the kind existed only in the prose of the message, so every
|
||
* consumer and every test had to regex an English sentence — and rewording a
|
||
* message silently un-asserted the tests that pinned it. The repo already had
|
||
* the settled seam for exactly these semantics: `diffLiveConfig` emits
|
||
* `kind:'unverified'` for a truncated scan (`CONTEXT.md`), and
|
||
* `WAVE_CLEANUP_WARNING` carries codes in `src/worktree-safety.cts`.
|
||
*
|
||
* Carried ALONGSIDE the prose, never instead of it. `warnings[]` is a
|
||
* documented `string[]` in `phase complete`'s JSON output, rendered by
|
||
* execute-phase.md's "If has_warnings is true" step, so changing its element
|
||
* shape would be a breaking output-contract change for a shipped command. The
|
||
* code is emitted as its own additive `requirements_line_warning` field.
|
||
*/
|
||
const REQ_LINE_WARNING_CODE = {
|
||
/** ID-shaped content was demonstrably not selected — the line failed to parse. */
|
||
misparse: 'req-line-misparse',
|
||
/** A range READING is at stake; every endpoint the rule fired on was selected. */
|
||
rangeReading: 'req-line-range-reading',
|
||
/** A token past the scan cap means the line was not classified — never that it is clean. */
|
||
unverified: 'req-line-unverified',
|
||
} as const;
|
||
|
||
type ReqLineWarningCode = (typeof REQ_LINE_WARNING_CODE)[keyof typeof REQ_LINE_WARNING_CODE];
|
||
|
||
/** The formatter's result. `null` still means CLEAN, which is a value, not a failure. */
|
||
type ReqLineWarning = { code: ReqLineWarningCode; message: string };
|
||
|
||
// R4 — a full ID with a trailing statement delimiter glued to it. ANCHORED on
|
||
// both ends, so it is linear and needs no cap of its own beyond the token
|
||
// length guard its caller applies.
|
||
// Zero-width, bidi-control, joiner and variation-selector codepoints. INVISIBLE
|
||
// to the author, and the pre-push review's continuation drove the consequence
|
||
// from both sides: a line of only these warned with nothing on screen to
|
||
// explain it, AND stripping them wholesale from the detector made
|
||
// `REQ-01<ZWSP>, REQ-02` go SILENT while the selector really did drop REQ-01 —
|
||
// #3697's own defect, introduced by the fix for its mirror image. So they are
|
||
// never stripped from the line: they are DECORATION on a token (R4 below) and
|
||
// absence-of-content for the empty test (visibleContent), which are two
|
||
// different questions about the same character.
|
||
const REQ_INVISIBLE_RE = /[\u00AD\u200B-\u200F\u2060-\u2064\u2066-\u2069\uFE0F\uFEFF]/g;
|
||
// The wrappers R4 shaves. Emphasis, quotes and backticks, because the SELECTOR
|
||
// shaves none of them — `**REQ-01**` is genuinely not selected and is a real,
|
||
// silent drop.
|
||
//
|
||
// PARENTHESES ARE DELIBERATELY ABSENT, and this is load-bearing. A parenthesis
|
||
// is this rule's citation MARKER, not decoration to shave: `(REQ-02)` and
|
||
// `(ADR-7)` are the same shape and the rule declines both. Including them here
|
||
// made `REQ-01, (REQ-02), REQ-03 — REQ-05` report a glued delimiter that was
|
||
// never there, and broke #3697-9d's channel routing with it — caught by the
|
||
// suite immediately after the widening.
|
||
const REQ_WRAPPER_RE = /^["'`*_~“”‘’]+|["'`*_~“”‘’]+$/g;
|
||
// An id with a list delimiter glued to EITHER end, once styling is removed.
|
||
// The capture is the bare id; a match means the delimiter was ADJACENT to it.
|
||
const REQ_DELIMITED_ID_RE = /^[;:]*([A-Z][A-Z0-9]*-\d+)[;:]*$/i;
|
||
|
||
|
||
|
||
/**
|
||
* CENSUS (round 4): the domain is "separators an author writes between two
|
||
* REQ-IDs INSTEAD of a comma" — distinct from the range-operator domain
|
||
* censused above, and it had no census at all before this round.
|
||
*
|
||
* ROUND 4'S CENSUS WAS WRONG, AND THE WAY IT WAS WRONG IS THE LESSON. It swept
|
||
* 26 spellings and concluded "exactly two — `; ` and `: `". It reached that
|
||
* answer because it swept the ONE-SIDED form (`REQ-01; REQ-02`) for the
|
||
* semicolon and colon, and only the BARE and SYMMETRIC forms (`|`, ` | `) for
|
||
* every other separator. Different members of the domain were tested in
|
||
* different shapes, so the conclusion could not have come out any other way.
|
||
*
|
||
* Re-swept round 5, fully crossed: 21 separators x {bare, trailing-space,
|
||
* leading-space, both-spaces} = 84 combinations, driven through the built
|
||
* artifact. 26 select both IDs, 24 under-select and already warn, and
|
||
* 34 UNDER-SELECT SILENTLY. All 34 are the same shape — a separator glued to
|
||
* exactly ONE of the two IDs, e.g. `REQ-01/ REQ-02` or `REQ-01 /REQ-02` — for
|
||
* every punctuation except `,` (the real delimiter) and `;` / `:` (R4).
|
||
* Measured silent: | / + & \ > . ! ? • · ؛ ; , - ~ and the word operators
|
||
* `and` / `plus` in trailing-space form.
|
||
*
|
||
* So the honest statement is that R4 covers TWO CHARACTERS of a domain that is
|
||
* wide open, not that the domain has two members. The round-4 review
|
||
* hand-listed the semicolon; the colon is its sibling and fails identically;
|
||
* everything else in that list is disclosed here and NOT caught. Widening the
|
||
* delimiter class is a small change and deliberately not made at the end of a
|
||
* round: three successive cuts of this rule fired on a citation.
|
||
*
|
||
* THE GATE IS ADJACENCY, and it is the part to read. Styling is stripped, then
|
||
* the delimiter must be touching the id: `REQ-01;`, `;REQ-02`, `**REQ-01;**`
|
||
* and the backticked form all qualify. `**REQ-01**;` does NOT — outside the
|
||
* styling a `;` is sentence punctuation, which is why `REQ-01, see **REQ-7**;
|
||
* next topic` is a citation and not a drop. An INVISIBLE anywhere in the token
|
||
* qualifies without an adjacency test, because nobody types one on purpose, so
|
||
* it is corruption rather than intent.
|
||
*
|
||
* Markdown styling on its own is NOT a trigger and NOT reported. It reaches
|
||
* the skipped-text rider, which names the id without asserting a drop — but a
|
||
* rider only exists inside a MESSAGE, and a message only exists when some rule
|
||
* set `warn`. On a line where nothing else fires, `REQ-01, **REQ-02**` is
|
||
* wholly silent. Saying it is "left to the rider" reads as coverage and is
|
||
* not; #3697-19m pins the silence so this comment cannot drift back.
|
||
*
|
||
* NOT reached, stated rather than fixed, and the second member is WIDER than
|
||
* this comment first claimed:
|
||
* - anything inside a parenthetical. A parenthesis is this rule's citation
|
||
* MARKER, never decoration to shave — `(REQ-02)` and `(ADR-7)` are the
|
||
* same shape and the rule declines both.
|
||
* - a decorated id whose prefix is on NO selected id: `REQ-01, FOO-02: x`
|
||
* stays silent even when FOO-02 is real. Prefix agreement is what
|
||
* separates a drop from a bare citation — `REQ-01, see ADR-7: section 3`
|
||
* carries `ADR-7:` in exactly `REQ-01;`'s shape — and it is the module's
|
||
* own idiom, not a new heuristic (reqEndpointsImplyInterior already
|
||
* requires an agreeing prefix). The gate is NOT complete: a citation that
|
||
* DOES share a selected prefix (`ADR-01, see ADR-7: sec 3`) still fires,
|
||
* and nothing at token level separates that from a real drop. Saying so is
|
||
* the honest position; a prose heuristic on "see" is exactly the free-text
|
||
* detector this module exists to avoid.
|
||
* The trade, plainly: an under-report on a rare shape over an over-report on a
|
||
* common one — the same call the strict-dash rule makes.
|
||
*/
|
||
function reqDelimiterDroppedIds(rawLine: string, selected: Set<string>, cap: number): string[] {
|
||
// MATCHED parenthetical spans are removed OUTRIGHT, not tracked as a depth.
|
||
//
|
||
// Two bugs died here. A running depth counter let an unbalanced `(` stay open
|
||
// to end-of-line and swallow every real drop after it. Promoting a whole
|
||
// token to immune because it CONTAINED a matched character then leaked the
|
||
// other way: `REQ-01, REQ-02;(note) REQ-03` is one whitespace token, so the
|
||
// parenthetical conferred immunity on the `REQ-02;` sitting outside it.
|
||
// Deleting the span states what is actually meant — for this rule a citation
|
||
// is not on the line — while an UNMATCHED paren is a typo and confers
|
||
// nothing.
|
||
//
|
||
// Square brackets go too, exactly as the SELECTOR strips them: `[REQ-01;
|
||
// REQ-02]` is the documented form and was silently dropping REQ-01.
|
||
//
|
||
// INVISIBLES STAY. They are the evidence this rule reads; the tokenizer
|
||
// strips them for the classification rules, and the two sites answer two
|
||
// different questions about the same character.
|
||
const chars = [...String(rawLine).replace(/<!--[\s\S]*?-->/g, ' ')];
|
||
const openStack: number[] = [];
|
||
for (let i = 0; i < chars.length; i += 1) {
|
||
if (chars[i] === '(') openStack.push(i);
|
||
else if (chars[i] === ')' && openStack.length > 0) {
|
||
const open = openStack.pop() as number;
|
||
for (let j = open; j <= i; j += 1) chars[j] = ' ';
|
||
}
|
||
}
|
||
const line = chars.join('').replace(/[[\]]/g, '');
|
||
|
||
// The prefixes actually SELECTED on this line. A dropped id must agree with
|
||
// one of them — that is what separates a delimiter typo from a citation,
|
||
// since `REQ-01, see ADR-7: sec 3` carries `ADR-7:` in exactly `REQ-01;`'s
|
||
// shape. Same-prefix agreement is the module's own idiom, not a new
|
||
// heuristic (see reqEndpointsImplyInterior).
|
||
const selectedPrefixes = new Set<string>();
|
||
for (const id of selected) {
|
||
const m = REQ_ID_PARTS_RE.exec(id);
|
||
if (m) selectedPrefixes.add(m[1].toUpperCase());
|
||
}
|
||
|
||
const hits: string[] = [];
|
||
for (const raw of line.split(/[,\s]+/)) {
|
||
if (!raw || raw.length > cap) continue;
|
||
// Strip STYLING only. What survives is the id plus whatever was glued
|
||
// directly to it.
|
||
const core = raw.replace(REQ_INVISIBLE_RE, '').replace(REQ_WRAPPER_RE, '');
|
||
const m = REQ_DELIMITED_ID_RE.exec(core);
|
||
if (!m) continue;
|
||
const bare = m[1];
|
||
// ADJACENCY IS THE WHOLE RULE. A `;`/`:` touching the id is a list
|
||
// separator someone meant; the same character OUTSIDE the styling is
|
||
// sentence punctuation — `see **REQ-7**; next topic` cites a requirement
|
||
// while `**REQ-01;** REQ-02` fails to list one, and only the delimiter's
|
||
// POSITION separates them. An INVISIBLE needs no adjacency test: nobody
|
||
// types one on purpose, so anywhere in the token it is corruption rather
|
||
// than intent.
|
||
const hadAdjacentDelimiter = core !== bare;
|
||
REQ_INVISIBLE_RE.lastIndex = 0;
|
||
const hadInvisible = REQ_INVISIBLE_RE.test(raw);
|
||
REQ_INVISIBLE_RE.lastIndex = 0;
|
||
if (!hadAdjacentDelimiter && !hadInvisible) continue;
|
||
if (selected.has(bare.toUpperCase())) continue;
|
||
const parts = REQ_ID_PARTS_RE.exec(bare);
|
||
if (parts && selectedPrefixes.has(parts[1].toUpperCase())) hits.push(bare);
|
||
}
|
||
return [...new Set(hits)];
|
||
}
|
||
|
||
/** Endpoints imply a dropped interior only on an AGREEING prefix and a gap > 1. */
|
||
function reqEndpointsImplyInterior(a: string, b: string): boolean {
|
||
const ma = REQ_ID_PARTS_RE.exec(a);
|
||
const mb = REQ_ID_PARTS_RE.exec(b);
|
||
if (!ma || !mb) return false;
|
||
if (ma[1].toUpperCase() !== mb[1].toUpperCase()) return false;
|
||
// BigInt keeps the gap exact for numbers past 2^53.
|
||
const gap = BigInt(mb[2]) - BigInt(ma[2]);
|
||
return gap > 1n || gap < -1n;
|
||
}
|
||
|
||
function analyzeRequirementsLine(rawLine: string): RequirementsLineAnalysis {
|
||
const line = typeof rawLine === 'string' ? rawLine : '';
|
||
// SELECTOR — byte-identical to the pre-extraction expression.
|
||
const citedReqIds = line
|
||
.replace(/[\[\]]/g, '')
|
||
.split(/[,\s]+/)
|
||
.map((r) => r.trim())
|
||
.filter(Boolean)
|
||
.filter((r) => REQ_ID_SHAPE_RE.test(r));
|
||
|
||
// DETECTOR tokenization. A token with NO alphanumerics is shaved of brackets
|
||
// ONLY, so `(..)` surfaces its operator while a bare `..` is not shaved to
|
||
// nothing by the punctuation classes. A trailing run of 2+ dots is a glued
|
||
// range operator (`REQ-01.. REQ-05`), not sentence punctuation — keep it.
|
||
const tokens = line
|
||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||
// Invisibles are removed HERE, for the classification rules — an operator
|
||
// spelled `<ZWSP>..<ZWSP>` is still the range operator, and a line of only
|
||
// invisibles yields no tokens at all. R4 works on the RAW line and does
|
||
// NOT strip them, because there they are the evidence of a dropped id.
|
||
// Removing them in both places is what made `REQ-01<ZWSP>, REQ-02` silent;
|
||
// removing them in neither is what made `REQ-01 <ZWSP>..<ZWSP> REQ-05`
|
||
// silent. The two questions have two different answers.
|
||
.replace(REQ_INVISIBLE_RE, '')
|
||
.split(/[,\s]+/)
|
||
.map((t) => {
|
||
const trimmed = t.trim();
|
||
if (!/[A-Za-z0-9]/.test(trimmed)) {
|
||
return trimmed.replace(/^[[({]+/, '').replace(/[\])}]+$/, '');
|
||
}
|
||
if (/\.{2,}$/.test(trimmed)) {
|
||
return trimmed.replace(/^[[({"'`*_~“”‘’]+/, '');
|
||
}
|
||
return trimmed.replace(/^[[({"'`*_~“”‘’]+/, '').replace(/[\])}.;:"'`*_~“”‘’]+$/, '');
|
||
})
|
||
.filter(Boolean);
|
||
|
||
// Every predicate below is applied through the scan limit (Nit 6): a token
|
||
// past the bound is not classified at all rather than classified expensively.
|
||
const short = (t: string): boolean => t.length <= REQ_TOKEN_SCAN_LIMIT;
|
||
const rangeTokens = tokens.filter((t) => short(t) && REQ_RANGE_TOKEN_RE.test(t));
|
||
const spacedRangePairs: Array<[string, string]> = [];
|
||
tokens.forEach((t, i) => {
|
||
const left = tokens[i - 1] ?? '';
|
||
const right = tokens[i + 1] ?? '';
|
||
if (
|
||
// EVERY participant is capped, not just the operator. Capping the operator
|
||
// alone left `<2049-char ID> .. <2049-char ID>` running REQ_ID_SHAPE_RE and
|
||
// BigInt over both neighbours unbounded — the cap read as uniform and was
|
||
// not (found by the round's pre-push review).
|
||
short(t) &&
|
||
short(left) &&
|
||
short(right) &&
|
||
REQ_PURE_RANGE_OP_RE.test(t) &&
|
||
i > 0 &&
|
||
i < tokens.length - 1 &&
|
||
REQ_ID_SHAPE_RE.test(left) &&
|
||
REQ_ID_SHAPE_RE.test(right) &&
|
||
reqEndpointsImplyInterior(left, right)
|
||
) {
|
||
spacedRangePairs.push([left, right]);
|
||
}
|
||
});
|
||
const hasSpacedRange = spacedRangePairs.length > 0;
|
||
// A half-spaced range splits at the tokenizer, so R1's own `\s*` never sees
|
||
// it. SYMBOL operators only on the LEAD arm: a word operator glued to an ID
|
||
// is an ID — `TORANGE-05` is a valid prefix-agnostic REQ-ID. The TRAIL arm
|
||
// keeps the word operators, because a valid ID must end in digits, so
|
||
// `REQ-01through` can only be a glued typo.
|
||
const hasGluedRangeFragment = tokens.some((t, i) => {
|
||
// Neighbours capped for the same reason as R2 above.
|
||
if (!short(t)) return false;
|
||
const before = tokens[i - 1] ?? '';
|
||
const after = tokens[i + 1] ?? '';
|
||
const lead = REQ_GLUED_RANGE_LEAD_RE.exec(t);
|
||
if (
|
||
lead &&
|
||
i > 0 &&
|
||
short(before) &&
|
||
REQ_ID_SHAPE_RE.test(before) &&
|
||
reqEndpointsImplyInterior(before, lead[1])
|
||
) {
|
||
return true;
|
||
}
|
||
const trail = REQ_GLUED_RANGE_TRAIL_RE.exec(t);
|
||
return Boolean(
|
||
trail &&
|
||
i < tokens.length - 1 &&
|
||
short(after) &&
|
||
REQ_ID_SHAPE_RE.test(after) &&
|
||
reqEndpointsImplyInterior(trail[1], after),
|
||
);
|
||
});
|
||
const leadToken = (tokens[0] ?? '').toUpperCase();
|
||
// CENSUS (round 3, review finding Minor 4): the placeholder domain is what
|
||
// GSD itself seeds plus what an author writes for "deliberately empty".
|
||
// Reached: `TBD` — the ONLY machine-written seed, at the three phase.add /
|
||
// -batch / -insert sites — and `None`, the author convention. NOT reached:
|
||
// `N/A`, `Deferred`, `Pending`, `TBA`, `-`. Consequence, and it is now
|
||
// ENFORCED rather than asserted: such a line selects zero IDs and warns
|
||
// through R3b below, which is what #3697's acceptance criterion asks for
|
||
// ("when it selects zero IDs from a line that is non-empty and is not the
|
||
// `TBD` placeholder"). Round 3 shipped this same paragraph while R3's
|
||
// ID-shape gate made it false for all five words — bare `Deferred` was
|
||
// silent, `Deferred (see ADR-7)` warned — and the claim sat in three
|
||
// artifacts with no test in either direction. Inferring placeholder-ness
|
||
// from arbitrary prose is still the free-text heuristic this detector
|
||
// avoids: R3b keys on the SELECTION being empty, never on what the prose
|
||
// means.
|
||
const placeholderLed = leadToken === 'TBD' || leadToken === 'NONE';
|
||
const inertIdShaped =
|
||
citedReqIds.length === 0 && !placeholderLed
|
||
? tokens.filter((t) => short(t) && t.includes('-') && REQ_ID_SUBSTRING_RE.test(t))
|
||
: [];
|
||
// R3b — the acceptance criterion's own narrow form. `tokens.length > 0` is
|
||
// what keeps an empty line and a comment-only line silent: the tokenizer
|
||
// strips `<!-- ... -->` before splitting, so `<!-- fill in -->` yields no
|
||
// tokens and cannot reach this rule. Every other zero-selection,
|
||
// non-placeholder line warns.
|
||
const zeroSelectionInert = citedReqIds.length === 0 && !placeholderLed && tokens.length > 0;
|
||
|
||
// R2 is the ONLY ambiguous rule — a tight range, a glued fragment and R3
|
||
// residue each implicate ID-shaped text the selector demonstrably did not
|
||
// take, so any of them means the line really did fail to parse. R2 is
|
||
// ambiguous only when its OWN endpoints were selected: the detector shaves
|
||
// brackets and the selector does not, so R2 can fire on a `(RANGE-02)` that
|
||
// was never selected — a real drop, and the assertive channel is right there.
|
||
// A token past the cap is NOT classified — and must therefore not be
|
||
// silently discarded. Round 3's first cut of the uniform cap did exactly
|
||
// that: a 2049-char range token warned before the round and went silent
|
||
// after it, which is #3697's own defect introduced by the fix for a nit
|
||
// (found by the round's pre-push review). The cap bounds the WORK, not the
|
||
// warning — so an over-cap token that could carry an ID is reported as
|
||
// unclassified. The test is `includes('-')`, a linear scan, never the
|
||
// unanchored regex the cap exists to keep off these tokens.
|
||
// ANY over-cap token, not just one carrying `-`. The first cut filtered on
|
||
// `includes('-')` and therefore missed an over-cap OPERATOR:
|
||
// `REQ-01 <2049 dots> REQ-05` warned before this round (R2 was uncapped) and
|
||
// went silent after it. A token we could not examine makes the line
|
||
// unverified whatever characters it happens to contain. Computed below,
|
||
// where the selected set is available.
|
||
|
||
// ID-shaped tokens the selector did not take, ANYWHERE on the line. This is
|
||
// reported as a fact, never used to pick the channel: `(ADR-7)` and
|
||
// `(REQ-02)` are indistinguishable by shape, so routing on it would put the
|
||
// false "could not be parsed" claim back on a line carrying a citation.
|
||
// Naming them lets the author see what the tokenizer skipped without the
|
||
// warning asserting a verdict it cannot support in either direction.
|
||
const selected = new Set(citedReqIds.map((id) => id.toUpperCase()));
|
||
// A token the SELECTOR took has had its own SELECTION verified — the selector
|
||
// is uncapped and anchored, so it examined the whole token. That is not the
|
||
// same as "no rule was suppressed by it", and conflating the two was the
|
||
// second continuation review's CLAIM J/K: two over-cap valid IDs either side
|
||
// of `..` are both selected, both exempted, and R2 is capped — so a line that
|
||
// warned before this round went silent, which is the very regression the
|
||
// field exists to close, arriving through the fix for its own over-report.
|
||
//
|
||
// The exemption therefore applies only when nothing could have been
|
||
// suppressed: an over-cap token that was selected AND has no neighbour that
|
||
// could pair with it into a range. Everything else is unexaminable and is
|
||
// reported as such.
|
||
const couldPairIntoRange = (i: number): boolean => {
|
||
for (const n of [tokens[i - 1], tokens[i + 1]]) {
|
||
if (n === undefined) continue;
|
||
if (!short(n)) return true;
|
||
if (REQ_PURE_RANGE_OP_RE.test(n)) return true;
|
||
if (REQ_GLUED_RANGE_LEAD_RE.test(n) || REQ_GLUED_RANGE_TRAIL_RE.test(n)) return true;
|
||
}
|
||
return false;
|
||
};
|
||
const oversizedTokens = tokens.filter(
|
||
(t, i) => !short(t) && (!selected.has(t.toUpperCase()) || couldPairIntoRange(i)),
|
||
);
|
||
const unselectedIdShaped = tokens.filter(
|
||
(t) => short(t) && REQ_ID_SUBSTRING_RE.test(t) && !selected.has(t.toUpperCase()),
|
||
);
|
||
// R4 runs on the RAW line, not on `tokens`: the shave that makes `REQ-01;`
|
||
// look like a clean `REQ-01` is exactly the evidence this rule needs, so it
|
||
// has to see the character the tokenizer removed.
|
||
const delimiterDroppedIds = reqDelimiterDroppedIds(rawLine, selected, REQ_TOKEN_SCAN_LIMIT);
|
||
const nothingDemonstrablyDropped =
|
||
rangeTokens.length === 0 &&
|
||
!hasGluedRangeFragment &&
|
||
inertIdShaped.length === 0 &&
|
||
// R4 is a DEMONSTRATED drop, so neither non-assertive voice — one claiming
|
||
// nothing was dropped, the other that nothing could be checked — may speak
|
||
// for a line carrying one.
|
||
delimiterDroppedIds.length === 0 &&
|
||
// R2 firing on an endpoint the selector did NOT take is itself a
|
||
// demonstrated drop, and the assertive channel is right there. Vacuously
|
||
// true when no spaced range fired, which is what makes this a strict
|
||
// superset of the `!hasSpacedRange` guard the over-cap channel used to
|
||
// carry — that channel's behaviour on a line with no spaced range is
|
||
// unchanged, byte for byte.
|
||
spacedRangePairs.every(([a, b]) => selected.has(a.toUpperCase()) && selected.has(b.toUpperCase()));
|
||
const rangeReadingOnly =
|
||
hasSpacedRange &&
|
||
nothingDemonstrablyDropped &&
|
||
// The cap bounds the WORK, never the warning. An over-cap token is not
|
||
// classified by ANY rule (R1-R4 all skip it), so the voice whose entire
|
||
// claim is that nothing was dropped has no basis to speak for this line.
|
||
// It falls to the over-cap channel below instead — `unverified`, because
|
||
// the line was not CHECKED; not `misparse`, because nothing on it
|
||
// demonstrably failed to parse either. Round 7 review, Minor 1.
|
||
oversizedTokens.length === 0;
|
||
|
||
// Named rather than inlined into the return literal (round 3 review Minor 3):
|
||
// this disjunction is the module's single most important predicate, and in
|
||
// the literal a later edit that reordered a local below the `return` would be
|
||
// a TDZ ReferenceError at runtime rather than an error at the reader's eye
|
||
// level. R3b joins it here — see its field docs above for why it is not
|
||
// gated on ID shape.
|
||
const warn =
|
||
rangeTokens.length > 0 ||
|
||
hasSpacedRange ||
|
||
hasGluedRangeFragment ||
|
||
inertIdShaped.length > 0 ||
|
||
zeroSelectionInert ||
|
||
delimiterDroppedIds.length > 0 ||
|
||
oversizedTokens.length > 0;
|
||
|
||
return {
|
||
citedReqIds,
|
||
tokens,
|
||
rangeTokens,
|
||
hasSpacedRange,
|
||
hasGluedRangeFragment,
|
||
inertIdShaped,
|
||
zeroSelectionInert,
|
||
placeholderLed,
|
||
spacedRangePairs,
|
||
nothingDemonstrablyDropped,
|
||
rangeReadingOnly,
|
||
delimiterDroppedIds,
|
||
oversizedTokens,
|
||
unselectedIdShaped,
|
||
warn,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Render the warning, or null when the line is clean.
|
||
*
|
||
* TWO CHANNELS, and the split is round 3's fix for review finding Major 3. The
|
||
* detector cannot distinguish `RANGE-02 — RANGE-05` meaning a range from the
|
||
* same text meaning an annotation separator; they are textually identical and
|
||
* no token-level rule separates them. What the old single-channel message did
|
||
* was resolve that ambiguity by ASSERTION — it told the author the line "could
|
||
* not be parsed" and to rewrite it, on a line where every ID present had in
|
||
* fact been selected and nothing had been dropped. That is a false statement
|
||
* under the annotation reading and the #2334 over-warning class.
|
||
*
|
||
* Going silent instead is not available: the range reading is equally live, and
|
||
* staying quiet on it re-opens the exact silent under-selection #3697 is about.
|
||
* So the ambiguity is DISCLOSED rather than decided —
|
||
*
|
||
* * any rule other than R2 fired, or R2 fired on an endpoint that was not
|
||
* selected → something ID-shaped was demonstrably NOT taken. The line did
|
||
* fail to parse; say so plainly, as before.
|
||
* * R2 alone fired and both its endpoints were selected → nothing was
|
||
* dropped. State both readings and let the author pick; never claim a parse
|
||
* failure that did not occur.
|
||
*/
|
||
function formatRequirementsLineWarning(
|
||
phaseNum: string,
|
||
rawLine: string,
|
||
analysis: RequirementsLineAnalysis,
|
||
): ReqLineWarning | null {
|
||
if (!analysis.warn) return null;
|
||
const shown = String(rawLine).trim();
|
||
const rangeRuleFired =
|
||
analysis.rangeTokens.length > 0 || analysis.hasSpacedRange || analysis.hasGluedRangeFragment;
|
||
|
||
// Tokens the selector skipped, stated as a fact in EITHER channel. `(ADR-7)`
|
||
// and `(REQ-02)` are the same shape, so no rule can say which one matters —
|
||
// but the author can, and only if the warning tells them. Round 3's first
|
||
// cut instead let this drive the channel, which put the false "could not be
|
||
// parsed" claim back on a line carrying a citation.
|
||
// Names only what the rule-specific clauses did NOT already name, so the
|
||
// assertive voice can carry it too without repeating itself.
|
||
const alreadyNamed = new Set(
|
||
[...analysis.rangeTokens, ...analysis.inertIdShaped, ...analysis.delimiterDroppedIds].map((t) =>
|
||
t.toUpperCase(),
|
||
),
|
||
);
|
||
const skippedNames = analysis.unselectedIdShaped.filter((t) => !alreadyNamed.has(t.toUpperCase()));
|
||
// Named, then qualified. The `PREFIX-N-N` shape is the one the range rules
|
||
// deliberately decline to act on, so the rider says WHY it might not be a
|
||
// requirement instead of silently deciding it is not.
|
||
const ambiguousNamed = skippedNames.filter((t) => REQ_AMBIGUOUS_NUMERIC_RE.test(t));
|
||
const skipped =
|
||
skippedNames.length > 0
|
||
? ` ID-shaped text on the line that was NOT selected: ${skippedNames.join(', ')}` +
|
||
` (parentheses are not stripped, unlike square brackets) — check whether any of it is a` +
|
||
` requirement.` +
|
||
(ambiguousNamed.length > 0
|
||
? ` ${ambiguousNamed.join(', ')} may equally be a date or a sub-numbered id, which is` +
|
||
` why the range rules do not act on that shape.`
|
||
: '')
|
||
: '';
|
||
// R4's clause. Named separately from the generic skipped-text rider because
|
||
// this one is not a "check whether any of it is a requirement" hedge — the
|
||
// token IS an ID, the selector demonstrably did not take it, and the cause
|
||
// is nameable.
|
||
const delimiterDropped =
|
||
analysis.delimiterDroppedIds.length > 0
|
||
? ` ${analysis.delimiterDroppedIds.join(', ')} ${analysis.delimiterDroppedIds.length === 1 ? 'was' : 'were'}` +
|
||
` NOT selected: a \`;\` or \`:\` is glued to the ID, or it carries an invisible character, and` +
|
||
` the line is split on commas and whitespace only. Write each requirement as a bare ID` +
|
||
` separated by a comma.`
|
||
: '';
|
||
const oversized =
|
||
analysis.oversizedTokens.length > 0
|
||
? ` One or more tokens exceed the ${REQ_TOKEN_SCAN_LIMIT}-character scan limit and were NOT` +
|
||
` classified, so this line may carry more than is reported here.`
|
||
: '';
|
||
|
||
if (analysis.rangeReadingOnly) {
|
||
// AMBIGUOUS channel — the RANGE reading is what is at stake, not a parse
|
||
// failure: every endpoint the range rule fired on was selected.
|
||
//
|
||
// What this voice must NOT do is claim the whole LINE is correct. It has
|
||
// no basis for that: an unrelated `(REQ-02)` elsewhere on the line is
|
||
// dropped by the selector and invisible to every rule, so "nothing needs
|
||
// to change" is an affirmative false statement on exactly the input the
|
||
// rule-scoped discriminator was built to reach. It speaks about the
|
||
// SEPARATOR, and defers the rest to the skipped-text clause above.
|
||
return {
|
||
code: REQ_LINE_WARNING_CODE.rangeReading,
|
||
message:
|
||
`ROADMAP Phase ${phaseNum} **Requirements** line (\`${shown}\`) contains what reads as a range ` +
|
||
`between two cited REQ-IDs. Range forms are not expanded, so no interior IDs were selected; ` +
|
||
`the line selected: ${analysis.citedReqIds.join(', ')}. If a range was intended, rewrite it ` +
|
||
`naming every requirement explicitly (e.g. \`REQ-01, REQ-02, REQ-03\`); if that separator is ` +
|
||
`an annotation rather than a range, it selected nothing to expand and needs no change.` +
|
||
delimiterDropped +
|
||
skipped +
|
||
oversized,
|
||
};
|
||
}
|
||
|
||
if (analysis.oversizedTokens.length > 0 && analysis.nothingDemonstrablyDropped) {
|
||
// A DEMONSTRATED drop outranks this voice, whose whole claim is that
|
||
// NOTHING could be checked — both cannot be true at once. `REQ-01,
|
||
// REQ-02: <over-cap token>` names REQ-02 in `delimiterDroppedIds` and
|
||
// then reported `req-line-unverified`, whose message never mentions it:
|
||
// the concrete, actionable finding masked by the token beside it. That
|
||
// exclusion now lives in `nothingDemonstrablyDropped`, shared verbatim
|
||
// with `rangeReadingOnly` above rather than duplicated here — the
|
||
// duplication is what let the two drift (round 7 review, Minor 1). The
|
||
// assertive channel already appends the over-cap rider, so routing a
|
||
// demonstrated drop there loses nothing about the cap.
|
||
// OVER-CAP channel — no rule could run, so no rule may be diagnosed. Say
|
||
// exactly that: the line was not classified, rather than not a problem.
|
||
return {
|
||
code: REQ_LINE_WARNING_CODE.unverified,
|
||
message:
|
||
`ROADMAP Phase ${phaseNum} **Requirements** line (\`${shown.slice(0, 200)}…\`) could not be ` +
|
||
`checked: one or more tokens exceed the ${REQ_TOKEN_SCAN_LIMIT}-character scan limit, so the ` +
|
||
`REQ-ID selection on this line is unverified. Rewrite it as a comma-separated list ` +
|
||
`(e.g. \`REQ-01, REQ-02, REQ-03\`).`,
|
||
};
|
||
}
|
||
|
||
// ASSERTIVE channel — ID-shaped content was demonstrably not selected.
|
||
// Deliberately says "selected", NOT "marked complete": a range whose
|
||
// endpoints are themselves unregistered selects them and marks nothing, and a
|
||
// warning that overclaims the write is a warning the reader learns to
|
||
// distrust.
|
||
const selectedDesc =
|
||
analysis.citedReqIds.length > 0
|
||
? `the only REQ-ID(s) selected from it were: ${analysis.citedReqIds.join(', ')}`
|
||
: 'it selected NO REQ-IDs at all, so nothing was marked';
|
||
const unparsed = [...new Set([...analysis.rangeTokens, ...analysis.inertIdShaped])];
|
||
// Only diagnose "range" when a range rule actually fired — an R3 warning on
|
||
// non-range ID text must not claim one was written. And on the R3 path the
|
||
// residue is ID-SHAPED TEXT, which is not the same claim as "a requirement we
|
||
// failed to parse" (round 3 review finding Minor 4: `Deferred (see ADR-7)`
|
||
// reported `ADR-7` as missed requirement content when it is a citation). Name
|
||
// what it is, and name the placeholder escape the author actually has.
|
||
const advice = rangeRuleFired
|
||
? ' Range forms are not expanded; rewrite the line naming every requirement explicitly ' +
|
||
'(e.g. `REQ-01, REQ-02, REQ-03`).'
|
||
: ' If these are requirements, name them explicitly (e.g. `REQ-01, REQ-02, REQ-03`); if the line ' +
|
||
'is deliberately empty, write `TBD` or `None` — any other wording selects nothing and warns.';
|
||
return {
|
||
code: REQ_LINE_WARNING_CODE.misparse,
|
||
message:
|
||
`ROADMAP Phase ${phaseNum} **Requirements** line could not be parsed as a comma-separated REQ-ID list ` +
|
||
`(\`${shown}\`) - ${selectedDesc}.` +
|
||
(unparsed.length > 0
|
||
? rangeRuleFired
|
||
? ` Unparsed text: ${unparsed.join(', ')}.`
|
||
: ` ID-shaped text that was not selected: ${unparsed.join(', ')}.`
|
||
: '') +
|
||
advice +
|
||
delimiterDropped +
|
||
skipped +
|
||
oversized,
|
||
};
|
||
}
|
||
|
||
function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||
if (!phaseNum) {
|
||
error('phase number required for phase complete');
|
||
}
|
||
|
||
// #2028: fail safe in workstream mode with no active workstream. With no active
|
||
// workstream and no --ws, planningDir(cwd) resolves to root .planning, so
|
||
// phase.complete would write STATE.md/ROADMAP.md (and mislabel milestone status)
|
||
// into the shared root that other workstreams read. Mirror the #1912 guard that
|
||
// init.progress got (resolution: GSD_WORKSTREAM env > stored active pointer; an
|
||
// explicit --ws sets GSD_WORKSTREAM upstream and satisfies the check).
|
||
const availableWorkstreams = listAvailableWorkstreams(cwd);
|
||
// #3579 root-cause fix: this is a check, not a consuming read — use the
|
||
// non-mutating peek so an unresolvable pointer isn't self-healed (cleared)
|
||
// here and then found "absent" by diagnoseUnresolvedActiveWorkstream below,
|
||
// which would misreport a present-but-bad marker as no marker at all.
|
||
const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || peekActiveWorkstream(cwd);
|
||
if (availableWorkstreams.length > 0 && !resolvedWorkstream) {
|
||
// #3579: getActiveWorkstream now inherits a pointer-less session's read
|
||
// from the shared .planning/active-workstream marker, so reaching this
|
||
// branch with a marker actually present means the marker EXISTED but
|
||
// didn't resolve (invalid name, or its workstream dir is gone) — a
|
||
// materially different situation from "nothing was ever set" and one
|
||
// that deserves its own diagnostic instead of the generic message below.
|
||
const diagnosis = diagnoseUnresolvedActiveWorkstream(cwd);
|
||
if (diagnosis.present) {
|
||
error(
|
||
`phase.complete requires a workstream in workstream mode — the active-workstream marker names '${diagnosis.value}', but it did not resolve: ${describeUnresolvedWorkstreamReason(diagnosis.reason)}. Root STATE.md/ROADMAP.md (likely stale) would be written otherwise. ` +
|
||
`Pass --ws <name> or run ${formatGsdSlash('workstream set', resolveRuntime(cwd)) as string} to point it at an existing workstream. ` +
|
||
`Available workstreams: ${availableWorkstreams.join(', ')}`,
|
||
ERROR_REASON.WORKSTREAM_MODE_MARKER_UNRESOLVED,
|
||
{ marker_value: diagnosis.value, marker_reason: diagnosis.reason },
|
||
);
|
||
}
|
||
error(
|
||
`phase.complete requires a workstream in workstream mode — no active workstream is set, so root STATE.md/ROADMAP.md (likely stale) would be written. ` +
|
||
`Pass --ws <name> or run ${formatGsdSlash('workstream set', resolveRuntime(cwd)) as string} first. ` +
|
||
`Available workstreams: ${availableWorkstreams.join(', ')}`,
|
||
ERROR_REASON.WORKSTREAM_MODE_NONE_ACTIVE,
|
||
);
|
||
}
|
||
|
||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||
const today = realClock.localToday();
|
||
|
||
const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
|
||
if (!phaseInfoRaw) {
|
||
error(`Phase ${phaseNum} not found`);
|
||
}
|
||
const phaseInfo = phaseInfoRaw as unknown as Record<string, unknown>;
|
||
|
||
const planCount: number = phaseInfo['plans']
|
||
? (phaseInfo['plans'] as string[]).length
|
||
: 0;
|
||
const summaryCount: number = phaseInfo['summaries']
|
||
? (phaseInfo['summaries'] as string[]).length
|
||
: 0;
|
||
let requirementsUpdated = false;
|
||
// #3685: mirror requirementsUpdated's diff-tracking contract at the
|
||
// writes.push({filePath, before, after}) sites below, rather than
|
||
// reporting via fs.existsSync (which is true whenever the file merely
|
||
// exists, not when the transaction actually wrote a change).
|
||
let roadmapUpdated = false;
|
||
let stateUpdated = false;
|
||
|
||
const warnings: string[] = [];
|
||
// The machine kind of the Requirements-line warning, carried out to the JSON
|
||
// result as its own field (round 4 review Major 3). Declared HERE, in the
|
||
// same scope as `warnings[]`, because the assignment happens inside
|
||
// withPlanningLock and the emission happens after it.
|
||
let reqLineWarningCode: ReqLineWarningCode | undefined;
|
||
// ADR-3408 §8.5 / D2 (#3374): "liberal but visible" — when the write-seam
|
||
// composition's preservation stage restores a curated frontmatter value
|
||
// over a disagreeing derived one, that divergence is surfaced here rather
|
||
// than silently absorbed. Structured (field + reason), not prose, so a
|
||
// caller can assert on the value rather than regex a rendered message.
|
||
// Named `preservation_warnings`, NOT `warnings`: `warnings` above is
|
||
// already a prose `string[]` on this exact command — reusing it for a
|
||
// structured `{field, reason}[]` shape would be the "Generative Fix
|
||
// Divergence" anti-pattern (two sibling fields, same name, different
|
||
// element types). Mirrors `cmdMilestoneComplete`'s identical field
|
||
// (milestone.cts).
|
||
const preservationWarnings: Array<{ field: string; reason: string }> = [];
|
||
// #3057 B3: mirrors `verification_stale_check_indeterminate` on init.cts /
|
||
// roadmap.cts / uat-predicate.cts's outputs — set on the non-blocking path
|
||
// below (inside withPlanningLock) alongside the warnings[] entry, so a
|
||
// caller can assert on the typed field instead of the warning's prose.
|
||
let staleCheckIndeterminate = false;
|
||
const phaseFullDir = path.join(cwd, phaseInfo['directory'] as string);
|
||
|
||
// #2648: fail-closed plan-coverage gate. phase.complete used to gate ONLY on a
|
||
// single *-VERIFICATION.md status, so a phase could close "complete" while an
|
||
// arbitrary number of its plans — including plans a lock/recovery decision
|
||
// silently dropped — had no completion record (a confirmed production incident
|
||
// closed a phase with 6/30 plans unexecuted, including its entire final UI
|
||
// scope, with every tool-reported signal green). Now refuse completion when any
|
||
// plan lacks a matching *-SUMMARY.md, UNLESS that plan is explicitly retired
|
||
// via machine-readable `status: superseded` frontmatter (the #2349 marker).
|
||
//
|
||
// scanPhasePlans is the superseded-AWARE counter (it drops status: superseded
|
||
// plans from planFiles before returning), so a deliberately-retired plan never
|
||
// appears in the unsummarized set and never blocks completion — closing the
|
||
// Goodhart hole (delete a SUMMARY to raise the %) without regressing the
|
||
// legitimate lock/recovery pattern (retire a plan instead of executing it).
|
||
// This is evaluated BEFORE the verification-gate transaction below so a
|
||
// plan-coverage refusal fails fast without mutating ROADMAP/STATE. The count
|
||
// path (cmdPhaseComplete's own planCount/summaryCount above) is NOT superseded-
|
||
// aware (it comes from findPhaseInternal/phase-locator.cts); that is fine for
|
||
// DISPLAY (the X/Y cell) but must not be the gate — the gate needs the
|
||
// superseded-adjusted set so retired plans don't re-block the very phases the
|
||
// marker exists to unblock. Matches roadmap.cts's already-correct-but-unenforced
|
||
// `summaryCount >= planCount` predicate, now enforced at the completion seam.
|
||
const coverageScan = scanPhasePlans(phaseFullDir);
|
||
// #2648 security: fail CLOSED when the phase directory cannot be read.
|
||
// scanPhasePlans deliberately swallows readdirSync errors and returns an empty
|
||
// plan set ({planFiles: []}), which is indistinguishable from a readable empty
|
||
// phase. For a COVERAGE gate that is the wrong posture: "I could not read the
|
||
// plans" must mean "I cannot prove coverage," not "all plans are summarized" —
|
||
// otherwise any I/O failure (permissions, ENOTDIR, EBUSY on Windows, a dir
|
||
// present in ROADMAP.md but missing/unreadable on disk) silently re-opens the
|
||
// exact hole this gate exists to close. Distinguish the two: a readable
|
||
// directory with zero plans is a legitimately complete empty phase; an
|
||
// UNREADABLE directory is a fail-closed refusal. Mirrors cmdPhaseInsert's own
|
||
// readdirSync-fail-closed posture (a swallow there used to risk writing a
|
||
// colliding phase number).
|
||
try {
|
||
fs.readdirSync(phaseFullDir);
|
||
} catch (readErr) {
|
||
error(
|
||
`Phase ${phaseNum} cannot be completed: its plan directory is unreadable (${phaseInfo['directory'] as string}: ${(readErr as NodeJS.ErrnoException).code || (readErr as Error).message}), so plan coverage cannot be verified. Restore read access and retry — a coverage gate that passes when it cannot read the plans is no gate at all (#2648).`,
|
||
ERROR_REASON.PHASE_PLAN_COVERAGE_INCOMPLETE,
|
||
);
|
||
}
|
||
const unsummarizedPlans = findUnsummarizedPlans(
|
||
coverageScan.planFiles,
|
||
coverageScan.summaryFiles,
|
||
);
|
||
if (unsummarizedPlans.length > 0) {
|
||
// Sanitize plan filenames before interpolation: they come raw from
|
||
// readdirSync and could carry C0 control chars / DEL (a committable filename
|
||
// could spoof the terminal in plain-error mode). Strip them so the message is
|
||
// safe to print regardless of --json-errors. Path traversal sequences are not
|
||
// a code-execution vector here (printed only, never reopened from the message).
|
||
const sanitize = (name: string): string => name.replace(/[\u0000-\u001f\u007f]/g, '?');
|
||
const listed = unsummarizedPlans.slice(0, 20).map(sanitize).join(', ');
|
||
const more = unsummarizedPlans.length > 20 ? ` (and ${unsummarizedPlans.length - 20} more)` : '';
|
||
// Audit surface (#2648 review M1): name how many plans were excluded as
|
||
// superseded so a reviewer can see WHICH work was declared retired, not just
|
||
// that some plans are missing summaries. The status: superseded marker is a
|
||
// committable, review-time-trusted bypass; surfacing its count keeps that
|
||
// bypass visible rather than silent.
|
||
const phaseInfoPlanCount = Array.isArray(phaseInfo['plans']) ? (phaseInfo['plans'] as string[]).length : 0;
|
||
const supersededCount =
|
||
coverageScan.planFiles.length === 0 ? 0 : Math.max(0, phaseInfoPlanCount - coverageScan.planFiles.length);
|
||
const supersededNote = supersededCount > 0
|
||
? ` ${supersededCount} plan(s) excluded as status: superseded (retired).`
|
||
: '';
|
||
error(
|
||
`Phase ${phaseNum} cannot be completed: ${unsummarizedPlans.length} plan(s) have no completion record (*-SUMMARY.md): ${listed}${more}.` +
|
||
supersededNote +
|
||
` Execute the plans and write their summaries, or retire a plan with machine-readable \`status: superseded\` frontmatter (#2349) if it was deliberately dropped — a retired plan is excluded from this gate. ` +
|
||
`Completing a phase with unexecuted plans is what lost an entire promised deliverable silently (#2648).`,
|
||
ERROR_REASON.PHASE_PLAN_COVERAGE_INCOMPLETE,
|
||
);
|
||
}
|
||
|
||
try {
|
||
const phaseFiles = fs.readdirSync(phaseFullDir);
|
||
// #3511: scope this advisory pre-scan to THIS phase's own token so a
|
||
// stray, cross-phase, or ad-hoc file cannot name a warning against a
|
||
// phase it does not belong to.
|
||
const phaseFullDirBaseName = path.basename(phaseFullDir);
|
||
|
||
for (const file of scopeToPhase(
|
||
phaseFiles.filter((f) => f.includes('-UAT') && f.endsWith('.md')),
|
||
phaseFullDirBaseName,
|
||
)) {
|
||
const content = fs.readFileSync(path.join(phaseFullDir, file), 'utf-8');
|
||
if (/result: pending/.test(content)) warnings.push(`${file}: has pending tests`);
|
||
if (/result: blocked/.test(content)) warnings.push(`${file}: has blocked tests`);
|
||
if (/status: partial/.test(content)) warnings.push(`${file}: testing incomplete (partial)`);
|
||
if (/status: diagnosed/.test(content)) warnings.push(`${file}: has diagnosed gaps`);
|
||
}
|
||
|
||
for (const file of scopeToPhase(
|
||
phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md')),
|
||
phaseFullDirBaseName,
|
||
)) {
|
||
const verificationFilePath = path.join(phaseFullDir, file);
|
||
// #3707-CR follow-up MINOR: normalize line endings at this read boundary
|
||
// (same fix as src/verification.cts's readVerificationStatus) so a
|
||
// lone-CR VERIFICATION.md's `---\r...\r---` frontmatter fence still
|
||
// matches extractFrontmatter's byte-0 check instead of silently
|
||
// dropping the human_needed/gaps_found advisory warning below.
|
||
const content = normalizeLineEndings(fs.readFileSync(verificationFilePath, 'utf-8'));
|
||
// #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
|
||
// from historical metadata in the file body (e.g. `previous_status: gaps_found`).
|
||
// A full-text regex like /status: gaps_found/ matches the substring inside
|
||
// `previous_status: gaps_found`, producing spurious warnings even when the
|
||
// current frontmatter status is `passed`.
|
||
const verFm = extractFrontmatter(content, verificationFilePath) as Record<string, unknown>;
|
||
// Normalise to lower-case so `status: Passed` (title-case) is not missed.
|
||
const verStatus = typeof verFm['status'] === 'string' ? verFm['status'].trim().toLowerCase() : '';
|
||
if (verStatus === 'human_needed') warnings.push(`${file}: needs human verification`);
|
||
if (verStatus === 'gaps_found') warnings.push(`${file}: has unresolved gaps`);
|
||
}
|
||
} catch {
|
||
/* best-effort (#2245 audit): this is an ADVISORY pre-scan of UAT/
|
||
* VERIFICATION files for `warnings` in the phase-complete output — the
|
||
* actual completion GATE is readVerificationStatus below (a separate
|
||
* mechanism). A readdirSync/readFileSync failure here just means fewer
|
||
* warnings are surfaced this run, not a blocked or corrupted completion. */
|
||
}
|
||
|
||
// #2572: artifact↔disk advisory for the SUMMARYs of the phase being completed.
|
||
//
|
||
// A SUMMARY asserts "I created these files". Nothing checked that claim for
|
||
// phase summaries — the `verify-summary` verb has existed since the beginning
|
||
// but was only ever pointed at `.planning/research/SUMMARY.md`. An interrupted
|
||
// or over-reported phase therefore counted toward 100% silently.
|
||
//
|
||
// Joins the same ADVISORY channel as the pre-scan above: findings land in
|
||
// `warnings[]` (rendered by execute-phase.md's "If has_warnings is true"
|
||
// step), never in the completion GATE (readVerificationStatus below).
|
||
// Completion is never blocked.
|
||
//
|
||
// `checkCommits: false` — only the file-existence half is surfaced here, so
|
||
// the `git cat-file` probes would be spawned and their result discarded. The
|
||
// hash pattern is a loose `\b[0-9a-f]{7,40}\b` that matches any hex-shaped
|
||
// token in prose, too noisy to put in front of a user even as a warning.
|
||
//
|
||
// `Infinity` — report every referenced file, not the CLI verb's default first
|
||
// two, so a phase that lists twelve files and landed three says so. The verb
|
||
// keeps its 2-file default; only this caller opts out of the cap.
|
||
try {
|
||
const phaseDirRel = phaseInfo['directory'] as string;
|
||
// `summaries` arrives pre-sorted from the phase locator, so warning order is
|
||
// deterministic across platforms rather than readdir-dependent.
|
||
const summaryNames = (phaseInfo['summaries'] as string[] | undefined) || [];
|
||
for (const summaryName of summaryNames) {
|
||
const v = verifyMod.verifySummaryCore(
|
||
cwd,
|
||
`${phaseDirRel}/${summaryName}`,
|
||
Infinity,
|
||
{ checkCommits: false },
|
||
);
|
||
const missing = v.checks.files_created.missing;
|
||
if (missing.length > 0) {
|
||
warnings.push(
|
||
`${summaryName}: references ${missing.length} file(s) not on disk: ${missing.join(', ')}`,
|
||
);
|
||
}
|
||
}
|
||
} catch {
|
||
/* best-effort, same posture as the #2245 pre-scan above: an unreadable
|
||
* SUMMARY means one fewer advisory this run, never a blocked completion. */
|
||
}
|
||
|
||
let nextPhaseNum: string | null = null;
|
||
let nextPhaseName: string | null = null;
|
||
let isLastPhase = true;
|
||
|
||
// #3311: typed conflict descriptor surfaced on the result JSON alongside the
|
||
// warnings[] entry below (same parity pattern as
|
||
// verification_stale_check_indeterminate).
|
||
let milestoneConflict: milestoneLockMod.MilestoneConflict | null = null;
|
||
|
||
// #3227: set inside `runPhaseCompleteTransaction` below from
|
||
// `writePlanningFileSet`'s applied-count return — the transaction always
|
||
// RUNS (verification passed, the lock was taken, `writes[]` was built),
|
||
// but a re-run against a phase whose ROADMAP/STATE bytes already reflect
|
||
// completion produces a `writes[]` where every entry is byte-identical to
|
||
// disk, so `writePlanningFileSet` applies none of them. That must not
|
||
// still refresh state.json's `updated_at` (design doc §40 row 26).
|
||
let anyPlanningWrite = false;
|
||
|
||
const verificationBlocked = withPlanningLock(cwd, () => {
|
||
// #3311: completing a phase while a live milestone claim (phase + session)
|
||
// holds a DIFFERENT phase means two sessions are working two phases against
|
||
// the single Current Position slot. Warn via the established warnings[]
|
||
// channel (rendered by execute-phase.md's "If has_warnings is true" step)
|
||
// rather than blocking — the claim may simply be stale-but-live.
|
||
milestoneConflict = milestoneLockMod.checkMilestoneConflictForPhase(cwd, phaseNum);
|
||
if (milestoneConflict) {
|
||
const holder = milestoneConflict.locked_session ?? 'an unknown (headless) session';
|
||
const actor = milestoneConflict.session ?? 'an unknown (headless) session';
|
||
warnings.push(
|
||
`milestone lock conflict (#3311): ${holder} holds the milestone claim for phase ` +
|
||
`${milestoneConflict.locked_phase}, but ${actor} is completing phase ${phaseNum} — ` +
|
||
`STATE.md's Current Position is a single slot; verify it before trusting it`,
|
||
);
|
||
milestoneLockMod.warnMilestoneConflict(milestoneConflict, `phase.complete ${phaseNum}`);
|
||
}
|
||
// #2617: pass the project's runtime so the blocked-completion error below
|
||
// suggests the command surface this runtime actually installs
|
||
// ($gsd-… on Codex) rather than a hard-coded Claude-style string.
|
||
const verificationStatus = readVerificationStatus(phaseFullDir, {
|
||
runtime: resolveRuntime(cwd),
|
||
convention: resolvePhaseIdConvention(cwd),
|
||
});
|
||
// #3057 B3: the staleness check inside readVerificationStatus can itself
|
||
// fail (fs / scanPhasePlans / clock error), in which case `status` above
|
||
// was routed as if nothing were stale (unchanged fail-open routing) — but
|
||
// that must not be silently identical to a check that actually ran and
|
||
// found nothing stale. Join the SAME advisory channel the UAT/VERIFICATION
|
||
// pre-scan above already uses (`warnings[]`, rendered by execute-phase.md's
|
||
// "If has_warnings is true" step) rather than inventing a new one. This
|
||
// only fires on the non-blocking path (status resolves to 'passed' despite
|
||
// the indeterminate check) — the blocked path below carries its own note.
|
||
if (verificationStatus.staleCheckIndeterminate) {
|
||
staleCheckIndeterminate = true;
|
||
warnings.push(
|
||
`verification staleness check could not complete for phase ${phaseNum} — routed as not-stale, but this was not actually verified (#3057)`,
|
||
);
|
||
}
|
||
if (verificationStatus.status !== 'passed') {
|
||
return verificationStatus;
|
||
}
|
||
|
||
const runPhaseCompleteTransaction = () => {
|
||
const writes: WriteSpec[] = [];
|
||
let roadmapContent: string | null = null;
|
||
|
||
if (fs.existsSync(roadmapPath)) {
|
||
const originalRoadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||
roadmapContent = originalRoadmapContent;
|
||
|
||
const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
|
||
// #2067: the gap between `]` and `Phase N` must allow only whitespace /
|
||
// markdown bold emphasis — NOT greedy `.*`. A greedy gap matched a later
|
||
// phase whose description merely mentioned the completed phase number,
|
||
// so completing an already-checked phase (idempotent re-run) checked the
|
||
// wrong phase's box. Mirrors the tight pattern used by phase-insert
|
||
// (`]\\s*(?:\\*\\*)?Phase`).
|
||
// #2067/#2200: line-anchored (^, optional leading indent) so an
|
||
// inline / backticked prose literal cannot match. Milestone-scoped below
|
||
// (mutateMilestonePhase) so a Backlog entry or a same-numbered shipped-
|
||
// milestone phase cannot be flipped either.
|
||
// ADR-2143 §4 note / #2245 audit: this is the phase-LIST checkbox — it
|
||
// lives in the milestone's `- [ ] Phase N: …` checklist, OUTSIDE any
|
||
// `### Phase N` detail section, so there is no section for
|
||
// withPhaseSection to bind to. Migrated onto the sectionizer's
|
||
// `updateBullet` bullet-write seam: the pattern itself is unchanged,
|
||
// only the "find the right line, splice it back" plumbing moved off a
|
||
// whole-slice `.replace()` onto the seam. Applied per single physical
|
||
// line by updateBullet, so the pattern no longer needs the `m` flag
|
||
// (it never sees more than one line at a time); see
|
||
// planCountBodyPattern below for the sites that were migrated onto
|
||
// withPhaseSection instead.
|
||
//
|
||
// #2245 review Fix 6: this is behaviour-preserving for GSD-GENERATED
|
||
// inputs (the only shape ROADMAP.md ever actually has), NOT byte-parity
|
||
// across every conceivable input. `updateBullet` is fence-aware — a
|
||
// checkbox-shaped line inside a fenced (``` / ~~~) code block is never
|
||
// offered to `match`/`transform` — whereas the retired whole-slice
|
||
// `.replace()` had no such fence tracking and would have flipped a
|
||
// bullet-shaped line inside a fence too. That divergence has no live
|
||
// bug because a GSD-authored ROADMAP.md milestone checklist never puts
|
||
// its own `- [ ] Phase N: …` entries inside a fenced code block, but it
|
||
// is a real (and correct) behavioural difference on pathological input.
|
||
const checkboxPattern = new RegExp(
|
||
`^[ \\t]*(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`,
|
||
'i',
|
||
);
|
||
|
||
// Progress table row: update Plans Complete/Status/Completed columns BY
|
||
// COLUMN NAME (handles 4- or 5-column RoadmapProgress tables) via the
|
||
// markdown-table seam (ADR-2143 §7) — supersedes the prior ordinal
|
||
// cells[]-index regex. Applied inside mutateMilestonePhase below (per
|
||
// milestone window), further scoped to the ## Progress heading within
|
||
// that window so the row lookup doesn't bind to an earlier table (e.g.
|
||
// | Phase | Requirements | Count |) whose rows also start with the
|
||
// phase number (#2012).
|
||
// #2245 Blocker 4: optional dot must be followed by whitespace-or-end,
|
||
// not dot-OR-whitespace-OR-end as alternatives — the prior form let a
|
||
// bare "." satisfy the whole lookahead, so completing phase "2"
|
||
// over-matched a decimal sub-phase row like "2.5 Extra". Matches "2",
|
||
// "2.", "2 Alpha"; rejects "2.5 Extra".
|
||
const phaseCellRe = new RegExp(`^${phaseEscaped}\\.?(?:\\s|$)`, 'i');
|
||
const rowMatch = (row: Record<string, string>): boolean => phaseCellRe.test((row['Phase'] ?? '').trim());
|
||
const dateShape = /^\d{4}-\d{2}-\d{2}$/;
|
||
|
||
/**
|
||
* Within `text` (already scoped to one milestone window by the
|
||
* caller), scope further to the `## Progress` heading section (up to
|
||
* the next `#`/`##` heading) when present, run `edit` against just
|
||
* that slice, and splice the result back — falling back to the whole
|
||
* `text` when no `## Progress` heading exists (mirrors phase-
|
||
* lifecycle.cjs's deriveProgressFromRoadmap read-side scoping).
|
||
*/
|
||
const editProgressHeadingSlice = (text: string, edit: (scoped: string) => string): string => {
|
||
const progressMatch = text.match(/^##[ \t]+Progress\b/im);
|
||
if (!progressMatch || progressMatch.index === undefined) {
|
||
return edit(text);
|
||
}
|
||
const headingOffset = progressMatch.index;
|
||
const beforeHeading = text.slice(0, headingOffset);
|
||
const fromHeading = text.slice(headingOffset);
|
||
const nextHeading = fromHeading.search(/\n#{1,2}[ \t]/);
|
||
const scoped = nextHeading >= 0 ? fromHeading.slice(0, nextHeading) : fromHeading;
|
||
const after = nextHeading >= 0 ? fromHeading.slice(nextHeading) : '';
|
||
return beforeHeading + edit(scoped) + after;
|
||
};
|
||
|
||
// ADR-2143 §4: the plan-count write is now routed through
|
||
// withPhaseSection (see mutateMilestonePhase below), which hands this
|
||
// pattern ONLY phase N's own detail-section body — so the pattern no
|
||
// longer needs its own `#{2,4}\s*Phase\s+N` anchor + skip-ahead-past-
|
||
// interior-headings lookahead; the section boundary itself confines
|
||
// the match (the #2067/#2200 boundary-crossing class is now
|
||
// structurally impossible for this site rather than regex-enforced).
|
||
const planCountBodyPattern = /(\*\*Plans:\*\*\s*)[^\n]+/i;
|
||
|
||
const phaseInfoSummaries = phaseInfo['summaries'] as string[];
|
||
|
||
// #2200: apply the phase-checkbox flip, the plan-count write, and the
|
||
// per-plan checkbox flips ONLY within the current milestone's region(s)
|
||
// (primary section + optional Phase Details section). A bullet/heading in
|
||
// a shipped milestone, a Backlog section, or a backticked prose literal is
|
||
// outside the window and stays untouched. With no versioned active
|
||
// milestone, fall back to whole-content mutation (prior behaviour).
|
||
const mutateMilestonePhase = (slice: string): string => {
|
||
let s = slice;
|
||
s = updateBullet(
|
||
s,
|
||
(_bulletText, rawLine) => checkboxPattern.test(rawLine),
|
||
(rawLine) => rawLine.replace(checkboxPattern, `$1x$2 (completed ${today})`),
|
||
);
|
||
|
||
s = editProgressHeadingSlice(s, (scoped) => {
|
||
let text = scoped;
|
||
|
||
const plansResult = updateTableCell(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
|
||
if (plansResult.ok) text = plansResult.value;
|
||
|
||
const statusResult = updateTableCell(text, rowMatch, 'Status', ' Complete ');
|
||
if (statusResult.ok) text = statusResult.value;
|
||
|
||
// Preserve only a valid ISO date (#1161: idempotent; self-heal
|
||
// garbage). Ragged-tolerant (#2245 Blocker 2): decide via the
|
||
// CURRENT Completed cell inside a single updateTableCell callback
|
||
// (its own tolerant row scan) rather than gating on
|
||
// findTableWithColumns (which requires the WHOLE table to parse —
|
||
// a ragged SIBLING row elsewhere used to silently no-op this
|
||
// row's date stamp too).
|
||
const completedResult = updateTableCell(text, rowMatch, 'Completed', (current) =>
|
||
dateShape.test(current.trim()) ? current : ` ${today} `);
|
||
if (completedResult.ok) text = completedResult.value;
|
||
|
||
return text;
|
||
});
|
||
|
||
// ADR-2143 §4: the plan-count write and the per-plan checkbox flips
|
||
// are both scoped to phase N's OWN detail section via
|
||
// withPhaseSection — the edit callback below only ever sees that
|
||
// section's body, so neither regex can escape into a sibling
|
||
// phase's section, a shipped milestone, or a Backlog entry.
|
||
s = withPhaseSection(s, phaseNum, (body) => {
|
||
let b = body.replace(planCountBodyPattern, `$1${summaryCount}/${planCount} plans complete`);
|
||
for (const summaryFile of phaseInfoSummaries) {
|
||
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||
if (!planId) continue;
|
||
const planEscaped = escapeRegex(planId);
|
||
const planCheckboxPattern = new RegExp(
|
||
`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
|
||
'i',
|
||
);
|
||
b = b.replace(planCheckboxPattern, '$1x$2');
|
||
}
|
||
return b;
|
||
});
|
||
return s;
|
||
};
|
||
|
||
const milestoneRanges = currentMilestoneRawRanges(roadmapContent, cwd);
|
||
if (milestoneRanges) {
|
||
// Splice later windows first so an earlier window's offsets are not
|
||
// shifted by a length-changing mutation in a later window.
|
||
const windows = [milestoneRanges.details, milestoneRanges.primary]
|
||
.filter((w): w is { start: number; end: number } => w !== null)
|
||
.sort((a, b) => b.start - a.start);
|
||
for (const w of windows) {
|
||
roadmapContent =
|
||
roadmapContent.slice(0, w.start)
|
||
+ mutateMilestonePhase(roadmapContent.slice(w.start, w.end))
|
||
+ roadmapContent.slice(w.end);
|
||
}
|
||
} else {
|
||
roadmapContent = mutateMilestonePhase(roadmapContent);
|
||
}
|
||
|
||
writes.push({
|
||
filePath: roadmapPath,
|
||
before: originalRoadmapContent,
|
||
after: roadmapContent,
|
||
});
|
||
// #3685 / #3691: normalize both sides before comparing — see
|
||
// contentChangedAfterNormalize's doc (shell-command-projection.cts).
|
||
// A raw `!==` here false-positives whenever this phase-complete
|
||
// roadmap mutation regenerates a section in a different-but-
|
||
// equivalent raw shape than the already-normalized on-disk original.
|
||
roadmapUpdated = contentChangedAfterNormalize(roadmapPath, originalRoadmapContent, roadmapContent);
|
||
|
||
const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md');
|
||
if (fs.existsSync(reqPath)) {
|
||
const phaseEsc = phaseMarkdownRegexSource(phaseNum);
|
||
const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
|
||
const phaseSectionMatch = currentMilestoneRoadmap.match(
|
||
new RegExp(
|
||
`(#{2,4}\\s*Phase\\s+${phaseEsc}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`,
|
||
'i',
|
||
),
|
||
);
|
||
|
||
const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
|
||
const reqMatch = sectionText.match(
|
||
/\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]+)/i,
|
||
);
|
||
|
||
const originalReqContent = fs.readFileSync(reqPath, 'utf-8');
|
||
let reqContent = originalReqContent;
|
||
|
||
// #2316: `citedReqIds` — the REQ-IDs ROADMAP's own **Requirements:**
|
||
// line for this phase actually cites — is hoisted out of the
|
||
// `if (reqMatch)` block (previously scoped only inside it) so the
|
||
// ghost-ID cross-check below (~#2316-1) can consult it. `TBD` is the
|
||
// literal placeholder `phase.add`/`-batch`/`-insert` seed
|
||
// (`**Requirements**: TBD`, src/phase.cts:833,920,1078) — never a
|
||
// real REQ-ID, so it is filtered out wherever a cited-ID list feeds
|
||
// a warning (#2316-7 boundary).
|
||
const isPlaceholderReqId = (id: string): boolean => id.toUpperCase() === 'TBD';
|
||
let citedReqIds: string[] = [];
|
||
// #2316-1: Traceability-row writes that matched NO row (ghost or
|
||
// otherwise) — the `if (reqUpdate.ok)` below previously had no
|
||
// `else`, discarding this fact silently instead of surfacing it.
|
||
const traceabilityWriteMisses: string[] = [];
|
||
|
||
if (reqMatch) {
|
||
// #2334 HIGH 3 + #3697: selection and under-selection detection both
|
||
// live in `analyzeRequirementsLine` (module scope, above), extracted in
|
||
// round 3 so the parser is directly testable — a closure in here is
|
||
// reachable only by spawning the CLI, which no fast-check property test
|
||
// can do. `citedReqIds` is byte-identical to the expression that stood
|
||
// here; nothing about what phase-complete MARKS has changed.
|
||
const reqLineAnalysis = analyzeRequirementsLine(reqMatch[1]);
|
||
citedReqIds = reqLineAnalysis.citedReqIds;
|
||
const reqLineWarning = formatRequirementsLineWarning(
|
||
phaseNum,
|
||
reqMatch[1],
|
||
reqLineAnalysis,
|
||
);
|
||
if (reqLineWarning) {
|
||
warnings.push(reqLineWarning.message);
|
||
// Carried out to the JSON result as its own field — see
|
||
// REQ_LINE_WARNING_CODE for why it is not folded into
|
||
// `warnings[]`.
|
||
reqLineWarningCode = reqLineWarning.code;
|
||
}
|
||
|
||
for (const reqId of citedReqIds) {
|
||
const reqEscaped = escapeRegex(reqId);
|
||
// Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**.
|
||
// #2945: the flip is CONDITIONAL (porting #2788 defect-2's rollback from
|
||
// cmdRequirementsMarkComplete). Capture the pre-flip content; if a
|
||
// traceability row EXISTS for this ID below but its Status write is rejected
|
||
// (Out/Deferred/Blocked), the checkbox is rolled back so the two surfaces
|
||
// cannot silently diverge. A requirement recorded as deferred must not read
|
||
// as shipped.
|
||
const checkboxRe = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
|
||
const beforeCheckbox = reqContent;
|
||
reqContent = reqContent.replace(checkboxRe, '$1x$2');
|
||
const checkboxFlipped = reqContent !== beforeCheckbox;
|
||
|
||
// Traceability row: | <REQ-ID> | Phase N | Pending|In Progress | ->
|
||
// ... Complete | via the markdown-table seam (ADR-2143 §7). Match the
|
||
// row by its FIRST cell's value (the requirement-ID column) regardless
|
||
// of that column's HEADER name — real tables head it `REQ-ID`, others
|
||
// `Requirement` (#2769/#2203); this mirrors the prior regex's first-cell
|
||
// `\|\s*<id>\s*\|` anchor, not a by-name lookup. Object.values(row) is in
|
||
// header order, so [0] is the first column. Case-insensitive.
|
||
const reqRowMatch = (row: Record<string, string>): boolean =>
|
||
(Object.values(row)[0] ?? '').trim().toLowerCase() === reqId.toLowerCase();
|
||
// Ragged-tolerant (#2245 Blocker 2): drive the write purely off
|
||
// updateTableCell's own tolerant row scan — a DIFFERENT
|
||
// requirement's row elsewhere in the same table having a
|
||
// mismatched cell count must never silently no-op THIS
|
||
// requirement's write. The "only flip Pending/In Progress ->
|
||
// Complete" gate is folded into the newValue callback so one
|
||
// updateTableCell call both probes and writes.
|
||
// #2945: track tableHit (did the callback actually CHANGE the value?) so the
|
||
// checkbox rollback below can distinguish "row existed and accepted" from
|
||
// "row existed and rejected".
|
||
let tableHit = false;
|
||
const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => {
|
||
// #2788: accept `Gaps Found` too so a phase stranded by revert-phase (the
|
||
// gaps_found response) can complete without hand-editing the table.
|
||
if (/^(?:pending|in progress|gaps found)$/i.test(current.trim())) {
|
||
tableHit = true;
|
||
return ' Complete ';
|
||
}
|
||
return current;
|
||
});
|
||
if (reqUpdate.ok) {
|
||
reqContent = reqUpdate.value;
|
||
} else if (!isPlaceholderReqId(reqId)) {
|
||
traceabilityWriteMisses.push(reqId);
|
||
}
|
||
|
||
// #2945 defect-2 (port of milestone.cts:200-210): if a row EXISTS for this
|
||
// ID but its Status write was rejected (row reads Out/Deferred/Blocked,
|
||
// which the callback returned unchanged), roll the checkbox back so the
|
||
// checkbox and the row cannot silently diverge. reqUpdate.ok === a row
|
||
// matched (existence probe); !tableHit === the callback did not advance it.
|
||
if (checkboxFlipped && reqUpdate.ok && !tableHit) {
|
||
reqContent = beforeCheckbox;
|
||
}
|
||
}
|
||
}
|
||
|
||
// #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
|
||
// Requirements under headings whose text contains "deferred", "backlog",
|
||
// "future", or an OFF-milestone `v<N>` (case-insensitive) are explicitly
|
||
// out of current scope and must not be flagged as missing from the
|
||
// Traceability table.
|
||
//
|
||
// Strategy: walk lines, track heading depth, and toggle a "deferred" flag
|
||
// when a heading matching the pattern is encountered. A sub-heading (higher
|
||
// depth) that is ITSELF in a deferred parent remains deferred unless it
|
||
// opens a same-or-shallower heading that does NOT match the pattern.
|
||
// Lines inside fenced code blocks (``` or ~~~) are treated as content, not
|
||
// headings, to avoid false deferred-section detection from code examples.
|
||
//
|
||
// #2334 BLOCKER fix (regresses closed bug #1159 against GSD's OWN
|
||
// shipped template): #2316-4a dropped the bare `v\d+` alternative
|
||
// entirely to stop it over-matching an ACTIVE heading like "## v1
|
||
// Requirements" — but the shipped `templates/requirements.md:35`
|
||
// scaffold ships `## v2 Requirements` / "Deferred to future release"
|
||
// as its ONLY deferred marker, and `v\d+` was the ONLY alternative
|
||
// that ever matched a bare version heading (the deferred-ness lives
|
||
// in body prose, not the heading text). Dropping it regressed #1159
|
||
// for every project scaffolded from the shipped template.
|
||
//
|
||
// Fix: make the `v<N>` alternative MILESTONE-AWARE instead of
|
||
// deleting it. A `## v<N> ...` heading is deferred ONLY when `<N>`
|
||
// (MAJOR version only — "v1" vs milestone "v1.3" is the SAME major
|
||
// version) does not match the CURRENT milestone's major version,
|
||
// resolved via `stateExtractField` against STATE.md's `milestone:`
|
||
// frontmatter field (the same seam `getMilestoneInfo`/state.cts's
|
||
// frontmatter builder already use — no bespoke frontmatter parsing).
|
||
// "## v1 Requirements" while the milestone is v1.x is the ACTIVE
|
||
// milestone's own section (#2316's original ask) and must NOT be
|
||
// swallowed; "## v2 Requirements" while the milestone is v1.x is a
|
||
// genuinely future milestone (#1159's ask, and the literal shipped-
|
||
// template shape) and MUST stay suppressed. `deferred`/`backlog`/
|
||
// `future` are unaffected by milestone resolution — a genuinely
|
||
// deferred heading always spells one of those words too (see
|
||
// #2316-5 regression guard: "## Deferred v2 Requirements", "##
|
||
// Future Backlog", "## Deferred", "## Backlog", "## Future").
|
||
//
|
||
// Fail-safe: when the milestone version cannot be resolved at all
|
||
// (no STATE.md, or no `milestone:` field), fall back to the OLD
|
||
// pre-#2316-4a behavior and treat every `v\d+` heading as deferred.
|
||
// A false "deferred" here only ever SUPPRESSES a warning — strictly
|
||
// safer than spamming a warning on every v\d+-headed scaffold when
|
||
// we cannot tell whether it names the active milestone.
|
||
const DEFERRED_KEYWORD_RE = /\b(?:deferred|backlog|future)\b/i;
|
||
const HEADING_VERSION_RE = /\bv(\d+)(?:\.\d+)*\b/i;
|
||
const stateRawForMilestone = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : null;
|
||
const currentMilestoneRaw = stateRawForMilestone
|
||
? stateExtractField(stateRawForMilestone, 'milestone')
|
||
: null;
|
||
const currentMilestoneMajor = currentMilestoneRaw ? extractMajorVersion(currentMilestoneRaw) : null;
|
||
const bodyReqIds: string[] = [];
|
||
// deferredDepth: the heading level that opened the current deferred block,
|
||
// or 0 when we are in an active section.
|
||
let deferredDepth = 0;
|
||
let inFence = false;
|
||
for (const line of reqContent.split(/\r?\n/)) {
|
||
// Track fenced code blocks (``` or ~~~).
|
||
if (/^\s*(?:```|~~~)/.test(line)) {
|
||
inFence = !inFence;
|
||
continue;
|
||
}
|
||
if (inFence) continue; // ignore content inside a code fence
|
||
|
||
const headingM = line.match(/^(#{1,6})\s+(.*)/);
|
||
if (headingM) {
|
||
const depth = headingM[1].length;
|
||
const text = headingM[2];
|
||
if (deferredDepth > 0 && depth > deferredDepth) {
|
||
// Sub-heading inside a deferred block: stays deferred regardless of name.
|
||
continue;
|
||
}
|
||
// Heading at same level or shallower than current deferred opener,
|
||
// or no active deferred block yet.
|
||
if (DEFERRED_KEYWORD_RE.test(text)) {
|
||
deferredDepth = depth; // enter a deferred block
|
||
} else {
|
||
const versionMatch = text.match(HEADING_VERSION_RE);
|
||
if (versionMatch) {
|
||
const headingMajor = versionMatch[1];
|
||
deferredDepth =
|
||
currentMilestoneMajor === null || headingMajor !== currentMilestoneMajor
|
||
? depth // unresolved milestone (fail-safe) or off-milestone version -> deferred
|
||
: 0; // same major version as the current milestone -> active
|
||
} else {
|
||
deferredDepth = 0; // back in an active section
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (deferredDepth > 0) continue; // skip content in deferred sections
|
||
|
||
// Collect bold REQ-ID patterns from active-section lines.
|
||
const reqPat = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g;
|
||
let bodyMatch: RegExpExecArray | null;
|
||
while ((bodyMatch = reqPat.exec(line)) !== null) {
|
||
const id = bodyMatch[1];
|
||
if (!bodyReqIds.includes(id)) bodyReqIds.push(id);
|
||
}
|
||
}
|
||
|
||
const traceabilityHeadingMatch = reqContent.match(/^#{1,6}\s+Traceability\b/im);
|
||
const traceabilitySection = traceabilityHeadingMatch
|
||
? reqContent.slice(traceabilityHeadingMatch.index)
|
||
: '';
|
||
const tableReqIds = new Set<string>();
|
||
// #2203: match REQ-IDs in any pipe-delimited cell (not just the first
|
||
// column) so a traceability table that leads with a status column (e.g.
|
||
// | ☐ | REQ-01 | …) is parsed correctly instead of reporting every row
|
||
// as missing.
|
||
const tableRowPat = /\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/g;
|
||
let tableMatch: RegExpExecArray | null;
|
||
while ((tableMatch = tableRowPat.exec(traceabilitySection)) !== null) {
|
||
tableReqIds.add(tableMatch[1]);
|
||
}
|
||
|
||
const unregistered = bodyReqIds.filter((id) => !tableReqIds.has(id));
|
||
if (unregistered.length > 0) {
|
||
warnings.push(
|
||
`REQUIREMENTS.md: ${unregistered.length} REQ-ID(s) found in body but missing from Traceability table: ${unregistered.join(', ')} — add them manually to keep traceability in sync`,
|
||
);
|
||
}
|
||
|
||
// #2316-1: ghost REQ-IDs — cited by ROADMAP's own **Requirements:**
|
||
// line for this phase, but registered NOWHERE in REQUIREMENTS.md
|
||
// (neither its body nor its Traceability table). The `unregistered`
|
||
// check above only ever compares REQUIREMENTS.md's own body against
|
||
// its own Traceability table; it never consults `citedReqIds`, so an
|
||
// ID that ROADMAP cites but REQUIREMENTS.md never defines at all was
|
||
// previously invisible to every guard. `TBD` (the phase.add/-batch/
|
||
// -insert placeholder) is excluded — see #2316-7 boundary.
|
||
//
|
||
// #2334 HIGH 2: classify "ghost" by PROBING THE ACTUAL WRITE
|
||
// SURFACES this same function just wrote to (:1947 checkbox,
|
||
// :1967 Traceability row) — case-insensitively — mirroring
|
||
// milestone.cts's `notFound`/`hasRow`/`doneCheckbox` classification
|
||
// (src/milestone.cts:117-141,209-215), instead of set-differencing
|
||
// `bodyReqIds` (deferred-filtered, case-sensitive, bold-only) and
|
||
// `tableReqIds` (case-sensitive) against `citedReqIds`. Those two
|
||
// indexes can disagree with the writes: an ID under a `##
|
||
// Deferred` heading gets its checkbox ticked by the write loop
|
||
// above but is deliberately EXCLUDED from `bodyReqIds` by the
|
||
// deferred-heading filter (#1159), so the old set-diff reported it
|
||
// as an unregistered ghost in the SAME response that just ticked
|
||
// its checkbox; a case-mismatched citation (`known-01` vs
|
||
// `**KNOWN-01**`) lands its write via the writes' case-insensitive
|
||
// regexes but failed the old set-diff's case-SENSITIVE
|
||
// `Array.includes`/`Set.has`. An ID whose checkbox OR Traceability
|
||
// row actually matched is registered — not a ghost — regardless of
|
||
// which section (deferred or not) it lives under.
|
||
const reqIsRegisteredAnywhere = (id: string): boolean => {
|
||
const reqEscaped = escapeRegex(id);
|
||
// Surface 1 — checkbox, EITHER state (`[ ]` or `[x]`), case-
|
||
// insensitive: existence check, not the write's space-only match.
|
||
if (new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent)) {
|
||
return true;
|
||
}
|
||
// Surface 2 — Traceability row exists at all (any Status value),
|
||
// via the SAME no-op-probe-through-updateTraceabilityCell
|
||
// technique milestone.cts's `hasRow` uses (:210-214): a case-
|
||
// insensitive first-cell match, regardless of current Status.
|
||
const rowProbeMatch = (row: Record<string, string>): boolean =>
|
||
(Object.values(row)[0] ?? '').trim().toLowerCase() === id.toLowerCase();
|
||
return updateTraceabilityCell(reqContent, rowProbeMatch, 'Status', (current) => current).ok;
|
||
};
|
||
const ghostReqIds = citedReqIds.filter(
|
||
(id) => !isPlaceholderReqId(id) && !reqIsRegisteredAnywhere(id),
|
||
);
|
||
if (ghostReqIds.length > 0) {
|
||
warnings.push(
|
||
`ROADMAP Phase ${phaseNum} cites REQ-ID(s) not registered anywhere in REQUIREMENTS.md (neither body nor Traceability table): ${ghostReqIds.join(', ')} — add them to REQUIREMENTS.md or correct the ROADMAP citation`,
|
||
);
|
||
}
|
||
|
||
// #2316-1 cont.: a cited ID whose Traceability-row write matched no
|
||
// row for a reason OTHER than being a ghost (e.g. a malformed table)
|
||
// still deserves a warning instead of a silent discard — but skip
|
||
// IDs already reported above as ghosts to avoid a duplicate message
|
||
// for the same root cause.
|
||
const traceabilityWriteFailures = traceabilityWriteMisses.filter(
|
||
(id) => !ghostReqIds.includes(id),
|
||
);
|
||
if (traceabilityWriteFailures.length > 0) {
|
||
warnings.push(
|
||
`REQUIREMENTS.md: Traceability row write skipped for REQ-ID(s) cited by ROADMAP (no matching row found): ${traceabilityWriteFailures.join(', ')}`,
|
||
);
|
||
}
|
||
|
||
writes.push({ filePath: reqPath, before: originalReqContent, after: reqContent });
|
||
// #2316-3: `requirements_updated` must reflect whether REQUIREMENTS.md
|
||
// content actually CHANGED, not merely that the file existed in the
|
||
// transaction — mirrors the `writes.push({filePath,before,after})`
|
||
// diff-tracking pattern used for the ROADMAP write above. A phase
|
||
// whose citations match nothing (ghost REQ-IDs only) must report
|
||
// `false`, not a bare "the file was present" `true`.
|
||
// #3685 / #3691: normalize both sides before comparing — same
|
||
// false-positive shape as the sibling roadmapUpdated/stateUpdated
|
||
// flags in this same transaction; all three must agree by
|
||
// construction (see contentChangedAfterNormalize's doc).
|
||
requirementsUpdated = contentChangedAfterNormalize(reqPath, originalReqContent, reqContent);
|
||
}
|
||
}
|
||
|
||
// #3701 — the ROADMAP decides WHICH phase is next; the disk decides only HOW it
|
||
// is spelled. Both scans select the numerically lowest phase above N.
|
||
//
|
||
// Both scans below are unchanged in what they match; what changed is that
|
||
// the roadmap is no longer gated behind "the disk found nothing". It used
|
||
// to be (`if (isLastPhase && roadmapContent !== null)`), which made a wrong
|
||
// disk answer uncorrectable: phase directories are created lazily, but
|
||
// `phase insert` scaffolds an inserted phase's directory immediately, so an
|
||
// inserted decimal is routinely the ONLY directory above N and outranked
|
||
// every phase preceding it in the roadmap. Observed: roadmap `1, 2, 02.1,
|
||
// 3` with directories for 01 and 02.1 only reported `next_phase: "02.1"`
|
||
// after completing 1 — and PERSISTED it to STATE.md — while
|
||
// `roadmap.analyze` correctly said `2`.
|
||
//
|
||
// #3581 fixed exactly this at `init.progress` and named the rule: "the
|
||
// frontier is ROADMAP ORDER, not artifact presence". This call site was not
|
||
// in that change's scope.
|
||
//
|
||
// Why the disk scan survives, rather than being replaced:
|
||
// 1. It is the only resolver when there is no ROADMAP.md, or when its
|
||
// phase rows do not parse.
|
||
// 2. When both agree, it carries the SPELLING the output has always used
|
||
// — the zero-padded directory token and the on-disk slug (`02`/`beta`),
|
||
// where the roadmap would give `2` and a slugified title. Promoting the
|
||
// roadmap without this would silently change the reported value on
|
||
// every aligned project, which is the majority case.
|
||
let diskNextNum: string | null = null;
|
||
let diskNextName: string | null = null;
|
||
let roadmapNextNum: string | null = null;
|
||
let roadmapNextName: string | null = null;
|
||
|
||
try {
|
||
// #3185 (ADR-3180 Decision 1): "which phase directories belong to
|
||
// the CURRENT milestone" — routed through the canonical owner
|
||
// instead of a hand-rolled readdirSync + isDirInMilestone filter
|
||
// (which also never excluded sentinels on its own, unlike the
|
||
// owner; the per-directory isSentinelPhaseId check below stays as a
|
||
// defensive second check against the REGEX-EXTRACTED token, which
|
||
// is not necessarily identical to the raw directory name).
|
||
const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
|
||
|
||
for (const dir of dirs) {
|
||
const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
|
||
if (dm) {
|
||
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
||
if (isSentinelPhaseId(dm[1])) continue;
|
||
// Numeric MINIMUM above N, not "first encountered". `listMilestonePhaseDirs`
|
||
// does sort by `comparePhaseNum`, so a `break` on the first hit happens to be
|
||
// correct today — but that makes this scan's correctness depend on an
|
||
// upstream sort nothing here states. Selecting the minimum explicitly costs
|
||
// one comparison and removes the hidden coupling.
|
||
if (comparePhaseNum(dm[1], phaseNum) > 0
|
||
&& (diskNextNum === null || comparePhaseNum(dm[1], diskNextNum) < 0)) {
|
||
diskNextNum = dm[1];
|
||
diskNextName = dm[2] || null;
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
/* best-effort (#2245 audit): stage 1 of a deliberate 3-stage
|
||
* cascading fallback for locating the next phase (disk dirs → roadmap
|
||
* headings/checkboxes → lowest-outstanding-checkbox override, #2028
|
||
* below). A disk-scan failure here is indistinguishable from "found
|
||
* nothing on disk" and correctly falls through to stage 2, which
|
||
* derives the same information independently from ROADMAP.md content
|
||
* — not a silent data-loss path. */
|
||
}
|
||
|
||
if (roadmapContent !== null) {
|
||
try {
|
||
const roadmapForPhases = extractCurrentMilestone(roadmapContent, cwd);
|
||
// #1591: match BOTH heading-style phases (`### Phase N:`) AND
|
||
// checkbox-list items, INCLUDING the canonical bold form the roadmap
|
||
// template emits (`- [ ] **Phase N: Name**`). When the active
|
||
// milestone's checklist is `- [ ]` items inside a <details> block
|
||
// (and the next phase has no directory yet, so the disk-based
|
||
// resolver finds nothing), this roadmap-enumeration fallback is the
|
||
// only path that can find the next phase. The prior heading-only
|
||
// pattern missed checkbox items, and a checkbox-only broadening still
|
||
// missed the bold template rows → is_last_phase=true on a mid-milestone
|
||
// phase. Allow optional `**`/`__` emphasis after the marker and stop
|
||
// the name capture at emphasis so bold names slug cleanly; the number
|
||
// capture is unchanged.
|
||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` after the number tolerates a pre-colon
|
||
// ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE) so
|
||
// `### Phase N (Cluster B): X` resolves. Captures are unchanged.
|
||
//
|
||
// #4078: the checkbox branch's separator is no longer colon-only. The
|
||
// canonical phase lookup has accepted the bullet-house dash grammar
|
||
// (`- [ ] **Phase N — Name**`, em/en-dash/hyphen/colon) since #2199
|
||
// (`BULLET_PHASE_LINE_PATTERN`, roadmap-parser.cjs), but this scan still
|
||
// required `:`, so on a roadmap whose original rows use the dash grammar
|
||
// the ONLY parseable row above N was typically a later phase.add-ingested
|
||
// colon-form phase — positionally last — and it won the numeric-minimum
|
||
// vote it should never have been alone in (observed: 18 of 18 selected,
|
||
// phases 2–17 skipped). The heading branch stays colon-only, mirroring
|
||
// `findRoadmapPhaseInContent`'s heading grammar exactly; only the
|
||
// checkbox branch widens, and only to the separators #2199 already
|
||
// accepts. The two branches keep separate capture groups, normalized
|
||
// just below the loop.
|
||
const phasePattern = new RegExp(
|
||
`(?:#{2,4}\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)` +
|
||
`|-\\s*\\[[ xX]\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+))`,
|
||
'gi'
|
||
);
|
||
let pm: RegExpExecArray | null;
|
||
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
|
||
// #4078: normalize the two alternation branches' captures (heading
|
||
// branch → groups 1/2, widened checkbox branch → groups 3/4).
|
||
const pmNum = pm[1] ?? pm[3];
|
||
const pmName = pm[2] ?? pm[4];
|
||
// #2786: skip sentinel phase ids (999.x backlog, 0.x drafts) — stage 1
|
||
// already skips sentinel dirs on disk via isSentinelPhaseId (#3185);
|
||
// stage 2's heading scan must not advance into backlog headings either.
|
||
if (isSentinelPhaseId(pmNum)) continue;
|
||
// #3701 review: the numeric MINIMUM above N, not the first row above N in
|
||
// DOCUMENT order. This scan walks raw roadmap text, and one global regex
|
||
// sweeps both the `## Phases` checklist and the `## Phase Details`
|
||
// headings, so "first match" is a statement about where a line sits in the
|
||
// file — not about which phase comes next.
|
||
//
|
||
// It mattered only once this scan started deciding the answer. Before, it
|
||
// ran solely when the disk scan found nothing; now it outranks the disk, so
|
||
// a roadmap listing rows out of numeric sequence (`1, 3, 2`) reported
|
||
// `next_phase: 3` and PERSISTED it, skipping Phase 2 — on an input the
|
||
// pre-#3701 code got right, because the disk scan is numerically sorted.
|
||
// Phase NUMBERS define sequence here, exactly as `comparePhaseNum` does for
|
||
// the disk scan and for #2028's lowest-outstanding override; the roadmap
|
||
// defines which phases EXIST and which milestone they belong to.
|
||
if (comparePhaseNum(pmNum, phaseNum) > 0
|
||
&& (roadmapNextNum === null || comparePhaseNum(pmNum, roadmapNextNum) < 0)) {
|
||
roadmapNextNum = pmNum;
|
||
roadmapNextName = pmName
|
||
.replace(/\(INSERTED\)/i, '')
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/\s+/g, '-');
|
||
}
|
||
}
|
||
} catch {
|
||
/* best-effort (#2245 audit): stage 2 of the next-phase cascade
|
||
* (see stage 1's comment above) — a failure here just leaves
|
||
* isLastPhase as stage 1 left it; stage 3 (#2028) below runs next
|
||
* regardless and provides a further, independent override. */
|
||
}
|
||
}
|
||
|
||
|
||
// Resolve. The roadmap wins on identity; the disk wins on spelling when it
|
||
// is talking about the same phase.
|
||
if (roadmapNextNum !== null) {
|
||
// Same comparator both scans already use to order phases, so "the disk
|
||
// and the roadmap mean the same phase" cannot drift from "N is above the
|
||
// one just completed". `02` and `2` compare equal, which is the whole
|
||
// point — they are the same phase spelled two ways.
|
||
const diskAgrees = diskNextNum !== null && comparePhaseNum(diskNextNum, roadmapNextNum) === 0;
|
||
nextPhaseNum = diskAgrees ? diskNextNum : roadmapNextNum;
|
||
nextPhaseName = diskAgrees ? diskNextName : roadmapNextName;
|
||
isLastPhase = false;
|
||
} else if (diskNextNum !== null) {
|
||
// No usable roadmap (absent, unreadable, or no parseable phase rows) —
|
||
// the disk is all there is. Unchanged from the pre-#3701 behaviour.
|
||
nextPhaseNum = diskNextNum;
|
||
nextPhaseName = diskNextName;
|
||
isLastPhase = false;
|
||
}
|
||
|
||
// #2028: don't stamp "All phases complete" when a LOWER-numbered phase is
|
||
// still outstanding. The two blocks above only clear isLastPhase when a
|
||
// HIGHER-numbered phase exists, so completing the numerically-highest phase
|
||
// out of order (e.g. Phase 10 before Phase 9) wrongly read as milestone-end.
|
||
// A phase is complete iff its roadmap checkbox is `[x]` (phase.complete sets
|
||
// this on completion — including the one just marked above); any earlier
|
||
// phase in this milestone whose checkbox is still `[ ]` means the milestone
|
||
// is not done, and the LOWEST such phase is the real next actionable item —
|
||
// point next_phase at it so STATE.md advances to the gap rather than parking
|
||
// on the just-completed phase. Roadmaps without phase checkboxes (heading-
|
||
// only) retain the prior behavior — there is nothing to scan. The checkbox
|
||
// pattern mirrors the sibling phasePattern's anchoring (only whitespace/bold
|
||
// between the box and "Phase", a required `:`) so unrelated checklist lines
|
||
// that merely mention "Phase N" don't match.
|
||
// #3350: this stage answers a DIFFERENT question than stages 1-2 ("what is
|
||
// the next actionable phase?" vs "is this the last phase?"), so it must not
|
||
// be gated on their answer. Gating on isLastPhase let a merely-positionally
|
||
// next higher heading (stage 2) permanently mask a genuinely-outstanding
|
||
// lower phase — stage 2 cleared isLastPhase and this scan never ran. The
|
||
// scan already refuses anything not strictly lower than the completed phase
|
||
// (plus sentinels, #2949), so running it unconditionally cannot manufacture
|
||
// a wrong answer: when no lower phase is outstanding it finds nothing and
|
||
// stages 1-2's pick stands unchanged; in the masking case isLastPhase is
|
||
// already false, so the last-phase signal has no reachable regression.
|
||
if (roadmapContent !== null) {
|
||
try {
|
||
const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
|
||
// #4078: the separator class here mirrors stage 2's widened checkbox
|
||
// branch (and #2199's BULLET_PHASE_LINE_PATTERN): em/en-dash/hyphen/colon.
|
||
// Without it, this lowest-outstanding override was blind to dash-grammar
|
||
// rows and could not correct an out-of-order completion on the same
|
||
// mixed-grammar roadmaps that broke stage 2.
|
||
const cbPattern = new RegExp(
|
||
`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+)`,
|
||
'gi'
|
||
);
|
||
let cbm: RegExpExecArray | null;
|
||
let lowestOutstanding: { num: string; name: string } | null = null;
|
||
while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
|
||
const isChecked = cbm[1].toLowerCase() === 'x';
|
||
// #2949: exclude sentinel-range phase ids (0.x backlog, 999.x) from candidacy.
|
||
// comparePhaseNum("0.1","12") === -12, so without this guard an unchecked 0.x
|
||
// backlog row sorts below every real phase and is wrongly selected as next_phase,
|
||
// corrupting STATE.md and desyncing current_phase from current_phase_name.
|
||
// isSentinelPhaseId covers both sentinel ranges (SENTINEL_RANGES = [0, 999]); a
|
||
// real lower-numbered outstanding phase (e.g. Phase 9) is NOT a sentinel and is
|
||
// still selected, preserving #2028's out-of-order-completion behavior.
|
||
if (!isChecked && !isSentinelPhaseId(cbm[2]) && comparePhaseNum(cbm[2], phaseNum) < 0) {
|
||
if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num) < 0) {
|
||
lowestOutstanding = {
|
||
num: cbm[2],
|
||
name: cbm[3].replace(/\(INSERTED\)/i, '').trim().toLowerCase().replace(/\s+/g, '-'),
|
||
};
|
||
}
|
||
}
|
||
}
|
||
if (lowestOutstanding !== null) {
|
||
isLastPhase = false;
|
||
nextPhaseNum = lowestOutstanding.num;
|
||
nextPhaseName = lowestOutstanding.name;
|
||
}
|
||
} catch {
|
||
/* best-effort (#2245 audit): stage 3 (#2028) of the next-phase
|
||
* cascade — a failure here simply leaves isLastPhase/nextPhaseNum
|
||
* as stages 1-2 already determined them; this stage only ever
|
||
* overrides toward "not last" when it finds a genuinely lower
|
||
* outstanding phase, never the reverse. */
|
||
}
|
||
}
|
||
|
||
if (fs.existsSync(statePath)) {
|
||
const originalStateContent = platformReadSync(statePath) || '';
|
||
let stateContent = originalStateContent;
|
||
|
||
// ADR-1769 Phase 3: the STATE.md field-update policy (Current Phase
|
||
// shape/name, Status, Current Plan, Last Activity + Description, and
|
||
// the Completed/Total Phases + Progress percent block) now dispatches
|
||
// to the STATE.md Transition Module. The ~90-line inline RMW callback
|
||
// that lived here is the pure `completePhaseCore` in
|
||
// src/state-transition.cts, backed by the field-classification table.
|
||
// `updatePerformanceMetricsSection` stays in this adapter: it is a
|
||
// section-table / disk-scan concern, not a classified field. The
|
||
// sync + post-sync preservation this transaction needs runs via the
|
||
// single write-seam composition, `syncAndPreserveStateMd` (it does
|
||
// NOT go through readModifyWriteStateMd because STATE.md is
|
||
// committed atomically with ROADMAP/REQUIREMENTS, ADR-3408 §8.3 /
|
||
// #3374 / #3469).
|
||
const nextPhaseDisplayName =
|
||
phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
|
||
phaseDisplayNameFromSlug(nextPhaseName);
|
||
const completeResult = transitionCore(
|
||
stateContent,
|
||
{
|
||
kind: 'completePhase',
|
||
phaseNum,
|
||
nextPhaseNum,
|
||
nextPhaseName: nextPhaseDisplayName,
|
||
isLastPhase,
|
||
planCount,
|
||
summaryCount,
|
||
},
|
||
{
|
||
clock: realClock,
|
||
roadmapProvider: () => roadmapContent,
|
||
sourcePath: statePath,
|
||
},
|
||
);
|
||
stateContent = completeResult.content;
|
||
|
||
stateContent = updatePerformanceMetricsSection(
|
||
stateContent,
|
||
cwd,
|
||
phaseNum,
|
||
planCount,
|
||
summaryCount,
|
||
);
|
||
// #2736: the transition holds the next phase's exact display name in
|
||
// the intent; pass it as authoritative so the sync's prose
|
||
// re-derivation cannot rewrite current_phase_name to the name's own
|
||
// parenthetical (`Closer-ruling measurement (D1a)` → `D1a`).
|
||
// #3350: PAIR the override. When STATE.md's body carries no Current
|
||
// Phase / Phase field to re-derive from (narrative prose), the #905
|
||
// preserve guard in syncStateFrontmatter keeps the OLD frontmatter
|
||
// current_phase while the authoritative current_phase_name advances —
|
||
// leaving the two fields describing different phases. Pin BOTH to the
|
||
// resolved next phase in that case. When the body DOES carry the field
|
||
// (completePhaseCore just rewrote it), stay name-only so the body's
|
||
// richer `N of T (name)` derived shape survives the sync.
|
||
const fmBody = frontmatterMod.stripFrontmatter(stateContent);
|
||
const bodyHasPhaseField =
|
||
stateExtractField(fmBody, 'Current Phase') != null ||
|
||
stateExtractField(fmBody, 'Phase') != null;
|
||
// #4129: the POST-completion progress counters, derived from the very
|
||
// ROADMAP this transaction just mutated (still in memory — it hits disk
|
||
// only at writePlanningFileSet, AFTER this content was assembled).
|
||
// buildStateFrontmatter's disk scan inside syncAndPreserveStateMd
|
||
// reads the PRE-completion ROADMAP (and any stale-dated sibling
|
||
// verification), so without this intent the persisted counter failed
|
||
// to increment on the completing phase's own transaction. Routed
|
||
// through the #2736 authoritativeFm seam's object direction: the
|
||
// pre-preservation merge makes it the derived truth the ratchet
|
||
// compares, and the post-preservation re-assert (completedOnlyRaise)
|
||
// is a floor no preservation branch can drop below. clampPercent is
|
||
// completePhaseCore's own percent formula (state-transition.cts),
|
||
// reused so the frontmatter and the body `Progress:` line agree.
|
||
const postCompletionRoadmapScope = roadmapContent !== null
|
||
? extractCurrentMilestone(roadmapContent, cwd)
|
||
: null;
|
||
const postCompletionRoadmapProgress = postCompletionRoadmapScope !== null
|
||
? deriveProgressFromRoadmapForIntent(postCompletionRoadmapScope)
|
||
: null;
|
||
const authoritativeProgress: Record<string, number> | undefined =
|
||
postCompletionRoadmapProgress && postCompletionRoadmapProgress.completedPhases !== null
|
||
? postCompletionRoadmapProgress.totalPhases !== null && postCompletionRoadmapProgress.totalPhases > 0
|
||
? {
|
||
completed_phases: postCompletionRoadmapProgress.completedPhases,
|
||
percent: clampPercentForIntent(
|
||
postCompletionRoadmapProgress.completedPhases,
|
||
postCompletionRoadmapProgress.totalPhases,
|
||
),
|
||
}
|
||
: { completed_phases: postCompletionRoadmapProgress.completedPhases }
|
||
: undefined;
|
||
const authoritativeFm: Record<string, unknown> | undefined = authoritativeProgress
|
||
? {
|
||
...(nextPhaseDisplayName
|
||
? bodyHasPhaseField || !nextPhaseNum
|
||
? { current_phase_name: nextPhaseDisplayName }
|
||
: {
|
||
current_phase: String(nextPhaseNum),
|
||
current_phase_name: nextPhaseDisplayName,
|
||
}
|
||
: {}),
|
||
progress: authoritativeProgress,
|
||
}
|
||
: nextPhaseDisplayName
|
||
? bodyHasPhaseField || !nextPhaseNum
|
||
? { current_phase_name: nextPhaseDisplayName }
|
||
: {
|
||
current_phase: String(nextPhaseNum),
|
||
current_phase_name: nextPhaseDisplayName,
|
||
}
|
||
: undefined;
|
||
// ADR-3408 §8.3 / #3469: this deliberately bypasses
|
||
// readModifyWriteStateMd (STATE.md is committed atomically with
|
||
// ROADMAP/REQUIREMENTS), so it calls the single write-seam
|
||
// composition (`syncAndPreserveStateMd`) directly instead of
|
||
// assembling `syncStateFrontmatter` + `applyPostSyncPreservation`
|
||
// itself — a call site re-assembling the pair, even with every step
|
||
// calling an owner, is the exact re-derivation §8.3 forbids by name
|
||
// (Phase 2 found this shape live here). The composition runs
|
||
// snapshots from the on-disk pre-image (originalStateContent) and
|
||
// the transformed content, table-driven applyStatePreservation, then
|
||
// the #2736 authoritative re-assert (which restores the #3350
|
||
// pairing override the preserve-always restore may have reverted).
|
||
// resync=true is the lifecycle-transition posture (progress
|
||
// recomputed from disk; only the preserve-when-unchanged deltas
|
||
// apply). Fields the transition legitimately rewrote (Status, Phase,
|
||
// Stopped At via completePhaseCore's #3374 continuity line) have
|
||
// changed body sources, so their deltas do not fire.
|
||
// ADR-3408 §8.5 / D2 (#3374): thread `divergedFields` through so this
|
||
// command reports what it preserved, following `cmdMilestoneComplete`'s
|
||
// shape (milestone.cts) — the same composition, the same out-param,
|
||
// the same visibility contract.
|
||
const divergedFields: string[] = [];
|
||
stateContent = syncAndPreserveStateMd(
|
||
originalStateContent,
|
||
stateContent,
|
||
statePath,
|
||
cwd,
|
||
{
|
||
resync: true,
|
||
authoritativeFm,
|
||
divergedFields,
|
||
},
|
||
);
|
||
for (const field of divergedFields) {
|
||
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
|
||
}
|
||
|
||
writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
|
||
// #3685 / #3691: normalize both sides before comparing (same
|
||
// transitionCore-regenerated-section artifact cmdMilestoneComplete
|
||
// hit — see contentChangedAfterNormalize's doc). Reported "not
|
||
// exposed" by a previous agent; the reviewer disproved that by
|
||
// inspection and this branch closes it.
|
||
stateUpdated = contentChangedAfterNormalize(statePath, originalStateContent, stateContent);
|
||
}
|
||
|
||
anyPlanningWrite = writePlanningFileSet(writes) > 0;
|
||
};
|
||
|
||
if (fs.existsSync(statePath)) {
|
||
withStateLock(statePath, runPhaseCompleteTransaction);
|
||
} else {
|
||
runPhaseCompleteTransaction();
|
||
}
|
||
// #3311: a successful completion of the CLAIMED phase releases the
|
||
// milestone claim — regardless of which session completes it (an
|
||
// orchestrator cleaning up after a dead session must not be blocked by the
|
||
// dead session's own claim). No-ops when the claim names another phase.
|
||
milestoneLockMod.releaseMilestonePhase(cwd, phaseNum);
|
||
return null;
|
||
});
|
||
|
||
if (verificationBlocked) {
|
||
const nextStep = verificationBlocked.next_command
|
||
? ` Next: ${verificationBlocked.next_command}`
|
||
: '';
|
||
// #3057 B3: purely additive to the message text — does not change WHETHER
|
||
// this blocks (verificationBlocked was already truthy) or the
|
||
// ERROR_REASON, only whether the operator can see the staleness check
|
||
// itself did not complete. The same fact is also attached as a typed
|
||
// field (`verification_stale_check_indeterminate`) on the JSON-error-mode
|
||
// payload so a test can assert on it by value instead of regexing this
|
||
// human-readable note.
|
||
const staleCheckIndeterminate = verificationBlocked.staleCheckIndeterminate === true;
|
||
const indeterminateNote = staleCheckIndeterminate
|
||
? ' (staleness check could not complete — see #3057)'
|
||
: '';
|
||
error(
|
||
`Phase ${phaseNum} verification is incomplete: ${verificationBlocked.next_action}${nextStep}${indeterminateNote}`,
|
||
ERROR_REASON.PHASE_VERIFICATION_INCOMPLETE,
|
||
{ verification_stale_check_indeterminate: staleCheckIndeterminate },
|
||
);
|
||
}
|
||
|
||
let autoPruned = false;
|
||
try {
|
||
const configPath = path.join(planningDir(cwd), 'config.json');
|
||
if (fs.existsSync(configPath)) {
|
||
const rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
||
const workflow = rawConfig['workflow'] as Record<string, unknown> | undefined;
|
||
const autoPruneEnabled = workflow && workflow['auto_prune_state'] === true;
|
||
if (autoPruneEnabled && fs.existsSync(statePath)) {
|
||
// Non-hoisted: load-order matters (stateMod must be fully resolved first).
|
||
const { cmdStatePrune } = stateMod;
|
||
cmdStatePrune(cwd, { keepRecent: '3', dryRun: false, silent: true }, true);
|
||
autoPruned = true;
|
||
}
|
||
}
|
||
} catch {
|
||
/* intentionally empty — auto-prune is best-effort */
|
||
}
|
||
|
||
const result = {
|
||
completed_phase: phaseNum,
|
||
phase_name: phaseInfo['phase_name'],
|
||
plans_executed: `${summaryCount}/${planCount}`,
|
||
next_phase: nextPhaseNum,
|
||
next_phase_name: nextPhaseName,
|
||
is_last_phase: isLastPhase,
|
||
date: today,
|
||
roadmap_updated: roadmapUpdated,
|
||
state_updated: stateUpdated,
|
||
requirements_updated: requirementsUpdated,
|
||
auto_pruned: autoPruned,
|
||
warnings,
|
||
has_warnings: warnings.length > 0,
|
||
// ADDITIVE, never a change to `warnings[]`'s element shape — that array is
|
||
// a documented string[] consumed by execute-phase.md, so re-typing it
|
||
// would break a shipped output contract. Absent when the line is clean.
|
||
...(reqLineWarningCode ? { requirements_line_warning: { code: reqLineWarningCode } } : {}),
|
||
verification_stale_check_indeterminate: staleCheckIndeterminate,
|
||
milestone_conflict: milestoneConflict,
|
||
preservation_warnings: preservationWarnings,
|
||
};
|
||
|
||
output(result, raw);
|
||
// #3227: gate on `anyPlanningWrite` (whether `writePlanningFileSet`
|
||
// actually wrote anything), not on reaching this line — reaching here only
|
||
// means verification passed and the transaction ran, not that ROADMAP.md
|
||
// or STATE.md bytes changed (see the `anyPlanningWrite` declaration above).
|
||
if (anyPlanningWrite) publishStateContract(cwd);
|
||
}
|
||
|
||
function cmdPhaseUatPassed(
|
||
cwd: string,
|
||
phaseNum: string | undefined,
|
||
raw: boolean,
|
||
opts: { policy?: { requireVerification?: boolean } } = {},
|
||
): void {
|
||
if (!phaseNum) {
|
||
error('phase number required for phase uat-passed');
|
||
}
|
||
|
||
const phaseInfoRaw = findPhaseInternal(cwd, phaseNum!);
|
||
if (!phaseInfoRaw) {
|
||
error(`Phase ${phaseNum} not found`);
|
||
}
|
||
const phaseInfo = phaseInfoRaw as unknown as Record<string, unknown>;
|
||
const phaseFullDir = path.join(cwd, phaseInfo['directory'] as string);
|
||
|
||
const report = evaluateUatPassed(phaseFullDir, { policy: opts.policy });
|
||
|
||
output({ phase: phaseNum, ...report }, raw);
|
||
}
|
||
|
||
// #1437 — phase.list-plans: list plan files for a given phase number.
|
||
// Returns the full scan result from scanPhasePlans so callers can read plan
|
||
// paths without re-discovering the phase directory themselves.
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
||
import planScanMod = require('./plan-scan.cjs');
|
||
const { scanPhasePlans, isCanonicalPlanFile } = planScanMod;
|
||
|
||
function cmdPhaseListPlans(cwd: string, phaseNum: string | undefined, raw: boolean): void {
|
||
if (!phaseNum) {
|
||
error('phase number required for phase list-plans');
|
||
}
|
||
|
||
const phaseInfo = findPhaseInternal(cwd, phaseNum!);
|
||
if (!phaseInfo) {
|
||
output({ phase: phaseNum, plan_count: 0, has_plans: false, plans: [], phase_dir: null }, raw);
|
||
return;
|
||
}
|
||
|
||
const phaseDir = path.join(cwd, (phaseInfo as unknown as Record<string, unknown>)['directory'] as string);
|
||
const scan = scanPhasePlans(phaseDir);
|
||
const phaseRel = (phaseInfo as unknown as Record<string, unknown>)['directory'] as string;
|
||
|
||
// Build absolute-usable relative paths for each plan file.
|
||
const plans = scan.planFiles.map((f: string) => toPosixPath(path.join(phaseRel, f)));
|
||
|
||
output({
|
||
phase: phaseNum,
|
||
phase_dir: phaseRel,
|
||
plan_count: scan.planCount,
|
||
has_plans: scan.planCount > 0,
|
||
plans,
|
||
}, raw);
|
||
}
|
||
|
||
export = {
|
||
cmdPhasesList,
|
||
cmdPhaseNextDecimal,
|
||
cmdFindPhase,
|
||
cmdPhasePlanIndex,
|
||
cmdPhaseAdd,
|
||
cmdPhaseAddBatch,
|
||
cmdPhaseMvpMode,
|
||
cmdPhaseTddApplicable,
|
||
cmdPhaseInsert,
|
||
cmdPhaseRemove,
|
||
cmdPhaseComplete,
|
||
analyzeRequirementsLine,
|
||
formatRequirementsLineWarning,
|
||
REQ_LINE_WARNING_CODE,
|
||
cmdPhaseUatPassed,
|
||
cmdPhaseListPlans,
|
||
computeDependencyLevels,
|
||
buildShortFormToId,
|
||
};
|