23 KiB
ADR-612: Bracket Phase-ID Convention
- Status: Proposed (PR-0 — ADR only; locks the contract PR-1…PR-6 execute against. No production code lands in this PR.)
- Date: 2026-07-11
- Issue: #612 — epic (bracket phase-ID convention,
type: enhancement+approved-enhancement) - Supersedes: nothing (no prior ADR). The
milestone-prefixed(M-NN) convention introduced by #565 is deprecated forward via the migrator — see Decision 5. This is a convention-direction call, not an ADR supersede. - Relationship to prior work: completes #39 (milestone-encoding phase IDs), which #565 partially implemented as M-NN. Sibling of ADR-2121 (#2121) — the phase-identifier parsing consolidation that made
src/phase-id.ctsthe single canonical owner of phase-token grammar; the bracket grammar lands inside that owner (Decision 4). The opt-in gating discipline mirrors ADR-1244's capability boundary without modeling bracket as a capability (Decision 9).
Context
GSD phase identifiers encode up to three numeric dimensions — milestone, phase (with optional dotted sub-phase decomposition), and plan — across four surfaces: human display, on-disk directory names, ROADMAP.md headings, and plan/summary filenames. The same string is read by many subsystems (CLI args, migrator, validator, progress renderer, statusline) and, increasingly, by multiple coordinating LLM sessions across repositories — the motivating use case for #39 and #565.
The hyphen is overloaded under M-NN
The milestone-prefixed (M-NN) convention encodes the milestone by hyphen-joining it to the phase (2-01 = milestone 2, phase 01) and the plan by another hyphen (…-01). That is two separator types (hyphen, dot) for three dimensions, so the hyphen means milestone↔phase in one position and phase↔plan in another. Once a token carries both a dotted sub-phase and a plan, it has no deterministic parse (proven in Decision 3).
The lineage, stated honestly
- #39 proposed milestone-encoding phase IDs.
- #565 implemented that as M-NN. This is a partial resolution: it lifts the milestone into the leading integer but leaves the hyphen overloaded, so the token becomes ambiguous once sub-phases and plans coexist.
- This ADR completes #39's intent with the bracket grammar, which lifts the milestone out of the phase token entirely.
M-NN's current status is first-class, not vestigial. Unlike the situation at the original #612 filing, M-NN is now a shipped, tested convention on next (v1.7.0-rc.5). Three distinct facts must be kept separate — conflating them is the trap:
- The default config value is
null, not M-NN —gsd-core/bin/shared/config-defaults.manifest.json:14sets"phase_id_convention": null. A fresh repo speaks no milestone convention. - M-NN is a shipped, first-class convention with live machinery: a deprecation-style migration nudge in
src/roadmap-parser.cts:430(getMilestonePhaseFilter, warning body at:441-445) and the W021 read-path check insrc/verify.cts:1724(phase_id_convention === 'milestone-prefixed'branch, warning at:1733). W021 is pinned by shippedtests/milestone-prefixed-convention.test.cjsand cannot be renumbered. - M-NN is the default and only migrator target today —
src/roadmap-command-router.cts:190defaultsconvention = 'milestone-prefixed'and:204rejects any other--conventionvalue;src/roadmap-upgrade.cts:593writesphase_id_convention = 'milestone-prefixed'.
The deprecation stance in Decision 5 is therefore a forward-migration call (consolidation), not the earlier "nobody adopted M-NN so it can be discarded" argument — that argument is dead.
Decision
1. Adopt the bracket grammar
[GSD.02] 05.03-01
│ │ │ │ └── plan 01 one hyphen — only ever the plan (filename surface only)
│ │ │ └────── subphase 03 dot — optional decomposition
│ │ └───────── phase 05 zero-padded integer
│ └───────────── milestone 02 dot-joined INTO the bracket
└───────────────── project GSD uppercase alpha [A-Z]{1,6}
Three dimensions, two separators, zero reuse: dots are always phase-levels, the single hyphen is always the plan, the milestone always lives in the bracket / dir-prefix.
| Surface | Form |
|---|---|
| Display | [GSD.02] 05.03-01 |
| On-disk dir (Option B, no brackets) | GSD.02-05.03-some-feature/ |
| Plan/Summary file | 05.03-01-PLAN.md (milestone in dir prefix, not filename) |
| ROADMAP phase heading | ### [GSD.02] 05.03: Name |
| ROADMAP milestone heading | ## [GSD.02] Foundation (name, no number) |
Milestone source (READING-B). Under bracket, the milestone comes from the [PROJECT.MM] bracket / dir prefix, never from the phase-token leading integer. This is a deliberate departure from getMilestoneFromPhaseId (src/phase-id.cts:91), which today reads the leading integer (READING-A: stripped.match(/^0*(\d+)-\d/) → v{major}.0). READING-A is correct for M-NN and stays intact for it; READING-B is added only on the bracket path (Decision 6).
Heading discriminator. 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 ([GSD.02] Foundation). A milestone name that begins with a digit is disambiguated by the trailing colon (phase numbers carry it; names do not). The current parser already tolerates a […] prefix before Phase (src/roadmap-parser.cts heading regexes carry (?:\[[^\]]{1,200}\]\s*)?), which is the porting anchor for PR-2.
2. Opt-in gating — legacy paths byte-untouched
Every bracket emit / display / milestone-detection path is gated on config.phase_id_convention === 'bracket', and never on project_code presence (a repo can carry a project_code without opting into bracket — gating on project_code presence is a latent-bug class caught while prototyping this series). The null (un-migrated) and 'milestone-prefixed' (M-NN) parse/emit paths are byte-identical to today. Reads remain tolerant of all forms during the migration window; read-tolerance is not a second active convention.
3. The plan dimension and the concrete collision (PR-0 anchor)
The plan is the third dimension. In bracket form it is a single trailing hyphen + zero-padded integer that appears only in filenames (05.03-01-PLAN.md); the milestone never shares that hyphen because it lives in the bracket. Under M-NN the same hyphen carries both milestone↔phase and phase↔plan, which is the defect.
The empirical anchor, grounded in the two live regexes of normalizePhaseName (src/phase-id.cts:66):
- Anchored milestone regex,
src/phase-id.cts:71:/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i. On2-01.02-01it captures2, then-01, then.02— but the trailing-01(the plan) cannot be consumed by the dot-only sub-phase tail(?:\.\d+)*, so the$anchor fails and the match is rejected. - Unanchored numeric fallback,
src/phase-id.cts:79:/^(\d+)([A-Z])?((?:\.\d+)*)/i. With no$, it matches only the leading2and zero-pads it. Everything after the leading integer is silently dropped.
Result (empirically confirmed against gsd-core/bin/lib/phase-id.cjs):
normalizePhaseName('2-01.02-01') === '02' // milestone 2, phase 01, subphase 02, plan 01 → collapses to bare "02"
normalizePhaseName('10-02.03-04') === '10' // same failure class — trailing plan hyphen after ".03" kills the anchor
A fully-specified (milestone, phase, subphase, plan) identity silently collapses to a bare two-digit integer that is indistinguishable from a bare phase. This is the "prove the defect first" artifact the #612 approval requires; it is locked as a green characterization test in tests/adr-612-collision-characterization.test.cjs (see Decision 3's note on green-ness).
The boundary of the defect is instructive — the collapse appears only when all four dimensions coexist:
normalizePhaseName('2-01') === '02-01' // milestone + phase — anchor matches, no collapse
normalizePhaseName('2-01.02') === '02-01.02' // + subphase (dot) — anchor still matches
normalizePhaseName('2-01.02-01') === '02' // + plan (hyphen after dot) — anchor fails, collapse
A second, cross-subsystem ambiguity (semantic, not a parse collapse): the token 02-04 is a valid production in two subsystems with two meanings. normalizePhaseName('02-04') === '02-04' reads it as milestone 02 / phase 04 (src/phase-id.cts:71 matches — no collapse). The same string is the phase-02 / plan-04 token in plan-file notation ({padded_phase}-{NN}-PLAN.md, per gsd-core/references/universal-anti-patterns.md and --plan NN-MM in docs/COMMANDS.md). The token alone cannot tell a resolver which subsystem is asking. This is a genuine defect but a different class than the collapse above — it is characterized (asserting the non-collapsing current output) but is not the primary anchor.
The bracket contrast (target contract, expected-fail until PR-1 — prose only, NOT in the PR-0 test): once the grammar lands, parsePhaseId('GSD.02-05.03-01') yields exactly one tuple { project:'GSD', milestone:'02', phase:'05', subphase:'03', plan:'01' }, and renderPhaseId / toDir round-trip it. parsePhaseId does not exist on next, so this contract is documented here as the PR-1 acceptance target and deliberately excluded from the PR-0 test, which is green-only and asserts current behavior exclusively.
4. EMIT/RENDER as one pure function pair with round-trip property tests
PR-1 introduces one pure model — a single parsePhaseId plus two renders sharing one PhaseId shape — added inside src/phase-id.cts, the single canonical owner established by ADR-2121:
type PhaseId = {
project: string; // 'GSD'
milestone: string; // '02' (zero-padded; from bracket/dir prefix — READING-B)
phase: string; // '05' (zero-padded)
subphase?: string; // '03' (optional)
plan?: string; // '01' (filename surface only)
};
function parsePhaseId(input: string): PhaseId; // accepts display OR dir OR bare arg
function renderPhaseId(id: PhaseId): string; // '[GSD.02] 05.03-01'
function toDir(id: PhaseId, slug: string): string; // 'GSD.02-05.03-slug'
The pair is verified with fast-check property tests (fast-check ^4.8.0 is an installed devDependency on this base — package.json:66 — and tests/phase-id.test.cjs already imports it), holding the bijective contract the maintainer requires:
render(parse(x)) === xfor every well-formed display stringx;toDir(parse(display), slug) === dirfor every display/dir pair.
Because the grammar lands in phase-id.cts, it inherits the #2128 single-owner regime: the canonical token source PHASE_NUMBER_TOKEN_SOURCE (src/phase-id.cts:44-54) and the anti-divergence guard scripts/lint-phase-id-drift.cjs (ADR-2121 Decision 7). Design constraint for PR-1: the bracket grammar must be an owner-sanctioned extension inside phase-id.cts — any new phase-token regex literal it introduces elsewhere requires a // phase-id-owner: <reason> sanction, or check:phase-id-drift fails CI. It must never be re-derived in another module. This strengthens, rather than replaces, the centralization the maintainer already enforces.
All existing consumers (phase.cts, roadmap.cts, roadmap-parser.cts, validate.cts, verify.cts, commands.cts) call this pair rather than re-implementing regexes inline.
5. M-NN deprecation is terminal — end state is two conventions, by consolidation
Bracket is the terminal convention. The end state is two conventions — null and bracket — down from the transient three (null, milestone-prefixed, bracket). This is achieved by consolidation, not addition: M-NN is deprecated forward through the migrator, not removed out from under any adopter.
- Going forward the runtime speaks one milestone convention:
bracket(gated onphase_id_convention: 'bracket').nullremains permanently supported for un-migrated / non-milestone repos. - M-NN parse/emit is retained as migration-window read-tolerance and as a migrator source — it is not a second active emit convention.
- The deprecation is grounded in machinery that already exists: the
getMilestonePhaseFilternudge (src/roadmap-parser.cts:441-445) and the W021 hint (src/verify.cts:1733) already tell M-NN repos to run the migrator. The bracket migrator (Decision 7) extends that forward path to carry M-NN → bracket. - Which release carries the cutover is the maintainer's call — release mechanics, not design. No stale rc is named here.
This is the design hinge the maintainer flagged; it is decided here, not deferred.
6. Milestone-detection rewrite behind the opt-in flag; legacy intact
getMilestoneFromPhaseId (src/phase-id.cts:91, single-arg, READING-A today) gains a bracket path that derives the milestone from the {CODE}.{MM}- prefix (READING-B), gated on phase_id_convention === 'bracket'. The null and 'milestone-prefixed' paths keep the current READING-A body byte-untouched. Gating requires either a new parameter or a config read at the call boundary — that is a PR-1 design choice, not decided here. Discriminator test (PR-1): getMilestoneFromPhaseId('GSD.02-05.03') resolves to milestone 2, not milestone 5. Sentinel behavior (0.x / 999.x → milestone null) is preserved (src/phase-id.cts:96).
The convention value is validated by a new enum VALID_PHASE_ID_CONVENTIONS in src/config.cts. Today the value is an un-validated magic literal — config.cts has per-key enum blocks for other keys but none for phase_id_convention. The enum lands in the display/config PR (PR-4/PR-5).
7. Migrator commitments
The bracket migrator (src/roadmap-upgrade.cts) preserves the existing safety triad and extends the source grammar:
- Dry-run by default —
applyMigration(src/roadmap-upgrade.cts:478) setsconst dryRun = options.dryRun !== falseand prints the full plan without mutating (:485-487). - Dirty-tree guard — a non-empty
git status --porcelainthrows before any write (:497-498). - Atomic rollback on failure. The #612 approval names this "HEAD-sha rollback." On this base the mechanism is intentionally different and stronger: a surgical, git-independent reverse-rename + per-file snapshot (
#1542,src/roadmap-upgrade.cts:509-524and the rollback block:598-616). Agit reset --hard+git cleanrestores nothing for a gitignored.planning/(commit_docs:false, the default) and is a whole-repo operation besides; the surgical rollback reverses exactly the renames performed and restores exactly the files snapshotted, correct whether.planning/is tracked or ignored. The requirement's intent — atomic, safe rollback on failure — is met; PR-3 preserves this safer mechanism rather than regressing to a HEAD-sha reset. This is a deliberate deviation from the requirement's literal wording, surfaced here for the maintainer. - Real-world dir-layout fixture corpus with two named invariants (both are failure classes confirmed against real repository layouts while prototyping the migrator):
- Decimal multi-milestone directories keep distinct per-milestone prefixes — a tree with ≥2 milestones in dotted form must not flatten distinct milestones into one phase counter.
- Project-prefixed single-milestone directories don't no-op — a single-milestone legacy tree (
HQ-01,HQ-02…) must derive its milestone (01) from## vN.M/ STATE.md and emit bracket dirs, not exit "already migrated."
- M-NN → bracket lift preserves the milestone integer —
2-01→01,2-04-01→04.01. - HARD-REFUSE when
project_codeis absent — bracket requires a project code; the migrator refuses (throws) rather than emitting a malformed prefix.
8. Single-sourced, generated injection block with a verify parity check (PR-6)
The convention block injected into agent and workflow definitions is generated from one canonical source, and a verify check enforces parity between the source and every injected copy. This keeps the injection machine-uniform across the surfaces the #612 approval scopes — ~34 agent definitions, ~90 workflow files, and 7 phase templates — so PR-6 cannot be reviewed line-by-line and does not silently drift. (On this base the surface counts are: agents/*.md = 34; gsd-core/workflows/**/*.md = 114 — 91 top-level command workflows (the ~90 the approval scopes) plus 23 nested mode/step/template fragments under discuss-phase/ (11), execute-phase/ (5), help/ (4), and plan-phase/ (3); the "7 templates" figure is the approval's stated parity scope, corresponding to the phase-emitting subset of gsd-core/templates/, e.g. phase-prompt.md, roadmap.md, state.md, planner-subagent-prompt.md, summary.md. The generator, not this ADR, pins the exact set.) There is no existing block-injection pattern in the repo — PR-6 builds it net-new, with the parity check as the acceptance gate.
9. Guardrail — bracket is not a capability
The repo has a capability ecosystem (ADR-1244, ADR-1016, ADR-1143, ADR-1213). Capabilities are add-only descriptors; a capability cannot deprecate M-NN. Bracket is therefore core config-gated behavior, not a capability descriptor. Keep three levels unblurred:
- Capability model = add-only → bracket is not a capability (this guardrail).
- Implementation = additive, gated, legacy byte-untouched (Decision 2) — the code is additive.
- Convention direction = M-NN terminal, end state two (Decision 5) — the convention consolidates.
"Additive implementation" must not contaminate the deprecation stance: the code is additive; the convention consolidates.
Phases
Each phase is an independently-green PR. Tolerant reads (PR-2) ship before emit (PR-4), so a partially-landed series never breaks legacy repos. Module homes are the current src/*.cts names (post-ADR-857 / #1267; core.cts no longer exists).
| PR | Scope | Primary modules |
|---|---|---|
| PR-0 | This ADR + plan-dimension spec + concrete collision characterization test. No behavior change. | docs/adr/612-*.md, tests/adr-612-collision-characterization.test.cjs |
| PR-1 | Core grammar: PhaseId type, parsePhaseId/renderPhaseId/toDir with fast-check round-trip properties, READING-B getMilestoneFromPhaseId (gated), bracket extractPhaseToken branch, comparator, sentinel + slug guards — all inside the single-owner leaf. |
phase-id.cts |
| PR-2 | Read path: bracket heading/dir/checklist tolerance + bracket-coherence checks. | roadmap.cts, roadmap-parser.cts, validate.cts, verify.cts |
| PR-3 | Migrator: legacy + M-NN → bracket; dry-run / dirty-guard / surgical rollback preserved; HARD-REFUSE on absent project_code; real-layout fixture corpus (two invariants). |
roadmap-upgrade.cts, roadmap-command-router.cts |
| PR-4 | Write path: bracket emit gated on convention; new-project default; STATE.md milestone: frontmatter; VALID_PHASE_ID_CONVENTIONS enum. |
phase.cts, state.cts, config.cts |
| PR-5 | Display + card: progress render + stats display_id route through the pure pair; statusline; convention card single-source. |
commands.cts, hooks/gsd-statusline.js |
| PR-6 | Generated injection: single-sourced convention block across agents / phase-emitting workflows / templates + verify parity check; canonical reference doc; grep-evidence gate. | gsd-core/references/, agents/*.md, gsd-core/workflows/**/*.md, gsd-core/templates/ |
Consequences
- Positive: every token is uniquely parseable; one pure round-trippable model under the single-owner regime; milestone-detection correct under READING-B; legacy (
null/ M-NN) repos byte-untouched; migration is opt-in and reversible; the convention count drops from a transient three to a terminal two. - Negative: a migration window in which reads must tolerate three forms; a second milestone authority under bracket (bracket integer vs STATE.md
milestone:) whose coherence-check teeth are advisory (W021, message-disambiguated per the folded W021 disposition below); thegetMilestoneFromPhaseIdreturn-form under bracket is ratified asvN.0(Decision 3 below), with the archive-dir glob coupled to that form.
Decisions to ratify
Resolved items are folded into the body above. These remain open and gate their named PR:
- Bare
02-04resolution (gates PR-1normalizePhaseName/ PR-2 resolvers). Options: (i) throw a disambiguation error; (ii) keep the current M-NN reading and let only the bracket path reject it; (iii) asurface: 'phase' | 'plan'context hint. The throw is a new behavior —normalizePhaseName('02-04')returns'02-04'today, it does not truncate — so it is not merely a fix. Not committed here. phase_namingvsphase_id_conventionaxis relationship (gates PR-4 build-default). Confirm these are two independent axes before wiring the new-project'bracket'default; the build-default object setsphase_namingbut omitsphase_id_convention.getMilestoneFromPhaseIdreturn-form under bracket + archive-dir naming (gated PR-1/PR-2) — RATIFIED 2026-07-24:vN.0(STATE.md parity, lowest churn), chosen over a bare integer; the archive-dir glob is value-coupled to this form and followsvN.0. PR-1 (#2258) ships this return-form. The separate milestone-identity normalization arc is unaffected.- Convention-card single-source location (gates PR-5/PR-6). Pin the single module/path so the ASCII grammar card renders identically at installer completion, migrator start (dry-run and apply), and in docs.
Folded (resolved): M-NN deprecation stance → terminal by consolidation (Decision 5). W021 renumber → void: W021 is pinned by tests/milestone-prefixed-convention.test.cjs and is kept, message-disambiguated (the earlier plan to renumber it is dropped). READING-B milestone source → Decision 6.
References
- Issue #612 (this epic) —
approved-enhancement, tracer-bullet sequence PR-0…PR-6. - Issue #39 (milestone-encoding origin), #565 (M-NN implementation).
- ADR-2121 (#2121) — phase-identifier parsing consolidation; single canonical owner
src/phase-id.cts; anti-divergence guard. #2128 is thePHASE_NUMBER_TOKEN_SOURCEconstant (src/phase-id.cts:44-54). - ADR-1244, ADR-1016 — capability model (guardrail, Decision 9).