* 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). --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
438 KiB
Context
Format: this document is machine-greppable. Each operational fact is a single-line predicate (
CLASS.subkey=value). Agent briefs cite predicates by ID verbatim (perMETA.RULE.brief-must-cite-doc) — never paraphrase from this file. New learnings go in as predicates; chronological prose belongs in the session log at the bottom.
Glossary — Domain modules and seams
Milestone Module
Module owning milestone complete (archive roadmap/requirements/phases, build MILESTONES.md entry, update STATE.md), requirements mark-complete (checkbox + table update with regex-global-state fix), and phases clear. Key behaviors: milestone-phase scoping (extract phases from ROADMAP.md milestone slice, support project-code-prefix dirs e.g. CK-01-name, exclude prior-milestone phases), milestone-archive layout (resolve phase dirs from .planning/milestones/v*-phases/ when .planning/phases/ absent), fenced-code-block boundary tracking in extractCurrentMilestone. Source of truth: gsd-core/bin/lib/milestone.cjs (query handlers for milestone.complete, phases.archive, milestone.archive-quick). milestone.archive-quick (#2142 escalation) is the narrow archival-only helper gsd-core/workflows/cleanup.md uses instead of milestone.complete --archive-quick — it shares milestone.complete's quick-task move/README-index logic but skips the ROADMAP/REQUIREMENTS/MILESTONES.md writes and the milestone-completion guards, so it is safe to run against an already-completed milestone. Test consolidation: PR #3753 (10 files → 4). (The SDK milestone surface and GSD.run() milestone runner were retired with the SDK package per ADR-0174.)
Dispatch Pipeline Module
Module that composes Dispatch Policy Module, Query Execution Policy Module, and per-stage handlers (input-validation, plan, execution, result-builder, formatting, error-mapping, observability) into the end-to-end pipeline that produces a QueryDispatchResult. The SDK-era pipeline collapsed onto the Command Routing Hub per ADR-0174; current dispatch seam: gsd-core/bin/lib/command-routing-hub.cjs (see Command Routing Hub below).
Phase Id Module
Module owning the pure phase-id parsing and matching helpers: phase-name normalization, phase-token extraction/matching, canonical phase-directory selection, milestone- and phase-dir id parsing, phase-markdown regex builders, and the ADR-612 bracket phase-id round-trip grammar (escapeRegex, normalizePhaseName, comparePhaseNum, extractPhaseToken, phaseTokenMatches, matchPhaseDirs, phaseNumberForMatch, phaseMarkdownRegexSource/phaseMarkdownRegexSourceExact, getMilestoneFromPhaseId, getPhaseDirFromPhaseId, parsePhaseId/renderPhaseId/toDir over the PhaseId type, isSentinelPhaseId/SENTINEL_RANGES, and the BRACKET_PHASE_TOKEN_SOURCE/PHASE_HEADING_PREFIX_SRC grammar sources, plus the #612 read-path surface: the one bracket identity grammar BRACKET_ID_SRC/BRACKET_MILESTONE_NUMERIC_SRC/BRACKET_DIR_PREFIX_SRC, the case-folding foldBracketId, bracketQualifiedKey, and the convention-gated heading-intro selector phaseHeadingPrefixSrcFor over PHASE_HEADING_BASELINE / BASE_ANY_BRACKET_HEADING_PREFIX_SRC / BASE_PHASE_LABEL_PREFIX_SRC). Also owns the canonical phase KEY surface (#2562) — phaseKeyFromToken/phaseKeyFromDir/phaseKeyFromProse/parentPhaseKey — the padding-, case- and project-code-insensitive identity used whenever two independently-derived phase references (a ROADMAP table cell and a phase directory, say) are compared; deriving one side of such a comparison with a bespoke regex is what silently zeroed a rollup in #2562. Pure string/regex — no I/O, no config, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2a (#865) as the cycle-free leaf that unblocks the roadmap-parser and phase-locator extractions; the core.cjs re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: gsd-core/bin/lib/phase-id.cjs (generated from src/phase-id.cts).
Phase Lifecycle Module
Module owning phase create, rename, complete, remove, list, and plan-index operations, plus phase-dir prefix validation, STATE.md staleness detection, and auto-prune behaviour. Entry point: gsd-core/bin/lib/phase.cjs (CJS surface). Typed phase events: GSDPhaseStartEvent, GSDPhaseStepStartEvent, GSDPhaseStepCompleteEvent, GSDPhaseCompleteEvent. (The SDK native-query surface, the types.ts event definitions, phase-runner.ts, and phase-prompt.ts were retired with the SDK package per ADR-0174.)
Phase Estimation Module
Module owning phase-effort estimation and its calibration against measured reality (ADR-2629, epic #1952). Pure — no I/O, no config reads; the CLI seam (src/estimate-cli.cts, verbs estimate-check / estimate-calibration) owns reading .planning/config.json and .planning/estimation-calibration.json. Interface: parseEstimate/renderEstimate (the PLAN.md estimate: {tokens, tasks, confidence} block), parseActuals/renderActuals (the SUMMARY.md actuals: {tokens, tasks, commits} block), deriveConfidence(sampleCount) → low|med|high, classifyAgainstBudget(estimate, budget) → {overBudget, ratio, recommendation, budgetValid}, computeCalibration(samples) → {factor, sampleCount, applied, confidence, clamped}, applyCalibration, parseCalibrationDocument/renderCalibrationDocument, extractFrontmatterBlock (leading-----anchored scalar-block reader; stays hand-rolled for its TYPE CONTRACT — it returns numeric-looking values as numbers so parseEstimate/parseActuals see the types they validate, whereas the migrated extractFrontmatter, ADR-3473 §8.1/#3881, resolves every scalar as a string under js-yaml's FAILSAFE_SCHEMA; migrating this function onto the shared parser is follow-on work under ADR-3473 §8.1, not done), calibrationBasis (returns estimate.raw_tokens when present, else tokens — calibration must measure actual/raw or the loop un-corrects itself), and measureTokens (a re-export of prompt-budget's estimateTokens). Domain terms: raw vs calibrated tokens — the same token count in two mutually incompatible states, carried by the compile-time brands RawTokens (the planner's uncorrected projection, and the only legal calibration denominator) and CalibratedTokens (the projection with the project's factor applied, and the only figure meaningful against the budget), constructed at trust boundaries via asRawTokens / asCalibratedTokens (#2671). The brands erase at compile time — the emitted .cjs, the CLI JSON, and both frontmatter schemas are unchanged — and exist because mixing the two states was NOT catchable at runtime: both are positive integers of the same magnitude, and the mix-up shipped twice past a green ~26,800-test suite (#2631 factor², #2632 self-defeating loop). asRawTokens refuses a CalibratedTokens by design; the single legitimate crossover (a pre-#2632 plan whose tokens IS the raw projection) lives behind one commented assertion in calibrationBasis. Compile fixtures: tests/fixtures/brand-typing/. smart zone — the usable prefix of a model's context window before output quality degrades, expressed as the configurable workflow.smart_zone_tokens budget (default 100000, a policy default rather than a benchmark constant since the effective ceiling is model/task-dependent); estimate/actuals — a projected phase cost recorded at plan time and the measured cost recorded at completion, both on the same estimateTokens scale so their ratio measures the miss rather than a difference between two measurement methods. Two invariants: (1) every signal is exogenous — the correction routes on a measured actual/estimate ratio and confidence routes on a calibration sample count, never on a model's self-assessment (this project measured self-rated confidence and found it weak — gsd-core/references/honest-verifier.md:25-29; see .out-of-scope/general-purpose-agent-prompt-skills.md); (2) the over-budget flag is advisory — a warning plus a split recommendation, never a block. Calibration is median-of-ratios, clamped to [0.5, 3.0], and inert below 3 samples. CLI seam verbs: estimate-check (classify one figure; --calibrated when the input already has the factor applied — omitting it squares the correction), estimate-calibration (report the current factor), estimate-calibrate (#2632 — pair every completed phase's PLAN estimate with its SUMMARY actuals, rebuild .planning/estimation-calibration.json idempotently, and report the result; this is what closes the loop). Source of truth: gsd-core/bin/lib/phase-estimation.cjs and src/estimate-cli.cts. Test anchors: tests/phase-estimation.test.cjs, tests/estimate-calibrate.test.cjs.
Verification Module
Module owning the canonical phase-verification status projection shared by phase transition, progress, manager, autonomous, and closeout readiness paths. readVerificationStatus(phaseDir, opts?) reads the first *-VERIFICATION.md frontmatter status, maps it through VERIFICATION_ROUTING_TABLE, and fail-closes — only {passed} satisfies the canonical gate; missing/unknown/gaps_found/human_needed/stale all route away from "complete" (#1522). findStaleVerificationSummary flags a SUMMARY newer than the VERIFICATION file (status stale). Both honor a no-throw, degrade-to-safe contract (any FS error → missing / not-stale) and an injectable opts.fs seam. isPhaseComplete(phaseDir, deps?) is the single canonical owner of "is phase P complete?" (ADR-3180 §7.4, issue #3186, disk-strict per #2957): it wraps readVerificationStatus, calling it UNCONDITIONALLY — plan count is never a precondition, so a zero-plan phase with a passing *-VERIFICATION.md is complete (#3168) — and returns { value: { complete, verification }, scope }; complete is exactly verification.status === 'passed'. A ROADMAP checkbox carries no machine authority and is never consulted. cmdPhaseComplete, buildPhaseCompletionProjection, and buildStateFrontmatter all route through it. Source of truth: gsd-core/bin/lib/verification.cjs (generated from src/verification.cts).
SEAM.verification-isphasecomplete.owns=isPhaseComplete(phaseDir, deps?) is the single canonical owner of "is phase P complete?" (ADR-3180 §7.4, issue #3186)
SEAM.verification-isphasecomplete.enforced-by=test:tests/verification-status.test.cjs
Verify Command Grounding Module
Module owning the deterministic resolvability probe over PLAN.md <automated> verify commands (#2401), plus the prior-phase command harvest that feeds the planner. extractAutomatedCommands(planText) pulls every <automated>…</automated> body with its owning <task><name>, in document order, via a ReDoS-safe stop-at-next-open task pattern (shape mirrors PLAN_TASK_BLOCK_RE in verify.cjs) and a monotonic span pointer; non-string input yields []. resolveVerifyCommandTarget(command, {projectRoot, declaredPaths}) is a RECOGNIZER, not a shell interpreter (deliberate, per Greenspun): it grounds exactly two forms — a folded leading cd <literal> chain and npm --prefix <literal> — and any path carrying $, a backtick, *, ?, ~, or a newline returns unresolvable/dynamic_path at WARNING severity, never BLOCKER. Status is a closed 5-atom enum (ok/broken/unresolvable/not_applicable/pending_creation) and severity a closed 3-atom enum (blocker/warning/none); broken is only ever missing_dir or no_manifest, while script_missing/manifest_unreadable/outside_root stay advisory on an ok status. A target an earlier task in the same phase declares (<files> or the ## Artifacts this phase produces section) is pending_creation, never a blocker — without that, every greenfield phase would red. A bare ancestor climb (cd ../.., every segment ..) short-circuits to outside_root without touching the filesystem, because the checker's root and a parallel executor worktree's root differ; a climb naming a concrete sibling (cd ../../frontend — the exact #2401 shape) still names something checkable and is probed normally. The module never executes command text (fs.statSync/existsSync/readFileSync/readdirSync only — PLAN.md is model-authored untrusted input) and deliberately exposes no suggestion field: prescribing a replacement path is the failure being fixed, not the fix. probePhaseVerifyCommands({phaseDir, projectRoot}) backs gsd-tools check verify-command-paths <N> (routed in check-command-router.cjs), degrading to a populated readError rather than throwing — an empty commands with a non-empty readError means could not look, not nothing to report. harvestPriorVerifyCommands({planningDir, beforePhase, limit=20, lookback=3}) walks descending phase dirs for the nearest prior phase with any command, deduped first-seen and capped, and is emitted as init.plan-phase's prior_verify_commands ungated by context_window — the >= 500000 enrichment gate is exactly what starved the planner at 200k. Failing-direction probe (#3172), the module's second concern. Every runnable <automated> must carry a <fails_when> sibling naming what output constitutes failure; a command with no expressible failure mode is not an acceptance test. extractFailingDirections(planText) recovers <automated> and <fails_when> in ONE document-order pass (a single backreferenced alternation — two independent scans would discard the relative positions the pairing walk needs) over the SAME text units as extractAutomatedCommands (each <task> body, then the task-stripped remainder), sharing that function's MAX_BLOCK_WALK guard, MISSING_SENTINEL_RE and task grammar rather than copying them (DEFECT.GENERATIVE-FIX-DIVERGENCE); pairing never crosses a task boundary. Each <fails_when> binds to the nearest PRECEDING <automated>, FIRST-WINS — a redundant second statement for one command is ignored and adds no row, and a statement preceding every command is an orphan WARNING, never a blocker. resolveFailingDirection(command, statement) is the single verdict implementation: status is a closed 6-atom enum (ok/missing/empty/placeholder/sentinel/orphan) over the same 3-atom severity enum, and check ORDER is load-bearing — empty command first, then the MISSING Wave-0 sentinel (exempt at none even when a statement IS present, because it is not runnable and without that exemption every greenfield phase would red), then missing/empty/placeholder. The placeholder set (tbd/todo/n/a/na/none/unknown/tba/?/-) matches the WHOLE trimmed value case-insensitively, never a substring: "TBD in the harness output" is real prose and passes. PRESENCE only — whether a statement names the RIGHT signal stays plan-checker judgment at WARNING, so every BLOCKER is deterministic and reproducible. The probe never prescribes a statement, for the same reason it never prescribes a path: a prescribed one is copied verbatim and carries zero information. probePhaseFailingDirections({phaseDir}) backs gsd-tools check verify-failure-directions <N>, degrading to a populated readError with top-level status: 'unresolvable' — an empty commands with a non-empty readError means could not look, not nothing to report. The #2401 path-probe surface is deliberately NOT overloaded (its 5-atom status and counts are unchanged by a missing statement) so docs/how-to/resolve-verify-command-path-findings.md stays true. Source of truth: gsd-core/bin/lib/verify-command-grounding.cjs (generated from src/verify-command-grounding.cts). Design: .gsd/phase/feat-3172-stated-failing-direction/40-design.md.
Phase Locator Module
Module owning phase-directory search and location: active-phase discovery against the .planning/phases/ tree (searchPhaseInDir, findPhaseInternal) and archived-phase-dir enumeration (getArchivedPhaseDirs), matching phase ids/tokens against the filesystem. Depends only on leaf modules (phase-id for token/name matching, core-utils for fs-scan/path helpers, planning-workspace for planningDir) — no loadConfig, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2d (#881); the core.cjs re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: gsd-core/bin/lib/phase-locator.cjs (generated from src/phase-locator.cts). Since #2830, searchPhaseInDir also parses each plan's depends_on and each completed plan's SUMMARY status and calls Plan Dependency Graph Module's computeHaltPropagation to populate halted_plans/blocked_by/runnable_plans — additive fields; incomplete_plans keeps its pre-#2830 meaning unchanged. Since #3185 (ADR-3180 Decision 1, Phase 3), the module also owns listMilestonePhaseDirs(phasesDir, { cwd, ws, versionOverride, phaseIdConvention }), the single canonical owner of milestone-scoped phase-directory enumeration: it applies the current milestone's ROADMAP.md window (via getMilestonePhaseFilter) and then the canonical isSentinelPhaseId sentinel filter, in that order, over the raw phasesDir directory listing. It returns { value: string[], scope }, where scope is the SCOPE enum from src/planning-scope.cts (complete/truncated/unscoped/unreadable), so a caller can distinguish a genuinely empty milestone from an enumeration that could not be scoped. Consumed by query progress, stats, and the bare phases list, all of which need "which phases belong to this milestone." phases list --phase and --include-archived (lookup/archive questions) read the unscoped physical directory set and do not call this owner. phases clear and milestone complete's phase-archival move call isSentinelPhaseId directly instead — they must sweep every non-sentinel phase directory regardless of milestone window, so they take the sentinel filter without this owner's window scoping. Note that combination is obtainable from the owner itself: called with no cwd, listMilestonePhaseDirs applies no window (inWindow = () => true) while still refusing sentinels unconditionally — "sentinels are never milestone phases" — so an all-milestones, sentinel-free enumeration needs no separate implementation. That is the call collectCalibrationSamples was missing.
SEAM.phase-locator-milestone-enum.owns=listMilestonePhaseDirs(phasesDir, opts) is the single canonical owner of milestone-scoped phase-directory enumeration (ADR-3180 Decision 1, Phase 3, #3185)
SEAM.phase-locator-milestone-enum.enforced-by=test:tests/phase-locator.test.cjs
Since #3882 (ADR-3473 §8.2) the module also owns listAllPhaseDirs(phasesDir, { includeSentinels, phaseIdConvention }) — the OTHER axis, the physical directory set with sentinel inclusion stated rather than implied. includeSentinels is required with no default, so sentinel inclusion cannot be obtained by omission; §8.2's "a caller that wants sentinels asks for them explicitly" is enforced at compile time, not documented against. It mirrors the owner's absent-vs-unreadable handling and returns the same { value, scope } shape, so the discriminator survives. This is what the lookup-index callers (cmdRoadmapAnalyze's _phaseDirNames, cmdInitMilestoneOp's diskPhaseDirs) now call instead of hand-rolling a readdirSync, and what buildAllPhaseDirNamesField (Planning Snapshot Module) delegates to — that function was a near-duplicate of this combination differing only in sort order, and now re-applies its own lexicographic sort over the owner's result because W007's output order is observable. Exactly one readdirSync over the phases directory remains across the two modules.
Plan Dependency Graph Module
Module owning the single halt-propagation engine over a plan's depends_on DAG (#2830). Domain term: halted — a plan that reached a designed stop (a gate failure, a spike concluding without expanding, or any other intentional non-completion) and wrote a SUMMARY recording that fact via status: halted in its frontmatter, as opposed to status: complete (ordinary finish) or no SUMMARY at all (not yet attempted). Domain term: blocked — a plan whose depends_on chain reaches a halted plan, directly or transitively; distinct from merely incomplete (no SUMMARY yet) — an ordinary in-progress/not-yet-started dependency does not block. computeHaltPropagation(nodes: {id, resolvedDependsOn, halted}[]) performs exactly one Kahn's-algorithm topological pass and returns {order, visited, blockedBy}, where blockedBy maps a plan id to the de-duplicated set of halted plan ids transitively upstream of it (diamond-safe, any depth). This is the SHARED engine both of the two independent "which plans are incomplete" readers call — phase.cts's wave-grouping (cmdPhasePlanIndex) and phase-locator.cts's phase-location primitive (searchPhaseInDir) — so the two-implementation divergence that caused #2830 (one parsed depends_on for waves only, the other never parsed it at all) cannot recur: each caller resolves its own raw depends_on tokens to canonical ids before calling in, but the graph traversal itself exists in exactly one place. Pure — no I/O, no config; each caller does its own file reads (a plan's frontmatter, a completed plan's SUMMARY status) and fails open (treats an unreadable/malformed file as "not halted"/"no deps") rather than throwing. Source of truth: gsd-core/bin/lib/plan-dependency-graph.cjs (generated from src/plan-dependency-graph.cts).
Runtime Identity Module
Module owning this package's runtime identity surface and the resolver preference that keeps a shipped workflow off a foreign handler (#3146). Domain term: colliding bin — a binary name published by more than one package with different semantics behind it; here gsd-tools, published by both this package and the predecessor get-shit-done-cc (verified against 1.42.3: bin.gsd-tools → bin/gsd-sdk.js), whose phases.clear deletes where this package's archives, both printing success-shaped output against a gitignored .planning/ (#3129). The fix is resolution, not detection. _runtime-launcher.snippet.sh's PATH branch resolves gsd_run — published only by this package, and self-locating via its own symlink chain to the gsd-tools.cjs beside it — instead of the colliding gsd-tools, so a foreign handler is unreachable from PATH; when no gsd_run is reachable the resolver fails closed through its remaining path-based branches rather than falling back to an arbitrary gsd-tools. unset -f gsd_run leading that branch is load-bearing: on a second source of the preamble command -v gsd_run finds the shell FUNCTION and returns the bare string gsd_run, which would otherwise define the function in terms of itself. An [ -x ] guard was tried here instead and REMOVED — it rejected the bare name, fell through every branch, and reached the resolver's exit 1, which kills a SOURCED caller's shell. Resolution is not the whole fix (#3841). The path-based branches — a project-local install, a runtime config directory — trust their configured location and have no structural guarantee, so the preamble ASSERTS identity once after resolving and before any verb runs: it probes runtime-identity --raw and matches anchored at BOTH ends of the compact payload — the IDENTITY_RAW_PREFIX opener, then anything, then a literal } — because an unanchored substring match verifies the decoy {"packageName":"get-shit-done-cc","note":"@opengsd/gsd-core"}, and an opener-only match verifies a TRUNCATED payload. Closing on } is safe for any future additive field: a JSON object's own brace is always the last character, whatever the last value's type. The trailing } is also load-bearing for a reason unrelated to security, and removing it turns a distant test red. The preamble is inlined into 112 shipped files, and several downstream guards balance braces over RAW TEXT with no awareness of shell quoting — tests/new-project-mvp-prompt.test.cjs's #3784 brace guard scans new-project.md plus new-project/steps/, which carry one preamble copy EACH, so an off-by-one snippet reports a combined net depth of 2 in a test naming neither the launcher nor this issue (verified: that is exactly how #3841 first went red). tests/runtime-launcher-parity.test.cjs (F0) now pins brace balance at the snippet so the next edit fails on the file it broke. Domain term: identity status — the two-valued GSD_IDENTITY_STATUS (ok/unverified, frozen as IDENTITY_STATUS, bridged from the five-way reason by statusForVerdict) the preamble exports so the gate is asserted on a VALUE, never on its warning prose. The rollout is warn-then-fail: unverified prints one line and continues, because no_identity_verb cannot tell a foreign package from an @opengsd/gsd-core older than the verb, and at rollout the old-version case is the common one. The byte budget was the blocker, and folding the resolver is what cleared it: the preamble is inlined into 113 shipped files, several of which sat within single-digit bytes of frozen ceilings (agents/gsd-verifier.md had 16 bytes; gsd-executor.md 33; execute-phase.md 234), and a first attempt broke five of them. Collapsing the twenty near-identical elif [ -f … ] arms into one candidate-list helper (_gsd_at) buys far more than the assertion costs — net 1,876 bytes SMALLER per inlined file. Editing this preamble is still a ceiling hazard; measure before adding. The module exports the identity surface backing the runtime-identity verb: classifyIdentityProbe (pure, total — (stdout, exitCode, spawnFailed, timedOut) → ok/identity_mismatch/no_identity_verb/unparseable/probe_failed; strict because JSON.parse admits 0/"str"/[]/null/true and a truthiness test would verify []), buildIdentityPayload over baked package-identity.cjs coordinates plus readHostVersion(), and explainVerdict. That verb is a manual diagnostic, not an automatic gate. Source of truth: gsd-core/bin/lib/runtime-identity.cjs (generated from src/runtime-identity.cts).
Dispatch Policy Module
Module owning dispatch error mapping, fallback policy, timeout classification, and CLI exit mapping contract.
Canonical error kind set:
unknown_commandnative_failurenative_timeoutfallback_failurevalidation_errorinternal_error
Command Definition Module
Canonical command metadata Interface powering alias, catalog, and semantics generation.
Query Runtime Context Module
Module owning query-time context resolution for projectDir and ws, including precedence and validation policy used by query adapters.
Native Dispatch Adapter Module
Adapter Module that satisfies native query dispatch at the Dispatch Policy seam, so policy modules consume a focused dispatch Interface instead of closure-wired call sites.
Query CLI Output Module
Module owning projection from dispatch results/errors to CLI { exitCode, stdoutChunks, stderrLines } output contract.
STATE.md Document Module
Module owning STATE.md parse, field extraction, field replacement, status normalization, frontmatter reconstruction, and ## Current Position section scoping (stateCurrentPositionSlice, #1956 — the one owner of that scope for the read path; state.cts's matchCurrentPositionSection is a thin alias over it, and the drift-guard phase-status seam consumes it, so the #2956 archive-shadowing fix cannot be re-derived into a second copy — the byte-exact mutation path served by state-transition.cts's locateCurrentPosition/sliceCurrentPositionSection is a deliberately separate, un-consolidated locator, #3187). stateFieldValue (#3187) is the single owner of the #1760 frontmatter-then-body field fallback chain, consolidating the 14 re-derivations of that ladder onto one scope-carrying (complete/truncated/unscoped/unreadable) primitive. isRealCalendarDate (#3696) is the single owner of "does this y/m/d exist on the calendar" — moved here from a private copy in smart-entry.cts so that reader and state validate's S008 cannot drift into disagreeing about whether a STATE.md is usable; it enforces ADR-227's shape-AND-value rule, rejecting 2026-02-30 rather than letting Date.parse roll it forward to 2026-03-02. stateFieldContinuation (#3696) reports the prose a wrapped single-line field leaves behind, which stateExtractField's newline-excluding (.+) silently drops (S009); it is additive beside stateExtractField rather than a widening of it, because ADR-3180 §7.7 Rejected #1 pins that function as untouchable (20 direct callers, CRITICAL blast radius) and a continuation join there would apply to every field — Status: would swallow the line beneath it. It does not scan .planning/phases and does not own persistence or locking; phase/plan/summary counts arrive from inventory/progress Modules as inputs, and read-modify-write paths remain Adapters. Source of truth: gsd-core/bin/lib/state-document.cjs. Commit provenance (#2573): state_head records the full sha STATE.md was written against, stamped by syncStateFrontmatter and omitted entirely outside a git repo. readStateHeadFreshness(cwd, stateHead) (src/state.cts) is the single derivation consumed by both validate.health (W024) and smart-entry — it returns { state_head, current_commit, commits_behind, commit_stale } with tri-state commit_stale: null = unknown (no stamp, no git, or a stamp that is not an ancestor of HEAD after a history rewrite), false = known fresh, true = the codebase has moved. Mirrors the graphify commit-staleness contract deliberately. It is a freshness PROXY, never a drift measurement: rev-list counts unrelated commits and the stamp restamps on every state write, so a low count means STATE.md was written recently, not that its contents are accurate — it must never gate. toFiniteNumber (#3871) is now EXPORTED as the single owner of "coerce this frontmatter scalar to a finite number, or say it is not one" — the STATE.md Transition Module's progress-ratchet consults it to decide whether a derived total is a real measurement, and a private second copy there would have diverged immediately, because frontmatter scalars arrive as STRINGS ("0", not 0) and a === 0 test is wrong at both call sites.
STATE.md Transition Module
Module owning STATE.md lifecycle/maintenance transitions as intent-based methods (beginPhase, advancePlan, completePhase, plannedPhase, milestoneSwitch, milestoneComplete, patch, sync, prune, update, rebuild). Pure core (content, intent, deps) → newContent with injected I/O (file read/write, lock, disk scan); consults a field-classification table that names each STATE.md field's class (derived-from-body | derived-from-disk | derived-from-external | curated | free) and its preservation policy. Supersedes the 14 scattered RMW callbacks in state.cts (phase.cts's former direct caller has since been migrated away). syncAndPreserveStateMd (state.cts, #3469) is the single composition of syncStateFrontmatter + applyPostSyncPreservation; readModifyWriteStateMd and cmdPhaseComplete both call it rather than assembling the two steps, because assembling them at a call site is a re-derivation even when every step calls an owner. Exactly two direct writeStateMd callers remain, and both are SANCTIONED PERMANENT exceptions under ADR-3408 §8.3 (as amended): cmdStateSync and the REGENERATE_STATE remedy (health-diagnostic.cts). Neither is debt — state sync exists to re-derive frontmatter from the body (#905), and REGENERATE_STATE is a factory reset; preservation on either would re-lock precisely what the command was invoked to replace. cmdMilestoneComplete was the third and is now routed through the composition (#3469). Phase 1's whole-repo drift guard, not this line, is the authoritative count. (Location correction: this entry previously placed the factory-reset primitive at verify.cts:1925. It moved to health-diagnostic.cts when cmdValidateHealth migrated onto the rule table (#3309); verify.cts now contains no writeStateMd call. The design intent was unchanged — only the address was stale.) Absorbs syncStateFrontmatter + readModifyWriteStateMd's post-sync preservation block; Encoding 3 (cmdStateBuildFrontmatter) stays separate — read path concern. Sibling/super-module of the STATE.md Document Module; consumes its stateReplaceField/stateExtractField primitives. Body section structure (## Current Position, ## Session, etc.) lives as a constants block inside the Module. Append-only transitions (addDecision, addBlocker, etc.) stay on today's RMW seam for now. Targets the #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 bug cluster. Migration per ADR-1372 §T6 sequenced as substrate + beginPhase first (PR1), then transition-by-transition with characterization tests first per transition. ADR-1817 adds rebuild as the capstone 11th transition — the body-structure derivability contract. Re-derives ## Current Position prose from frontmatter and ## By-Phase Progress table from phase dirs on disk; preserves ## Session / ## Decisions / unknown sections verbatim; de-duplicates ## Session Continuity Archive (keep most-recent N, default 3); appends a structured audit entry to ## Rebuild Log (timestamp, kind, section, before, after, reason) for every mutation. Hard idempotency guarantee: a no-mutation rebuild appends no log entry, so two successive invocations on a clean file are byte-identical. Non-overlapping with sync (3 lightweight frontmatter fields, auto-triggered) and orthogonal to auto_prune_state (age-based removal) — rebuild reconciles with current canonical sources, prune removes by retention policy, the two compose (rebuild first, then prune). Section ordering is invariant: rebuild rewrites content in place, never reorders. Targets the #1776/#1761/#1591 body-drift cluster that survived ADR-1769's per-field transitions. Phased per ADR-1817: Phase 0 = this ADR + predicates (closes #1817), Phase 1 = rebuildCore body + rebuild dispatch case + drift-class unit tests (#1827), Phase 2 = cmdStateRebuild CLI + --dry-run/--verbose + integration tests + docs + changeset (#1826). Source of truth: gsd-core/bin/lib/state-transition.cjs (generated from src/state-transition.cts). state_head (#2573) is classified { source: 'free', preservation: 'derive' } — an ambient git read recomputed on every write, like last_updated; never preserved, because a stale stamp would claim STATE.md was written against a commit it wasn't. Phase 4 (#3471): syncStateFrontmatter's six empty-only guards (D1) are now GATED — active only for the §8.3 sanctioned-permanent exceptions (cmdStateSync, REGENERATE_STATE), which have no preservation executor downstream and for which body-beats-frontmatter is the deliberate contract; OFF on the write seam, where applyStatePreservation owns the empty case via the exported applyPreserveWhenUnchanged executor. reconcileReportedFields (state.cts, §8.4/D4) is the single owner of reconciling a command's reported updated/failed field array against what was actually persisted, closing both #3351's (reported-but-discarded) and #3345's (persisted-but-unreported) directions; used by seven commands rather than seven re-derivations. cmdStateJson (§8.5/D3) no longer carries a private copy of the empty-only guards; it routes through applyPreserveWhenUnchanged directly, scoped to the same six fields. This entry has now needed correcting three times inside this one epic; Phase 1's whole-repo drift guard, not this line, remains the authoritative count. ADR-3473 §8.7 (#3872) — the transaction diff. reconcileReportedFields no longer compares the transform's own output against persisted bytes, nor filters divergedFields by policy class. It compares persisted against the pre-write state the transaction already holds, surfaced to the command through the same caller-allocates out-param idiom divergedFields established (and which readModifyWriteStateMd's hand-enumerated option forwarding must list, or the field is silently dropped). Both prior directions fall out of that one comparison: a field the transform reported but the pipeline discarded is persisted-equals-snapshot and drops out (#3351), and a field nobody reported but the write moved is different and appears (#3345's total_phases 7→4, #3818's current_phase 203→204). The classification filter is deleted, not relocated — no getFieldClassification test survives in the reporting path. Reporting is at dotted-leaf granularity, enumerated from the progress.* rows FIELD_CLASSIFICATION already declares rather than by walking user data to arbitrary depth; that closes a live drop, because plannedPhaseCore already pushed progress.total_plans and a flat hasOwnProperty could never resolve it against nested frontmatter (Current Position was lost the same way). The one exclusion is last_updated, and it is by provenance, not classification: it is the only field measured to change on every write regardless of content. state_head was measured NOT to qualify — recomputed every write, but its value moves only when git HEAD moved. Without that single exclusion, state.patch's success signal (updated.length > 0, state.cts) would be permanently true and a fully-failed patch would report success. ADR-3473 §8.6 (#3871) — the state transaction. StateTransaction is the Module's pre-write policy value, constructed only by openStateTransaction (preservation applies) or rebuildStateTransaction (it does not); both carry a MANDATORY snapshot, and an absent one is a construction failure (STATE_TRANSACTION_SNAPSHOT_REQUIRED), never a runtime no-op. This replaces the preFm/preFmSnapshot pair, which were the same extractFrontmatter call with one copy nulled on resync — a policy flag encoded as a missing input, which is why a declared preserve-always row silently skipped on the default write path (#3756). An EMPTY snapshot ({}) stays legal: that is the honest snapshot of a document with no parseable frontmatter, and /gsd-health --repair runs precisely on such documents. rebuildStateTransaction is the typed expression of ADR-3408 §8.3's closed exception list — cmdStateSync (#905) and REGENERATE_STATE — so writeStateMd takes a rebuild transaction and rejects anything else, and the two sanctioned-permanent entries the write-path drift guard used to ratchet as strings are retired with their baseline file. Within applyPreserveAlways, an all-zero or absent set of derived progress TOTALS is an unmeasured scan, not a measurement (the convention #3233 established, and why computeProgressPercent already returns null on an empty denominator), so the curated block stands rather than being overwritten with zeros; completed_* being zero is normal and does not decide it, and a measured scan still corrects totals downward (#1446/#2440). The rule carries a second, non-negotiable condition: it does not fire when the caller NAMED a progress field, because preserve-always has always meant "never overwrite unless the caller explicitly names this field" and state update Progress exists precisely to re-derive the block from the body just rewritten. explicitProgressField carries that and is derived, never hand-set — it comes from shouldResyncStateProgress(fields) (true iff the field set contains Progress, Total Plans in Phase or Total Phases), so it cannot drift from what the caller actually asked for. Omitting it silently discarded an explicitly-requested resync (#3242 / #1972), caught by the remote matrix, not by review. getPreserveWhenUnchangedFields() projects the preserve-when-unchanged rows out of FIELD_CLASSIFICATION so cmdStateJson consults the declaration instead of the hand-maintained parallel list that had drifted from it (#3836).
STATE.md Field Schema Module
The one declaration (ADR-3473 §8.8, #3873) for "which STATE.md keys exist and what they carry", replacing three hand-maintained tables that were already observed to disagree: FIELD_CLASSIFICATION and FRONTMATTER_BODY_SOURCE (STATE.md Transition Module) and FRONTMATTER_KEY_TO_BODY_LABEL (STATE.md Document Module's bodyLabelFor, state.cts). One frozen, null-prototype STATE_FIELD_SCHEMA row per key carries type, cardinality, source, preservation, guard/mergeStrategy (ADR-3408's closed vocabularies, whose type declarations moved here), bodySource/bodyLabel, acceptedShapes (declared value shapes a hand-written parser accepts — e.g. current_plan's N / N of M, #3784 — never a predicate; Greenspun's Tenth Rule still applies), and emitted (mirrors buildStateFrontmatter's null-guards). The three original tables are now PROJECTIONS derived from this schema at module load, byte-identical in shape/key-order/frozen-and-null-prototype-ness to what they were before #3873 — every existing consumer (the preservation dispatch loop, getFieldClassification, getPreserveWhenUnchangedFields, bodyLabelFor, #3872's declaredLeavesOf) is unaffected. The last_activity disagreement is resolved by declaration, not by picking the table that "looks right": it carries a bodySource (it IS body-derived) but deliberately no bodyLabel, matching what ships today — its preservation is derive, so it can never reach bodyLabelFor's STATE_BODY_LABEL_UNWIRED_ROW throw, pinned by tests/state.test.cjs's lastActivityLabelResolutionMatchesShippedBehavior. Leaf module: imports from neither state-transition.cts nor state.cts (both import it), avoiding the CJS require-cycle src/health-diagnostic-types.cts was split out to break. scripts/lint-state-field-drift.cjs is UNCHANGED and retained — it guards the #3187 STATE.md field-extraction fallback-chain re-derivation, an orthogonal concern this schema does not make unrepresentable (§8.8's own claim that the guard is deleted here was verified false). Source of truth: gsd-core/bin/lib/state-md-schema.cjs (generated from src/state-md-schema.cts).
STATE.md Status Lifecycle (ADR-2207)
The Status field in STATE.md follows a strict lifecycle: Ready to plan → All phases complete (all phases done, milestone awaiting formal close) → <version> milestone complete (terminal, written only by the milestone-close verb milestoneCompleteCore) → Awaiting next milestone (archived). Phase-completion verbs write All phases complete on the last phase — never Milestone complete (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination). normalizeStateStatus maps any status containing "complete" → completed, so consumers using the normalized projection (workstream inventory's status field, statusline) recognize All phases complete without code changes. Note: isCompletedInventory (workstream-inventory-builder.cts) intentionally checks only for the terminal \bmilestone\s+complete\b / \barchived\b — All phases complete returns false (intermediate, not terminal).
Query Execution Policy Module
Module owning query transport routing policy projection (preferNative, fallback policy, workstream subprocess forcing) at execution seam.
Query Subprocess Adapter Module
Adapter Module owning subprocess execution contract for query commands (JSON/raw invocation, @file: indirection parsing, timeout/exit error projection).
Query Command Resolution Module
Canonical command normalization and resolution Interface (query-command-resolution-strategy) used by internal query/transport paths after dead-wrapper convergence.
Command Topology Module
Module owning command resolution, policy projection (mutation, output_mode), unknown-command diagnosis, and handler Adapter binding at one seam for query dispatch.
Init Command Module
Module owning the init.* family of query handlers that compose atomic queries into the flat JSON bundles consumed by init workflows (/gsd-execute-phase, /gsd-plan-phase, /gsd-verify-work, /gsd-new-project, /gsd-onboard, /gsd-manager, /gsd-progress, /gsd-resume, etc.). Source of truth: src/init.cts and the compiled gsd-core/bin/lib/init.cjs; onboarding routing readiness lives in src/onboard-projection.cts. The basic handlers (plus withProjectRoot project-identity injection) and the heavyweight handlers (initNewProject, initOnboard, initProgress, initManager) return { data: <flat JSON> }. Test seams: tests/init.test.cjs, tests/onboard-command.test.cjs, and tests/init-manager.test.cjs (cover withProjectRoot precedence, onboarding projection/rendering, progress/manager precedence regression #2674, workstream scoping regression #3196, and cross-milestone dependency regression #2267). (The SDK handlers/init/*.ts sources and the init*.test.ts seams were retired with the SDK package per ADR-0174.)
Command Routing Hub
Single dispatch seam (gsd-core/bin/lib/command-routing-hub.cjs) that centralizes CJS routing, the no-throw pure-result contract, typed error variants, and dispatch-event emission for all command family adapters. Interface: createHub({ cjsRegistry, manifest, logger }) → hub; hub.dispatch({ family, subcommand, args, cwd, raw, parentTraceId? }) → Result where Result = { ok: true, data } | { ok: false, kind, ...typedPayload } and kind ∈ { UnknownCommand, InvalidArgs, HandlerRefusal, HandlerFailure }. The InvalidArgs variant carries an optional exitReason?: string field (amendment #1642 / #1644 Phase 1) holding the ERROR_REASON enum value, separate from reason (the explanation text); the makeInvalidArgs(arg, reason, exitReason?) factory omits the field when the third arg is absent, undefined, or empty — preserving the strict-keys invariant tested at tests/command-routing-hub.test.cjs:444. The Hub is single-runtime (no mode selection, no sdkLoader), never prints, never exits, never throws. Adapters call createHub, dispatch, then translate the pure Result to output()/error() calls; when an InvalidArgs Result carries exitReason, the adapter passes it as the second arg to error(message, exitReason) so the JSON-error envelope (GSD_JSON_ERRORS=1) preserves the typed reason. Source: gsd-core/bin/lib/command-routing-hub.cjs; ADR: docs/adr/0174-retire-gsd-sdk-package-boundary.md (§5 amended #1642).
Command Dispatch Completion (ADR-2346, epic #2345)
Decision to dissolve runCommand's 73-case ~2,338-line switch (the repo's #1 PageRank / #1 Tarjan-bridge / #1 complexity symbol) into a two-layer dispatch: families via the commandFamilies registry (ADR-959 mechanism, completed) and single-purpose leaf verbs via a dispatch table filling the prepared _dispatchNonFamily seam (today a dead shim returning false); runCommand collapses to a ~15-line try registry → try leaf table → unknown. Family/leaf rule: promote a cluster to a family iff ≥3 related subcommands + shared backing module + shared parse/return shape; lone verbs or pairs stay leaves. Nine families result (state/phase/init/roadmap/validate/verify/capability + promoted config/research/resolve/git); worktree+workstream stay leaves; ~40 remaining verbs rehome into ~4 themed leaf modules. A shared parseFamilyArgs (in cjs-command-router-adapter.cts beside routeHubCommandFamily) deletes the 4+ duplicated inline arg-parsers. The 706-line capability arm extracts to a thin capability-command-router (intel-shaped) + capability-cli.cts (CLI wiring), consolidating duplicated probes (capHostVersion→readHostVersion(), capReadStrict+drift-guard → one readStrictKnownRegistries). Behavior-preserving — each cutover proven equivalent by extending tests/audit-command-cutover.test.cjs's 5-category template. Phased: P1 parseFamilyArgs+Tier-1 cutovers, P2 capability extraction, P3 new families, P4 leaf table + collapse. Source: docs/adr/2346-command-dispatch-completion.md; graduates ADR-959 Proposed → Accepted. Avoid: "the dispatch service" (when you mean the seam).
Runtime Source Layout Module
Single-runtime seam layout for this repository after SDK retirement. Runtime execution paths live under gsd-core/bin/lib/ and are grouped by seam concern (dispatch, manifest, handlers, runtime, observability, installer). ADR-0174 preserves the seam vocabulary and defines the canonical long-term shape as a seam-aligned TypeScript src/ tree (src/dispatch/, src/handlers/, src/errors/, src/manifest/, src/config/, src/state/, src/workstream/, src/runtime/, src/cli/, src/observability/) compiled to CJS.
Runtime Launcher Module
Canonical space-safe shell preamble (gsd_run) used by every workflow bash block to invoke the GSD runtime CLI. Resolves gsd-core/bin/gsd-tools.cjs via node when present, falls back to a gsd-tools binary on PATH, else errors. Single source of truth: gsd-core/workflows/_runtime-launcher.snippet.sh; propagated by scripts/sync-runtime-launcher.cjs; enforced by tests/runtime-launcher-parity.test.cjs. Replaced the retired unquoted $GSD_SDK variable (#373). gsd_run is also shipped as a standalone executable (gsd-core/bin/gsd_run) via the npm bin field; on runtimes that run each fenced bash block in a fresh shell (e.g. Claude Code), the per-file preamble appends the bin directory to CLAUDE_ENV_FILE so gsd_run resolves from PATH in later blocks — the inline function definition remains the fallback for all other runtimes.
Dispatch Observability Module
Module owning dispatch-event creation, redaction, and logger behavior for the Command Routing Hub. Core files: gsd-core/bin/lib/observability/event.cjs, gsd-core/bin/lib/observability/logger.cjs, gsd-core/bin/lib/observability/redaction.cjs. Contract: silent on success by default, structured JSON to stderr on error, and opt-in audit trail at .planning/.gsd-trace.jsonl via GSD_AUDIT=1 or config (audit.enabled). Each dispatch carries a traceId; composed dispatches set parentTraceId for correlation.
Query Pre-Project Config Policy Module
Module policy that defines query-time behavior when .planning/config.json is absent: use built-in defaults for parity-sensitive query Interfaces, and emit parity-aligned empty model ids for pre-project model resolution surfaces.
Configuration Module
Module owning legacy-key normalization, defaults merge, and explicit on-disk migration for .planning/config.json. Interface: normalizeLegacyKeys(parsed) → { parsed, normalizations[], skipped[] } (idempotent, pure, returns the list of normalizations applied plus the list of migrations declined), isConfigSection(value) → boolean (is a value usable as a nested config section — a non-null, non-array object), mergeDefaults(parsed) → MergedConfig (deep-merge of parsed config over canonical defaults), migrateOnDisk(cwd) → MigrationReport (explicit, opt-in, called by the installer and by gsd-tools migrate-config). Invariants: legacy top-level keys (branching_strategy, sub_repos, multiRepo, depth) are normalized into their canonical nested locations in the returned value; a destination section that is present but is NOT an object blocks its own migration rather than being overwritten (#3760) — spreading such a value expands a string into character keys ({...'main'} is {0:'m',1:'a',2:'i',3:'n'}) and collapses a number or boolean to {}, and because a reported normalization is what marks a config dirty, that shape was then written to the user's config.json; the refusal leaves the section, the legacy key, and the file byte-identical and records a skipped entry in-band — the nested-section analog of the top-level ADR-227 shape check _readConfigFile already performs. The ADR-1411 out-of-band UNUSABLE_REASON.CONFIG_SECTION_NOT_OBJECT diagnostic is emitted by this module's CALLERS — cmdMigrateConfig (Config CRUD) and loadConfigResolved (Config Loader) — never by this module, because configuration.cjs must load from an install layout containing only itself plus bin/shared/*.manifest.json (the #3571 contract, pinned by tests/install.test.cjs "co-located bin/shared manifests let configuration.cjs load without sdk/shared"); a sibling require the installer does not co-locate fails at load time with MODULE_NOT_FOUND. Reporting in-band via skipped[] is what keeps the module both pure AND dependency-free; defaults come from the shared gsd-core/bin/shared/config-defaults.manifest.json; schema (VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS) comes from gsd-core/bin/shared/config-schema.manifest.json. Note: loadConfig (project config read + merge) was extracted to the Config Loader Module (config-loader.cjs) per ADR-857 phase 2e (#885); configuration.cjs now provides only the pure normalization and defaults primitives that config-loader.cjs depends on. Source of truth: gsd-core/bin/lib/configuration.cjs, consumed via bin/lib/config-loader.cjs and bin/lib/config-schema.cjs. Eliminates the recurring #3523-class drift bug structurally.
Planning Scope Module
Leaf module owning the frozen SCOPE discriminator (COMPLETE / TRUNCATED / UNSCOPED / UNREADABLE) that every consolidated .planning/ semantic derivation returns alongside its payload, per ADR-3180 Decision 2. It exists to make one distinction representable: COMPLETE with zero items is a REAL answer (a phase genuinely has no plans; a milestone genuinely has no phases yet), while the other three with zero items are NON-answers — the derivation could not see all of its input. Before it, those two cases were output-identical, which is the failure class epic #3180 removes: a truncated milestone window returned phase_count: 0 with no error, indistinguishable from a freshly-declared milestone. It is a frozen enum rather than a message string because CONTRIBUTING.md bans raw-text matching on outputs and requires a typed IR, so callers branch on result.scope === SCOPE.TRUNCATED. Pure and import-free — the bottom of the dependency graph, so any consumer can depend on it without a cycle (mirrors the Phase Id Module's leaf position). Source of truth: gsd-core/bin/lib/planning-scope.cjs (generated from src/planning-scope.cts). The contract is PROVISIONAL: #3183 is its first real implementation, and ADR-3180 requires the ADR be amended before Phase 2 rather than the contract worked around, if it does not fit.
Pattern Module
Leaf module owning the construction of a regex from a runtime value, per ADR-3212 §1 (epic #3212 Phase 1, #3412). Exposes escapeRegex(value) → string and literalPattern(value, flags?) → RegExp. escapeRegex delegates to the built-in RegExp.escape (TC39 Stage 4, ES2026, Node 24+) — it is deliberately NOT an implementation, which is the whole point: the ~39 hand-rolled copies it replaces existed because every author re-derived the metacharacter set, and TC39 standardized the primitive precisely because userland versions "miss edge cases." escapeRegex is the primary export (the large majority of call sites build a regex source string and interpolate it into a larger pattern); literalPattern is the minority convenience for the new RegExp(escapeRegex(v)) shape. Pure and import-free — a leaf, so any consumer can depend on it without a cycle (mirrors the Planning Scope and Phase Id modules' position). Behavioral note, measured not assumed: RegExp.escape is a superset escaper and produces different pattern SOURCE TEXT than the hand-rolled class did — it hex-escapes the leading character of nearly every string ("abc" → "\x61bc"), plus -, space, /, and control chars. It is match-equivalent (verified by a seeded fast-check property test against the deleted implementation as oracle, plus a fixed corpus), so no consumer's matching behavior changes; but anything asserting on pattern text rather than match results does. It also fixes a latent bug as a side effect: a hyphen-bearing value interpolated into a character class previously formed a real range ([a-z] built from an escaped "a-z" matched "m"), and no longer does. Enforced by eslint-rules/no-adhoc-regex-escape.cjs (fires on the .replace(<metachar-class>, '\\$&') shape anywhere outside this module, matching on shape rather than exact bytes, and on new RegExp built from an unescaped runtime value; exempts reviewed pattern-fragment constants such as PHASE_NUMBER_TOKEN_SOURCE by structural provenance — a module-scope const with a static initializer — not by name alone) plus scripts/lint-no-adhoc-regex-escape.cjs, a whole-tree companion covering directories ESLint's globs miss. Requires engines.node >= 24.0.0; a seam test asserts typeof RegExp.escape === 'function' so the floor and the capability cannot silently diverge. Source of truth: gsd-core/bin/lib/pattern.cjs (generated from src/pattern.cts). Design: .gsd/phase/chore-3412-pattern-seam/40-design.md.
Text Lines Module
Leaf module owning \r?\n line-terminator splitting and CRLF normalization, per ADR-3212 §3 (epic #3212 Phase 2, #3413). Exposes splitLines(content) → string[] (splits on \r\n or \n; a lone \r is not a delimiter), normalizeEol(content) → string (strips every \r, matching the four scripts/gen-*.cjs --check copies it replaces), detectEol(content) → '\n' | '\r\n' (dominant terminator, '\r\n'-default on a tie or no terminator), and joinLines(lines, eol?) → string (inverse of splitLines; round-trips byte-for-byte with detectEol). Closes #3360: parseMustHavesBlock (src/frontmatter.cts) matched ^(\s*)must_haves:\s*$ and a sibling block-header regex against the WHOLE multi-line YAML string under /m, and \r is its own ECMA-262 LineTerminator — a greedy \s* anchored on ^ could cross a CRLF boundary and inflate the captured indent by one character, tripping the nesting guard and silently returning [] for every must_haves block on a CRLF plan file. The fix reroutes both lookups through split-then-match (splitLines first, then match per already-split line), which cannot straddle the delimiter that produced it. Pure and import-free — a leaf, so any consumer can depend on it without a cycle (mirrors the Pattern Module's position). Enforced going forward by the widened eslint-rules/no-crlf-fragile-split.cjs (now also scanned against src/**/*.cts, not only tests/). Source of truth: gsd-core/bin/lib/text-lines.cjs (generated from src/text-lines.cts). Design: .gsd/phase/chore-3413-text-lines-seam/40-design.md.
Frontmatter Module
Module owning YAML frontmatter parsing, serialization and CRUD commands. As of ADR-3473 §8.1 (#3881) the read path is no longer a hand-rolled line scanner — parseYamlRegion, escapeDoubleQuoted, unescapeDoubleQuoted and parseQuotedScalar are deleted, not patched (§8.1's words), and extractFrontmatter parses through the vendored js-yaml (gsd-core/bin/lib/vendor/js-yaml.cjs, type twin src/vendor/js-yaml.d.cts) under FAILSAFE_SCHEMA + json: true. FAILSAFE_SCHEMA resolves only !!str/!!seq/!!map, so every scalar still comes back a string (today's contract, unchanged for callers), and json: true makes a duplicate key overwrite rather than throw — the documented last-wins invariant tests/fixtures/adversarial/frontmatter/duplicate-keys.md pins. What js-yaml does not do is layered on top in this module: anchors, aliases and merge keys are refused outright (a raw-text pre-scan, refuseAnchorsAndAliases) because FAILSAFE_SCHEMA still resolves core YAML anchor/alias mechanics — not a tag-resolution concern a schema choice can disable — and .planning/ documents are untrusted, user-authored input a hostile few-line alias fan-out could otherwise expand by orders of magnitude in milliseconds; the #3257 full-line-comment channel and the #1882 truncation probe are likewise preserved unchanged. A closed region that js-yaml itself cannot parse (malformed YAML, or a refused anchor/alias/merge key) returns {} carrying an exported FRONTMATTER_UNPARSEABLE Symbol rather than a bare, indistinguishable {} — Symbol-keyed so it is invisible to Object.keys/JSON.stringify/for-in and the ~70 call sites that never inspect it are unaffected, while the ~8 hasFrontmatter = Object.keys(...).length > 0 call sites can consult it to tell "genuinely empty" apart from "could not parse" and avoid reassembling a document without its (unparsed but still present) frontmatter block. All 8 sites are wired — 7 in src/state-transition.cts (beginPhaseCore, advancePlanCore, complete-phase, planned-phase, milestone-complete, patchCore, updateCore) and 1 in src/state.cts — verified against a STATE.md carrying git merge-conflict markers, which previously lost its frontmatter block entirely on the next write and now round-trips intact. Scope note: the full CLI write path (readModifyWriteStateMd → syncAndPreserveStateMd → syncStateFrontmatter) re-derives and rebuilds the frontmatter block on every write regardless of what the transform returned, so the marker's observable effect is at the transform layer and for any caller bypassing that rebuild — it does not change on-disk output for today's state update / state patch. extractFrontmatterBlock (Phase Estimation Module) is a deliberate sibling, not a duplicate: it stays hand-rolled because it returns numeric-looking values as numbers rather than strings, a type contract FAILSAFE_SCHEMA cannot express. Source of truth: gsd-core/bin/lib/frontmatter.cjs (generated from src/frontmatter.cts).
Token Scanner Module
Leaf module generalizing the proven hooks/lib/git-cmd.js token-walk (#3129) into a shared primitive for stateful grammars, per ADR-3212 §4 (epic #3212 Phase 3, #3414). Exposes tokenizeShellLike(cmd) → string[] (quote-aware shell tokenizer — single/double-quoted spans as one token, no escape or brace/variable expansion, byte-identical port of git-cmd.js's original tokenize()) and indentWidth(line) → number (leading-whitespace column count, tabs counted as one column each, no tab-width policy introduced). hooks/lib/git-cmd.js migrates its tokenize()/isGitSubcommand() onto tokenizeShellLike with zero behavior change (ADR §6 extend-never-mutate; parity-asserted against every existing fixture) and gains extractBranchArgument(cmd) → string | null (git checkout -b <name> / git branch <name>) — a new capability exercising the seam on the domain the ADR names, not a migration of existing duplicated logic (none existed). Closes #3169: src/decisions.cts's decision-bullet parser could not distinguish a cross-reference bullet NESTED under an already-open decision from a fresh, malformed top-level declaration attempt — both have identical shape under any bullet-content classifier (an earlier design using bold-run-content classification was disproven against the repo's own existing FIX-B fixtures before being adopted, per the phase's design doc). indentWidth supplies the structural signal a per-line regex cannot see: a bulleted line indented deeper than the currently-open decision's own bullet is that decision's elaboration, folded into its text like a continuation line, never tested against the declaration/parse-miss regexes. A bullet at the same-or-shallower indent is unaffected. Pure and import-free — a leaf, so any consumer can depend on it without a cycle (mirrors the Pattern and Text Lines modules' position). Hook-staging note: hooks/ scripts are staged as standalone files at install time, so git-cmd.js requires the BUILT gsd-core/bin/lib/token-scanner.cjs artifact (matching Phase 2's scripts/gen-*.cjs consolidation), not a sibling hooks/lib/ file. Source of truth: gsd-core/bin/lib/token-scanner.cjs (generated from src/token-scanner.cts). Design: .gsd/phase/chore-3414-tokenizer-first-seam/40-design.md.
Planning Snapshot Module
Module owning the parsed projection of .planning/ that a diagnostic rule may read, per ADR-3180 §8.1 (Decision 8, Phase 10, #3308). buildPlanningSnapshot(cwd) → PlanningSnapshot is composed EXCLUSIVELY from the already-consolidated §7 owners — getMilestoneInfo (Roadmap Parser Module), listMilestonePhaseDirs (Phase Locator Module), isPhaseComplete (Verification Module), scanPhasePlans (Plan Scan Module), stateFieldValue/stateCurrentPositionSlice (STATE.md Document Module), planningPaths (Planning Workspace Module) — and introduces no new semantic derivation of its own. PlanningSnapshot exposes milestone/phaseDirs/phases/currentPhaseLabel, each a {value, scope} pair per the Planning Scope Module's frozen SCOPE enum; phases additionally carries a PhaseSnapshot[] (dir, complete, verificationStatus, planCount, summaryCount, scope). The one new piece of logic this module adds is worstScope(...scopes) → Scope, a pure severity-ordered combinator (UNREADABLE > UNSCOPED > TRUNCATED > COMPLETE) that folds several independently-scoped owner answers about the same phase directory into one composite signal — NOT a re-derivation of any owner (each owner's own algorithm is untouched; only their already-computed scope verdicts are combined), but new coordination logic no single owner has the visibility to express. Every exposed field carries PARSED values only, never raw document text — this is structural, not advisory: a diagnostic rule given only the parsed value cannot re-derive a field's location the way #3162's three inert Current Phase literal-search predicates did. Read failures on STATE.md (exists-but-unreadable, distinct from absent) are reported via the Unusable Input Diagnostic Module's warnUnusableInput(UNUSABLE_REASON.STATE_UNREADABLE). Guarded by scripts/lint-planning-snapshot-bypass-drift.cjs (ratcheted per Decision 4(e), scoped to DIAGNOSTIC_RULE_FUNCTIONS — currently cmdValidateHealth in src/verify.cts only, acknowledging its existing raw .planning/ reads as debt owned by Phase 11, #3309, which migrates it onto this snapshot). Source of truth: gsd-core/bin/lib/planning-snapshot.cjs (generated from src/planning-snapshot.cts). Design: .gsd/phase/refactor-3308-planning-snapshot-parsed-projection/40-design.md.
Plan Document Module
Leaf module owning the parse of a *-PLAN.md document BODY: <objective> extraction, the <task> block grammar (with the legacy ## Task N heading fallback), per-task <files> / <acceptance_criteria> / <done>, and the frontmatter-derived scheduling metadata (wave, depends_on, autonomous, agent_hint, files_modified). parsePlanDocument(content, planPath?) → PlanDocument; TASK_KIND is a frozen {AUTO, CHECKPOINT} enum so a <task type="checkpoint:*"> block — which carries an entirely different element set (<decision>/<what-built>, no <name>/<files>) — is reported as its own kind rather than as a malformed auto task. Extracted from cmdPhasePlanIndex's inline pass-1 loop (#2790) because two commands in two families now need it (phase.plan-index and planning.inspect); leaving it in phase.cts would have forced a planning → phase dependency, and copying it is the DEFECT.GENERATIVE-FIX shape. NOT an ADR-3180 §7 derivation — §6 puts the document-parsing layer (#2143) explicitly out of that epic's scope; this module answers "what does this plan document say", never "how many plans are outstanding" (scanPhasePlans, §7.5) or "is this phase complete" (isPhaseComplete, §7.4). Behaviour is byte-for-behaviour identical to the prior inline code, INCLUDING the invariant taskCount === tasks.length === (xmlTaskCount || mdTaskCount) and its known fence-blindness (a ## Task 1 inside a fenced block still counts) — characterised, not endorsed: changing it would silently alter phase.plan-index's output for existing projects. As of ADR-3646 (#3970) this module also owns the tracker-id task attribute: parsed verbatim off the <task> block into PlanTask.trackerId (string or null), never split into prefix/id here — that split is the Task Content Resolution Module's job. Source of truth: gsd-core/bin/lib/plan-document.cjs (generated from src/plan-document.cts).
Task Content Resolution Module
Given a task's tracker-id attribute (Plan Document Module) and the set of installed capabilities' taskContentResolver declarations, resolves that task's content — action/verify/acceptanceCriteria/readFirst/done — from the matching external tracker via one bounded subprocess call (ADR-3646 Phase 1, #3970), or reports that no resolution applies. Pure/impure split: splitTrackerId (first-:-only split, colons after the first stay in the id verbatim), findResolver (matches a capability's trackerPrefix, returns null/one match/'ambiguous', never silently picks one), and buildInvocation (expands invoke.args' {{id}} placeholder) are pure and total, never throwing even on hostile third-party-shaped capability input; resolveTaskContent is the one impure boundary, spawning through an injectable execFn (spawnSync with a bounded timeout) so tests never spawn a real process or wait a real timeout. Four non-throwing outcomes (TASK_CONTENT_RESULT: not-applicable, no-resolver, resolved, empty) and HARD-HALT on four throwing outcomes (ResolverAmbiguousError, ResolverFailedError, ResolverTimeoutError, ResolverMalformedOutputError) — ADR-3646 Decision 4: an ambiguous resolver match, a non-zero resolver exit, a timeout, or malformed resolver stdout must never degrade to a silently-empty or silently-picked result. Wired at task resolve-content --plan <path> --task-id <tracker-id> --raw (task-command-router.cts's routeResolveContent), which turns each of the four throws into the CLI's own non-zero exit rather than swallowing them into a {resolved: false} JSON answer; capabilities are loaded through the merged first-party + validated-installed-overlay registry (ADR-1244 D2) so a third-party capability's resolver is honored. Source of truth: gsd-core/bin/lib/task-content-resolution.cjs (generated from src/task-content-resolution.cts).
Planning Inspect Module
Module owning the schema-v1 canonical planning snapshot emitted by the read-only planning inspect query (#2790), for downstream harness UIs that need truthful .planning/ state without parsing ROADMAP/REQUIREMENTS/PLAN/SUMMARY Markdown a second time. buildPlanningInspect(cwd) → payload; cmdPlanningInspect(cwd, raw) emits it through output() (so the existing >50 KB @file: spill seam applies unchanged). PLANNING_INSPECT_SCHEMA_VERSION = 1 is the wire contract — a consumer MUST reject any other value rather than best-effort-parse an unknown shape. Composes, never re-derives: milestone identity/windowing and phase enumeration via buildPlanningSnapshot (Planning Snapshot Module), completion via isPhaseComplete (§7.4, disk-strict), live-plan counting via scanPhasePlans (§7.5), percent via clampPercent (§7.6), STATE fields via stateFieldValue/stateCurrentPositionSlice (§7.7), plan bodies via parsePlanDocument, requirement IDs via parseRequirements, UAT items via parseUatItems/selectPhaseUatFiles. It deliberately does NOT serialize PlanningSnapshot: that shape is the §8.1 diagnostic-rule subject and is explicitly additive/growing (4 fields at Phase 10, 20+ by Phase 12), so handing it to external consumers would freeze an internal contract by accident (Hyrum's Law) — this module declares its own flat schema and maps into it, and a field added to PlanningSnapshot must never change schema-v1 output. Three frozen enums carry every non-answer — INSPECT_DIAGNOSTIC, TASK_STATUS (done|pending|unknown), PROVENANCE (task_scoped|plan_scoped|absent) and AGREEMENT (agreed|conflicting|unknown) — because unknown or conflicting evidence serializes as unknown plus a diagnostic and is never inferred, reconciled, or defaulted; keys are always present, null is the explicit non-answer. Roadmap acceptance, verification and UAT are reported side by side and never folded into one verdict, and a ROADMAP checkbox is emitted with authoritative: false per §7.4. Not a diagnostic rule, and deliberately NOT registered in scripts/lint-planning-snapshot-bypass-drift.cjs, which is DIAGNOSTIC_RULE_FUNCTIONS-scoped and must remain prunable to zero when #3309 lands. Dispatched by the Planning Command Router (src/planning-command-router.cts, family planning, subcommand inspect, no arguments in v1 — a stray positional or unknown flag is a fail-loud ERROR_REASON.USAGE). Source of truth: gsd-core/bin/lib/planning-inspect.cjs (generated from src/planning-inspect.cts). Design: .gsd/phase/feat-2790-planning-inspect/40-design.md.
State Contract Module
Module owning the machine-readable state contract v1 published to .planning/state.json at every step boundary (#3227), so an external reader (GSD Workbench, an editor extension, a dashboard) binds to a versioned contract instead of parsing STATE.md/ROADMAP.md heuristically. buildStateContract(cwd, deps?) → snapshot is PURE (writes nothing); publishStateContract(cwd, deps?) → {published, reason} writes it and never throws for any input. Wire shape, keys always present and in a pinned order: contract (semver 1.0.0; additive-only under 1.x — the consumer's version gate), flavor (core), milestone ("v1.1 — Hardening", or the bare version when the roadmap carries no name, or null), phases[] ({number, name, status}; number is a STRING because 01 and 2.1 are both real ids), next ({command, label, reason} or null), updated_at. Two frozen enums are the wire vocabulary: PHASE_STATUS (complete|in_progress|pending) and PUBLISH_REASON (published|no_planning_dir|write_failed). Composes, never re-derives: phase rows from locateProgressTable (Phase Lifecycle Module — the SAME ## Progress locator deriveProgressFromRoadmap uses, extracted in this change precisely so state.json cannot disagree with GSD's own progress counters), milestone identity from getMilestoneInfo (Roadmap Parser Module), the recommended action from classifyProject (Smart Entry Module) so next equals smart-entry's routing BY CONSTRUCTION rather than by a second copy of its table, paths from planningPaths (workstream-aware), and I/O through platformReadSync/platformWriteSync — the latter is already atomic (sibling tmp + retryRenameSync), so this module adds no sixth atomic-write helper and takes no withPlanningLock (that lock is non-re-entrant and several phase commands already hold it). Every owner is required LAZILY inside the function body, never at module top level: state.cts imports this module and smart-entry.cts destructures off state.cjs at load time, so a static import would close the cycle state → state-contract → smart-entry → state and bind undefined. Best-effort by contract: a missing ROADMAP.md, an unreadable document, or an unwritable target degrades to a typed reason and can never change the parent command's exit code or stdout; a directory with no .planning/ is left untouched rather than having one created for it. Publishing is wired at the 11 boundary commands (state begin-phase/planned-phase/advance-plan/complete-phase/milestone-switch, phase add/add-batch/insert/remove/complete, milestone complete) and deliberately NOT at their early-return error or idempotent-no-op paths, so a refreshed updated_at always means something actually moved. Known limits: phases: [] cannot be told apart from "no ROADMAP" (the 1.0 schema has no diagnostic channel — planning inspect is the surface that does), a Deferred roadmap phase folds to pending (four roadmap statuses, three wire values), and phases[] is not milestone-scoped. Contrast with the Planning Inspect Module: that is a rich, diagnostic-carrying PULL query a consumer runs; this is a small PUSH artifact a consumer watches. Source of truth: gsd-core/bin/lib/state-contract.cjs (generated from src/state-contract.cts). Design: .gsd/phase/feat-3227-state-contract/40-design.md. Test anchor: tests/state-contract.test.cjs.
Health Diagnostic Types Module
Leaf module owning the SEVERITY/REMEDY_ACTION/REMEDY_RISK enums and Diagnostic/Remedy/Rule types shared between the Health Diagnostic Module (the evaluator) and the Health Diagnostic Rule Groups (the eight rule-group files it concatenates). Split out of src/health-diagnostic.cts (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) to break a CJS circular dependency: the evaluator must require() every rule-group file to populate RULES, and every rule-group file needs these enums/types — if the rule-group files required the evaluator back, the require cycle would resolve module.exports before it is assigned. This leaf has no runtime dependency on either side of that cycle. Source of truth: gsd-core/bin/lib/health-diagnostic-types.cjs (generated from src/health-diagnostic-types.cts).
Health Diagnostic Module
Module owning the frozen rule-table contract for validate health, per ADR-3180 §8.2/§8.3/§8.5 (Phase 11, #3309). Exposes three frozen enums — SEVERITY (error/warning/info), REMEDY_ACTION (the six real repair actions harvested from cmdValidateHealth's existing --repair implementation — createConfig, resetConfig, regenerateState, addNyquistKey, addAiIntegrationPhaseKey, backfillMilestones — plus advise, the non-repairable payload every non-actionable finding's fix text becomes), and REMEDY_RISK (none/destructive) — plus the Diagnostic/Remedy/Rule shapes every rule's check(snapshot: PlanningSnapshot) → Diagnostic[] signature and every finding's remedy conform to. RULES: Rule[] is the rule table, fully wired: the static concatenation of the 33 rules exported by the eight Health Diagnostic Rule Groups files below (extracted from cmdValidateHealth, src/verify.cts:1616-2577; the count is locked by tests/health-diagnostic.test.cjs's frozen RULES assertion and by scripts/lint-health-diagnostic-rule-table.cjs, so update all three together). evaluateRules(snapshot) → Diagnostic[] runs every rule in RULES against one PlanningSnapshot and flattens the results, throwing on any two rules sharing a code — defense in depth beside the static 1:1 lint guard (§8.2 rule 1, scripts/lint-health-diagnostic-rule-table.cjs). applyRepairs(cwd, diagnostics, repair, backfill) → {applied, refused, details} is the --repair/--backfill dispatcher: a DESTRUCTIVE remedy (resetConfig/regenerateState — health.md's own published table: "loses custom settings" / "loses session history") is reported but never executed by --repair, a deliberate, disclosed breaking change (§8.3 rule 3) from cmdValidateHealth's current unconditional application; backfillMilestones alone among the NONE-risk actions is requested by --backfill without --repair, mirroring cmdValidateHealth's existing gate (src/verify.cts:2504). Per-action repair handlers (runRepairAction) are REAL, ported behavior-preserving from verify.cts:2405-2553's repair switch — createConfig/resetConfig (write the default config.json payload), regenerateState (backs up and regenerates STATE.md), addNyquistKey/addAiIntegrationPhaseKey (add a missing workflow.* key), backfillMilestones (synthesize missing MILESTONES.md entries from archive snapshots) — not stubs. applied records only a repair that actually SUCCEEDED (outcome.success === true); a thrown or {success: false} attempt is recorded in details with success: false but is never pushed to applied. Source of truth: gsd-core/bin/lib/health-diagnostic.cjs (generated from src/health-diagnostic.cts). Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md.
Health Diagnostic Rule Groups
Directory src/health-diagnostic-rules/ (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) owning the 33 rules migrated off cmdValidateHealth (the count actually summed from each group's exported RULES array and locked by tests/health-diagnostic.test.cjs's "RULES" describe block; it was stale at 31 through W028 and is corrected here alongside W029, #3586), split into eight files — one per subject-area group from the design doc's "Rule table organization" table — each exporting a RULES: Rule[] conforming to the Health Diagnostic Module's frozen Rule shape (worktree-health.cts additionally exports isActiveWorktreePath(activeCwd, worktreePath, platform?), the #3663 win32-only path-casing comparison key helper W027's active-worktree exclusion delegates to — a pure predicate, not a rule). src/health-diagnostic.cts concatenates all eight into the single RULES table evaluateRules runs; no group re-derives its own Diagnostic/Remedy shapes. Groups: root-existence.cts (root .planning/ + PROJECT.md existence, E002-E004/W001), state-consistency.cts (STATE.md vs config/ROADMAP/disk, W002/W011/W021/W026 — W024's state_head freshness check is a disclosed gap, deliberately not migrated), config-validation.cts (config.json shape, W003/W004/W022/E005/W008/W012-W016, plus W029 — the tracked-but-gitignored .planning/ contradiction, #3586), phase-structure.cts (phase directory structure, W005/W023/I001/W009), agent-install.cts (agent-installation completeness, W010), roadmap-disk-consistency.cts (ROADMAP-vs-disk phase matching via the shared matchPhaseDirs matcher, W006/W007), worktree-health.cts (worktree health, W020/W017/W027), milestone-archive-hygiene.cts (milestone archive + root hygiene, W018/W019). Every rule is a behavior-preserving port of one addIssue call site in cmdValidateHealth (src/verify.cts), reading only the parsed PlanningSnapshot fields the Planning Snapshot Module already computes — never raw .planning/ I/O. Source of truth: gsd-core/bin/lib/health-diagnostic-rules/*.cjs (generated from src/health-diagnostic-rules/*.cts).
Planning Workspace Module
Module owning .planning path resolution, active workstream pointer policy (session-scoped > shared), pointer self-heal behavior, and planning lock semantics for workstream-aware execution.
Workstream Inventory Module
Module owning workstream directory discovery, per-workstream state projection, phase/plan/summary counting, roadmap-declared phase count, active marker projection, and active-workstream collision inputs. Command handlers render list/status/progress outputs from this inventory instead of rescanning .planning/workstreams/* directly. Source of truth for the pure projection is gsd-core/bin/lib/workstream-inventory-builder.cjs (a Builder Module); the Reader Adapter gsd-core/bin/lib/workstream-inventory.cjs collects filesystem inputs and delegates projection to the Builder.
readStateProjection (src/workstream-inventory.cts) resolves the per-workstream status/current_phase/last_activity fields through the #1760 fallback chain owner (stateFieldValue, STATE.md Document Module, #3187): the YAML frontmatter scalar is preferred, falling back to the body field only when the frontmatter side is absent. A frontmatter-only STATE.md — previously projected as unknown/null for these fields — now reports its real values.
Completion is scoped to the workstream's CURRENT milestone (#2562): roadmap_phase_count, completed_phases and progress_percent describe that one milestone, not the workstream's lifetime, and status: "milestone complete" is derived from the current milestone's own close artifacts (an archived milestones/<version>-ROADMAP.md snapshot, or isMilestoneShippedInRoadmap on its own ROADMAP heading) rather than any project-lifetime shipped marker. That marker is a claim, not a verdict: it is cross-validated against the milestone's own artifacts, and the two signals are checked at different strengths because one check cannot serve both. A live-ROADMAP heading marker is refused when the completion ratio is short. An archived snapshot is not ratio-gated alone — milestone complete moves the milestone's phase directories into milestones/<version>-phases/ while copying, never truncating, the live ROADMAP, so a CLEAN archive reads 0/N by construction — and is refused on the conjunction of a still-live in-milestone directory (the archive is not clean; a phase was added or reopened after it, reachable because milestone complete does not advance STATE.md's milestone: field) AND a short completion ratio. The legacy project-lifetime fallback is ungated by signal. The cross-check as a whole engages only under milestone scoping, for all three signals: unscoped, the denominator is the whole-roadmap count and membership is everything, so there is no current-milestone artifact set to check against. A refused marker does not fall through to a STATE.md field asserting the same thing, and the refusal is reported as milestone_shipped_unverified, projected by workstream list/status/progress so it is visible at the CLI — distinct from status_conflict, which reports only derived-vs-field disagreement. Membership and the denominator are derived from one canonical key surface — the phase-id owner module's phaseKeyFromDir/phaseKeyFromProse — with directory membership additionally consulting the Roadmap Parser Module's getMilestonePhaseFilter when that filter is genuinely version-scoped (that filter keeps its own internal normaliser, OR'd in, so a divergence can only widen membership); the Builder enforces completed_phases <= denominator and throws on violation rather than capping the percentage. A current milestone that is DECLARED but not yet populated is scoped, not unscoped: versionSectionFound / missingExplicitVersion / a Progress table attributing every row elsewhere each witness that state, and without them scoping switched off and the whole-lifetime fallback reported a milestone with no work done as 100%. Within such a milestone, membership inverts — a directory belongs unless another milestone's row claims it — so a phase scaffolded ahead of the ROADMAP is counted rather than hidden. Scoping is stated by the Reader (milestoneScoped) rather than inferred from currentMilestonePhaseCount > 0, which cannot represent a scoped, legitimately zero-phase milestone. A ROADMAP that attributes no versions at all matches none of the witnesses, so free-form legacy projects keep their whole-roadmap count. The Reader passes the workstream name to getMilestonePhaseFilter/extractCurrentMilestone so their planningDir resolution targets .planning/workstreams/<ws>/, which a loop over workstreams cannot express through GSD_WORKSTREAM.
Project-Root Resolution Module
Module owning project-root resolution from any starting directory. Walks the ancestor chain (bounded by FIND_PROJECT_ROOT_MAX_DEPTH = 10) applying five heuristics in order: (0) own .planning/ guard (#1362), (1) parent .planning/config.json sub_repos traversal, (2) legacy multiRepo: true boolean + ancestor .git, (3) .git heuristic with parent .planning/, (4) nearest-ancestor .planning/ walk-up (#1414, epic #1411) — a last-resort second walk (same depth bound, stops at os.homedir()) that anchors a plain descendant subdirectory of a single-repo project to its nearest ancestor .planning/ instead of degrading to defaults; ordered after (1)–(3) so sub_repos/multiRepo resolution always wins (the Resolution Provenance deterministic-anchoring rule). Returns startDir when no ancestor qualifies. Sync node:fs I/O. Source of truth: gsd-core/bin/lib/project-root.cjs; the core.cjs re-export spine was retired in epic #1267, so callers import this leaf directly.
Planning Path Projection Module
Module owning projection from project/workstream context to concrete .planning paths. Policy precedence is explicit workstream > env workstream > env project > root. Invalid workspace context is a validation error at this seam rather than a silent fallback.
Reviewer Lane Descriptor Module
Module owning the single declared contract for a reviewer lane — one external CLI or model endpoint that /gsd:review hands a plan to for independent review (ADR-2782 Phase 1, #2794; subsumes #2690). Before it, a lane was declared across three unrelated surfaces — the roster in src/review-reviewer-selection.cts, ~640 lines of hand-authored per-CLI bash in the invoke_reviewers step of gsd-core/workflows/review.md, and the hardcoded section headings in write_reviews — so cross-cutting fixes landed per-leg (#2494 and #2605 were the same empty-output defect filed twice; #2475/#2295/#2272 are the same shape). Interface: REVIEWER_LANES (frozen table of the 11 shipped lanes, each declaring slug, flags, probe, invoke, timeoutFloorMs, emptyOutput, reviewsSection, evidenceClass, requiresBinaries, promptBudgetKey, handler), PARITY_VIOLATION (frozen reason enum — adding a reason is three coordinated changes: enum + emitting site + the test locking Object.keys(...).sort()), and checkReviewerLaneParity({descriptor, roster, workflowText}) → {ok, violations[]}. The module DECLARES; it does not execute — invoke_reviewers still runs hand-authored legs until Phase 5b (#2799) makes it iterate; what Phase 1 guarantees is that a leg cannot be added, removed, or renamed without the table and the REVIEWS.md section moving with it. Field names, nesting, and enum members track ADR-2782 D1/D2/D6/D7 so Phase 2 (#2795) harvests the shape into the capability manifest with no translation layer — including transport at the lane level (a sibling of probe/invoke, as D1's manifest example places it) rather than nested inside invoke, which would read more naturally as a TS discriminated union and is exactly the convenience Phase 2 would have to translate away. Four vocabulary widenings were forced by surveying the eleven shipped legs, all additive widenings of closed enums, each forced by a lane that exists today — and ADR-2782 was amended in the same PR (its Amendments section, 2026-07-29) rather than left diverging, so Phase 2 implements the validator against the amended vocabulary: promptChannel: 'none' (CodeRabbit is fed no prompt — it reviews the working-tree diff), outputChannel: 'file-arg' (Codex captures the review through its own -o/--output-last-message and discards stdout, #1698), outputArg (its companion — knowing the review lands in a file is useless without the argument naming the file), and flags: string[] where D1 shows a singular flag (Antigravity is selected by both --antigravity and --agy, which one field cannot express; this also widens D8's uniqueness invariant, enforced here over the flattened flag set). LANE_SLUG_RE (^[a-z0-9][a-z0-9_-]*$) pins the slug grammar because LEG_MARKER_RE can only capture [a-z0-9_-]: a slug outside that class is unmatchable — its marker can be present and correct and the scan still never sees it — so a violating slug is reported INVALID_SLUG rather than reported missing forever. A loud named violation beats a silent miss. The descriptor deliberately does not promise uniformity — lane divergence is real and frequently correct (three lanes are HTTP endpoints with no binary; timeout floors genuinely differ; Antigravity needs a three-layer fallback for an upstream stdout bug) — so behavior data cannot express is delegated to a named handler (closed first-party enum: null | antigravity | openai-compatible), never to conditionals inside the table. checkReviewerLaneParity is the RULESET.GENERATIVE-FIX assertion the roster has never had, and it is bidirectional: a forward-only check misses the failure it exists to catch (#2718 added a lane leg, #2781 was the drift that followed), so an undeclared leg fails too. Legs are identified by an explicit <!-- reviewer-lane: <slug> --> marker rather than inferred from prose shape, because five non-lane bold labels in invoke_reviewers share the bold-then-fence shape a heuristic would key on. Section matching is anchored at h2 with an exact Review suffix and no parenthetical, so ADR-1517 reviewer-instance headings (## OpenCode Review (opencode-deepseek)) are exempt — ADR-2782 D8: instances are not lanes. Pure and total: no filesystem access, CRLF-insensitive, and never throws on any input — every field is validated before use (MALFORMED_LANE / INVALID_SLUG) rather than trusted, because Phase 2 feeds this same function manifest-derived data from third-party overlays, and a parity gate that crashes on bad input is indistinguishable from one that was never run. Empty input degrades to violations so a read failure is never mistaken for a clean bill of health. Totality, determinism (no leaked regex lastIndex), and the invalid-slug contract are fast-check property-tested with a pinned seed. Phase 6 (#2800) adds a SECOND, deliberately separate pure gate in the same module — checkReviewerDocsParity({descriptor, docs}) → {ok, violations, skipped} with its own frozen DOCS_PARITY_VIOLATION enum — answering what is documented rather than what runs, so a stale doc can never make the runtime checker look red and the seven dependents of checkReviewerLaneParity never move. It is the RULESET.GENERATIVE-FIX parity assertion this roster had always required and never had (only the Cursor lane ever carried one). Three independent arms: declared flags appear delimited (backticked or bracketed, so a bare flag in a fenced example cannot satisfy the gate) in docs/COMMANDS.md, docs/FEATURES.md and their four locale mirrors; a Command: signature line is held to the full roster and rejects undeclared bracketed lane flags; and the Purpose paragraph beneath it must name every declared reviewsSection — the arm that catches the class where a Command: line is updated and the line below it is not. Section titles are matched LITERALLY (llama.cpp would otherwise let llamaXcpp pass). A mirror carrying no /gsd:review surface is reported in skipped, never failed, so a partial translation is not misread as drift. Its totality is property-tested too, which is how the non-callable-toString coercion crash was found before it shipped. Source of truth: src/review-lane-descriptor.cts. Test anchors: tests/review-lane-descriptor.test.cjs, tests/reviewer-docs-parity.test.cjs. See docs/adr/2782-reviewer-lane-capability-surface.md.
Reviewer Lane Invocation Module
Module owning the projection from a declared reviewer lane plus resolved configuration to a concrete invocation plan — the value a lane is actually run from (ADR-2782 Phase 5b, #2799). Pure: no filesystem, network, subprocess or clock; configuration arrives through a configGet seam. Interface: resolveLanePlan({lane, configGet, runDir, repoRoot, effortArgs}) → {ok, plan} | {ok:false, reason, detail}, LANE_UNAVAILABLE (frozen reason enum — a lane that will not run reports WHY, because the ambiguity between "failed" and "ran cleanly with nothing to report" is the defect class this epic closes), plus isEmptyReview, normalizeHost and fileRefPrompt. TOTAL — a malformed lane yields an unavailable result, never a throw, because third-party overlay manifests reach this seam. invoke.args is an argv template over a closed four-member placeholder vocabulary ({{model}}, {{effort}}, {{output}}, {{prompt}}), not a prefix: the injected pieces do not all go in the same place — codex injects the model after its exec subcommand and the output file later still, while five lanes end with a bare - that must stay last. Each lane's plan was derived FROM its former bash leg, and a golden table asserts all twelve; that table is the strangler-fig substitute for a parallel run. SpawnPlan now carries model — the configured model that was actually applied to the invocation, null when a lane declares a modelConfigKey but no modelArg so the configured value never entered argv (#2295) — and configString, the "what counts as unset" normalizer, is now exported and shared with the runner so the plan resolver and the runner's model-recovery arms cannot disagree on the same question.
Reviewer Lane Runner
Module owning execution of an invocation plan (ADR-2782 Phase 5b, #2799): probe, spawn or HTTP call, empty-output policy, and dispatch of the three first-party handler modules D6 names (antigravity, openai-compatible, opencode). Replaces ~640 lines of hand-authored per-CLI bash in invoke_reviewers. Interface: runLane, probeLane, checkEgressHost, writeReviewOrStub, and the handler entry points (handleOpencodeOutput, antigravityArgv, antigravityPrompt, antigravityWatermark, antigravityTranscriptFallback, antigravityDiagnostic, stampBlindReview, runOpenAiCompatible). Every dependency is injected, so behaviour is testable without a network or a spawn. Every subprocess call passes timeout + killSignal + maxBuffer (DEFECT.UNBOUNDED-SUBPROCESS): a frozen synchronous spawn cannot be interrupted and hangs a whole CI chunk to its kill with # fail 0. Three runtime dependencies disappear here — jq, curl, and external timeout/gtimeout — which also closes two platform holes: five lanes were unavailable on stock Windows/Git-Bash for want of jq, and the Antigravity lane ran unbounded on stock macOS, which ships neither killer. Owns ADR-2782 D5 rules 2–4: the egress destination is re-resolved at invocation and a changed host blocks the lane rather than silently redirecting it; absence of a consent record allows, since first-party lanes are never consent-gated. antigravityWatermark's final read can throw (permissions, mid-write truncation) on a transcript that indisputably exists, which is not the same fact as a genuinely empty or absent one; that case now sets unreadable: true on the returned mark rather than folding into lines: 0, and antigravityTranscriptFallback declines ('') for a same-conv-id unreadable mark instead of skipping zero lines and replaying a stale pre-run response (#3118). The resolved model (#2295) adds MODEL_SOURCE, UNRESOLVED_MODEL, parseModelBanner, parseTranscriptModel, antigravityModel, resolveSpawnModel, BANNER_SCAN_LINES and MODEL_VALUE_MAX to the interface: LaneRunResult now carries model: {value, source}, where value === null iff source === 'unknown'. The banner arm is gated on outputTarget.kind === 'file', because a stdout lane's own review text would otherwise be scanned as its banner. antigravityWatermark now snapshots BOTH transcripts — lines for transcript.jsonl, fullLines for transcript_full.jsonl — since they are different files with different counts and one cannot offset the other. The model arm's staleness rule is deliberately looser than the review body's: a matching conv-id means the same agy session, whose model is this run's. Every arm is total and degrades to unknown — it may never fail a lane (#2295).
Code Review Depth Module
Module owning the single resolution of a code review's depth tier (#2554), consumed by gsd-core/workflows/code-review.md's resolve_depth step. Interface: resolveCodeReviewDepth({flagDepth, configDepth, overrides, files, repoRoot}), REASON (frozen reason enum — adding a member is three coordinated changes: enum + emitting site + the test locking Object.keys(...).sort()), DEPTH_TIERS (quick < standard < deep, ordered weakest-first), LARGE_SCOPE_THRESHOLD, and the two matching primitives normalizeRelPath / ruleMatchesFile. Resolution order is --depth= flag → strongest matching path rule → workflow.code_review_depth → standard, and the result reports its own source (flag/rule/config/default) per ### Resolution Provenance — a depth with no provenance is what let the operator surface claim a global setting produced a result it did not. workflow.code_review_depth_overrides is an ordered array of {paths, depth} rules matched against the review's changed-file set; a matching rule REPLACES the global rather than being max'd with it, because folding the global in would make every quick and standard rule inert whenever the global was stronger — a config that parses and does nothing, which is the silently-discarded failure ### Federated Config exists to prevent. Escalation is whole-review, not per-file: depth is a single scalar handed to gsd-code-reviewer, so one matched file raises the tier for the entire review and a sensitive file is never reviewed shallowly. Matching is segment-aware path-prefix — a rule naming a directory matches that directory and everything beneath it, and never a sibling whose name merely shares the prefix (a rule for "src/auth" must not match "src/authfoo") — and case-sensitive, following git — the same anchoring rule ### Emitted Artifact Provenance states for its sources prefixes. Glob metacharacters are a hard configuration error, not sugar for a prefix: accepting a trailing double-star as a prefix would make a mid-path star look supported while matching nothing, arming a policy the operator believes is live (v1 scope decision on #2554 — no glob engine exists in this tree and none was added; runtime dependencies stay at two). A rule path carrying an interior control character (U+0000-U+001F or U+007F, checked after the glob check so a path that is both a glob and control-bearing still reports the glob reason) is likewise a hard configuration error — an unrejected newline or NUL would otherwise flow through the matched rule into the workflow's provenance string and corrupt the rendered review summary. Malformed rules halt the review rather than degrading to standard, since a misconfigured sensitive-path policy reviewing shallowly is the exact hole the module closes. The module is PURE — no fs, no clock, no subprocess, no require — so the workflow reaches it through the same node -e idiom it already uses for src/code-review-flags.cts; the changed-file list crosses on stdin, never argv (DEFECT.WINDOWS-ARGV-OVERFLOW). The pre-existing large-scope downgrade (deep → standard above LARGE_SCOPE_THRESHOLD files) moved into this module from the workflow so one seam owns depth end to end; it preserves matchedRule through the downgrade so the operator surface can name the rule it overrode. Note the key is registered in the CENTRAL config schema, not as a capability config slice: ### Federated Config's VALID_SLICE_TYPES admits only boolean/string/number/enum, so an array slice is dropped as malformed — the ship.pr_body_sections precedent is the one this follows.
Resolution Provenance
Cross-seam principle (ADR-1411, epic #1411): context resolution — config loading, project-root anchoring, workstream resolution — must report its provenance, not fall open silently to defaults. A resolver anchors deterministically to the project root (one walk-up module, no dependence on an arbitrary descendant cwd), returns what it resolved and where it came from (source/degraded), and surfaces a diagnostic when a configured input resolves empty (not configured and configured-but-empty are distinguishable). The resolution-side analog of ADR-227 (input-validation shape). Target seams: Config Loader Module (loadConfig → ConfigResolution { config, source, degraded }), Project-Root Resolution Module (single nearest-.planning/ walk-up, retiring ad-hoc resolvers like resolvePlanningCwd), I/O Module (Resolution<T> { value, configured, reason, warnings } output envelope). A configured input resolving empty without a reason is a CI-guarded regression. P1 (nearest-.planning/ heuristic) shipped in #1413; P2 (loadConfigResolved + agent-skills diagnostic) shipped in #1415 / closes #1366: loadConfigResolved now implements the Config Loader seam target; cmdAgentSkills uses findProjectRoot + loadConfigResolved and emits configured/reason/source/degraded in its --json IR. Corrupt is not absent (ADR-1411 amendment 2026-07-26, epic #1879 Phase 0 / #2674): the principle above governs a resolution miss; input that is present but not usable (a SyntaxError, an errno such as EACCES/EIO, or a malformed structure with no exception at all) is a distinct class that must stay distinguishable from genuine absence. The defect in that class is not the fallback — ADR-227 requires malformed input to be coerced rather than propagated, and this ADR already permits a fallback — it is that the fallback is invisible. So every current return value is preserved and the cause is made visible by one of two mechanisms: in-band, where the result already carries a provenance envelope, name the cause in it (ConfigResolution gains a reason; Resolution<T>'s four documented values all describe a miss, so new unusable-input values are introduced with the first adopter) and also expose it on the surface callers actually use, since a reason no caller reads is an unreachable field; out-of-band, where the read returns a bare sentinel or a plausible default it cannot extend, keep that value and emit a deduplicated stderr diagnostic keyed on resolved-path + errno, reusing the _warnedUnknownConfigKeys guard pattern. The diagnostic is unconditional — a deliberate divergence from ADR-227's never-implemented GSD_DEBUG opt-in, since an opt-in nobody sets is the same silence. Throwing is not the cluster's answer — it stays confined to ADR-227's genuinely-fatal carve-out, decided per call, never inferred from the return shape.
Resolution Convention
Diagnostic-output convention for the Resolution Provenance principle (ADR-1411 P3, #1416). Config-interpreting read verbs expose Resolution<T> { value, configured, reason, warnings } (src/resolution.cts); agent-skills is the first adopter, where value = { block, skills_count } and source/degraded remain config-provenance extras outside the envelope. Other read verbs expose at least warnings[] (e.g. capability-state { runtimeConfigDir, capabilities, warnings? }) without configured/reason, which are meaningful only for config-interpreting verbs. Mutation verbs expose warnings[] (advisory) PLUS errors[] (operation-not-applied), e.g. capability-writer { capabilities, warnings, errors }. The shared seam across all shapes is warnings: string[]; a single generic Resolution<T> across read+write verbs was rejected by the deletion test (configured/reason are meaningless for capability verbs; errors[] cannot fold into warnings[]) — ADR-1411 P3 amendment. Recurrence prevention is delivered by P4's CI guard (a configured input resolving empty must carry a reason), not by a shared envelope. A CI guard (scripts/lint-resolution-provenance.cjs, wired into lint:ci) enforces that every registered config-interpreting read verb keeps a configured_empty/not_configured contract test; the registry in that script is the registration point for future verbs (ADR-1411 P4 / #1417).
Unusable Input Diagnostic Module
Leaf module owning the out-of-band half of ADR-1411's "corrupt is not absent" amendment (epic #1879). Where a read already returns a provenance envelope the cause is named in-band (ConfigResolution.reason, #1880); where a read returns a bare sentinel or a plausible default it cannot extend, the return value is preserved exactly and the cause is surfaced here instead. Interface: UNUSABLE_REASON (frozen reason enum — one entry per condition that has an emitting call site; adding a reason is three coordinated changes: enum + call site + the test locking Object.keys(...).sort()), warnUnusableInput({reason, source?, content?}) → boolean (returns whether this call actually wrote, so tests assert emission counts on a typed surface rather than scraping stderr), plus the _resetUnusableInputWarningsForTests / _unusableInputWarningCountForTests seams. Dedup key is <normalized source>\0<reason> — both halves are load-bearing: keying on the path alone would let a second, different fault on the same file go unreported, and keying on message prose would couple the guard to wording (ADR-1411 dedup clause). Path separators are deliberately not normalized: an earlier revision folded backslashes to / so two spellings of one Windows path would not double-report, but a backslash is a legal filename character on Linux and macOS, so that folding collapsed two genuinely distinct POSIX files onto one key and swallowed the second file's diagnostic. The trade is now one-directional — two spellings of one Windows path may report twice (noise), but two distinct files can never silence each other (lost signal), and ADR-1411 ranks the swallow the worse failure; ASCII control characters are stripped from the source before it is keyed or written, because the key separator is NUL (a crafted path could otherwise forge a collision) and because a path carrying ANSI escapes would replay into the operator's terminal. Callers with no path (in-memory content) fall back to a short content digest so different bad inputs still key differently. The diagnostic is unconditional — a deliberate divergence from ADR-227's never-implemented GSD_DEBUG opt-in, since "an opt-in nobody sets is indistinguishable from the silence #1879 is about" — and never throws: a failed stderr write is swallowed so a degraded read is never escalated into a crash. Adopted by extractFrontmatter (#1882, frontmatter_unterminated) and by getRoadmapPhaseInternal/getMilestoneInfo (#1881, roadmap_unreadable); planning-workspace/verify (#1883) follow. Tenth site: cmdMigrateConfig (Config CRUD Module) and loadConfigResolved (Config Loader Module) emit config_section_not_object when a legacy-key migration's destination section holds a non-object (#3760). The detecting module, configuration.cjs, deliberately does NOT emit: it reports in-band as skipped[] and its callers — which hold both the resolved path and an unconstrained dependency budget — do the emitting, because configuration.cjs must stay loadable with no sibling requires under the #3571 install-layout contract. #1881 detects on the errno alone: platformReadSync returns null for ENOENT and its callers convert that to an errno-less Error, so reporting unconditionally in those catches would flag every project without a ROADMAP.md as corrupt. Exists as a shared seam rather than a per-site copy because four sites need identical behavior and four hand-rolled copies is RULESET.GENERATIVE-FIX by construction. Source of truth: gsd-core/bin/lib/unusable-input.cjs (generated from src/unusable-input.cts). Test anchor: tests/unusable-input.test.cjs. See Resolution Provenance, Config Loader Module.
Worktree Safety Policy Module
CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: resolveWorktreeContext(cwd, deps) → WorktreeContext (linked-worktree root mapping), parseWorktreePorcelain(output) → WorktreeEntry[] (porcelain parser, skips detached HEAD), planWorktreePrune(repoRoot, opts, deps) → PrunePlan (metadata-prune plan, never destructive by default), executeWorktreePrunePlan(plan, deps) → PruneResult (executes prune; degrades gracefully on git timeout), listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult, inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult (orphan + stale detection), snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult, planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan (manifest-scoped, fail-closed), executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult (per-entry gauntlet: branch → base → deletions → advisory scope conformance (#2596) → SUMMARY-rescue → clean-worktree → merge → remove; the scope check compares the branch's committed diff against the entry's declared files_modified and appends WAVE_CLEANUP_WARNING-coded entries to a warnings channel WITHOUT touching ok — an advisory, not a gate, and skipped entirely with no git call when no scope was declared), planWaveScopeConformance(changedPaths, declaredFiles, branch) → WaveCleanupWarning[] (pure; literal-prefix path coverage deliberately mirroring the submodule-intersection gate's glob-prefix rule rather than introducing a second matcher; over-accepts by design because a false alarm costs an advisory more than a miss), isSummaryArtifactRelPath(relPath) → boolean (the single definition of "executor-written SUMMARY artifact", shared with defaultFindSummaryFiles so the rescue walker and the scope exemption cannot drift), WAVE_CLEANUP_WARNING (frozen advisory-code enum: scope_out_of_declared, scope_check_unavailable), planWorktreeRecordAgent(manifestRaw, fields) → RecordAgentPlan (write-strict per-agent manifest append; validates each field at write time via the same normalizeCleanupManifestEntry rules the reader enforces; fail-closed on a missing/garbled field or a duplicate (worktree_path, branch) the reader would dedup away), cmdWorktreeRecordAgent(cwd, args, deps) → RecordAgentCmdResult (thin deps-injectable IO wrapper for the worktree record-agent verb), planWorktreeCreate(fields) → WorktreeCreatePlan (write-strict worktree create planner — same missing-field-hint and normalizeCleanupManifestEntry validation as planWorktreeRecordAgent, pure/no-git), executeWorktreeCreatePlan(plan, repoRoot, deps) → WorktreeCreateResult (bounded git rev-parse --verify base check THEN git worktree add -b <branch> <path> <base>; fail-closed base_unresolved/git_timeout/worktree_add_failed; returns cwd — the working directory an executor spawn would use), cmdWorktreeCreate(cwd, args, deps) → WorktreeCreateCmdResult (CLI verb: requires --root — confinement is mandatory, not opt-in; omitting it fails closed with reason:'root_required' before any git side effect, rather than silently creating an unconfined worktree (#3050); plans, creates the worktree, then appends the manifest entry so it is immediately manageable by cleanup-wave/reap-orphans; dedupes by (worktree_path, branch)). #2584 ADR-1239 Codex-binding amendment, Phase 2: worktree create is the git-worktree-creation primitive for dispatch.isolation: orchestrator-worktree hosts — consumed since #2584 Phase 3 — executor-isolation-dispatch.md calls it to create the worktree an orchestrator-worktree host is then process-spawned into. worktree record-agent / worktree create accept an optional --files recording the plan's declared scope, consumed by the advisory scope-conformance check above; a blank or omitted value leaves the 4-field on-disk entry shape unchanged. Source of truth: gsd-core/bin/lib/worktree-safety.cjs. Timeout path: all git subprocess calls are bounded; callers receive ok:false, reason:'git_timed_out' rather than a thrown exception. Test anchor: tests/worktree-safety.test.cjs. The core.cjs re-export spine was retired in epic #1267: this module absorbed the two thin compositional wrappers that squatted in Core — resolveWorktreeRoot(cwd, deps) → {root, reason} (a projection over resolveWorktreeContext; returns the reason alongside root — a git_timed_out reason means root is a best-effort cwd fallback, not a confirmed resolution, and callers must surface that risk rather than trust it silently, #3050) and pruneOrphanedWorktrees(...) (sequences planWorktreePrune + executeWorktreePrunePlan with a timeout warning) — so callers reach this single worktree-lifecycle seam directly. gitWorktreeInfoInternal did NOT move here — worktree-info detection belongs to the Git Query Module.
Worktree Lifecycle Module
Workflow contract seam covering agent worktree lifecycle orchestration rules. The worktree_branch_check block lives in one canonical fragment (gsd-core/references/worktree-branch-check.md) that execute-phase.md, quick.md, diagnose-issues.md, and execute-plan.md embed at dispatch. Key invariants: worktree_branch_check is verify-only and fail-closed — the orchestrator owns worktree lifecycle and base recovery, so the sub-agent holds no state-correction primitives; HEAD attachment verified via git symbolic-ref; positive allow-list ^worktree-agent-* enforced; git update-ref on protected refs is prohibited; on base mismatch the sub-agent halts with exit 42 and surfaces to the orchestrator (#48); the orchestrator runs a cwd-drift guard at execute_waves entry that resolves the worktree root and refuses drift into an agent worktree (#48); #1856: that refusal now also reports what the agent worktree holds — commits ahead of the resolved base and uncommitted files, both with true counts plus a truncation notice — and the commit/switch/merge-or-cherry-pick sequence to integrate them, because re-run from the orchestrator worktree alone silently meant abandoning work that lives only on the agent branch. The refusal condition and exit code are unchanged, and every added command is diagnostic and || true-guarded so a failure degrades to the plain refusal; cleanup is manifest-scoped (WAVE_WORKTREE_MANIFEST) not global-discovery-based; worktree spawning is sequential (one run_in_background at a time to avoid config.lock contention). Test anchor: tests/worktree.test.cjs.
Worktree Root Resolution Adapter Module
Adapter Module owning linked-worktree root mapping and metadata-prune policy (git worktree prune non-destructive default) for planning/workstream callers.
Git Query Module
Module owning bounded, never-throw git repository introspection — the single seam for read-only git queries that degrade gracefully rather than throwing. Adapter 1 — base-branch detection (gsd_run query git.base-branch): Implements a full precedence ladder: (1) git.base_branch from the EFFECTIVE configuration — since #3648 this tier is resolved by the Config Loader Module's loadConfig, not by a direct .planning/config.json read, so it carries the root+workstream deep merge, flat-then-nested key lookup, and builtin/federated defaults that authority applies; (2) git symbolic-ref --short refs/remotes/origin/HEAD; (3) git remote show origin HEAD branch (authoritative when origin/HEAD is unset — the common case for git init + remote add + fetch without set-head); (4) local branch existence (master present and main absent → master; main present → main); (5) "main" last-resort default. All git subprocesses are bounded with timeouts (5–15 s) and degrade gracefully to the next tier; the function never throws. The plain base-branch adapter also resolves through the Config Loader with persist: false: it retains loader stderr diagnostics while suppressing legacy-key migration; its stdout remains the resolved branch string. Replaces duplicated per-workflow bash detection that silently fell through to :-main on master repos (#1146). Adapter 1a — protected-branch predicate (gsd_run query git.base-branch --is-protected <branch>, #3552): resolveProtectedBranchStatus answers whether a branch is shared-and-protected, resolving the base branch through the same ladder and extending it with the optional git.protected_branches list, so a git-flow project whose GitHub default (main) differs from its integration branch (develop) can protect both. Matching is by exact name — no globs. Two invariants distinguish this adapter from the plain query and must hold together: it fails CLOSED, rendering true with a stderr diagnostic when a tier-2/3/4 git query timed out or could not be spawned (verified:false, #3057 B4) — note the narrowness, since a git command that RUNS and exits non-zero is verified:true by that same contract, so a cwd that is not a repository at all answers false, not true — and it must not write, so its config read passes persist: false and the Config Loader's normalize-then-write-back is suppressed — a predicate invoked on every execute-phase and ship run cannot be allowed to rewrite the config it is asking about (#3648). Per-entry validation drops only unusable git.protected_branches names and reports each on stderr, rather than discarding the whole list, so a hand-edited config cannot fail the guard open. Consumed by execute-phase.md (handle_branching, "none" strategy) and ship.md (preflight); both emit an advisory warning and continue. Adapter 2 — worktree-info detection: gitWorktreeInfoInternal (git rev-parse --is-inside-work-tree + --show-toplevel), absorbed from the Core module when the core.cjs re-export spine was retired and aligned to this module's bounded-timeout / degrade-don't-throw convention (worktree-info detection is a query concern, distinct from the Worktree Safety Policy Module's lifecycle policy). Adapter 3 — phase change-set detection (#1953): phaseStartCommit resolves the commit that ADDED a phase's PLAN.md (git log --diff-filter=A -1) — the anchor for "what did this phase touch", since STATE.md records no phase-start sha — and changedFilesSince returns the changed paths from that anchor to HEAD. changedFilesSince uses -z with core.quotepath=false and splits on NUL, because git otherwise quotes and escapes non-ASCII paths (lossy round-trip) and a \n split corrupts a filename containing a newline; the ref precedes a literal -- so a dash-leading path cannot be read as an option. Both bounded at 15 s and both degrade to null. Consumed by the Complexity Trigger Module. Source: src/git-base-branch.cts → gsd-core/bin/lib/git-base-branch.cjs. Wired into execute-phase.md, quick.md, ship.md, complete-milestone.md, and pr-branch.md.
SEAM.git-query-readonly-seam.owns=bounded, never-throw git repository introspection — base-branch detection, worktree-info detection, phase change-set detection
SEAM.git-query-readonly-seam.enforced-by=test:tests/git-base-branch.test.cjs
Complexity Trigger Module
Leaf module (imports only node:fs/node:path) owning per-function complexity measurement and the refactor-proposal decision, behind the opt-in refactor-trigger capability (#1953, ADR-1953). analyzeSource scores each function by decision-point counting over comment- and literal-stripped source — base 1 plus one per if/else if/for/while/do/case/catch/&&/||/?:, explicitly NOT ?., ??, bare else, or default:. The stripper preserves length and newlines so line numbers survive, keeps ${…} interpolations as code, and disambiguates a regex literal from division by the preceding significant token; an unterminated literal returns REFACTOR_ANALYZER_UNPARSEABLE rather than an approximate number, because a silently-wrong score is worse than no score. evaluateCandidates flags a function when its score exceeds refactor.complexity_threshold or its growth over its anchor exceeds refactor.complexity_jump_delta — both strictly greater, matching ESLint's complexity: {max: N}. The baseline is an anchor, not a rolling value: set on first observation, never advanced by a plain evaluate, moved only by reanchorBaseline on disposition — so the delta accumulates since the last conscious decision and slow creep is caught a phase before the absolute threshold reaches it. Stored at .planning/complexity-baseline.json; proposals at ${PHASE_DIR}/${NN}-REFACTOR.md. Frozen REASON/VERDICT enums are the typed surface tests assert against. Avoid: "the complexity gate" — this capability declares no gate; strict mode records a deviation window and the Broken-Windows Ledger's ship gate does the blocking. Sources: src/complexity-trigger.cts, src/refactor-trigger-command-router.cts (CLI family gsd-tools refactor).
Runtime Name Policy Module
Module owning runtime identity normalization at runtime-selection seams. Canonicalizes alias signals from env/config (GSD_RUNTIME, .planning/config.json:runtime) to supported runtime IDs so output emitters and query runtime gates stay consistent across naming variants (for example codex-app/codex-cli -> codex). Sources: gsd-core/bin/lib/runtime-name-policy.cjs, alias manifest gsd-core/bin/shared/runtime-aliases.manifest.json.
Host Runtime Detection Module
Pure, no-write Module owning the detection rung of runtime identity — ADR-2313 Phase 5 (#3245, folded from #2320). The Runtime Name Policy Module normalizes the two explicit signals (GSD_RUNTIME, .planning/config.json:runtime); this module answers the different question those two cannot: which host is this process actually running inside when neither is set. Before it, init reported agent_runtime: claude inside a Codex session, because the ladder ended at a hardcoded default. detectHostRuntime(deps?) → {runtime, source, signal} is the typed surface tests assert against (source ∈ session-env|config-home|none); it probes, in order, the frozen CODEX_SESSION_ENV_SIGNALS table (CODEX_SANDBOX, CODEX_SANDBOX_NETWORK_DISABLED — injected by Codex into shell-tool children per openai/codex AGENTS.md; absent under sandbox_mode = "danger-full-access", so best-effort), then an explicitly-exported CODEX_HOME whose config.toml exists — the marker FILENAME is single-sourced from the Update-Context Module's inferPreferredRuntime (CODEX_CONFIG_MARKER, re-exported here), while the TRUTHINESS RULE deliberately differs: inferPreferredRuntime accepts a bare, unchecked CODEX_HOME as sufficient to resolve an update context, whereas this module additionally requires the marker file to exist, because it asserts session identity rather than resolving an update context and needs the stronger signal — a difference pinned by a test in tests/host-runtime-detection.test.cjs rather than left implicit. resolveReportedRuntime(projectDir, deps?) composes the whole ladder: GSD_RUNTIME > config runtime > detection > 'claude'. Three invariants are load-bearing and each has a test: it never writes (#2297 shared-defaults poisoning — no ~/.gsd/defaults.json, no config mutation); it never shells out, so there is no subprocess to time-bound and no degraded-on-timeout path to design; and the default ~/.codex/config.toml is never probed, because every machine that has run Codex carries that file and probing it would misreport Claude Code sessions as codex. Avoid: calling this "runtime resolution" — resolveRuntime (Runtime Slash Module) keeps its own frozen GSD_RUNTIME > config > 'claude' contract and its 71 dependents, including formatGsdSlash's command-style decision, are deliberately untouched; only withProjectRoot's reported agent_runtime consumes the detection rung. Sources: src/host-runtime-detection.cts → gsd-core/bin/lib/host-runtime-detection.cjs; the resolveExplicitRuntime seam it composes lives in src/runtime-slash.cts. See ADR-2313.
Host-Integration Interface
Pure, additive, no-I/O Module owning the versioned, negotiated contract over the six host-integration interface points (command, dispatch, model, hooks, state, artifact) — ADR-1239 Phase A. Extends the ADR-1016 runtime descriptor with nine closed-vocabulary axes carried under capability.json runtime.hostIntegration: embeddingMode (imperative|declarative), commandSurface (slash-file|slash-programmatic|slash-toml|palette|prose-only), dispatch ({namedDispatch,nested,maxDepth,background,backgroundDispatch,subagentToolkit,isolation}), modelMode (active|passive), hookBus (host|engine|none), stateIO (filesystem|sandboxed-storage|session-log-append), transport (mcp|native-extension), runtime (node|bun|sandboxed-web|python|go|rust|electron|other), effortSurface (argv|none — how reasoning effort reaches the host; ADR-1239 amendment #2481, the first axis whose consumer is an invocation-time argument rather than an install-time artifact). dispatch.isolation (harness-worktree|orchestrator-worktree|none — how a host isolates concurrent same-wave executors; ADR-1239 Codex-binding amendment #2584; consumed by the phase scheduler since #2584 Phase 3 and, since #2652, by every single-agent dispatch site — quick.md, diagnose-issues.md, execute-plan.md — which resolve it through the canonical gsd-core/references/dispatch-isolation-gate.md rather than branching on a runtime id; and, since #2486, by the runtime-neutral diagnostics — /gsd:settings gates its Worktrees recommendation and /gsd:health raises W025 from this axis, read through the sentinel-free inspect-dispatch-isolation verb). resolveOrchestratorExec(orchestratorExec, cwd) → { ok:true, command, args, cwd } | { ok:false, reason } (#2584 Phase 2, pure, no I/O — resolves the runtime.orchestratorExec descriptor field, a sibling of runtime.hostBehaviors in capability.json carrying {command, args?, cwdFlag?}, into the concrete argv/cwd a process-spawn primitive would use for a dispatch.isolation: orchestrator-worktree host; appends [cwdFlag, cwd] to args when cwdFlag is a non-empty string, e.g. codex exec --cd <cwd>, opencode run --dir <cwd>, kimi --work-dir <cwd>; when cwdFlag is null/absent — kimi-code's process-cwd case — no flag is appended and cwd alone is returned for the caller to bind via the subprocess's own working-directory option; fail-closed missing_command/invalid_cwd/invalid_args/invalid_cwd_flag; CONSUMED since #2584 Phase 3 — routeDispatchIsolation resolves it into the exec field of gsd_run query dispatch-isolation --json, and gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md process-spawns that command/args/cwd; #2652 adds a second consumer, _negotiatedDispatchIsolation, which probes it at install time against a placeholder target to decide whether an orchestrator-worktree declaration actually resolves). Interface: negotiateHostCapabilities(host, engine?) → { protocolVersion, effective, points, warnings } enforcing the trust-boundary invariant effective ⊆ host-declared ∩ engine-known (never augment with an undeclared or unknown/future-protocolVersion value — fail-closed via the most-restrictive-known SAFE_DEFAULTS); degradationFor(point, axes) → { level, fallback } (a pure Full/Degraded/Absent ladder table, never throws); profileOf(axes) → 'programmatic-cli'|'declarative-cli'|'ide'|null; plus PROTOCOL_VERSION (integer, starts at 1 — distinct from the package version/engines.gsd semver), HOST_INTEGRATION_AXES (the frozen closed vocabulary, single source of truth), PROFILE_BASELINES, and shouldFlattenDispatch(dispatch) → boolean (ADR-1239 Phase B / #1708 — graduates the #853 rule: returns true = run the orchestrator inline UNLESS the host is documented to background a nesting-capable orchestrator (background === true && backgroundDispatch === true); fail-closed to inline; exposed to the plan/execute workflows via the gsd_run query dispatch-should-flatten --raw CLI, which replaced the former scattered RUNTIME === 'codex' prose check). The runtime-descriptor validator (gsd-core/bin/lib/capability-validator.cjs validateRuntimeBody) mirrors the closed vocabulary inline (exported as _HOST_INTEGRATION_VOCAB) and is kept in lock-step by the parity guard tests/host-integration-validator-parity.test.cjs. Orthogonal axes (resolved explicitly per ADR-1239 Phase A): commandStyle (GSD emission style, retained) vs commandSurface (host surface type); hookEvents dialect vs hookBus ownership (a host with hooksSurface:none may still be hookBus:host — e.g. opencode); runtimeCompat (feature→host) vs these negotiated runtime→engine axes. Phase A defined the interface; Phase B (#1679) wires it incrementally — destSubpath write-confinement (#1704) and the typed documentation-sourced #853 dispatch-flatten (#1708, the first consumer of a negotiated dispatch axis); adapters/MCP/host-bindings remain Phases C–E. Source of truth: gsd-core/bin/lib/host-integration.cjs (generated from src/host-integration.cts). See ADR-1239 and ADR-1016.
Statusline
Host-integration hook (hooks/gsd-statusline.js) that renders the session status line: model name, context-window meter, workspace directory, and the GSD-state segment (formatGsdState() projecting .planning/ STATE.md). readGsdState() is workstream-aware (#2850): when the walk-up finds no flat .planning/STATE.md but lands on a .planning/workstreams/ directory, it resolves the active workstream via resolveActiveWorkstream (active-workstream-store.cts), called with an empty args array — only its env>store precedence applies for this caller, since the CLI leg is inert without argv — and planningPaths/listAvailableWorkstreams (planning-workspace.cts) for path/mode resolution, the same seams every other workstream-aware command uses, and reads that workstream's STATE.md instead. The store tier is peekActiveWorkstream, a read-only sibling of getActiveWorkstream that never deletes a stale/invalid pointer file — a renderer invoked on every prompt must never mutate persistent state as a side effect of drawing a screen (getActiveWorkstream's self-heal is correct for a command, not a render). When workstream mode is detected but nothing resolves, it returns a {noActiveWorkstream:true} sentinel that formatGsdState/formatGsdStateCompact render as "no active workstream" — observable, never silent emptiness. Opt-in segments are gated by .planning/config.json keys (statusline.show_last_command, statusline.context_position, plus the approved statusline.show_context_tokens, statusline.state_format, statusline.show_git and statusline.show_state_freshness), each registered across gsd-core/bin/shared/config-schema.manifest.json + src/config.cts + the loadConfig whitelist + docs/CONFIGURATION.md. The compact GSD-state format consumes the canonical status vocabulary from normalizeStateStatus() (STATE.md Document Module) rather than a parallel keyword list. Config resolution for every statusline.* key is centralized in resolveStatuslineOptions(cfg) — the two entry points (runStatusline(), the stdin path, and renderStatusline(data), the test-facing renderer) previously duplicated it byte-for-byte, and a single resolver is what keeps a newly-added key from reaching only one of them (#2734). The opt-in STATE.md freshness marker (statusline.show_state_freshness, #2734) renders state ~N commits back in both renderers when STATE.md's state_head stamp is at least STATE_HEAD_ADVISORY_COMMITS (20) commits behind HEAD — the same threshold validate.health's W024 uses, not > 0, because commit_docs: true advances HEAD by one on every STATE sync and a > 0 threshold would alarm permanently on a fresh project. It follows the git segment's impure-reader → pure-IR → pure-formatter shape (readStateHeadCommits → deriveStateFreshness → formatStateFreshness), spends exactly one bounded git rev-list --left-right --count per render (ancestry and distance in one spawn) and none when disabled, and mirrors src/state.cts's hash fence and projectOwnsItsRepo/sub_repos degradation guards hook-side rather than requiring state.cjs on the per-render path; tests/gsd-statusline.test.cjs binds the two copies by behavioral differential parity against readStateHeadFreshness, not a source comparison. Every degradation yields the tri-state unknown (marker absent), never a "fresh" claim the project cannot substantiate. Data-source boundary (ADR-2164): the statusline sources only local, read-only data — it refines the stdin payload Claude Code already sends and may add a new local source (e.g. git), but does not read credentials or call external/network APIs for data; account/usage/platform-level state is out of scope.
Install Engine Module
Module owning the layout-driven runtime-artifact install pipeline — installRuntimeArtifacts, uninstallRuntimeArtifacts, installOpencodeFamilySkills, and their cluster helpers (_copyStaged, _snapshotDir/_restoreDir, legacy-migration + GSD-entry pruning, user-artifact preserve/restore). Extracted from the 12k-line bin/install.js (ADR-1239 Phase B, #1679) so adapters import the engine instead of reaching into the installer. Commit-attribution resolution stays in bin/install.js and is injected via a resolveAttribution parameter (the engine takes no config I/O). Source: src/install-engine.cts -> gsd-core/bin/lib/install-engine.cjs.
CommonJS Marker Module
Module owning the {"type":"commonjs"} module-type marker GSD writes beside its own staged .js files (#2544) — markerPathFor, classifyMarker, ensureCommonJsMarker, removeCommonJsMarker, and the COMMONJS_MARKER / COMMONJS_MARKER_CONTENT constants. Exists so two rules are enforced in exactly one place: write only where GSD owns the contents (hooks/, and the nativePlugin.dir for runtimes declaring one — never the shared runtime config root, which on OpenCode and Kilo is documented, user-writable territory for local-plugin npm dependencies), and never overwrite a file GSD did not write. Ownership is decided by exact content match, the same predicate the uninstall path always used; classifyMarker is the shared seam behind both the write and the remove path so install and uninstall cannot drift apart again. Fails closed throughout: lstat (not existsSync) so a dangling symlink is never classified absent; anything that is not a regular file is foreign; a present-but-unreadable file is foreign, never downgraded to the permissive answer; the write uses flag:'wx' so anything appearing between classify and write yields preserved-foreign rather than a follow-or-overwrite. ensureCommonJsMarker never throws — an unwritable directory returns failed and both call sites warn and continue, matching the best-effort posture of every other marker interaction. The stale pre-#2544 config-root marker is retired by src/installer-migrations/007-retire-config-root-commonjs-marker.cts (and, for kimi's out-of-configDir root, by bin/install.js directly). Source: src/commonjs-marker.cts -> gsd-core/bin/lib/commonjs-marker.cjs. Test anchor: tests/commonjs-marker.test.cjs.
Installer Migration Authoring Guard Module
Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply.
Installer Module
Primary installer for all runtimes. Single production file: bin/install.js (hand-authored JS — it is NOT generated from src/*.cts; ADR-1508 keeps it hand-authored deliberately, and no npm run build step emits it). Exports: install(isGlobal, runtime[, configDir]) → typed result { runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }; uninstall(isGlobal, runtime[, configDir]); installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile); uninstallRuntimeArtifacts(runtime, configDir, scope); writeManifest(configDir, runtime). Runtime enum: allRuntimes (18 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, hermes, kimi, kimi-code, kilo, opencode, pi, qwen, trae, windsurf, zcode). Directory helpers: getDirName(runtime) → local dir name; getConfigDirFromHome(runtime, isGlobal) → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir]) — the canonical, env-var–aware projection (explicitDir override + opencode/kilo *_CONFIG file-path precedence); the legacy in-installer getGlobalDir/getOpencodeGlobalDir/getKiloGlobalDir were retired into it (#56). The same module exposes detectAntigravityDirAmbiguity(opts) — a side-effect-free probe reporting whether multiple ~/.gemini/antigravity{,-ide,-cli} dirs coexist and which one GSD's gsd-core/VERSION marker (the dot-home-nested probeExists) resolves to, for installer / /gsd-update operator guidance when a pre-#217 install landed in the wrong sibling dir (#1441). Runtime-specific helpers: resolveKiloConfigPath(configDir), configureKiloPermissions(isGlobal[, explicitDir]). Claude-specific permission helpers: mergeClaudePermissions(settings) — non-destructively appends GSD-owned allow/deny entries (see GSD_CLAUDE_ALLOW_PERMISSIONS, GSD_CLAUDE_DENY_PERMISSIONS constants) to a Claude Code settings object; called from finishInstall for runtime === 'claude' only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout (throws TypeError for unknown runtimes). Five runtimes with non-recursive skill loaders (cline, qwen, hermes, augment, trae) use a nested router layout: 6 gsd-ns-* router bundles emitted as top-level skills, with concrete skills nested at <router>/skills/<name>/SKILL.md (hermes prefix='': skills/gsd/ns-*/…). claude (reverted from nested per #924 — the Skill tool errors on unrouted names) and antigravity (one-level scan, but concrete skills must be top-level discoverable) plus the remaining skills-runtimes (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) use the flat skills/gsd-<stem>/ layout. See Skill Surface Budget Module and Runtime Artifact Layout Module.
I/O Module
Module owning the tool's CLI I/O primitives: output() result emission (with large-payload temp-file spillover via GSD_TEMP_DIR/ensureGsdTempDir/reapStaleTempFiles), error() stderr emission with exit-code mapping, and the JSON-error-mode toggle (setJsonErrorMode/getJsonErrorMode, ERROR_REASON). Degraded result vs fault (ADR-2980, #2980): the two emitters are a deliberate two-channel failure contract, not a drift. A fault is error(message, reason) — stderr, exit 1, structured {ok:false,reason,message} envelope under --json-errors. A degraded result is output({ error: … }) — stdout, exit 0, --json-errors does not apply — and means the command ran to completion and is reporting a condition (absent artifact, and in practice also missing-argument and unusable-input cases) through its result; a caller detects it by inspecting the payload, never by exit code. Ratified across 60 sites in 9 modules (state 25, verify 8, workstream 7, frontmatter 6, commands 5, template 3, gsd2-import 2, phase 2, roadmap 2) because normalizing them to exit 1 is a Hyrum's Law break over a CRITICAL radius (get_impact(cmdStateSnapshot); output has 170 direct callers). #2966/#2980 record "42 sites" — that counts only literals whose FIRST key is error (the output\(\{\s*error: regex); 18 more put another key first ({found:false, error}) and are identical in contract, so 60 is the population and 42 is a subset. New code prefers the fault path or a named-field result ({updated:false, reason}), not a 61st site. Known cost carried by the decision: the exit code does not distinguish absent from unusable, which is ADR-1411's "corrupt is not absent" open edge. Docs: docs/json-errors.md → "Degraded results vs faults". Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (graphify, intel, audit, profile-pipeline) depend on a small I/O seam instead of the core god-module; the core.cjs re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: gsd-core/bin/lib/io.cjs (generated from src/io.cts).
Markdown Sectionizer
Canonical markdown-structure parsing seam (gsd-core/bin/lib/markdown-sectionizer.cjs). Pure functions, Node built-ins only. Exports: stripFencedCode(content) → { text, unterminatedFence } (CommonMark-correct state machine, CRLF-safe, signals unterminated fences); stripInlineCode(content) → string (per-line CommonMark inline-code-span stripper — removes `code` spans while leaving fenced blocks to stripFencedCode; #2365); scanInlineCodeSpans(content) → InlineCodeSpan[] (locates every inline code span as { start, end, content }, offsets into the full string and spans never crossing a \n; callers that need the span CONTENT (e.g. api-coverage's package-name evidence, #2365) use this, callers that just want spans gone use stripInlineCode); tokenizeHeadings(content) → HeadingToken[] (ATX headings outside fenced blocks, { level, text, line, offset }); collectSections(content, stopPredicate) → Section[] (line-by-line section collection driven by a heading predicate); collectSection(content, headingPredicate, { levelBounded, stripFences }) → Section | null (single named section with level-bounded stop); iterateBullets(sectionText) → BulletItem[] (dash/checkbox/numbered markers with indented continuation); extractTaggedBlocks(content, tagName) → string[] (inner text of every <tagName>…</tagName> block in document order, tagName regex-escaped, caller decides fence-stripping — generalises decisions.cts's bespoke extractor for T1); replaceSection(content, section, newBody) → string (pure character-offset splice using Section.bodyStart/bodyEnd for read-modify-write callers — eliminates T6 state.cts's 7× inline content.replace pattern); withSection(content, target, edit) → string (resolve the section whose heading matches target — exact heading text or a HeadingToken predicate — and run edit(body) against ONLY that section's body before splicing the result back; bounded no-op when no heading matches or edit returns the same/non-string body; ADR-2143 §4 structurally retires the #2130/#2067/#2080 boundary-crossing class by confining any regex the caller runs to the matched section). Section carries bodyStart/bodyEnd offsets for replaceSection. ADR-1372 (epic #1372) establishes this seam and a tiered migration plan (T0–T7) to retire the 8+ ad-hoc markdown parsers and ~20 inline section-collects across src/*.cts. New src/*.cts modules must import this seam instead of hand-rolling fence strippers or heading-regex section walks (enforced by the no-adhoc-markdown-parsing ESLint rule landing in tier T7).
Markdown Table Model
Canonical GFM table parsing + schema registry seam (gsd-core/bin/lib/markdown-table.cjs; ADR-2143, epic #2143). Pure functions, Node built-ins only, string-in/value-out, no I/O. Exports: parseMarkdownTable(sectionText) → Result<MarkdownTable> (parses the first GFM pipe table found; typed {ok:false,reason} parse errors for no-table, missing/misaligned delimiter row, and ragged data rows — never silently drops or coerces a malformed row); MarkdownTable ({columns: string[], rows: Record<string,string>[]}, rows addressed by column name, not position); Result<T> ({ok:true,value}\|{ok:false,reason} — re-exported from the Write-Set Module, the ADR-2143 §5 single source of truth for this shape, so existing importers of Result from markdown-table.cjs are unaffected; deliberately distinct from command-routing-hub's dispatch Result {ok,data\|kind}; the two never mix); TABLE_SCHEMAS (Record<string, CanonicalTableVariant[]> — the canonical column-header variants for every GFM table GSD parses or generates: RoadmapProgress flat/milestone-grouped, RequirementsTraceability, QuickTasks no-status/with-status, Security trust-boundaries/threat-register/accepted-risks/audit-trail); matchTableSchema(columns) → {id,label}\|null (resolves a parsed header back to its canonical schema by exact column-name/order match). This registry is the single source of truth for ROADMAP/STATE/SECURITY canonical tables — a parity test (tests/markdown-table.test.cjs) asserts every variant's header appears verbatim in the template/workflow file that generates it, so the registry and templates can never silently drift (ADR-2143 §3 Generative-Fix-Divergence guard). phase-lifecycle.cts's deriveProgressFromRoadmap is the first consumer: it locates the Progress section via the Markdown Sectionizer's collectSection and reads cells by column NAME through this seam, fixing #2137 (the prior position-anchored regex assumed Status was always the 3rd cell, which broke for the 5-column milestone-grouped Milestone variant).
Write-Set Module
Shared fail-loud Result<T> and per-surface write-set contracts (gsd-core/bin/lib/write-set.cjs; ADR-2143 §5/§6, epic #2143). Pure, Node built-ins only, no I/O. Exports: Result<T> ({ok:true,value}\|{ok:false,reason} — ADR-2143 §5 fail-loud parse shape, never a bare null a caller can mistake for "empty but fine"; the single source of truth markdown-table.cjs re-exports so its existing importers are unaffected; deliberately distinct from command-routing-hub's dispatch Result {ok,data\|kind}); WriteOutcome ({surface: string, applied: boolean} — one surface's outcome within a multi-surface write); WriteSet (WriteOutcome[]); writeSetComplete(ws) → boolean (true only when the set is non-empty AND every surface applied — ADR-2143 §6's "no OR-into-one-flag" rule: a command that mutates more than one surface must not collapse independent surface outcomes into a single boolean, the anti-pattern that let a checkbox-only partial write (#2140) report full success). milestone.cts's requirements mark-complete handler is the first consumer: it reports a write_set (checkbox/traceability surfaces) and write_set_complete alongside its existing updated/marked_complete/already_complete/not_found/table_unmatched fields, which remain computed exactly as before — the write-set is additive, structured ADR-2143 documentation of the same per-surface facts #2140's tactical fix already exposed via table_unmatched.
Roadmap Parser Module
Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone extraction, milestone/phase lookups, and milestone-phase filtering (stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter, isMilestoneShippedInRoadmap, withPhaseSection). Milestone shipped/active heading classification is owned here (#2562): isMilestoneShippedInRoadmap(content, version) answers "does the ROADMAP mark THIS milestone shipped" from heading and <summary> lines only — never a bullet that merely names the version — with the version token boundary-matched so v2.0 does not match inside v2.0.1. extractCurrentMilestone and getMilestonePhaseFilter take an optional trailing workstream name so their planningDir resolution targets .planning/workstreams/<ws>/; omitted, it resolves exactly as before (including the GSD_WORKSTREAM fallback). getMilestonePhaseFilter exposes versionScoped, true only when the returned phase set really is one milestone's — consumers must not read phaseCount as a current-milestone denominator otherwise — and versionSectionFound, true whenever the requested version's section was located at all. The two differ precisely for a located-but-EMPTY section: it falls through to the zero-count pass-all degrade, which resets versionScoped to false, leaving versionSectionFound the only surviving evidence that the milestone exists rather than being absent. missingExplicitVersion covers the complementary shape (versioned roadmap, no section for this version). withPhaseSection(content, phaseId, edit) resolves a phase's ### Phase N detail-section heading via the #2121 phase-id source (phaseMarkdownRegexSource) and delegates to the markdown-sectionizer seam's withSection, so a per-phase ROADMAP edit is bounded to that phase's own section (ADR-2143 §4). Depends only on leaf modules (phase-id, planning-workspace, shell-command-projection, markdown-sectionizer, and — since #1881 — unusable-input for the out-of-band diagnostic) — no loadConfig, no other core dependency. An unreadable ROADMAP.md is reported rather than collapsed into the same sentinel as a genuinely absent one; absence itself stays silent, and neither lookup gains a throw (the #2245 audit records that src/state.cts removed its defensive try/catch on the strength of getMilestoneInfo never throwing). Milestone WINDOWING — which headings bound a milestone — is owned here as of #3184 (epic #3180 Phase 2, ADR-3180 Decision 1): computeMilestoneSectionEnd (the section-end walk, formerly duplicated as two distinct nested computeSectionEnd functions plus an inline third copy in getMilestonePhaseFilter's versionOverride branch), locateMilestoneHeadings (heading location, version token boundary-matched with \b, NOT the stricter (?![\w.-]): v2.0 therefore DOES match inside v2.0.1, and a milestone STATE of v8.0 legitimately selects a live ## v8.0-B … over a closed v8.0-A sibling — deliberate, load-bearing #730 behavior that ADR-3180 Amendment 2 tried to tighten and then reverted; the earlier text here described that reverted alternative as if it had shipped, corrected by #3216), listMilestoneHeadings (#3216 — the version-AGNOSTIC enumeration of every milestone heading in document order, sharing ONE grammar source with locateMilestoneHeadings so the two cannot drift; locateMilestoneHeadings is now a version-filtered view over it rather than a second expression of the pattern). Milestone IDENTITY — which milestone is current and what it is CALLED — is owned by getMilestoneInfo, which since #3216 binds to that same locator instead of its own heading regexes and returns a ScopedResult<MilestoneInfo | null>: a name retains parentheses and drops a trailing ✅/📋/🚧 marker, a ### Phase N … heading is never the milestone heading (#3197), and an identity that cannot be determined returns a non-COMPLETE scope rather than the former {version:'v1.0', name:'milestone'} default, which was output-identical to a successful read of a genuine v1.0 project. buildStateFrontmatter and archivePhaseDirectories branch on that scope, so a fabricated identity is never persisted to STATE.md nor used as a milestones/<version>-phases/ path component, sliceMilestoneWindow (the one composition of locate → prefer-non-closed → section-end, so a consumer cannot re-assemble its own window from the primitives), and isMilestoneBoundedInRoadmap (the named predicate replacing two byte-identical re-derivations in state.cts). hasMilestoneSectioning(content) is the sibling predicate answering "could a whole-document phase count conflate two different milestones?" — and since #3185 it is decided by milestone VOCABULARY, not by heading position: a heading is a milestone heading iff it is a non-Phase heading (level 1-3) carrying a version token, a shipped/active marker, or the word Milestone, and sectioning means two or more of them, since one section cannot conflate siblings. Since #3642, buildStateFrontmatter's flat-vs-milestoned gate consumes the >=1 sibling hasAnyMilestoneSection over the same countMilestoneHeadings walk instead: asserted-vs-section is a different question than sibling conflation, and needs only ONE section to go wrong — with exactly one section and an asserted milestone absent from the ROADMAP, the whole-document count IS that foreign section's phases, so the #3354 withhold (stored value preserved + warning) governs; a zero-signal (genuinely flat) roadmap keeps the whole-document count per #2828. Three position-based models were tried and each shipped a defect — "any non-Phase heading" over-detects, so a flat ROADMAP carrying an ordinary ## Progress was called sectioned and its declared phase count discarded for the on-disk directory count (#3204, #2828 regressing at 1.9.1 via #3184's own consolidation); strict nesting misses same-level siblings (regressing #1761) and false-positives on the bundled templates/roadmap.md shape, where a ## Phases wrapper holds a single nested milestone; adjacency false-positives whenever a structural heading merely precedes a phase heading. Known limit: two milestone sections carrying none of the three signals are not detected. extractCurrentMilestoneScoped is the real extractor and returns the Planning Scope Module's ScopedResult; extractCurrentMilestone remains a one-line wrapper over .value because its blast radius is CRITICAL (200+ affected symbols, 20 direct callers) and its signature must not move. getMilestonePhaseFilter gains a scope field: its pass-all degrade is PRESERVED where its premise holds (a genuinely-empty, freshly-declared milestone reports SCOPE.COMPLETE) and is now labelled where it does not (SCOPE.TRUNCATED when the window reached no phase entries while the document has them), so the destructive consumer — milestone.complete, which MOVES phase directories — can refuse instead of archiving every phase directory on disk (#3166). The filter's function behavior is deliberately unchanged: making it deny-all on a non-COMPLETE scope would trade a silent over-inclusive answer for a silent under-inclusive one on the read paths that count with it. findRoadmapProgressTable(content) (#1956) locates the ## Progress table — scoped to that heading via the markdown-sectionizer seam, falling back to the whole document for a headingless milestone slice — so a differently-headed table sharing the Phase | Plans Complete | Status | Completed columns cannot be read instead (the #2012 decoy class). phase-lifecycle.cts's deriveProgressFromRoadmap expresses the same scope independently for the completion RATIO; the two are held in agreement by a parity test rather than by a shared call, because that symbol's blast radius does not justify a refactor. Extracted from the Core module per ADR-857 rollout phase 2b (#870), resolving the ROADMAP.md parse/write straddle so the Roadmap module (roadmap.cjs, which owns ROADMAP.md mutation) imports parsing directly instead of through Core; the core.cjs re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: gsd-core/bin/lib/roadmap-parser.cjs (generated from src/roadmap-parser.cts).
Core Utilities Module
Module owning the shared low-level utility primitives extracted from Core: POSIX path normalization (toPosixPath), filesystem scanning (detectSubRepos, readSubdirectories, getPhaseFileStats, pathExistsInternal), plan/summary pairing helpers (countMatchedSummaries, findUnsummarizedPlans, findOrphanSummaries), and small pure helpers (generateSlugInternal, extractOneLinerFromBody, extractCanonicalPlanId, timeAgo). filterPlanFiles/filterSummaryFiles were retired by #3183 (ADR-3180 Decision 2) — getPhaseFileStats no longer re-derives plan/summary filename matching locally; it now sources plans/summaries (plus a scope field, COMPLETE/TRUNCATED/UNREADABLE) directly from scanPhasePlans (src/plan-scan.cts), the single owner of live-plan counting. Depends only on Node built-ins and already-leafed modules (phase-id for comparePhaseNum, planning-workspace for findContextMdIn) — no loadConfig, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2c (#877) as the shared leaf that unblocks the phase-locator fs-search extraction (2d); the core.cjs re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: gsd-core/bin/lib/core-utils.cjs (generated from src/core-utils.cts).
Agent Install Check Module
Module owning agent-presence resolution and verification, extracted from the Core module as the cleanup step that retired the core.cjs re-export spine (the final ADR-857 decomposition, epic #1267). Interface: getAgentsDir(runtime?, projectRoot?) — env-var-aware, runtime-aware agents-directory resolution; Claude resolves __dirname-relative unless that path contains a node_modules path segment, in which case it falls back to getGlobalConfigDir('claude')/agents (#3203); that segment test is lexical and case-sensitive rather than an install-shape guarantee — it targets the layouts where the sibling agents/ is the package's own bundled copy and the check would otherwise validate the package against itself, and any path merely carrying a directory of that name resolves the same way. Other runtimes prefer a manifest-backed project-local agents directory before their global configuration home. The manifest gate is intentional: runtime-native project agents must not shadow a working global GSD install. checkAgentsInstalled(...) validates gsd-file-manifest.json completeness and confirms the declared agents exist on disk. Pure read/verify — no install-write side effects (writes remain the Installer Module's). Consumed by the Init Command Module, the verify workflow, and the docs workflow. Source of truth: gsd-core/bin/lib/agent-install-check.cjs (generated from src/agent-install-check.cts); replaced the two functions that squatted in core.cts. See Installer Module and ADR-857.
Config Loader Module
Module owning project configuration loading: reads .planning/config.json, merges built-in defaults (CONFIG_DEFAULTS/CANONICAL_CONFIG_DEFAULTS), normalizes legacy keys, applies the active-workstream overlay, validates against the config schema, and warns on unknown keys/profile overrides. Primary interface: loadConfigResolved(cwd, options) → ConfigResolution { config, source, degraded } (provenance-aware, ADR-1411 P2 / #1415) — source ∈ 'workstream' | 'root' | 'builtin-defaults' | 'global-defaults'; degraded:true when a workstream was requested but its config.json was absent (fell back to root config). loadConfig(cwd, options) → Record<string,unknown> is the back-compat thin wrapper over loadConfigResolved (byte-identical result). Loading is not side-effect-free by default: normalizing a legacy key marks the config dirty and writes the migrated shape back to disk, which is how a legacy project is migrated by ordinary use. options.persist: false (#3648) suppresses exactly that write while leaving resolution identical — the option a caller passes when it is only ASKING the config something (the Git Query Module's protected-branch predicate runs on every execute-phase and ship, and a question must not rewrite the file it asks about). It is opt-OUT, so every pre-existing caller keeps the historical write-back. Resolution is caller-anchored, not loader-anchored: loadConfigResolved resolves cwd as-is (no walk-up), so loadConfig stays byte-identical for its callers; callers that need cwd-drift tolerance (e.g. cmdAgentSkills) anchor to the project root via findProjectRoot (Project-Root Resolution Module) before calling loadConfigResolved. Helper exports: _deepMergeConfig, isGitIgnored, _warnUnknownProfileOverrides. Depends only on leaf modules (configuration, config-schema, planning-workspace, shell-command-projection, core-utils, model-catalog) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2e (#885) as the prerequisite for the model-resolver extraction (the resolvers call loadConfig); the core.cjs re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: gsd-core/bin/lib/config-loader.cjs (generated from src/config-loader.cts).
Planning Publication Gate (planning.pr_strict)
The seam deciding whether .planning/ artifacts reach the REMOTE — distinct from the Planning Commit Gate below, which decides whether they reach git at all. planning.pr_strict (boolean, manifest default false) resolves through the same loadConfigResolved chain as its planning.* siblings (explicit top-level pr_strict, then the planning.pr_strict alias, then the manifest default), and is additionally registered in SCHEMA_DEFAULTS (src/config.cts) so query config-get planning.pr_strict answers false for an absent key rather than Key not found — gsd-core/workflows/pr-branch.md reads it with a plain config-get and must not special-case a missing key. It selects between two filter modes in that workflow: default preserves the five structural planning files plus milestones/** and drops nine transient subdirectories; strict drops every .planning/ path and includes a commit only when it touches at least one file outside .planning/. The two path lists are declared ONCE in the workflow (TRANSIENT_DIRS, STRUCTURAL_RE) and both the un-stage step and the verification assertion are derived from them, because the prior shape declared them twice and the two steps disagreed by construction — verify asserted zero .planning/ paths while create_pr_branch was specified to preserve five, so a correct run reported itself as failed on every phase (#2971). A third gate is deliberately NOT derived from those declarations: PLANNING_DELETIONS (#3679) counts DELETED .planning/ paths via git diff --name-status --no-renames and must be 0 in every mode — a deleted planning path is data loss, not filtering, and name-only counting cannot see status. The two gates are independent but not orthogonal in effect: pr_strict is inert when commit_docs is false, since nothing is committed for the PR-branch filter to remove. Source of truth: gsd-core/workflows/pr-branch.md; key registered in gsd-core/bin/shared/config-{defaults,schema}.manifest.json.
Planning Commit Gate (commit_docs)
The seam deciding whether .planning/ artifacts reach git. commit_docs resolves in the Config Loader Module (loadConfigResolved) through an ordered chain — an explicit value (top-level commit_docs or its planning.commit_docs alias) wins; absent that, isGitIgnored(cwd, '.planning/') auto-resolves it to false; otherwise the manifest default (true) applies. cmdCommit (Command Module) is the ONLY sanctioned writer: it returns the typed skip envelope { committed: false, skipped: true, hash: null, reason } with reason ∈ { 'skipped_commit_docs_false', 'skipped_gitignored', 'skipped_commit_docs_phase_false' } rather than erroring, and skipped: true is explicit so agent prompts match a first-class success signal instead of inferring a skip from a missing committed and improvising a raw-git fallback (#3678). Those reason strings are a de facto public contract — agents/gsd-executor.md pattern-matches on them — so they are additive-only.
Per-phase override (#3587, epic #2292 Phase 3). A NEW tier resolves ABOVE the chain above, entirely inside cmdCommit (src/commands.cts's resolveCommitDocsPolicy/resolvePhaseCommitDocsOverride) — deliberately NOT inside loadConfigResolved, which has no phase context and is called by nearly every command. The dynamic config key phase_commit_docs.<phase-id> (a { "<phase-id>": boolean } map, registered in config-schema.manifest.json's dynamicKeyPatterns and threaded through config-loader.cts's _baseConfig projection the same way agent_skills is — a dynamic key absent from that hand-maintained allowlist is silently dropped on read, the exact failure mode features.<feature_name> demonstrates today) lets a tech lead commit one phase's artifacts while the project-wide commit_docs stays false (or the reverse). The phase being committed is resolved via the PRE-EXISTING detectPhaseNumberFromFiles(files) (the same #2539-hardened, project-code-aware derivation branching_strategy already used one branch below) and normalized through normalizePhaseName on both sides of the comparison, so 3/03/PROJ-03 hit one entry and a value scoped to a DIFFERENT phase never leaks. A non-boolean stored value ("true", 1, null) is never coerced — it falls through to the pre-existing chain untouched. When this tier suppresses a commit, the envelope's reason is skipped_commit_docs_phase_false — deliberately distinct from skipped_commit_docs_false, so a per-phase suppression is never reported as "your project setting is false" when it is actually true. AC4 (byte-identical when unset): with no phase_commit_docs key, this tier is a no-op and the three-tier chain above resolves exactly as before — pinned by the folded:phase-commit-docs block's C1-C5 in tests/commit-docs-bypass.test.cjs. The phase_commit_docs.<phase-id> grammar is a hand-copy of the canonical PHASE_NUMBER_TOKEN_SOURCE (src/phase-id.cts, #2128) into the hand-maintained schema manifest — pinned against drift by that same block's describe 'E' in tests/commit-docs-bypass.test.cjs (behavioral, over a shared shape list, per CLAUDE.md's Generative Fix Divergence class). The two reasons are ordered, not peers, and skipped_gitignored is near-unreachable in a real project (measured #3585): cmdCommit tests resolved commit_docs FIRST, and whenever .planning/config.json exists — which it does in every initialized project — the loader's gitignore auto-detect has already resolved that value to false, so the first branch returns skipped_commit_docs_false and the isGitIgnored branch below it is never reached. skipped_gitignored fires only when config.json is absent entirely, so the loader falls back to the true default and cmdCommit's own check is what fires. A gitignore-driven skip therefore reports the config-driven reason; both members are behaviorally pinned by tests/commit-docs-bypass.test.cjs (B1-B3 and G1) so a rename fails loudly, but the reason a user sees does not distinguish which input suppressed the commit. The gate is bypassable only from OUTSIDE the code: a workflow step that types git add into its own shell reaches the index without passing through cmdCommit, and no code change can intercept that. Two guards therefore enforce it as text rather than at runtime: tests/commit-files-pathspec.test.cjs (#2269) requires every shipped commit invocation to declare --files, and tests/commit-docs-bypass.test.cjs (#1783, made repo-wide by #3585) requires every shipped git add able to reach .planning/ to sit inside an executable commit_docs check — a markdown prose conditional ("If commit_docs is true:") is not a guard, because the bash block below it runs regardless. Both consume one shell tokenizer (tests/helpers/shipped-command-scan.cjs) and one exemption marker (# gsd-scan-ignore: #NNN, reason must cite a tracking ref per ADR-456). Guard state does not cross a fenced-block boundary: each fenced block is its own shell, so a guard opened in one block does not protect a git add in the next. Known limit: .gitignore has no effect on files git already TRACKS, so a project that committed .planning/ before ignoring it keeps staging those paths — see gsd-core/references/planning-config.md and #3586. cmdCheckCommit (Command Module, verb check-commit) is a SEPARATE reader of the same commit_docs value, for callers outside GSD's own commit path: it inspects the staged set directly and refuses (non-zero exit) when commit_docs is false and any staged path is under .planning/; otherwise it allows. gsd-tools commit-docs-guard enable/disable (#3588) is the opt-in installer for a .git/hooks/pre-commit hook that shells out to exactly this verb, closing the one bypass the text-scan guards above cannot reach — a human or script running a bare git add -A && git commit in their OWN shell, outside any GSD-shipped workflow. The hook is identified by a # gsd-core:commit-docs-guard marker line (presence-checked, not byte-equality), is written only on explicit request (no install path wires it by default — locked by tests/commands.test.cjs's E2 row), refuses rather than overwrite or delete a foreign pre-commit, resolves the real hooks dir via git rev-parse --git-path hooks so a linked worktree or submodule whose .git is a FILE works, and refuses outright when core.hooksPath is already set, because a written-but-ignored hook is worse than a refusal.
Model Catalog Module
Leaf module owning the static model tables and the closed vocabularies derived from them — the tier/runtime/provider enums (VALID_TIERS, VALID_AGENT_TIERS, KNOWN_RUNTIMES, KNOWN_PROVIDERS, RUNTIMES_WITH_REASONING_EFFORT, RUNTIMES_WITH_FAST_MODE, ADAPTIVE_TIER_VALUES), the alias and profile maps (MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, PROVIDER_PRESETS), effort rendering (renderEffortForRuntime), and the agent→model projections (getAgentToModelMapForProfile, formatAgentToModelMapAsTable). Also owns the per-model Codex effort capability table (CODEX_MODEL_EFFORT, sourced from model-catalog.json's codexModelEffort with a _baseline fallback for unknown model ids) because Codex declares supported_reasoning_levels per model rather than per runtime, so renderEffortForRuntime('codex', level, model) resolves against that table. A genuine leaf: it imports node:path and its own model-catalog.json and nothing else, which is what makes it the correct home for anything several unrelated surfaces must agree on. Model ids live in model-catalog.json, never inline — changing one means regenerating goldens (UPDATE_GOLDEN). Also owns the Anthropic-flavored-model rule (#3241, ADR-2313): CLAUDE_AGENT_ALIASES (the frozen four-alias set opus/sonnet/haiku/fable) and isAnthropicFlavoredModel(model), which is true for a bare tier alias or for any claude-* id in any provider namespacing (anthropic/claude-*, us.anthropic.claude-*); no OpenAI/Codex model id contains "claude", so the case-insensitive substring test is safe and exhaustive. It was moved down here from the Model Resolver Module rather than shared from there, because the Codex posture check (agent-install-check, epic #2313 Phase 2) and the Codex .toml sync (commands, Phase 3) both need the rule and neither may take the config-loader dependency model-resolver would have brought; model-resolver re-exports it for back-compat and a parity test fails if the two ever fork. Avoid: "the model list" (ambiguous between the catalog JSON and the derived enums). See Model Resolver Module, ADR-2313, and ADR-0003.
Model Resolver Module
Module owning model and effort resolution policy: resolves the model, runtime tier, planning granularity, reasoning effort, and fast-mode for a given agent by reading project config and resolving against the model profiles and catalog (resolveModelInternal, resolveModelPolicy, resolveTierEntry, resolveModelForTier, resolveGranularityInternal, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, nextEffort, assertValidGranularityOverride). Depends only on leaf modules (config-loader for loadConfig, configuration for defaults, model-profiles and model-catalog for the static tables) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2f (#888) — the final core.cts decomposition step; the core.cjs re-export spine was retired in epic #1267, so callers import this leaf directly. CLAUDE_AGENT_ALIASES no longer lives here — it moved down to the Model Catalog Module (#3241, ADR-2313 Phase 1) so the Agent Install Check and Codex-sync surfaces can consume the alias rule without taking a config-loader dependency this module would have dragged with it; it is still re-exported from here, so existing importers (bin/install.js, tests/codex-config.test.cjs) are unaffected and a parity test asserts both modules expose the same set. Source of truth: gsd-core/bin/lib/model-resolver.cjs (generated from src/model-resolver.cts).
Install Model Override Resolver Module
Module owning install-time per-agent model-override resolution (#2256 / #2794), extracted from bin/install.js's inline agent-staging loop (#2875 Part 2 / J8), which duplicated this EXACT precedence chain across two runtime branches (OpenCode/Kilo, ~24 lines each): model_overrides[agent] > model_profile_overrides.<runtime>.<tier> > omit. Interface: readGsdGlobalModelOverrides/readGsdEffectiveModelOverrides/readGsdRuntimeProfileResolver (impure config-file reads, mirroring install-effort-resolver.cts's existing extraction precedent) and resolveAgentModelOverride (pure given their pre-resolved outputs). Reached from runtime-artifact-layout.cts's agents-kind stage() for the opencode/kilo converters, so it is on the installRuntimeArtifacts call tree — every fs touch routes through installFs() (Install Fs Adapter Module) rather than calling node:fs directly, so a fake adapter injected via withInstallFs is honored. A single source of truth means the descriptor-driven agents pipeline and bin/install.js's own callers can only diverge if this module changes, not silently across two hand-maintained copies (the Generative Fix Divergence class CLAUDE.md's "Known Defects" section warns about). Source: src/install-model-override-resolver.cts -> gsd-core/bin/lib/install-model-override-resolver.cjs. See Install Engine Module, Model Resolver Module.
Codex Agent TOML Module
A genuine leaf (node builtins only) owning the typed IR for ~/.codex/agents/<agent>.toml (#3243, ADR-2313 Phase 3). It is a document model, not a policy — it knows how to parse/render/strip the two keys the posture owns (model, model_reasoning_effort); it does NOT know which model values are illegal for Codex (that predicate, isAnthropicFlavoredModel, stays in the Model Catalog Module and the caller decides what to strip). parseCodexAgentToml(content) → {ok:true,doc} | {ok:false,reason} is the STRICT half: PARSE_REASON.UNTERMINATED_BLOCK when the developer_instructions block is opened but never closed, because a writer that proceeds on a malformed document risks rewriting it. renderCodexAgentToml(doc) round-trips byte-identically for an unmodified doc — the load-bearing property that stops a sync from silently reformatting a user's file — by keeping the original lines array (never re-derived) plus the detected eol/BOM/trailing-newline metadata, and rejoining rather than reconstructing. stripModel/stripReasoningEffort remove exactly one targeted line, re-indexing the block range and the sibling key's line index; every other line (comments, hand-added keys, the prompt block, line endings) is untouched. scanTomlLines/stripBOM/findDeveloperInstructionsBlockRange/unquoteTomlValue are the LENIENT reader primitives — moved here (not copied) from the Agent Install Check Module (#3242, Phase 2), which still imports and calls them directly, unchanged in behavior: an unterminated block falls back to "rest of file is inside the block" rather than failing, because misreading prompt prose as a pin is only a false positive. One block-range detector (findDeveloperInstructionsBlockRange, now carrying a terminated flag the strict parser reads and the lenient scanner ignores) serves both policies, so the reader and the writer can never silently diverge on where the block ends. Consumed by the Codex .toml sync (Commands Module's cmdEffortSyncCodex, ADR-2313 D7) and the Agent Install Check Module's checkCodexModelPosture. Source of truth: gsd-core/bin/lib/codex-agent-toml.cjs (generated from src/codex-agent-toml.cts). See Agent Install Check Module, Model Catalog Module, ADR-2313.
Package Identity Module [Planned]
Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is package.json; values are derived, not re-typed: packageName (.name → @opengsd/gsd-core), binName (Object.keys(.bin)[0] → gsd-core), repoSlug (parsed from .repository.url → open-gsd/gsd-core), plus derived changelogRawUrl and manualInstallCommand({ scope, runtime }). Generated .cjs per ADR-457 (generated-single-source); shipped under gsd-core/bin/lib/. Three consumer worlds: Node consumers require() it at runtime (worker, check-latest-version.cjs, bin/install.js); the bash launcher snippet receives the literal injected by scripts/sync-runtime-launcher.cjs at sync time; prose/help literals (update.md, installer help) carry a committed copy. A drift-guard lint (scripts/lint-package-identity-drift.cjs, sibling to check:alias-drift) fails CI on any raw package/repo literal outside package.json, the generated module, and the value-checked materialization sites — this is what keeps the seam real (two adapters, not one). Replaces the contradictory pair it consolidates: the runtime-broken require('../package.json').name in hooks/gsd-check-update-worker.js (#378, resolves to undefined post-install) and the hardcoded constant in check-latest-version.cjs (#2992). Avoid: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module.
SEAM.package-identity.owns=GSD's published-package coordinates (packageName, binName, repoSlug, changelogRawUrl, manualInstallCommand) — single seam so a repoint/rename is a one-line change
SEAM.package-identity.enforced-by=test:tests/package-identity.test.cjs
Update Context Module [Planned]
Module owning install detection for /gsd:update. resolveUpdateContext({ home, cwd, env, fs, preferredConfigDir, preferredRuntime }) is a pure, injected-fs port of update.md's former ~280-line get_installed_version bash; it reproduces the full precedence cascade — preferred-config-dir fast path, local-over-global probe with same-path dedup, env-var overrides (CLAUDE_CONFIG_DIR, OPENCODE_CONFIG, KILO_CONFIG, XDG_CONFIG_HOME, CODEX_HOME, …), and semver validation — and returns the 4-field contract { installedVersion, scope, runtime, gsdDir } (scope ∈ LOCAL/GLOBAL/UNKNOWN). Antigravity is modelled first-class (its .gemini/antigravity{,-ide,-cli} dirs probe before bare .gemini; #3608). Exposed to the workflow as gsd-tools update-context [--config-dir <d>] [--runtime <r>] --json; loadUpdateContext wires the real fs. The workflow keeps only the execution_context path → PREFERRED_* derivation (the one input it alone knows). Source: gsd-core/bin/lib/update-context.cjs; tests: tests/update-context.test.cjs. See Installer Module and Package Identity Module.
Skill Surface Budget Module
Module owning which skills and agents are written to runtime config directories at install time (Phase 1) and at runtime via cluster-level toggles (Phase 2). Phase 1: gsd-core/bin/lib/install-profiles.cjs defines named profiles (core, standard, full), computes transitive closure over requires: frontmatter, stages skills/agents to runtime config dirs, and persists the chosen profile in a .gsd-profile marker. Profile resolution precedence: explicit --profile= flag > .gsd-profile marker > full. --minimal/--core-only are back-compat aliases for --profile=core. Phase 2: gsd-core/bin/lib/surface.cjs implements the /gsd:surface slash command for cluster-level enable/disable without reinstall; cluster definitions live in gsd-core/bin/lib/clusters.cjs; per-runtime state persists in <runtimeConfigDir>/.gsd-surface.json independent from the .gsd-profile marker. See ADR-0011.
Context Composer Module
Shared, pure, no-I/O seam owning priority-ordered composition of content fragments within a measured budget (ADR-1671 Decision item 2; extracted from prompt-budget by #2929, epic #1671 Phase 2). Interface: composeWithinBudget({fragments, budget, measure, options}) → {fragments, metadata}, plus the relocated headShrink/tailTruncate primitives. The composer decides; the caller renders — it returns a plan of surviving fragments with their trimmed content and never emits a rendered string, because prompt-budget's assembly is prompt-shaped (## Roadmap, ### <file>, note in position two) and per-runtime emission renders differently; owning rendering would prevent one seam from serving both. Budget unit is injected via measure(text) → number (prompt-budget passes estimateTokens, chars/4; emission passes a byte counter per ADR-1671's "bytes for emission caps"), with charsPerUnit as measure's inverse for the proportional-truncate step — the prior code hardcoded * 4, which silently assumed the token estimator. Shrink strategies, not a cutoff: ADR-1671's literal contract says "priority + binary-search cutoff", which cannot express head-shrink, proportional-truncate-with-floor, or never-droppable classes; the closed strategy set is verbatim | head-shrink | proportional-truncate(floorChars) | drop, and a cutoff strategy joins that set when per-runtime emission needs it (Phases 3-4). Ordering is declaration order, not a numeric priority field. A flexReserve-style floor is a per-fragment guarantee, NOT a budget cap — it may deliberately push the total above the proportional share. The note reserve is deducted only under pressure (baseline > effectiveBudget, strict): deducting unconditionally drops sections reserve units early, which is the PR #3708 regression recorded at LEARNING.prompt-budget.boundary-gap. Presence is a truthy test, so an empty-string fragment is indistinguishable from an absent one — characterized, not designed. Source of truth: gsd-core/bin/lib/context-composer.cjs (generated from src/context-composer.cts). Test anchors: tests/prompt-budget-parity.test.cjs, tests/fixtures/prompt-budget-parity/corpus.json.
Workflow Fragments Module
Pure, no-I/O seam owning in-file <!-- gsd:section id="<id>" when="<when>" --> / <!-- /gsd:section --> marker parsing and composition for GSD workflow markdown (ADR-1671 Decision item 1 + migration step 4 + open questions 1 & 2; epic #1671 Phase 3, #2930). parseWorkflowSections partitions a document into explicit (marked) and gap (unmarked, explicit: false) sections in document order — a marker line is removed in full (text + its own terminator), so an unmarked workflow (88 of 89 today) parses to exactly one implicit gap fragment and round-trips byte-identical. toFragments maps sections to Context Composer Module fragments, every one {kind: 'verbatim'} — non-lossiness in this phase is a structural guarantee of the strategy set, never a large-budget trick. composeWorkflow is the emission entry point: parse → toFragments → composeWithinBudget → renderFragments, run BEFORE the per-runtime converters so a marker attribute is stripped before any path-rewrite regex can reach it. The grammar is deliberately CLOSED (Greenspun's Tenth Rule): when= takes exactly one atom from the frozen WHEN_VOCABULARY — 29 atoms (4 at #2930, widened 4→14 by #2992, 14→19 by #2993, 19→29 by #2994, each requiring a coordinated ADR-1671 amendment) — with no boolean operators, negation, or nesting; an unknown when= value throws rather than being silently dropped, and widening the vocabulary requires an ADR amendment, not an organic edit. Two gates govern admission: a named consuming section of at least 400 bytes, and a fact the init seam demonstrably computes at a real entry point — an atom failing the second evaluates false forever, so its marker looks like working gating while silently disabling itself. Any condition that cannot reduce to a single boolean is not an atom: a compound is resolved upstream in the FACT by the init seam (state:chunked-mode), never in the grammar, and negation is expressed as its own positively-phrased atom (state:flat-mode), never as an operator. Fence and HTML-comment interleaving is scanned in one left-to-right pass with two mutually exclusive states, reusing the discipline from the Context Predicates module's fence/comment scan (the two-pass design that caused #2928's silent-skip-to-EOF defect). when= was parsed and validated but not acted on at #2930; applicability selection shipped in Phase 5 (#2932, Section Manifest Module) and the rollout is complete across 15 workflows / 37 sections (pilot execute-phase.md at #2930 — retargeted from plan-phase.md, which sat 36 B under the ADR-857 PRE_PHASE6 gate and could not absorb marker overhead until #2993 fragmentized it; the remaining 13 LARGE/XL workflows at #2994). Emission scope covers agents/ as of #2995, so a marker in an agent file is stripped at emit rather than shipped verbatim into every runtime — composition runs at two call sites (stageAgentsForRuntimeWithConverter, with agentsKind/kimiAgentsKind routed through it, and bin/install.js's inline agent loop) plus installCodexConfig's per-agent TOML read, always BEFORE any path rewrite. when= GATING remains workflow-only: section-manifest.json is keyed {workflows: …} and gen-section-manifest.cjs scans only gsd-core/workflows/*.md, so an agent atom has no consumer and would fail admission gate (2); agents are size-managed by extraction to gsd-core/references/ per DEFECT.AGENT-FILE-SIZE-CAP-BREACH instead. Source of truth: gsd-core/bin/lib/workflow-fragments.cjs (generated from src/workflow-fragments.cts). Test anchors: tests/workflow-fragments.test.cjs, tests/workflow-fragments.property.test.cjs, tests/workflow-fragments-emission.install.test.cjs.
Section Manifest Module
Pure, no-I/O when= evaluator over InvocationFacts, mapping a document-order list of parsed gsd:section sections (Workflow Fragments Module) to an included/excluded partition for one concrete invocation (ADR-1671 Decision items 3 & 4 + migration step 6; epic #1671 Phase 5, #2932). The evaluator is a LOOKUP, not a parser — WHEN_PREDICATES is a total map from each frozen WHEN_VOCABULARY entry (imported unchanged from workflow-fragments.cjs, never redeclared) to exactly one predicate over InvocationFacts — {flags: ReadonlySet<string>, phaseNumber: string|null, hasPriorPhases: boolean} plus optional already-resolved booleans (needsCodebaseMap, phaseMvpMode, worktreesEnabled, chunkedMode, uiPhaseActive, fallowEnabled, …), every field a plain value the caller computed before selectSections runs; flags is a ReadonlySet rather than a plain object because .has() carries no prototype hazard — and note parseNamedArgs NEVER returns undefined for an absent flag (booleans come back false, value keys null), so "present in the options record" is not token presence — as of ADR-3473 §8.4 / #3884, parseNamedArgs(args, spec) returns a Result ({ok:true,data} | {ok:false,kind:'InvalidArgs',...}, spec requiring an explicit positionals: number|'rest'), so this predicate now describes .data's fields on the ok:true branch, not a bare returned object; the null/false-never-undefined guarantee on those fields is unchanged. It MUST NOT tokenize, split on operators, or interpret when= structure — the moment it parses, the ad-hoc language Greenspun's Tenth Rule warns against has begun. selectSections(sections, facts) returns {included, excluded} id arrays that together contain every input id exactly once, in the same relative document order, never mutating the input. An unrecognized when= value fails closed via a TypeError carrying .reason = REASON.UNKNOWN_WHEN — never silently excluded — matching the discipline Phase 3 already established for the same vocabulary at parse time. Every predicate treats an absent fact key as falsy without throwing, since the caller (the init CLI seam) may not always populate every field. A coordinated-change guard runs at module load: every WHEN_VOCABULARY entry must have exactly one predicate here, so a 5th vocabulary entry added without a matching predicate fails loudly at load time rather than silently falling through to REASON.UNKNOWN_WHEN only at run time. Selection output is generated ahead of time into the committed gsd-core/workflows/section-manifest.json (scripts/gen-section-manifest.cjs, reusing parseWorkflowSections unchanged — a second marker parser here would be the DEFECT.GENERATIVE-FIX divergence class) rather than derived from markers at run time, because markers are stripped at emit and the installed parent carries no gsd:section metadata. Source of truth: gsd-core/bin/lib/section-manifest.cjs (generated from src/section-manifest.cts). Test anchors: tests/section-manifest.test.cjs, tests/section-manifest.property.test.cjs, tests/gen-section-manifest.test.cjs.
Runtime Artifact Layout Module
Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed kinds per runtime (commands, agents, skills) with destination subpath, prefix, and stage adapter (with per-runtime converters in bin/install.js: convertClaudeCommandToClaudeSkill, …CodexSkill, …CopilotSkill, …AntigravitySkill). Owns the per-runtime nested skill-bundle decision (#69): a skillsKind flag in src/runtime-artifact-layout.cts drives whether a runtime receives the nested router layout (6 gsd-ns-* routers + concrete skills under <router>/skills/<name>/) or the flat skills/gsd-<stem>/ layout; the evidence/doc-link matrix is recorded in a comment above resolveRuntimeArtifactLayout. Phase 1 applies this seam to the Runtime Surface Module (surface.cjs:applySurface); as of #813, applySurface applies the same per-runtime skill-body path rewrites as installRuntimeArtifacts for skills kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default ~/.claude paths. Per ADR-1508 / #1511 the former getInstallExports/loadInstallExports relay (a GSD_TEST_MODE-guarded require('bin/install.js') by which surface.cjs reached computePathPrefix/applyRuntimeContentRewritesInPlace) was DELETED from this module; content rewriting now lives in the Runtime Artifact Conversion Module and surface.cjs:applySurface calls its rewriteStagedSkillBodies directly. The resolved scope is still carried on the Layout object so applySurface derives the same pathPrefix (global $HOME form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in bin/install.js so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). The .gsd-source marker (#1477) is a two-party provisioning contract that lets source resolution succeed on the Claude global skills layout, which ships gsd-core/{bin,contexts,references,templates,workflows} but no commands/gsd source tree for findInstallSourceRoot to walk up to: the writer is bin/install.js, which writes <configDir>/.gsd-source (content: the absolute path to its own commands/gsd, terminated by a newline) when runtime === 'claude' && isGlobal, guarded by fs.existsSync so a half-published package never writes a dangling marker; the reader is findInstallSourceRoot(configDir), which prefers the marker over its walk-up but falls through to the walk-up if the marker is absent, dangling, or empty/whitespace-only. #2871 Phase 2 widens the Module from placement to placement + trigger resolution: resolveTriggerSurface(runtime, scopes, { stems, routerStems?, childToRouters?, registry? }) -> TriggerSurface[] answers "what does a user type" rather than "where does a file land" — a new, pure function alongside resolveRuntimeArtifactLayout (untouched, still 7 callers), never a widened signature. Only commands and skills are trigger-bearing; agents/kimi-agents are excluded entirely — an agent is invoked through the Agent/Task tool's subagent_type, a separate dispatch interface point, never a /gsd-<name> a user types (ADR-2866 amendment below). Each TriggerSurface names its trigger, kind, scope, destPath (computed through the SAME namespacedByDir branch _copyStaged uses), registration ('direct' | 'via-router', the latter naming the owning router's routerTrigger for a nested-router runtime's concrete child skill — #69), and shadowedBy (the winning sibling entry, or null). The winner across scopes and kinds is decided by scope rank first (Install Scope Module's scopeRank, consumed not re-derived — global outranks local) then by the runtime's new runtime.triggerPrecedence descriptor axis (ordered kind names, highest priority first; default ['skills', 'commands'], required-with-default so a pre-#2871 capability.json keeps validating). shadowedBy ships unread this phase — Phase 4 (#2873) is its first consumer. See ADR-3660.
Runtime Artifact Conversion Module
Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (kind, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through bin/install.js for converter functions or GSD_TEST_MODE-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (convertClaudeCommandTo*Skill, converted command-file emitters, buildKimiAgentArtifacts) plus the minimal helper closure they need; do not leave helper dependencies in bin/install.js because that would preserve the same shallow seam under a new filename. Installer integration decision: bin/install.js imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import bin/install.js or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped convertRuntimeArtifact Interface until after relocation proves byte-for-byte behavior. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (getDirName→runtime-name-policy, processAttribution here); #1511 Phase 2 moved the content-rewrite engine here in full — _applyRuntimeRewrites (per-runtime switch, injected attribution), the staged-content walkers applyRuntimeContentRewritesInPlace/applyRuntimeContentRewritesForCommandsInPlace, computePathPrefix (private; _computePathPrefix for tests), and the deep public seam rewriteStagedSkillBodies/rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?}). bin/install.js binds these back (single owner, exports preserved); getCommitAttribution stays in bin/install.js (impure install-time config I/O) and is injected. The getInstallExports relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Exception: opencode and kilo path-prefix rewriting is a deliberate bin/install.js-owned pre-conversion step (applyOpencodeFamilyPathPrefix) per #784, not a violation of the single-owner rule. Source: gsd-core/bin/lib/runtime-artifact-conversion.cjs. Also exports resolveVersionFrom(libDir) — a lazy, defensive GSD-version resolver (installed-tree gsd-core/VERSION first, then the source/npm package.json three dirs up, both validated against the repo's shared semver-prefix shape, degrading to '' on failure) that replaced a module-load-time require('../../../package.json') which crashed on runtimes whose root carries no package.json (e.g. Codex) (#1383).
Runtime Artifact Install Plan Module
Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }. It iterates layout.kinds in order, calls each kind's stage(resolvedProfile), delegates commands to Runtime Artifact Conversion rewriteStagedCommandBodies, delegates skills and kimi-agents to rewriteStagedSkillBodies, leaves non-rewritten kinds unchanged, and projects copy items as { kind, sourceDir, destDir }. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into bin/install.js. Write-confinement (ADR-1239 Phase B / #1679): the exported pure assertDestWithinConfigHome(configDir, destSubpath) -> resolvedDest is the security gate — every kind's destDir is computed through it on both the install and uninstall plan paths, so a destSubpath that escapes configHome (../../etc, a NUL byte, etc.) is rejected at plan-build time with a clear error; surface.cjs:applySurface and bin/install.js:installOpencodeFamilySkills route their joins through the same helper, and _copyStaged carries a defense-in-depth containment check. This is security-load-bearing for the Phase C third-party-descriptor loader (which is where an untrusted destSubpath could arrive). Source: gsd-core/bin/lib/runtime-artifact-install-plan.cjs. See Runtime Artifact Layout Module and Runtime Artifact Conversion Module.
Install Fs Adapter Module
Narrow, enumerated fs seam for the installRuntimeArtifacts call tree (src/install-engine.cts) — lands ADR-58's never-shipped cleanup rollout step (registry → adapter → helpers → cleanup, #2874, epic #2866 Phase 5). installRuntimeArtifacts now returns the executed plan it ran ({ runtime, scope, kinds: [{kind, sourceDir, destDir, preserved}], cleanup: [{dir, ok}], postSteps }) instead of void, including on the combinedFamilyInstall (OpenCode/Kilo) early-return path — no path may return undefined after this phase. Failure is unchanged: stage/rewrite errors still throw rather than becoming an ok:false value, so a caller cannot read success-shaped data off a failure path. Delivery is an ambient single mutable adapter (current), swapped for the duration of one synchronous install via withInstallFs(deps.fs, fn) and always restored in a finally — a deps parameter threaded through every function on the call tree (install-profiles.cts, runtime-artifact-conversion.cts, commonjs-marker.cts, installer-migrations.cts's two reachable entry points) was rejected as a dozen+-site touch for no behavioral gain over the ambient swap, extending rather than replacing createRuntimeArtifactInstallPlan's existing deps bag precedent (Runtime Artifact Install Plan Module). An injected deps.fs is a PARTIAL adapter merged over real node:fs; any method it omits — except realpathSync, the one method documented to degrade gracefully — now THROWS immediately if actually called, naming the missing method, rather than silently resolving to the real filesystem (#2875 defect fix: the prior silent fall-through let a fake adapter missing e.g. rmSync perform real destructive IO unnoticed). Routes destination IO only, by design: every write/probe against the install destination (copies, removals, snapshot/restore of preserved skill dirs, the manifest read) is fake-able; locating this package's own source tree (findInstallSourceRoot/findAgentsSourceRoot's walk-up-from-__dirname, readGsdCommandNames) stays real and unrouted — a destination-fake's store starts empty and was never seeded with the repo's own paths, so routing that lookup would make every fake-adapter install throw instead of staging. The symlink-escape guard (hasExistingSymlinkBetween) and assertDestWithinConfigHome keep their REFUSAL DECISIONS outside this seam — only their existsSync/lstatSync/realpathSync probes route through it, so an injected fake can change what a probe observes but never flip the security decision itself. Writes remain byte-identical to pre-#2874 (AC4/AC5); existing void-ignoring callers (bin/install.js) are unaffected. Source: gsd-core/bin/lib/install-fs-adapter.cjs (generated from src/install-fs-adapter.cts). See Runtime Artifact Install Plan Module, ADR-58.
Real Home Guard Module
Refuses an in-process install whose destination would land in the developer's REAL home while a Node test runner is in scope (#3712, ADR-1239/#2088 territory). Interface: assertTestHomeSandboxed(operation, runtime, kinds, deps?) (throws), isTestHomeGuardRefusal(err), SANDBOX_MARKER. Exists because a runtime kind may declare a global home override resolved from os.homedir() rather than from the caller's configDir — codex's skills kind (home: ".agents") and, since #3738, antigravity's global skills AND agents kinds (home: ".gemini/config", the dir AGY scans for global discovery; configHome stays ~/.gemini/antigravity for settings/runtime files) are the live cases — so sandboxing configDir/targetDir does NOT contain it, and assertDestWithinConfigHome (Runtime Artifact Install Plan Module) structurally cannot see the class: that gate confines a destSubpath to whatever root it is handed, and here the root IS the escaped home. The observed failure was silent destruction — a test file that sandboxed only targetDir pruned all 71 gsd-* skills from a real ~/.agents/skills via _removeGsdEntries while the suite exited 0 and the manifest still reported a healthy install. Six writers resolve a kind home and then write or destroy under it and therefore carry a call: installRuntimeArtifacts, uninstallRuntimeArtifacts (Install Engine Module), applySurface (Surface Module), and migrateLegacyDevPreferencesToSkill — which CREATES rather than prunes, which is why it was missed on the first pass, and which runs from _runLegacyInstallMigrations BEFORE installRuntimeArtifacts' own assertion, so it guards the destination it already resolved rather than resolving a second time (generative-fix divergence) — plus installOpencodeFamilySkills and installAgentsKindStandalone, guarded against a future descriptor change rather than a present escape since no combined-family or agents kind declares a home today. Each of the four reachable writers carries an optional deps: { os?, env? } tail parameter — the guard's trigger condition is "HOME equals the passwd home", which cannot be simulated without pointing at the developer's real home — so tests/install-write-confinement.test.cjs drives the REAL entrypoint for each one and deleting any guard call site turns a row red. migrateLegacyDevPreferencesToSkill guards only when target.hasHomeOverride — that same resolution's own answer to "did the skills kind declare a home?", never inferred from installRoot !== targetDir, which is FALSE whenever the override resolves onto targetDir itself (a configDir of $HOME/.agents, exactly where codex's override points); both directions of that condition are pinned, so widening it to refuse ordinary confined migrations fails a row too. Gated on NODE_TEST_CONTEXT (set by node --test), never on GSD_TEST_MODE — several in-process test files, including the one that caused #3712, never set the latter — so ordinary installs outside a Node test context are untouched and codex still installs to $HOME/.agents normally. The predicate asks about the DESTINATION, not about HOME state, and about the path the write RESOLVES to rather than how it is spelled: a stale layout captured before sandboxHome() still names the real ~/.agents while HOME is sandboxed, so a HOME-state check would wave it through, and an aliased <sandbox>/.agents symlink or junction would otherwise redirect an allowed path into the real home. Canonicalization itself fails CLOSED: a component that cannot be resolved (EACCES/EPERM on a directory whose mode changed, ELOOP on a symlink cycle, EIO on a failing mount) is REFUSED rather than falling back to the lexical spelling — that fallback was a second, unnamed fail-open, and precisely the ALLOW an unresolvable alias needs, since the sandbox spelling then satisfies the nested-sandbox exemption (Codex review of #3725). Only ENOENT/ENOTDIR walk up, matching identify's own errno split rather than inventing a second one: both mean "no such path as named", which is the ordinary shape of a destination no install has created yet. A destination inside the real home is exempted only when all three hold — HOME differs from the passwd home by filesystem identity, the passwd home is not itself beneath that HOME (/Users, C:\Users), and the destination resolves beneath it. That exemption is not a softening: on Windows os.tmpdir() is under %USERPROFILE%, so EVERY test sandbox is a descendant of the real home and containment alone cannot separate the safe case from the dangerous one — without it the whole Windows matrix refuses. Every containment decision compares by filesystem identity (st_dev+st_ino), never pathname (the one pathname comparison in the module is sameDirectory's equality shortcut on the marker branch, described below): path.resolve resolves neither symlinks nor case and realpath returns a canonical pathname two routes to one directory can disagree on. Fails CLOSED, with one named exception: where no passwd entry is readable (some CI images) it falls back to a path-valued marker set by sandboxHome()/installSpawnEnv() in tests/helpers.cjs, which must identify, must equal the home in effect, AND must contain every resolved destination. All three are load-bearing: matching HOME attests that a caller sandboxed HOME and says nothing about where these destinations resolve, so on its own the marker waved through the very stale-layout shape the primary branch refuses; and sameDirectory's pathname-equality shortcut can answer yes for two identical UNidentifiable paths, which is not enough to place a destination against. It remains a deliberate weakening, since nothing on such a host can contradict a marker naming the real home. installSpawnEnv re-points the marker at an explicitly overridden HOME, so a spawn that supplies its own sandbox is not refused by a marker still naming the helper's default one. Refusals are STAMPED so bin/install.js can rethrow them without running _codexPreConfigRollback(), which deletes and recreates every snapshotted gsd-* directory in the resolved skills root — otherwise the guard's own refusal would provoke the mutation it exists to prevent. Known limits, stated rather than implied: a subordinate bind mount of the real ~/.agents into the sandbox is not closed (a bind mount is not a link, so realpath keeps the mount-point spelling; closing it needs non-portable mount-table introspection), and a cross-process TOCTOU swap between check and write is out of reach. Source: gsd-core/bin/lib/real-home-guard.cjs (generated from src/real-home-guard.cts). See Install Engine Module, Surface Module, Runtime Artifact Install Plan Module, Runtime Artifact Layout Module.
User Artifact Staging Module
Durable, on-disk staging for USER_OWNED_ARTIFACTS (Install Engine Module's preserveUserArtifacts/restoreUserArtifacts callers) across the preserve → wipe → restore window, closing #1874-F19: an in-memory-only Map held across a wipe is silently discarded on process death (Ctrl-C, OOM, a converter throw mid-copy), losing the user's file permanently (#2875, epic #2866 Phase 6, governed by ADR-3574). Interface: stageUserArtifacts(destDir, fileNames, stagingRoot) -> StagedUserArtifacts, restoreStagedUserArtifacts(destDir, staged), discardStagedUserArtifacts(staged), recoverOrphanedUserArtifacts(stagingRoot, configDir) -> RecoveryResult — four operations rather than two because call sites genuinely differ (one defers to a migration helper instead of restoring; another restores only on migration FAILURE). Synchronous only, every fs call routed through installFs() (Install Fs Adapter Module), which now GUARDS a partial injected adapter: any method the partial omits — except the one documented degrade-to-real-fs exception, realpathSync — throws immediately if actually called, instead of silently falling through to real fs (#2875 defect fix). Staging layout is fixed by convention — <configDir>/.gsd-staging/user-artifacts/<sha256(destDir)-16>/{record.json,files/<name>} — a sibling of every wipe target this phase's four call sites wipe, so staging survives all of them while resolving inside configDir; record.json is written AFTER every file copy lands, never before, so a half-written staging directory (crash mid-copy) has no record and is never mistaken for a complete one. All staged/restored/recovered names are FLAT (no path separator of either platform's flavor) — matching every real caller's actual usage and rejected the same way traversal/NUL-byte names already were. Durability alone is not the fix: a staged copy nothing ever reads back is bytes-safe but user-visibly lost — the #1879-F15 inert-fix failure mode — so recoverOrphanedUserArtifacts is wired at the START of bin/install.js's install() and uninstall(), before the ordinary preserve step, for every runtime; this is the only production entry point that makes recovery reachable rather than merely callable. Its "never throws" contract is enforced with a per-file try/catch (one bad name is reported via skipped and the batch continues) wrapped in a per-entry try/catch (one bad batch is reported and the next staging entry is still attempted) — an earlier version left mkdirSync/the symlink-safe copy/the final cleanup rmSync unguarded, so a single unrecoverable entry (e.g. a directory unexpectedly staged where a file was expected, or symlinkSync throwing EPERM for an unprivileged Windows user) threw out of the function entirely — before that entry was ever cleaned up — permanently bricking every future install/uninstall (#2875 defect fix). Carries NO policy (same discipline as the Install Fs Adapter Module): every path this module writes, and every path recovery reads OUT of an on-disk record before writing to it (attacker-influenceable the moment an install runs on a shared machine), is re-resolved through the SAME assertDestWithinConfigHome (Runtime Artifact Install Plan Module) every other write on this call tree uses, THEN through the SAME hasExistingSymlinkBetween (Install Engine Module) _copyStaged/migrateLegacyDevPreferencesToSkill apply to their own writes — never reimplemented, and required lazily (call-time, not module-load-time) specifically to avoid a real circular require with Install Engine Module, which imports this module statically. Lexical confinement (assertDestWithinConfigHome) alone cannot see a symlinked ANCESTOR directory between configDir and a recorded destDir; the hasExistingSymlinkBetween re-check closes that gap (#2875 defect fix). recoverOrphanedUserArtifacts takes configDir as a REQUIRED, EXPLICIT parameter — it is never derived from stagingRoot's own path shape, which would rest the confinement guarantee on a naming convention rather than an explicit caller-supplied value. Never overwrites something already present at the recovered destination, decided by lstatSync rather than existsSync — existsSync FOLLOWS symlinks and reports false for a DANGLING one, so it cannot see a dangling symlink an attacker planted at the destination to redirect the eventual copyFileSync/symlinkSync outside configDir; restoreStagedUserArtifacts applies the same lstatSync-based refusal before writing (#2875 defect fix — both were previously existsSync-based). Symlink-safe: staged files copy via Installer Migration Module's copyPreservingSymlink (itself newly routed through installFs() this phase, all five of its fs calls), which never dereferences a symlink — a managed path replaced by a link to (e.g.) ~/.ssh/id_rsa cannot have the referent's bytes copied into the staging tree or back out of it; a consumer that reads a staged copy's CONTENT back (rather than re-copying it) must separately check for a staged symlink before readFileSync, or it will follow the link and read the referent (Install Engine Module's _runLegacyInstallMigrations applies this guard). Staging failure is a HARD throw (not swallowed) so a caller cannot proceed to wipe the source directory having staged nothing — worse than no staging at all; recovery and restore/discard degrade instead (missing files, missing staging root, malformed records are all "nothing to do", never a crash). stagingRoot confinement against configDir, and the symlinked-staging-root refusal (hasExistingSymlinkBetween), are both call-site responsibilities (Install Engine Module's _resolveUserArtifactStagingRoot, mirrored locally in bin/install.js) — this module accepts no configDir parameter to stageUserArtifacts and cannot perform that outer check itself. Known limitation, documented rather than closed: concurrent installs targeting the SAME destDir are not safe against each other — the staging key is sha256(destDir), and both the stage-time entryDir-clear and the recovery-time end-of-batch cleanup unconditionally rmSync an entryDir they did not necessarily create, so two processes racing the same destDir can have one wipe the other's in-flight or just-committed batch; closing this fully needs either a cross-process lock (its own crash-safety design surface) or a guarantee installs never run concurrently against one configDir, neither of which this module can decide unilaterally. Explicitly out of scope: fsync durability (crash-safe against process death only, not power loss); routing the raw-fs uninstall wipe at Install Engine Module's _runLegacyUninstallCleanup (only the staging call itself routes through installFs() there — the surrounding wipe stays unrouted, matching Phase 5's deliberate exclusion of the uninstall tree). Source: gsd-core/bin/lib/user-artifact-staging.cjs (generated from src/user-artifact-staging.cts). See Install Engine Module, Install Fs Adapter Module, Runtime Artifact Install Plan Module, Installer Migration Module, ADR-3574.
Install Scope Module
Owns the two-value install-scope axis ('global' | 'local') as a typed value, replacing the bare isGlobal ? 'global' : 'local' string re-derived at 12 sites in bin/install.js plus several downstream re-derivations (#2870, ADR-2866). Interface: resolveScope({ id, runtime, explicitDir?, env?, home?, existsSync? }) -> { id, configHome, settingsFile, consentRequired, hostPrecedenceRank } — pure (no writes, never mutates input) and the returned value is frozen so a caller cannot corrupt a subsequent resolution. Owns the InstallScope type name: previously a private, non-exported TypeAlias inside Runtime Artifact Install Plan Module; that module now import types it from here instead of re-declaring it, so the codebase does not grow a fifth spelling of the axis alongside the layout module's 'local' | 'global', capability-lifecycle.cts's 'global' | 'project', and capability-consent.cts's single 'project' literal. configHome for global composes resolveConfigHomeFromDescriptor (Runtime Homes Module) unmodified rather than adding a scope parameter to it — that function is CRITICAL blast radius (60 dependents across 13 files); for local it joins the capability registry's per-runtime localConfigDir onto the real process cwd (the project you are standing in — not injectable via home, by design). explicitDir short-circuits both scopes identically to getGlobalConfigDir's existing override, and every returned configHome is normalized to forward slashes UNCONDITIONALLY (.replace(/\\/g,'/'), never gated on path.sep). settingsFile reads the registry's hostBehaviors.settingsFileByScope[id] and is null for the 18 of 19 registered runtimes that declare none — absence is a value, not an invented Claude-shaped default; the one caller that legitimately wants a Claude fallback (bin/install.js:550) still applies it itself. consentRequired is false for global (nothing is recorded — matches Capability Registry Overlay's rule that a GLOBAL-scope capability is trusted without a consent record) and true for local; it reports the requirement only; it does not perform or waive consent. hostPrecedenceRank (global outranks local) is carried as data only this phase — unread until Phase 2 (#2871) defines precedence semantics. Throws TypeError for an invalid id (wrong case, empty, missing, or any non-string value — never coerced), an unknown runtime, or a runtime whose configHome.kind === 'none' (vscode — non-installable, #2103) — all three share one instanceof TypeError catch shape with Runtime Artifact Layout Module's existing unknown-runtime contract. The local/project boundary is documented, not unified: this module's 'local' spelling — chosen because it is the CLI's own vocabulary (--local) and what the layout module and manifest already use — is deliberately NOT reconciled with Capability Consent Store's ConsentRecord.scope: 'project' or Capability Lifecycle's 'global' | 'project' operations. ConsentRecord.scope is a value persisted on disk in user-owned consent records outside any repository; renaming that literal to match would silently invalidate every existing project-scoped consent record on a user's machine the next time it is read back — a far worse defect than the vocabulary split. The mapping instead lives here as a fact: install scope 'local' ⇄ consent scope 'project'; install scope 'global' ⇄ no consent record at all. Source: gsd-core/bin/lib/install-scope.cjs (generated from src/install-scope.cts). See Runtime Homes Module, Runtime Artifact Layout Module, Runtime Artifact Install Plan Module, Capability Consent Store, Capability Lifecycle.
Installed Surface Resolver Module
Read-only Module answering "which GSD surfaces are installed, at which scopes, for which runtimes — and is one shadowing another?" (#2872, ADR-2866 Phase 3). Interface: resolveInstalledSurfaces(runtime?, { home?, cwd?, env?, existsSync?, registry?, readManifest? }) -> InstalledRuntimeSurface[], each carrying both scope records ({ scope, configHome, installed, manifestVersion, declaredRuntime, declaredScope, declaredScopeMatchesProbe, declaredRuntimeMatchesProbe, stems }) plus a triggers: TriggerSurface[] resolved in ONE resolveTriggerSurface call over only the scopes actually installed — which is what makes shadowedBy describe this machine rather than a hypothetical. The first code path in the repo that reads both install scopes at once. Mutates nothing; builds fresh objects per call; caches nothing. It composes rather than re-derives: resolveScope (Install Scope Module) supplies each scope's configHome, readInstallManifest (Installer Migration Module) supplies presence, and resolveTriggerSurface + the exported isNamespacedByDir/composeCommandFilename (Runtime Artifact Layout Module) supply the trigger surface — the stem derivation is the inverse of that filename composition and consumes those exports rather than becoming a fourth copy of the rule (fast-check round-trip property guards the bijection). Installed-ness is decided by manifest PRESENCE, never by the schema-2 runtime/scope fields, which is what keeps a pre-#2872 (schema-1) install fully functional with no reinstall; the recorded fields are corroboration, surfaced as declaredScopeMatchesProbe / declaredRuntimeMatchesProbe so a manifest that disagrees with the directory it was found in (an --config-dir install, a copied config tree) is reported, never silently corrected. A runtime whose resolveScope throws (unknown, or configHome.kind === 'none' — vscode) propagates the TypeError when asked for explicitly but is skipped in the all-runtimes sweep, so one non-installable runtime cannot kill the sweep. Two scopes resolving to one configHome are deduplicated before the trigger call, so a single physical install is never reported as shadowing itself. The local-scope manifest read is lstat-guarded (#2873): it refuses to follow a symlinked config dir or a symlinked manifest file, degrading that scope to installed: false — the same degraded verdict the pre-existing EACCES path already produces — rather than resolving a manifest outside the project the caller asked about; the global scope carries no such guard (it resolves against the real machine home, never an arbitrary cloned repository). Phase 4 (#2873, Install Shadow Report Module) is its first consumer, projecting shadowedBy into the install-time report and the /gsd-health W028 diagnostic. Source: gsd-core/bin/lib/installed-surface-resolver.cjs (generated from src/installed-surface-resolver.cts). See Install Scope Module, Runtime Artifact Layout Module, Installer Migration Module, Agent Install Check Module, Install Shadow Report Module.
Install Shadow Report Module
Read-only projection Module answering "is a cross-scope trigger collision hiding a spec tree from the user right now, and if so, which scope wins?" (#2873, epic #2866 Phase 4, resolving #2218). Composes Installed Surface Resolver Module's resolveInstalledSurfaces rather than re-deriving install state — the design doc's "Rejected" list explicitly refuses to widen that module with a second export, keeping rendering/sanitization a separate concern with a separate consumer set (installer + /gsd-health). Interface: buildShadowReport(runtime, { home?, cwd?, env?, existsSync?, registry?, readManifest? }) -> ShadowReport (a typed IR, never rendered text — tests assert the IR, per CONTRIBUTING's ban on raw-text-matching test outputs) plus renderShadowReport(report) -> string[], the sole renderer consumed identically by the installer and the W028 health rule so install-time output and /gsd-health output can never drift. Per-scope truth filter: resolveTriggerSurface (Runtime Artifact Layout Module) synthesizes a candidate trigger for every stem at every installed scope's trigger-bearing kind, regardless of whether that specific scope's own manifest shipped that stem — left unfiltered this would over-report triggers as shadowed when no artifact exists at one side. This Module filters shadowedBy groups down to triggers whose underlying stem is present in BOTH scopes' own real stems list before reporting. kindsDiffer distinguishes vanish from override: for claude (skills@global vs commands@local) the kinds differ, so the losing side's entire spec tree becomes unreachable through the trigger (#2218's exact failure); for the 12 both-scopes-skills runtimes the kinds are the same on both sides, so the loser is merely overridden, not vanished — renderShadowReport's wording is gated on this flag so it never claims a same-kind local tree "disappears". Report, don't correct: a declared runtime/scope that disagrees with the probed one is surfaced via mismatches, never silently absorbed or substituted (Postel's Law, liberal-but-visible). Sanitized at the render seam: declaredRuntime is attacker-influenceable (sourced from a manifest that may live inside a merely-cloned repository) and is length-bounded (64) but deliberately not charset-gated at the reader (declaredRuntimeMatchesProbe needs the raw value); sanitizeForRender strips ANSI escapes, C0/C1 controls, and Unicode bidi overrides/isolates before render, and never truncates a second time. Degrades to reason: RESOLVER_UNAVAILABLE (renders []) for a runtime whose configHome.kind === 'none' (vscode) rather than propagating the resolver's TypeError — a shadow report must never crash an install or /gsd-health. Consumers: the installer (prints the report after writeManifest, exit code unchanged — a shadowed install is a warning per ADR-2866 Consequences, never a failure) and health-diagnostic-rules/install-surface-shadowing.cts (W028, WARNING severity, ADVISE remedy with risk: NONE — never auto-fixable, there is no single correct scope to remove). Source of truth: gsd-core/bin/lib/install-shadow-report.cjs (generated from src/install-shadow-report.cts). See Installed Surface Resolver Module, Runtime Artifact Layout Module, Runtime Artifact Conversion Module (spec-root reachability, the sibling deliverable in the same phase).
Command Roster Module
Tiny read-only helper Module owning discovery of canonical commands/gsd/*.md command stems for artifact conversion and runtime projection. It is a sibling dependency of Runtime Artifact Conversion Module, not part of conversion itself: conversion consumes a roster to safely rewrite gsd: / /gsd- references, while roster discovery owns filesystem/catalog knowledge. First slice: extract existing readGsdCommandNames behavior behind this Module instead of moving it into Runtime Artifact Conversion Module or keeping it as installer-owned state.
Runtime Install Policy Module
Projects a pure, typed install plan for a given runtime by composing artifact placements (Runtime Artifact Layout Module), command text (Shell Command Projection Module), and per-runtime config intentions — with no filesystem IO or format-specific serialization. Runtime-specific adapters consume the plan and execute concrete file mutations and config rendering. See ADR-58.
Capability [Planned]
A bundle delivering one optional GSD feature, toggled as a unit at install or after install. Owns its skills, agents, hooks, federated config-key schema (keys + defaults + validation), and loop extension-point registrations, plus a requires list of other Capabilities, plus an optional activationKey (a dotted config key, e.g. graphify.enabled) naming the config toggle that gates the whole capability — consumed by the Capability State Resolver's per-capability active (absent → no config gate; see Capability State Resolver tri-state deepening). Declared co-located in the Capability's own folder and compiled into a generated central Capability Registry at build time. The five-step loop (Discuss → Plan → Execute → Verify → Ship) and shared-infrastructure skills (phase, config, help, update, surface, progress) are the privileged host, not Capabilities, in v1 — but host extension points are data so a loop step can become a Capability under a future uniform kernel. Supersedes the implicit feature-scattering across clusters, install-profiles, and config-schema. Generalizes the Skill Surface Budget Module and Runtime Install Policy Module.
Loop Host Contract
Generated description of what the five-step loop (Discuss → Plan → Execute → Verify → Ship) exposes as extension points: per-step loop points, agent roles, and core artifacts. Sourced from structured <!-- gsd:loop-host ... --> HTML-comment markers embedded near the top of each of the five step workflow files (discuss-phase.md, plan-phase.md, execute-phase.md, verify-work.md, ship.md). Generated by scripts/gen-loop-host-contract.cjs → gsd-core/bin/lib/loop-host-contract.cjs (ADR-894 §3 phase 3a-impl-2). Covers exactly the 12 canonical points (discuss:pre/post, plan:pre/post, execute:pre/wave:pre/wave:post/post, verify:pre/post, ship:pre/post). The generator enforces a drift guard: every declared non-orchestrator agent role must correspond to an actual agent reference in the workflow file. Consumed by gen-capability-registry.cjs (replaces the former inline LOOP_HOST_CONTRACT constant). Run node scripts/gen-loop-host-contract.cjs --write after editing a workflow step marker.
Gate Predicate Evaluator Module
Pure, deps-injected evaluator for capability gate check.predicate blocks (#2008, ADR-2008). Prior to #2008 the registry validator accepted check.predicate (one of exactly-one-of query/predicate/agentVerdict) and the loop-resolver rendered it, but nothing EVALUATED a declared predicate — only check.query was enforced (dispatched via gsd_run check <query>), and built-in gates like security worked only via hard-coded capId prose branches in ship.md/execute-phase.md/verify-work.md. This module is the generic evaluation path: evaluatePredicate(predicate, context, deps) → { block, message, details? } dispatches by predicate.kind through a KIND_TABLE. Built-in kinds: command-exit-zero runs a declared command in a bounded sh -c subprocess (production binding: shell-command-projection.execTool) at the project root, inheriting env; exit 0 ⇒ pass, non-zero ⇒ block, timeout (SIGTERM) ⇒ block; artifact-frontmatter-equals resolves a phase or project-level Markdown artifact through injected dependencies and compares a frontmatter field by scalar string value. Command predicates interpolate ${PHASE_NUMBER}/${PHASE_DIR}/${PHASE_REQ_IDS} from gate context. A THROWN error (malformed predicate, non-positive/non-finite timeout, command >4096 chars, missing dependencies, unknown kind) maps at the CLI seam to a non-zero check-command exit, which the workflow's two-step gate contract treats as a step-1 command failure routed per onError — so an evaluator bug is never conflated with a legitimate block decision. Leaf pure module (no fs/child_process/config — subprocess and artifact seams injected). CLI entry: gsd_run check predicate --predicate '<json>' [--phase-dir …] [--phase-number …] [--phase-req-ids …] --raw, wired into check-command-router.cts:cmdCheckPredicate; the four generic workflow gate-dispatch sites (execute:wave:post, execute:post, plan:post, and ship:pre — the last wired by #3559, which had resolved gates generically but enforced only two hardcoded capIds) branch on check shape (query vs predicate). Source of truth: src/gate-predicate-evaluator.cts. Docs: docs/reference/gate-predicates.md, docs/how-to/command-exit-zero-gate.md.
Capability Registry
Generated central manifest projecting all co-located Capability declarations into one validated artifact for runtime resolution and for the install, surface, config, and loop-extension adapters. Mirrors the research-profiles / package-identity generation pattern (co-located source → generated central file). Generated by scripts/gen-capability-registry.cjs → gsd-core/bin/lib/capability-registry.cjs (ADR-894 §5 phase 3a-impl). Role-partitioned indexes: bySkill, byAgent, byLoopPoint (hook ordering materialized), configKeys (ownership map: key→capId), configSchema (full per-key schema: key→{ owner, type, default, description }), runtimes, requiresClosure(id). Each feature capability's entry in capabilities now includes the optional activationKey field (the dotted config key that gates the whole capability, e.g. "graphify.enabled"; absent means no config gate). ADR-857 phase 3b adds configSchema with validated type/default/description per key, sourced from each capability's .config slice. ADR-857 phase 4a adds two derived views: capabilityClusters ({ <capId>: [<skill stems>] } — each cap's skills array, sorted, derived from the capability's skills declaration; consistency-gated against the hand-authored CLUSTERS) and profileMembership ({ <capId>: { tier, profiles: [...] } } — the tier-derived index: suffix of PROFILE_RANK starting at the capability's tier). Both views cover the same capability set: only capabilities that own skills (non-empty skills array). The generator enforces a HARD gate (throws) if a capId matching a CLUSTERS key has a mismatched skill set, and emits SOFT ⚠ pending-reconciliation warnings to stderr (never to the file) for skills not yet in the hand-authored profile at the capability's tier. install and surface are UNTOUCHED (still read hand-authored constants; derived views are emitted and tested but unconsumed until cutover). Validated against the Loop Host Contract (12 points; generated by gen-loop-host-contract.cjs from workflow markers, phase 3a-impl-2). Run node scripts/gen-capability-registry.cjs --write after editing any capabilities/<id>/capability.json.
Federated Config
ADR-857 phase 3b seam that merges capability-declared config slices into the loadConfig return value. Implemented in src/federated-config.cts → gsd-core/bin/lib/federated-config.cjs. Exports mergeFederatedConfig({ configSchema, isCentralKey, userConfig }) → { values, validKeys, warnings }. Rules: central-schema keys are skipped with a pending-migration warning; malformed slices are skipped with a warning (never throws); valid federated keys (absent from the central schema) resolve to the user-supplied value (if type-matches) or the slice default. Object writes are guarded against prototype pollution with inline literal __proto__/constructor/prototype key checks. ADR-857 phase 6 made the channel live for migrated Capability keys: config-schema.cjs exposes isCentralConfigKey() for central ownership and isValidConfigKey() accepts central + runtime + dynamic + Capability-owned registry keys. loadConfig exposes _setFederatedRegistryForTests/_resetFederatedRegistryForTests seams for injecting a synthetic registry in tests.
Capability Registry Overlay
Runtime seam (gsd-core/bin/lib/capability-loader.cjs, ADR-1244 D2) that composes the frozen first-party Capability Registry (capability-registry.cjs) with a validated installed overlay of third-party capability manifests discovered at load time. Install roots are global ($GSD_HOME/.gsd/capabilities/<id>/capability.json, where GSD_HOME defaults to ~) and project (<projectRoot>/.gsd/capabilities/<id>/capability.json). Primary interface: loadRegistry({ includeInstalled }) → registry — when includeInstalled is true the overlay is merged via the canonical buildRegistry so all derived views (bySkill, byAgent, byLoopPoint, configKeys) cover first-party and overlay entries identically. First-party always wins: any overlay entry whose id, owned skill/agent stem, or federated config key collides with first-party, or whose id uses a reserved gsd-/gsd-core-/anthropic- prefix, is rejected at load time. Load-time re-gate: an overlay failing schema validation or whose engines.gsd semver range does not satisfy the running GSD version is skipped with a warning and never crashes the load loop. Per-hook-kind policy: a skipped capability that declared a gate-kind hook now fails OPEN (#2009) — the loop resolver injects no gate and instead emits a loud warning naming the load-failure reason and the exact gsd capability remove <id> remediation, and the loop proceeds (the loader still records _overlay.blockedGates; only the consequence changed from block to warn); skipped step or contribution capabilities skip open. A capability dir whose co-located ledger entry carries an in-flight _pending intent (a crashed/uncommitted install or upgrade, ADR-1244 Phase 4) is skipped OPEN (never activated until reconciliation commits or rolls it back). #1459 user-owned consent gate: a PROJECT-scope overlay is activated (declarative surfaces AND command dispatch) ONLY when the user-owned Capability Consent Store holds a record for (realpath(projectRoot), id) whose stored contentHash equals the bundle content hash the loader RECOMPUTES at load (bundleContentHash(capDir) over the whole on-disk bundle) — NOT the repo-plantable ledger integrity nor the executable-only disclosure signature — otherwise the cap is DISCOVERED-BUT-INACTIVE (a warning carrying kind:'unconsented', no surfaces, empty commandRoots), so a forged/cloned in-repo project ledger or any post-consent tamper no longer activates anything; GLOBAL scope (under the user's own home) is trusted without a record, and the global-vs-project root dedup/escalation is realpath-keyed so a symlinked GSD_HOME aliasing the project root cannot bypass the gate (finding 1). The consent lookup is wrapped to fail CLOSED (inactive); both the per-scope ledger AND the capability.json manifest are read via the shared bounded readSmallRegularFile (a repo-planted FIFO/oversized ledger or manifest can no longer hang or OOM the loader — finding 2). The loader reuses the ledger's shared isValidLedgerEntry for committed-entry parity. Consumers wired to the overlay-aware registry: config-loader.cjs, config-schema.cjs, capability-state.cjs, loop-resolver.cjs.
Community Capability Registry
Human-facing discoverability catalog (docs/registries/capability-registry.md, generated from docs/registries/capabilities.json; issue #2182) listing third-party Feature Capabilities registered by a docs PR so a solo developer can find one before installing it. Distinct from Capability Registry (the generated runtime manifest compiled from first-party capability.json declarations, ADR-894) and Capability Registry Overlay (the runtime seam that merges an installed third-party manifest into that generated registry at load time, ADR-1244 D2): this registry is a static document rendered by scripts/gen-registry.cjs, not a runtime data structure or loader. Each entry enumerates the capability's Loop Extension Points and hook kinds so a reader can judge blast radius before running gsd capability install, and declares its engines.gsd range. Inclusion is an explicit non-endorsement — a maintainer merged a link, nothing more — per docs/registries/README.md.
EoS Registry
Human-facing discoverability catalog (docs/registries/eos-registry.md, generated from docs/registries/eos.json; issue #2182) listing third-party Embeddable Orchestration System (EoS) host integrations — projects that embed GSD as an orchestration engine behind the ADR-1239 six-interface-point Host-Integration Interface. Entries are registered by the same docs-PR process, schema conventions, and non-endorsement stance as the Community Capability Registry, but enumerate the six interface points, the eight negotiated axes plus an optional ninth (effortSurface), and protocolVersion in place of Loop Extension Points and hook kinds. It has no generated-manifest or Capability Registry Overlay counterpart: an ADR-1239 host integration runs inside the third-party host, not inside GSD's own capability loader, so there is nothing for a runtime registry to merge. See docs/registries/README.md for the full entry schema. One of three third-party discoverability catalogs alongside the Community Capability Registry and the Reviewer Lane Registry.
Reviewer Lane Registry
Human-facing discoverability catalog (docs/registries/reviewer-registry.md, generated from docs/registries/reviewers.json; issue #2904) listing third-party role: "reviewer" capabilities (ADR-2782) — reviewer lanes that add an external review lane to /gsd-review, installed with gsd capability install <spec>. A lane registers on zero Loop Extension Points and owns no artifacts, which is why it cannot be listed on the Community Capability Registry: the Capability entry schema's loopExtensionPoints/hookKinds are unsatisfiable by construction for a lane. Entries are registered by the same docs-PR process, schema conventions, and non-endorsement stance as the other two registries, but enumerate slug, flags, transport, evidenceClass, and reviewsSection in place of Loop Extension Points and hook kinds. See docs/registries/README.md for the full entry schema and disambiguation against the Community Capability Registry and EoS Registry.
Capability Validator
Shared conformance validator (gsd-core/bin/lib/capability-validator.cjs, ADR-1244 D2) extracted from scripts/gen-capability-registry.cjs so the build-time generator and the runtime overlay loader share one validator implementation. Exports the same validateCapability(manifest) surface consumed by both the generator (build-time) and capability-loader.cjs (runtime). Generative-parity is CI-guarded: a drift between the generator's validation logic and the extracted module is a hard failure. Callers that previously inlined validation against the generator's internal helpers are migrated to import this module directly. Source of truth: gsd-core/bin/lib/capability-validator.cjs.
Capability Source Resolver
ADR-1244 D3 fetch-and-stage seam (gsd-core/bin/lib/capability-source.cjs). Primary interface: resolveCapabilitySource(spec, opts) → { id, version, stagedDir, integrity, source }. Parses specs via parseSpec and dispatches to one adapter per source kind: local (fs copy from a ./-prefixed path), git (clone --depth 1 + checkout via execGit; https/ssh/git transports only — ext:: and file:// are rejected), npm (pack via execNpm --ignore-scripts + tar extract — NEVER npm install, no lifecycle scripts; shell-metacharacter spec rejection for Windows shell safety), tarball (HTTPS download + sha512 integrity verify BEFORE extraction + tar extract), and registry (explicit stub — no first-party endpoint yet). Security contract: install never executes capability code (copy/extract only); integrity is verified before staging; tar-slip member paths and symlinks are rejected. Staging is atomic: a per-pid/timestamp scratch directory under .staging/ is renamed into $GSD_HOME/.gsd/capabilities/<id>/ on success and removed on failure. The Phase 1/2 validator suite runs on the fetched manifest before finalizing; engines.gsd is pre-checked. #3514 (epic #1900 F21): realHttpsGet gates every fetch PRE-transport — loopback/link-local/metadata/unspecified hosts (127/8, 169.254/16 incl. cloud metadata, 0/8, ::1, fe80::/10, ::, v4-mapped spellings) and localhost-form names are denied with a named error, non-https URLs fail with a named https-only reason while parseSpec still CLASSIFIES http:// tarball specs (no parse-time hard reject — internal-mirror flows; the transport never fetches plaintext), and RFC1918 ranges are deliberately allowed (internal mirrors; the denylist is not an allowlist; host-LITERAL check only — DNS rebinding out of scope). Test seams: _setCapabilitySourceHttpGet (whole fetch) and _setHttpsGetImpl (low-level transport, exercises the gate).
Capability Ledger
ADR-1244 D4 per-runtime install manifest (gsd-core/bin/lib/capability-ledger.cjs). Leaf module (only node:fs/node:path plus shell-command-projection's platformWriteSync). Records { id, version, source, integrity, files[], sharedEdits[{file,marker}] } per installed capability in .gsd-capabilities.json at the runtime config dir root. Exports: readLedger (structural-validated, never throws), writeLedger (atomic via platformWriteSync), recordInstall (idempotent, prototype-pollution-guarded), removeEntry, and reconcile (reports orphans whose files[] are missing on disk; hardened against non-string/.. members; never mutates). Serves as the atomic commit point for Phase-4 upgrade/remove and the reconciliation basis for detecting stale entries after out-of-band deletions.
Capability Consent Store
Issue #1459 user-owned consent seam (gsd-core/bin/lib/capability-consent.cjs). Leaf module (node:fs/node:path/node:os/node:crypto + the ledger's shared bounded readSmallRegularFile/readSmallRegularFileBuffer + the shared capability-lock primitive). Stores { version:"1", records: { "<JSON disk key {r:realpath(projectRoot),i:id}>": { projectRoot, id, scope:'project', integrity, disclosureSignature, contentHash, consentedAt } } } at ${GSD_HOME||homedir()}/.gsd/consent.json — a USER-OWNED file OUTSIDE any repository. Exports: consentStorePath(gsdHome?), readConsentStore(gsdHome?) (bounded via readSmallRegularFile + 8 MiB cap, NON-THROWING — missing/corrupt/oversized/FIFO/wrong-shape → empty {records:{}}; caps records at MAX_RECORDS=4096), bundleContentHash(capDir) (THE security binding — a sha512-<base64> over a DETERMINISTIC, INJECTIVE, LOSSLESS serialization of every regular file AND directory under the bundle, with two NARROW exclusions from the DIGEST ONLY (#3631, amended after two orthogonal reviews found the original wholesale directory-skip unsafe): a __pycache__/.pytest_cache DIRECTORY's own marker is suppressed but the directory is ALWAYS recursed into (so no unbounded unhashed region — H1), and a .pyc/.pyo FILE is excluded only when its immediate parent basename is exactly __pycache__ (a .pyc elsewhere, e.g. bundle root or scripts/, stays hashed — H2); .DS_Store is deliberately NOT excluded (stays hashed like any other file — an excluded filename is a permanently unhashed name a declared hook could still point at, and it is unrelated to #3631's Python-bytecode symptom); excluded entries still count toward the walk's size/count caps, and the filter runs AFTER the symlink/non-regular rejection so it can never be used to smuggle one past; manifest-declared hook script paths naming a __pycache__/.pytest_cache segment or a .pyc/.pyo basename are rejected by isSafeHookScriptPath (capability-lifecycle.cts/capability-validator.cjs), but that guard is NOT containment — it only inspects the declared script path string, so a hashed hooks/run.js doing require('../__pycache__/mod.pyc') reaches the excluded region in one hop via Node's default .js handler, after which that file is free to change post-consent with the digest unmoved; ACCEPTED RESIDUAL RISK: CPython's default timestamp-based .pyc invalidation checks only the source's mtime+size (both attacker-forgeable by whoever can already write the bundle), so a forged __pycache__/*.pyc matching an unmodified source executes without moving the digest — bounded only by requiring post-consent write access and by everything outside __pycache__/*.pyc staying hashed (the declared-surface guard is NOT a bound, per the indirection above); .pytest_cache CONTENTS still change the digest (only its directory marker is suppressed); node_modules/dist/build are deliberately NOT excluded because their contents are executed at runtime): length-FRAMED entry COUNT + per-entry TYPE tag + uint32 path-byte-len + RAW path bytes from a {encoding:'buffer'} dir walk [finding 4] + for files uint64 content-byte-len + RAW content bytes via readSmallRegularFileBuffer [finding 1b], plus typed DIR markers binding empty directories [finding 2]; symlinks/non-regular rejected; size+count bounded), hasProjectConsent({gsdHome,projectRoot,id,contentHash}) (true iff a record for ${realpath(projectRoot)}\x00<id> exists AND its stored contentHash equals the supplied recomputed hash — the binding is contentHash, NOT integrity and NOT disclosureSignature (those remain on the record purely for the human disclosure + re-consent-on-executable-change UX); unsafe ids → false; prototype-pollution-safe NUL-joined keys + Object.prototype.hasOwnProperty), recordProjectConsent({gsdHome,projectRoot,id,integrity,disclosureSignature,contentHash}) (LOCKED, atomic+durable write — tmp wx/fsync/rename/dir-fsync mirroring writeLedger; enforces the record cap at write time) and revokeProjectConsent({gsdHome,projectRoot,id}) (LOCKED atomic delete, no-op if absent) — BOTH THROW rather than perform an UNLOCKED read-modify-write when the consent-store lock cannot be acquired (finding 3; the lifecycle treats a consent-write failure as non-fatal, and the trust revoke CLI catches the throw and emits a clean error). This is the authoritative consent signal the loader recomputes (bundleContentHash(capDir)) and checks at load before activating a PROJECT-scope third-party overlay (declarative surfaces AND command dispatch): a forged/cloned in-repo project ledger, OR any post-consent tamper (swapped declarative manifest, edited hook script, empty-integrity local install — all change the recomputed hash), leaves the cap DISCOVERED-BUT-INACTIVE until the user consents on THIS machine to the EXACT bundle (the lifecycle records the consent on a consented project install/upgrade and revokes it on remove; install/lookup/revoke share one canonical consentProjectRoot root key). GLOBAL-scope overlays (under the user's own home) need no record; and when GSD_HOME resolves (via realpath, defeating symlink aliasing — finding 1) to a genuine project root the in-repo bundle still requires a record. The consent lock is the SHARED hardened primitive (below), so it never stale-steals a slow-but-live writer (finding 4). See docs/explanation/capability-trust-model.md "project-scope trust boundary".
Capability Lock
Issue #1459 finding 4 shared cross-process lock primitive (gsd-core/bin/lib/capability-lock.cjs). Leaf module (node:fs/node:path/node:os/node:crypto + the ledger's bounded readSmallRegularFile + shell-command-projection's execTool for the rare start-time shell-out). THE single hardened lockfile protocol shared by BOTH capability-lifecycle (the .gsd/capabilities/.lock mutation lock) and capability-consent (the consent-store .consent.lock) — extracted so the two locks cannot diverge (mirrors the shared-validator / shared bounded-reader lessons). Exports: acquireLock(lockPath, opts?) (O_EXCL create with a JSON {token,pid,hostname,startTime,ts} body; steal protocol binds age to the body's own ts, never stale-steals a VERIFIED-LIVE same-host holder — pid alive AND recorded start-time matches the pid's current start-time, defeating pid-reuse without ever stealing a live holder — and reclaims only a dead/unverifiable holder via the dead-pid fast path or the hard LOCK_DEADMAN_MS deadman; opts.maxAttempts raises the bounded retry budget and opts.waitForFresh makes a contended fresh/live holder be WAITED FOR rather than failed-fast so genuinely-racing consent writers serialize), releaseLock(handle) (token + inode owner-safe — never deletes a successor's lock), getProcessStartTime, and the _setLockProbes/_resetLockProbes test seams. Carries the #1462 lifecycle-lock invariants (process-start-time liveness, TOCTOU-safe pre-rename identity recheck, bounded iterative loop).
Capability Trust Gate
ADR-1244 Phase 4 (D5) PURE policy module (gsd-core/bin/lib/capability-trust.cjs). Computes what a capability would do and whether policy permits it; performs no mutation and no I/O beyond existence-checking declared artifacts. Exports: discloseExecutableSurfaces(manifest, stagedDir?, resolveHost?) (enumerates the four executable surfaces — hooks, command modules, mcpServers, and reviewer lanes (ADR-2782 D5) — plus a fifth, non-executable class, instruction surfaces (skills stems only, ADR-2363 D5, #3248), returned as instructionSurfaces; flags hasExecutable from the four executable classes only — instruction surfaces deliberately do NOT contribute to it; a reviewer lane is the one class that receives data, so it discloses its binary + full args (spawn) or destination host + hostConfigKey (openai-http) together with the egress payload classes); evaluateInstallTrust(args) (composes source policy + reserved-namespace + engines gate + disclosure into { allowed, requiresConsent, disclosure, engines, blockReasons }); evaluateSourceAllowed(parsed, strictKnownRegistries) enforcing capabilities.strict_known_registries (unset/null → permissive-with-consent; [] → block all external; non-empty → host-based allowlist, never substring); checkEngines(manifest, hostVersion) (engines.gsd hard gate via semverSatisfies + compatVersions graceful-downgrade picking the newest working version); executableSetChanged(old, new) (auto-update re-consent trigger); checkReservedNamespace (gsd-/gsd-core-/anthropic-); collectInstructionSurfaces(manifest) (the instruction-surface collector — skills stems only, independently testable, same total/safeCollect contract as the four executable collectors; ADR-2363 D3 classifies agents as an instruction surface too, but a third-party capability's declared agents[] are never staged into the agent's instruction context — stageAgentsForRuntimeWithConverter (src/install-profiles.cts) has no registry-aware third-party path the way readInstalledCapabilitySkill gives skills — so disclosing them would name a surface that does not exist; agents stay unimplemented pending a maintainer decision, and are NOT thereby safe or inert, only undisclosed); summarizeInstructionSurfaces(disclosure) (renders the instruction-surface section of the consent summary; called from BOTH branches of summarizeDisclosure because a skill-only capability has hasExecutable === false and takes the early return, so a section appended only at the end would never render for exactly the capabilities that need it). The MCP disclosure also captures each server's env (string→string, filtered) and cwd (#1459) — disclosureSignature folds them in as STABLE SORTED JSON so any env/cwd add/change forces re-consent while a key reorder does not; signatureForManifest(manifest, stagedDir?) is the single source of truth for that signature (consumed by the loader's consent check and the lifecycle's consent binding). #3514 (epic #1900 F21c): evaluateInstallTrust accepts optional integrityPin ('sha512' = a verified --integrity pin | 'git-commit' = a git #sha:<7-40-hex-commit> ref, hex-validated so a mutable #sha:main is NOT a pin | 'none') and sets a PROMPT-ONLY Disclosure.integrityStatus ('pinned' | 'commit-pinned' | 'unverified') rendered by summarizeDisclosure as a NO PINNED HASH — staged unverified line when no pin was supplied — deliberately EXCLUDED from disclosureSignature (consent's content binding is bundleContentHash, #1459; a rendered line must never fire a spurious re-consent), mirroring the ADR-2363 D4 instruction-surface exclusion one field over. #1459 finding 5: each MCP surface also carries rawConfig — the FULL declared server config the writer persists ({...config}), prototype-pollution-cleaned — folded into the signature as STABLE SORTED JSON so a change to ANY persisted field (not just the explicit whitelist — a future envFile/workingDir/launch option) forces re-consent, while a pure key reorder does not; the human summary stays readable via the key fields only. #3515 (epic #1900 F20): the MCP disclosure section additionally renders an INTENTIONALLY-NOT-CONFINED notice for every SPAWNED (stdio) server — command/args/cwd are written verbatim and may point anywhere on the machine, unlike the confined hook path (capability-lifecycle's MCP write documents the same asymmetry in code); remote-only (http/sse) servers render no notice (nothing local is spawned), and the line introduces NO new Disclosure field so disclosureSignature is untouched. Instruction surfaces are deliberately EXCLUDED from disclosureSignature (ADR-2363 D4, #3248) — a manifest gaining, losing, or changing skills produces a byte-identical signature and disturbs no stored consent record; any future binding arrives as a versioned v2, never an in-place re-encoding of v1. The barrier is consent + integrity + reversibility, NOT a sandbox — see docs/explanation/capability-trust-model.md.
Capability Lifecycle
ADR-1244 Phase 4 (D5+D6) orchestration seam (gsd-core/bin/lib/capability-lifecycle.cjs) composing the source resolver, ledger, and trust gate into the mutating operations. Exports: installCapability (pre-fetch source gate → resolve copy-only with promote:false → trust verdict → promote + apply marker-stamped shared edits → ledger commit; nothing written on block/abort), upgradeCapability (atomic stage-then-swap: old set aside, new swapped in, shared edits re-derived, ledger committed, backup dropped; re-prompts when the executable set changed), removeCapability (strip only _gsdCapability-marked shared-config entries — user hand-edits preserved — delete exactly the ledger-recorded files, then drop the entry; CAPABILITY_DATA preserved unless removeData), reconcileCapabilities (crash recovery driven by the ledger's _pending {kind,backupName,sharedFiles} INTENT — not a version comparison: roll an uncommitted upgrade back by restoring the backup, an uncommitted fresh install away entirely, and re-sync shared config from the winning bundle, guaranteeing no half-state), plus applyCapabilitySharedEdits/stripCapabilitySharedEdits (marker-isolated JSON edits, prototype-pollution-guarded). All four mutating ops + reconcile take a cross-process lock (.gsd/capabilities/.lock, atomic stale-steal) so a concurrent reconcile can't clear a live intent. Capability code never executes during any operation. The source resolver's promote:false/skipEnginesGate options are the seams that let this module own the swap/commit ordering and the engines gate (with compatVersions downgrade hint).
Capability Command Dispatch
ADR-1244 Phase 5 (D7) registry-driven dispatch of capability command families. First-party families (graphify/intel/audit, shipped in bin/lib/) dispatch via dispatchCapabilityCommand (gsd-core/bin/gsd-tools.cjs) against the FROZEN capability-registry.cjs commandFamilies (confined to bin/lib/) — unchanged. Third-party (installed overlay) families dispatch via dispatchOverlayCapabilityCommand: after the first-party path returns false, it calls loadRegistry({ includeInstalled, cwd }) and dispatches a family iff its capId is in _overlay.commandRoots — which capability-loader.cjs populates ONLY for accepted overlay capabilities that declare commands AND pass the loader's activation gate (a committed ledger entry, present and non-_pending, PLUS — for PROJECT scope — a matching user consent record in the Capability Consent Store; GLOBAL scope needs no consent record). A bundle dropped on disk with no install (no ledger entry) or no on-this-machine consent is NOT command-dispatchable. The router module is require()'d FROM the capability's install root via defaultRequireFromInstallRoot (bare-.cjs basename + realpath containment, rejecting .. traversal and symlink escape); same own-property/function/sync-only guards as the first-party path. Wired into the runCommand default arm before "Unknown command". A repo-planted project ledger no longer activates anything on its own (#1459) — see docs/explanation/capability-trust-model.md "project-scope trust boundary".
Claude Orchestration Capability
Default-off, BETA, claude-only Capability (capabilities/claude-orchestration/, role: feature, runtimeCompat.supported: ["claude"], tier: full, activationKey: claude_orchestration.enabled) adopting Claude Code's Workflow tool (the engine behind /effort ultracode, Agent SDK ≥ v0.3.149) as an optional parallel-execution backend for the GSD loop, and folding the gsd-ultraplan-phase plan-offload under the same runtime gate (#1143; ADR-1143). Pure, fail-closed core in gsd-core/bin/lib/claude-orchestration.cjs: detectWorkflowBackend({ runtimeId, hostIntegration, config, agentSdkVersion }) → { available, backend:'workflow'|'inline', reason } (gate ladder: enabled → Claude → execution_backend ≠ inline → host dispatch nested+background → valid Agent SDK → SDK ≥ floor; every miss degrades to inline, never throws); emitWorkflowScript({ phaseDir, waves, runId, budgetTokens?, executorModel? }) → { ok, script, summary } mapping waves → parallel() stage barriers, plans → agent({ agentType:'gsd-executor', isolation:'worktree', model? }), files_modified overlap → separate sequential stages (greedy first-fit), resumeFromRunId wired to the run id, shared budget(tokens); all interpolated identifiers validated script-safe (no ",\,control chars) and briefs JSON-quoted (review anti-injection). #2686: executorModel — resolved for gsd-executor from the same config the inline path reads, defaulted by the router so no caller change is needed, --executor-model to pin — is emitted per plan and OMITTED when it resolves to inherit/empty/whitespace/non-string (#2517: an empty model 404s on runtimes without native tier aliases); a value carrying an unscriptable character is rejected outright (ok:false) rather than quoted, because the ADR-1411 provenance header interpolates it into a // comment where U+2028/U+2029 would terminate the comment and execute the remainder. resolveWaveDispatch forwards it. Registers two loop contributions at WIRED points only (execute:wave:pre/execute:pre are declared but not rendered, same constraint external-job documents): execute:wave:post into:executor (Workflow-backend guidance) and plan:post into:planner (ultraplan ownership declaration), both when: claude_orchestration.enabled, onError: skip. Federated config keys (claude_orchestration.enabled default false, execution_backend enum auto|workflow|inline default auto, min_agent_sdk_version string default "0.3.149") live only in the registry — uninstall removes them cleanly. Pre-release versions of the floor compare below GA (SemVer precedence). Restores the wave parallelism + plan-checker + verifier that #853 forces inline on Claude Code; on any runtime lacking the Workflow tool, behaviour is byte-identical to today. BETA v1 ships detection + emission + declarative ultraplan ownership + a claude-orchestration command family (gsd-tools claude-orchestration detect-backend|emit-workflow, router gsd-core/bin/lib/claude-orchestration-command-router.cjs from src/claude-orchestration-command-router.cts); full install-profile migration of the ultraplan skill into skills[] is a follow-up (CLUSTERS/profile gate). Test anchors: tests/claude-orchestration.test.cjs, tests/claude-orchestration-command-router.test.cjs.
Loop Extension Point
A named, stable site on a host loop step (per-step pre/post plus per-wave in Execute; 12 total) where Capabilities register hooks. Three hook kinds: step (runs as its own sequenced unit), contribution (injects into the core step's prompt/context), and gate (checks and optionally blocks via a declared blocking flag). Each hook declares the artifacts it produces and consumes; hook order is derived by topological sort of that produces/consumes graph (capability-id tiebreak), which also defines data flow — file-artifact based, surviving /clear and fresh executor contexts. Hooks are surfaced by runtime resolution with concrete projection: the workflow calls a query that resolves the active hooks and returns fully-rendered, ordered markdown for the executor. Failure is default-resilient — a non-gate hook that errors is skipped with a warning; a hook may opt into onError: halt. Part of the Capability system. ADR-857 phase 3c ships the registry-consuming query layer: gsd-core/bin/lib/loop-resolver.cjs exposes resolveLoopHooks({ point, registry, config }) (pure, no I/O), renderLoopHooks(resolved) (pure markdown renderer), and cmdLoopRenderHooks(cwd, point, raw, opts) (I/O entry point); activated via gsd-tools loop render-hooks <point> which emits { point, activeHooks[], rendered }. Activation is driven by when (dotted config key resolved against loadConfig), with inline literal __proto__/constructor/prototype prototype-pollution guard. The first phase-6 cutovers wiring workflows to this query have landed — ui-phase at plan:pre and ui-review at verify:post (in plan-phase.md/autonomous.md); further per-feature cutovers are ongoing.
Capability State Resolver
ADR-857 phase 4b/6 unified resolver that composes the three toggle systems (install profile, runtime surface, config activation) into one per-capability view consumed by workflow hook rendering. Source of truth: gsd-core/bin/lib/capability-state.cjs (generated from src/capability-state.cts). Interface: resolveCapabilityState({ registry, installedSkills, surfacedSkills, config, cwd? }) → { capabilities: CapabilityStateEntry[] } (pure, no I/O); resolveCapabilityRuntimeState(cwd, runtimeConfigDir) (I/O resolver shared by workflow dispatch and diagnostics — returns { runtimeConfigDir, warnings, capabilities } only; registry and config are NOT returned — callers that need them import capability-registry.cjs and call loadConfig(cwd) directly); cmdCapabilityState(cwd, runtimeConfigDir, raw, opts) (I/O output entry point); isCapabilityActive(capId, cwd): boolean (convenience predicate — calls resolveCapabilityRuntimeState, finds the entry, returns entry.active; false when capability not found). CLI surface: gsd-tools capability state [--config-dir <path>] — emits { runtimeConfigDir, capabilities[] }. Per-capability output: { id, tier, skills[], installed, surfaced, enabled, active, hooks[] } where installed = every owned skill ∈ installedSkills (or installedSkills==='*'; vacuously true for empty-skills caps), surfaced = every owned skill ∈ surfacedSkills (vacuously true for empty-skills caps), enabled = installed && surfaced (unchanged — install+surface toggle only), active = enabled && configActivation (tri-state deepening: configActivation resolves the capability's activationKey via _resolveActivationValue; absent activationKey → configActivation=true, so active===enabled for ungated capabilities), and hooks = [{ point, kind: 'step'|'gate'|'contribution', when, configured, active }] derived from the cap's steps, gates, contributions arrays (configured resolves when; active = enabled && configured). Capabilities sorted by id for determinism. Defensive: malformed registry → { capabilities: [] }, never throws; inline literal __proto__/constructor/prototype prototype-pollution guard on capability id keys.
Capability State Writer
The write-side mirror of the Capability State Resolver. Takes a desired capability state — per-capability enabled plus per-hook gates — and projects it onto the substrates: enabled drives the runtime surface (.gsd-surface.json) as the capability on/off switch; gates drive the federated config keys (config.json workflow.*) for hook-level granularity; the install profile (.gsd-profile) is a read-only floor it never writes. Writes the surface once and config once (atomic per substrate), then re-runs the resolver and reports divergence (assert-and-report) — so 'off means off' holds as a write-time invariant rather than by caller discipline. Source of truth: src/capability-writer.cts; the surface and config writers become its internal adapters.
Capability Command Family [Planned — mechanism built, unconsumed]
ADR-959 (phase 4d) — a CLI command family (a top-level gsd-tools command and its subcommands) owned by a Capability via a new optional commands: [{ family, module, router }] field on the feature role. The Capability declares the family name, a first-party in-tree module (under gsd-core/bin/lib/), and the exported router — a standard route*Command({ args, cwd, raw, error }) function identical in shape to the 12 existing host routers (so it routes through the stateless CommandRoutingHub via routeCjsCommandFamily, owning its own subcommand list and arg parsing). The registry materializes a commandFamilies index (family → { capId, module, router }); the formerly-dead _dispatchNonFamily shim is replaced by a real dispatchCapabilityCommand (exported from gsd-core/bin/gsd-tools.cjs) consulted in runCommand's default case — an unmigrated command hits its hardcoded case; a migrated command's case is removed so it reaches default → registry → router, making collision structurally impossible. The registry discovers a router (it does not rebuild a handler table). First-party only; third-party command loading deferred. Mechanism built (4d-impl-1): commands schema + validator + single-family-ownership cross-check in gen-capability-registry.cjs; commandFamilies index emitted in the generated capability-registry.cjs (currently {} — no capability declares commands yet); dispatchCapabilityCommand wired into runCommand's default case (behavior-preserving today). Pilot complete (4d-impl-2): graphify cut over as the first real capability command family — capabilities/graphify/capability.json bundles the command (family: graphify, module: graphify-command-router.cjs, router: routeGraphifyCommand), skill (graphify), config gate (graphify.enabled), and tier: full; the case 'graphify': arm removed from gsd-tools.cjs; dispatch flows default → dispatchCapabilityCommand → commandFamilies.graphify → graphify-command-router.cjs → routeGraphifyCommand; behavior proven equivalent (all subcommands: build, query, status, diff, build snapshot, unknown subcommand error, usage error, disabled gate). Template for phase-6 per-feature cutovers. Audit cutover (4d-impl-3): audit-uat and audit-open cut over as the second capability command family pair — capabilities/audit/capability.json declares two commands (family: audit-uat, module: audit-command-router.cjs, router: routeAuditUat) and (family: audit-open, module: audit-command-router.cjs, router: routeAuditOpen); the case 'audit-uat': and case 'audit-open': arms removed from gsd-tools.cjs; commandFamilies now holds audit-uat, audit-open, and graphify; dispatch flows default → dispatchCapabilityCommand → commandFamilies["audit-uat"|"audit-open"] → audit-command-router.cjs → routeAuditUat|routeAuditOpen; behavior equivalence proven by existing regression tests (bug-2659, bug-2911, uat.test.cjs) plus new cutover tests. Confirms hyphenated family names pass registry validator (no format restriction beyond non-empty + non-reserved). Intel cutover (4d-impl-4, last first-party cutover): intel cut over — capabilities/intel/capability.json declares the command (family: intel, module: intel-command-router.cjs, router: routeIntelCommand) and the existing config gate (intel.enabled, default false); the case 'intel': arm removed from gsd-tools.cjs; commandFamilies now holds intel, audit-uat, audit-open, and graphify; dispatch flows default → dispatchCapabilityCommand → commandFamilies.intel → intel-command-router.cjs → routeIntelCommand; all 9 subcommands (query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface) and both usage-error paths preserved; non-raw timeAgo transform on status.files[*].updated_at preserved exactly. intel.enabled is Capability-owned config after ADR-857 phase 6. Completes the initial 4d capability command cutover batch.
Runtime Capability [Planned]
A role: runtime variant of a Capability (a Capability carries role: feature | runtime) that projects GSD's produced artifacts (skills/agents/hooks/commands) onto one host CLI's conventions — config-surface format, artifact-layout kinds, command template, hooks manifest, shared-hooks directory name, sandbox tier. It is a declarative descriptor over a fixed first-party primitive vocabulary (not a code adapter); install composes active Feature Capabilities × the chosen Runtime Capability at the InstallPlan seam (ADR-0058). First-party runtimes are authored through the same descriptor a third party would write (dogfooding the interface); tier-1 (Claude Code, Codex, Antigravity) is fully tested, the other existing runtimes ship lower-tier, none dropped. Third-party runtime loading is deferred to a purely additive external loader + trust gate. Note: "third-party" here is the authorship/distribution axis (who wrote/ships it), distinct from the integration-shape axis (in-host vs Connected Capability).
Connected Capability [Planned — deferred design]
A Capability whose integration shape brings its own external process, service, or persistent state — for example an MCP server plus a backing database — rather than running entirely within the host's process and trust boundary as declarative artifacts and in-tree first-party code referenced by closed name. Orthogonal to authorship: a Connected Capability may be first-party (e.g. MemPalace, issue #956) or third-party. Contrast with a plain Capability (declarative artifacts + in-host-trust code) and a Runtime Capability (closed-vocabulary projection descriptor), both of which run within host trust. The Connected Capability contract — external-process/MCP-server/backend-provider contributions plus a trust and load gate — is deferred design (ADR-857 §7); vehicle issue #956. It is NOT expressed by the current capability schema.
RULESET.CAPABILITY.off-means-off=the host derives shared outputs from the ACTIVE hook set (via loop.render-hooks); a hook may ADD a labeled block or be COUNTED into a host-computed aggregate (e.g. a score denominator), but NEVER mutates host source — so a disabled capability yields the base output by construction, not by authoring discipline. Ratify in ADR-894; proven by spike #1018.
RULESET.CAPABILITY.cutover-self-gating=a phase-6 per-feature cutover moves the host's phase-context detection + mode/flag logic INTO the skill (self-gating, per ADR-894); the loop hook is intentionally COARSE — "invoke skill X at point Y when config Z" — and carries no detection/mode. WORKED EXAMPLE: plan-phase.md §5.6 UI gate (frontend-detection via ui-safety-gate.cjs + --auto/manual branch + --skip-ui bypass) must move into gsd-ui-phase before its plan:pre hook can replace the inline call without behavior loss. Spike #1018 finding.
RULESET.CAPABILITY.step-additive-gate-blocks=a stephook is purely additive (invoke skill + produce artifacts, NEVER halts the host); host-blocking preconditions aregates (blocking:true, onError:halt); runtime/mode context (auto/chain vs manual) self-gates IN THE SKILL, not via when (config-only). §5.6 = plan:pre step (ui-phase; skill self-gates on frontend+pipeline, auto-fires only in pipelines) + a NEW plan:pre gate (frontend-and-no-UI-SPEC → halt, when:workflow.ui_safety_gate); the loop.render-hooks dispatch template handles steps AND gates. Resolves #1022.
RULESET.CAPABILITY.precedence-engine-single-owner=the config-key four-level precedence walk (loadConfig result → workstream config.json → root config.json → registry.configSchema default → absent) is owned solely by src/capability-activation.cts: raw-value primitive resolveConfigKey(dotKey, {config,cwd,registry}) and boolean wrapper _resolveActivationValue(dotKey,config,cwd,registry); loop-resolver.cts imports the engine (no duplicate); resolveConfigValues in loop-resolver.cts delegates to resolveConfigKey; resolveCapabilityRuntimeState does NOT return registry/config — callers import capability-registry.cjs and call loadConfig(cwd) directly.
SEAM.capability-activation-precedence-owner.owns=the config-key four-level precedence walk (loadConfig result → workstream config.json → root config.json → registry.configSchema default → absent), owned solely by src/capability-activation.cts
SEAM.capability-activation-precedence-owner.enforced-by=test:tests/capability-precedence-parity.test.cjs
Teams Status Module
Pure read-only detector for claude-code's experimental agent-teams feature (issue #1355). Stops gsd-core hanging silently when run under CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS. Source of truth: src/teams-status.cts → gsd-core/bin/lib/teams-status.cjs. Exports: resolveTeamsStatus({ runtime, env }) → TeamsStatus (pure, env injected, no process.env/disk inside — hermetic for tests) and cmdTeamsStatus(cwd, { active? }) (I/O entry point; reuses resolveRuntime from runtime-slash.cjs for canonical GSD_RUNTIME → config.runtime → 'claude' precedence). TeamsStatus shape: { active: boolean, runtime: string, env_present: boolean, source: 'on: env' | 'off: flag absent' | 'off: non-claude' }. active is true only when the flag is strictly truthy ("1" or "true", case-insensitive) AND the runtime is "claude". CLI surface: gsd-tools query teams-status [--active] (default: JSON; --active: exit 0/1 boolean). Used only by a non-fatal --active check warning in plan-phase.md init block — does NOT block execution, does NOT activate capabilities, does NOT change behavior on any non-claude runtime.
Wing
A MemPalace organizational unit corresponding to one project or repository. GSD derives the wing name from project_code or the project directory when mempalace.wing is unset. A wing contains Rooms. Cross-project Tunnels connect rooms across wings. MemPalace vocabulary — see Connected Capability, MemPalace memory capability (issue #956).
Room
A named bucket inside a Wing that groups drawers by semantic kind. GSD maps its phase artifacts to five fixed rooms: decisions (CONTEXT.md), planning (PLAN.md), milestones (SUMMARY.md/UAT.md excerpts), problems (confirmed bug→fix pairs), and learnings (extract-learnings output). MemPalace vocabulary — see Wing.
Drawer
A verbatim-content unit stored inside a Room. GSD files phase artifacts as drawers using mempalace_add_drawer; duplicate-check via mempalace_check_duplicate makes capture idempotent. GSD stores verbatim text (not AAAK summaries) to preserve recall fidelity. MemPalace vocabulary — see Room.
Tunnel
A cross-Wing knowledge connection created by mempalace_create_tunnel. GSD proposes tunnels at ship:post when mempalace.cross_project_tunnels: true, linking rooms in the current wing to semantically related rooms in other project wings. MemPalace vocabulary — see Wing.
Diary
A per-agent narrative entry written by mempalace_diary_write. GSD's gsd-mempalace-curator writes a diary entry at ship:post when mempalace.diary_journal: true, recording a session summary scoped to the project and agent role. MemPalace vocabulary — see Connected Capability.
memory_mode
The mempalace.memory_mode config key controlling how authoritative MemPalace is during recall/capture relative to GSD's native memory. Three wired values: augment (default — palace is an additive recall layer; native memory stays authoritative; lowest coupling), kg_backend (knowledge-graph queries resolve against MemPalace's temporal graph as the primary source, .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 mode loses memory. Read at hook-render time; switching is a config change, not a reinstall. Cross-mode migration of existing .planning/graphs/ into the palace is a separate, not-yet-implemented concern (PRD/ADR §17 open question). See MemPalace Settings in docs/CONFIGURATION.md.
Runtime Hooks Surface Module
Standalone hook-surface writer module extracted from bin/install.js as ADR-857 phase 5f-1 (behavior-preserving relocation, no logic change). Owns: Cline rules-body/agents-md/pre-tool-use hook generation (buildClineRulesBody, buildClineAgentsMdBody, buildClinePreToolUseHook, mergeGsdAgentsMd, writeClineArtifacts); Cursor hooks.json lifecycle (buildCursorHookEntry, isManagedCursorHookEntry, reconcileCursorHooksJson, writeCursorHooksJson, removeCursorHooksJson); Copilot session-hook config (buildCopilotHookConfig, writeCopilotHookConfig); Codex hook-block and event management (buildCodexHookBlock, rewriteLegacyCodexHookBlock, reconcileCodexHooksJsonEvent, reconcileCodexHooksJsonSessionStart, ensureCodexHooksJsonSessionStart, ensureCodexHooksJsonEvent, removeCodexHooksJsonEvent, removeCodexHooksJsonSessionStart, buildCodexHookWindowsShimIR); Kimi native config.toml [[hooks]] lifecycle (buildKimiHooksTomlBlock, stripKimiHooksTomlBlock, writeKimiHooksToml, removeKimiHooksToml — #2095 EoS/kimi Upgrade 1, the first genuinely NEW hook surface added post-relocation rather than a behavior-preserving move: kimi's [[hooks]] array lives in its own native config.toml, resolved by resolveKimiHooksTomlDir in Runtime Homes Module to a directory deliberately separate from kimi's GSD configDir, wrapped in # GSD Hooks BEGIN/END marker comments for idempotent reinstall); and shared hook command helpers (buildHookCommand, rewriteLegacyManagedNodeHookCommands, normalizeNodePath, resolveNodeRunner, and — #3662 — buildNodeRunnerChainToken, the POSIX-sh runner token that resolves node at hook-fire time for non-portable managed JS hooks, plus the NODE_RUNNER_RESOLVER_HOOK basename of the staged hooks/gsd-node-runner.sh resolver that portable installs route through with the baked node path as its first argument). bin/install.js delegates to this module via thin wrappers and re-exports its functions unchanged so existing tests require no modification. Source: src/runtime-hooks-surface.cts. Built output: gsd-core/bin/lib/runtime-hooks-surface.cjs.
Runtime Config Adapter Registry
Module owning the explicit per-runtime config-mutation dispatch table for the installer. resolveRuntimeConfigIntent(runtime) projects a typed config intent — installSurface (settings-json | codex-toml | copilot-instructions | cline-rules | cursor-hooks-json | profile-marker-only), writesSharedSettings (the finishInstall shared-settings write gate), and finishPermissionWriter (opencode | kilo | antigravity | none) — that bin/install.js dispatches on instead of inline runtime === '...' branching. Owns adapter selection only: it performs no filesystem IO and does not execute config mutations (the install/finishInstall handlers and the per-runtime writers do that). Unknown runtimes fail loudly with a TypeError, guarded by an Object.hasOwn own-property check so prototype-chain keys (__proto__, constructor) also throw. Also exports resolveInstallPlan(runtime) — the ADR-58 InstallPlan capstone — which collects the install-level descriptor axes (installSurface, writesSharedSettings, finishPermissionWriter, hookEvents, extendedHookEvents, hooksSurface, sandboxTier) into one typed InstallPlan value consumed by install() and finishInstall() in bin/install.js. sandboxTier (none | codex-agent-sandbox) gates per-agent sandbox_mode emission in the codex TOML path and fails loud on a missing/invalid value (#1151). The spatial axes (configHome, artifactLayout, commandStyle) remain behind their self-resolving adapter modules and are not part of the plan; they are the execution adapters. Realizes both the adapter-selection and plan-collection halves of the Runtime Install Policy Module boundary. Source: gsd-core/bin/lib/runtime-config-adapter-registry.cjs. See ADR-58, #60.
Claude Code Plugin Manifest Module
Module owning the projection of gsd-core's artifact surfaces (commands, agents, hooks) onto the Claude Code plugin contract (.claude-plugin/plugin.json + hooks/hooks.json) — the plugin-contract sibling of the Runtime Artifact Layout Module (which projects the same surfaces onto filesystem placements). Defined mapping: name=binName (drives the /gsd-core: command namespace), repository/homepage=repoUrl (Package Identity Module), version/description/license from package.json (version is required for claude plugin validate --strict), commands=./commands/gsd/, agents via Claude Code's default agents/ discovery (the explicit string form is schema-rejected), hooks=./hooks/hooks.json. The hook projection carries ONLY the always-on subset of the Installer Module's Claude settings.json wiring (check-update, context-monitor, prompt-guard, read-guard, worktree-path-guard, read-injection-scanner, write-guard) via ${CLAUDE_PLUGIN_ROOT}; config-gated opt-in hooks are excluded because a static manifest cannot honor per-project config gates, and plugin-shipped agents cannot carry hook frontmatter (so all plugin-path hook wiring lives in hooks.json). hooks.json covers all seven Claude Code lifecycle events: SessionStart, PreToolUse, PostToolUse, SubagentStop, Stop, PreCompact (all wired to context-monitor for context-headroom awareness), and FileChanged (matcher: config.json → config-reload, injects additionalContext when .planning/config.json changes mid-session). Additive — the file-copy path (Runtime Artifact Layout / Install Policy / Installer Modules) is unchanged. Conformance is validated by claude plugin validate --strict plus the in-repo drift-guard tests/plugin-manifest.test.cjs. Avoid: "the plugin API", "the plugin file" (when you mean the seam). See ADR-766 and Runtime Artifact Layout Module.
Knowledge Graph Module
Module owning the graphify integration: tri-state capability gate (isCapabilityActive('graphify', cwd) from capability-state.cjs — requires installed AND surfaced AND config-enabled; replaces the former config-only isGraphifyEnabled gate, cutover in #1306), disabled response (disabledResponse), subprocess helper (execGraphify, typed GRAPHIFY_REASON enum), presence detection (checkGraphifyInstalled), version checking (checkGraphifyVersion), query surface (graphifyQuery — BFS seed-expand + budget trim), status surface (graphifyStatus — node/edge counts, mtime staleness, commit-staleness tri-state via built_at_commit/commits_behind/commit_stale), diff surface (graphifyDiff — added/removed/changed nodes+edges), build pre-flight (graphifyBuild), snapshot management (writeSnapshot). Config leg reads .planning/config.json:graphify.enabled; all three legs (install, surface, config) must be active; writes to .planning/graphs/. Graph location override (#1825): graphify.graph_path in .planning/config.json (a path relative to the project root, or absolute) redirects where graphifyQuery/graphifyStatus/graphifyDiff read graph.json — so one umbrella-level cross-repo graph serves multiple sibling projects without N drifting mirror copies; the diff snapshot (.last-build-snapshot.json) travels with the configured graph (same dir); the auto-update status sidecar stays project-local; writeSnapshot honors the key (reads the configured graph, writes the snapshot alongside it); build stays project-scoped (.planning/graphs/) since the build skill hardcodes that destination — the umbrella graph is built in the umbrella project and sub-projects only READ it. Unset/blank/non-string → byte-identical .planning/graphs/graph.json default; a configured-but-missing file yields an actionable error naming the path. The key is registered in config-schema.manifest.json validKeys. Auto-update hook (hooks/gsd-graphify-update.sh) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when graphify.auto_update=true. Status file .planning/graphs/.last-build-status.json carries { ts, status, exit_code, duration_ms, head_at_build, graphify_version }. Graph IR uses nodes[], edges[] (or links[] for graphify ≥0.7 compat), hyperedges[], built_at_commit. commit_stale is tri-state: false (known fresh), true (stale), null (unknown — no git or pre-v0.7 graph). Source: gsd-core/bin/lib/graphify.cjs. Skill: commands/gsd/graphify.md.
Intel Module
Module owning the code-intelligence store: tri-state capability gate (isCapabilityActive('intel', cwd) from capability-state.cjs — honours installed+surfaced+config-enabled; replaces the former config-only isIntelEnabled gate, cutover in #1307; intel has skills:[] so installed/surfaced are vacuously true and the effective gate is intel.enabled in config), disabled response, query surface (intelQuery — full-text search across all intel JSON files), status surface (intelStatus — per-file freshness, 24-hour staleness threshold), diff surface (intelDiff — added/changed/removed files vs last-refresh snapshot), snapshot management (saveRefreshSnapshot/intelSnapshot), validation (intelValidate — existence, JSON validity, _meta.updated_at recency), api-surface render (intelApiSurface — generates .planning/intel/API-SURFACE.md from api-map.json), plus ungated utilities (intelPatchMeta — patches _meta.updated_at in any JSON file; intelExtractExports — extracts CJS/ESM exports from any JS file). Loop hook rendering gates on state.active (not state.enabled) so the activationKey config gate is honoured even without a per-hook when guard (Phase 4 tri-state alignment, #1307). Source: gsd-core/bin/lib/intel.cjs. Router: gsd-core/bin/lib/intel-command-router.cjs. See Capability Command Family Module (ADR-959 4d-impl-4) and Loop Extension Point.
Research Module
The GSD-RESEARCH capability behind an L2-hybrid seam: code owns cache + provider policy + package legitimacy; MCP owns the actual fetch. Reachable via gsd-tools query research-plan|research-store|package-legitimacy. Source: src/research-{store,provider}.cts + src/package-legitimacy.cts (generated to gsd-core/bin/lib/*.cjs per ADR-457). Replaces the prose provider-waterfall duplicated across the researcher agents and the pip-install slopcheck bolt-on.
GSD-RESEARCH.MODULE.research-store=content-addressed cache; key=sha256(ecosystem+library+version+query+kind); getResearch->{hit,stale} never throws (mirrors graphify staleness); ttlForSource curated HIGH 30d|MED 7d|web LOW 1d; tiers: curated-doc kinds -> ~/.gsd/research-cache (cross-project), web/synthesis -> project .planning/research/.cacheGSD-RESEARCH.MODULE.research-provider=single source of truth PROVIDER_WATERFALL (docs Context7->Ref->Jina->websearch; web Exa->Tavily->Perplexity->Brave->websearch; scrape Firecrawl->Jina); planResearch returns cache-hits+fetch-plan; classifyConfidence stamps HIGH|MEDIUM|LOW by provider AUTHORITY + verification EVIDENCE (HIGH requires code-computed ground-truth corroboration e.g. legitimacyVerdict OK; provider authority alone caps at MEDIUM; SLOP caps at LOW); Firecrawl is scrape-only (not in the docs or web legs)GSD-RESEARCH.MODULE.package-legitimacy=registry-API verdicts (npm/PyPI/crates.io injectable adapters) computed from thresholds {minAgeDays:30,minWeeklyDownloads:1000,requireRepo:true}; verdict OK|SUS|SLOP per package; slopcheck=optional adapter that can only escalate, never the install-or-degrade gateGSD-RESEARCH.INTEGRATION.L2-hybrid=code owns cache+legitimacy+confidence+provider-pick (gsd-tools query research-plan/research-store/package-legitimacy); MCP owns the fetch; agent returns RESEARCH.md path, never raw fetchesGSD-RESEARCH.PROVIDER.availability=config flags brave_search/exa_search/firecrawl/tavily_search/ref_search/perplexity/jina (env <X>_API_KEY or ~/.gsd/<x>_api_key); context7/jina/websearch always available; planResearch falls through waterfall to websearch terminalGSD-RESEARCH.CONTEXT-DISCIPLINE=less-context levers: subagent isolation + compact provider output + fetches-to-disk + cache-returns-digest; API clear_tool_uses/memory tool are the conceptual model, not a Claude Code harness knob
UAT-Passed Predicate
Runtime-neutral predicate evaluating *-UAT.md / *-VERIFICATION.md result fields with markdown-aware parsing that ignores false-positive contexts (frontmatter body, fenced code, HTML comments, blockquotes). Returns passed: true only when all required checks pass; supports --require-verification to demand at least one VERIFICATION.md file alongside UAT results. Output envelope: { passed, uat_files[], verification_files[], checks[], blockers[], policy }. Source: gsd-core/bin/lib/uat-predicate.cjs. Wired via phase uat-passed alias → phase-command-router → cmdPhaseUatPassed.
Coverage Metadata Module
Deterministic classifier for the per-deliverable coverage RTM on SUMMARY.md (#1602). Parses the optional coverage: frontmatter block (a list-of-maps-with-nested-list-of-maps that extractFrontmatter cannot represent — so a dedicated indentation parser, sibling of parseMustHavesBlock), validates each entry's schema, and classifies each into auto_passed (deterministically covered) vs present (human UAT required). Output envelope: { mode, summary_file, total, all_auto_covered, auto_passed[], present[], errors[] } with frozen MODE/PRESENT_REASON/ERROR_CODE enums. Auto-pass requires the narrow proven case (strict-boolean human_judgment:false AND non-empty all-pass verification AND zero errors); everything else, including a malformed entry, routes to present (fail-safe — never drops a deliverable, never false-auto-passes). mode:legacy (absent block) ⇒ caller falls back to prose ## Accomplishments extraction, byte-identical for un-migrated phases. Source: gsd-core/bin/lib/coverage.cjs. Wired via uat classify-coverage --summary <f> → cmdClassify; authored by execute-plan create_summary, consumed by verify-work extract_tests. See RULESET.WORKFLOW.COVERAGE-METADATA.
Eval Scoring Module
Deterministic eval-scoring projection (#10 / #1579) that moves the gsd-eval-auditor's weighted arithmetic out of the prompt into code. computeEvalScore(covered, total, infra[]) returns { coverage_score, infra_score, overall_score, verdict } — coverage = covered/total*100, infra = mean of per-item weights (ok=1, partial=0.5, missing=0) over exactly 5 items, overall = coverage*0.6 + infra*0.4 (2-dp rounding), verdict banded at 80/60/40 (PRODUCTION READY / NEEDS WORK / SIGNIFICANT GAPS / NOT IMPLEMENTED). cmdEvalScore is the CLI guard: rejects empty/NaN flags, infra.length !== 5, and out-of-domain counts (requires 0 <= covered <= total). Pure arithmetic — no .planning/ access (it is in SKIP_ROOT_RESOLUTION), no Date.now/Math.random. Wired via the eval.score verb (and the eval score spaced alias) → eval-command-router → cmdEvalScore; consumed by gsd-eval-auditor. Source of truth: gsd-core/bin/lib/eval.cjs (generated from src/eval.cts, gitignored per ADR-457). Tests: tests/eval.test.cjs, tests/eval.property.test.cjs.
Probe Core Module
Generic spec-phase probe resolution model — the shared seam underlying spec-completeness probes (ADR-550 Decision 7). Owns the status × verification model (status: resolved | dismissed | unresolved × a per-probe verification tier), structural validation (validateResolution, validateRequirement — fail-closed: verification must be null unless status is resolved, and an out-of-enum status, a dismissed-without-reason, or an unresolved carrying a resolution/reason/tier payload all throw rather than silently miscount), the analyzeCoverage(items, resolutions?, validators) merge/rollup/orphan-reject pipeline, the byVerification per-tier rollup, and the runProbeCli I/O scaffold (parse → validate → analyze → emit, structurally guarding the report shape before write — a malformed report fails closed with stderr + exit 2 instead of stringifying as green). Adapter-agnostic: consumed by the Edge Probe Module today and the Prohibition Probe Module (#644) next. Exports (generic surface): VALID_STATUS, validateResolution, validateRequirement, analyzeCoverage, runProbeCli — the prohibition adapter exports that also ship from this module (projectProhibitions, PROHIBITION_VALIDATORS, validateProhibitionResolution, dispositionForProhibition) are documented under the Prohibition Probe Module's own locked-surface line. Source of truth: gsd-core/bin/lib/probe-core.cjs (generated from src/probe-core.cts, gitignored per ADR-457). Tests: tests/probe-core.test.cjs. See ADR-550 and Edge Probe Module. Under ADR-857 (phase-6 boundary, settled 2026-06-12) this seam is classified core verification substrate on the contract side: its deterministic validators are the verifier↔predicate contract's CI-testable surface (ADR-550 Decision 5) — core and non-toggleable, never an off-by-default Feature Capability. (The recall-gapped generator is the probe adapters that propose predicates, not this resolution engine — see Edge Probe Module and Verification substrate (predicate boundary).)
Edge Probe Module
First adapter of the Probe Core Module (ADR-550 Decision 7): the spec-phase edge-completeness probe wired into spec-phase Step 5.5. Owns shape classification (classifyShape), the applicable-category relevance filter (applicableCategories over the 8-category edge TAXONOMY), edge proposal (proposeEdges), and the {explicit, backstop} verification validators; delegates merge/rollup/CLI to probe-core. Fail-closed input contract: an edge requirement with missing/empty text and no shapes override is rejected (a { id }-only requirement no longer classifies to zero edges and silently drops), while the legitimate shapes: [] opt-out is preserved. SHAPE_CUES are English word-boundary patterns, so text is an English-language input regardless of the project's response_language: spec-phase Step 5.5 passes a faithful English translation of each requirement while the SPEC itself keeps its original language and the id is never translated (#2773 — without it every requirement in a non-English project matched no cue and landed in unclassified, silently disabling the taxonomy). Downstream, the plan-phase planner lifts every covered/backstop edge from the SPEC ## Edge Coverage section into must_haves.truths. Exports (locked surface): classifyShape, applicableCategories, proposeEdges, analyzeCoverage, validateResolution, validateRequirement, plus the constants TAXONOMY (the closed 8 edge categories), UNCLASSIFIED_CATEGORY (the unclassified review-manually sentinel for zero-cue prose, #1110 — deliberately not a 9th taxonomy entry: it stays out of TAXONOMY but is present in EDGE_VALIDATORS.categories), VALID_SHAPES, SHAPE_CUES, and EDGE_VALIDATORS (the {explicit, backstop} validators bundle injected into probe-core's generic engine; its categories = the TAXONOMY ids plus UNCLASSIFIED_CATEGORY). Source of truth: gsd-core/bin/lib/edge-probe.cjs (generated from src/edge-probe.cts, gitignored per ADR-457). Tests: tests/edge-probe.test.cjs, tests/edge-probe-spec-phase-contract.test.cjs, tests/edge-probe-planner-contract.test.cjs. See ADR-550 and Probe Core Module. Per ADR-857's phase-6 boundary (2026-06-12) the predicates this module generates are core verification substrate (they set the verifier's reach), so it is wired onto the core predicate rail as a core-default module rather than migrated to an off-by-default capabilities/edge-probe/ Feature Capability.
Verification substrate (predicate boundary)
The ADR-857 classification (settled 2026-06-12, prompted by @davesienkowski's boundary analysis on #857) that predicate-generation — the must-NOT-have / edge predicates that set the verifier's reach — is core, not an off-by-default Feature Capability. Load-bearing premise: verifier reach = spec reach (the verifier can only catch what the spec concretely names). Decomposes into: the verifier↔predicate contract (the verifier always expects predicates and grades exogenously against them — core, non-toggleable, a stability contract alongside the Loop Extension Point names), and the generator (the probe adapters that propose predicates — edge-probe's classifyShape/proposeEdges, the prohibition probe's adversarial LLM-propose — core-default but independently versionable, kept their own module because of a measured recall gap; probe-core's deterministic validators sit on the contract side, not the generator side). Altitude rule distinguishing it from gate hooks: a gate runs against the spec (hook); predicate-generation defines the spec's reach (core). The decision-#6 produces/consumes artifact flow is the internal rail from generation to the core verifier. See ADR-857 Verification substrate vs. plug-in tier (the predicate boundary) and the ADR-550 cross-reference.
Probe Family
The set of spec-phase completeness probes that share the Probe Core Module seam (ADR-550 Decision 7): the Edge Probe (Step 5.5, data-shape edges) and the Prohibition Probe (Step 5.6, unwritten must-NOT constraints) today, with room for a third nearly-free adapter. A "probe" walks each SPEC requirement, surfaces candidate omissions, and resolves each through the shared status × verification model — but each family member owns its own recall mechanism: a deterministic closed-taxonomy classifier for edges (shape→category), open-vocabulary adversarial LLM prose for prohibitions (recall is model-driven, not a compute adapter — ADR-550 D7b). What is shared is the resolution/validation/rollup engine and the soft-gate lifecycle; what diverges is how candidates are recalled. See Probe Core Module, Edge Probe Module, Prohibition Probe Module.
Verification Tier
The orthogonal verification dimension a resolved probe item carries alongside its status (ADR-550 D7a — status: resolved | dismissed | unresolved is the shared resolution lifecycle; verification is the probe-defined enforcement axis). Each probe defines its own tier vocabulary: the edge probe uses explicit | backstop; the prohibition probe uses test | judgment. For prohibitions the tier names how a must-NOT can be enforced — test (a negative test can fail-close on it) vs judgment (an irreducible values/safety rule only human/LLM judgment can assess). Verify-phase routes on the tier: test-tier items must be provably wired and fail closed when unwired (never a silent green); judgment-tier items take the mode-dependent soft-gate (ADR-550 D4 — interactive demands human resolution, autonomous records a non-authoritative LLM-judge verdict + an unverified-prohibition flag, never a silent pass and never a hard halt). The rollup exposes coverage.byVerification: { <tier>: count } so verify-phase reads the per-tier denominator without re-scanning. See Probe Core Module, Prohibition Probe Module.
Bespoke vs Canon Prohibition
The ownership seam between the prohibition probe and security/compliance tooling (ADR-550 D6). The probe owns bespoke product/values prohibitions — the unwritten must-NOTs specific to this feature's intent (e.g. "the streak reminder must not manipulate the user into returning"). When precision classifies an item as a canon security/compliance concern (OWASP / GDPR / fairness / prototype-pollution / path-traversal — the codified, cross-project rule sets), the probe does not mint a SPEC prohibition: it emits a one-line breadcrumb ("possible canon-security concern X — owned by /gsd:secure-phase / eslint") and stops. Canon checks are referred, not duplicated — keeping the surfaced list short (#644's ~2–3-item precision goal) and the secure-phase boundary explicit. See Prohibition Probe Module.
Prohibition Probe Module
Second adapter of the Probe Core Module (ADR-550 Decision 7): the spec-phase prohibition-completeness probe wired into spec-phase Step 5.6, surfacing the unwritten must-NOT constraints (values/safety/ethics) the spec never forbids. Unlike the Edge Probe, recall is prose-orchestrated, not a compiled engine (ADR-550 D7b) — a two-stage pass per requirement: Stage 1 an adversarial recall question, Stage 2 a one-pass precision classifier (drop routine engineering, keep genuine prohibitions). The code surface is schema/projection only: projectProhibitions() (deterministic SPEC↔must_haves.prohibitions projection backing the RULESET.GENERATIVE-FIX parity assertion), the {test, judgment} PROHIBITION_VALIDATORS, validateProhibitionResolution, and dispositionForProhibition() (the fail-closed default — an unwired test-tier item resolves to unverified/flagged, never green). Deterministic test-tier locate (#1278, ADR-550 D3 addendum): a resolved test-tier prohibition MAY carry an optional flat-scalar check descriptor — check_kind (node-test | lint-rule), check_target, and check_rule (lint-rule only) — that projectProhibitions emits into must_haves.prohibitions when well-formed, and descriptorFromProjection() (the read-back seam in the #1259 enforcement producer, src/prohibition-enforcement.cts) reconstructs into a {kind, target, rule?} CheckDescriptor, so verify-phase locates the wired check with zero LLM/author authoring. Flat scalars, never a nested check:{} object — so the round-trip rides the unchanged shared parseMustHavesBlock (the #644 no-parser-rewrite precedent); an absent/partial descriptor falls through to the producer's existing fail-closed locate, and failFirst stays caller-attested (machine-proof is #1279). No proposeProhibitions() — recall is LLM prose. plan-phase lifts every resolved prohibition from the SPEC ## Prohibitions (must-NOT) section into the must_haves.prohibitions sibling block (never truths). Exports (locked surface): projectProhibitions, PROHIBITION_VALIDATORS (the {test, judgment} validators bundle injected into probe-core's generic engine), validateProhibitionResolution, and dispositionForProhibition (the fail-closed disposition) — the prohibition adapter surface, shipped from probe-core alongside the generic engine. Source of truth: gsd-core/bin/lib/probe-core.cjs (the prohibition exports live in src/probe-core.cts, gitignored per ADR-457) + gsd-core/references/prohibition-probe.md. Tests: tests/prohibition-probe.*.test.cjs. See ADR-550, Probe Core Module, Edge Probe Module, Verification Tier, Bespoke vs Canon Prohibition.
Spec-Section Helper Module
The SINGLE source of truth for "did the phase SPEC supply section X (with at least one resolved row)?" — the SPEC-section detection seam consumed by plan-phase Step 7.95 (the spec-less probe fallback) to decide, per section, whether to run the fallback. Replaces the ad-hoc awk that previously lived in the workflow body, which hard-coded the section header strings at the call site and hand-rolled markdown-table row counting — a brittleness that produced two bugs: an exact ^## Prohibitions$ anchor that missed the canonical ## Prohibitions (must-NOT) heading, and a single-table row-counting assumption. Suffix-tolerant header invariant: SECTION_HEADERS regexes match a heading AND any parenthetical/whitespace suffix — prohibitions matches both ## Prohibitions and ## Prohibitions (must-NOT); edges matches ## Edge Coverage (and any future suffix); if spec-phase renames a heading, update HERE and the templates/spec.md heading together (the contract is pinned by tests/spec-section.test.cjs). Supply rule: supplied = present AND dataRows > 0 — a present-but-empty section is NOT supplied (it triggers the fallback). Multi-table robustness: a blank or prose line resets the per-table state, so a section with multiple tables (or prose between them) counts every table's data rows without miscounting a second table's header row; the |…| line before a |---| separator is the table header row and is never counted. Fail-safe: a missing/unreadable SPEC file resolves to present:false / supplied:false (so the fallback fires) rather than throwing. Exports (locked surface): the SpecSectionKey type (edges | prohibitions), SECTION_HEADERS (the canonical header matchers), the SectionStatus shape ({ key, present, dataRows, supplied }), countSectionDataRows (pure specText → { present, dataRows }), and specSectionStatus (disk-reading wrapper). CLI: node spec-section.cjs <specFile> <edges|prohibitions> prints SectionStatus JSON — exit 0 on success (an absent file is a valid "not supplied" answer), exit 2 only on a usage error (missing args / bad key). Pure and dependency-free. Source of truth: gsd-core/bin/lib/spec-section.cjs (generated from src/spec-section.cts, gitignored per ADR-457). Tests: tests/spec-section.test.cjs. See Edge Probe Module, Prohibition Probe Module, and references/specless-probe-fallback.md.
MVP Mode
Phase-level planning enrichment layered on top of the default tracer-first decomposition (see Tracer Bullet): it frames the phase goal as a User Story and, on Phase 1 of a new project, emits a Walking Skeleton. Vertical slicing itself is now the default, so MVP Mode no longer turns it on — it adds the user-story framing + skeleton. Resolved at workflow init via the precedence chain: --mvp CLI flag → ROADMAP.md **Mode:** mvp field → workflow.mvp_mode config → false. All-or-nothing per phase (PRD #2826 Q1). Surfaced as MVP_MODE=true|false to the planner, executor, verifier, and discovery surfaces (progress, stats, graphify). Canonical parser: roadmap.cjs **Mode:** field; canonical resolution chain documented in workflows/plan-phase.md. Concept index: references/mvp-concepts.md.
Claim Disposition
Three-way verdict every research claim carries before it may be shared, on the /gsd-explore Step-3 research pass (#2229): admit (survived a prompted-to-refute pass AND grounded in a source authoritative for that claim → stated with the source), refute (a source authoritative for that claim contradicts it → corrected, with the source), abstain (unverifiable, no primary support, a non-authoritative disagreement, a source-vs-prior conflict, or an untagged return → routed to the Unresolved Ledger, never smoothed into prose). Authority for the claim's own subject — not surprise — is the refute/abstain discriminator; a "strong prior" is never authoritative alone and can only produce an abstain. Two guards: conflict-abstention (a source-vs-prior conflict is never a silent pick-a-side) and the tier floor (every would-be admit is presented as an abstain when the researcher's resolved tier — gsd_run query resolve-model <agent> --pick tier, computed above the resolve_model_ids: "omit" gate in resolveModelInternal so it stays readable when the model id is blank or runtime-substituted — is haiku (over-defers to whatever source it was handed) or is unknown/inherit/empty (could not be determined, treated as potentially-cheap, never as verified-adequate); refute and abstain are unaffected. --pick profile defaults to balanced when config.model_profile is unset — it is not a tier signal. Disclosed residual, two cases: a per-agent model_overrides pin to a raw model id carries no tier, so it reports unknown and is floored — deliberate over-flooring in the safe direction; and a model_profile_overrides.<runtime>.<tier> entry that repoints a tier at another tier's model reports the asked-for tier, not the answering model's tier, so the floor stays silent on a cheap model — the one direction that fails open). A prompt-level judgment on this ideation surface: it deliberately does NOT call the verify-time probe-core disposition, which sits on the verifier↔predicate rail (ADR-857) and is out of altitude here. Claims-side analogue of the #1154 honest verifier (abstain-and-flag on the non-inferable; ADR-550 D4 — never a silent pass). Canonical text: workflows/explore.md. See Unresolved Ledger.
Unresolved Ledger
The named output bucket a /gsd-explore research claim lands in when its Claim Disposition is abstain (#2229). Presented side by side with the admitted claims rather than folded into the narrative, each entry carrying its abstention reason (unverifiable | source-vs-prior conflict | non-authoritative source | tier-floor: unearned confidence | untagged — disposition not reported). Empty sections are suppressed; an all-unresolved outcome is stated in one line, because that is itself the signal. Exists to make the absence of grounding visible — the failure mode it replaces is a plausible-but-ungrounded claim entering the conversation as confident prose (the class the research pass targets: recent / version-drift facts the model is measurably overconfident on). See Claim Disposition.
User Story
Phase-goal format under MVP Mode: As a [role], I want to [capability], so that [outcome]. Required regex shape: /^As a .+, I want to .+, so that .+\.$/. Used as the framing input by gsd-planner (emits as bolded ## Phase Goal header in PLAN.md) and as the verification target by gsd-verifier (the [outcome] clause is the goal-backward verification anchor). Authored interactively by /gsd-mvp-phase, validated by SPIDR Splitting when too large.
Walking Skeleton
Phase 1 deliverable under --mvp on a new project — the Phase-1 whole-application special case of a Tracer Bullet: the thinnest end-to-end stack proving every layer (framework, DB, routing, deployment) works together. Emitted as SKELETON.md capturing the architectural decisions subsequent vertical slices inherit. Gate fires when phase_number == "01" AND prior_summaries == 0 AND MVP_MODE=true. Scope intentionally narrow (PRD #2826 Q2) — does not retrofit existing projects.
Vertical Slice
Single-feature task that moves one user capability from open-to-close (happy path) end-to-end. Contrast with the horizontal layer (all models, then all APIs, then all UI). The default planning unit under tracer-first decomposition (the leading task is a Tracer Bullet); SPIDR Splitting axes (Spike, Paths, Interfaces, Data, Rules) are the canonical decomposition tools when a slice is too large for one phase.
Tracer Bullet
The default GSD decomposition lead: a permanent, production-quality, minimal end-to-end slice that wires one path through every layer a phase touches and becomes part of the skeleton of the final system — written for keeps, not thrown away. Contrast with a prototype (throwaway reconnaissance code, deleted once its lesson is learned): a tracer's functionality gaps are acceptable but its architectural gaps are not; stubs are allowed only where they can later be filled without an architectural change. GSD ships tracers, never prototypes — which is why gsd-planner LEADS every plan with a type="tracer" task (default; --no-tracer / TRACER_MODE=false opts back into horizontal layers) and gsd-executor runs an early integration feedback gate on the tracer's <verify> before expansion tasks (autonomous: halt-on-fail; interactive: honors workflow.human_verify_mode — under end-of-phase an automated-only <verify> continues with no checkpoint, otherwise checkpoint:human-verify, #3299). Origin: The Pragmatic Programmer "Tracer Bullets" (#1945); the Walking Skeleton is the Phase-1 whole-application special case. See Vertical Slice, MVP Mode, Walking Skeleton, Precondition.
Precondition
The front-of-task side of the GSD plan contract (issue #1949, The Pragmatic Programmer Topic 23 — Design by Contract). An optional <precondition> element on <task> stating, in a single line of runnable/checkable prose, what must already be true for the task to begin safely — e.g. "OPENAI_API_KEY is set", "dist/schema.json from Phase 02 exists", "server responds to GET /health". gsd-executor evaluates it before any other task work, using read-only checks only (file existence, env var presence with no value output, idempotent GET /health-style pings — no writes, no network POSTs, no secret emission; if a side-effecting check seems required, the executor halts and surfaces a checkpoint rather than running it): met-or-absent is a no-op (back-compat for every existing plan); unmet returns a checkpoint:human-verify with no partial commit, and is NEVER auto-approved under AUTO_CFG=true (a missing prerequisite is a fact the executor cannot establish on its own, not a verification step). gsd-planner emits <precondition> in exactly three cases — user_setup consumption, prior-phase artifact dependency, or env-var/runtime-config dependency — when the assumption is not already guaranteed by depends_on ordering. Closes the contract triad whose other two sides are postconditions (<verify>/<done>/<acceptance_criteria>) and invariants (must_haves.truths). The structural validator (cmdVerifyPlanStructure) does not reject unknown optional tags, so adding <precondition> passes plan-structure validation unchanged. Canonical schema reference: docs/reference/plan-md.md → Preconditions; emission rules + anti-patterns: gsd-core/references/planner-preconditions.md. The architectural-end companion is the Tracer Bullet (#1945); together they close both ends of the "outrunning your headlights" failure mode. See Tracer Bullet.
Reversibility Rating
Classification of a planning decision by what undoing it would cost later (issue #1951, The Pragmatic Programmer Topic 15 — "Reversibility"; Bezos's one-way/two-way door framing). Three closed values: reversible (undo is local and cheap — one file, one function, an implementation swapped behind a stable interface), costly (undo touches many call sites or needs a coordinated change — shared interface shape, cross-module contract, dependency major bump), one-way (undo requires a data migration, breaks a published contract, or is impossible — on-disk/wire format, public API shape, external-service lock-in). Surfaced two places: discuss-phase records it inline on <decisions> entries in phase CONTEXT.md as — **Reversibility:** <rating> — <rationale> (optional; an unrated decision is treated as reversible), and gsd-planner carries it onto the implementing task as the optional <reversibility rating="…"> element. Planner behavior is rating-dependent: one-way inserts a checkpoint:decision BEFORE the dependent task (and forces autonomous: false), costly is flagged in the plan but never blocks, reversible flows normally. Reuses the existing checkpoint:decision mechanism — no new checkpoint machinery. Override --no-reversibility-gates (REVERSIBILITY_GATES=false, /gsd:plan-phase) suppresses checkpoint insertion for intentionally-unattended runs while still recording ratings, so the signal survives a run that chose not to stop for it. The structural validator (cmdVerifyPlanStructure) does not reject unknown optional tags, so <reversibility> passes plan-structure validation unchanged. Canonical taxonomy owner: gsd-core/references/planner-reversibility.md; schema reference: docs/reference/plan-md.md → Reversibility; the Reversibility Test thinking model (references/thinking-models-planning.md #4) is the reasoning step that produces the rating and consumes this taxonomy rather than defining a second one. Primary anti-pattern: rating everything one-way (checkpoint fatigue) — default to reversible when unsure, and prefer removing irreversibility (writer seam, versioned contract, vendor adapter) over gating it. The decision-risk companion to the Precondition (#1949), which guards implementation assumptions. See Precondition, Tracer Bullet.
Behavior-Adding Task
Predicate over a PLAN.md task: tdd="true" frontmatter AND <behavior> block names a user-visible outcome AND <files> includes at least one non-*.md / non-*.json / non-*.test.* source file. Pure doc/config/test-only tasks are exempt. The MVP+TDD Gate (in references/execute-mvp-tdd.md) only halts execution on this predicate; the gsd-executor agent applies all three checks at runtime. Currently a prose-only specification — no shared utility.
MVP+TDD Gate
Per-task runtime gate in /gsd-execute-phase that, when both MVP_MODE and TDD_MODE are true, refuses to advance a Behavior-Adding Task until a failing-test commit (test({phase}-{plan})) exists for it. The tdd_review_checkpoint end-of-phase review escalates from advisory to blocking under the same condition. Documented contract: references/execute-mvp-tdd.md. Reserved escape hatch --force-mvp-gate is documented but not implemented.
SPIDR Splitting
Five-axis story decomposition discipline (Spike, Paths, Interfaces, Data, Rules) used by /gsd-mvp-phase when a User Story is too large for one phase. Full interactive flow per PRD #2826 Q3 (not a lightweight filter). Reference: gsd-core/references/spidr-splitting.md.
Clock seam
An injectable time abstraction accepted as an optional parameter by production code ({ clock = Date } = {}). Test code substitutes node:test mock.timers to control time deterministically without waiting for real OS scheduler events. Canonical pattern established by ADR 456 (docs/adr/456-test-rigor-architecture.md).
Process seam
The single subprocess-spawning primitive test code uses (tests/helpers/process-seam.cjs, #3055): runNode / runGit / runHook, each returning one discriminated union { outcome, exitCode, stdout, stderr, timedOut, signal, killed, code } where outcome is the frozen OUTCOME enum (EXITED / KILLED / TIMED_OUT / BUFFER_OVERFLOW / SPAWN_FAILED). Every call is timeout-bounded — there is no unbounded code path — and nothing throws for a child's exit code, kill, timeout, buffer overflow, or spawn failure; all five are data. KILLED is a child terminated by a signal the seam did not send (a genuine OOM kill): spawnSync reports no error for that case, so it must be distinguished from EXITED, and the runGsdTools adapter retries it exactly as the pre-seam isKilled() did. This is what makes timedOut and signal assertable, so a fail-open guard's degraded verdict can be tested instead of merely observing that the call did not throw. Discrimination order is forced by runtime behavior: a timeout and a maxBuffer overflow are identical on both status (null) and signal (SIGTERM) and differ only by code (ETIMEDOUT vs ENOBUFS), so overflow is classified first — but that ordering assumes status === null, which is checked ahead of it: at the exact timeout boundary spawnSync can report error.code === 'ETIMEDOUT' on a result that ALSO carries a real status (the child finished on its own just as the timer fired), so status !== null is classified EXITED before any error-code branch runs, keeping exitCode coherent with the reported outcome. Per-suite wrappers remain and bind fixtures (cwd, env, payload); only the spawn body delegates here. Deliberately not a fault-injection surface — it cannot distinguish an injected timeout from a genuine bench OOM and would retry it; injection is in-process via deps (#3056). runGsdTools is an adapter over it that preserves its own legacy { success, output, error, exitCode } shape and retry-once-on-kill behavior.
Exit Code Registry Module
Generated central allocator for this repo's process exit codes (ADR-3889, epic #3889 — "nothing fails with success"; Phase 1, #3905). Declaration source of truth: gsd-core/bin/shared/exit-codes.json (append-only entries: code, name, meaning, owner, authorizedBy); generated artifact gsd-core/bin/lib/exit-code-registry.cjs (EXIT_CODES, exitCodeFor(name), nameForExitCode(code)) is produced by scripts/gen-exit-code-registry.cjs --write and byte-guarded by npm run lint:generated-sync — the same declaration → generator → --check gate pattern as the Capability Registry, the Model Catalog, and the ADR index. Band contract (ADR-3889 §1): 0 and 1 are free and never allocatable here; 2 is reserved to the Claude Code hook-adapter protocol (owner must be hook-adapter); 3-13 are Node-reserved; 64-78 are the generic band; 80-125 are the domain band; every other value (14-63, 79, 126+) sits outside every band and is rejected — so every registered code is non-zero by construction. The generator also enforces one-number-one-meaning and one-name-one-code across the whole declaration. This module is the ALLOCATOR only: nothing in the generator or the generated artifact emits a registered exit code itself — wiring real call sites onto the registry is later work that does not land until #3906.
Git fixture wrapper
The throw-preserving companion to the process seam (tests/helpers/git-fixture.cjs, #3143): gitOrThrow(args, options) runs runGit and returns stdout as a string on a clean exit, but throws on any other outcome. It exists because the seam deliberately never throws while execSync and execFileSync — the two forms 237 migrating call sites use — both throw on a non-zero exit. Migrating those mechanically onto runGit would convert a loud failure into a silent one: fixture setup that failed would return an empty string and surface as a baffling assertion failure further down. The thrown error carries status and exitCode as deliberate aliases (status is what the legacy execSync catch idiom reads, e.g. tests/worktree-safety.test.cjs:1361), plus stdout, stderr, signal, timedOut and outcome. Use runGit when every outcome is data you branch on; use gitOrThrow for fixture setup that must abort loudly. The seam module is not modified to add this — a throwing export would falsify the never-throws contract stated in its own header and in the ### Process seam entry above. The module also exports throwIfFailed(result, displayName), the single implementation of that throw shape: gitOrThrow itself is throwIfFailed specialized to runGit, so it routes through the same code path and the two cannot drift apart. Per-suite wrappers driving non-git targets — a node CLI via runNode, a bash snippet via runHook — call throwIfFailed directly rather than hand-rolling their own copy of this shape, which is exactly how five call sites had drifted from each other before this module exported it (#3144). It also exports toLegacyResult(result), the non-throwing counterpart: a bare mapping onto the legacy { status, stdout, stderr } shape (status aliasing the seam's exitCode) for call sites that already branch on exit status as data rather than wanting a throw — ~8 test files hand-rolled that identical three-line mapping before this module exported it too (#3147). Callers needing an extra field beyond that shape (e.g. a parsed-JSON body, a fixture-specific path) compose it — { ...toLegacyResult(result), extra } — rather than folding the extra behavior into the shared helper.
Unbounded-spawn guard
The lint rule enforcing DEFECT.UNBOUNDED-SUBPROCESS across the test suite (eslint-rules/no-unbounded-spawn.cjs, #3143, wired into the tests/**/*.cjs block of eslint.config.mjs). Flags spawnSync / execFileSync / execSync whose options carry no usable timeout. It resolves renamed destructures (const { execSync: exec } = require('node:child_process')) and chained requires (require('node:child_process').execSync(...)) rather than matching literal callee names — both forms exist in the suite today and a name-only matcher leaves them permanently invisible. It resolves an options object held in a single-write const, which is what keeps process-seam.cjs — the bounded reference implementation — from flagging itself. Two values are rejected as nominally bounded: timeout: 0 (Node reads zero as no timeout) and anything above the 600000 ms ceiling (effectively unbounded); a non-literal value is trusted, since the target shape is one named constant with a comment. #3148 (the terminal wave of epic #3064) deleted no-unbounded-spawn.allowlist.json and its wiring from eslint.config.mjs once the migration reached zero remaining violations — the rule now runs with no exemption surface across tests/**; there is no allowlist to add a file to. The one companion guard that survives (tests/no-unbounded-spawn-allowlist.test.cjs) asserts that no eslint-disable naming this rule exists anywhere under tests/ — with the allowlist gone, that inline disable is the only remaining way to silence the rule, so this guard is now the sole remaining defense. The only sanctioned escapes are an explicit timeout on a raw spawn (for a call shape the process seam cannot express, e.g. a shell: true invocation for npm.cmd on Windows, or stdio redirection to a real fd) and the ceiling-escape marker below. #3145 added an auditable ceiling escape: a literal timeout over the 600000 ms ceiling is permitted, without timeoutTooLarge, only when the call carries an inline // allow-spawn-timeout-ceiling: <reason> marker comment (the // allow-test-rule: <reason> idiom) with a non-empty reason, on the line immediately above the call or anywhere inside the call's own source range. It binds to that call only — a marker on one call never suppresses a different over-ceiling call elsewhere in the file — and it is deliberately narrow: it raises the ceiling for a call that already has a resolvable numeric timeout, it never waives the requirement for a bound, so a marked call with no timeout at all still reports unboundedSpawn. The real load-tested exception this exists for is fragment-single-edit-propagation.install.test.cjs's timeout: 900000 on npm run regen:derived (a full build plus eight generators), where 300000 was observed killing a genuinely-completed run near the end on a loaded bench.
Deterministic scheduler
Test-execution model in which all timing and concurrency outcomes are fully controlled by the test (via clock seam, explicit await ordering, or synchronous stepping) rather than by the OS thread scheduler. Opposed to real-race tests, which are non-deterministic on loaded CI runners.
Property-based test
A test that generates many adversarial inputs automatically (via fast-check) and asserts that a stated invariant holds for all of them, rather than asserting on a fixed set of hand-chosen examples. Invariant categories used in this codebase: round-trip, monotonicity, boundary containment, idempotency. See RULESET.TESTS.property-based-testing.
Mutation testing / mutation score
Stryker injects small code mutations (e.g., flipping a > to >=, deleting a return statement) and reruns the test suite for each. A mutation is "killed" if at least one test fails; "surviving" if all tests pass despite the mutation. Mutation score = killed / total. Score below 80 % on the changed scope blocks PR merge. See RULESET.TESTS.mutation-score.
ESLint harness
The canonical lint infrastructure adopted in ADR 452 (docs/adr/452-eslint-lint-harness.md): ESLint flat config (eslint.config.mjs) with typescript-eslint, eslint-plugin-n, eslint-plugin-no-only-tests, and a local AST-rule plugin at eslint-rules/. Replaces the homegrown scripts/lint-*.cjs regex scanners. All three custom test-rigor rules (local/no-source-grep, local/no-magic-sleep-in-tests, local/no-elapsed-assertion) now ship at error (promoted by #3313 and #3331 respectively, superseding the original #453 cleanup-sweep handoff).
External-job-waiting half-state
A legal deferred state of an Execute step (external_job_waiting): the executor has dispatched a long-running async external job and committed an async-job manifest at .planning/async-jobs/<job>.json instead of a SUMMARY.md. Distinct from the synchronous "mid-production-commits" half-state and from an illegal partial-plan state. The core loop's step-completion + safe-resume/pause contract treats a non-terminal manifest as legal and reconciles against it (never re-dispatching the plan, which would duplicate the external job); SUMMARY.md is deferred until the job reaches a terminal state and its expected_artifacts are verified. The manifest is a versioned stability contract (docs/reference/planning-artifacts.md); core consumes it while a default-off scheduler-adapter Capability (#1164) produces it at execute:wave:post — the contract-is-core / producer-is-capability seam mirrors ADR-857's verification-substrate decision. Status enum is closed and scheduler-agnostic: submitted, running, completed-unverified, failed, cancelled, timeout.
External-job Capability
The producer half of the async external-job contract (#1164, part of #1105). Default-off Capability (capabilities/external-job/capability.json) that writes .planning/async-jobs/<job>.json manifests — the only thing that does; core never writes them. SLURM is the first backend (sbatch --parsable submit, squeue poll with sacct fallback, terminal-state mapping); the design stays scheduler-pluggable via the backend field (LSF/PBS/Kubernetes batch forward-declared, not built). Contributions inject at execute:wave:post into the executor (classify runtime budget → externalize long_compute, commit manifest + handoff, return external_job_waiting, defer SUMMARY.md) and at plan:post into the planner (emit <runtime_budget> quick|medium|unknown|long_compute per task). Activation key external_job.enabled (default false); sibling keys external_job.backend, external_job.artifact_dir (default Artifacts/jobs, per-job dirs — no fixed log paths, no hardcoded cluster/partition/account), external_job.submit_timeout_ms / external_job.poll_timeout_ms (bounded subprocesses per CLAUDE.md). Pure producer logic — SLURM state→manifest-status mapping, manifest build/validate, sbatch/squeue/sacct parsers, and the fail-closed manifest writer (refuses a second non-terminal job for a plan_id already in flight; refuses to clobber a malformed manifest) — lives in gsd-core/src/external-job.cts (generated to gsd-core/bin/lib/external-job.cjs); the operator CLI surface is scripts/slurm-adapter.cjs (submit/poll/show). Manifest commands are untrusted across the trust seam: show surfaces them for confirmation, never auto-runs submit_command/verification_command/resume_command. Test seam: tests/external-job.test.cjs (producer behavioral + fast-check property tests; the consumer invariant suite is tests/external-job-waiting.test.cjs).
Live-DOM UAT Capability
Default-off Capability (capabilities/live-dom-uat/capability.json, role: feature, tier: full, runtimeCompat.supported: ["*"], activationKey: workflow.live_dom_uat) that confines browser MCP reach to ONE purpose-built agent (#2856). Owns the boolean key workflow.live_dom_uat (default false), the agent gsd-dom-verifier (tools: carries mcp__chrome-devtools__* + mcp__claude-in-chrome__*, and deliberately NO Bash and NO mcp__playwright__*), and one step hook at execute:wave:post (ref.agent, fragment: fragments/execute-wave-post.md, produces: DOM-VERIFY.md, consumes: PLAN.md, when: workflow.live_dom_uat, onError: skip) — additive per RULESET.CAPABILITY.step-additive-gate-blocks; gates: [], it can never halt a wave. agents/gsd-executor.md is NOT widened, in any configuration — the shape the report proposed and triage refused: for a first-party agent the static tools: list is the only control that exists (ADR-1244 D2 forbids a capability granting tools to one; ADR-857 D4's contribution injects prose, never permissions; there is no per-dispatch tool override), and ADR-1244 D5's "there is no sandbox" governs installed capabilities, not what a spawned subagent does with a granted tool. Containment is TWO independent fail-closed gates: isCapabilityActive renders a hook only on state.active === true (an installed-but-config-disabled capability renders nothing), plus the step's own when. Tool presence alone never activates it — a browser MCP configured for unrelated work is the exact case the default-off key exists for. The orchestrator half extends gsd-core/workflows/verify-work/steps/automated-ui-verification.md with a <!-- gsd:live-dom-families --> block naming both new families AND the key; the pre-existing mcp__playwright__* branch keeps its prior gating (presence + state:ui-phase-active) and stays OUTSIDE that block — pulling it behind a default-off key would silently remove working behavior from every current Playwright-MCP user on upgrade (Hyrum). The glob list is a SHARED CONSTANT across two surfaces (agent frontmatter + workflow block), so tests/live-dom-uat.test.cjs carries a parity assertion per DEFECT.GENERATIVE-FIX-DIVERGENCE. Landing it also closed a host gap: execute:wave:post dispatched only contribution + gate, so ANY registered step was declared and silently never run — execute-phase.md step 5.75 now dispatches every kind == "step" per gsd-core/references/loop-hook-dispatch.md, the same single-kind hand-roll that document names. Browser-profile lock is tolerated, never coordinated: chrome-devtools-mcp holds an exclusive lock on $HOME/.cache/chrome-devtools-mcp/chrome-profile and --isolated is a flag on the OPERATOR's own MCP-server registration that GSD neither launches nor parameterizes, so the verifier reports could_not_look/profile_locked, names the flag, and stops — no retry, no wait, no lease manager over a resource GSD does not own. DOM-VERIFY.md frontmatter is scalars only with a closed reason enum (ok | no_criteria | no_browser_mcp | profile_locked | target_unreachable) under a closed outcome (verified | nothing_to_report | could_not_look); nothing_to_report and could_not_look are never conflated — a report claiming "no issues" that never opened a browser is the ambiguous-run-notes defect the issue was filed about. Test seam: tests/live-dom-uat.test.cjs. Docs: docs/how-to/enable-live-dom-verification.md, docs/explanation/live-dom-uat-capability.md.
Broken Windows Ledger
The enforced cross-phase defect register operationalizing GSD's no-defer discipline as a tracked artifact (#1950). Markdown file at .planning/WINDOWS.md (project-level, cross-phase) with YAML frontmatter carrying scalar counts (schema_version, open_count, waived_count, fixed_count, total_count, last_updated) for the FAST path the gate reads via jq without parsing JSON, plus a JSON code block as the AUTHORITATIVE entries source; the two cross-check and fail closed on drift. The rendered markdown table is a THIRD projection of that same source and is cross-checked the same way, but at the WRITE seam rather than the read seam (#3689): writeLedgerAtomic compares the on-disk table against renderTable(<entries parsed from the on-disk JSON>) and refuses with WINDOWS_LEDGER_TABLE_DRIFT before writing, so a hand-edited cell is never silently reverted and a table-only row is never silently erased. Deliberately NOT enforced in parseLedger: hardening the read would break windows status and the ship gate on exactly the ledgers an operator needs to inspect. Each entry: { id, kind, phase, file, line, description, status, reason, recorded_at, resolved_at }; kinds are closed (stub | todo | fixme | skipped-test | lint-warning | unmet-truth | unrun-verify | deviation); statuses are closed (open | waived | fixed). The broken-windows Capability (capabilities/broken-windows/capability.json) registers one ship:pre gate with predicate artifact-frontmatter-equals WINDOWS.md open_count == 0; federated config key workflow.windows_enforce (default false — opt-in enforcement, tracking-only by default so a project can adopt the ledger before turning the gate on). Population is best-effort and never blocks execution: agents/gsd-executor.md appends stubs/skipped-tests/unrun-verifies via gsd_run windows append after writing SUMMARY.md. Source of truth: src/broken-windows.cts → gsd-core/bin/lib/broken-windows.cjs (pure parseLedger/renderLedger/appendWindow/markWaived/markFixed + I/O cmdWindowsStatus/Append/Waive/MarkFixed); CLI surface gsd-tools windows status|append|waive|fixed. Ship gate enforcement is a capId == "broken-windows" named specialization inside gsd-core/workflows/ship.md preflight's generic kind == "gate" dispatch loop (sibling to the security specialization; #3559 made that loop generic, so every OTHER capability's ship:pre gate is now evaluated through gsd_run check predicate instead of being resolved and silently dropped, while these two keep their bespoke fail-closed reads and are each visited exactly once); it reads gsd_run windows status --raw and fails closed on a non-zero/non-numeric open_count (an unparseable ledger is itself a broken window). /gsd:progress surfaces the open+waived count. The ledger is optional and backward-compatible: a project with no .planning/WINDOWS.md reports open_count: 0 and ships cleanly, and with workflow.windows_enforce=false (the default) ship never blocks on it. Frozen REASON enum: WINDOWS_LEDGER_MISSING | WINDOWS_LEDGER_MALFORMED | WINDOWS_LEDGER_TABLE_DRIFT | WINDOWS_ID_NOT_FOUND | WINDOWS_ALREADY_RESOLVED | WINDOWS_WAIVE_REASON_EMPTY | WINDOWS_INVALID_KIND | WINDOWS_INVALID_FILE | WINDOWS_INVALID_ID | WINDOWS_APPEND_MISSING_FIELD | WINDOWS_USAGE | WINDOWS_OK — surfaced through --json-errors for typed test assertions. Test seam: tests/broken-windows.test.cjs. Origin: The Pragmatic Programmer Topic 3 (Hunt & Thomas — software transplant of Wilson & Kelling's broken-windows metaphor) plus Cunningham's debt metaphor (decay accrues interest ⇒ accounting, not just habit).
Emitted Artifact Provenance
Cross-seam principle (ADR-2719, epic #2719): a committed artifact that is a pure function of the source tree is not reviewable state — it is derived state wearing a review costume, and it must be attributable rather than pinned. Concept, not a Module: it ships nothing, so it takes no Module suffix (follows the ### Resolution Provenance precedent). Scope is the emitted-artifact family named by RULESET.EMITTED_ATTRIBUTION. The principle: every emitted path whose hash moved between next HEAD and PR HEAD must be attributable — through a declarative provenance table — to a path the pull request actually changed; unattributable deltas are a hard failure that names them rather than an anomaly a reviewer must notice inside 7,500 lines of hex. Totality is enforced, so an emitted path matching no rule fails loudly instead of passing through unattributed. The escape hatch is a commit trailer on the PR's own commits (ADR-3942, superseding ADR-2719 §3), not a committed document — two structurally distinct key spaces (separate maps, closing a latent defect where a growth key could satisfy a hash lookup by naming coincidence and vice versa): Emitted-Drift-Ack-Hash: keyed on the emitted path (always contains /), Emitted-Drift-Ack-Growth: keyed on the bare filename under gsd-core/workflows/ or agents/ — key and reason separated by — (space, em dash, space; split on the FIRST occurrence), deliberately not a flag or env var — a trailer appears in the PR's commit list ONLY when something rippled unexpectedly, so adding one IS the alarm, whereas today 100% of emitted-byte changes touch fixtures and touching them signals nothing. Read from git log $(git merge-base <base> HEAD)..HEAD — the SAME merge-base changedPaths uses via git diff base...HEAD, so the ack set and the change set cannot disagree about which commits are this PR's; a two-dot range would be a defect. "Spent" no longer exists: a merged trailer is out of range by construction, not by computation, since there is no base-side copy to compare against. An uncomputable range (shallow clone) THROWS rather than passing vacuously — zero trailers means a PR needing one fails, a false red rather than a false green. staleAcks is retained: a trailer declaring a key nothing consumed is still a hard error, and the message names WHICH key space. This design is the terminus of a chain that began because the ack was PR-lifetime data kept in permanent shared state: the single legacy tests/emitted-drift-ack.json was a guaranteed merge-conflict cell (#2789; 5 of 6 conflicting PRs in one open queue collided on it and nothing else); #2914 split it into per-PR fragments under tests/emitted-drift-acks/, ending the FILE conflict but not the KEY conflict, since two sources could never name the same path; #3078 found a fully-spent fragment left on next still walled off every path it owned until swept; #3842 and #3823 tried automated and hand-authored sweeps and each created the next conflict; #3875's timed sweeper automated the remedy but could not merge its own PRs. ADR-3942 ends the chain by moving the ack off the tree entirely (see RULESET.EMITTED_ATTRIBUTION). The same differential machine carries the size ratchet: growth is reported with exact byte deltas and needs the same acknowledgment, so anti-creep survives without pinning a number. Distinguish from the absolute check that remains: tests/fixtures/install-tree/*.json stays committed and normally-merged (ADR-2719 §7) because "the installer stopped shipping X" must fail with no attribution reasoning involved. Delivery was phased — #2721 naming + interim merge relief, #2722 provenance table + totality guard, #2723 differential check dual-run beside golden-install-parity.test.cjs, #2724 cutover (COMPLETE: the dual-run window observed agreement on real PRs after fixing #2750/#2760, and the golden fixtures/test/generator/merge-driver bridge are now deleted; the differential is the sole gate). The table LANDED in #2722 as tests/helpers/emitted-provenance.cjs (19 rules, guarded by tests/emitted-provenance.test.cjs); it maps emitted path → repo source and is TOTAL over EVERY emitted path in all 19 manifests — exactly one rule per path, with zero-match, two-match, AND dead-rule (a rule matching nothing) all hard failures, so table rot is loud in both directions. Deliberately NO path/family counts are recorded here: those move with every shipped-content edit, and a hand-maintained number in glossary canon is the exact silent-drift failure this whole seam exists to end. The guard recomputes them from the fixtures on every run — read them from a failure message, never from prose. What IS stable is the rule count, which changes only when a new emitted family or host appears. Note the surface is materially wider than #2722 estimated from claude.json alone (its "13 families / 15-20 rules" was a single-runtime sample; the 19-manifest surface spans runtime-specific roots — .agents/, .kimi/hooks/, command/, agents/subagents/, .clinerules/, plugins/, extensions/, .gsd/, the hermes skills/gsd/ category and the #69 nested skills/<router>/skills/<child>/ layout). Two design invariants carry forward to #2723: emitted SHAPES are hard-coded (deriving them from the installer would make the guard tautological — it would follow any installer change silently), while source PATHS may read a first-party descriptor where that descriptor is the sole declaration (hostBehaviors.nativePlugin.source); and attribution is keyed on (rel, runtime), never rel alone, because one emitted path has different sources per host (plugins/gsd-core.js ← .opencode/ vs .kilo/). Emitted skills attribute to commands/gsd/*.md, NEVER the repo skills/ dir — that dir is itself generated from commands/gsd by scripts/gen-plugin-skills.cjs, so attributing to it is false attribution that still passes totality. Totality does NOT catch a rule pointing at the WRONG source (the recorded residual); the guard against that is the companion assertion that every attributed source EXISTS in the repo, which caught three real cases while the table was built (Copilot's <name>.agent.md rename, Kimi's code-literal agents/gsd.{yaml,md} root agent, and Copilot's hooks/gsd-session.json). A sources entry ending in / is a PREFIX, not a file — and prefix matching is SEGMENT-AWARE, so a source of agents/ must not attribute agentsfoo/x.md. The differential check LANDED in #2723 as tests/helpers/emitted-diff.cjs (the conservation law, a PURE function — no fs/git/installer/clock) + tests/helpers/emitted-baseline.cjs (baseline resolution), guarded by tests/emitted-attribution.test.cjs. It ran DUAL beside golden-install-parity.test.cjs through the #2723 dual-run window with both green and fixtures untouched; #2724 deleted the golden fixtures/test/generator and the check is now the sole gate. Purity is deliberate and load-bearing: the naive one-big-integration-test shape would need ~38 installer spawns per assertion, so the four failing-first criteria would not in practice get written — which is exactly how a phase ships promised-but-not-built. Buckets are CONSERVED: every moved emitted path lands in exactly one of attributed | unattributable | acked (property-tested), and a path the provenance table cannot resolve surfaces as an ERROR rather than a silent skip. Four asymmetries worth knowing: an ADDED emitted key is a ripple too (not just modified ones); synthesized paths are exempt but code-derived ones are NOT (that is why Phase 2 refused to mark them exempt — exempt means permanently blind); SHRINKAGE needs no ack while growth does (gating shrinkage would punish what the ratchet wants); and a STALE ack — a declared key nothing consumed — is a hard failure (#2789 built spent/live detection against a base-relative document that persisted after merge, requiring readAckFileAtRef/baseAck/spentAcks and a git show-vs-ls-tree absence check to tell "never explained anything" from "already merged, and reddening next and every branching PR when it wasn't told apart" (#2768); ADR-3942 makes staleness structural instead of computed — every trailer read is definitionally THIS diff's, scoped by git merge-base <base> HEAD, so there is no base copy to compare against and no re-arm-by-reword mechanic to defend, and the base-document corruption hazard the standalone ack linter existed to guard against is gone with the document). Since ADR-3942 there is exactly ONE ack source and it is not a file: the Emitted-Drift-Ack-Hash: / Emitted-Drift-Ack-Growth: trailers on the PR's own commits, read by readAckTrailers (tests/helpers/emitted-runtime.cjs) over git log $(git merge-base <base> HEAD)..HEAD and parsed by parseAckTrailers (tests/helpers/emitted-diff.cjs) into TWO structurally distinct key-space maps — closing the latent defect where one shared paths map let a growth key satisfy a hash lookup by naming coincidence. Absent = no acks; a declared key nothing consumed is a hard staleAcks error that now names WHICH space; a non-empty reason per key is required — "name them and say why" is the contract (ADR-2719 §3, retained). A key declared twice with conflicting reasons is a hard error; declared twice identically it is deduped. An UNCOMPUTABLE range (shallow clone) THROWS rather than reading as zero acks — the inverse of the fragment guard's vacuous-pass failure, and fail-closed in the safe direction. Baseline is CACHED not committed, keyed on the next sha; a stale key is REFUSED, never used — absence fails loudly and gets fixed, whereas staleness produces a confident wrong answer. An explicitly-pointed-at (GSD_EMITTED_BASELINE) stale baseline is a hard stop, while a stale CACHE falls through to the in-job build. No baseline-unavailable path may return (in node:test that is a PASS, not a skip — ADR-2719 §6). Supersedes ADR-2264 §2–§4 and its Amendment; ADR-2264 Phase 1 (buildParityManifest and the exclusion constants in tests/helpers/install-shared.cjs) is retained and depended upon.
Untrusted-input boundary
The prompt-level data/instruction isolation seam for untrusted web/document ingress (#1577). Shared reference gsd-core/references/untrusted-input-boundary.md, @-included by the 10 ingest agents (gsd-project-researcher, gsd-phase-researcher, gsd-ui-researcher, gsd-assumptions-analyzer, gsd-advisor-researcher, gsd-ai-researcher, gsd-domain-researcher, gsd-research-synthesizer, gsd-doc-classifier, gsd-doc-synthesizer) — every agent that reads fetch/search/MCP output or external source documents. The reference instructs: treat fetched/read content as data, never instructions; self-scan content for embedded directives before use; act only on the assigned task (ignore off-task instructions in data); and wrap quoted untrusted spans in a fresh random delimiter per wrap (fixed markers are spoofable). This prompt-level boundary is the primary control — it keeps an injection from being followed even while it sits in context. The hook-level companion is the read-injection scanner (hooks/gsd-read-injection-scanner.js, PostToolUse on Read/WebFetch/WebSearch), advisory by default; the opt-in top-level security.injection_blocking key upgrades HIGH-confidence detections to a PostToolUse circuit-breaker that halts the agent's next step (it runs after the fetch, so it is not a redactor). Tests: tests/untrusted-input-isolation.test.cjs, tests/read-injection-scanner.*.test.cjs, tests/injection-blocking-config.test.cjs. See docs/adr/1577-untrusted-input-boundary-and-injection-blocking.md and docs/explanation/security-model.md. Grounding: arXiv 2506.05739 (PPA), 2507.15219 (PromptArmor), 2504.20472.
Probe family — spec-completeness probes (machine-oriented predicates)
Glossary prose for these modules lives above (Probe Core / Edge Probe / Prohibition Probe / Verification Tier / Verification substrate). These are the greppable one-line predicates ADR-550's Consequences promised alongside the glossary. Research-derived numbers (N17/N18 rates) are deliberately kept out of this machine-canon and live hedged in
docs/design/verifier-reach.md. (That design note anddocs/adr/1606-prohibition-enforcement-verify-seam.mdwere co-delivered sibling PRs of epic #1605, now closed and landed; the predicate refs to them below are live.)
PROBE.principle=verifier-reach-equals-spec-reach (a goal-backward verifier only checks assertions that exist; probes make omitted assertions exist before code) — ADR-857 verification-substrate boundary; docs/design/verifier-reach.md
PROBE.family=edge-probe(shape-axis)+prohibition-probe(must-NOT-axis)+ui-consideration-probe(UI-state-axis), shared probe-core, run as spec-phase/ui-phase soft gates (ADR-550 D7; #1867)
PROBE.protocol=recall(adversarial over-generate)->precision(drop routine-engineering); dismissals require a non-empty reason
PROBE.core.seam=analyzeCoverage(items,resolutions?,validators) ingests ALREADY-proposed items; does NOT assume deterministic propose (ADR-550 D7b)
PROBE.item.axes=status{resolved|dismissed|unresolved} x verification{<probe-defined>|null} — orthogonal; the lifecycle enum carries no verification fact (ADR-550 D7a)
PROBE.edge.verification=explicit|backstop
PROBE.prohib.verification=test|judgment
PROBE.ui.verification=explicit|backstop
PROBE.ui.axis=MIXED — closed compiled shape-rooted 8 (empty/loading/error/populated/partial/overflow/zero-one-many/long-text) via ui-consideration-probe adapter; open UX (real-time/a11y/i18n-RTL) prose-owned in references/domain-probes.md, NOT compiled (#1867)
PROBE.ui.seam=ui-phase Step 9.5 post-verification: element-cue classify -> propose-then-confirm (partial-cue mitigation, Goodhart) -> autoResolve --auto floor (never dismiss; unclassified stays unresolved #1110) -> ## UI Considerations write-back -> plan-phase ## UI Considerations lift rule (#1867)
PROBE.ci.surface=the contract (parse/validate, projection round-trip, fail-closed guards), NEVER the LLM judgment (ADR-550 D5)
PROHIB.recall=LLM-prose; no compiled prohibition-probe recall engine (only the schema/projection layer is code, ADR-550 D7b)
PROHIB.canon-referral=OWASP/GDPR/fairness-canon are REFERRED to /gsd:secure-phase+eslint, never minted as prohibitions (ADR-550 D6)
PROHIB.enforce.green-rule=passed iff provenFailFirst===true && run.passed===true (runProhibitionEnforcement); every miss/fail/un-provable HARD-GATES both modes via dispositionForProhibition's fail-closed default
PROHIB.enforce.kinds=node-test (non-vacuous red via isNonVacuousNodeTestRed; pass-side vacuity via isNonVacuousNodeTestPass) | lint-rule (eslint --format json filtered by ruleId)
PROHIB.enforce.failfirst=MACHINE-PROVEN against an author-supplied violation fixture (#1279); caller failFirst attestation DEMOTED to a non-authoritative hint (FF-08)
PROHIB.enforce.causation=clean-fixture control proves the red is content-caused not env-var-set; MANDATORY for node-test (#1906 supersedes #1346 opt-in) — absent clean-fixture ⇒ node-test un-provable/fail-closed; lint-rule needs none (its subject IS the linted file)
PROHIB.descriptor.shape=5 FLAT scalars (check_kind,check_target,check_rule,check_violation_fixture,check_clean_fixture) — NEVER a nested check:{} (parseMustHavesBlock is a flat parser, src/frontmatter.cts)
PROHIB.rail=core verify rail, non-toggleable (ADR-857 verification-substrate boundary / decision #6); the verifier<->predicate contract is NOT an off-by-default capability
PROHIB.judgment-tier=never-silent / never-hard-halt soft gate; autonomous emits "unverified-prohibition — human review recommended" (exogenous grading, ADR-550 D4)
PROHIB.enforce.adr=docs/adr/1606-prohibition-enforcement-verify-seam.md (verify-time enforcement seam) + docs/adr/550-spec-phase-probe-contract.md (spec-phase contract)
Test rules and lint
RULESET.TESTS.no-source-grep=local/no-source-grep ESLint AST rule (eslint-rules/no-source-grep.cjs) rejects readFileSync of a source .cjs/.js/.ts path bound to a var later hit with .includes()/.match()/.startsWith()/.endsWith()/.indexOf()/.search(); error in tests/**/*.test.cjs, warn in gsd-core/bin/**/*.cjs + scripts/**/*.cjs (ADR 452 retired the old regex script, removed for good in #632)
RULESET.TESTS.no-source-grep.exemption=// allow-test-rule: <runtime-contract-is-the-product> with one-line justification; reserved for tests where the file content IS the product surface (STATE.md, config.toml, hooks.json, agent .md). Migration to typed-IR parser tracked in #2974.
RULESET.TESTS.no-source-grep.tmp-file-traps=reading tmp files written by the SUT in tests still trips lint; round-trip through CLI (e.g. frontmatter get) instead of readFileSync+.includes()
RULESET.TESTS.no-duplicate-fold-marker=local/no-duplicate-fold-marker ESLint AST rule (eslint-rules/no-duplicate-fold-marker.cjs, #3271) reports the 2nd and every later __foldDescribe("folded:<marker> ...") call carrying a marker already seen in the SAME file, naming the first occurrence's line; error in tests/**/*.cjs. The key is the WHITESPACE-delimited token after folded:, NOT a [a-z0-9-]* slice — a slice truncates at "." and collides feat-443-effort-fast-mode.integration with feat-443-effort-fast-mode (two distinct suites coexisting in tests/model-resolver.test.cjs), and NOT the whole title, so a re-fold under a different batch label ("B1 #1970" vs "B5 #1975") is still caught. Deliberately silent on: a __foldDescribe title with no folded: prefix (the alias is reused for one ordinary describe in tests/review-default-reviewers-workflow.test.cjs), a plain describe(), a non-literal title, and the same marker in two DIFFERENT files (the defect class is intra-file).
RULESET.TESTS.no-duplicate-fold-marker.why=consolidation epic #1969 folds are self-contained blocks, so a second verbatim copy parses, registers and PASSES twice — nothing reports it; #3271 found 25 such copies (~5,800 lines) in tests/install.test.cjs (18), tests/install-minimal-hooks.test.cjs (5) and tests/install-write-confinement.test.cjs (2), all from one stale-base re-application in 6d072435d (#1975 re-applying #1970's hunks, 2026-07-03). Ref DEFECT.GENERATIVE-FIX: the two copies drift apart silently when a contributor fixes one and leaves the other asserting the old behavior, with the suite still green.
RULESET.TESTS.escape-regex=new RegExp("prefix${var}") must escapeRegex(var); phase-id.cjs exports escapeRegex (core.cjs re-export spine retired in epic #1267); phase IDs like 5.1 contain . which is metacharacter
RULESET.TESTS.no-dead-regex-in-includes=src.includes("foo.*bar") is always false — .* is regex metacharacter not wildcard; use new RegExp(...).test(src) or delete
RULESET.TESTS.guard-toplevel-readFileSync=module-level const src = readFileSync(...) throws before any test() registers — wrap in try/catch in test() or use lazy load
RULESET.TESTS.coderabbit-fix-prefer=behavioral tests (call exported fn, capture JSON, assert typed fields) over source-grep
RULESET.TESTS.diagnostics=after JSON.parse, assert output shape (Array.isArray(output.phases)) with raw-output-prefix diagnostics before .map() — prevents opaque TypeErrors when CLI output shape changes
RULESET.TESTS.boundary-coverage=tests MUST exercise inputs at and near the threshold/limit, not only trivial-fit and trivial-overflow; pick inputs where N ∈ {limit-1, limit, limit+1} and where pre-trim/pre-check accumulators ≈ effective limit; "very small" and "very large" inputs alone do not constitute edge-case coverage and routinely miss off-by-one + reservation-accounting bugs
RULESET.TESTS.feedback-loop-convergence=when a feature's OUTPUT feeds back into its own INPUT (calibration, retry backoff, adaptive budgets, ratchets, any self-correcting signal), step-wise tests are NOT sufficient evidence of correctness: they assert given X return Ywhile the defect lives in the TRAJECTORY across iterations. Required: a closed-loop test that (a) drives the REAL end-to-end surface — not the pure core alone, since composition bugs live between surfaces — for N >= 2x the loop's window, (b) asserts convergence on the known-true value, (c) asserts the fixed point (an already-correct history must produce NO correction), and (d) asserts boundedness under an adversarial/oscillating history. Two defects shipped past a green ~26,800-test suite in epic #1952 for want of exactly this: calibration applied twice across two surfaces (factor^2, #2631) and calibration measured against its own corrected output so it oscillated to ~1.41 instead of converging on 2.0 (#2632). Every unit, boundary, property and round-trip test passed for both. HOW TO SPOT ONE (the detection tell, not a judgment call): the feature's own acceptance criterion carries a TEMPORAL QUANTIFIER — "after N phases", "subsequent", "over time", "improves", "learns", "adapts". That phrasing means the claim is about a TRAJECTORY, so a step-wisegiven X return Y test does not test the claim that was made. #1952's AC4 read "After N phases, the error is computed and applied as a correction to SUBSEQUENT estimates" — the tell was in plain sight and was still tested as a point. Survey of this repo (2026-07): estimation calibration is the ONLY true instance; size/mutation ratchets are exempt because they fail on both growth AND shrinkage (cannot self-satisfy), and retry ladders (node_repair_budget, plan_bounce_passes, provider_escalation) terminate rather than feed back. Test anchor: tests/estimate-loop-convergence.test.cjs
RULESET.TESTS.boundary-coverage.fixtures=for any code with budget/limit/quota/threshold parameter, test suite MUST include: (a) input where SUT estimate == limit exactly, (b) input where estimate == limit - 1, (c) input where estimate == limit + 1, (d) input where any internal reserve/safety constant pushes baseline within reserve-distance of limit (catches early-pressure firing)
RULESET.TESTS.boundary-coverage.anti-pattern=test suites that pair budget:1_000_000 (trivially fits) with budget:1 (trivially overflows) and skip the boundary region; failure mode that shipped PR #3708 UNNEEDED_TRIM + FALSE_HARDFAIL regressions (commit 2df566ed, fixed bde1ae8f)
LEARNING.prompt-budget.boundary-gap=PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures
RULESET.TESTS.no-timing-assertion=do not assert on wall-clock elapsed time (Date.now() delta, performance.now(), process.hrtime() comparison); such assertions test the host machine not the SUT and flake on loaded CI runners; enforcement: local/no-elapsed-assertion ESLint rule, error (promoted by #3331 once #3314 delivered the ADR-456 §(a) reachability rule + deterministic backfill precondition); canonical replacement: clock-seam pattern with node:test mock.timers
RULESET.TESTS.clock-seam=concurrency logic must accept an optional {clock=Date} parameter; tests control time via t.mock.timers.enable(['Date']) + t.mock.timers.setTime(0) + t.mock.timers.tick(N); real OS scheduler races are not a permitted test pattern after ADR 456 (2026-05-28); real-race tests are deleted once deterministic seam tests cover the same logical path; clock.cjs realClock adds nowIso() (→ new Date(this.now()).toISOString()) and today() (→ nowIso().split('T')[0]) so all date-stamping in state.cjs routes through the seam; subprocess time-pin adapter: set GSD_TEST_MODE=1 + GSD_NOW_MS=<epoch-ms> in runGsdTools env to pin the date written by the SUT without touching real wall-clock (issue #474)
RULESET.TESTS.property-based-testing=modules implementing parsing / transformation / budget-limit / bijective contracts must include at least one fast-check (fc) property test asserting a domain invariant; invariant categories: round-trip, monotonicity, boundary-containment, idempotency; property tests live in *.test.cjs alongside unit tests; CI signal: Stryker mutation score below 80% blocks merge
RULESET.TESTS.mutation-score=Stryker runs incremental (--since origin/next) on ubuntu-latest/Node24 CI leg; default threshold 80% killed/total; surviving mutants in scope block merge unless path is listed in stryker.config.mjs with documented reason; treat surviving mutant as a failing test specification
RULESET.TESTS.mutation-runner=Stryker executes every shard through the OFFICIAL @stryker-mutator/tap-runner (testRunner:'tap'), never the built-in 'command' runner (#3915); 'command' is the one runner Stryker excludes from coverage analysis, which forced coverageAnalysis:'off' and made cost strictly linear in (mutants x whole-shard test time) — the frontmatter shard measured 1751s on run 33021042847 vs 212s for the next slowest. tap.testFiles is injected per shard via MUTATION_TEST_FILES (mutation.yml env <- matrix.tests <- scripts/mutation-matrix.cjs buildResult); resolveMutationTestFiles is the SINGLE fail-closed reader and existence-checks every entry, because the tap runner's findTestyLookingFiles resolves the list with glob() and a non-matching pattern yields an EMPTY list SILENTLY (a fast, confident, meaningless run). tap.forceBail is FALSE by measurement, not preference: 3 of 26 shard test files spawn subprocesses (config-schema.property, core-utils, feat-3881-yaml-parser-consequences) and bail fires on every KILLED mutant, so leaving it on kills processes mid-spawnSync and orphans their children; Stryker's separate disableBail still skips remaining FILES, which is most of the win. tap.nodeArgs and top-level buildCommand stay UNSET so no rebuild lands between mutation and test (ADR-457). Coverage granularity is per FILE, not per test ("a test is always a test file"), so the #2790 excludeTests bans on spawn-heavy integration files remain necessary and unchanged
RULESET.TESTS.mutation-score-denominator=the gated number is mutation-testing-metrics' mutationScore = totalDetected/totalValid, which counts NoCoverage in the denominator EXACTLY as Survived; both Stryker's own thresholds.break (core dist/src/reporters/mutation-test-report-helper.js) and scripts/check-mutation-score-ratchet.cjs read THAT field, which is what makes the #3915 coverageAnalysis 'off'->'perTest' switch score-neutral. NEVER gate on mutationScoreBasedOnCoveredCode — it EXCLUDES NoCoverage and inflates sharply under perTest (measured on a synthetic report: 8 killed/2 survived = 80 and 80; 8 killed/2 noCoverage = 80 and 100), so swapping to the better-sounding field would make every minScore floor trivially satisfiable and the gate decorative. Under the pre-#3915 coverageAnalysis:'off' the two fields were ALWAYS identical (noCoverage was structurally 0), which is why nothing had ever pinned the choice; tests/mutation-score-ratchet.test.cjs now pins it with a non-vacuity assertion that the two numbers genuinely diverge
RULESET.TESTS.delete-bad-tests=pass-always / vacuous-truth / source-grep / elapsed-time / real-race / permanent-allow-test-rule tests are DELETED and replaced with compliant tests in the same PR; not skipped, not commented out, not permanently exempted; replacement must cover the same logical path via typed-surface assertion or clock-seam pattern
RULESET.TESTS.eslint-harness=ADR 452 (2026-05-28): ESLint flat config + typescript-eslint + eslint-plugin-n + eslint-plugin-no-only-tests + local plugin at eslint-rules/ (repo root, NOT scripts/eslint-rules/); replaces scripts/lint-*.cjs regex scanners (fully removed in #632); all three test-rigor rules now ship at error in tests/**/*.test.cjs scope: local/no-source-grep and local/no-magic-sleep-in-tests promoted by #3313, local/no-elapsed-assertion promoted by #3331 once #3314 delivered its ADR-456 §(a) precondition (epic #1885 was subsumed into epic #3053 and closed stale before this promotion landed)
RULESET.AUDIT.search-source-not-generated=verify an invariant/validation EXISTS by searching the AUTHORED source (src/*.cts OR the scripts/gen-*.cjs generator), never the generated bin/lib/*.cjs (gitignored, ADR-457); gen-time checks live in gen-*.cjs not the .cts it consumes → search BOTH before declaring absent; read generated .cjs only for output drift. Repro: grep src/*.cts for VALID_CONVERTER_NAMES → false "5e ConverterName unenforced"; actually enforced in gen-capability-registry.cjs. cf RULESET.TESTS.no-source-grep
RULESET.WORKFLOW_MARKDOWN.FENCES=preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)
RULESET.WORKFLOW_SIZE_BUDGET=workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs next, no committed snapshot, requires an Emitted-Drift-Ack-Growth:commit trailer on the PR's own commits (ADR-3942, superseding ADR-2719 §3's fragment model — key is the bare filename, reason follows — )) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, npm run size:baseline) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (NEW_FILE_CAP in tests/helpers/emitted-diff.cjs) rather than lost: "not yet baselined" is exactly "present in sizeCurrent, absent from sizeBaseline", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification
RULESET.AGENT_SIZE_BUDGET=agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same Emitted-Drift-Ack-Growth:commit trailer (ADR-3942, superseding ADR-2719 §3's fragment model) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json,npm run size:baseline) is REMOVED by #2724
RULESET.EMITTED_ATTRIBUTION=the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS "recompute" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), npm run gen:golden, UPDATE_GOLDEN, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, npm run setup:merge-driver, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (npm run size:baseline). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. npm run regen:derivedstill exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, andtests/fixtures/install-tree/.json(nownpm run gen:install-tree, folded into regen:derived). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves "the installer stopped shipping X" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by scripts/gen-emitted-baseline.cjson every push tonext(cached, keyed on sha) and restored in PR lanes viaGSD_EMITTED_BASELINE/resolveBaseline()(tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwawaygit worktree(tests/helpers/emitted-runtime.cjs'sbuildBaselineAtRef). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's "conspicuous declaration" only works if the contributor can discover how to make it. Both failing branches name the commit trailer to add — Emitted-Drift-Ack-Hash:orEmitted-Drift-Ack-Growth: (ADR-3942) — print its exact grammar ( — , key and reason split on the FIRST em dash), and repeat "do NOT regenerate anything" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT, now STRUCTURALLY DISTINCT trailer key spaces (separate maps since ADR-3942, closing a latent defect where a growth key could satisfy a hash lookup by naming coincidence and vice versa) and each says which: the hash pass keys on the EMITTED PATH (always contains a /, Emitted-Drift-Ack-Hash:), the size ratchet keys on the BARE FILENAME (Emitted-Drift-Ack-Growth:; currentSizeswritessizes[entry.name]from readdirSync overgsd-core/workflows/+agents/). A stale-ack failure additionally says to drop the trailer line (amending the commit) when removing its last entry, since a lingering unused trailer signals nothing; post-#2789 it also offers CORRECTING the reason to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the NEW_FILE_CAPbranch, whose remedy is extraction. Text is sourced from one frozenREMEDIATIONexport in tests/helpers/emitted-diff.cjs, whose example line is rendered viarenderAckTrailer (: — , ADR-3942) so the taught grammar cannot drift from what parseAckTrailers actually accepts (a round-trip test feeds the printed line back through the parser); a key that is reserved (proto/constructor/prototype) or contains <, >, or whitespace is rejected loudly, and a doc example like <emitted/path> — must never parse as a real declaration. Note the ADR's Consequences originally called the #2724 migration "terminal"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. The ack was PR-lifetime data kept in permanent, shared, merge-path state, and each fix generated the next defect until ADR-3942 moved it off the tree entirely (see### Emitted Artifact Provenance): the single shared tests/emitted-drift-ack.jsonwas a guaranteed merge-conflict cell (#2789; 5 of 6 conflicting PRs in one open queue collided on it and nothing else); #2914 replaced it with per-PR fragments undertests/emitted-drift-acks/— the.changeset/shape — ending the FILE conflict but not the KEY conflict, since two sources could never name the same path; #3078 found a fully-spent fragment left onnextstill walled off every key it owned (measured at the sweep: 45 fragments owning 403 paths, up from 13/272 at triage 19 days earlier) and added the post-merge-onlyguard-no-ack-on-nextjob plus a manual sweep; #3842's hand sweep handed three in-flight external PRs amodify/deleteconflict each; #3823's hand-authored sweep, computed at branch time against a guard that evaluates at merge time, lost the race to a fragment merged mid-flight and leftnextred for 24 consecutive pushes; #3875's timed sweeper workflow automated the remedy but could not merge its own PRs (three independent, deterministic defects — bad conventional-title match, wrong CI-lane classification, no auto-merge path). ADR-3942 ends the chain: the escape hatch is now a commit trailer scoped to the PR's own commits, so there is no shared file, no shared key namespace, and nothing to sweep — the fragment directory, the next-lane guard job, the scheduled sweep workflow and the standalone ack linter are all DELETED (named by ROLE rather than by filename on purpose: a backticked path here asserts a LIVE repo path andcheck-glossary-refs.cjsfails on one that does not exist, whilelint-removed-but-needed.cjsadditionally fails on a deleted file's bare BASENAME appearing anywhere it scans — and this predicate's generated projection lands in docs/, which it does scan. ADR-3942 carries the exact paths; it sits under docs/adr/, which that guard exempts as a historical record). cfRULESET.WORKFLOW_SIZE_BUDGET, RULESET.AGENT_SIZE_BUDGET; see ### Emitted Artifact Provenance``
RULESET.WORKFLOW_FILE_NAMES=workflow files use hyphens; <step name="..."> XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name
RULESET.WORKFLOW_EXECUTION_CONTEXT=@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \bug-3135-capture-backlog-workflow`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; "Invoked by" attribution must move when a flag absorbs a micro-skill RULESET.WORKFLOW_EXECUTE_END_TO_END=standard for single-workflow commands is "Execute end-to-end." (no bolded Follow the X workflow fragments); flag-dispatch routing uses "execute the X workflow end-to-end." in routing bullets — convention verified live across ~20 commands/gsd/.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention) RULESET.WORKFLOW.COVERAGE-METADATA=#1602 SUMMARY frontmatter coverage: block (list of {id,description,requirement?,verification:[{kind∈unit|integration|e2e|automated_ui|manual_procedural|other, ref, status∈pass|fail|unknown}],human_judgment:bool,rationale?}) is the per-deliverable RTM consumed DETERMINISTICALLY by verify-work extract_tests via gsd-tools uat classify-coverage --summary <f> (src/coverage.cts → bin/lib/coverage.cjs). AUTHORING: execute-plan create_summary populates it from task results; every deliverable MUST be classified; fail-safe default = human_judgment:true + rationale. CLASSIFY CONTRACT: auto-pass (skip human) ONLY when human_judgment===false (strict boolean) AND verification non-empty AND every status==='pass' AND zero validation errors — else PRESENT to human. mode:legacy (no block) ⇒ byte-identical prose ## Accomplishments fall-through; coverage: [] ⇒ mode:coverage, zero entries (single-confirmation). Frozen IR: MODE/PRESENT_REASON/ERROR_CODE enums locked by tests/coverage-metadata-parser.test.cjs. extractFrontmatter CANNOT parse it (scalars-only - items) → dedicated parser, sibling of parseMustHavesBlock. Asymmetry by design: false-negative=redundant prompt (status quo); false-positive=shipped bug UAT existed to catch`
RULESET.ALLOWED-TOOLS-FRONTMATTER=command's allowed-tools must cover every tool the workflow calls (including Write for file creation); thin-wrapper pattern makes this easy to miss
RULESET.ARGUMENTS-SANITIZE=any workflow step constructing .planning/.../{SLUG}.md path from user input ($ARGUMENTS, parsed remainder) must sanitize inline ([a-z0-9-] only, reject ..//\\, max-length) — "(already sanitized)" must trace back to explicit guard; RESUME/fallback modes need own guards
RULESET.SHARED-HELPERS-LINT-VS-TEST=when a lint script and test suite both implement same constant (CANONICAL_TOOLS) or parser (parseFrontmatter, executionContextRefs), extract to scripts/*-helpers.cjs required by both — silent divergence otherwise
RULESET.ADR-HEADER=every docs/adr/NNNN-*.md must open with - **Status:** Accepted|Proposed|Superseded (by [ADR-NNNN](file.md))|Legacy + - **Date:** YYYY-MM-DD immediately after title
RULESET.MANIFEST-CANONICAL-KEY=docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL EIGHT families.* arrays (agents/commands/workflows/references/cli_modules/hooks flat, plus workflow_modes/workflow_steps nested — #2996, epic #1671 Phase 6.5) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all eight, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the six flat families are keyed by BARE BASENAME while the two nested families are keyed by <workflow>/<subdir>/<file> path, deliberately, because two workflows may each own a same-named step file and a basename key would silently drop one under a JSON-equality comparison; recursion is bounded at exactly one named subdirectory, never a general walk; the family tables live ONCE in scripts/gen-inventory-manifest.cjs and are IMPORTED by the test (the test formerly redeclared them, a DEFECT.GENERATIVE-FIX divergence that let a new family be verified by nobody while still reporting green); the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write, AFTER build:lib; #3762 added the ROSTER half — tests/inventory-manifest-sync.test.cjs now also asserts every manifest entry has a hand-written row in docs/INVENTORY.md, via the pure matcher in tests/helpers/inventory-roster.cjs. Scope is the SIX FLAT families only, each searched inside its own ## section; workflow_steps/workflow_modes are DELIBERATELY exempt because docs/INVENTORY.md §"Workflow Sub-Files" is a shipped decision that they carry no hand-written per-file rows. Matching is whole-CELL-exact (never substring — the rostered host-integration-adapters/imperative-hook-bus.cjs must not satisfy the separate top-level hook-bus.cjs) and section-scoped (smart-entry.md and smart-entry.cjs are different families), EXCEPT commands, which match on the row's Source-column link to ../commands/gsd/<file>.md because the six ns-* namespace routers deliberately RENDER a name that is not their file stem (/gsd-workflow ← ns-workflow.md) — DEFECT.DISPLAY-VALUE-AS-IDENTITY. Landing the gate required backfilling 32 pre-existing unrostered surfaces on next
RULESET.PR-SCOPE.one-concern-per-pr=split unrelated changes into separate PRs; cherry-pick doc changes to dedicated docs/ branch immediately, then force-push original to remove the commit
RULESET.TRIAGE-EXISTING-WORK=before writing agent brief for confirmed bug, check (1) local branches git branch -a | grep <issue>, (2) untracked/modified files on that branch, (3) stash, (4) open PRs with matching head branch — recover existing work rather than re-implement
RULESET.CR-THREAD-RESOLVE=after adding // allow-test-rule: to silence lint, resolve existing inline CR threads via graphql resolveReviewThread mutation before merge — open threads mislead future reviewers; pattern: gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:"PRRT_..."}) { thread { isResolved } } }'
CodeRabbit + repo-process guards (machine-oriented predicates)
RULESET.CONTRIB.GATE.ORDER=issue-first -> approval-label -> code -> PR-link -> changeset/no-changelog
RULESET.CONTRIB.CLASSIFY.fix=requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)
RULESET.CONTRIB.CLASSIFY.enhancement=requires approved-enhancement before implementation
RULESET.CONTRIB.CLASSIFY.feature=requires approved-feature before implementation
Workspace seams (machine-oriented predicates)
RULESET.GH.AUTH.DEFAULT=source .envrc GITHUB_TOKEN before gh; exception=ambient allowed only when user explicitly says machine-only fallback
RULESET.CODERABBIT.GUARD.OPEN_PRS=gh pr list --repo open-gsd/gsd-core --author @me --state open; repeat near end because open PR set can change mid-run
RULESET.CODERABBIT.GUARD.COMPLETE=required_checks_green && coderabbit_check_pass && graphQL(reviewThreads.unresolved_count)==0
RULESET.CODERABBIT.GUARD.GRAPHQL=reviewThreads(first:100){nodes{id isResolved comments{nodes{author body path line originalLine url}}}}; use unresolved threads as authoritative, not badge text alone
RULESET.CODERABBIT.GUARD.RERUN=after every push wait for CodeRabbit completion, then re-query unresolved threads; CodeRabbit can add new findings after earlier threads were resolved
RULESET.CODERABBIT.GUARD.RESOLVE=fix validated finding -> focused tests -> commit/push -> resolveReviewThread(threadId) -> wait CI/CodeRabbit -> final unresolved_count query
RULESET.CODERABBIT.GUARD.SCOPE=if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete
RULESET.TESTS.CODERABBIT_FIX=prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule
CI.GATE.issue-link-required=hard-fail if PR body lacks closes/fixes/resolves #<issue>
CI.GATE.changeset-lint=hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label
CI.GATE.repair-sequence(PR)=create issue -> apply approval label -> edit PR body w/ closing keyword -> apply no-changelog if appropriate -> re-run checks
PR.3267.POSTMORTEM.root-cause=[missing issue link, missing changeset/no-changelog]
PR.3267.POSTMORTEM.recovery=[issue#3270 created, label approved-enhancement applied, PR reopened, body includes "Closes #3270", label no-changelog applied]
WORKTREE.SEAM.current=Worktree Safety Policy Module
WORKTREE.SEAM.files=[gsd-core/bin/lib/worktree-safety.cjs]
WORKTREE.SEAM.interface=[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent]
WORKTREE.SEAM.default-prune-policy=metadata_prune_only (non-destructive)
WORKTREE.SEAM.decision-1=retain non-destructive default; destructive path only as explicit future opt-in scaffold
WORKSTREAM.INVARIANT.migrate-name=must normalize through canonical slug policy
WORKSTREAM.INVARIANT.slug-contract=all .planning/workstreams/<name> must be addressable by set/get/status/complete
WORKSTREAM.REGRESSION.test-anchor=tests/workstream.test.cjs::normalizes --migrate-name to a valid workstream slug
ARCH.SKILL.improve-codebase.next-candidates=[Workstream Progress Projection Module]
WORKTREE.SEAM.test-policy=cover all decision branches in policy module before changing prune behavior
WORKTREE.SEAM.test-anchors=[resolveWorktreeContext:has_local_planning|linked_worktree|not_git_repo|main_worktree, planWorktreePrune:git_list_failed|worktrees_present|no_worktrees|parser_throw_fallback, executeWorktreePrunePlan:missing_plan|skip_passthrough|unsupported_action|metadata_prune_only]
WORKTREE.SEAM.invariant=parser failure must degrade to metadata_prune_only and never escalate to destructive removal
WORKTREE.SEAM.inventory-interface=[listLinkedWorktreePaths, inspectWorktreeHealth]
WORKTREE.SEAM.caller-rule=verify.cjs must consume inspectWorktreeHealth for W017 classification; no ad-hoc porcelain parsing in callers
WORKTREE.SEAM.test-anchor-w017=tests/orphan-worktree-detection.test.cjs + tests/worktree-safety.test.cjs
WORKTREE.SEAM.inventory-snapshot=snapshotWorktreeInventory(repoRoot,{staleAfterMs,nowMs}) is canonical linked-worktree health snapshot for callers
SEAM.worktree-safety-policy.owns=Worktree Safety Policy Module — resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, W017 classification (see WORKTREE.SEAM.* above for full interface/invariant detail)
SEAM.worktree-safety-policy.enforced-by=test:tests/worktree-safety.test.cjs
PLANNING.PATH.PARITY.project-scope=.planning/<project> (never .planning/projects/<project>); mirror planning-workspace.cjs planningDir()
PLANNING.PATH.SEAM.helpers=helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root
PLANNING.PATH.SEAM.init-handlers=[initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp] consume helpers.planningPaths().planning (no direct relPlanningPath join)
WORKSTREAM.NAME.POLICY.cjs-module=gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation
WORKSTREAM.POINTER.SEAM.cjs-module=gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream
CONFIG.SEAM.loadConfig-context=loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites
CONFIG.LOCATION.SEAM.scrub-set=tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources rather than maintained as one hand-written list (source 4 IS a literal residue list, for vars that fit no other rung — what is never hand-listed is the SET): capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal
CONFIG.LOCATION.SEAM.two-families=runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2
CONFIG.LOCATION.SEAM.kimi-two-homes=kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second
CONFIG.LOCATION.SEAM.in-process-scrub=TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST
LIVE-CONFIG.GUARD.SEAM.module=scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite
LIVE-CONFIG.GUARD.SEAM.scope=ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled
LIVE-CONFIG.GUARD.SEAM.non-root-targets=resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition. SECOND RESIDUAL: config.toml is not all GSD writes into those roots — installSharedHooksBundle also populates <root>/hooks/, which is UNWATCHED; closing it is a layout decision, like skills bases; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot
LIVE-CONFIG.GUARD.SEAM.truncation=MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing
LIVE-CONFIG.GUARD.SEAM.severity=reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1
LIVE-CONFIG.GUARD.SEAM.ci-blind=the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes
Release notes standard
RELEASE-NOTES.SCOPE=GitHub Releases body for tags vX.Y.Z, vX.Y.Z-rc.N; not CHANGELOG.md (changeset workflow owns that)
RELEASE-NOTES.DEFAULT-STATE=auto-generated body is "What's Changed" PR list + Full Changelog link; treat as draft, not final
RELEASE-NOTES.GATE.hotfix=manual edit required; auto-generated body for vX.Y.{Z>0} is "Full Changelog only" and must be replaced with structured body
RELEASE-NOTES.GATE.rc=manual edit recommended; auto-generated PR list is acceptable for early RCs but final RC before vX.Y.0 should match standard
RELEASE-NOTES.GATE.minor=auto-generated body acceptable when PR titles are clean; promote to structured body when >20 PRs or contains feature+refactor+fix mix
RELEASE-NOTES.STANDARD.taxonomy=Keep-a-Changelog 1.1.0: Added | Changed | Deprecated | Removed | Fixed | Security | Documentation
RELEASE-NOTES.STANDARD.heading-level=## for category, ### for subgroup (area), - for bullet
RELEASE-NOTES.STANDARD.bullet-shape=**Bold user-visible change** — explanation of what was broken or what's new, leading with symptom not implementation. Trailing (#NNN) PR ref.
RELEASE-NOTES.STANDARD.subgroups=phase-planning-state | workstream | query-dispatch-cli | code-review | install | capture | docs | architecture | security
RELEASE-NOTES.STANDARD.footer.hotfix=Install/upgrade: \npx @opengsd/gsd-core@latest` RELEASE-NOTES.STANDARD.footer.rc=Install for testing: `npx @opengsd/gsd-core@next` (per branch->dist-tag policy) RELEASE-NOTES.STANDARD.footer.full-changelog=Full Changelog: https://github.com/open-gsd/gsd-core/compare/... RELEASE-NOTES.STANDARD.intro=optional one-paragraph framing for RC/feature releases; omit for pure-fix hotfixes`
RELEASE-NOTES.SOURCE.commits=git log <prev-tag>..<this-tag> --pretty=format:'%s%n%n%b' --no-merges
RELEASE-NOTES.SOURCE.changesets=.changeset/*.md (frontmatter pr: + body bullets)
RELEASE-NOTES.SOURCE.pr-bodies=gh pr view <NNN> --json title,body for fixes lacking a changeset
RELEASE-NOTES.SOURCE.precedence=changeset body > commit body > PR body > commit subject (prefer authored content over auto-generated)
RELEASE-NOTES.WORKFLOW.edit=gh release edit <tag> --notes-file <path>
RELEASE-NOTES.WORKFLOW.view=gh release view <tag> --json body --jq .body
RELEASE-NOTES.WORKFLOW.token=must use .envrc GITHUB_TOKEN per RULESET.GH.AUTH.DEFAULT (this doc); never ambient gh auth
RELEASE-NOTES.WORKFLOW.idempotency=gh release edit overwrites body wholesale; safe to re-run after refining
RELEASE-NOTES.ANTI-PATTERN=raw "What's Changed" PR list as final body for hotfix or feature release; "Full Changelog only" body for tagged release with >0 user-facing fixes
RELEASE-NOTES.ANTI-PATTERN.implementation-first=do not lead bullet with file path or function name; lead with symptom/user-visible behavior
RELEASE-NOTES.ANTI-PATTERN.risk-commentary=do not include "may break", "be careful", "test thoroughly" - release notes state what changed, not hedges about what might go wrong
RELEASE-NOTES.EXAMPLE.hotfix=v1.41.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.41.1) - 14 fixes grouped by 6 subgroups
RELEASE-NOTES.EXAMPLE.rc=v1.7.0-rc.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.7.0-rc.1) - intro + Added/Changed/Fixed/Documentation taxonomy
RELEASE-NOTES.EXAMPLE.minor-auto-acceptable=v1.41.0 - kept auto-generated body; many small fixes with clean conventional-commit titles
RELEASE-NOTES.TEMPLATE.hotfix=## Fixed\n\n### <subgroup>\n- **<bold change>** — <explanation>. (#<PR>)\n\n---\n\nInstall/upgrade: \npx @opengsd/gsd-core@latest`\n\nFull Changelog: RELEASE-NOTES.TEMPLATE.rc=\n\n## Added\n### \n- — . (#)\n\n## Changed\n### Architecture\n- — . (#)\n\n## Fixed\n### \n- — . (#)\n\n## Documentation\n- — . (#)\n\n---\n\nThis is a release candidate. Install for testing:\n```bash\nnpx @opengsd/gsd-core@next\n```\n\nFull Changelog: `
RELEASE-NOTES.RELEASE-STREAM.main-branch=next (RCs) + latest (stable); install via @next or @latest
RELEASE-NOTES.RELEASE-STREAM.rule=streams do not mix; do not document @next in hotfix/stable notes
Repo-rule reinforcement — k320..k331
META.RULE.canonical-source-precedence=CONTRIBUTING.md > docs/adr/* > CONTEXT.md > agent memory
META.RULE.read-contributing-first=read CONTRIBUTING.md sections "Pull Request Guidelines" + "CHANGELOG Entries" before EVERY agent dispatch
META.RULE.brief-must-cite-doc=agent prompts MUST quote the canonical doc line being applied; paraphrasing from predicate memory drifts and produces violations
META.RULE.brief-no-paraphrase=writing "k040 — never leave changelog box unchecked" caused 5 of 8 agents to edit CHANGELOG.md in violation of CONTRIBUTING.md L110
PRED.k320.signal=changelog-direct-edit-forbidden
PRED.k320.canonical-source=CONTRIBUTING.md L193-211
PRED.k320.rule=do not edit CHANGELOG.md in feature/fix/enhancement PRs
PRED.k320.cure=drop .changeset/<adj>-<noun>-<noun>.md fragment ONLY
PRED.k320.tool=npm run changeset -- --type <T> --pr <NNN> --body "..."
PRED.k320.types=Added|Changed|Deprecated|Removed|Fixed|Security
PRED.k320.opt-out-label=no-changelog
PRED.k320.ci-enforcement=scripts/changeset/lint.cjs
PRED.k320.ci-paths-monitored=bin/ gsd-core/ src/ agents/ commands/ hooks/ sdk/src/ sdk/prompts/
PRED.k320.recovery=open Removed-typed cleanup PR deleting only the redundant row
PRED.k320.evidence=PR #3302 merge-conflict against #3308 CHANGELOG.md row 2026-05-09
PRED.k321.signal=cr-outside-diff-range-finding
PRED.k321.shape=CR posts "[!CAUTION] outside the diff" findings in review BODY, not in reviewThreads
PRED.k321.poll-shape=parse pulls/<n>/reviews body AND graphql reviewThreads
PRED.k321.resolution=address in code; no GraphQL resolveReviewThread needed for body-only findings
PRED.k321.evidence=PRs #3304/#3305 (2026-05-09): real Minor/Major findings in body, 0 threads
PRED.k322.signal=cr-sustained-throttle
PRED.k322.distinct-from=k080
PRED.k322.shape=ack posted, real review never lands within [5s, 410s] cooldown after burst of N PRs <15min
PRED.k322.cure-1=2nd retrigger ~10min after first ack
PRED.k322.cure-2=if silent at 50min, treat as silent-pass with maintainer flag in merge-commit body
PRED.k322.merge-gate-impact=k070 real_coderabbit_review_present unsatisfied; requires maintainer judgment
PRED.k322.evidence=PR #3306 (2026-05-09): 0 reviews after 50min + 2 retriggers
PRED.k323.signal=sibling-audit-cross-pr-overlap
PRED.k323.shape=2+ open issues touch same canonical bug site; each fix's sibling-audit produces overlapping diff
PRED.k323.cure-pre-dispatch=brief one agent canonical-owner; brief others to EXCLUDE shared site
PRED.k323.cure-alt=consolidate into single PR when 2+ issues share root cause
PRED.k323.recovery=close smaller PR as "subsumed by #N" or rebase second to drop overlap hunk
PRED.k323.evidence=#3300 (#3297) overlapped #3306 (#3298) on add-backlog.md hunks 2026-05-09
PRED.k324.signal=agent-terminates-mid-monitor
PRED.k324.k095-restatement=k095 confirmed shape: agent reports "waiting for monitor" / "tests still running" then terminates
PRED.k324.cure=verify via gh api on every agent-completion notification; never trust narrative
PRED.k324.poll-shape=gh pr view <n> --json mergeStateStatus,statusCheckRollup + pulls/<n>/reviews + graphql reviewThreads + issues/<n>/comments tail
PRED.k324.evidence=2026-05-09 session: 5+ mid-monitor terminations across PRs #3232/#3271/#3251/#3255/#3262
PRED.k325.signal=worktree-branch-lock-on-force-push
PRED.k325.shape=git checkout <branch> errors "already used by worktree at <agent-worktree>"
PRED.k325.cure=detached-HEAD: git checkout --detach $(git ls-remote origin <branch>); modify; commit; git push --force-with-lease=<branch>:<remote-sha> origin HEAD:refs/heads/<branch>
PRED.k325.cleanup=git worktree remove --force <path> for aged agent worktrees
PRED.k325.evidence=2026-05-09 CHANGELOG.md strip on PRs #3300/#3302/#3304/#3305 required detached-HEAD
PRED.k326.signal=brief-contradicts-canonical-doc
PRED.k326.shape=N parallel agents amplify a single brief-vs-doc contradiction into N violations
PRED.k326.cure=quote canonical doc verbatim in brief; mentally simulate "if all N agents follow this brief literally, do they violate any rule?"
PRED.k326.evidence=2026-05-09 brief "k040 — update CHANGELOG.md" → 5 of 8 agents violated CONTRIBUTING.md L110
PRED.k327.signal=cr-ack-vs-real-review
PRED.k327.ack-shape=body "✅ Actions performed - Full review triggered"
PRED.k327.real-review-shape=body starts "Actionable comments posted: N" OR "[!CAUTION] Some comments are outside the diff"
PRED.k327.distinguish-key=len(pulls/<n>/reviews) — ack=0, real=≥1
PRED.k327.cooldown-normal=[5s, 410s]
PRED.k327.cooldown-throttled=k322
PRED.k328.signal=pr-template-typed-heading-required
PRED.k328.canonical-source=CONTRIBUTING.md L48,L64,L81 (template links) + .github/PULL_REQUEST_TEMPLATE/{fix,enhancement,feature}.md L1 (heading text)
PRED.k328.k100-restatement=heading must match issue class: bug→## Fix PR, enhancement→## Enhancement PR, feature→## Feature PR
PRED.k328.audit-list=[heading-matches-class, closing-keyword-present, changeset-fragment-or-no-changelog-label]
PRED.k329.signal=changeset-fragment-canonical-shape
PRED.k329.canonical-source=CONTRIBUTING.md L196-202 + .changeset/README.md
PRED.k329.filename=.changeset/<adj>-<noun>-<noun>.md
PRED.k329.frontmatter=---\\ntype: <Added|Changed|Deprecated|Removed|Fixed|Security>\\npr: <NNN>\\n---
PRED.k329.body=**<Bold user-visible change>** — <symptom-led explanation>. (#<NNN>)
PRED.k329.observed-clean=#3299 sunny-ibex-wave, #3301 sturdy-rams-caper, #3306 3298-phase-dir-prefix-drift-workflows
PRED.k330.signal=mempalace-diary-not-callable-by-ai
PRED.k330.shape=mempalace MCP tools require explicit user call; AI cannot trigger
PRED.k330.fallback=append predicate-format findings directly to CONTEXT.md
PRED.k331.signal=close-with-no-comment-is-literal
PRED.k331.shape=instruction "close with no comment (rationale)" — parenthetical is rationale, NOT comment body
PRED.k331.k101-restatement=k101 includes close-time --comment flag; rationale belongs in subsuming PR's squash-merge body
PRED.k331.cure=gh pr close <n> with NO --comment flag
PRED.k331.recovery=if violation lands, gh api -X DELETE repos/<o>/<r>/issues/comments/<id>
PRED.k331.evidence=2026-05-09 wave-3: violation on #3300 close, deleted within 30s
PROC.AGENT-DISPATCH.preflight=[read-CONTRIBUTING.md-fresh, read-relevant-ADRs, cite-specific-line-in-brief, require-closing-keyword, require-changeset-fragment, forbid-CHANGELOG.md-edit, require-isolation-worktree, forbid-self-PR-comment, mandate-trust-but-verify]
PROC.AGENT-DISPATCH.parallel-overlap-audit=before dispatching N sibling-audit fixers, compute file-set union and assign canonical owners
PROC.AGENT-DISPATCH.completion-verify=run k324.poll-shape on every agent-completion notification
PROC.MERGE-WAVE.ordering=[wave1: isolated-files, wave2: CHANGELOG-only-overlap (better: strip per k320), wave3: same-file-overlap with explicit decision]
PROC.MERGE-WAVE.preflight=gh pr view <n> --json files for every PR; identify overlap pairs; surface to maintainer
PROC.MERGE-WAVE.changelog-strip-pattern=detached-HEAD per k325 + git checkout main -- CHANGELOG.md + commit + force-with-lease
PROC.MERGE-WAVE.merge-tool=gh pr merge <n> --squash --delete-branch
PROC.MERGE-WAVE.merge-tool-warning=delete-branch may fail with "used by worktree at" — harmless; remote branch still deleted
Triage and merge-wave lessons
WAVE.LESSON.changelog-policy-violation-multiplier=brief contradicting CONTRIBUTING.md's changelog-fragment policy ("CHANGELOG Entries — Drop a Fragment" section) produced violations on 5 of 8 PRs (#3300, #3302, #3304, #3305, #3308); k326 + k320 capture
WAVE.LESSON.cr-throttle-burst-correlation=8 PRs in <15min triggered k322 sustained-throttle on multiple PRs (#3306 worst case)
WAVE.LESSON.sibling-audit-overlap=k015-family parallel dispatch on #3297 + #3298 produced k323 add-backlog.md cross-PR overlap
WAVE.LESSON.agent-narrative-unreliable=k095/k324 confirmed at scale: 5 of 8 agents terminated mid-monitor with stale claims requiring direct verification
WAVE.LESSON.k101-still-trips=even after CONTEXT.md k101 reinforcement, agent of record posted self-PR comment on close; k331 adds explicit close-time literal-instruction guard
Defect anti-patterns and fix-forwards
RULESET.GENERATIVE-FIX=parallel implementations diverge silently when no parity test enforces equality at the test layer; for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge; exemplar: tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher)
RULESET.CONTENT-PATH-NORMALIZATION=filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional; mechanically enforced by local/normalize-path-in-content (eslint, src/**/*.cts; #1733)
Shell Command Projection Module (expanded glossary entry, 2026-05-13)
Module owning all OS-facing I/O for the tool: runtime-aware command-text rendering (hook commands, PATH action lines, shim scripts), subprocess dispatch (execGit, execNpm, execTool, probeTty, isSpawnTimeout), Windows binary resolution (resolveExecutableBinary, projectSpawnInvocation), and platform file I/O (platformWriteSync, platformReadSync, platformEnsureDir). Single seam for platform-conditional logic — one place to fix any shell or file write regression across Windows, macOS, and Linux. WINDOWS BINARY RESOLUTION — which file a declared command name actually names, and what must be handed to spawnSync to start it — is owned here as of #3411 (epic #3411 Phase 1), which found the seam declaration untrue for this axis: four divergent implementations had grown outside it (execNpm's shell:true, execTool's absence of any handling, a private PATH+PATHEXT scan in gsd-core/bin/gsd-tools.cjs, and a fourth candidate-extension array in fallow-runner.cts), and #3275's fix to one provably never reached the others. resolveExecutableBinary(name, {platform, env}) returns the RESOLVED PATH (the prior hasBinary computed it and threw it away): on win32 it tries PATHEXT entries ONLY and never the bare name, because npm global installs drop an extensionless POSIX sh shim beside foo.CMD and a bare-name-first scan resolves to it, leaving the ENOENT unchanged (#3275); a name already carrying a PATHEXT-listed extension is tried as-is BEFORE the append loop, and a suffix outside PATHEXT is not an extension. On POSIX it answers existence only, and execTool does not consult it there at all — the bare name goes to spawnSync unchanged and Node's own PATH search does the work, keeping macOS/Linux byte-identical for a symbol whose blast radius is CRITICAL (167 affected symbols, 53 files). projectSpawnInvocation(command, args, {platform, env}) is the inseparable second half: CreateProcess cannot execute a .cmd/.bat at all, so resolution alone does not fix Windows, and exporting only the resolver would leave every caller to re-derive the mediation — which is how the four copies accumulated. It mediates through ComSpec with an EXPLICIT argv array (/d /s /c), never shell: true: that is CVE-2024-27980's argument-injection vector and Node 26's DEP0190. Mediation keys on the target — the resolved path, or the declared name when resolution found nothing — so a .cmd resident in the current directory (which PATH-only resolution misses but cmd.exe /c still finds) keeps working, while a BARE unresolved name is passed through untouched so the spawn fails with ENOENT rather than cmd.exe's exit 9009, preserving _spawnResult's '<declared>: not found' contract. gsd-core/bin/gsd-tools.cjs's resolveSpawnBinary is the bin/ entry point onto this and holds no copy of the logic. CALLERS CHOOSE HOW MUCH OF THE PROJECTION TO ADOPT, and the asymmetry is deliberate: windowsVerbatimArguments marks the cases where mediation was REQUIRED (the caller must take command and args together, or the batch file cannot start at all), whereas a merely-RESOLVED path is an offer a caller may decline. execTool declines it and keeps spawning the DECLARED name — libuv's CreateProcess path already performs PATH+PATHEXT search, so resolving a .exe there buys nothing while changing what 167 dependents observe being spawned; tests/graphify.test.cjs pins that contract by spying on spawnSync's first argument, and the Windows CI lane caught the violation when execTool briefly adopted the resolved path (python3 arriving as C:\…\python3.EXE). deps.spawn accepts it, because that lane's hasBinary probe answers from the same resolver and probe and spawn must agree on the exact file (#3445). Env lookups go through a case-insensitive read: Windows names the variable Path, process.env is a case-insensitive proxy that hides this, and execTool's {...process.env, ...opts.env} spread produces a PLAIN object that keeps the OS casing and loses the proxy — an exact-case env['PATH'] there returns undefined and the scan silently sees nothing. This is fixed inside the seam by envGet(env, name) (formerly private _envGet, exported as of #3624), and ratcheted outside the seam by local/no-exact-case-env-access (ADR-1703 catalog, epic #3411 Phase 4): it flags a read of any casing of PATH/PATHEXT/ComSpec/USERPROFILE/TEMP/TMP/APPDATA off any receiver that is not literally process.env, matched via an "env-shaped receiver" check (<expr>.env/<expr>['env'] or a bare env-named identifier) rather than a blanket case-insensitive property-name match — the blanket form produced 113 false positives against ordinary .path-named properties elsewhere in the tree. DEFECT.WINDOWS-EXACT-CASE-ENV-ACCESS. resolveExecutableBinary carries two OPT-IN options, both defaulting off so Phase 1's callers are byte-identical: prependPaths (directories searched before env.PATH, in order, with the identical per-directory candidate logic — this is how node_modules/.bin-first precedence is expressed without env surgery) and requireExecutable (POSIX-only additional accessSync(X_OK); a no-op on win32, where mode bits do not mean execute). requireExecutable is opt-in rather than default because making it unconditional would break #3445's own suite, which stages candidates with plain writeFileSync and never sets an exec bit — the repo bans chmod in tests — so every one of those would resolve to null on POSIX. As of #3618 (epic #3411 Phase 2) src/fallow-runner.cts holds no resolver of its own: resolveFallowBinary is one seam call passing prependPaths: [<cwd>/node_modules/.bin] and requireExecutable: true, preserving .bin-before-PATH precedence and the POSIX executability check. Its prior win32 candidate list ended in a BARE fallow; the seam does not, and dropping it is the fix — an extensionless file beside fallow.cmd is npm's POSIX sh shim that CreateProcess cannot run (#3275). Note the precedence was documented BACKWARDS (PATH then .bin) in structural-pre-pass.md and four INVENTORY translations until #3618 corrected them; the code was always .bin first. As of #3619 (epic #3411 Phase 3) the seam's ownership is RATCHETED by local/no-private-binary-resolution (eslint-rules/no-private-binary-resolution.cjs, ADR-1703 catalog): re-implementing Windows binary resolution outside src/shell-command-projection.cts is an eslint error, keyed on the two unambiguous signals — reading PATHEXT in any casing from any object, and a hardcoded list carrying two or more of .exe/.cmd/.bat/.com (the shapes all four deleted resolvers actually had). DEFECT.WINDOWS-PRIVATE-BINARY-RESOLUTION. It deliberately does NOT flag a bare-name spawn — ~30 such sites exist and none is a defect, since git/gh/npm ship native .exe that CreateProcess resolves unaided — nor a PATH scan, which is indistinguishable from a legitimate membership check (bin/install.js). The extension threshold is TWO because a single .endsWith('.cmd') is a classification, not a candidate set (runtime-hooks-surface.cts derives .cmd shim paths that way), and matching is boundary-aware because a naive substring test flags .execute and .compacting. The seam exemption is path-SUFFIX anchored, not substring; the rule's own surface is src/**/*.cts, gsd-core/bin/**/*.cjs, scripts/**/*.cjs, and hooks/**/*.js, and tests/** is deliberately outside that surface — test setup legitimately assigns process.env.PATHEXT (tests/fallow-runner.test.cjs's P3 case), so tests/shell-command-projection-dispatch.test.cjs is NOT linted by this rule at all; the suffix-vs-substring distinction is instead proven by RuleTester case I9's synthetic filename, not by real-world coverage of that test file. eslint-rules/** is outside the rule's globs entirely rather than exempted, because lib/portability-vocab.cjs owns the extension set. To make the ratchet strict with no carve-out, resolveExecutableBinary also gained pathOverride — "search THIS PATH, read everything else including PATHEXT from the ambient environment" — so resolveFallowBinary supplies its own search path without hand-threading PATHEXT, which would itself have been a private PATHEXT read. pathOverride: '' means an EMPTY search path, never a fallback to env.PATH (!== undefined, not truthiness). isSpawnTimeout is the single shared "did this subprocess time out" predicate (error.code==='ETIMEDOUT' only — cross-platform-safe; does not require signal==='SIGTERM'), consumed by worktree-safety.cts, worktree-base-ref.cts, commands.cts, and this module's own dispatchGsdCommand (#3050 — "Generative Fix Divergence"). projectPathExportLine(targetDir) is the single source of the export PATH="<dir>:$PATH" line for all three PATH-persistence lanes (repair, persist, win32 Git Bash) — it double-quote-escapes for the line's final rc-file context before any lane single-quotes it for its own echo transport, closing the #3118 command-substitution injection where a lane re-escaped the line itself and let a $(…) in the target dir execute on every new shell; the win32 cmd.exe lane fails closed (empty actions) whenever the target dir contains ", since that character is reserved on Windows and would otherwise close cmd's quoted region, and now tags that empty result with a typed PATH_ACTION_REASON (win32_reserved_quote) so it stays distinguishable from the unrelated "no target directory given" empty result (no_target_dir, #3118); the fish lane emits fish_add_path -- '<dir>' (-- end-of-options separator, verified empirically against fish 4.8.1), since a leading-dash target dir is otherwise misparsed by fish's argparse-based option scanning regardless of quoting; escapeTomlDoubleQuotedString now escapes TOML's required control characters (U+0000-U+0008, U+000A-U+001F, U+007F), not just backslash and quote, closing a raw-newline/CR/NUL config.toml parse failure (#3118). Lives in gsd-core/bin/lib/shell-command-projection.cjs. See ADR-0009 (superseded "does not execute" constraint) and ADR-0010 (superseded File Operation Engine).
SEAM.shellcmdproj-win-binary-resolution.owns=Windows binary resolution (resolveExecutableBinary, projectSpawnInvocation) — which file a declared command name actually names, and cmd.exe mediation
SEAM.shellcmdproj-win-binary-resolution.enforced-by=lint-rule:no-private-binary-resolution
Invariants:
- Result shape: all exec* functions return
{ exitCode, stdout, stderr }; never throw on non-zero exit code. - Platform policy owned at the seam:
shell: process.platform === 'win32'lives only in execNpm; probeTty returnsnullon Windows. - Normalization policy: platformWriteSync owns full
normalizeMdfor.md; CRLF-to-LF + trailing newline for all others; callers must NOT pre-callnormalizeMd. _normalizeMdis re-implemented inline (not imported fromcore.cjs) to avoid circular dep.atomicWriteFileSync,safeReadFile,normalizeMdwere duplicated wrappers incore.cjs, removed from there in this module's own Migration Phase 4 (#3468, see ADR-0009/ADR-0010) — a separate effort from epic #1267 (the core.cjs re-export-spine retirement); callers now importplatformWriteSync/platformReadSync/normalizeContentfrom this module directly.
Migration: Phases 1-4 (#3465-#3468) shipped 2026-05-13 — seam additions, subprocess-dispatch migration (6 files), fs migration (15 files / 215 call sites), and core.cjs compat-export removal are all complete; see ADR-0009's 2026-05-13 Update section.
Dependency posture: this seam OWNS its Windows binary resolution and cmd.exe mediation rather than delegating to cross-spawn, nano-spawn, or execa — evaluated and decided in ADR-3625 (#3625). Do not re-litigate it in a PR; read the ADR. Headlines: nano-spawn is async-only and cannot back a spawnSync seam; cross-spawn is the only structurally-eligible candidate and independently arrives at the SAME escaping mechanism this seam uses (cmd.exe /d /s /c + pre-escaped line + windowsVerbatimArguments), which validates the approach rather than superseding it — but it resolves process.cwd() FIRST on Windows even when an explicit PATH is supplied (a binary-planting surface), calls process.chdir() during resolution, and keys its escape depth on a node_modules/.bin/*.cmd path regex of the same fragile shape #3411 was filed to delete. Adoption would also break execTool's observable not-found contract across 53 dependent files. The decision carries revisit-if conditions (notably: if the seam ever needs an async dispatch shape, re-run the comparison — the honest sync→async ripple is 14 symbols / 7 files upstream, NOT the 167/53 figure, which is a direction:both measurement inflated by downstream callees).
Session log (chronological, append-only, one line per session)
Discipline: new operational lessons go into a predicate above. Each dated entry below is a one-line pointer at the predicates derived from that session — NOT a prose narrative. If you can't compress a session's lesson into a predicate, the lesson isn't sharp enough yet — keep grinding.
SESSION.2026-05-05=[PRED.k320..k331 introduced; DEFECT.SOURCE-GREP-IN-NEW-TESTS, DEFECT.CHANGESET-PR-FIELD-DRIFT, DEFECT.PHASE-DIR-PREFIX-DRIFT, DEFECT.PROMPT-INJECTION-SCAN-COLLISION; ADR-0002 thin-wrapper pattern findings folded into RULESET.WORKFLOW_*]
SESSION.2026-05-05.sdk-bridge=PR #3158 SDK Runtime Bridge — observability isolation rule; strict-mode dispatchMode reporting invariant; transport decision ordering (guard before event emission); folded into Dispatch Policy Module glossary
SESSION.2026-05-09=[8-PR triage wave, 7 merged + 1 subsumed; META.RULE.* introduced; WAVE.LESSON.* captured; k320/k322/k323/k326/k331 evidence; AI Ops Memory predicate format established]
SESSION.2026-05-10=[ai-ops memory consolidation; release-notes standard taxonomy + templates; RELEASE-NOTES.* predicates introduced]
SESSION.2026-05-13=[Shell Command Projection Module expansion (#3465-#3468); ADR-0009 superseded; new exports for subprocess dispatch and platform file I/O; phase-gated migration plan; PR #3464 three-gate invariant CI+CR+unresolved=0; PR #3470 stash-include-untracked rebase pattern]
SESSION.2026-05-14=[#3095/PR #3490 EXEC.CLASSIFY.* introduced (Anthropic/Copilot/Codex/Gemini [runtime removed #1928] cross-runtime rate-limit sentinel coverage); #3489/PR #3499 DEFECT.STATE-TRAMPLE.idempotency-oracle (STATE.md current_phase field is oracle for state.complete-phase); #3488/PR #3501 DAG resolver same-phase short-form depends_on (shortFormToId index added to sdk/src/query/phase.ts); #3491/PR #3502 DEFECT.NESTED-GIT-INIT (gitWorktreeInfoInternal helper); #3493/PR #3500 extractCurrentMilestone generic Phase Details continuation past planned-milestone siblings; #3503/PR #3504 DEFECT.PATH-SUBSTRING-CHECK (trailing-slash anchor for homedir checks); #3346/PR #3505 codex AoT TOML leaf-key via extractFlatHookEventName; #3506/PR #3507 label-scoped stale-bot sub-job pattern; multi-PR triage operational lessons folded into PROC.TRIAGE.*; #3508 DEFECT.AGENT-ISOLATION-SILENT-FAIL; gsd-test image-missing auto-build (locally-built image via embedded heredoc Dockerfile); refined PRED.k322 threshold to 3 PRs/<10min]
SESSION.2026-05-15=[#3537/PR #3538 DEFECT.PHASE-REGEX-FANOUT — phaseMarkdownRegexSource promoted to core.cjs and wired to 7 sites; parity-style regression test established as DEFECT.GENERATIVE-FIX exemplar; trek-e/gsd-test-runner#1 filed for DEFECT.GSD-TEST-MIRROR-POISONED — chown-back-before-exec legacy gap (poisoned holodeck mirror unstuck via authorized docker chown to remote 1000:1000); RULESET.PR-FLOW.* codified from project CLAUDE.md load-bearing rule; first dispatch under run-tests-before-create held cleanly (PR #3520 worker stopped on Docker exit 12 infra failure, orchestrator opened PR after unblock); CONTEXT.md refactored from 882 lines of mixed prose+predicates into ~500 lines of pure-predicate format with chronological session log]
SESSION.2026-05-15.parallel-fix-dispatch=[#3542/PR #3546 prohibit git stash family in executor agents (shared refs/stash across worktrees); #3541/PR #3547 non-TTY resolution for installer prompt-user actions (default remove for SDK build artifacts, keep for skills/gsd-*/SKILL.md); #3545 filed for gsd-test-summary concurrent /tmp output collision; new predicates DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking, DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION, DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL, DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT, PROC.PARALLEL-FIX-DISPATCH; agent-trust-but-verify caught /gsd-update retired-syntax comment slip in #3541 implementation before PR open]
SESSION.2026-05-16=[multi-PR triage wave (#3577/3581/3640/3641/3642/3648/3649/3637/3639). Established global PreToolUse hook ~/.claude/hooks/test-memory-guard.sh denying new node/test spawns when sum(RSS of node|vitest|jest|...) >= 4 GiB on the 24 GB Mac OR when a same-runner process is already in argv[0] — hard deny via hookSpecificOutput.permissionDecision=deny. PR #3577 fix: revert config-ensure-section dispatch to CJS cmdConfigEnsureSection (SDK author wrote single-section semantics under a name whose legacy callers expect full-default config init); plus 3 SDK parity carve-outs (configNewProject defaults align with sdk/shared/config-defaults.manifest.json, return relative .planning/config.json path, drop quotes from Unknown config key, lead malformed-JSON error with "Failed to read config.json:"). PR #3649 fix: chunk node --test spawn at 28K argv ceiling (Windows CreateProcess lpCommandLine cap 32,767 was instantly aborting unchunked spawn of 546 paths). Chunking fix surfaced 14 pre-existing Windows-only test bugs (4010 pass / 14 fail; vs 0/0 before — entire suite was un-runnable on Windows). PRs #3639 + #3637 confirmed unable to stand alone (legitimately depend on Phase 6 scaffolding only present on feat/3575-enforcement-hardening) — user decision: cherry-pick into #3577 and close. Five other PRs each had ≤1 unresolved CR thread of the changeset-pr-number / null-vs-throw / implicit-Claude-runtime / docs-stale-guidance / hardcoded-tests-path family — all quick wins. New predicates: DEFECT.SDK-PORT-NAME-COLLISION, DEFECT.WINDOWS-ARGV-OVERFLOW, DEFECT.STACKED-PR-CANNOT-STAND-ALONE, DEFECT.CANARY-VERSION-LEAK, DEFECT.GSD-TEST-HOST-MID-RUN-DEATH, RULESET.HARNESS.test-memory-guard, RULESET.PR-FLOW.docker-before-push, RULESET.PR-FLOW.templates-mandatory]
RULESET.HARNESS.test-memory-guard=~/.claude/hooks/test-memory-guard.sh fires on every Bash PreToolUse; if argv[0]∈{node|vitest|jest|mocha|tsx|ts-node|tap|ava|playwright|cypress} OR matches (npm|pnpm|yarn|bun) (run )?(t|test|tests|vitest|jest); blocks via hookSpecificOutput.permissionDecision=deny when sum(RSS of running matching procs, excluding tsserver|*-mcp|claude|Electron|...) ≥ 4 GiB OR when argv[0] basename matches a running process's argv[0]. Exception: node --version|-v|--help|-h|-p|-e are trivial probes and skip the check. Designed for a 24 GB Mac where prior accidental fan-out exhausted RAM
RULESET.PR-FLOW.docker-before-push=before ANY git push of any fix to any PR, run gsd-test (docker on the remote, mirrors ubuntu CI) and confirm exit 0. macOS-local node --test is NOT a substitute — many failures are platform-specific (path separators, case sensitivity, locale, fs semantics). Watchdog with Monitor on the output log; never set a sleep/timer and walk away. Source: user feedback 2026-05-16 — "we don't set a timer we actively watch and record results in real time as possible". SUPERSEDED 2026-07-17: 'confirm exit 0' is a false-green trap — piping/backgrounding can report exit 0 on a failed suite; gate on the verdict-line outcome:"passed" for the exact HEAD sha instead. See CLAUDE.md's gsd-test rule and the gsd-test-is-ref-based-commit-first predicate for the current, correct gating contract.
RULESET.PR-FLOW.templates-mandatory=every gh pr create|edit|gh issue create|edit MUST first invoke the gh-templates-first skill and Read (Read tool, not Bash cat — k321 read-tracking) the matching template in .github/. Apply ALL required sections; never write freeform bodies. Repo enforces this via gsd-pr-template-policy GitHub Action which flags any non-templated body — the bot allows the PR to stay open only because authors are contributors-or-higher, but the warning is a real complaint that must be cured. Source: user feedback 2026-05-16 (multi-message escalation) — "the whole reason i have that github action is because you fucking blow through and ignore using the templates"
Executor failure classification (#3095 / PR #3490)
EXEC.CLASSIFY.handler=gsd-core/bin/lib/agent-command-router.cjs:classifyAgentFailure (registered via command-aliases.cjs; mutation:false outputMode:json)
EXEC.CLASSIFY.workflow=gsd-core/workflows/execute-phase.md step 7; class-distinct prompts (quota-to-wait-for-reset; classify-handoff-bug-to-spot-check; unknown-to-continue/stop)
EXEC.CLASSIFY.classes={class:'quota-exceeded'|'classify-handoff-bug'|'unknown-failure', sentinel?, retryAfterSeconds?}
EXEC.CLASSIFY.sentinel-order=most specific first: 429 beats too-many-requests; resource_exhausted beats quota (array order in src/agent-command-router.cts QUOTA_SENTINELS checks resource_exhausted before quota); case-insensitive; canonical sentinel value is lower-cased form
EXEC.CLASSIFY.cross-runtime=Anthropic/CC: usage limit|rate limit|quota|429|retry-after; Copilot CLI: rate_limit (stem); Codex CLI: 429|usage_limit_reached|too many requests
EXEC.CLASSIFY.precedence=quota sentinel wins over classifyHandoffIfNeeded bug when both appear
EXEC.CLASSIFY.retry-after-parser=\bretry[-_ ]after[:\s]+(\d+)\b avoids embedded-word false matches like noretry-after
EXEC.CLASSIFY.proactive-signal-not-usable=Anthropic exposes anthropic-ratelimit-* headers + Agent SDK RateLimitEvent; Claude Code subprocess does NOT forward to hooks/statusline today (upstream #33820, #22407, #32796)
PROC.PARALLEL-FIX-DISPATCH.pattern=bot triage brief → worktree per branch → parallel sub-agents do rubber-duck/RCA/TDD implementation only → top-level orchestrator owns commit + gsd-test + push + PR + changeset-pr-backfill
PROC.PARALLEL-FIX-DISPATCH.rationale=long-running test runs need cross-turn notifications (orchestrator-only); CONTRIBUTING.md gh-templates-first hook requires session-scoped Read calls sub-agents wouldn't otherwise make; sequencing test runs avoids GSD-TEST-CONCURRENT-OUTPUT-COLLISION
PROC.PARALLEL-FIX-DISPATCH.observed=#3541 + #3542 dispatched simultaneously this session; PRs #3546 #3547 opened green; one syntax slip caught by AGENT-RETIRED-SLASH-SYNTAX-DRIFT and fixed before second PR opened
PROC.TRIAGE.routing-incoming=stale-bug-already-fixed to close as duplicate of originating issue + cite fix PR + first stable tag; release-publish-or-backport to ready-for-human; reporter-can-self-test to awaiting-retest
PROC.TRIAGE.comment-shape=lead with "duplicate of #NNNN, fixed by PR #MMMM, in v1.X.Y"; show current code snippet proving bug-surface gone; give @latest and @next upgrade commands; close
PROC.TRIAGE.no-duplicate-label=this repo has no duplicate label; framing lives in comment text + closing the issue
PR fix discipline — patterns observed 2026-05-23
Full detail in ~/.claude/skills/gsd-pr-fix-discipline/SKILL.md. AI agents MUST check this section before pushing to open-gsd/gsd-core.
INVENTORY / manifest drift
- Symptom:
tests/inventory-manifest-sync.test.cjsfails —"New surfaces not in manifest"(the MANIFEST half) or"Shipped surfaces in docs/INVENTORY-MANIFEST.json with NO row in docs/INVENTORY.md"(the ROSTER half, #3762); ortests/inventory-headings-countfree.test.cjsfails if a(N shipped)count was re-added to a heading - Affected this session: #154, #156, #143, #155, #169
- Fix: Add row to
docs/INVENTORY.md+node scripts/gen-inventory-manifest.cjs --write. The two halves have DIFFERENT remedies and the roster half cannot be regenerated — a role sentence is hand-written by design, so re-running the generator never clears it.
Slash command two-tier confusion
- Symptom:
tests/slash-command-namespace.test.cjs(folds formerbug-2543-gsd-slash-namespace, consolidation epic #1969) ortests/init-manager.test.cjs(folds formerbug-3584-runtime-slash-emitters, consolidation epic #1969) fails - Affected this session: #154 (three passes), #164 (added the authoritative matrix)
- Fix: Consult
## Slash-command formsection of this file before touching any/gsd-or/gsd:token — colon foragents//commands/, hyphen for runtime emitters
Concurrency cancel-in-progress masking real CI state
- Symptom:
gh pr checksshows failures but the latest commit SHA's run was cancelled before Tests even started - Affected this session: #154, #136
- Fix:
gh workflow run Tests --repo open-gsd/gsd-core --ref <branch>; verify withgh run list --branch <branch> --workflow Tests --limit 1 --json status,conclusion,headSha
Missing changeset fragment
- Symptom:
changeset-lintfails withfail_missing_fragment(~5s) - Affected this session: #156, #143, #164
- Fix:
node scripts/changeset/new.cjs --type <Type> --pr <N> --body "..."or applyno-changeloglabel for doc-only PRs
Cross-platform Windows / Node 24 hazards
- Symptom: Windows CI leg fails; Mac/Linux green — POSIX paths in
node -e, hardcoded.nvmrcfixtures, 2000ms wall-clock budget flakes,synckituncaught Worker exception - Affected this session: #157
- Fix: Use
./package.jsonnot$PWD/package.json; write.nvmrcdynamically inbefore()hook; use 5000ms budget; wrapgetExecuteForCjs()intry/catch
Sub-agent rubber-duck stall
- Symptom: Sub-agent returns a question list and halts; no commits or push in the worktree
- Affected this session: Multiple agents mid-session
- Fix: Every sub-agent brief must include:
Skill rubber-duck is BANNED in this sub-agent. Convert to internal monologue and proceed.
Stacked PR squash-merge breakage
- Symptom: After base PR squash-merges, stacked PR shows conflicts or wrong diff; GitHub auto-retarget fails
- Affected this session: #158 stacked on #156
- Fix:
git rebase --onto next <old-base> <stacked-branch>then force-push andgh pr edit --base next(repo's integration branch as of thenext-branch model introduced 2026-05-24; wasmainbefore that)
tee pipe swallowing exit codes
- Symptom: piping any test-runner output through
tee(e.g.... 2>&1 | tee /tmp/log) returns0even when the run reports failures — the pipeline exits ontee's status, not the runner's - Affected this session: Session-wide risk
- Fix: Run
gsd-test(classic executor) UNPIPED per CLAUDE.md, orset -o pipefailbefore any pipe. Do not use the deprecatedgsd-test-summary/gsd-test-bothwrappers at all (see DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION / DEFECT.GSD-TEST-MIRROR-POISONED).
Auto-merge disabled
- Symptom:
gh pr merge --autoreturnsGraphQL: Auto merge is not allowed for this repository - Affected this session: All stacked PRs
- Fix: Merge manually by hand in dependency order once CI greens;
gh pr merge <N> --squash --repo open-gsd/gsd-core
Defect enforcement (ADR-2143 follow-on)
Prose defect entries retired in favour of gates; the gate IS the record. DEFECT.UNBOUNDED-SUBPROCESS → eslint-rules/require-subprocess-timeout.cjs (error, src/**; options literal must carry timeout — git 5-30s, npm 60s; the rule only requires the call be bounded, not what the caller does after — 7 of the 8 sites this surfaced degrade to an empty/false/null result on failure, and the 1 that guards a destructive real-run migration (roadmap-upgrade.cts's pre-mutation clean-tree check) correctly still throws rather than proceed against an unverified working tree). DEFECT.CANARY-VERSION-LEAK → scripts/lint-canary-version-leak.cjs + the canary-version-leak job in .github/workflows/version-gate.yml (PRs whose base is main). DEFECT.CHANGESET-PR-FIELD-DRIFT → findPrFieldDrift in scripts/changeset/lint.cjs. Entries whose condition no automated check can evaluate were deleted rather than kept as unenforceable prose. DEFECT.FRONTMATTER-SCALAR-BROAD-GREP → scripts/lint-frontmatter-scalar-broad-grep.cjs (in lint:ci; flags an unscoped grep "^key:" over a whole planning doc with no frontmatter slice and no -m1/head -1 guard). DEFECT.REMOVED-BUT-NEEDED → scripts/lint-removed-but-needed.cjs (in lint:ci; a deleted file whose basename still appears in .github/workflows/, gsd-core/, docs/ or package.json; and since #3565, in tests/ behind a pins-existence vs asserts-absence discriminator — fs.existsSync/readFileSync/require/quoted-object-key on the deleted basename fails, a negated !…includes/!fs.existsSync absence assertion is the correct post-deletion state and passes; a bare mention that is neither is not flagged, because the undiscriminated widening was tried in #3560 and reverted for firing on the very tests that prove a deletion worked). DEFECT.DEFAULT-FLIP-DOCUMENTATION → scripts/lint-default-flip-documentation.cjs + .github/workflows/default-flip-documentation.yml, covering gsd-core/bin/shared/config-defaults.manifest.json only: a changed value for an EXISTING key requires a ## Breaking Changes PR section. The six Windows-portability entries (WINDOWS-TEST-PORTABILITY, WINDOWS-POSIX-MODE-BIT-ASSERT, WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT, WINDOWS-PATH-LITERAL-IN-ASSERT, WINDOWS-FS-OPS, TEST-SHELL-PIPELINE-NONPORTABLE) were already enforced by ADR-1703's own local/* AST ESLint rules (no-posix-mode-bit-assert, normalize-path-in-content, no-path-literal-in-assert, require-fs-op-fallback, no-crlf-fragile-split, no-unguarded-nonportable-exec — see eslint.config.mjs) before this PR; their prose duplicated the rules' own doc comments, so it was deleted rather than kept as a second copy. Residual, deliberately unenforced: buildNewProjectConfig's hardcoded literal in src/config.cts has env-derived branches and CONFIG_DEFAULTS spreads, so no reliable resolved-value diff exists without executing the compiled module at both refs — a line-diff there false-fires on any refactor that merely moves the object, so it was left unchecked rather than shipped noisy.