* 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>
2378 lines
181 KiB
Markdown
2378 lines
181 KiB
Markdown
# GSD Configuration Reference
|
||
|
||
Complete schema reference for `.planning/config.json`. For setup walkthroughs and task-oriented guides see the [docs index](README.md).
|
||
|
||
> Full configuration schema, workflow toggles, model profiles, and git branching options. For feature context, see [Feature Reference](FEATURES.md).
|
||
|
||
---
|
||
|
||
## Configuration File
|
||
|
||
GSD stores project settings in `.planning/config.json`. Created during `/gsd-new-project`, updated via `/gsd-settings`.
|
||
|
||
### Full Schema
|
||
|
||
```json
|
||
{
|
||
"mode": "interactive",
|
||
"granularity": "standard",
|
||
"model_profile": "balanced",
|
||
"model_overrides": {},
|
||
"agent_tools": {},
|
||
"models": {},
|
||
"dynamic_routing": null,
|
||
"planning": {
|
||
"commit_docs": true,
|
||
"search_gitignored": false,
|
||
"sub_repos": []
|
||
},
|
||
"context": null,
|
||
"workflow": {
|
||
"research": true,
|
||
"plan_check": true,
|
||
"verifier": true,
|
||
"auto_advance": false,
|
||
"nyquist_validation": true,
|
||
"ui_phase": true,
|
||
"ui_safety_gate": true,
|
||
"ui_review": true,
|
||
"node_repair": true,
|
||
"node_repair_budget": 2,
|
||
"research_before_questions": false,
|
||
"discuss_mode": "discuss",
|
||
"max_discuss_passes": 3,
|
||
"skip_discuss": false,
|
||
"human_verify_mode": "end-of-phase",
|
||
"tdd_mode": false,
|
||
"text_mode": false,
|
||
"use_worktrees": true,
|
||
"code_review": true,
|
||
"code_review_point": "execute:post",
|
||
"code_review_depth": "standard",
|
||
"code_review_depth_overrides": [],
|
||
"plan_bounce": false,
|
||
"plan_bounce_script": null,
|
||
"plan_bounce_passes": 2,
|
||
"plan_chunked": false,
|
||
"code_review_command": null,
|
||
"cross_ai_execution": false,
|
||
"cross_ai_command": null,
|
||
"cross_ai_timeout": 300,
|
||
"test_gate_timeout": 600,
|
||
"security_enforcement": true,
|
||
"security_asvs_level": 1,
|
||
"security_block_on": "high",
|
||
"post_planning_gaps": true,
|
||
"build_command": null,
|
||
"test_command": null
|
||
},
|
||
"code_quality": {
|
||
"fallow": {
|
||
"enabled": false,
|
||
"scope": "phase",
|
||
"profile": "standard",
|
||
"mcp": false
|
||
}
|
||
},
|
||
"ship": {
|
||
"pr_body_sections": []
|
||
},
|
||
"hooks": {
|
||
"context_warnings": true,
|
||
"workflow_guard": false
|
||
},
|
||
"statusline": {
|
||
"context_position": "end"
|
||
},
|
||
"review": {
|
||
"default_reviewers": null,
|
||
"reviewer_instances": {},
|
||
"models": {},
|
||
"parallel_lanes": false
|
||
},
|
||
"parallelization": {
|
||
"enabled": true,
|
||
"plan_level": true,
|
||
"task_level": false,
|
||
"skip_checkpoints": true,
|
||
"max_concurrent_agents": 3,
|
||
"min_plans_for_parallel": 2
|
||
},
|
||
"git": {
|
||
"branching_strategy": "none",
|
||
"create_tag": true,
|
||
"phase_branch_template": "gsd/phase-{phase}-{slug}",
|
||
"milestone_branch_template": "gsd/{milestone}-{slug}",
|
||
"quick_branch_template": null
|
||
},
|
||
"gates": {
|
||
"confirm_project": true,
|
||
"confirm_phases": true,
|
||
"confirm_roadmap": true,
|
||
"confirm_breakdown": true,
|
||
"confirm_plan": true,
|
||
"execute_next_plan": true,
|
||
"issues_review": true,
|
||
"confirm_transition": true
|
||
},
|
||
"safety": {
|
||
"always_confirm_destructive": true,
|
||
"always_confirm_external_services": true
|
||
},
|
||
"security": {
|
||
"injection_blocking": false
|
||
},
|
||
"project_code": null,
|
||
"agent_skills": {},
|
||
"agent_skills_security": {
|
||
"trusted_global_roots": []
|
||
},
|
||
"response_language": null,
|
||
"features": {
|
||
"thinking_partner": false,
|
||
"global_learnings": false
|
||
},
|
||
"learnings": {
|
||
"max_inject": 10
|
||
},
|
||
"intel": {
|
||
"enabled": false
|
||
},
|
||
"claude_md_path": "./.claude/CLAUDE.md"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## When your config file cannot be read
|
||
|
||
GSD distinguishes a config file that is **absent** from one that is **present but unusable**. The
|
||
two used to be indistinguishable: a single trailing comma in `.planning/config.json` silently
|
||
replaced your entire configuration with built-in defaults, and nothing said so (#1880).
|
||
|
||
| Situation | What GSD does | Diagnostic |
|
||
|---|---|---|
|
||
| No `.planning/config.json` | Uses built-in defaults. This is normal. | none |
|
||
| File present, valid, has settings | Uses your settings. | none |
|
||
| File present, valid, but empty (`{}`) | Uses built-in defaults. | none |
|
||
| **File present but not valid JSON** | Uses built-in defaults — **your settings are not applied** | `warning: <path> is not valid JSON — its settings were NOT applied` |
|
||
| **File present but unreadable** (e.g. permissions) | Uses built-in defaults — **your settings are not applied** | `warning: <path> could not be read (EACCES) — its settings were NOT applied` |
|
||
|
||
The warning is printed once per file per run, so a repeated command will not spam it.
|
||
|
||
The same applies to the global `~/.gsd/defaults.json`. If the project config is also unusable, the
|
||
project one is reported, since that is the file you are most likely able to fix.
|
||
|
||
**If you see this warning:** your config was not applied. Validate the file, for example with
|
||
`node -e "JSON.parse(require('fs').readFileSync('.planning/config.json','utf8'))"`, then re-run.
|
||
|
||
## Agent tool grants
|
||
|
||
`agent_tools` is an opt-in, install-time addition to the tools already declared by shipped
|
||
agents. Put defaults shared by your projects in `~/.gsd/defaults.json` and project-specific
|
||
choices in the nearest `.planning/config.json`:
|
||
|
||
```json
|
||
{
|
||
"agent_tools": {
|
||
"*": ["mcp__docs__search"],
|
||
"gsd-executor": ["WebFetch"]
|
||
}
|
||
}
|
||
```
|
||
|
||
Selectors are agent names; `"*"` applies to every agent. For an agent, GSD appends wildcard
|
||
grants before its named grants, after the agent's existing tools, in first-seen order. Re-running
|
||
the same install is idempotent: it does not add another copy of an existing grant.
|
||
|
||
Project configuration replaces only selectors it names. For example, this project setting keeps
|
||
the global wildcard but replaces the global `gsd-executor` list:
|
||
|
||
```json
|
||
{
|
||
"agent_tools": {
|
||
"gsd-executor": ["WebSearch"]
|
||
}
|
||
}
|
||
```
|
||
|
||
Each selector value must be an array. A usable entry is a single tool token which, after trimming,
|
||
is non-empty and contains no whitespace, comma, `#`, quote, U+0000–U+001F, U+007F–U+009F,
|
||
U+2028, or U+2029, and does not end with `:`.
|
||
Invalid entries are ignored. An explicitly present but invalid project selector resolves to no
|
||
grant for that selector; it does not restore the global value. A present but invalid project
|
||
`agent_tools` container suppresses all global grants. Inline grants remain plain comma-separated
|
||
tool names as required by Claude; block-sequence entries are YAML-quoted. Agents without a
|
||
`tools:` key inherit the runtime's default tool surface, so GSD leaves those agents unchanged.
|
||
|
||
A `--global` install still discovers the nearest `.planning/config.json` from the current working
|
||
directory, so `gsd install <runtime> --global` run from inside a project applies that project's
|
||
`agent_tools` selectors to the global install too — not just to that project's own local install.
|
||
|
||
Run `gsd install <runtime>` again after changing `agent_tools`; installed artifacts do not read
|
||
configuration at agent-spawn time. The shared staging path gives Claude, Codex, and Qwen their
|
||
existing host representations. Kimi maps supported canonical tools and continues to omit MCP
|
||
grants with its existing diagnostic. ZCode continues to omit `mcp__*` entries because its
|
||
dispatcher treats them as required MCP servers, and OpenCode keeps its converter-owned tools
|
||
omission. These are converter-specific output rules, not a claim that every runtime authorizes a
|
||
tool identically. Codex custom agents inherit the parent session's MCP servers natively;
|
||
`agent_tools` does not encode an allowlist into their TOML or widen `sandbox_mode`, which remains
|
||
derived from the shipped agent declaration.
|
||
|
||
## Core Settings
|
||
|
||
| Setting | Type | Options | Default | Description |
|
||
|---------|------|---------|---------|-------------|
|
||
| `mode` | enum | `interactive`, `yolo` | `interactive` | `yolo` auto-approves decisions; `interactive` confirms at each step |
|
||
| `granularity` | enum | `coarse`, `standard`, `fine` | `standard` | Controls phase count: `coarse` (2-4), `standard` (4-6), `fine` (6-10) |
|
||
| `agent_tools.<selector>` | string[] | tool names meeting the [agent tool grant validation rules](#agent-tool-grants) | (none) | Additive install-time grants for `"*"` or a named agent. A project selector replaces the corresponding global selector; wildcard grants precede named grants. Re-run `gsd install <runtime>` after changing it. |
|
||
| `model_profile` | enum | `quality`, `balanced`, `budget`, `adaptive`, `inherit` | `balanced` | Model tier for each agent (see [Model Profiles](#model-profiles)). `adaptive` was added per [#1713](https://github.com/open-gsd/gsd-core/issues/1713) / [#1806](https://github.com/open-gsd/gsd-core/issues/1806) and resolves the same way as the other tiers under runtime-aware profiles. |
|
||
| `runtime` | string | `claude`, `codex`, or any string | (none) | Active runtime for [runtime-aware profile resolution](#runtime-aware-profiles-2517). When set, profile tiers (opus/sonnet/haiku) resolve to runtime-native model IDs. The resolved ID is embedded into each agent's static frontmatter at install time on `opencode` (whose `spawn_agent` interface does not accept an inline `model` parameter, so editing `model_overrides` requires re-running `gsd install <runtime>` to take effect — see [Per-Agent Overrides](#per-agent-overrides)); other runtimes consume the resolver at spawn time. **Codex keeps profile-resolved models out of static TOML and transports them conditionally at spawn time.** A Codex skill passes the resolved `model` and `reasoning_effort` only when the visible `spawn_agent` schema advertises each field; otherwise it omits that field and inherits the session/static agent configuration. Explicit real-Codex IDs in `model_overrides` (for example `"gpt-5.6-sol"`) are still written into `.toml` as a fallback. When unset (default), model resolution is unchanged from prior versions — but the runtime GSD *reports* (`agent_runtime`) then falls through to [host detection](how-to/control-the-reported-host-runtime.md), which can resolve `codex` from Codex's own session environment. Detection affects reporting and the agent-installation check only; it never feeds tier resolution, which still reads this key alone. Added in v1.39; Codex static posture changed in v1.11; reporting-only host detection added in v1.11 |
|
||
| `model_profile_overrides.<runtime>.<tier>` | string \| object | per-runtime tier override | (none) | Override the runtime-aware tier mapping for a specific `(runtime, tier)`. Tier is one of `opus`, `sonnet`, `haiku`. Value is either a model ID string (e.g. `"gpt-5-pro"`) or `{ model, reasoning_effort }`. See [Runtime-Aware Profiles](#runtime-aware-profiles-2517). Added in v1.39 |
|
||
| `model_policy.provider` | string | `openai`, `anthropic`, `anthropic-fable`, `google`, `qwen`, `generic` | (none) | Declares the model provider. Known providers (`openai`, `anthropic`, `anthropic-fable`, `google`, `qwen`) unlock catalog-backed presets. `generic` treats all model IDs as opaque strings — no prefix inference, no reasoning-effort defaults. `model_policy.runtime_tiers` resolves before legacy `model_profile_overrides`. See [Model Policy Presets](#model-policy-presets-model_policy--added-in-v142). Added in v1.42 ([#49](https://github.com/open-gsd/gsd-core/issues/49)) |
|
||
| `model_policy.budget` | enum | `high`, `medium`, `low` | (none) | Selects a budget tier when using a known provider. GSD materializes the matching catalog preset into explicit tier mappings at resolve time. Ignored when `provider` is `generic` or `custom`. Added in v1.42 ([#49](https://github.com/open-gsd/gsd-core/issues/49)) |
|
||
| `model_policy.high` | string | model ID | (none) | High-cost tier model ID for `generic`/`custom` provider. Used when `provider: "generic"` or `"custom"`. Added in v1.42 ([#49](https://github.com/open-gsd/gsd-core/issues/49)) |
|
||
| `model_policy.medium` | string | model ID | (none) | Medium-cost tier model ID for `generic`/`custom` provider. Added in v1.42 ([#49](https://github.com/open-gsd/gsd-core/issues/49)) |
|
||
| `model_policy.low` | string | model ID | (none) | Low-cost tier model ID for `generic`/`custom` provider. Added in v1.42 ([#49](https://github.com/open-gsd/gsd-core/issues/49)) |
|
||
| `model_policy.runtime_tiers.<runtime>.<tier>` | object | `{ model, reasoning_effort? }` | (none) | Explicit per-runtime, per-tier model entry. `tier` is one of `opus`, `sonnet`, `haiku` (matching the existing profile tier names). `reasoning_effort` is forwarded only to runtimes that support it; unsupported runtimes never receive the field. Takes precedence over `model_profile_overrides`. Added in v1.42 ([#49](https://github.com/open-gsd/gsd-core/issues/49)) |
|
||
| `models.<phase_type>` | enum | `opus`, `sonnet`, `haiku`, `inherit` | (none) | Per-phase-type model tier. Six accepted slots: `planning`, `discuss`, `research`, `execution`, `verification`, `completion`. Lets you tune at the phase level ("Opus for planning, Sonnet for the rest") without learning agent names. Resolves between `model_overrides` (higher) and `model_profile` (lower); see [Per-Phase-Type Models](#per-phase-type-models-models--added-in-v140). Added in v1.40 ([#3023](https://github.com/open-gsd/gsd-core/pull/3030)) |
|
||
| `granularities.<phase_type>` | enum | `coarse`, `standard`, `fine` | (none) | Per-phase-type granularity override. Six accepted slots: `planning`, `discuss`, `research`, `execution`, `verification`, `completion`. Lets you tune phase count at the phase level without changing the global `granularity`. Precedence: `granularities[phaseType]` (highest, enum-guarded) → `granularity` (global) → `planning.granularity` → `'standard'` (hard default). Added in v1.43 ([#68](https://github.com/open-gsd/gsd-core/issues/68)) |
|
||
| `dynamic_routing.enabled` | boolean | `true`, `false` | `false` | Master switch for [dynamic routing with failure-tier escalation](#dynamic-routing-with-failure-tier-escalation-dynamic_routing--added-in-v140). When `true`, agents resolve to `tier_models[default_tier]` and escalate one tier up on orchestrator-detected soft failure. Added in v1.40 ([#3024](https://github.com/open-gsd/gsd-core/pull/3031)) |
|
||
| `dynamic_routing.tier_models.<tier>` | enum | `opus`, `sonnet`, `haiku` | (none) | Tier alias for `light`, `standard`, or `heavy`. Used when `dynamic_routing.enabled: true`. Added in v1.40 |
|
||
| `dynamic_routing.escalate_on_failure` | boolean | `true`, `false` | `true` | When `false`, escalation is disabled even if `enabled: true` — every attempt uses the default tier. Added in v1.40 |
|
||
| `dynamic_routing.max_escalations` | integer | `0`, `1`, `2`, … | `1` | Hard cap on retries per agent invocation. Beyond the cap the resolver returns the cap-tier model. Also caps `provider_escalation`. Added in v1.40 |
|
||
| `dynamic_routing.provider_escalation` | string[] | ordered model IDs | (none) | Opt-in fallback providers tried when a run dies on a quota / rate limit — see [provider escalation](#provider-escalation-on-quota-exceeded--added-in-v143). Added in v1.43 ([#2296](https://github.com/open-gsd/gsd-core/issues/2296)) |
|
||
| `project_code` | string | any short string | (none) | Prefix for phase directory names (e.g., `"ABC"` produces `ABC-01-setup/`). Added in v1.31 |
|
||
| `phase_id_convention` | enum | `"milestone-prefixed"`, `"bracket"`, `null` | `null` | Phase ID naming convention. `null` = legacy numeric IDs (`Phase 1`, `Phase 2`). `"milestone-prefixed"` = globally unique IDs that encode the enclosing milestone (`Phase 1-01`, `Phase 1-02`). Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate an existing ROADMAP.md. `"bracket"` = IDs that carry the milestone in a bracket ahead of the phase number — heading `### [GSD.02] 05: Name`, directory `GSD.02-05-name` — per [ADR-612](adr/612-bracket-phase-id-convention.md). **`"bracket"` currently affects the READ path only:** `roadmap analyze` / `roadmap get-phase`, the W005/W006/W007 phase checks, `validate health` (including an advisory W021 — a bracket phase's milestone disagreeing with its enclosing section, or a phase heading still spelled in legacy form that has not yet been migrated to bracket form), and both `total_phases` derivations recognise the bracket spelling once it is set. There is no bracket migrator and no bracket emit yet, so set it only on a project whose ROADMAP.md already uses that spelling; a project on any other value compiles the same patterns it did before and is unaffected. **What opting in costs:** on a bracket repo a heading whose bracket is followed directly by a digit is read as a phase heading, so shapes that are legal prose headings on any other convention — `### [RFC.2119] 5:`, `### [v1.0] 2024:`, `### [ADR.612] 3:` — are claimed as phases and will move `phase_count`, `total_phases` and W006. A bracket repo cedes that heading shape; that is the trade the opt-in buys, and it is why the widened read is selected at construction time from this value rather than applied everywhere ([#2761](https://github.com/open-gsd/gsd-core/issues/2761)). **Phase-directory membership** on a bracket repo scopes by the directory's real bracket token, so an artifact misfiled from another phase (`01-VERIFICATION.md` sitting in a phase `03` directory) no longer supplies `phase complete`'s pass/fail verdict for the phase it was misfiled into — the same protection legacy directories already have. Call sites that do not yet resolve a convention keep the wider include-everything fail-safe on bracket directories until they thread one: the aggregate scans (`uat`, `audit`, `init` projections, `gap-checker`, `phase-locator`); `phase complete`'s advisory UAT/VERIFICATION warning pre-scan, which can still surface a spurious warning but cannot decide completion; and the workstream inventory's per-phase completion projection, which can still report a bracket phase complete or incomplete from a cross-phase stray. |
|
||
| `response_language` | string | language code | (none) | Language for agent responses (e.g., `"pt"`, `"ko"`, `"ja"`). Propagates to all spawned agents for cross-phase language consistency. Added in v1.32. UAT checkpoint frames (`/gsd-verify-work`) render a localized banner/instruction for English, Spanish, French, German, Portuguese, Japanese, Chinese, Korean, Italian, Dutch, Polish, Russian, Ukrainian, Turkish, Hindi, Arabic, Vietnamese, and Indonesian (endonyms and ISO codes also accepted); any other value falls back to the English frame. One deliberate exception: the `spec-phase` edge-completeness probe is fed an English translation of each requirement's text, because its shape cues are English-only — the SPEC itself stays in this language. See [Spec-Phase Edge-Completeness Probe](FEATURES.md#144-spec-phase-edge-completeness-probe). Every workflow is required to carry a directive honouring this setting, including for inter-tool narration; authors add or fix one per [response-language coverage](contributing/response-language-coverage.md), and `npm run lint:response-language` enforces it. |
|
||
| `context_window` | number | any integer | `200000` | Context window size in tokens. Set `1000000` for 1M-context models (e.g., `claude-fable-5`). Values `>= 500000` enable adaptive context enrichment (full-body reads of prior SUMMARY.md, deeper anti-pattern reads). Configured via `/gsd-config --advanced`. |
|
||
| `context_profile` | string | `dev`, `research`, `review` | (none) | Execution context preset that applies a pre-configured bundle of mode, model, and workflow settings for the current type of work. Added in v1.34 |
|
||
| `claude_md_path` | string | any file path | `./.claude/CLAUDE.md` | Custom output path for the generated CLAUDE.md file. Useful for monorepos or projects that need CLAUDE.md in a non-root location. Defaults to `./.claude/CLAUDE.md` — a valid project-scoped memory location that keeps GSD-generated content from polluting a hand-crafted repo-root `CLAUDE.md` ([#1098](https://github.com/open-gsd/gsd-core/issues/1098)). An existing file without GSD markers is never overwritten unless `--force` is passed. Default changed from `./CLAUDE.md` in v1.5. Added in v1.36 |
|
||
| `claude_md_assembly.mode` | enum | `embed`, `link` | `embed` | Controls how managed sections are written into CLAUDE.md. `embed` (default) inlines content between GSD markers. `link` writes `@.planning/<source-path>` instead — Claude Code expands the reference at runtime, reducing CLAUDE.md size by ~65% on typical projects. `link` only applies to sections that have a real source file; `workflow` and fallback sections always embed. Per-block overrides: `claude_md_assembly.blocks.<section>` (e.g. `claude_md_assembly.blocks.architecture: link`). Added in v1.38 |
|
||
| `context` | string | any text | (none) | Custom context string injected into every agent prompt for the project. Use to provide persistent project-specific guidance (e.g., coding conventions, team practices) that every agent should be aware of |
|
||
| `phase_naming` | string | any string | (none) | Custom prefix for phase directory names. When set, overrides the auto-generated phase slug (e.g., `"feature"` produces `feature-01-setup/` instead of the roadmap-derived slug) |
|
||
| `brave_search` | boolean | `true`/`false` | auto-detected | Override auto-detection of Brave Search API availability. When unset, GSD checks for `BRAVE_API_KEY` env var or `~/.gsd/brave_api_key` file |
|
||
| `firecrawl` | boolean | `true`/`false` | auto-detected | Override auto-detection of Firecrawl API availability. When unset, GSD checks for `FIRECRAWL_API_KEY` env var or `~/.gsd/firecrawl_api_key` file |
|
||
| `exa_search` | boolean | `true`/`false` | auto-detected | Override auto-detection of Exa Search API availability. When unset, GSD checks for `EXA_API_KEY` env var or `~/.gsd/exa_api_key` file |
|
||
| `tavily_search` | boolean | `true`/`false` | auto-detected | Override auto-detection of Tavily Search API availability. When unset, GSD checks for `TAVILY_API_KEY` env var or `~/.gsd/tavily_api_key` file |
|
||
| `ref_search` | boolean | `true`/`false` | auto-detected | Override auto-detection of Ref search API availability. When unset, GSD checks for `REF_API_KEY` env var or `~/.gsd/ref_api_key` file |
|
||
| `perplexity` | boolean | `true`/`false` | auto-detected | Override auto-detection of Perplexity API availability. When unset, GSD checks for `PERPLEXITY_API_KEY` env var or `~/.gsd/perplexity_api_key` file |
|
||
| `jina` | boolean | `true`/`false` | `true` | Override auto-detection of Jina API availability. Jina is a terminal fallback in the docs waterfall and defaults to available (`true`); GSD checks for `JINA_API_KEY` env var or `~/.gsd/jina_api_key` file when an explicit override is needed |
|
||
| `search_gitignored` | boolean | `true`/`false` | `false` | Legacy top-level alias for `planning.search_gitignored`. Prefer the namespaced form; this alias is accepted for backward compatibility |
|
||
|
||
> **Note:** `granularity` was renamed from `depth` in v1.22.3. Existing configs are auto-migrated.
|
||
|
||
---
|
||
|
||
## Integration Settings
|
||
|
||
Configured interactively via [`/gsd-config --integrations`](COMMANDS.md#gsd-config). These are *connectivity* settings — API keys and cross-tool routing — and are intentionally kept separate from `/gsd-settings` (workflow toggles).
|
||
|
||
### Search API keys
|
||
|
||
API key fields accept a string value (the key itself). They can also be set to the sentinels `true`/`false`/`null` to override auto-detection from env vars / `~/.gsd/*_api_key` files (legacy behavior, see rows above).
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `brave_search` | string \| boolean \| null | `null` | Brave Search API key used for web research. Displayed as `****<last-4>` in all UI / `config-set` output; never echoed plaintext |
|
||
| `firecrawl` | string \| boolean \| null | `null` | Firecrawl API key for deep-crawl scraping. Masked in display |
|
||
| `exa_search` | string \| boolean \| null | `null` | Exa Search API key for semantic search. Masked in display |
|
||
| `tavily_search` | string \| boolean \| null | `null` | Tavily Search API key used in the web-discovery waterfall. Masked in display |
|
||
| `ref_search` | string \| boolean \| null | `null` | Ref search API key used in the docs-discovery waterfall. Masked in display |
|
||
| `perplexity` | string \| boolean \| null | `null` | Perplexity API key used in the web-discovery waterfall. Masked in display |
|
||
| `jina` | string \| boolean \| null | `null` | Jina API key (docs / scrape fallback). Masked in display |
|
||
|
||
**Masking convention (`gsd-core/bin/lib/secrets.cjs`):** keys 8+ characters render as `****<last-4>`; shorter keys render as `****`; `null`/empty renders as `(unset)`. Plaintext is written as-is to `.planning/config.json` — that file is the security boundary — but the CLI, confirmation tables, logs, and `AskUserQuestion` descriptions never display the plaintext. This applies to the `config-set` command output itself: `config-set brave_search <key>` returns a JSON payload with the value masked.
|
||
|
||
### Code-review CLI routing
|
||
|
||
`review.models.<cli>` maps a reviewer flavor to a bare model id, which is injected into the CLI's own model flag (`--model`, `-m`, …) when the reviewer is invoked.
|
||
|
||
The key suffix is **not** always the lane slug. Each lane declares the config key it reads, and one shipped lane already differs: the Antigravity lane's slug is `antigravity` but its key is `review.models.agy`, after the CLI's own name. Consult the table below rather than deriving the key from the flag.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `review.models.claude` | string | (session model) | Model id for Claude-flavored review. Defaults to the session model when unset |
|
||
| `review.models.codex` | string | `null` | Model id for Codex review (injected into --model), e.g. `"gpt-5"` |
|
||
| `review.models.gemini` | string | `null` | Model id for Gemini review (injected into -m), e.g. `"gemini-2.5-pro"` |
|
||
| `review.models.opencode` | string | `null` | Model id for OpenCode review (injected into --model), e.g. `"claude-sonnet-4"` |
|
||
| `review.models.cursor` | string | `null` | Model id for Cursor review (injected into --model), e.g. `"cursor-grok-4.5-high"` |
|
||
| `review.models.kimi-code` | string | `null` | Model id for Kimi Code review (injected into -m) |
|
||
|
||
### Resolved model recording (#2295)
|
||
|
||
Every `/gsd-review` run records the resolved model per reviewer in the `REVIEWS.md` frontmatter as `models:` and `model_sources:`, whether or not the lane was pinned via the keys above.
|
||
|
||
| `model_sources` value | Meaning |
|
||
|---|---|
|
||
| `pinned` | `review.models.<slug>` (or an ADR-1517 reviewer-instance `--model`) that really reached the invocation |
|
||
| `served` | An OpenAI-compatible server echoed the model it actually ran. Most authoritative |
|
||
| `requested` | openai-http: discovered from `/v1/models`, or the lane's declared `fallbackModel`; the server did not echo one |
|
||
| `banner` | The CLI's own startup banner named it. File-output lanes only (`codex` today) |
|
||
| `transcript` | The lane handler's own on-disk session log named it (`agy`'s `transcript_full.jsonl`) |
|
||
| `unknown` | Nothing recoverable |
|
||
|
||
A `models:` value reads `unknown` if and only if its `model_sources:` entry is `unknown`.
|
||
|
||
When GSD applies a reasoning effort to a lane, the recorded value carries it as a
|
||
`(reasoning=<level>)` suffix (for example `gpt-5.6-sol (reasoning=high)`) — the level is GSD's
|
||
own resolved effort, not the CLI's default.
|
||
|
||
**Ownership.** These keys are owned by their reviewer-lane capabilities rather than the central
|
||
config schema — `review.models.ollama` belongs to the `ollama` capability, `review.ollama_host`
|
||
to the same, and so on. Key names and existing `.planning/config.json` files are unchanged; only
|
||
which schema validates them moved.
|
||
|
||
One consequence follows: `<cli>` must now name a **declared reviewer lane**. Previously any slug
|
||
matching `[a-zA-Z0-9_-]+` was accepted, so a typo or a key left over from a removed reviewer
|
||
validated silently and was never read. Such a key is now rejected by `config-set`. The declared
|
||
lanes are `gemini`, `claude`, `codex`, `opencode`, `cursor`, `agy` (the Antigravity lane — its key suffix is
|
||
the CLI's own name, not the lane slug), `ollama`, `lm_studio` and `llama_cpp`.
|
||
|
||
The same applies to `review.max_prompt_tokens_per_reviewer.<slug>`. `review.max_prompt_tokens`
|
||
(the global default), `review.default_reviewers` and `review.reviewer_instances` describe policy
|
||
across lanes rather than one lane's behavior, so they remain central and are unaffected.
|
||
|
||
### Reviewer lane timeouts (`review.timeouts.*`, #3274)
|
||
|
||
Nine of the twelve declared reviewer lanes accept an outer wall-clock timeout override, federated
|
||
per-lane exactly like `review.max_prompt_tokens_per_reviewer.<slug>` above — the key is owned by
|
||
that lane's own capability manifest, not a central schema. Keys are seconds: `review.timeouts.gemini`,
|
||
`review.timeouts.claude`, `review.timeouts.codex`, `review.timeouts.opencode`,
|
||
`review.timeouts.antigravity`, `review.timeouts.kimi-code`, `review.timeouts.ollama`,
|
||
`review.timeouts.lm_studio`, `review.timeouts.llama_cpp`. Unset (or `0`/negative/non-numeric)
|
||
falls back to that lane's built-in floor. For the `antigravity` lane specifically, this value also
|
||
derives its native `agy --print-timeout` flag (roughly 60 seconds under the configured outer
|
||
value), so raising `review.timeouts.antigravity` raises both bounds together — this is how to fix
|
||
a reviewer lane being killed mid-run on a large plan set: `gsd config-set review.timeouts.antigravity 900`.
|
||
Two lanes — `qwen` and `coderabbit` — take neither a model flag nor a host and do not federate a
|
||
timeout key either, matching the same narrow key-ownership invariant their `review.models.*`/host
|
||
keys already follow (each owns only its own prompt-budget key). `cursor` gained a model flag
|
||
(`review.models.cursor`, #3653) but still owns no federated timeout key of its own.
|
||
|
||
### Reviewer lane reasoning effort (`review.effort.*`, #4255)
|
||
|
||
The three lanes that can carry a reasoning level on their command line — `codex`, `claude`,
|
||
`opencode` — federate a `review.effort.<slug>` key, owned by that lane's capability manifest like
|
||
its model and timeout keys. Accepted values are the usual effort levels (`minimal`, `low`,
|
||
`medium`, `high`, `xhigh`, `max`) plus `inherit`.
|
||
|
||
**Resolution order for a lane's effort, highest first:**
|
||
|
||
| # | Source | Result |
|
||
|---|---|---|
|
||
| 1 | `review.effort.<slug>` | the level you set, rendered in the host's own effort syntax and clamped to what that host supports |
|
||
| 2 | the lane's declared review default | `high` on all three lanes today |
|
||
| 3 | nothing declared | **no effort argument is emitted** — the reviewer CLI's own configuration decides |
|
||
|
||
Row 1 is the level you asked for, not always the level that runs: each host clamps to its own
|
||
supported set. Verified against the shipped catalog, `minimal` reaches Codex and Claude as `low`
|
||
while OpenCode takes it as-is; every other level passes through on all three. `REVIEWS.md` records
|
||
the level that actually ran, not the one requested.
|
||
|
||
`inherit` selects row 3 explicitly: use it when you want your own `~/.codex/config.toml` (or the
|
||
equivalent for another CLI) to be the authority, because the argument GSD renders is a
|
||
command-line config override and beats that file for the invocation. A value that is not a
|
||
recognized level falls back to row 2 rather than being forwarded, since an argument the CLI
|
||
rejects kills the lane outright.
|
||
|
||
Before #4255 there was no review-specific source at all: every lane's level came from the
|
||
`gsd-plan-checker` agent's installed frontmatter — `low` under every shipped model profile — so a
|
||
prompt-fed, source-grounded review ran at the level chosen for a fast structural verifier, and a
|
||
large plan set could come back as an empty lane. Effort is now a property of the review.
|
||
|
||
The lanes with no effort channel (`gemini`, `cursor`, `antigravity`, `qwen`, `coderabbit`,
|
||
`kimi-code`, `ollama`, `lm_studio`, `llama_cpp`) federate no key and emit no argument, matching the
|
||
same narrow key-ownership invariant their model and timeout keys already follow.
|
||
|
||
### Reviewer defaults for `/gsd-review`
|
||
|
||
Use `review.default_reviewers` to scope the no-flag `/gsd-review` run to a subset of detected reviewers.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `review.default_reviewers` | string[] \| null | `null` (all detected reviewers) | Optional default subset for no-flag `/gsd-review`, e.g. `["gemini","codex"]`. Entries may be built-in reviewer slugs or configured `review.reviewer_instances` names. Precedence is: explicit reviewer flags > `--all` > `review.default_reviewers` > all detected. Unknown slugs are ignored with a warning when no instances are configured; with `review.reviewer_instances` present, unknown entries are hard errors to catch typoed instance names. Known-but-undetected slugs are ignored with an info note; empty arrays are rejected by `config-set`. This leniency is specific to the configured default: a reviewer named by an explicit CLI flag that cannot run is an error, not an info note. |
|
||
|
||
Example:
|
||
|
||
```json
|
||
{
|
||
"review": {
|
||
"default_reviewers": ["gemini", "codex"]
|
||
}
|
||
}
|
||
```
|
||
|
||
### Parallel reviewer lanes for `/gsd-review` (#3034)
|
||
|
||
By default `/gsd-review` invokes reviewer lanes one at a time. That is deliberate: concurrent
|
||
invocation can trip provider rate limits, and a lane dropped to a rate limit is a review that
|
||
silently lost an opinion. A pass with several reviewers therefore costs roughly the sum of their
|
||
runtimes.
|
||
|
||
The lanes within one review pass have no data dependency on one another — they all inspect the
|
||
same immutable plan snapshot. If your providers can accept concurrent requests (independent
|
||
accounts, generous quota, or local model servers), you can opt in:
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `review.parallel_lanes` | boolean | `false` | When `true`, dispatch independent selected reviewer lanes concurrently within a single `/gsd-review` pass. All lanes are joined before `REVIEWS.md` and consensus are rendered. |
|
||
|
||
```bash
|
||
gsd config-set review.parallel_lanes true
|
||
/gsd-plan-review-convergence 3 --all
|
||
```
|
||
|
||
```json
|
||
{
|
||
"review": {
|
||
"parallel_lanes": true
|
||
}
|
||
}
|
||
```
|
||
|
||
**What this does not change.** Convergence cycles stay sequential — `review → replan → re-review`
|
||
has a real data dependency, so enabling this speeds up each pass, not the number of passes.
|
||
Per-lane timeouts, prompt budgets, diagnostic stubs, explicit-lane failure, trust/egress checks and
|
||
result-file layout are all unchanged.
|
||
|
||
**Before you enable it.** Every selected lane dispatches at once — there is no concurrency bound.
|
||
Selecting eleven lanes issues eleven concurrent requests. Two reviewer instances backed by the same
|
||
adapter (see below) also dispatch concurrently against that one provider, which is the most likely
|
||
way to hit a limit. If a lane does get rate-limited it fails the way any other failing lane does:
|
||
a diagnostic stub with captured stderr, never a silently dropped review.
|
||
|
||
### Reviewer instances for `/gsd-review` (#1517)
|
||
|
||
Use `review.reviewer_instances` to run one model-capable adapter as several independent
|
||
reviewer identities — e.g. two OpenCode-backed reviews with different models in a single
|
||
`/gsd-review` pass. Each entry maps an instance name to `{ cli, model?, agent? }`.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `review.reviewer_instances.<name>.cli` | string | (required) | A known reviewer adapter the instance reuses (e.g. `opencode`). Must be a built-in slug; never an arbitrary shell command. |
|
||
| `review.reviewer_instances.<name>.model` | string | (adapter default) | Opaque `provider/model` id passed through verbatim to the adapter's `--model`. GSD does not parse it. |
|
||
| `review.reviewer_instances.<name>.agent` | string | (none) | Opaque agent name; honoured only by adapters with a native agent concept (OpenCode `--agent` in v1). |
|
||
|
||
Instance names must match `^[a-z0-9][a-z0-9-]*$` and must not equal a built-in reviewer slug.
|
||
Instances participate ONLY through `review.default_reviewers` (there are no per-instance CLI
|
||
flags). Instance references are expanded before built-in slugs; an instance is available iff
|
||
its `cli` is detected. An entry that is neither a defined instance nor a built-in slug is a
|
||
hard error (a typo'd instance name must be loud). When two or more selected instances share
|
||
the same `cli`, `REVIEWS.md` prints a one-line shared-adapter caveat so review consensus is
|
||
not silently overstated. See [ADR-1517](adr/1517-reviewer-instances-config-surface.md).
|
||
|
||
Example:
|
||
|
||
```json
|
||
{
|
||
"review": {
|
||
"reviewer_instances": {
|
||
"opencode-deepseek": { "cli": "opencode", "model": "deepseek/deepseek-v4-pro", "agent": "review" },
|
||
"opencode-mimo": { "cli": "opencode", "model": "xiaomi/mimo-v2.5-pro" }
|
||
},
|
||
"default_reviewers": ["opencode-deepseek", "opencode-mimo", "codex"]
|
||
}
|
||
}
|
||
```
|
||
|
||
Set each field via `config-set`:
|
||
|
||
```bash
|
||
gsd config-set review.reviewer_instances.opencode-deepseek.cli opencode
|
||
gsd config-set review.reviewer_instances.opencode-deepseek.model deepseek/deepseek-v4-pro
|
||
gsd config-set review.reviewer_instances.opencode-deepseek.agent review
|
||
gsd config-set review.default_reviewers '["opencode-deepseek","opencode-mimo","codex"]'
|
||
```
|
||
|
||
### Agent-skill injection (dynamic)
|
||
|
||
`agent_skills.<agent-type>` extends the `agent_skills` map documented below. Slug is validated against `[a-zA-Z0-9_-]+` — no path separators, no whitespace, no shell metacharacters. Configured interactively via `/gsd-config --integrations`.
|
||
|
||
---
|
||
|
||
## Workflow Toggles
|
||
|
||
All workflow toggles follow the **absent = enabled** pattern. If a key is missing from config, it defaults to `true`.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `workflow.research` | boolean | `true` | Domain investigation before planning each phase |
|
||
| `workflow.plan_check` | boolean | `true` | Plan verification loop (up to 3 iterations) |
|
||
| `workflow.verifier` | boolean | `true` | Post-execution verification against phase goals |
|
||
| `workflow.auto_advance` | boolean | `false` | Auto-chain discuss → plan → execute without stopping |
|
||
| `workflow.nyquist_validation` | boolean | `true` | Test coverage mapping during plan-phase research |
|
||
| `workflow.ui_phase` | boolean | `true` | Generate UI design contracts for frontend phases |
|
||
| `workflow.ui_safety_gate` | boolean | `true` | Prompt to run /gsd-ui-phase for frontend phases during plan-phase |
|
||
| `workflow.assumption_delta` | boolean | `true` | Advisory architecture checkpoint during planning. When a phase makes something **plural, optional, or chosen** that used to be **singular, required, or derived** (e.g. a second auth method, a required field becoming optional, a constant becoming a parameter), the planner is prompted to re-ask whether the primary key / identity model still names the right thing (promote the new general representation vs. add it alongside). Non-blocking; fires only on a detected signal. Bare "or" is intentionally excluded (prose false-positives). Inspect a phase with `gsd_run query assumption-delta scan <phase>`. Added in #1561. A phase section that cannot be resolved returns `{"skipped":true,"reason":"phase_unresolved"}` rather than a fabricated `detected:false` (#3909) |
|
||
| `workflow.ui_review` | boolean | `true` | Run visual quality audit (`/gsd-ui-review`) after phase execution in autonomous mode. When `false`, the UI audit step is skipped. |
|
||
| `workflow.live_dom_uat` | boolean | `false` | **Default-off.** Enable live-DOM verification (#2856). When `true`, a `gsd-dom-verifier` step runs after each execution wave and writes `{phase}-DOM-VERIFY.md`, and the orchestrator's automated UI verification will additionally consider `mcp__chrome-devtools__*` / `mcp__claude-in-chrome__*` when present. Browser reach is confined to `gsd-dom-verifier` — `gsd-executor`'s tool surface is unchanged in every configuration. Presence of a browser MCP server is **not** sufficient on its own: a server configured for unrelated work is never driven unless this key is on. The pre-existing `mcp__playwright__*` path is unaffected by this key. Note `chrome-devtools-mcp` holds an exclusive browser-profile lock, so concurrent waves need `--isolated` on **your** MCP server registration — GSD cannot pass it. See [Enable live-DOM verification](how-to/enable-live-dom-verification.md). |
|
||
| `workflow.node_repair` | boolean | `true` | Autonomous task repair on verification failure |
|
||
| `workflow.node_repair_budget` | number | `2` | Max repair attempts per failed task |
|
||
| `workflow.smart_zone_tokens` | number | `100000` | Smart-zone token budget for phase-effort estimation (#2630, [ADR-2629](adr/2629-phase-effort-estimation-calibration.md)). A phase whose estimate exceeds this is flagged with a split recommendation — **advisory only, never a block**. This is a *policy default, not a benchmark constant*: LLM output quality degrades before the advertised context window is full, but the effective ceiling is model-, task-, and distractor-dependent, so no universal number exists. Lower it for models that degrade early; the estimate-vs-actual calibration loop corrects the figure per project over time. Must be a positive integer. |
|
||
| `workflow.research_before_questions` | boolean | `false` | Run research before discussion questions instead of after |
|
||
| `workflow.discuss_mode` | string | `'discuss'` | Controls how `/gsd-discuss-phase` gathers context. `'discuss'` (default) asks questions one-by-one. `'assumptions'` reads the codebase first, generates structured assumptions with confidence levels, and only asks you to correct what's wrong. Added in v1.28 |
|
||
| `workflow.max_discuss_passes` | number | `3` | Maximum number of question rounds in discuss-phase before the workflow stops asking. Useful in headless/auto mode to prevent infinite discussion loops. |
|
||
| `workflow.skip_discuss` | boolean | `false` | When `true`, `/gsd-autonomous` bypasses the discuss-phase entirely, writing minimal CONTEXT.md from the ROADMAP phase goal. Useful for projects where developer preferences are fully captured in PROJECT.md/REQUIREMENTS.md. Added in v1.28 |
|
||
| `workflow.text_mode` | boolean | `false` | Replaces AskUserQuestion TUI menus with plain-text numbered lists. Required for Claude Code remote sessions (`/rc` mode) where TUI menus don't render. Can also be set per-session with `--text` flag on discuss-phase. Added in v1.28 |
|
||
| `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31. **Branch-divergence note:** when your branch has diverged from `origin/HEAD`, GSD auto-degrades to sequential and prints a warning. See [`worktree.baseRef`](#worktree-settings) to restore parallel execution on a diverged branch. **Per-runtime note:** whether this key can be honored depends on the runtime's declared `dispatch.isolation` capability, not on its name (#2584). Runtimes whose own harness isolates each executor (**Claude Code**, **Cursor**) run parallel worktrees natively; runtimes exposing a headless exec with an explicit working directory (**Codex**, **OpenCode**, **Kimi**, **Kimi Code**) get worktrees GSD itself creates and merges — where a dispatch site can only drive the harness model, those hosts degrade to sequential with a warning rather than aborting. Every other runtime declares no isolation primitive, and forcing `use_worktrees: true` there still fails closed before any executor dispatch. `/gsd-health` reports such a value as warning `W025` (#2486). **Default on a non-Claude install:** if a worktree-capable non-Claude host is not isolating as described above, check whether the install stamped this key's default to `false` and set an explicit `use_worktrees: true`. See [Executor isolation per runtime](#executor-isolation-per-runtime). |
|
||
| `workflow.agent_hint_routing` | boolean | `true` | Per-plan specialist executor routing (#1689). When `true`, a plan whose `agent_hint:` frontmatter names a subagent that resolves on the active runtime is dispatched to that specialist instead of `gsd-executor`. Default `true` — a no-op for plans without `agent_hint:`, so existing dispatch is unchanged. Set `false` to disable. See [PLAN.md `agent_hint`](reference/plan-md.md#per-plan-executor-routing). |
|
||
| `workflow.compact_content` | boolean | `false` | Compact content mode (#4139, [ADR-4139](adr/4139-compact-content-seam.md)). Per-project boolean selecting the terser form of GSD's own shipped prompt content (workflows, templates, agent-skill payloads). Two mechanisms exist, chosen per stream. **Spine + detail** (top-level, eagerly-`@`-included workflows): six workflows branch on it today — `plan-phase` (#4402, the pilot), `execute-phase`, `docs-update`, `new-project`, `verify-work`, and `complete-milestone` (#4405) — each split into a spine plus a deferred `<workflow>/detail/*.md` elaboration: with the key off, the spine reads its own elaboration back in before continuing (byte-identical instruction set to before); with it on, that read is skipped. The remaining eagerly-`@`-included workflows were reviewed and recorded as not worth splitting (see `docs/PARTITION-RULES.md` § "Deciding whether a file is worth splitting") — either their size comes from safety-critical orchestration logic rather than deferrable narrative (`review.md`), or they're small enough that a split's fixed structural overhead would exceed the savings. **Variant swap** (#4406 — lazily-`Read` workflow subdirectory files and `gsd-core/templates/**` planning-artifact templates, which have no eager window to shrink): a `.compact.md` sibling next to the canonical file, resolved at the point of the existing `Read` per `gsd-core/references/compact-content-gate.md` § "Streams 1b and 4". Three call sites are wired today — `help --full`'s reference doc (`gsd-core/workflows/help/modes/full.md`) and the sequential-execution `SUMMARY.md`/`USER-SETUP.md` template reads in `execute-plan.md` — after a per-candidate reachability audit found most other size-based candidates were either genuinely unreferenced (deleted), reached only through an eager `@`-include or orchestrator build-time embed (left unconverted, same reasoning as the eagerly-included workflows above), or consumed only by a test fixture or a parser's documented grammar rather than a runtime `Read`. **Agent-skill payloads** (#4407 — the `gsd_run query agent-skills` CLI seam, `cmdAgentSkills` in `src/init.cts`): a `.compact.md` sibling next to each canonical `agents/<name>.md`, selected the same way as variant swap but resolved in code instead of prose, because this seam already runs through a real function call rather than an eagerly-loaded file — see `gsd-core/references/compact-content-gate.md` § "Stream 2". It fires only inside the `#2454` persona fallback for non-Claude, AGENTS-native runtimes with no named-subagent dispatch; Claude Code's own subagent dispatch never reaches this path, unchanged from today. An agent with no compact sibling registered falls back to the canonical persona and discloses the fallback inside the served payload itself. The token reduction each mechanism actually achieves is measured, not asserted: `npm run benchmark:compact-content` (spine/detail) and `npm run benchmark:compact-content-variants` (variant-swap) each report per-item and aggregate on/off token counts (a proxy-tokenizer delta — Anthropic publishes no tokenizer for Claude 3+, so the comparison is exact under a pinned tokenizer even though the absolute counts are not Claude's real ones) against their own committed baseline (`tests/fixtures/compact-content-benchmark-baseline.json`, #4404; `tests/fixtures/compact-content-variant-benchmark-baseline.json`, #4406). Both are reporting-only — neither ever fails CI. Discoverable, not just settable: `/gsd-new-project` asks a Compact Content question at init time, and `/gsd-settings`/`/gsd-config` toggle it on an already-initialized project (#4408) — `config-set`/`config-get` remain the direct route for scripting. |
|
||
| `workflow.worktree_skip_hooks` | boolean | `false` | When `true`, executor agents in worktree mode pass `--no-verify` (skipping pre-commit hooks) and post-wave hook validation runs against the merged result instead. Opt-in escape hatch for projects whose hooks cannot run in agent worktrees. Default `false` runs hooks on every commit (#2924). |
|
||
| `workflow.code_review` | boolean | `true` | Enable `/gsd-code-review` and `/gsd-code-review --fix` commands. When `false`, the commands exit with a configuration gate message. Added in v1.34 |
|
||
| `workflow.code_review_point` | string | `execute:post` | Loop point at which the code-review capability's step registers: `execute:post` reviews once, after every wave in a phase has landed (default — unchanged behavior); `execute:wave:post` reviews once per completed wave instead, scoped to what changed since the phase's prior review (the whole phase's diff on the first wave, each subsequent wave's own diff thereafter). Manual `/gsd-code-review <phase>` invocation is unaffected by this key — it is gated by `workflow.code_review` alone and runs regardless of which point is configured. `/gsd-autonomous` and `/gsd-quick` have no wave granularity of their own, so setting this to `execute:wave:post` means code review does not run automatically inside those two flows (consistent with how every other `execute:wave:post`-only capability already behaves for them). Added in #3661 |
|
||
| `workflow.code_review_depth` | string | `standard` | Default review depth for `/gsd-code-review`: `quick` (pattern-matching only), `standard` (per-file analysis), or `deep` (cross-file with import graphs). Can be overridden per-run with `--depth=`. Added in v1.34 |
|
||
| `workflow.code_review_depth_overrides` | array | `[]` | Ordered list of `{ paths: string[], depth }` rules that escalate `/gsd-code-review` depth for specific directories, e.g. `[{ "paths": ["src/auth"], "depth": "deep" }]`. Each rule's `paths` are matched against the review's changed-file set by whole-segment directory-path prefix (`src/auth` matches `src/auth/token.ts`, never `src/authfoo/x.ts` or `docs/src/auth/x.ts`); matching is case-sensitive, following git. Glob syntax (`*`, `?`) is a configuration error, not sugar for a prefix. One matched file escalates the entire review — depth is not applied per file. Resolution order: `--depth=` flag → strongest matching rule → `workflow.code_review_depth` → `standard`; a matching rule wins even when its tier is weaker than the global default. A malformed rule (bad `depth`, glob syntax, absolute path, `..` segment, empty path, non-array `overrides`, non-object rule, malformed `paths`) is a configuration error and the review halts rather than falling back silently. The resolved depth and the matching rule are printed in the review output. Added in #2554 |
|
||
| `workflow.plan_bounce` | boolean | `false` | Run external validation script against generated plans. When enabled, the plan-phase orchestrator pipes each PLAN.md through the script specified by `plan_bounce_script` and blocks on non-zero exit. Added in v1.36 |
|
||
| `workflow.plan_bounce_script` | string | (none) | Path to the external script invoked for plan bounce validation. Receives the PLAN.md path as its first argument. Required when `plan_bounce` is `true`. Added in v1.36 |
|
||
| `workflow.plan_bounce_passes` | number | `2` | Number of sequential bounce passes to run. Each pass feeds the previous pass's output back into the validator. Higher values increase rigor at the cost of latency. Added in v1.36 |
|
||
| `workflow.post_planning_gaps` | boolean | `true` | Unified post-planning gap report (#2493). After all plans are generated and committed, scans REQUIREMENTS.md and CONTEXT.md `<decisions>` against every PLAN.md in the phase directory, then prints one `Source \| Item \| Status` table. Word-boundary matching (REQ-1 vs REQ-10) and natural sort (REQ-02 before REQ-10). Non-blocking — informational report only. Set to `false` to skip Step 13e of plan-phase. |
|
||
| `workflow.plan_review_convergence` | boolean | `false` | Enable the `/gsd-plan-review-convergence` command. Disabled by default — the command exits with an enable instruction when this key is `false`. The command automates the manual plan→review→replan loop: it spawns configured reviewers (Codex, Gemini, Claude, OpenCode, Ollama, LM Studio, llama.cpp), counts unresolved HIGH concerns and actionable MEDIUM/LOW findings via the CYCLE_SUMMARY contract, replans with `--reviews` feedback, and repeats until converged or max cycles reached. Enable with `gsd config-set workflow.plan_review_convergence true`. Added in v1.39 |
|
||
| `workflow.plan_chunked` | boolean | `false` | Enable chunked planning mode. When `true` (or when `--chunked` flag is passed to `/gsd-plan-phase`), the orchestrator splits the single long-lived planner Task into a short outline Task followed by N short per-plan Tasks (~3-5 min each). Each plan is committed individually for crash resilience. If a Task hangs and the terminal is force-killed, rerunning with `--chunked` resumes from the last completed plan. Particularly useful on Windows where long-lived Tasks may hang on stdio. See [`planning.chunked_parallel`](#planning-settings) to dispatch the per-plan Tasks concurrently instead of one at a time. Added in v1.38 |
|
||
| `workflow.code_review_command` | string | (none) | Shell command for external code review integration in `/gsd-ship`. Receives changed file paths via stdin. Non-zero exit blocks the ship workflow. Added in v1.36 |
|
||
| `workflow.tdd_mode` | boolean | `false` | Enable TDD pipeline as a first-class execution mode. When `true`, the planner aggressively applies `type: tdd` to eligible tasks (business logic, APIs, validations, algorithms) and the executor enforces RED/GREEN/REFACTOR gate sequence. An end-of-phase collaborative review checkpoint verifies gate compliance. Added in v1.36 |
|
||
| `workflow.mvp_mode` | boolean | `false` | Persist the MVP-mode flag in config so every phase defaults to MVP framing without requiring `--mvp` on the CLI. Resolved via the precedence chain: `--mvp` CLI flag → ROADMAP.md `**Mode:** mvp` field → this config value → `false`. When `true`, the planner, executor, verifier, and discovery surfaces treat the phase as an MVP vertical slice (UI → API → DB) of one user-visible capability instead of a horizontal layer. |
|
||
| `workflow.human_verify_mode` | string | `'end-of-phase'` | Controls human verification checkpoints. `'end-of-phase'` (default since #3309) suppresses `checkpoint:human-verify` tasks and embeds checks into `<verify><human-check>` blocks for end-of-phase review. `'mid-flight'` restores blocking checkpoint tasks. `checkpoint:decision` and `checkpoint:human-action` are unaffected. See [Checkpoints Reference](../gsd-core/references/checkpoints.md#checkpoint_types). |
|
||
| `workflow.context_guard_mode` | string | `'warn'` | Context exhaustion guard for `execute-phase`. Before each wave, the orchestrator self-assesses context pressure using the degradation signals defined in `context-budget.md`. `'warn'` (default) emits a warning and recommends `/gsd-pause-work` when POOR tier (70%+) is detected. `'auto'` automatically invokes `/gsd-pause-work` before the next wave. `'off'` disables the guard. Set via: `gsd config-set workflow.context_guard_mode auto`. Added in #1452. |
|
||
| `workflow.cross_ai_execution` | boolean | `false` | Delegate phase execution to an external AI CLI instead of spawning local executor agents. Useful for leveraging a different model's strengths for specific phases. Added in v1.36 |
|
||
| `workflow.cross_ai_command` | string | (none) | Shell command template for cross-AI execution. Receives the phase prompt via stdin. Must produce SUMMARY.md-compatible output. Required when `cross_ai_execution` is `true`. Added in v1.36 |
|
||
| `workflow.cross_ai_timeout` | number | `300` | Timeout in seconds for cross-AI execution commands. Prevents runaway external processes. Added in v1.36 |
|
||
| `workflow.test_gate_timeout` | number | `600` | Wall-clock timeout (seconds) for a verification test gate; a watch-mode runner (vitest/jest) that never exits is aborted after this budget instead of hanging the orchestrator (#1857) |
|
||
| `workflow.ai_integration_phase` | boolean | `true` | Enable the `/gsd-ai-integration-phase` command. When `false`, the command exits with a configuration gate message |
|
||
| `workflow.api_coverage_gate` | boolean | `true` | Require an explicit API-coverage decision before a phase that integrates an external API/SDK/service can seal. At `plan:pre` the planner is prompted to produce a `COVERAGE.md` matrix (full coverage by default, every opt-out reasoned); at `verify:pre` a blocking gate fails the seal unless the matrix is complete. Independent of `ai_integration_phase` (#1562). A phase whose scope cannot be established at all (no plan body and no roadmap section) is held rather than passed, reporting `scope_unavailable` — see [Resolve a skipped capability probe](how-to/resolve-a-skipped-capability-probe.md) (#3909) |
|
||
| `workflow.auto_prune_state` | boolean | `false` | When `true`, automatically prune stale entries from STATE.md at phase boundaries instead of prompting |
|
||
| `workflow.pattern_mapper` | boolean | `true` | Run the `gsd-pattern-mapper` agent between research and planning to map new files to existing codebase analogs |
|
||
| `workflow.subagent_timeout` | number | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes) |
|
||
| `executor.stall_detect_interval_minutes` | number | `5` | Minutes between executor stall checks while an executor agent is active. The execute-phase orchestrator uses this cadence to inspect recent commits and avoid waiting forever on a silent agent. |
|
||
| `executor.stall_threshold_minutes` | number | `10` | Minutes without executor completion or expected-branch commit activity before execute-phase offers recovery choices for a possible stalled executor. |
|
||
| `planner.stall_detect_interval_minutes` | number | `5` | Minutes between planner/plan-checker stall checks while a planner or plan-checker agent is active. The plan-phase orchestrator uses this cadence to inspect on-disk `*-PLAN.md` activity and avoid waiting forever on a silent agent (#2650). |
|
||
| `planner.stall_threshold_minutes` | number | `10` | Minutes without a completion marker or fresh on-disk plan activity before plan-phase automatically surfaces the accept-plans/retry/stop recovery choice for a possible stalled planner or plan-checker (#2650). |
|
||
| `workflow.inline_plan_threshold` | number | `3` | Maximum number of tasks in a phase before the planner generates a separate PLAN.md file instead of inlining tasks in the prompt |
|
||
| `workflow.drift_threshold` | number | `3` | Minimum number of new structural elements (new directories, barrel exports, migrations, route modules) before the codebase-drift gate takes action. The gate runs at two points: `plan:pre` (before `/gsd-plan-phase` plans — **non-blocking, warn-only**, so plans are authored against a fresh STRUCTURE.md) and `execute:wave:post` (after `/gsd-execute-phase` — honors `workflow.drift_action`). See [#2003](https://github.com/open-gsd/gsd-core/issues/2003). Added in v1.39 |
|
||
| `workflow.drift_action` | string | `warn` | What to do when `workflow.drift_threshold` is exceeded **at `execute:wave:post`** (after `/gsd-execute-phase`). `warn` prints a message suggesting `/gsd-map-codebase --paths …`; `auto-remap` spawns `gsd-codebase-mapper` scoped to the affected paths. The `plan:pre` pre-check is always warn-only regardless of this setting — it never auto-spawns the mapper at plan entry. Added in v1.39 |
|
||
| `workflow.plan_drift_precheck` | boolean | `true` | Enable the non-blocking codebase-drift pre-check at `plan:pre`, before `/gsd-plan-phase` spawns the planner. Surfaces a stale STRUCTURE.md (drift over `workflow.drift_threshold`) as a warn-only advisory pointing to `/gsd-map-codebase`; never blocks planning, never spawns the mapper. Separate from the `execute:wave:post` gates so autonomous/CI runs can silence the plan-time advisory while keeping execute-time drift detection on. Added in v1.6.0. See [#1592](https://github.com/open-gsd/gsd-core/issues/1592). |
|
||
| `workflow.context_drift_precheck` | boolean | `true` | Enable the non-blocking context-drift pre-check at `plan:pre`, before `/gsd-plan-phase` reuses an existing RESEARCH.md/PATTERNS.md/VALIDATION.md/SPEC.md. Compares each artifact's effective last-changed time (git commit time, falling back to mtime for uncommitted edits) against CONTEXT.md's own; an artifact that predates CONTEXT.md's newest decision was derived from a premise that has since changed. Warn-only by default (see `workflow.context_drift_action`); never blocks planning on its own. See [#3348](https://github.com/open-gsd/gsd-core/issues/3348). |
|
||
| `workflow.context_drift_action` | string | `warn` | What to do when the context-drift gate finds a stale upstream artifact. `warn` prints an advisory naming the stale artifacts and how to regenerate them; `block` halts `/gsd-plan-phase` until the artifacts are regenerated or the check is disabled. See [#3348](https://github.com/open-gsd/gsd-core/issues/3348). |
|
||
| `workflow.build_command` | string | (none) | Shell command to build the project in the post-merge build gate (Step A of step 5.6 in execute-phase). When unset, the gate auto-detects: Xcode (`.xcodeproj` present) → `xcodebuild build`, `Makefile` with `build:` target → `make build`, Justfile → `just build`, `Cargo.toml` → `cargo build`, `go.mod` → `go build ./...`, Python → `python -m py_compile`, `package.json` with `build` script → `npm run build`. Runs with a 5-minute timeout; failure increments `WAVE_FAILURE_COUNT`. Added in v1.39 |
|
||
| `workflow.test_command` | string | (none) | Shell command to run the project's test suite in the post-merge test gate (Step B of step 5.6 in execute-phase) and the regression gate. When unset, the gate auto-detects: Xcode (`.xcodeproj` present) → `xcodebuild test`, `Makefile` with `test:` target → `make test`, Justfile → `just test`, `package.json` → `npm test`, `Cargo.toml` → `cargo test`, `go.mod` → `go test ./...`, Python → `python -m pytest`. Runs with a 5-minute timeout; failure increments `WAVE_FAILURE_COUNT`. Added in v1.39 |
|
||
|
||
## Worktree Settings
|
||
|
||
> **File:** `.claude/settings.local.json` — not `.planning/config.json`. Unlike all other keys in this reference, `worktree.*` settings live in the Claude Code runtime settings file. Fresh installs and upgrades auto-set `worktree.baseRef: "head"` there (no-clobber) when `workflow.use_worktrees` is enabled. The key can also be set via `gsd-tools worktree set-baseref`.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `worktree.baseRef` | string | (unset) | Controls which ref the worktree-based parallel executor uses as the base when creating new phase/wave worktrees. When unset, the executor bases new worktrees on the repository default branch (`origin/HEAD`); if the current branch has diverged, execute-phase auto-degrades to sequential execution rather than halting (as of v1.4.0). Set to `"head"` to base new worktrees on the local `HEAD` instead. **Where it applies (#48/#3659):** honored on runtimes where GSD itself creates the worktrees (Codex, OpenCode, Kimi, Kimi Code) — there it restores wave-based parallel execution on diverged branches. On harness-isolated runtimes (Claude Code, Cursor) the harness does **not** read this setting (verified 5/5 in #48; upstream claude-code#44965): the base check compares against the real fork base regardless and auto-degrades to sequential execution before dispatch when `HEAD` has diverged, so the exit-42 halt is a last-resort backstop rather than the only guard. See [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md). |
|
||
|
||
### Executor isolation per runtime
|
||
|
||
When `/gsd-execute-phase` runs a wave containing several independent plans, it can execute them concurrently — but only if the runtime can keep each executor isolated. Two executors sharing one checkout race on files, git state, hooks, and `.planning/`. Which runtimes can do this is a **declared capability** (`dispatch.isolation`), not a hardcoded list, so the scheduler behaves the same way for every host that declares the same value.
|
||
|
||
| Isolation | Runtimes | What happens |
|
||
|---|---|---|
|
||
| `harness-worktree` | `claude`, `cursor` | The runtime's own harness creates and binds a git worktree per executor. GSD passes the host's isolation flag and runs no git itself. |
|
||
| `orchestrator-worktree` | `codex`, `opencode`, `kimi`, `kimi-code` | The runtime has no harness-native isolation, but exposes a headless exec that accepts a working directory. **GSD** creates the worktree, spawns each executor into it, then validates and merges the result. All git operations are performed by GSD, never by the sandboxed executor. |
|
||
| `none` | every other runtime | No isolation primitive — plans in a wave run sequentially. Setting `workflow.use_worktrees: true` here fails closed before any executor is dispatched. |
|
||
|
||
You do not configure this directly: set `workflow.use_worktrees` and GSD negotiates the rest. `use_worktrees: false` forces sequential execution on **every** runtime, including the ones that support isolation. An unknown or undeclared isolation value always degrades to sequential — GSD never guesses its way into an unisolated parallel run.
|
||
|
||
To see what your current runtime negotiated:
|
||
|
||
```bash
|
||
gsd-tools query inspect-dispatch-isolation --json
|
||
```
|
||
|
||
(`inspect-dispatch-isolation` is the read-only form. The `dispatch-isolation` query is the executor-dispatch resolver: it records its decision to the isolation sentinel as a deliberate side effect, so it is not an inspection command.)
|
||
|
||
## Code Quality Settings
|
||
|
||
The `code_quality.*` namespace gates optional structural-analysis tooling that augments `/gsd-code-review`. Settings are additive: each tool is independently opt-in and off by default.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `code_quality.fallow.enabled` | boolean | `false` | Enables fallow structural pre-pass for `/gsd-code-review`. When `false`, no fallow binary probe or JSON artifact is produced. |
|
||
| `code_quality.fallow.scope` | string | `phase` | Scope for fallow analysis: `phase` (current review file scope) or `repo` (entire repository). |
|
||
| `code_quality.fallow.profile` | string | `standard` | Strictness preset for the fallow pre-pass (`minimal`, `standard`, `strict`). Fallow has no native profile concept, so this maps to its `--max-crap` complexity threshold: `minimal`→50, `standard`→30, `strict`→15 (lower = stricter). |
|
||
| `code_quality.fallow.mcp` | boolean | `false` | **Reserved — not yet implemented.** When `true`, enables MCP-backed structural findings mode for runtimes that support MCP server routing. Setting this to `true` is currently a no-op and emits a runtime warning. |
|
||
|
||
## Ship Settings
|
||
|
||
`ship.pr_body_sections` adds additional PR body sections for project-specific PRD/PR body content in `/gsd-ship` without editing `gsd-core/workflows/ship.md`.
|
||
|
||
For a user guide with onboarding examples and troubleshooting, see [Custom PR Body Sections](ship-pr-body-sections.md).
|
||
|
||
This list is append-only: configured entries are added after the core `Summary`, `Changes`, `Requirements Addressed`, `Verification`, and `Key Decisions` sections. They cannot replace, remove, or reorder required sections.
|
||
|
||
Recommended lean/agile PRD uses include user stories, acceptance criteria, Definition of Done or release criteria, risks and dependencies, success metrics, and stakeholder review notes. Keep these sections short and evidence-oriented so the PR body remains a living release artifact rather than a static requirements dump.
|
||
|
||
Each entry supports:
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `heading` | string | required | Markdown section heading rendered as `## {heading}`. Must be a single line. |
|
||
| `enabled` | boolean | `true` | When `false`, onboarding can keep a candidate section in config without rendering it in generated PR bodies. |
|
||
| `source` | string | (none) | Optional fallback chain of planning artifact headings, such as `PLAN.md ## Risks \|\| VERIFICATION.md ## Manual Checks`. Allowed artifacts are `ROADMAP.md`, `PLAN.md`, `SUMMARY.md`, `VERIFICATION.md`, `STATE.md`, `REQUIREMENTS.md`, and `CONTEXT.md`. |
|
||
| `template` | string | (none) | Literal Markdown with closed tokens: `{phase_number}`, `{phase_name}`, `{phase_dir}`, `{base_branch}`, `{padded_phase}`. |
|
||
| `fallback` | string | (none) | Literal Markdown used when `source` yields no content and no `template` is provided. |
|
||
|
||
At least one of `source`, `template`, or `fallback` is required for each section. The default is `[]`, so existing projects keep their current `/gsd-ship` output until onboarding adds enabled entries.
|
||
|
||
Example:
|
||
|
||
```json
|
||
{
|
||
"ship": {
|
||
"pr_body_sections": [
|
||
{
|
||
"heading": "User Stories & Acceptance Criteria",
|
||
"enabled": true,
|
||
"source": "REQUIREMENTS.md ## User Stories || REQUIREMENTS.md ## Acceptance Criteria",
|
||
"fallback": "- Acceptance criteria are covered by the linked requirements and verification evidence."
|
||
},
|
||
{
|
||
"heading": "Risks & Rollback",
|
||
"enabled": true,
|
||
"source": "PLAN.md ## Risks || PLAN.md ## Rollback",
|
||
"fallback": "- Rollback: revert this PR."
|
||
},
|
||
{
|
||
"heading": "Stakeholder Sign-off",
|
||
"enabled": false,
|
||
"template": "- Product owner: pending for {phase_name}"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
### Common Setting Combinations
|
||
|
||
The following combinations of `mode`, `granularity`, `model_profile`, and workflow toggles are commonly used together. See [Configure model profiles](how-to/configure-model-profiles.md) for setup guidance.
|
||
|
||
| Scenario | mode | granularity | profile | research | plan_check | verifier |
|
||
|----------|------|-------------|---------|----------|------------|----------|
|
||
| Prototyping | `yolo` | `coarse` | `budget` | `false` | `false` | `false` |
|
||
| Normal development | `interactive` | `standard` | `balanced` | `true` | `true` | `true` |
|
||
| Production release | `interactive` | `fine` | `quality` | `true` | `true` | `true` |
|
||
|
||
---
|
||
|
||
## Planning Settings
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `planning.commit_docs` | boolean | `true` | Whether `.planning/` files are committed to git |
|
||
| `planning.pr_strict` | boolean | `false` | Filter mode for [`/gsd-pr-branch`](COMMANDS.md#gsd-pr-branch). `false` — the generated PR branch keeps structural planning state (`STATE.md`, `ROADMAP.md`, `MILESTONES.md`, `PROJECT.md`, `REQUIREMENTS.md`, `milestones/**`) and drops the transient subdirectories. `true` — every `.planning/` path is dropped, structural files included, and a commit is carried over only when it touches at least one file outside `.planning/`. Applies to the root repository's PR branch only; `planning.sub_repos` companion branches are unaffected |
|
||
| `planning.search_gitignored` | boolean | `false` | Add `--no-ignore` to broad searches to include `.planning/` |
|
||
| `planning.sub_repos` | array of strings | `[]` | Paths of nested sub-repos relative to the project root. When set, GSD-aware tooling scopes phase-lookup, path-resolution, and commit operations per sub-repo instead of treating the outer repo as a monorepo |
|
||
| `planning.chunked_parallel` | boolean | `false` | Opt-in concurrent per-plan planners in chunked mode. See [Concurrent per-plan planners in chunked mode](#concurrent-per-plan-planners-in-chunked-mode-3777) below. |
|
||
|
||
### Concurrent per-plan planners in chunked mode (#3777)
|
||
|
||
[`workflow.plan_chunked`](#workflow-toggles) splits a phase's planning into a short outline Task
|
||
followed by N short per-plan Tasks, committing each plan individually for crash resilience. By
|
||
default those per-plan Tasks still run **one at a time** — a phase with 6 plans at ~3-5 minutes
|
||
each pays roughly the sum of their runtimes, even though each plan writes a disjoint
|
||
`{plan_id}-PLAN.md` file with no data dependency on its siblings.
|
||
|
||
```json
|
||
{
|
||
"planning": {
|
||
"chunked_parallel": true
|
||
}
|
||
}
|
||
```
|
||
|
||
```bash
|
||
gsd config-set planning.chunked_parallel true
|
||
/gsd-plan-phase 3 --chunked
|
||
```
|
||
|
||
When `true`, the runnable per-plan planners that share one outline Wave are dispatched together
|
||
(one message, `run_in_background=true` on each) instead of one at a time; a later Wave still waits
|
||
for every plan in the current Wave to be verified on disk and committed, honoring the outline's
|
||
Wave column as the schedule. `Depends On` itself is not separately parsed — it is expected to name
|
||
only a plan in an earlier Wave, so batching strictly by Wave already respects it; a same-Wave
|
||
`Depends On` would be a defect in the outline, not something this dispatch mechanism detects.
|
||
|
||
**This is gated, not unconditional.** Concurrent dispatch only fires when the runtime's negotiated
|
||
dispatch capacity (`gsd-tools query dispatch-capacity`, #3673) is greater than `1`. A runtime that
|
||
declares no `maxConcurrency` — most non-Claude runtimes today — resolves to the fail-closed floor
|
||
of `1` and stays serial regardless of this setting, exactly like the pre-#3777 loop. Claude Code
|
||
declares a capacity of 20, so this setting has an effect there out of the box.
|
||
|
||
**Trade-offs accepted by this setting.** Per-plan commits interleave within a batch instead of
|
||
landing strictly one-at-a-time, and a stalled plan's retry no longer blocks sibling plans in the
|
||
same batch that already completed and committed — a mid-batch interrupt leaves whichever plans
|
||
finished first already committed, rather than stopping after the last plan in strict outline
|
||
order. Default `false` keeps the original serial behavior byte-for-byte.
|
||
|
||
### Project-Root Resolution in Multi-Repo Workspaces
|
||
|
||
When `sub_repos` is set and `gsd-tools.cjs` or `gsd-tools query` is invoked from inside a listed child repo, both CLIs walk up to the parent workspace that owns `.planning/` before dispatching handlers. Resolution order (checked at each ancestor up to 10 levels, never above `$HOME`):
|
||
|
||
1. If the starting directory already has its own `.planning/`, it is the project root (no walk-up).
|
||
2. Parent has `.planning/config.json` listing the starting directory's top-level segment in `sub_repos` (or the legacy `planning.sub_repos` shape).
|
||
3. Parent has `.planning/config.json` with legacy `multiRepo: true` and the starting directory is inside a git repo.
|
||
4. Parent has `.planning/` and an ancestor up to the candidate parent contains `.git` (heuristic fallback).
|
||
|
||
If none match, the starting directory is returned unchanged. Explicit `--project-dir /path/to/workspace` is idempotent under this resolution.
|
||
|
||
### Auto-Detection
|
||
|
||
If `.planning/` is in `.gitignore`, `commit_docs` is automatically `false` regardless of config.json. This prevents git errors.
|
||
|
||
#### Caveat: `.gitignore` does not affect files git already tracks
|
||
|
||
Adding `.planning/` to `.gitignore` stops git from picking up **new** files there. It has no effect
|
||
on files already committed — git keeps tracking those, so `git add -A` keeps staging them even
|
||
though `commit_docs` now resolves to `false`. Because GSD's default is `commit_docs: true`, most
|
||
existing projects have already committed `.planning/`, which makes this the common case rather than
|
||
the edge case.
|
||
|
||
`/gsd-health` reports this contradiction as **`W029`**:
|
||
|
||
```
|
||
W029 .planning/ is gitignored but N file(s) are still tracked by git
|
||
Fix: git rm -r --cached .planning/ && git commit -m "chore: stop tracking planning docs"
|
||
```
|
||
|
||
The warning is advisory. GSD never untracks files for you — `--repair` deliberately will not act on
|
||
`W029`, because removing files from the index is destructive and the timing is yours to choose.
|
||
|
||
Once you run the `git rm -r --cached` above, `.planning/` is untracked, the ignore rule takes full
|
||
effect, and the warning clears.
|
||
|
||
Note: a file deliberately force-added under an otherwise-ignored `.planning/` (`git add -f
|
||
.planning/keep.md`) triggers this same warning — there is no reliable way to distinguish an
|
||
intentional force-add from the accidental case above, so `W029` is expected in that situation too.
|
||
|
||
### Per-Phase Override (`phase_commit_docs`)
|
||
|
||
`commit_docs` is a single project-wide switch by default, but a tech lead may want to commit one
|
||
phase's artifacts (e.g. an architecture or ADR phase) while keeping execution phases local. Set a
|
||
dynamic key of the form `phase_commit_docs.<phase-id>` to override `commit_docs` for that phase only:
|
||
|
||
```bash
|
||
gsd-tools config-set phase_commit_docs.03 true
|
||
gsd-tools config-set phase_commit_docs.07 false
|
||
```
|
||
|
||
```json
|
||
{
|
||
"commit_docs": false,
|
||
"phase_commit_docs": {
|
||
"03": true,
|
||
"07": false
|
||
}
|
||
}
|
||
```
|
||
|
||
The `<phase-id>` segment accepts the same phase-number shapes GSD uses elsewhere (`3`, `03`,
|
||
`12A`, `3.2` — a project-code prefix like `PROJ-03` is normalized to the bare phase number before
|
||
lookup), so `phase_commit_docs.3` and `phase_commit_docs.03` refer to the same entry.
|
||
|
||
**Resolution order** (highest wins) when `gsd-tools commit` / `query commit` resolves the phase from
|
||
the committed `--files` paths:
|
||
|
||
1. `phase_commit_docs.<phase-id>` for the phase being committed
|
||
2. explicit `commit_docs` / `planning.commit_docs` in config.json
|
||
3. `.gitignore` auto-detect (see [Auto-Detection](#auto-detection) above)
|
||
4. the manifest default (`true`)
|
||
|
||
A per-phase value must be a real boolean — `"true"` (string), `1`, or `null` are never coerced and
|
||
fall through to the next tier. A value set for a different phase than the one being committed never
|
||
applies (no cross-phase leak). A commit that names no phase-scoped file (e.g. a project-wide
|
||
`ROADMAP.md`-only commit) has no phase to look up, so tier 1 is inapplicable and resolution starts
|
||
at tier 2 — unchanged from pre-#3587 behavior.
|
||
|
||
When tier 1 suppresses a commit, the skip envelope's `reason` is
|
||
`skipped_commit_docs_phase_false` — distinct from the project-wide `skipped_commit_docs_false` —
|
||
so a caller is never told "commit_docs is false" when the project setting is actually `true`.
|
||
|
||
A commit spanning multiple phases resolves the override against the first phase in the `--files`
|
||
list, so scope `--files` to one phase when using the override.
|
||
|
||
See [Keep planning docs out of a shared repo](how-to/keep-planning-docs-private.md#per-phase-override)
|
||
for a worked example.
|
||
|
||
---
|
||
|
||
## Hook Settings
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `hooks.context_warnings` | boolean | `true` | Show context window usage warnings via context monitor hook |
|
||
| `hooks.context_warning_threshold` | number | `35` | Percent of context window REMAINING at or below which the monitor emits CONTEXT WARNING. Must be greater than 0 and at most 100, and strictly greater than `hooks.context_critical_threshold` — `config-set` refuses 0, which no critical value can pair with. An out-of-domain value falls back **per key**; both keys revert to their defaults only when the RESOLVED pair violates `critical < warning`. Read from the root project config — a workstream-scoped `config-set` does not reach this hook. Inert on a runtime with no context-monitor hook installed, Codex among them (#2586); see [context-monitor.md](context-monitor.md) (#4285) |
|
||
| `hooks.context_critical_threshold` | number | `25` | Percent of context window REMAINING at or below which the monitor escalates to CONTEXT CRITICAL. Must be at least 0 and less than 100, and strictly less than `hooks.context_warning_threshold` — `config-set` refuses 100, which no warning value can pair with. Setting only one of the pair is checked against the other's default, so tune both when moving either past the other. Same root-config scope, and the same installed-monitor prerequisite, as the key above (#4285) |
|
||
| `hooks.workflow_guard` | boolean | `false` | Warn when file edits happen outside GSD workflow context (advises using `/gsd-quick` or `/gsd-fast`). When enabled, the hook's one hard block — `git add -f` on `agent-*`/`worktree-agent-*` branches — also fails closed on internal error (see `docs/explanation/security-model.md`, #3504) |
|
||
| `statusline.show_last_command` | boolean | `false` | Append `last: /<cmd>` suffix to the statusline showing the most recently invoked slash command. Opt-in; reads the active session transcript to extract the latest `<command-name>` tag (closes #2538) |
|
||
| `statusline.context_position` | string | `"end"` | Position of the context-window meter. `"end"` (default) renders at line tail; `"front"` renders immediately after the model name so the meter stays visible in narrow terminals. Closes #2937 |
|
||
| `statusline.show_context_tokens` | boolean | `false` | Append the absolute token count (e.g. `(156k)`) after the context meter's percentage. Sums input, cache-creation, cache-read, and output tokens from the hook payload — a broader basis than the meter's percentage (which excludes output tokens), so the two figures can diverge slightly. Opt-in; the meter is unchanged when the flag is absent |
|
||
| `statusline.state_format` | string | `"full"` | Format of the GSD-state segment. `"full"` (default) is the existing rendering with milestone name and progress bar. `"compact"` renders `<version> · P<phase>/<total> · <status>` (e.g. `v1.12 · P7/12 · executing`) — drops the milestone name and bar, and collapses narrative statuses to the canonical keyword set from `normalizeStateStatus()` (`paused` — the canonical stuck state — renders uppercase as `PAUSED`) |
|
||
| `statusline.show_git` | boolean | `false` | Append a git segment after the directory: current branch plus compact work-state markers (`+staged` `~unstaged` `?untracked` `↑ahead` `↓behind`, or `✓` when clean and in sync). One `git status --porcelain=v2` call per render; the segment is absent outside a git repo or when git is unavailable |
|
||
| `statusline.show_state_freshness` | boolean | `false` | Append `state ~N commits back` to the GSD-state segment when STATE.md carries a `state_head` stamp and the codebase has moved at least 20 commits past it (the same advisory threshold `/gsd-health`'s W024 uses). Exactly one `git rev-list` call per render, and only when enabled and a stamp is present; the marker is absent below the threshold, outside a git repo, when the project root does not own its `.git`, and in `planning.sub_repos` workspaces |
|
||
|
||
The prompt injection guard hook (`gsd-prompt-guard.js`) is always active and cannot be disabled — it's a security feature, not a workflow toggle.
|
||
|
||
### Private Planning Setup
|
||
|
||
When `planning.commit_docs` is `false` and `.planning/` is listed in `.gitignore`, GSD treats planning artifacts as local-only. `planning.search_gitignored: true` ensures broad searches still include the `.planning/` directory in this configuration. See [Keep planning docs out of a shared repo](how-to/keep-planning-docs-private.md) for the full setup, including untracking files git is already tracking.
|
||
|
||
#### Two ways to keep planning private, and what each costs
|
||
|
||
"Private planning" is really two different questions — *is it in git at all?* and *does it reach the remote?* — and GSD answers them with two different keys.
|
||
|
||
`planning.commit_docs: false` answers the first by keeping `.planning/` out of git entirely. That has a cost most projects discover late: **parallel executor worktrees stop working.** A worktree is checked out from a *commit*, so a `.planning/` directory that is untracked or ignored simply does not exist inside it, and the executor has no `PLAN.md` to read. Local-only planning and parallel execution are mutually exclusive under this setting. Untracked planning also has no git history, so `/gsd-undo` and revert paths have nothing to restore.
|
||
|
||
`planning.pr_strict: true` answers the second instead, and leaves the first alone. Planning artifacts are committed normally on your working branch — so worktrees find them, and history exists — while `/gsd-pr-branch` guarantees that none of them reach the branch you publish. The guarantee is enforced by the command rather than by remembering never to push the working branch.
|
||
|
||
Pick by which question you are actually asking:
|
||
|
||
| You want | Setting | What you give up |
|
||
|---|---|---|
|
||
| Planning never enters git | `planning.commit_docs: false` | Parallel executor worktrees; planning git history |
|
||
| Planning is versioned locally but never published | `planning.commit_docs: true` + `planning.pr_strict: true` | Nothing — but the working branch itself must not be pushed; publish the generated `-pr` branch |
|
||
|
||
The two are independent keys and can be set together, but `pr_strict` is inert when `commit_docs` is `false`: with nothing committed, there is nothing for the PR-branch filter to remove.
|
||
|
||
See [Publish PRs without planning artifacts](how-to/publish-prs-without-planning-artifacts.md) for the setup.
|
||
|
||
### `commit_docs` Pre-Commit Guard (opt-in)
|
||
|
||
`planning.commit_docs: false` only stops GSD's own `gsd-tools commit`/`gsd-tools state`
|
||
write path from committing `.planning/`. It does **not** stop a plain `git add -A` +
|
||
`git commit` run by hand, or by a script outside GSD's own tooling, from staging and
|
||
committing `.planning/` anyway.
|
||
|
||
`gsd-tools commit-docs-guard enable` closes that gap by writing a `.git/hooks/pre-commit`
|
||
hook into the **current repository** that refuses any commit staging `.planning/` files
|
||
while `commit_docs` resolves to `false`. Resolution goes through the same
|
||
[per-phase precedence chain](#per-phase-override-phase_commit_docs) `gsd-tools commit`/`query commit`
|
||
use — a `phase_commit_docs.<phase-id>` override for the staged phase is honored here too, so the
|
||
hook cannot contradict them. It is entirely opt-in — no install path wires it
|
||
automatically:
|
||
|
||
```bash
|
||
gsd-tools commit-docs-guard enable # write the hook (refuses to clobber an existing pre-commit hook)
|
||
gsd-tools commit-docs-guard disable # remove it (refuses to remove a hook GSD didn't write)
|
||
```
|
||
|
||
The hook is identified by a stable `# gsd-core:commit-docs-guard` marker line, so `enable`/
|
||
`disable` detect it by presence of that marker rather than by byte-for-byte content — editing
|
||
the file afterward does not make it unrecognizable. `enable` refuses (rather than silently
|
||
writing an inert file) when `core.hooksPath` is already configured, since a hook written to
|
||
`.git/hooks/pre-commit` would never run in that case; wire the guard into the configured hooks
|
||
path by hand instead. See [Keep planning docs out of a shared repo](how-to/keep-planning-docs-private.md#pre-commit-guard-hook-optional) for the full walkthrough, including the linked-worktree case.
|
||
|
||
---
|
||
|
||
## Agent Skills Injection
|
||
|
||
Inject custom skill files into GSD subagent prompts. Skills are read by agents at spawn time, giving them project-specific instructions beyond what CLAUDE.md provides.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `agent_skills` | object | `{}` | Map of agent types to arrays of skill entries |
|
||
| `agent_skills_security.trusted_global_roots` | array of strings | `[]` | Opt-in allowlist of additional trusted directories for `global:` skills. See [Trusted global skill roots](#trusted-global-skill-roots-agent_skills_securitytrusted_global_roots) |
|
||
|
||
### Configuration
|
||
|
||
Add an `agent_skills` section to `.planning/config.json` mapping agent types to arrays of skill entries:
|
||
|
||
```json
|
||
{
|
||
"agent_skills": {
|
||
"gsd-executor": [
|
||
"skills/testing-standards",
|
||
"global:shared-conventions",
|
||
"global:coderabbit:code-review"
|
||
],
|
||
"gsd-planner": ["skills/architecture-rules"],
|
||
"gsd-verifier": ["skills/acceptance-criteria"]
|
||
}
|
||
}
|
||
```
|
||
|
||
### Skill Entry Forms
|
||
|
||
Each element in the array is one of three forms:
|
||
|
||
| Form | Example | Resolution |
|
||
|------|---------|------------|
|
||
| Project-relative path | `"skills/my-skill"` | Resolves to `<project>/skills/my-skill/SKILL.md`, injected as an `@`-include |
|
||
| Global personal skill | `"global:<name>"` | Resolves to `~/.claude/skills/<name>/SKILL.md`, injected as an `@`-include |
|
||
| Plugin-provided skill (Claude only) | `"global:<plugin>:<skill>"` | A Claude Code plugin skill, loaded by name via the Skill tool at agent spawn time |
|
||
|
||
**Project-relative paths** must point to a directory containing a `SKILL.md` file. Paths are validated for safety (no traversal outside the project root).
|
||
|
||
**Global personal skills** (`global:<name>`) resolve against the runtime's global skills directory (e.g. `~/.claude/skills/`). Symlink-escape protection applies unless the target is listed in `agent_skills_security.trusted_global_roots`.
|
||
|
||
**Plugin-provided skills** (`global:<plugin>:<skill>`) follow the namespaced form `seg(:seg)*`, where each segment is one or more alphanumeric characters, underscores, or hyphens joined by single colons (e.g. `global:coderabbit:code-review`). This form is **Claude-only**: on the Claude runtime GSD emits a Skill-tool load directive in the agent's `<agent_skills>` block so the agent loads the skill by name via the Skill tool, and Claude Code resolves the `plugin:skill` namespace. On all other runtimes the entry is skipped with a warning — the plugin/Skill-tool model is specific to Claude Code and has no equivalent elsewhere.
|
||
|
||
> **Why load by name rather than path?** Claude Code's plugin cache is versioned and ephemeral, so there is no stable filesystem path to `@`-include. Loading by namespaced name via the Skill tool lets Claude Code's own resolver locate the current version of the plugin skill at runtime.
|
||
|
||
The plugin must already be installed in the user's Claude Code environment (`/plugin install …`). GSD only references the skill by its namespaced name and does not read or validate the plugin cache itself.
|
||
|
||
### Supported Agent Types
|
||
|
||
Any GSD agent type can receive skills. The agent types that consume `agent_skills` are the GSD sub-agents the workflows dispatch. There are 22 consumer agents in total, including:
|
||
|
||
- `gsd-executor` — executes implementation plans
|
||
- `gsd-planner` — creates phase plans
|
||
- `gsd-plan-checker` — verifies plan quality
|
||
- `gsd-verifier` — post-execution verification
|
||
- `gsd-phase-researcher` — phase research
|
||
- `gsd-project-researcher` — new-project research
|
||
- `gsd-debugger` — diagnostic agents
|
||
- `gsd-codebase-mapper` — codebase analysis
|
||
- `gsd-code-reviewer` — code review
|
||
- `gsd-ui-researcher` — UI design contract creation
|
||
- `gsd-ui-checker` — UI spec verification
|
||
- `gsd-ui-auditor` — UI audit
|
||
- `gsd-roadmapper` — roadmap creation
|
||
- `gsd-research-synthesizer` — research synthesis
|
||
- and others (see `tests/agent-skills.test.cjs` `CONSUMER_AGENTS` list for the full 22)
|
||
|
||
The `Skill` tool is granted to consumer agents deliberately and is instruction-bounded — agents use it only to load the skills listed in the `<agent_skills>` block.
|
||
|
||
### How It Works
|
||
|
||
Skills reach a consumer agent through **two cooperating seams** (dual injection — see [ADR 1866](adr/1866-agent-skills-dual-injection-contract.md)):
|
||
|
||
1. **Orchestrator-side (primary on Claude Code).** At spawn time, workflows call `gsd-tools query agent-skills <type>` (or legacy `node gsd-tools.cjs agent-skills <type>`) in their bash init and interpolate the resulting `<agent_skills>` block into the `Task()`/`Agent()` prompt. This is established across ~25 workflows.
|
||
|
||
2. **Agent-side self-load (durable fallback).** Each of the 22 consumer agents also self-loads in its own mandatory init step — it runs `gsd_run query agent-skills <its-type>` and `Read`s every listed `SKILL.md` per [`gsd-core/references/agent-skills-bootstrap.md`](../gsd-core/references/agent-skills-bootstrap.md). This is the path that works on every runtime, including **Cursor**, where `Skill()`-delegated workflow bash init does not reliably execute and `/gsd-autonomous` delegates via flat `Skill()` calls.
|
||
|
||
**Dedup guard.** If an agent's prompt already contains an `<agent_skills>` block (orchestrator already injected one), the agent skips self-load — so on runtimes where both seams run (Claude Code), the prompt never carries two copies. `query agent-skills` is read-only and idempotent: it exits 0 with an empty block when nothing is configured for the type, so self-load is zero-overhead for unconfigured agents.
|
||
|
||
For project-relative and global personal skills, entries appear as `@`-includes:
|
||
|
||
```xml
|
||
<agent_skills>
|
||
Read these user-configured skills:
|
||
- @skills/testing-standards/SKILL.md
|
||
- @/Users/you/.claude/skills/shared-conventions/SKILL.md
|
||
</agent_skills>
|
||
```
|
||
|
||
For a mixed config (path-resolvable and plugin-provided skills together), entries appear interleaved in config order in a single section:
|
||
|
||
```xml
|
||
<agent_skills>
|
||
Read these user-configured skills:
|
||
- @skills/testing-standards/SKILL.md
|
||
- Load the `coderabbit:code-review` skill via the Skill tool before proceeding (plugin-provided).
|
||
</agent_skills>
|
||
```
|
||
|
||
If no skills are configured, the block is omitted (zero overhead).
|
||
|
||
### CLI
|
||
|
||
Set skills via the CLI:
|
||
|
||
```bash
|
||
gsd-tools query config-set agent_skills.gsd-executor '["skills/my-skill"]'
|
||
```
|
||
|
||
See [How to attach a plugin-provided skill to a GSD agent](how-to/attach-a-plugin-skill-to-a-gsd-agent.md) for a step-by-step walkthrough of the `global:plugin:skill` form.
|
||
|
||
---
|
||
|
||
## Trusted Global Skill Roots (`agent_skills_security.trusted_global_roots`)
|
||
|
||
Widen the symlink-safety boundary for `global:` skills by declaring additional trusted root directories.
|
||
|
||
### Purpose
|
||
|
||
By default, a `global:<name>` skill whose `SKILL.md` real path (after resolving symlinks) escapes the runtime's global skills directory (e.g. `~/.claude/skills/`) is rejected as a symlink-escape. `agent_skills_security.trusted_global_roots` lets you declare additional trusted root directories so symlinked skills whose real target lives under one of them are accepted.
|
||
|
||
Common use case: a single source-of-truth skills directory elsewhere on disk (e.g. `~/shared/skills`) symlinked into `~/.claude/skills/` so `git pull` or `rsync` keeps a team's skills up to date without maintaining copies.
|
||
|
||
### Configuration
|
||
|
||
```json
|
||
{
|
||
"agent_skills_security": {
|
||
"trusted_global_roots": [
|
||
"~/shared/skills",
|
||
"/opt/shared-skills"
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
### How It Works
|
||
|
||
- **Default `[]`** — behavior is byte-identical to omitting the option entirely: only skills whose real `SKILL.md` path resolves inside the default global skills directory are accepted.
|
||
- **Absolute or tilde-prefixed paths only.** Each entry must be an absolute path (`/opt/shared-skills`) or a `~`/`~/`-prefixed path (tilde expands to your home directory). Project-relative paths are rejected, so an untrusted repo's `.planning/config.json` cannot point trust at a directory inside itself.
|
||
- **`realpathSync` at load time.** Each declared root is resolved with `realpathSync` on every run, so trust follows the real target and cannot silently drift if a root itself later becomes a symlink. Non-existent or unreadable roots are dropped without error.
|
||
- **Dangerously broad roots are refused.** The filesystem root (`/`), drive or UNC roots, and your home directory itself cannot be declared as trusted roots — these would make the allowlist meaningless.
|
||
- **Acceptance rule.** A skill is accepted if and only if its real `SKILL.md` path lies inside the default global skills directory OR inside one of the resolved trusted roots. Skills resolving outside all of these are still rejected.
|
||
- **Audit note.** When a skill is accepted via a trusted root rather than the default global skills directory, a `[agent-skills] NOTE:` line is written to stderr so the widened boundary remains visible.
|
||
|
||
> **Security note:** `trusted_global_roots` is read from the project-local `.planning/config.json`. Only add roots you control and trust. Declaring a broad shared directory widens which symlinked global skills will load for every agent in this project.
|
||
|
||
### CLI
|
||
|
||
```bash
|
||
gsd config-set agent_skills_security.trusted_global_roots '["~/shared/skills"]'
|
||
```
|
||
|
||
Setting the parent object (`agent_skills_security`) directly is not supported; use the dot-notation leaf form shown above.
|
||
|
||
---
|
||
|
||
## Capability Trust (`capabilities.*`)
|
||
|
||
Policy for installing and updating third-party capabilities (ADR-1244). These keys govern the trust gate; they have no effect if you only ever use the native first-party capabilities shipped with GSD. They are **policy inputs** read by the `gsd capability` command flow, which passes the resulting decision into the capability lifecycle — `strict_known_registries` gates whether a source may be installed at all; `auto_update` is consulted by the `update`/`outdated` flow (which always re-prompts when a new version's executable surface set changes). The full rationale — including why there is no sandbox — is in [The capability trust model](explanation/capability-trust-model.md).
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `capabilities.strict_known_registries` | array \| null | `null` | Allowlist gating **which sources** third-party capabilities may be installed from. `null` (default) is permissive: external installs (git / npm / tarball) are allowed and each still passes the consent + integrity gate. `[]` (explicit empty array) is lockdown: **all external installs are blocked** — only local-filesystem installs are permitted (managed/enterprise mode). A non-empty list is a **host-based allowlist**: only sources whose host matches an entry (exact host or a subdomain of it — `github.com` matches `api.github.com` but never `evilgithub.com`) are permitted; add the literal token `npm` to permit the npm source kind. Local installs are never "external" and are always allowed. |
|
||
| `capabilities.auto_update` | boolean | `false` | Whether installed third-party capabilities may auto-update. **Off by default.** Even when enabled, GSD re-prompts for explicit consent whenever a new version's executable surface set (hooks / command modules / MCP servers) differs from the installed one — the consent you gave was for a specific surface, not a blank cheque. |
|
||
|
||
```bash
|
||
# Lock the machine down to local-only capability installs:
|
||
gsd config-set capabilities.strict_known_registries '[]'
|
||
|
||
# Allow only your org's GitHub + npm:
|
||
gsd config-set capabilities.strict_known_registries '["github.com", "npm"]'
|
||
```
|
||
|
||
> **Security note:** `strict_known_registries` matching is **host-based, not substring** — a lookalike host like `evilgithub.com` is rejected even when `github.com` is allowed. `integrity` (sha512) pins only the top-level fetched artifact, not an npm package's transitive dependency tree; see the trust-model explanation for that boundary.
|
||
|
||
---
|
||
|
||
## Feature Flags
|
||
|
||
Toggle optional capabilities via the `features.*` config namespace. Feature flags default to `false` (disabled) — enabling a flag opts into new behavior without affecting existing workflows.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `features.thinking_partner` | boolean | `false` | Enable thinking partner analysis at workflow decision points |
|
||
| `features.global_learnings` | boolean | `false` | Enable cross-project learnings pipeline (auto-copy at phase completion, planner injection) |
|
||
| `learnings.max_inject` | number | `10` | Maximum number of cross-project learnings injected into each planner prompt. Lower values reduce prompt size; higher values provide broader historical context |
|
||
| `intel.enabled` | boolean | `false` | Enable queryable codebase intelligence system. When `true`, `/gsd-map-codebase --query` commands build and query a JSON index in `.planning/intel/`. Added in v1.34 |
|
||
|
||
<a id="plan-review-settings"></a>
|
||
### Plan Review Settings
|
||
|
||
The `plan_review.*` namespace controls the plan drift guard, which verifies that symbols cited in generated plans (decorators, classes, functions, CLI flags) actually exist in your source code at review time. This catches hallucinated names before execution begins.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `plan_review.source_grounding` | boolean | `true` | Enable the plan drift guard. When `true` (the default), plan review resolves every symbol reference cited in a PLAN.md against the live source tree. Plans that cite a non-existent function, class, decorator, or CLI flag produce a `needs-acknowledgement` notice before the plan is approved. The same key also gates the cross-artifact fact-drift pass, which reports when ROADMAP.md, PLAN.md, STATE.md and CONTEXT.md state the same fact in contradictory ways (advisory only — it never blocks convergence). Disable with `false` to skip both passes entirely. Toggle during setup (`/gsd-new-project`) or at any time via `/gsd-settings`. |
|
||
| `plan_review.source_grounding_authority` | enum | `grep` | Selects the resolver adapter used to verify symbol existence. Allowed values: `grep` (default — ripgrep/grep search of source files, works in any project without additional tooling), `intel` (query the `.planning/intel/api-map.json` index built by `/gsd-map-codebase`; requires `intel.enabled: true`), `treesitter` (reserved for future tree-sitter adapter), `lsp` (reserved for future LSP adapter), `scip` (reserved for future SCIP/LSIF adapter). Use `intel` when you have run `/gsd-map-codebase` and want the faster, pre-indexed lookup. All other values beyond `grep` and `intel` are reserved and have no effect in the current release. |
|
||
|
||
<a id="mempalace-settings"></a>
|
||
### MemPalace Settings
|
||
|
||
MemPalace is an opt-in, default-resilient memory capability. Every hook is `onError: skip` — a missing or unreachable MemPalace installation never halts or fails the loop. Enable with `mempalace.enabled: true` after installing MemPalace (`pip install mempalace`).
|
||
|
||
`mempalace.enabled` is the **master gate**: all five loop hooks (discuss, plan, execute-wave, verify, ship) and both curator contributions are gated on this key. When it is `false` (the default), nothing fires and the GSD loop is byte-for-byte unchanged. The remaining keys only refine behavior when `mempalace.enabled` is `true`; they are honored at runtime by the skills, curator, and fragments — they do not add independent hook gating.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `mempalace.enabled` | boolean | `false` | Master gate for the MemPalace memory capability. When `false` (the default) every recall/capture hook is inactive and the loop is unchanged. All other `mempalace.*` keys are inert while this is `false`. |
|
||
| `mempalace.memory_mode` | enum: `augment`, `kg_backend`, `replace` | `augment` | How authoritative MemPalace is during recall/capture. `augment` (default — an additive recall layer alongside GSD's native graphs/learnings; native memory stays authoritative; lowest coupling). `kg_backend` (knowledge-graph queries resolve against MemPalace's temporal graph as the primary source, with `.planning/graphs/` as fallback; non-KG drawer recall stays additive). `replace` (recall resolves through the palace as the source of truth, native artifacts as fallback). Every mode is `onError:skip` and default-resilient: an unreachable palace degrades to native memory and GSD keeps writing `.planning/graphs/`, so no memory is lost. Cross-mode migration of existing `.planning/graphs/` into the palace is a separate, not-yet-implemented concern. |
|
||
| `mempalace.wing` | string | `""` | Palace wing name for this project. Empty (the default) derives the wing from `project_code` or the project directory name. |
|
||
| `mempalace.recall_on_discuss` | boolean | `true` | When `mempalace.enabled` is `true`: inject a wake-up + semantic-search recall fragment into the orchestrator at `discuss:pre`. Surfaces prior decisions, patterns, and surprises before the discussion starts. |
|
||
| `mempalace.recall_on_plan` | boolean | `true` | When `mempalace.enabled` is `true`: run the `mempalace-recall` skill at `plan:pre` to produce `MEMORY-RECALL.md` from prior decisions, patterns, and surprises relevant to the plan. |
|
||
| `mempalace.capture_artifacts` | boolean | `true` | When `mempalace.enabled` is `true`: file phase artifacts (`CONTEXT.md`, `PLAN.md`, `SUMMARY.md`) verbatim into MemPalace at their respective phase boundaries (`discuss:post`, `plan:post`, `verify:post`). Also captures confirmed bug→fix pairs at `execute:wave:post`. |
|
||
| `mempalace.mirror_kg` | boolean | `true` | When `mempalace.enabled` is `true`: mirror decisions and learnings into MemPalace's temporal knowledge graph (`mempalace_kg_add` with `valid_from` = phase date) alongside drawer capture. |
|
||
| `mempalace.cross_project_tunnels` | boolean | `false` | When `mempalace.enabled` is `true`: at `ship:post`, propose and create tunnels between this wing's rooms and semantically related wings in other projects (`mempalace_find_tunnels`, `mempalace_create_tunnel`). |
|
||
| `mempalace.diary_journal` | boolean | `true` | When `mempalace.enabled` is `true`: at `ship:post`, write a per-agent diary entry (`mempalace_diary_write`) summarising the session. |
|
||
| `mempalace.auto_capture_hooks` | boolean | `false` | **Reserved — not yet implemented.** Intended to install MemPalace's native Claude Code hooks (`session-start`, `stop`, `precompact`) for passive mid-session capture between loop points. The capability's `hooks` array is currently empty; no native hooks are installed by setting this key. This key is forward-declared for the future "Connected Capability" phase. |
|
||
|
||
#### Memory modes in detail
|
||
|
||
| Mode | KG-query source | Recall source | Coupling |
|
||
|------|-----------------|---------------|---------|
|
||
| `augment` (default) | GSD native + palace (additive) | GSD native + palace search | lowest |
|
||
| `kg_backend` | palace temporal KG primary, `.planning/graphs/` fallback | GSD native + palace search | medium |
|
||
| `replace` | palace primary, native fallback | palace as source of truth, native fallback | highest |
|
||
|
||
Mode is read at hook-render time; switching modes is a config change, not a reinstall. In every mode the palace is `onError:skip` and default-resilient — an unreachable palace degrades to native memory, and GSD keeps writing `.planning/graphs/` so no memory is lost. Switching an established project to `kg_backend`/`replace` changes how new recall/capture resolve but does not backfill existing `.planning/graphs/` into the palace (a separate, not-yet-implemented concern).
|
||
|
||
#### Example
|
||
|
||
```bash
|
||
# Enable MemPalace (augment is the default mode)
|
||
gsd-tools query config-set mempalace.enabled true
|
||
|
||
# Optional: route knowledge-graph recall through the palace's temporal KG (native as fallback)
|
||
# gsd-tools query config-set mempalace.memory_mode kg_backend
|
||
|
||
# Enable cross-project tunnel proposals at ship:post
|
||
gsd-tools query config-set mempalace.cross_project_tunnels true
|
||
```
|
||
|
||
<a id="graphify-settings"></a>
|
||
### Graphify Settings
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `graphify.enabled` | boolean | `false` | Enable the project knowledge graph. When `true`, `/gsd-graphify` builds and queries a graph in `.planning/graphs/`. Added in v1.36 |
|
||
| `graphify.build_timeout` | number (seconds) | `300` | Maximum seconds allowed for a `/gsd-graphify build` run before it aborts. Added in v1.36 |
|
||
| `graphify.auto_update` | boolean | `false` | **Opt-in (issue #3347).** When `true` (and `graphify.enabled` is also `true`), the bundled PostToolUse hook `hooks/gsd-graphify-update.sh` auto-rebuilds the project knowledge graph in a detached background process after `git commit/merge/pull/rebase --continue/cherry-pick` on the default branch (`git.base_branch` override, else `main`/`master`/`trunk`). Hook returns instantly; the rebuild updates `.planning/graphs/{graph.json,graph.html,GRAPH_REPORT.md}` and writes `.planning/graphs/.last-build-status.json` (`{ts, status: "running"\|"ok"\|"failed", exit_code, duration_ms, head_at_build}`). PID-locked, CI-aware (`$CI` env suppresses), bails silently if `graphify` is not on `PATH`. Default `false` so existing behaviour is unchanged after upgrade. |
|
||
| `graphify.graph_path` | string (path) | _unset_ | **Umbrella/multi-repo support (issue #1825).** Overrides where `/gsd-graphify query\|status\|diff` read the knowledge graph from. Set to a path (relative to the project root, or absolute) pointing at a shared umbrella-level `graph.json` so a single curated cross-repo graph serves every sub-project without N drifting mirror copies. The diff snapshot (`.last-build-snapshot.json`) travels with the configured graph (same directory); the auto-update status sidecar stays project-local. Build stays project-scoped (`.planning/graphs/`) — build the umbrella graph in the umbrella project, then point sub-projects at it. When unset, behaviour is byte-identical to the historical `.planning/graphs/graph.json`. A clear, actionable error is returned when the configured file is missing. |
|
||
|
||
#### Multi-developer setup
|
||
|
||
When multiple developers rebuild the graph in the same repository, `graphify hook install` (run once per clone) installs a git merge driver that union-merges concurrent `graph.json` writes, eliminating conflict markers. It also registers the post-commit rebuild hook, writes `.gitattributes`, and adds `graphify merge-driver` to `.git/config`. Solo projects may skip this step. Introduced upstream in graphify v0.7.0 alongside the `built_at_commit` freshness signal surfaced by `/gsd-graphify status`.
|
||
|
||
#### Commit-based staleness
|
||
|
||
`/gsd-graphify status` reports two orthogonal staleness signals:
|
||
|
||
- **`stale`** (mtime-based, 24-hour window) — when the graph file was last
|
||
written. Useful when graphify isn't run automatically.
|
||
- **`commit_stale`** (commit-based, requires graphify v0.7+) — whether the
|
||
graph was built against the current `git HEAD`. Trustworthy when present.
|
||
Tri-state: `true` / `false` / `null`. `null` means the signal is
|
||
unavailable (pre-v0.7 graph, no git, or unreachable commit) — fall back
|
||
to the mtime flag.
|
||
|
||
A CI-built graph rebuilt minutes ago against an old checkout will read as
|
||
fresh on mtime but `commit_stale: true`. Surface both when answering
|
||
architecture questions.
|
||
|
||
<a id="refactor-trigger-settings"></a>
|
||
### Refactor-Trigger Settings
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `refactor.trigger_enabled` | boolean | `false` | Enable the complexity-triggered refactor hook. When `true`, an `execute:post` step evaluates the complexity of the files the phase touched and writes a scoped refactor proposal if a function crosses `refactor.complexity_threshold` or jumps past `refactor.complexity_jump_delta`. Opt-in; when `false` the hook never runs. Added in v1.10.0 (#1953) |
|
||
| `refactor.complexity_threshold` | number | `15` | Absolute per-function complexity above which a refactor proposal is surfaced. Semantics match ESLint's `complexity: {max: N}` — the trigger is strictly greater, so a score of exactly `N` does not trigger. Default `15` follows SonarSource's default; ESLint's own default is `20` and radon's rank C begins at `11`. Raise it if proposals feel like noise. Added in v1.10.0 |
|
||
| `refactor.complexity_jump_delta` | number | `5` | Complexity growth above which a refactor proposal is surfaced even when the absolute threshold is not reached. Measured against the function's anchor — the score recorded the last time the function was consciously dispositioned (`refactor accept`/`refactor decline`) — so it accumulates across phases and catches slow creep the absolute threshold would miss. Strictly greater, as with the threshold. Added in v1.10.0 |
|
||
| `refactor.trigger_strict` | boolean | `false` | Record an untriaged refactor proposal as an open `deviation` entry in the broken-windows ledger, so it becomes a tracked task that must be resolved before ship. Off by default and deliberately so: a blocking complexity number is a metric an executor can satisfy by splitting one coherent function into two incoherent ones, so the entry clears on the proposal being dispositioned (`gsd-tools refactor accept\|decline`), never on the score improving. Ship blocking is the broken-windows capability's existing `ship:pre` gate — enable it separately with `workflow.windows_enforce`. With broken-windows absent, strict mode still records the proposal locally and says so; it cannot block on its own. Enabling `refactor.trigger_strict` without also enabling `workflow.windows_enforce` (or with broken-windows not installed) surfaces a typed `refactor_strict_not_enforcing` warning on every triggering `refactor evaluate`, naming the exact remediation. Advisory mode (the default) surfaces the same proposal and tracks nothing. Added in v1.10.0 |
|
||
|
||
See [ADR-1953](adr/1953-complexity-triggered-refactor.md) for the design rationale, including why the anchor moves only on disposition and never on the score improving.
|
||
|
||
### Usage
|
||
|
||
```bash
|
||
# Enable a feature
|
||
gsd-tools query config-set features.global_learnings true
|
||
|
||
# Disable a feature
|
||
gsd-tools query config-set features.thinking_partner false
|
||
```
|
||
|
||
The `features.*` namespace is a dynamic key pattern — new feature flags can be added without modifying `VALID_CONFIG_KEYS`. Any key matching `features.<name>` is accepted by the config system.
|
||
|
||
---
|
||
|
||
## Capability Overlay (installed third-party capabilities)
|
||
|
||
GSD supports an **installed overlay** of third-party capability manifests that are composed with the frozen first-party registry at runtime via `loadRegistry({ includeInstalled: true })` (ADR-1244; see [`docs/reference/capability-manifest.md`](reference/capability-manifest.md) and [`docs/how-to/import-a-capability-from-a-url.md`](how-to/import-a-capability-from-a-url.md)).
|
||
|
||
### Install roots
|
||
|
||
Capability manifests (`capability.json`) are discovered from two scoped roots:
|
||
|
||
| Scope | Path |
|
||
|-------|------|
|
||
| Global | `$GSD_HOME/.gsd/capabilities/<id>/capability.json` |
|
||
| Project | `<projectRoot>/.gsd/capabilities/<id>/capability.json` |
|
||
|
||
`GSD_HOME` defaults to your home directory (`~`) when unset. Both roots are scanned on every `loadRegistry` call; neither requires config changes to activate.
|
||
|
||
### Composition and first-party-wins invariant
|
||
|
||
Installed overlay capabilities are merged via the same `buildRegistry` pipeline as first-party capabilities, so all derived views (`bySkill`, `byAgent`, `byLoopPoint`, `configKeys`) cover first-party and overlay entries identically. **First-party always wins**: an overlay entry is rejected at load time if its `id`, any owned skill or agent stem, or any federated config key collides with a first-party entry, or if its `id` uses a reserved prefix (`gsd-`, `gsd-core-`, `anthropic-`). Rejected entries emit a warning and are skipped; they never crash the load loop.
|
||
|
||
### Load-time `engines.gsd` compatibility gate
|
||
|
||
Each overlay manifest may declare an `engines.gsd` semver range. At load time GSD evaluates this range against the running GSD version. An overlay that does not satisfy the range is **skipped with a warning** — it is never loaded and never crashes the loop. Manifests without an `engines.gsd` field are accepted unconditionally.
|
||
|
||
### Gate-kind fail-open policy (#2009)
|
||
|
||
If a skipped or load-failed overlay capability (for example, one whose `engines.gsd` range is incompatible) declared a `gate`-kind loop hook, the loop resolver does **not** inject a gate at that hook point (fail OPEN): the loop proceeds. Instead it emits a loud warning — to stderr and in the `loop render-hooks` envelope's `warnings` array — naming the load-failure reason and the exact remediation, `gsd capability remove <id>`, so the operator is loudly told how to clear it. Skipped capabilities whose hooks are `step` or `contribution` kind skip open too, as before — the loop proceeds without them.
|
||
|
||
### Overlay config federation
|
||
|
||
Config keys declared in an overlay capability's `.config` slice federate into the `loadConfig` return value via the same Federated Config channel as first-party capability keys. They appear as valid keys in `config-schema.cjs` (`isValidConfigKey`) and in the runtime config schema, so overlay capabilities can declare project-local config toggles without editing the central config schema.
|
||
|
||
> **See also:** [`docs/reference/capability-manifest.md`](reference/capability-manifest.md) for the full `capability.json` schema, [`docs/how-to/import-a-capability-from-a-url.md`](how-to/import-a-capability-from-a-url.md) for installation steps, and [ADR-1244](adr/1244-capability-ecosystem.md) for the design record.
|
||
|
||
---
|
||
|
||
## Parallelization Settings
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `parallelization` | boolean | `true` | Shorthand for `parallelization.enabled`. Setting `parallelization false` disables parallel execution without changing other sub-keys |
|
||
| `parallelization.enabled` | boolean | `true` | Run independent plans simultaneously |
|
||
| `parallelization.plan_level` | boolean | `true` | Parallelize at plan level |
|
||
| `parallelization.task_level` | boolean | `false` | Parallelize tasks within a plan |
|
||
| `parallelization.skip_checkpoints` | boolean | `true` | Skip checkpoints during parallel execution |
|
||
| `parallelization.max_concurrent_agents` | number | `3` | Maximum simultaneous agents |
|
||
| `parallelization.min_plans_for_parallel` | number | `2` | Minimum plans to trigger parallel execution |
|
||
|
||
> **Pre-commit hooks and parallel execution**: When parallelization is enabled, executor agents commit with `--no-verify` to avoid build lock contention (e.g., cargo lock fights in Rust projects). The orchestrator validates hooks once after each wave completes. STATE.md writes are protected by file-level locking to prevent concurrent write corruption. If you need hooks to run per-commit, set `parallelization.enabled: false`.
|
||
|
||
---
|
||
|
||
## STATE.md Frontmatter (Phase Lifecycle)
|
||
|
||
`STATE.md` carries YAML frontmatter that the status-line hook reads on every render. v1.40 adds four optional phase-lifecycle fields read by `parseStateMd()` and rendered by `formatGsdState()`:
|
||
|
||
| Field | Type | Purpose |
|
||
|-------|------|---------|
|
||
| `active_phase` | string (e.g. `"4.5"`) | Phase number when an orchestrator command is in flight |
|
||
| `next_action` | string | Recommended next command when idle (`discuss-phase` / `plan-phase` / `execute-phase` / `verify-phase`) |
|
||
| `next_phases` | YAML flow array | Phases the `next_action` applies to (e.g. `["4.5"]`) |
|
||
| `progress` | block | Nested `total_phases` / `completed_phases` / `percent` for the milestone progress bar |
|
||
|
||
All four fields are **optional and additive** — STATE.md files without them keep rendering exactly as in v1.38.x. See [STATE.md schema](reference/state-md.md) for the full field reference, parser constraints, and rendering scenes.
|
||
|
||
---
|
||
|
||
## Git Branching
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `git.branching_strategy` | enum | `none` | `none`, `phase`, or `milestone` |
|
||
| `git.base_branch` | string | `main` | The integration branch that phase/milestone branches are created from and merged back into. Override when your repo uses `master` or a release branch |
|
||
| `git.protected_branches` | array of non-empty strings | (none) | Optional additional shared branches that should trigger protected-branch warnings alongside the resolved base branch |
|
||
| `git.allow_default_branch_commits` | boolean | `false` | Escape hatch (#3819): when `true`, the executor's pre-commit guard no longer refuses to commit on the resolved default branch. Explicitly configured `git.protected_branches` names are still enforced. |
|
||
| `git.create_tag` | boolean | `true` | Create a git tag (`v[X.Y]`) on milestone completion. Set to `false` for projects with their own release flow |
|
||
| `git.phase_branch_template` | string | `gsd/phase-{phase}-{slug}` | Branch name template for phase strategy |
|
||
| `git.milestone_branch_template` | string | `gsd/{milestone}-{slug}` | Branch name template for milestone strategy |
|
||
| `git.quick_branch_template` | string or null | `null` | Optional branch name template for `/gsd-quick` tasks |
|
||
|
||
### Protected Branch Warnings
|
||
|
||
`git.protected_branches` is optional and has no persisted default. When the field is absent,
|
||
GSD protects only the resolved base branch, preserving existing project behavior. Every configured
|
||
item must be a non-empty string. The configured list extends the resolved base branch; it never
|
||
replaces that branch or changes base-branch detection.
|
||
|
||
A match produces an advisory warning at execute-phase and ship and does not change
|
||
`git.branching_strategy: "none"`: GSD still continues on the current branch and ship still offers
|
||
to create a feature branch.
|
||
|
||
Matching is by exact branch name — there is no glob or prefix support, so a git-flow
|
||
layout must name each `release/*` or `hotfix/*` branch it wants protected. An entry that
|
||
is not a non-empty string is ignored with a warning naming it, and the remaining names
|
||
still apply.
|
||
|
||
```json
|
||
{
|
||
"git": {
|
||
"branching_strategy": "none",
|
||
"protected_branches": ["develop", "staging"]
|
||
}
|
||
}
|
||
```
|
||
|
||
### Escape Hatch: Committing on the Default Branch
|
||
|
||
Some projects intentionally run GSD directly on their default branch (no branch-per-phase
|
||
workflow). For those, `git.allow_default_branch_commits: true` tells the executor's pre-commit
|
||
guard (see `agents/gsd-executor.md`) to stop refusing commits on the resolved default branch.
|
||
It narrows only the *automatic* default-branch protection — any branch name explicitly listed
|
||
in `git.protected_branches` stays protected regardless of this flag.
|
||
|
||
```json
|
||
{
|
||
"git": {
|
||
"allow_default_branch_commits": true
|
||
}
|
||
}
|
||
```
|
||
|
||
This does not change what gets committed, commit message format, or behavior on any
|
||
non-default branch — see issue #3819.
|
||
|
||
### Strategy Comparison
|
||
|
||
| Strategy | Creates Branch | Scope | Merge Point | Best For |
|
||
|----------|---------------|-------|-------------|----------|
|
||
| `none` | Never | N/A | N/A | Solo development, simple projects |
|
||
| `phase` | At `execute-phase` start | One phase | User merges after phase | Code review per phase, granular rollback |
|
||
| `milestone` | At first `execute-phase` | All phases in milestone | At `complete-milestone` | Release branches, PR per version |
|
||
|
||
### Template Variables
|
||
|
||
| Variable | Available In | Example |
|
||
|----------|-------------|---------|
|
||
| `{phase}` | `phase_branch_template` | `03` (zero-padded) |
|
||
| `{slug}` | Both templates | `user-authentication` (lowercase, hyphenated) |
|
||
| `{milestone}` | `milestone_branch_template` | `v1.0` |
|
||
| `{num}` / `{quick}` | `quick_branch_template` | `260317-abc` (quick task ID) |
|
||
|
||
**`phase_branch_template`'s `{slug}` when no slug can be derived** (no name segment on disk, or a name entirely outside the slug generator's scope): the `{slug}` token — and one adjacent separator — is dropped rather than substituted with a placeholder word, so `gsd/phase-{phase}-{slug}` renders `gsd/phase-08`, not `gsd/phase-08-phase`. This keeps the branch name honest about what it does not know instead of reading as a real (but wrong) name.
|
||
|
||
Example quick-task branching:
|
||
|
||
```json
|
||
"git": {
|
||
"quick_branch_template": "gsd/quick-{num}-{slug}"
|
||
}
|
||
```
|
||
|
||
### Merge Options at Milestone Completion
|
||
|
||
| Option | Git Command | Result |
|
||
|--------|-------------|--------|
|
||
| Squash merge (recommended) | `git merge --squash` | Single clean commit per branch |
|
||
| Merge with history | `git merge --no-ff` | Preserves all individual commits |
|
||
| Delete without merging | `git branch -D` | Discard branch work |
|
||
| Keep branches | (none) | Manual handling later |
|
||
|
||
---
|
||
|
||
## Gate Settings
|
||
|
||
Control confirmation prompts during workflows.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `gates.confirm_project` | boolean | `true` | Confirm project details before finalizing |
|
||
| `gates.confirm_phases` | boolean | `true` | Confirm phase breakdown |
|
||
| `gates.confirm_roadmap` | boolean | `true` | Confirm roadmap before proceeding |
|
||
| `gates.confirm_breakdown` | boolean | `true` | Confirm task breakdown |
|
||
| `gates.confirm_plan` | boolean | `true` | Confirm each plan before execution |
|
||
| `gates.execute_next_plan` | boolean | `true` | Confirm before executing next plan |
|
||
| `gates.issues_review` | boolean | `true` | Review issues before creating fix plans |
|
||
| `gates.confirm_transition` | boolean | `true` | Confirm phase transition |
|
||
|
||
---
|
||
|
||
## Safety Settings
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `safety.always_confirm_destructive` | boolean | `true` | Confirm destructive operations (deletes, overwrites) |
|
||
| `safety.always_confirm_external_services` | boolean | `true` | Confirm external service interactions |
|
||
|
||
---
|
||
|
||
## Security Settings
|
||
|
||
Settings for the security enforcement feature (v1.31). All follow the **absent = enabled** pattern. These keys live under `workflow.*` in `.planning/config.json` — matching the shipped template and the runtime reads in `workflows/plan-phase.md`, `workflows/execute-phase.md`, `workflows/secure-phase.md`, and `workflows/verify-work.md`.
|
||
|
||
These keys live under `workflow.*` — that is where the workflows and installer write and read them. Setting them at the top level of `config.json` is silently ignored.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `workflow.security_enforcement` | boolean | `true` | Enable threat-model-anchored security verification via `/gsd-secure-phase`. When `false`, security checks are skipped entirely |
|
||
| `workflow.security_asvs_level` | number (1-3) | `1` | OWASP ASVS verification level. Level 1 = opportunistic, Level 2 = standard, Level 3 = comprehensive |
|
||
| `workflow.security_block_on` | string | `"high"` | Minimum threat severity that blocks phase advancement. The auditor counts only open threats at or above this severity toward the blocking gate; `none` disables severity blocking. Options: `"critical"`, `"high"`, `"medium"`, `"low"`, `"none"` |
|
||
|
||
### Injection blocking (top-level `security.*`)
|
||
|
||
Distinct from the `workflow.security_*` keys above: the read-injection scanner reads a **top-level** `security` object (not `workflow.security`). Set it with `gsd config-set security.injection_blocking true` — it persists as a nested key (`security.injection_blocking`), never a flat dotted key.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `security.injection_blocking` | boolean | `false` | Opt-in circuit-breaker for the read-injection scanner hook (`gsd-read-injection-scanner.js`, PostToolUse on `Read`/`WebFetch`/`WebSearch`). Default (`false`) is **advisory**: HIGH-confidence injection detections are logged but not blocked. When `true`, a HIGH detection emits `decision: "block"` to halt the agent's next step. Because the hook runs *after* the fetch, blocking does **not** retroactively redact content already in the transcript — it is a circuit-breaker, not a redactor. See the [security model](explanation/security-model.md) and [ADR-1577](adr/1577-untrusted-input-boundary-and-injection-blocking.md). |
|
||
|
||
---
|
||
|
||
## Decision Coverage Gates (`workflow.context_coverage_gate`)
|
||
|
||
When `discuss-phase` writes implementation decisions into CONTEXT.md
|
||
`<decisions>`, two gates ensure those decisions survive the trip into
|
||
plans and shipped code (issue #2492).
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `workflow.context_coverage_gate` | boolean | `true` | Toggle for both decision-coverage gates. When `false`, both the plan-phase translation gate and the verify-phase validation gate skip silently. |
|
||
|
||
### What the gates do
|
||
|
||
**Plan-phase translation gate (BLOCKING).** Runs immediately after the
|
||
existing requirements coverage gate, before plans are committed. For each
|
||
trackable decision in `<decisions>`, it checks that the decision id
|
||
(`D-NN`) or its text appears in at least one plan's `must_haves`,
|
||
`truths`, or `objective` (front-matter), a `## must_haves`/`truths`/`tasks`/`objective`
|
||
heading, or an `<objective>`/`<tasks>`/`<task>`/`<action>`/`<read_first>`/`<behavior>`/`<verify>`/`<acceptance_criteria>`/`<done>`
|
||
tag body. A miss surfaces the missing decision by id and refuses
|
||
to mark the phase planned.
|
||
|
||
**Verify-phase validation gate (NON-BLOCKING).** Runs alongside the other
|
||
verify steps. Searches every shipped artifact (PLAN.md, SUMMARY.md, files
|
||
modified, recent commit subjects) for each trackable decision. Misses are
|
||
written to VERIFICATION.md as a warning section but do **not** flip the
|
||
overall verification status. The asymmetry is deliberate — by verify time
|
||
the work is done, and a fuzzy substring miss should not fail an otherwise
|
||
green phase.
|
||
|
||
### Invoking the plan gate directly
|
||
|
||
The plan-phase translation gate is runnable standalone (the same form the
|
||
workflow's gate dispatch uses):
|
||
|
||
```bash
|
||
gsd_run check decision-coverage-plan <phase-dir> <context-path>
|
||
```
|
||
|
||
The context path may also be supplied with the `--context` flag, following
|
||
the same convention as the other flag-taking check verbs (e.g.
|
||
`check predicate`):
|
||
|
||
```bash
|
||
gsd_run check decision-coverage-plan <phase-dir> --context <path>
|
||
gsd_run check decision-coverage-plan --context <path> [<phase-dir>]
|
||
```
|
||
|
||
The flag wins when both a positional context path and `--context` are given;
|
||
the positional form keeps working unchanged. A `--context` with no value is
|
||
a caller error — the gate fails closed with the missing-argument error, the
|
||
same as calling it with no context path at all, rather than silently
|
||
skipping. The phase directory remains a separate positional argument (there
|
||
is no `--phase` flag; the workflow caller passes both positionals).
|
||
|
||
### How to write decisions the gates accept
|
||
|
||
The discuss-phase template already produces `D-NN`-numbered decisions.
|
||
The gate is happiest when:
|
||
|
||
1. Every plan that implements a decision **cites the id** somewhere —
|
||
`must_haves.truths: ["D-12: bit offsets exposed"]` or a `D-12:` mention
|
||
in the plan body. Strict id match is the cheapest, deterministic path.
|
||
2. Soft phrase matching is a fallback for paraphrases — if a 6+-word slice
|
||
of the decision text appears verbatim in a plan/summary, it counts.
|
||
|
||
### Opt-outs
|
||
|
||
A decision is **not** subject to the gates when any of the following
|
||
apply:
|
||
|
||
- It lives under the `### Claude's Discretion` heading inside `<decisions>`.
|
||
- It is tagged `[informational]`, `[folded]`, or `[deferred]` in its
|
||
bullet (e.g., `- **D-08 [informational]:** Naming style for internal
|
||
helpers`).
|
||
|
||
Use these escape hatches when a decision genuinely doesn't need plan
|
||
coverage — implementation discretion, future ideas captured for the
|
||
record, or items already deferred to a later phase.
|
||
|
||
---
|
||
|
||
## Review Settings
|
||
|
||
Configure per-CLI model selection for `/gsd-review`. When set, overrides the CLI's default model for that reviewer.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `review.models.gemini` | string | (CLI default) | Model used when `--gemini` reviewer is invoked |
|
||
| `review.models.claude` | string | (CLI default) | Model used when `--claude` reviewer is invoked |
|
||
| `review.models.codex` | string | (CLI default) | Model used when `--codex` reviewer is invoked |
|
||
| `review.models.opencode` | string | (CLI default) | Model used when `--opencode` reviewer is invoked |
|
||
| `review.models.cursor` | string | (CLI default) | Model used when the `--cursor` reviewer is invoked (injected into `--model`) |
|
||
| `review.models.agy` | string | (CLI default) | Model used when the `--antigravity` / `--agy` reviewer is invoked. The key suffix is the CLI's own name (`agy`), not the lane slug — the lane declares which key it reads, so the two need not match |
|
||
| `review.models.kimi-code` | string | (CLI default) | Model used when the `--kimi-code` reviewer is invoked (injected into `-m`) |
|
||
| `review.models.ollama` | string | (server default) | Model name passed to Ollama when `--ollama` reviewer is invoked. If unset, the first available model reported by the server is used (e.g. `llama3`). Set to a specific tag: `gsd config-set review.models.ollama codellama` |
|
||
| `review.models.lm_studio` | string | (server default) | Model name passed to LM Studio when `--lm-studio` reviewer is invoked. If unset, the first available model reported by the server is used. |
|
||
| `review.models.llama_cpp` | string | (server default) | Model name passed to llama.cpp when `--llama-cpp` reviewer is invoked. If unset, the first model reported by `/v1/models` is used. |
|
||
| `review.default_reviewers` | string[] \| null | (all detected reviewers) | Default reviewer subset for no-flag `/gsd-review`. Example: `["gemini","codex"]`. May include configured `review.reviewer_instances` names. Explicit flags and `--all` override this setting. |
|
||
| `review.max_prompt_tokens` | number\|null | null | Default maximum estimated tokens for the assembled review prompt. When set, the prompt is deterministically trimmed before being sent to each reviewer. Per-reviewer overrides via `review.max_prompt_tokens_per_reviewer` take precedence. null = no trim (current behavior). |
|
||
| `review.max_prompt_tokens_per_reviewer` | object | {} | Per-reviewer token budget overrides. Keys are reviewer slugs. Every declared reviewer lane accepts one (`gemini`, `claude`, `codex`, `coderabbit`, `opencode`, `qwen`, `cursor`, `antigravity`, `kimi-code`, `ollama`, `lm_studio`, `llama_cpp`). A lane's value of `-1` (the default) is unset and inherits `review.max_prompt_tokens`; `0` disables trimming for that lane specifically; any other number is that lane's own budget. |
|
||
| `review.parallel_lanes` | boolean | `false` | Dispatch independent reviewer lanes concurrently within a single `/gsd-review` pass. Default `false` keeps the sequential dispatch that protects against provider rate limits. Opt in only when your providers can accept concurrent requests. Convergence cycles stay sequential either way. |
|
||
| `review.ollama_host` | string | `http://localhost:11434` | Base URL of the Ollama server. Override when running Ollama on a non-default port or remote host: `gsd config-set review.ollama_host http://192.168.1.10:11434` |
|
||
| `review.lm_studio_host` | string | `http://localhost:1234` | Base URL of the LM Studio local server. Override when using a non-default port. |
|
||
| `review.llama_cpp_host` | string | `http://localhost:8080` | Base URL of the llama.cpp server (`llama-server`). Override when using a non-default port. |
|
||
|
||
### Prompt budgets for reviewer lanes
|
||
|
||
Every declared reviewer lane can be capped, most usefully the local model servers (Ollama, llama.cpp, LM Studio), which typically accept far fewer tokens than cloud APIs — but any CLI lane can be given a budget too. Setting `review.max_prompt_tokens_per_reviewer` (or the global `review.max_prompt_tokens` fallback, which every lane whose own key is unset inherits) triggers deterministic prompt trimming before the prompt is sent to that reviewer: CONTEXT is dropped first, then RESEARCH, then REQUIREMENTS; PROJECT.md is head-shrunk to the first 40 lines; PLANs are tail-truncated proportionally — instructions and roadmap are always preserved. When a reviewer is trimmed, a disclosure note is injected at the top of the prompt and trim metadata (budget, omitted sections, truncation percentage) is recorded in the REVIEWS.md frontmatter under `trimmed_reviewers`. If even the minimum review set (instructions + roadmap + plan stubs) exceeds the budget, the reviewer is skipped with a warning rather than sending a truncated prompt that would produce misleading feedback.
|
||
|
||
### Example
|
||
|
||
```json
|
||
{
|
||
"review": {
|
||
"models": {
|
||
"gemini": "gemini-2.5-pro",
|
||
"qwen": "qwen-max"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Falls back to each CLI's configured default when a key is absent. Added in v1.35.0 (#1849).
|
||
|
||
---
|
||
|
||
## Manager Passthrough Flags
|
||
|
||
Configure per-step flags that `/gsd-manager` appends to each dispatched command. This allows customizing how the manager runs discuss, plan, and execute steps without manual flag entry.
|
||
|
||
| Setting | Type | Default | Description |
|
||
|---------|------|---------|-------------|
|
||
| `manager.flags.discuss` | string | (none) | Flags appended to discuss-phase commands (e.g., `"--auto"`) |
|
||
| `manager.flags.plan` | string | (none) | Flags appended to plan-phase commands (e.g., `"--skip-research"`) |
|
||
| `manager.flags.execute` | string | (none) | Flags appended to execute-phase commands (e.g., `"--cross-ai"`) |
|
||
|
||
**Example:**
|
||
|
||
```json
|
||
{
|
||
"manager": {
|
||
"flags": {
|
||
"discuss": "--auto",
|
||
"plan": "--skip-research",
|
||
"execute": "--cross-ai"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Invalid flag tokens are sanitized and logged as warnings. Only recognized GSD flags are passed through.
|
||
|
||
---
|
||
|
||
## Model Profiles
|
||
|
||
### Profile Definitions
|
||
|
||
| Agent | `quality` | `balanced` | `budget` | `adaptive` | `inherit` |
|
||
|-------|-----------|------------|----------|------------|-----------|
|
||
| gsd-planner | Opus | Opus | Sonnet | Opus | Inherit |
|
||
| gsd-roadmapper | Opus | Sonnet | Sonnet | Opus | Inherit |
|
||
| gsd-executor | Opus | Sonnet | Sonnet | Sonnet | Inherit |
|
||
| gsd-phase-researcher | Opus | Sonnet | Haiku | Sonnet | Inherit |
|
||
| gsd-project-researcher | Opus | Sonnet | Haiku | Sonnet | Inherit |
|
||
| gsd-research-synthesizer | Sonnet | Sonnet | Haiku | Haiku | Inherit |
|
||
| gsd-debugger | Opus | Sonnet | Sonnet | Opus | Inherit |
|
||
| gsd-codebase-mapper | Sonnet | Haiku | Haiku | Haiku | Inherit |
|
||
| gsd-verifier | Sonnet | Sonnet | Haiku | Sonnet | Inherit |
|
||
| gsd-plan-checker | Sonnet | Sonnet | Haiku | Haiku | Inherit |
|
||
| gsd-integration-checker | Sonnet | Sonnet | Haiku | Haiku | Inherit |
|
||
| gsd-nyquist-auditor | Sonnet | Sonnet | Haiku | Haiku | Inherit |
|
||
| gsd-pattern-mapper | Sonnet | Sonnet | Haiku | Haiku | Inherit |
|
||
| gsd-ui-researcher | Opus | Sonnet | Haiku | Sonnet | Inherit |
|
||
| gsd-ui-checker | Sonnet | Sonnet | Haiku | Haiku | Inherit |
|
||
| gsd-ui-auditor | Sonnet | Sonnet | Haiku | Haiku | Inherit |
|
||
| gsd-doc-writer | Opus | Sonnet | Haiku | Sonnet | Inherit |
|
||
| gsd-doc-verifier | Sonnet | Sonnet | Haiku | Haiku | Inherit |
|
||
|
||
> **All 33 shipped agents have explicit per-profile tier assignments** in the catalog (`gsd-core/bin/shared/model-catalog.json`). The table above shows a representative subset of the most-used agents. For agents not listed here, `model_overrides` accepts any shipped agent name. The authoritative profile data is derived from `gsd-core/bin/shared/model-catalog.json` via `src/model-catalog.cts`.
|
||
|
||
### Per-Agent Overrides
|
||
|
||
Override specific agents without changing the entire profile:
|
||
|
||
```json
|
||
{
|
||
"model_profile": "balanced",
|
||
"model_overrides": {
|
||
"gsd-executor": "opus",
|
||
"gsd-planner": "haiku"
|
||
}
|
||
}
|
||
```
|
||
|
||
Valid override values: `opus`, `sonnet`, `haiku`, `fable`, `inherit`, or any fully-qualified model ID (e.g., `"openai/o3"`, `"google/gemini-2.5-pro"`).
|
||
|
||
On the Claude runtime, fully-qualified Claude model IDs are honored as explicit generation pins (#4192): an ID that names the current tier default (e.g. `"claude-sonnet-5"`) collapses to its tier alias — the same model in the form Claude Code's Agent tool always accepts — while any other ID (e.g. `"claude-opus-4-7"`) is resolved verbatim, so the pinned generation is what `resolve-model` reports. GSD emits a warn-once stderr breadcrumb for verbatim pins, because Claude Code setups whose Agent tool accepts only tier aliases will not honor a full ID. `fable` is a Claude Code Agent-tool alias, not a GSD profile tier: it is valid in `model_overrides` but has no column in the profile table.
|
||
|
||
`model_overrides` can be set in either `.planning/config.json` (per-project)
|
||
or `~/.gsd/defaults.json` (global). Per-project entries win on conflict and
|
||
non-conflicting global entries are preserved, so you can tune a single
|
||
agent's model in one repo without re-setting global defaults. This applies
|
||
uniformly across Claude Code, Codex, OpenCode, Kilo, and the other
|
||
supported runtimes. On Codex and OpenCode, the resolved model is embedded
|
||
into each agent's static config at install time — `spawn_agent` and
|
||
OpenCode's `task` interface do not accept an inline `model` parameter, so
|
||
running `gsd install <runtime>` after editing `model_overrides` is required
|
||
for the change to take effect. See issue #2256.
|
||
|
||
### Per-Phase-Type Models (`models`) — added in v1.41
|
||
|
||
> Express tuning at the **phase** level (planning, research, execution, verification) without learning the agent taxonomy. Added in [#3023](https://github.com/open-gsd/gsd-core/pull/3030).
|
||
|
||
`model_overrides` is per-**agent** (precise but verbose; you have to know that `gsd-codebase-mapper` is research and `gsd-doc-writer` is execution). The `models` block lets you say "Opus for planning and execution, Sonnet for the rest" in two lines:
|
||
|
||
```json
|
||
{
|
||
"model_profile": "balanced",
|
||
"models": {
|
||
"planning": "opus",
|
||
"discuss": "opus",
|
||
"research": "sonnet",
|
||
"execution": "opus",
|
||
"verification": "sonnet",
|
||
"completion": "sonnet"
|
||
},
|
||
"model_overrides": {
|
||
"gsd-codebase-mapper": "haiku"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Phase-type → agent mapping
|
||
|
||
| Phase type | Agents |
|
||
|---|---|
|
||
| `planning` | `gsd-planner`, `gsd-roadmapper`, `gsd-pattern-mapper` |
|
||
| `discuss` | `gsd-assumptions-analyzer` |
|
||
| `research` | `gsd-phase-researcher`, `gsd-project-researcher`, `gsd-research-synthesizer`, `gsd-codebase-mapper`, `gsd-ui-researcher` |
|
||
| `execution` | `gsd-executor`, `gsd-debugger`, `gsd-doc-writer` |
|
||
| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier`, `gsd-code-reviewer` |
|
||
| `completion` | (reserved — no subagent today) |
|
||
|
||
`discuss` and `completion` are accepted by the schema for forward compatibility; setting them today is a no-op until a subagent maps to them.
|
||
|
||
#### Resolution precedence (highest → lowest)
|
||
|
||
```text
|
||
1. model_overrides[<agent>] ← per-agent; full IDs; targeted exception
|
||
2. dynamic_routing.tier_models[<tier>] ← when enabled (see §Dynamic Routing)
|
||
3. models[<phase_type>] ← coarse phase-level tier (this section)
|
||
4. model_profile (per-agent col) ← global tier strategy
|
||
5. Runtime default ← when nothing else applies
|
||
```
|
||
|
||
The five layers compose top-down: `model_profile` is the base tier, `models[<phase_type>]` overrides at the phase level, `dynamic_routing` (when enabled) escalates per-attempt on soft failure, `model_overrides[<agent>]` carves per-agent exceptions at the top, and the runtime default applies when nothing else does. In the example above, all five research agents resolve to `sonnet` *except* `gsd-codebase-mapper`, which the per-agent override pins to `haiku`. `dynamic_routing` is disabled by default — when off (`enabled: false` or block omitted), this section's behavior is unchanged from today.
|
||
|
||
#### Accepted values
|
||
|
||
`models.<phase_type>` accepts only tier aliases:
|
||
|
||
| Value | Effect |
|
||
|---|---|
|
||
| `"opus"` / `"sonnet"` / `"haiku"` | Standard tier — runtime resolution maps to the active runtime's model for that tier |
|
||
| `"inherit"` | Agents in this phase follow the session model (same semantics as `model_profile: "inherit"`) |
|
||
|
||
If you need a fully-qualified model ID (`"openai/gpt-5"`, `"google/gemini-2.5-pro"`), use `model_overrides` per agent instead. `models.*` is intentionally tier-only so the runtime-aware mapping stays correct on Codex / OpenCode / Antigravity CLI installs.
|
||
|
||
#### When to use which
|
||
|
||
| You want | Use |
|
||
|---|---|
|
||
| One global tier strategy ("balanced everywhere") | `model_profile` |
|
||
| Coarse phase-level tuning ("Opus for planning") | `models.<phase_type>` |
|
||
| Per-agent precision ("force haiku on the codebase mapper") | `model_overrides[<agent>]` |
|
||
| Full model ID for a specific agent | `model_overrides[<agent>]: "openai/gpt-5"` |
|
||
|
||
Mix freely — the precedence rule above resolves any overlap deterministically.
|
||
|
||
#### Validation
|
||
|
||
`config-set` rejects unknown phase-types:
|
||
|
||
```bash
|
||
$ gsd config-set models.deployment opus
|
||
Error: 'models.deployment' is not a valid config key
|
||
|
||
# Valid:
|
||
$ gsd config-set models.research sonnet
|
||
```
|
||
|
||
Direct edits to `.planning/config.json` are looser — the resolver simply ignores values it doesn't recognize and falls through to the profile tier — so a typo doesn't silently break tier resolution.
|
||
|
||
### Dynamic Routing with Failure-Tier Escalation (`dynamic_routing`) — added in v1.41
|
||
|
||
> Start cheap, escalate only when the agent fails the gate. Added in [#3024](https://github.com/open-gsd/gsd-core/pull/3031).
|
||
|
||
`dynamic_routing` lets you pay for the cheap tier by default and only escalate to the more expensive tier when the orchestrator detects a soft failure (verification inconclusive, plan-check FLAG, etc.).
|
||
|
||
```json
|
||
{
|
||
"dynamic_routing": {
|
||
"enabled": true,
|
||
"tier_models": {
|
||
"light": "haiku",
|
||
"standard": "sonnet",
|
||
"heavy": "opus"
|
||
},
|
||
"escalate_on_failure": true,
|
||
"max_escalations": 1
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Agent default tiers
|
||
|
||
Each agent in `MODEL_PROFILES` declares one of three default tiers. The resolver picks `tier_models[default_tier]` for the first attempt.
|
||
|
||
| Tier | Agents | Use case |
|
||
|---|---|---|
|
||
| `light` | gsd-codebase-mapper, gsd-doc-classifier, gsd-doc-verifier, gsd-integration-checker, gsd-intel-updater, gsd-nyquist-auditor, gsd-pattern-mapper, gsd-plan-checker, gsd-research-synthesizer, gsd-ui-auditor, gsd-ui-checker | Cheap/fast — pure mappers, scanners, low-stakes audits |
|
||
| `standard` | gsd-advisor-researcher, gsd-ai-researcher, gsd-code-fixer, gsd-code-reviewer, gsd-doc-synthesizer, gsd-doc-writer, gsd-domain-researcher, gsd-eval-auditor, gsd-executor, gsd-phase-researcher, gsd-project-researcher, gsd-ui-researcher, gsd-verifier | Default workhorse — research, writing, primary verification |
|
||
| `heavy` | gsd-assumptions-analyzer, gsd-debug-session-manager, gsd-debugger, gsd-eval-planner, gsd-framework-selector, gsd-planner, gsd-roadmapper, gsd-security-auditor, gsd-user-profiler | Deep reasoning — already at top, can't escalate further |
|
||
|
||
#### Escalation flow
|
||
|
||
```text
|
||
1. Orchestrator spawns agent → resolver returns tier_models[default_tier]
|
||
2. Soft failure?
|
||
├─ no → ✓ done (cheap path)
|
||
└─ yes → orchestrator re-spawns at attempt+1
|
||
→ resolver returns tier_models[next_tier_up]
|
||
→ cap at max_escalations
|
||
3. Hard failure (exception/crash) → bypass escalation, surface immediately
|
||
```
|
||
|
||
If `dynamic_routing.escalate_on_failure: false`, soft failures do **not** advance the tier — every respawn keeps using `tier_models[default_tier]` regardless of the attempt counter. The kill-switch overrides the soft-failure branch above.
|
||
|
||
`light → standard → heavy → heavy` (heavy stays at heavy; can't go further).
|
||
|
||
#### Resolution precedence (highest → lowest)
|
||
|
||
1. **`model_overrides[<agent>]`** — full IDs accepted; targeted exception
|
||
2. **`dynamic_routing.tier_models[<tier>]`** (when `enabled: true`)
|
||
3. **`models[<phase_type>]`** — coarse phase-level (#3023)
|
||
4. **`model_profile`** — per-agent column from active profile
|
||
5. **Runtime default**
|
||
|
||
The `dynamic_routing` block is **disabled by default** — `enabled: false` (or omitting the block) preserves today's static resolution exactly.
|
||
|
||
#### Settings
|
||
|
||
| Key | Type | Default | Description |
|
||
|---|---|---|---|
|
||
| `dynamic_routing.enabled` | boolean | `false` | Master switch. When `true`, the dynamic-routing resolver is used for tier selection. |
|
||
| `dynamic_routing.tier_models.light` | enum | (none) | Tier alias for the light tier. Typically `haiku`. |
|
||
| `dynamic_routing.tier_models.standard` | enum | (none) | Tier alias for standard. Typically `sonnet`. |
|
||
| `dynamic_routing.tier_models.heavy` | enum | (none) | Tier alias for heavy. Typically `opus`. |
|
||
| `dynamic_routing.escalate_on_failure` | boolean | `true` | When false, escalation is disabled (every attempt uses the default tier). |
|
||
| `dynamic_routing.max_escalations` | integer | `1` | Hard cap on retries per agent invocation. Prevents runaway loops. Also caps the provider ladder below. |
|
||
| `dynamic_routing.provider_escalation` | string[] | (none) | Ordered fallback model IDs tried when a run dies on a provider **quota / rate limit**. Added in v1.43 ([#2296](https://github.com/open-gsd/gsd-core/issues/2296)) |
|
||
|
||
#### Provider escalation on quota-exceeded — added in v1.43
|
||
|
||
The tier ladder above escalates *within one provider*. That does not help when the
|
||
provider itself is what ran out: a heavier tier on the same throttled account is still
|
||
throttled. `provider_escalation` is a separate, opt-in ladder for exactly that case.
|
||
|
||
```json
|
||
{
|
||
"dynamic_routing": {
|
||
"enabled": true,
|
||
"tier_models": { "light": "haiku", "standard": "sonnet", "heavy": "opus" },
|
||
"provider_escalation": ["gpt-5", "nvidia/llama-3.3"],
|
||
"max_escalations": 2
|
||
}
|
||
}
|
||
```
|
||
|
||
When an executor dies and `gsd-tools agent classify-failure` classifies the error body as
|
||
`quota-exceeded`, `execute-phase` re-resolves the model from this list instead of waiting
|
||
for a quota reset, logs the switch (`sonnet → gpt-5`), and honors any `Retry-After` the
|
||
provider sent. The ladder is capped at `min(max_escalations, provider_escalation.length)`;
|
||
once spent, GSD reports every model it tried and falls back to the manual recovery prompt
|
||
rather than silently retrying the last one.
|
||
|
||
- **Opt-in.** With no `provider_escalation` configured, quota failures keep today's manual
|
||
wait-for-reset prompt exactly as before.
|
||
- **Quota only.** Other failure classes (`classify-handoff-bug`, `unknown-failure`) never
|
||
consult this ladder — they keep the tier ladder.
|
||
- **`escalate_on_failure: false`** disables this ladder too.
|
||
- Entries are opaque model IDs passed to the runtime. Blank and non-string entries are
|
||
dropped; the surviving order is preserved.
|
||
|
||
#### When to use which
|
||
|
||
| You want | Use |
|
||
|---|---|
|
||
| One tier strategy across all agents | `model_profile` |
|
||
| Coarse phase-level tuning | `models.<phase_type>` |
|
||
| Per-agent precision (full IDs) | `model_overrides` |
|
||
| **Cheap-by-default, escalate only on failure** | **`dynamic_routing`** |
|
||
|
||
`dynamic_routing` is structurally a *cost lever*: you pay Opus rates only for the hard cases that warrant Opus. Compose with `model_overrides` for per-agent exceptions (override always wins).
|
||
|
||
---
|
||
|
||
### Effort Control (`effort`) — added in v1.42
|
||
|
||
> Unified cross-provider effort knob. Added in [#443](https://github.com/open-gsd/gsd-core/issues/443).
|
||
|
||
Control the reasoning effort of agent invocations with a single config. The universal ladder is:
|
||
|
||
```
|
||
minimal < low < medium < high < xhigh < max
|
||
```
|
||
|
||
Effort is rendered per-runtime: `output_config.effort` for Claude (Claude Code subagent `effort` frontmatter / `CLAUDE_CODE_EFFORT_LEVEL` env), `model_reasoning_effort` for Codex (Responses API `reasoning.effort`), and `variant` for OpenCode (agent frontmatter).
|
||
|
||
**OpenCode `variant` is opt-in ([#3706](https://github.com/open-gsd/gsd-core/issues/3706)).** OpenCode resolves a `variant` name against the variants available for the agent's model — the built-in sets are provider-specific (Anthropic ships `high` and `max`; OpenAI the full ladder) and you can define your own in `opencode.jsonc`. GSD writes the key only when an `effort` block is actually configured; with no `effort` config the generated agent carries no `variant` line and OpenCode applies its own default.
|
||
|
||
Note that the gate is on effort being configured **at all**, not on the individual agent being named. Once any `effort` block exists, the usual cascade resolves a level for *every* agent — an `agent_overrides` entry for one agent still leaves the others resolving through `routing_tier_defaults` and the tier ladder — so every generated OpenCode agent gets a `variant` line, not only the one you named. Two levels are never written: `inherit` (which means "follow the host default", so the key is omitted) and any level outside OpenCode's supported set. Runtimes with no declared effort surface — Kilo among them — never receive the key at all.
|
||
|
||
`effort sync` maintains the key too, so changing `effort` config does not require a reinstall: it writes the newly resolved `variant` into each installed OpenCode agent, and removes the key when the agent resolves to `inherit` or to a level OpenCode does not accept — the same states under which install writes nothing.
|
||
|
||
**Cross-provider clamping:** `minimal` is Anthropic-unsupported — it clamps to `low` on Claude.
|
||
|
||
**Codex effort is resolved per model, not per runtime (#3007).** Codex advertises a
|
||
`supported_reasoning_levels` set on each model and validates against it, so the same universal level
|
||
can pass cleanly on one model and clamp on another. GSD therefore renders against the model's own
|
||
advertised set:
|
||
|
||
| Model | Advertised levels |
|
||
|---|---|
|
||
| `gpt-5.6-sol` | `low`, `medium`, `high`, `xhigh`, `max`, `ultra` |
|
||
| `gpt-5.6-terra` | `low`, `medium`, `high`, `xhigh`, `max` |
|
||
| `gpt-5.6-luna` | `low`, `medium`, `high`, `xhigh`, `max` |
|
||
| any other / unknown id | `low`, `medium`, `high`, `xhigh`, `max` (family baseline) |
|
||
|
||
Today every shipped Codex model advertises the same usable range, so the same effort resolves
|
||
identically across `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna` — `ultra` is sol's only
|
||
differentiator, and GSD rejects it for every model regardless (see below), so no observable output
|
||
currently differs by model. The table is per-model, not per-runtime, because Codex declares
|
||
capability per model and the sets are free to diverge — the previous single per-runtime assumption
|
||
is exactly what went stale and produced this change.
|
||
|
||
Three consequences:
|
||
|
||
- **`max` reaches Codex.** It is no longer clamped to `xhigh`. Earlier GSD releases described `max`
|
||
as Anthropic-only; that was accurate when written and Codex has since added it. If you set `max`
|
||
for a Codex agent, your generated `model_reasoning_effort` now says `max` where it previously said
|
||
`xhigh`.
|
||
- **`minimal` no longer reaches Codex.** No Codex model advertises it, so it clamps up to `low` —
|
||
the floor every model does advertise. GSD previously emitted `minimal` verbatim, which Codex
|
||
rejects.
|
||
- **`ultra` is refused outright**, and is not part of GSD's ladder. See below.
|
||
|
||
**Every clamp is now visible.** `resolve-execution` reports the level you asked for alongside the
|
||
level actually rendered, so a downgrade is legible instead of silent. These are flat keys in the
|
||
same result object as `effort_rendered` — there is no nested `effort` object:
|
||
|
||
```json
|
||
{
|
||
"effort_rendered": "low",
|
||
"effort_requested": "minimal",
|
||
"effort_clamped": true,
|
||
"effort_clamp_reason": "requested 'minimal' is not in gpt-5.6-luna's advertised reasoning levels; clamped up to its floor, 'low'."
|
||
}
|
||
```
|
||
|
||
**Why `ultra` is rejected rather than clamped.** Codex's own catalog describes `ultra` as *"Maximum
|
||
reasoning with automatic task delegation"* — it is a mode switch, not a louder `max`. At `ultra`
|
||
Codex enters proactive multi-agent mode and spawns sub-agents on its own initiative, which would run
|
||
underneath GSD's orchestration rather than inside it ([#2167](https://github.com/open-gsd/gsd-core/issues/2167)).
|
||
GSD refuses it for every model, including `gpt-5.6-sol`, which does advertise it. This is
|
||
deliberately stricter than Codex requires: Codex only applies proactive mode to V2 sessions and
|
||
never to spawned sub-agents, but GSD writes effort into generated agent files at install time and
|
||
cannot know the session source of a future invocation. Clamping `ultra` down to `max` was rejected
|
||
as an option — it would silently discard what you actually asked for.
|
||
|
||
The model-catalog's `reasoning_effort` per-tier hint is a legacy field kept for reference; effort is now config-driven.
|
||
|
||
**Precedence (highest → lowest):**
|
||
1. Invocation override (e.g. `--effort` flag on `resolve-execution`)
|
||
2. `effort.agent_overrides[<agent-id>]`
|
||
3. `effort.routing_tier_defaults[<light|standard|heavy>]`, **merged per-tier over the
|
||
built-in tier defaults** (`light: low`, `standard: high`, `heavy: xhigh`) — a partial
|
||
block fills its gaps from the built-ins instead of discarding them, and an invalid
|
||
value falls back to that tier's built-in ([#3531](https://github.com/open-gsd/gsd-core/issues/3531))
|
||
4. `effort.default`
|
||
5. `"high"` (Anthropic Opus 4.8 universal default)
|
||
|
||
```json
|
||
{
|
||
"effort": {
|
||
"default": "high",
|
||
"routing_tier_defaults": {
|
||
"light": "low",
|
||
"standard": "high",
|
||
"heavy": "xhigh"
|
||
},
|
||
"agent_overrides": {
|
||
"gsd-planner": "max"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Settings
|
||
|
||
| Key | Type | Default | Description |
|
||
|---|---|---|---|
|
||
| `effort.default` | enum | `"high"` | Global fallback effort level. Applies when no tier or agent override matches. |
|
||
| `effort.routing_tier_defaults.light` | enum | `"low"` | Effort for light-tier agents (fast mappers/scanners). |
|
||
| `effort.routing_tier_defaults.standard` | enum | `"high"` | Effort for standard-tier agents (workhorse agents). |
|
||
| `effort.routing_tier_defaults.heavy` | enum | `"xhigh"` | Effort for heavy-tier agents (deep reasoning). |
|
||
| `effort.agent_overrides.<agent-id>` | enum | (none) | Per-agent effort override. Beats tier defaults. |
|
||
|
||
Valid effort values: `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, and `inherit` ([#3533](https://github.com/open-gsd/gsd-core/issues/3533)).
|
||
|
||
`inherit` means "follow the session/host default" — it is a declarable choice, not a level:
|
||
at install time the agent's `effort:` frontmatter key (claude), `model_reasoning_effort`
|
||
pin (Codex `.toml`), or `variant:` frontmatter key (OpenCode) is **omitted** for an agent
|
||
resolving to `inherit`; `effort sync` treats
|
||
an absent key as the correct in-sync state and strips a present one; no runtime ever receives
|
||
the literal. An explicit `inherit` also never escalates on failed attempts — your choice
|
||
outranks the automatic ladder.
|
||
|
||
Where you set `inherit` matters: every GSD agent has a routing tier, and the merged tier
|
||
ladder (#3531) answers for tiered agents before `effort.default` is consulted — so a bare
|
||
`effort.default: "inherit"` only affects agents **without** a catalog tier. To make tiered
|
||
agents follow the session, set `effort.routing_tier_defaults` (per tier, or all three) or the
|
||
agent's `agent_overrides` entry to `"inherit"`. `query resolve-execution` shows both views.
|
||
|
||
`query resolve-execution --json` reports two effort views ([#3534](https://github.com/open-gsd/gsd-core/issues/3534)):
|
||
`effort` is the **resolved** config-cascade value; `effort_effective` is what the installed
|
||
agent will actually run at — read from the installed agent's `effort:` frontmatter for the
|
||
claude runtime (`effort_effective_source: "frontmatter"`), reported as `"inherit"` when the
|
||
key is absent (`"frontmatter-absent"` — the agent follows the session effort), and equal to
|
||
the resolved value with source `"resolved"` when there is no install-time channel or no
|
||
agent file to read. `--pick effort` still returns the resolved value.
|
||
|
||
#### Where effort actually reaches — added in v1.8.0
|
||
|
||
Effort resolved from the cascade above reaches a runtime through one of two channels.
|
||
|
||
**Install-time.** The value is baked into the artifacts the installer generates — the
|
||
`effort:` frontmatter key on a Claude subagent, `model_reasoning_effort` in a generated
|
||
Codex `.toml`. This is fixed at install and changes only on reinstall or sync.
|
||
|
||
**Invocation-time.** When GSD spawns another CLI as a subprocess — the cross-AI reviewers
|
||
in `/gsd-review` — the effort is appended to that CLI's own command line. Whether a host
|
||
can receive effort this way is a declared capability (`effortSurface`, ADR-1239), not an
|
||
assumption:
|
||
|
||
| Reviewer CLI | Receives effort as |
|
||
|---|---|
|
||
| `claude` | `--effort <level>` |
|
||
| `opencode` | `--variant <level>` |
|
||
| `codex` | `-c model_reasoning_effort=<level>` |
|
||
|
||
A host whose documentation states no reasoning setting is left **untouched** — no flag is
|
||
guessed, and GSD never writes into your own CLI's config file to set one. Levels a given
|
||
CLI does not accept are clamped to its nearest supported value (`minimal` → `low` for
|
||
Claude, `max` → `xhigh` for Codex), so a cross-provider value never produces an invalid
|
||
argument.
|
||
|
||
Before this, a review run inherited whatever effort happened to be configured in your
|
||
personal CLI config, which is why the same project could produce very different review
|
||
times on two machines. Setting `effort.default` (or an agent/tier override) now controls
|
||
review runs too.
|
||
|
||
---
|
||
|
||
### Fast Mode (`fast_mode`) — added in v1.42
|
||
|
||
> Per-agent fast_mode propagation knob. Added in [#443](https://github.com/open-gsd/gsd-core/issues/443).
|
||
|
||
Control whether fast_mode is propagated to agent invocations. Only accepts real booleans — string `"true"` is rejected.
|
||
|
||
**Note:** `fast_mode` is only propagatable via API runtimes (`api` speed:"fast"). Claude Code has no per-subagent fast-mode mechanism — `/fast` is session-level only, so emitting a `fast_mode` frontmatter key on a Claude subagent is a silent no-op. `fast_mode_supported` in `resolve-execution` output tells you if the configured runtime supports it.
|
||
|
||
**Precedence (highest → lowest):**
|
||
1. Invocation override (e.g. `--fast-mode` flag on `resolve-execution`)
|
||
2. `fast_mode.agent_overrides[<agent-id>]` (boolean)
|
||
3. `fast_mode.routing_tier_defaults[<light|standard|heavy>]` (boolean)
|
||
4. `fast_mode.enabled` (boolean)
|
||
5. `false`
|
||
|
||
```json
|
||
{
|
||
"fast_mode": {
|
||
"enabled": false,
|
||
"routing_tier_defaults": {
|
||
"light": true,
|
||
"standard": false,
|
||
"heavy": false
|
||
},
|
||
"agent_overrides": {}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Settings
|
||
|
||
| Key | Type | Default | Description |
|
||
|---|---|---|---|
|
||
| `fast_mode.enabled` | boolean | `false` | Global fast_mode flag. Only honored when no tier/agent override matches. |
|
||
| `fast_mode.routing_tier_defaults.light` | boolean | `true` | Fast mode for light-tier agents. |
|
||
| `fast_mode.routing_tier_defaults.standard` | boolean | `false` | Fast mode for standard-tier agents. |
|
||
| `fast_mode.routing_tier_defaults.heavy` | boolean | `false` | Fast mode for heavy-tier agents. |
|
||
| `fast_mode.agent_overrides.<agent-id>` | boolean | (none) | Per-agent fast_mode override. |
|
||
|
||
---
|
||
|
||
### Execution Query (`resolve-execution`)
|
||
|
||
Use `node gsd-tools.cjs resolve-execution <agent-type> [--effort <level>] [--fast-mode <true|false>] [--attempt <n>]` to get the full resolved execution context for an agent:
|
||
|
||
```json
|
||
{
|
||
"model": "opus",
|
||
"profile": "balanced",
|
||
"effort": "xhigh",
|
||
"effort_rendered": "xhigh",
|
||
"effort_param": "output_config.effort",
|
||
"effort_propagation": "frontmatter",
|
||
"effort_requested": "xhigh",
|
||
"effort_clamped": false,
|
||
"effort_clamp_reason": null,
|
||
"fast_mode": false,
|
||
"fast_mode_supported": false
|
||
}
|
||
```
|
||
|
||
`effort_param` tells you which runtime parameter to set. `effort_requested` is the level you asked
|
||
for (before any clamp); `effort_rendered` is what actually shipped. `effort_clamped` is `true` only
|
||
when the two differ, and `effort_clamp_reason` explains why (`null` when unclamped). `fast_mode_supported` tells you whether the configured runtime supports per-agent fast_mode propagation.
|
||
|
||
---
|
||
|
||
### Non-Claude Runtimes (Codex, OpenCode, Antigravity CLI, Kilo)
|
||
|
||
> **Codex CLI minimum supported version: `0.130.0`** (issue [#3562](https://github.com/open-gsd/gsd-core/issues/3562)).
|
||
>
|
||
> [Codex CLI 0.130.0](https://github.com/openai/codex/releases/tag/rust-v0.130.0) (released 2026-05-08) removed extra-skills-roots discovery via [openai/codex#21485](https://github.com/openai/codex/pull/21485). From this version forward, Codex CLI only scans `~/.codex/skills/<name>/SKILL.md`, `<project>/.codex/skills/`, and registered plugin roots for invocable skills. GSD installs the `$gsd-*` surface as `~/.codex/skills/gsd-<name>/SKILL.md` so commands resolve after a Codex restart. Earlier Codex CLI versions can show a duplicate listing (the legacy extra-roots scan plus the user-root copies) — restart Codex and either upgrade to ≥ 0.130.0 or accept the duplicates until you do.
|
||
|
||
When GSD is installed for a non-Claude runtime, the installer automatically sets `resolve_model_ids: "omit"` in `~/.gsd/defaults.json`. This causes GSD to return an empty model parameter for all agents, so each agent uses whatever model the runtime is configured with. No additional setup is needed for the default case.
|
||
|
||
If you want different agents to use different models, use `model_overrides` with fully-qualified model IDs that your runtime recognizes:
|
||
|
||
```json
|
||
{
|
||
"resolve_model_ids": "omit",
|
||
"model_overrides": {
|
||
"gsd-planner": "o3",
|
||
"gsd-executor": "o4-mini",
|
||
"gsd-debugger": "o3",
|
||
"gsd-codebase-mapper": "o4-mini"
|
||
}
|
||
}
|
||
```
|
||
|
||
The intent is the same as the Claude profile tiers -- use a stronger model for planning and debugging (where reasoning quality matters most), and a cheaper model for execution and mapping (where the plan already contains the reasoning).
|
||
|
||
**When to use which approach:**
|
||
|
||
| Scenario | Setting | Effect |
|
||
|----------|---------|--------|
|
||
| Non-Claude runtime, single model | `resolve_model_ids: "omit"` (installer default) | All agents use the runtime's default model |
|
||
| Non-Claude runtime, tiered models | `resolve_model_ids: "omit"` + `model_overrides` | Named agents use specific models, others use runtime default |
|
||
| Claude Code with OpenRouter/local provider | `model_profile: "inherit"` | All agents follow the session model |
|
||
| Claude Code with OpenRouter, tiered | `model_profile: "inherit"` + `model_overrides` | Named agents use specific models, others inherit |
|
||
|
||
**`resolve_model_ids` values:**
|
||
|
||
| Value | Behavior | Use When |
|
||
|-------|----------|----------|
|
||
| `false` (default) | Returns Claude aliases (`opus`, `sonnet`, `haiku`) | Claude Code with native Anthropic API |
|
||
| `true` | Maps aliases to full Claude model IDs (`claude-opus-4-8`) | Claude Code with API that requires full IDs |
|
||
| `"omit"` | Returns empty string (runtime picks its default) | Non-Claude runtimes (Codex, OpenCode, Antigravity CLI, Kilo) |
|
||
|
||
### The `tier` Field
|
||
|
||
`node gsd-tools.cjs query resolve-model <agent> --pick tier` returns the tier GSD resolved for that agent, independent of `resolve_model_ids`: `opus` | `sonnet` | `haiku` | `fable` | `inherit` | `unknown`. It is also emitted as a `tier` key in the command's full JSON output.
|
||
|
||
`tier` is computed above the `resolve_model_ids: "omit"` gate, so it stays meaningful exactly where `model` does not — every non-Claude install (blank under `"omit"`) and any install where the runtime's tier map substitutes a name (e.g. `gpt-5.6-luna` for the haiku tier on Codex).
|
||
|
||
`tier` accounts for every step that can change which tier runs, including a `model_policy` preset — a preset resolves after the profile tier and can dispatch a different one, so `model_policy: {provider: anthropic, budget: low}` under a `balanced` profile reports `haiku`, not `sonnet`.
|
||
|
||
**Honesty semantics:** a `model_overrides` pin naming a known alias or a mappable full Claude id reports that alias; a pin to an unmappable raw model id reports `unknown`; a policy-resolved model that maps to no alias — including every non-Claude runtime, where the policy model is passed through verbatim — reports `unknown` rather than falling back to the profile tier; `model_profile: inherit` reports `inherit`; an agent with no catalog entry reports `unknown`. `tier` never guesses, so treat `unknown` and `inherit` as *cannot tell*, never as *adequate*.
|
||
|
||
**One limit:** a `model_profile_overrides.<runtime>.<tier>` entry that repoints a tier at another tier's model makes `tier` report the tier that was asked for, not the tier of the model that answers.
|
||
|
||
### Runtime-Aware Profiles (#2517)
|
||
|
||
When `runtime` is set, profile tiers (`opus`/`sonnet`/`haiku`) resolve to runtime-native model IDs instead of Claude aliases. This lets a single shared `.planning/config.json` work cleanly across Claude and Codex.
|
||
|
||
`resolve-model` JSON output includes `reasoning_effort` when the runtime tier resolved for the agent (after phase-type overrides) defines a `reasoning_effort`. Runtime adapters may pass that value to child-agent launch calls that support it; runtimes without explicit support omit it.
|
||
|
||
**Built-in tier maps:**
|
||
|
||
| Runtime | `opus` | `sonnet` | `haiku` | reasoning_effort |
|
||
|---------|--------|----------|---------|------------------|
|
||
| `claude` | `claude-opus-4-8` | `claude-sonnet-5` | `claude-haiku-4-5` | (not used) |
|
||
| `codex` | `gpt-5.6-sol` | `gpt-5.6-terra` | `gpt-5.6-luna` | `xhigh` / `medium` / `medium` |
|
||
| `qwen` | `qwen3-max-2026-01-23` | `qwen3-coder-plus` | `qwen3-coder-next` | (not used) |
|
||
| `opencode` | `anthropic/claude-opus-4-8` | `anthropic/claude-sonnet-5` | `anthropic/claude-haiku-4-5` | (not used) |
|
||
| `copilot` | `claude-opus-4-8` | `claude-sonnet-5` | `claude-haiku-4-5` | (not used) |
|
||
| `hermes` | `anthropic/claude-opus-4-8` | `anthropic/claude-sonnet-5` | `anthropic/claude-haiku-4-5` | (not used) |
|
||
| `kilo` | `anthropic/claude-opus-4-8` | `anthropic/claude-sonnet-5` | `anthropic/claude-haiku-4-5` | (not used) |
|
||
| `pi` | `claude-opus-4-8` | `claude-sonnet-5` | `claude-haiku-4-5` | (not used) |
|
||
| Group B (`cline`, `cursor`, `windsurf` (alias: `devin-desktop`), `augment`, `trae`, `codebuddy`, `antigravity`) | (no built-in default — your runtime handles model selection) | | | |
|
||
|
||
> **How these model IDs are sourced.** The catalog (`bin/shared/model-catalog.json`) pins each runtime's tier defaults to that provider's current frontier IDs, and may intentionally carry forward-dated IDs ahead of a provider's public docs. To verify an ID is live before changing it, check the provider's own source/API — e.g. Codex: `codex debug models` or the OpenAI Codex models page; Qwen: Alibaba Model Studio model list. Only change an ID that the provider actually rejects — absence from documentation alone is not proof of invalidity.
|
||
|
||
**Codex example** — one config, tiered models, no large `model_overrides` block:
|
||
|
||
```json
|
||
{
|
||
"runtime": "codex",
|
||
"model_profile": "balanced"
|
||
}
|
||
```
|
||
|
||
This resolves `gsd-planner` → `gpt-5.6-sol` (xhigh), `gsd-executor` → `gpt-5.6-terra` (medium), `gsd-codebase-mapper` → `gpt-5.6-luna` (medium). Codex skills pass each resolved `model` and `reasoning_effort` to `spawn_agent` when its visible schema advertises the corresponding field; otherwise they omit the field and inherit the session/static agent configuration.
|
||
|
||
**Claude example** — pin a tier's generation without giving up tier-based profiles (#4192):
|
||
|
||
```json
|
||
{
|
||
"runtime": "claude",
|
||
"model_profile": "quality",
|
||
"model_profile_overrides": {
|
||
"claude": { "opus": "claude-opus-4-7" }
|
||
}
|
||
}
|
||
```
|
||
|
||
On the Claude runtime, tier resolution stays on Claude Code's adaptive tier aliases (`opus` / `sonnet` / `haiku`) unless you override a tier. An override value that names the current tier default collapses back to its alias (the same model in the always-accepted form); any other value — a pinned older generation such as `claude-opus-4-7`, a bare alias repointing the tier, or a non-Anthropic model id — is resolved verbatim, so `resolve-model` reports exactly what the profile pins. Setting `runtime: "claude"` alone (no overrides) changes nothing: aliases resolve exactly as they do with the key absent. `resolve_model_ids: true` remains the global switch for materializing full IDs on every agent.
|
||
|
||
**Per-runtime overrides** — replace one or more tier defaults:
|
||
|
||
```json
|
||
{
|
||
"runtime": "codex",
|
||
"model_profile": "quality",
|
||
"model_profile_overrides": {
|
||
"codex": {
|
||
"opus": "gpt-5-pro",
|
||
"haiku": { "model": "gpt-5-nano", "reasoning_effort": "low" }
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Precedence (highest to lowest):**
|
||
|
||
1. `model_overrides[<agent>]` — explicit per-agent ID always wins.
|
||
2. **Runtime-aware tier resolution** (this section) — when `runtime` is set and profile is not `inherit`. On non-Claude runtimes this is the built-in tier map merged with your `model_profile_overrides`; on the Claude runtime it applies only the `model_profile_overrides.claude.<tier>` entry you set (#4192) — never the built-in defaults, so unpinned installs keep resolving aliases.
|
||
3. `resolve_model_ids: "omit"` — returns empty string when no `runtime` is set (an explicit project-level `"omit"` wins over a `claude` tier override too).
|
||
4. Claude-native default — `model_profile` tier as alias (current default).
|
||
5. `inherit` — propagates literal `inherit` for `Task(model="inherit")` semantics.
|
||
|
||
**Backwards compatibility.** Setups without `runtime` set see zero behavior change — every existing config continues to work identically. Codex installs that auto-set `resolve_model_ids: "omit"` continue to omit the model field unless the user opts in by setting `runtime: "codex"`.
|
||
|
||
**Unknown runtimes.** If `runtime` is set to a value with no built-in tier map and no `model_profile_overrides[<runtime>]`, GSD falls back to the Claude-alias safe default rather than emit a model ID the runtime cannot accept. To support a new runtime, populate `model_profile_overrides.<runtime>.{opus,sonnet,haiku}` with valid IDs.
|
||
|
||
### Profile Philosophy
|
||
|
||
| Profile | Philosophy | When to Use |
|
||
|---------|-----------|-------------|
|
||
| `quality` | Opus for all decision-making, Sonnet for verification | Quota available, critical architecture work |
|
||
| `balanced` | Opus for planning only, Sonnet for everything else | Normal development (default) |
|
||
| `budget` | Sonnet for code-writing, Haiku for research/verification | High-volume work, less critical phases |
|
||
| `inherit` | All agents use current session model | Dynamic model switching, **non-Anthropic providers** (OpenRouter, local models) |
|
||
|
||
---
|
||
|
||
## Model Policy Presets (`model_policy`) — Added in v1.42
|
||
|
||
> **[#49](https://github.com/open-gsd/gsd-core/issues/49)** — provider-neutral model policy config surface. Resolves before legacy `model_profile_overrides`.
|
||
|
||
`model_policy` provides a simpler, provider-neutral way to configure model tiers across runtimes. It is the preferred surface for non-Anthropic runtimes where `model_profile_overrides` would require manually knowing the right model IDs. Configure it via `/gsd-settings` → Section 8 (Model Policy).
|
||
|
||
### Known provider preset
|
||
|
||
Choose a provider and budget level via the settings workflow; GSD writes the canonical model IDs for that provider/budget combination:
|
||
|
||
```json
|
||
{
|
||
"runtime": "codex",
|
||
"model_policy": {
|
||
"provider": "openai",
|
||
"budget": "medium",
|
||
"high": "gpt-5.6-sol",
|
||
"medium": "gpt-5.6-terra",
|
||
"low": "gpt-5.6-luna"
|
||
}
|
||
}
|
||
```
|
||
|
||
Known providers: `openai`, `anthropic`, `anthropic-fable`, `google`, `qwen`. Budget levels: `high`, `medium`, `low`. Use `anthropic` to keep the Opus 4.8-backed Claude preset, or `anthropic-fable` to opt into Claude Fable 5 for high-budget top-tier routing. On the default `claude` runtime, policy-resolved model IDs are mapped to Claude Code agent aliases (for example `claude-fable-5` → `fable`); an ID with no corresponding Claude alias emits a warning and falls back to the configured tier.
|
||
|
||
For advanced per-runtime control, `runtime_tiers` accepts explicit entries using the internal profile tier names (`opus`, `sonnet`, `haiku`):
|
||
|
||
```json
|
||
{
|
||
"runtime": "codex",
|
||
"model_policy": {
|
||
"provider": "openai",
|
||
"runtime_tiers": {
|
||
"codex": {
|
||
"opus": { "model": "gpt-5.6-sol", "reasoning_effort": "high" },
|
||
"sonnet": { "model": "gpt-5.6-terra", "reasoning_effort": "medium" },
|
||
"haiku": { "model": "gpt-5.6-luna", "reasoning_effort": "low" }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Generic provider (escape hatch)
|
||
|
||
Use `provider: "generic"` (or `"custom"`) for OpenRouter, LiteLLM, local gateways, or any runtime where you supply exact model IDs. GSD treats model IDs as opaque strings — no prefix inference, no provider-specific defaults:
|
||
|
||
```json
|
||
{
|
||
"runtime": "opencode",
|
||
"model_policy": {
|
||
"provider": "generic",
|
||
"high": "openrouter/anthropic/claude-opus-4-5",
|
||
"medium": "openrouter/anthropic/claude-sonnet-4-5",
|
||
"low": "openrouter/anthropic/claude-haiku-4-5"
|
||
}
|
||
}
|
||
```
|
||
|
||
### Reasoning effort gating
|
||
|
||
`reasoning_effort` within a `runtime_tiers` entry is forwarded only to runtimes that declare support for it (currently: `codex`). Any runtime not on the allowlist receives the tier entry without the `reasoning_effort` field — it is silently stripped, never leaked.
|
||
|
||
### Precedence
|
||
|
||
`model_policy` resolution sits above `model_profile_overrides` in the resolver:
|
||
|
||
1. `model_overrides[<agent>]` — per-agent explicit ID (highest)
|
||
2. `model_policy.runtime_tiers[<runtime>][<tier>]` — explicit runtime/tier entry
|
||
3. `model_policy` flat `high`/`medium`/`low` keys — for `generic`/`custom` provider
|
||
4. `model_profile_overrides[<runtime>][<tier>]` — legacy per-runtime override
|
||
5. Built-in runtime catalog default
|
||
6. `model_profile` tier alias
|
||
|
||
**Backwards compatibility.** Configs without `model_policy` are unaffected. Existing `model_profile_overrides` blocks continue to work exactly as before.
|
||
|
||
---
|
||
|
||
## Environment Variables
|
||
|
||
| Variable | Purpose |
|
||
|----------|---------|
|
||
| `CLAUDE_CONFIG_DIR` | Override default config directory (`~/.claude/`) |
|
||
| `GEMINI_API_KEY` | Detected by context monitor to switch hook event name |
|
||
| `GSD_AUDIT` | Set to `1` to enable the dispatch audit file (`.planning/.gsd-trace.jsonl`) |
|
||
| `GSD_AUDIT_ARGS` | Set to `1` to include command args in audit/error events (omitted by default) |
|
||
| `GSD_PROJECT` | Override project root for multi-project workspace support (v1.32) |
|
||
| `GSD_SKIP_SCHEMA_CHECK` | Skip schema drift detection during execute-phase (v1.31) |
|
||
| `GSD_EXIT_CONTRACT` | Select the exit-code projection: `v1` (default) or `v2`. See [Exit-code contract](#exit-code-contract-gsd_exit_contract) below. |
|
||
| `GSD_ALLOW_SYMLINKED_DEST` | Set to `1` (or `true`) to permit install/update when `CLAUDE_CONFIG_DIR` (or any artifact-kind child like `skills/`, `hooks/`) is an **intentional, user-owned symlink** pointing outside the install root. v1.7.x write-confinement (ADR-1239 Phase B) refuses such layouts by default to prevent untrusted `destSubpath` traversal. Opt in only if you manage configHome via symlinked external dirs, multi-account config layouts (`~/.claude-personal`, `~/.claude-team`), or dotfiles-managed configHome (nix-darwin, etc.). Two refusals remain load-bearing even with opt-in: path-traversal in `destSubpath` (`../../etc`-style), and a symlink whose resolved target equals the install root itself (would let the prune pass wipe it). |
|
||
| `WSL_DISTRO_NAME` | Detected by installer for WSL path handling |
|
||
|
||
### Exit-code contract (`GSD_EXIT_CONTRACT`)
|
||
|
||
Which integers GSD's commands exit with is **versioned**, so the meanings can be
|
||
sharpened without breaking callers that already depend on today's numbers.
|
||
|
||
| Version | Behavior |
|
||
|---|---|
|
||
| `v1` | **Default.** Today's exit codes, unchanged. |
|
||
| `v2` | Codes come from the exit-code registry. |
|
||
|
||
Select `v2` either way — the flag wins when both are given:
|
||
|
||
```bash
|
||
GSD_EXIT_CONTRACT=v2 gsd-tools <command>
|
||
gsd-tools <command> --exit-contract=v2
|
||
```
|
||
|
||
An unrecognized value is **rejected**, not silently treated as `v1`. That is
|
||
deliberate: a selector that quietly ignores what you asked for is the failure
|
||
mode this contract exists to remove.
|
||
|
||
**What actually differs today.** Only one outcome: a command that ran to
|
||
completion and is reporting a condition **through its result payload** rather
|
||
than as a process failure. Under `v1` that exits `0` — a long-standing
|
||
contract across ~60 call sites, documented in
|
||
[`json-errors.md`](json-errors.md), where a caller detects the condition by
|
||
inspecting the payload rather than the exit code. Under `v2` it exits a
|
||
registered non-zero code instead. Pass or fail, and every other registered
|
||
outcome, are identical under both.
|
||
|
||
Every registered code is non-zero, so a caller written `if ! cmd; then` behaves
|
||
the same for success under either version and trips for everything else.
|
||
Switching to `v2` can turn a false green red; it cannot turn a red green.
|
||
|
||
`v2` is opt-in now and becomes the default at the next major version. Rationale
|
||
and the full band allocation are in
|
||
[ADR-3889](adr/3889-process-exit-contract.md).
|
||
|
||
---
|
||
|
||
## Global Defaults
|
||
|
||
Save settings as global defaults for future projects:
|
||
|
||
**Location:** `~/.gsd/defaults.json`
|
||
|
||
When `/gsd-new-project` creates a new `config.json`, it reads global defaults and merges them as the starting configuration. Per-project settings always override globals.
|
||
|
||
### What a global file can and cannot set at runtime
|
||
|
||
Two different rules apply, and the difference is deliberate ([#3532](https://github.com/open-gsd/gsd-core/issues/3532)):
|
||
|
||
- **In a directory with no `.planning/` at all**, `~/.gsd/defaults.json` is the active
|
||
configuration — model resolution reads it directly.
|
||
- **In a real project (`.planning/config.json` present, even if empty)**, the global file is
|
||
**not read for model resolution** — every model-side key it sets (`model_profile`,
|
||
`model_overrides`, `models`, `dynamic_routing`, `runtime`, and the rest of the resolution
|
||
set) is inert there. GSD prints a one-time stderr warning naming the shadowed keys when it
|
||
detects this, instead of failing silently. To apply a global model setting to a project,
|
||
put it in that project's `.planning/config.json`.
|
||
- **`effort` is the exception**: the install-time effort channel always merges
|
||
`~/.gsd/defaults.json` with the project config (that is how `effort sync` works), so a
|
||
global `effort` block keeps working in projects and does not trigger the warning.
|
||
- **The whole `git.*` namespace is project-scoped and never resolves from the global file**,
|
||
in either directory shape — not `git.base_branch`, not `git.protected_branches`, not
|
||
`git.allow_default_branch_commits`, not
|
||
`git.branching_strategy` or the branch templates. Branch policy is a property of the
|
||
repository, not of the machine, so it is read only from that project's
|
||
`.planning/config.json`. A `git` block in `~/.gsd/defaults.json` still seeds new projects
|
||
(`/gsd-new-project` copies globals into the new `config.json`), but it never takes effect
|
||
at runtime on its own. It is outside the shadowed-key warning above, which covers the
|
||
model-resolution set only.
|
||
|
||
---
|
||
|
||
## Observability
|
||
|
||
The Command Routing Hub emits a structured `DispatchEvent` after every dispatch — including capability commands (`graphify`, `intel`, `audit-uat`, `audit-open`) since #1646. Default behaviour is **silent on success** and **one structured JSON line to stderr on error**.
|
||
|
||
### Stderr error format
|
||
|
||
When a dispatch fails, one JSON line is emitted to stderr:
|
||
|
||
```json
|
||
{ "kind": "HandlerFailure", "traceId": "...", "command": "plan", "timestamp": "...", "message": "..." }
|
||
```
|
||
|
||
The `kind` field matches one of the Hub's error variants: `UnknownCommand`, `InvalidArgs`, `HandlerRefusal`, or `HandlerFailure`. Args are omitted by default (privacy); see `GSD_AUDIT_ARGS` below.
|
||
|
||
### Audit trail (opt-in)
|
||
|
||
Enable the append-only audit file to record every dispatch (success and error):
|
||
|
||
**Via environment variable:**
|
||
```bash
|
||
GSD_AUDIT=1 gsd plan
|
||
```
|
||
|
||
**Via config (`config.audit.enabled`):**
|
||
```json
|
||
{
|
||
"audit": {
|
||
"enabled": true
|
||
}
|
||
}
|
||
```
|
||
|
||
**Audit file location:** `.planning/.gsd-trace.jsonl` (gitignored)
|
||
|
||
Each line is a full `DispatchEvent` JSON object containing both `traceId` (a unique UUID v4 per dispatch) and `parentTraceId` (present when a caller passes `req.parentTraceId` into `Hub.dispatch`). A future init-composer (Phase 2) will wire `parentTraceId` automatically so that all child dispatches of a single top-level invocation share a common parent; until then, leaf dispatches emit `parentTraceId: undefined`. You can correlate child events to a parent by filtering the audit file on `parentTraceId === <rootTraceId>`. The file is append-only and never truncated; rotate or remove it manually when desired. `parentTraceId` must be a canonical UUID v4 (RFC 4122, format `xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx`); values that do not match this format are silently dropped from the emitted event and will not appear in audit output.
|
||
|
||
### Args redaction
|
||
|
||
By default, command args are **omitted** from all emitted events (both stderr errors and the audit file). To include args verbatim:
|
||
|
||
```bash
|
||
GSD_AUDIT_ARGS=1 GSD_AUDIT=1 gsd plan --tdd
|
||
```
|
||
|
||
`GSD_AUDIT_ARGS` applies to both the stderr error line and the audit file simultaneously.
|
||
|
||
---
|
||
|
||
## Related
|
||
|
||
- [Commands](COMMANDS.md)
|
||
- [Configure model profiles](how-to/configure-model-profiles.md)
|
||
- [STATE.md schema](reference/state-md.md)
|
||
- [Docs index](README.md)
|