Files
msd-core/docs/adr/612-bracket-phase-id-convention.md
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

30 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.cts the 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

MSD 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:

  1. The default config value is null, not M-NN — msd-core/bin/shared/config-defaults.manifest.json:14 sets "phase_id_convention": null. A fresh repo speaks no milestone convention.
  2. 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 in src/verify.cts:1724 (phase_id_convention === 'milestone-prefixed' branch, warning at :1733). W021 is pinned by shipped tests/milestone-prefixed-convention.test.cjs and cannot be renumbered.
  3. M-NN is the default and only migrator target today — src/roadmap-command-router.cts:190 defaults convention = 'milestone-prefixed' and :204 rejects any other --convention value; src/roadmap-upgrade.cts:593 writes phase_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

[MSD.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    MSD  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 [MSD.02] 05.03-01
On-disk dir (Option B, no brackets) MSD.02-05.03-some-feature/
Plan/Summary file 05.03-01-PLAN.md (milestone in dir prefix, not filename)
ROADMAP phase heading ### [MSD.02] 05.03: Name
ROADMAP milestone heading ## [MSD.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 ([MSD.02] 05:); a milestone heading is a bracket followed by a name ([MSD.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. On 2-01.02-01 it captures 2, 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 leading 2 and zero-pads it. Everything after the leading integer is silently dropped.

Result (empirically confirmed against msd-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 msd-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('MSD.02-05.03-01') yields exactly one tuple { project:'MSD', 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;     // 'MSD'
  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;         // '[MSD.02] 05.03-01'
function toDir(id: PhaseId, slug: string): string;   // 'MSD.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)) === x for every well-formed display string x;
  • toDir(parse(display), slug) === dir for 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 on phase_id_convention: 'bracket'). null remains 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 getMilestonePhaseFilter nudge (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('MSD.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) sets const dryRun = options.dryRun !== false and prints the full plan without mutating (:485-487).
  • Dirty-tree guard — a non-empty git status --porcelain throws 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-524 and the rollback block :598-616). A git reset --hard + git clean restores 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):
    1. 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.
    2. 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_code is 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; msd-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 msd-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:

  1. Capability model = add-only → bracket is not a capability (this guardrail).
  2. Implementation = additive, gated, legacy byte-untouched (Decision 2) — the code is additive.
  3. 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 phase counting (both total_phases derivations) and the #1514 retirement filter; the write-adjacent enumerator call sites that consume that same counting (milestone complete, state update-progress) now thread the resolved convention explicitly instead of inheriting a lazy default — see the amendment note below the table. health-diagnostic-rules/phase-structure.cts, health-diagnostic-rules/roadmap-disk-consistency.cts, health-diagnostic-rules/state-consistency.cts, milestone.cts, phase-id.cts, phase.cts, planning-snapshot.cts, planning-workspace.cts, roadmap-parser.cts, roadmap.cts, state.cts, validate.cts, workstream-inventory.cts, scripts/lint-phase-id-drift.cjs, scripts/lint-phase-enumeration-drift.cjs
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 (emit + milestone: frontmatter only — counting stays in PR-2, above), config.cts
PR-5 Display + card: progress render + stats display_id route through the pure pair; statusline; convention card single-source. commands.cts, hooks/msd-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. msd-core/references/, agents/*.md, msd-core/workflows/**/*.md, msd-core/templates/

AMENDED 2026-08-18 — APPROVED AND RATIFIED by maintainer. The PR-2 / PR-4 rows above are amended on three points, scoped to what actually ships in PR-2 (#2761) after the completion-seam work below was split out of it. The boundary between PR-2 and PR-4 is concern, not file: PR-2 owns everything that derives a number or a directory set from the ROADMAP; PR-4 owns everything that emits a bracket identifier.

  1. state.cts read-tolerance is in PR-2, not PR-4. Both total_phases derivations (buildStateFrontmatter, cmdStateSync) and the #1514 retirement filter are a parse-and-count of the ROADMAP, the same read-path concern PR-2 already owns in roadmap.cts and validate.cts. PR-4 retains only state.cts's write concerns: bracket emit, the milestone: frontmatter field, and VALID_PHASE_ID_CONVENTIONS.

  2. The shared phase-directory enumerator (listMilestonePhaseDirs) defaults its phaseIdConvention argument to "not yet resolved — resolve from config" rather than "resolved, and not bracket." That default reaches every call site that does not pass the parameter explicitly, including two commands that write: cmdMilestoneComplete (milestone.cts) archives whatever directory set the enumerator returns, and cmdStateUpdateProgress (state.cts) gates and counts off it. Both are now explicit about it: each resolves phase_id_convention once (via resolvePhaseIdConvention) and threads it into its own enumerator call, rather than relying on the lazy default to resolve it the same way. On a project that has opted into 'bracket', this means milestone complete archives the milestone's real bracket-declared phase directories instead of failing to recognize them — the same set the read path already reports. state update-progress's reported and written percent was already independently correct (it comes from buildStateFrontmatter, which resolves and threads its own convention); the explicit thread at its own enumerator call closes a second, silent, separately-resolved answer to the same question, not a defect in the reported percent — mutation-tested: reverting only this thread leaves every existing percent/completed/total assertion on this command green, because the enumerator's own lazy resolve-from-config default answers the identical question the explicit thread does. cmdMilestoneComplete's enumerated set is pinned by a test (tests/adr-612-bracket-phase-counting.test.cjs, round-11 BLOCKER block) so a future regression to the pass-all-degrade legacy reading cannot silently move what milestone complete archives without failing a test. state update-progress's own call site is pinned differently, matching what it actually gates: not the reported percent, but the #3233 zero-plans no-op — a bracket milestone whose declared phases carry no plans on disk stays a no-op only when a directory that plainly does not belong to the milestone window is excluded from this call site's enumerated set; swept in by a pass-all degrade, the no-op stops firing (also tests/adr-612-bracket-phase-counting.test.cjs, round-11 BLOCKER block, round-12 addition). A null / milestone-prefixed project's behavior at both call sites is unchanged either way — its resolved convention is the same value the lazy default would have produced.

  3. Two modules the table omitted, and one explicitly excluded. phase-id.cts is not PR-1-only: PR-2 consumes and extends the owner-sanctioned read sources (BRACKET_PHASE_TOKEN_SOURCE, PHASE_HEADING_PREFIX_SRC) inside the ADR-2121 canonical owner, and the phase-key derivation (phaseKeyFromDir) via an optional convention parameter forwarded to extractPhaseToken (additive, defaulted-absent, byte-identical at every existing call site). planning-workspace.cts had no row at all; it hosts the resolvePhaseIdConvention resolver every gated read (and, per point 2, both write-adjacent call sites above) consults. Both are PR-2 foundations, not scope creep. Explicitly excluded from PR-2: the completion-seam threading of convention through isPhaseArtifact/scopeToPhase and the rest of the phase-completion chain (phase-id.cts, ~424 lines) — round-11 review directed this split into a separate, stacked follow-up PR under epic #612, never reviewed as part of PR-2, and it ships independently.

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); the getMilestoneFromPhaseId return-form under bracket is ratified as vN.0 (Decision 3 below), with the archive-dir glob coupled to that form.
  • Negative (added 2026-08-18): because the tolerant reads land before emit, a repo that opts into 'bracket' between PR-2 and PR-4 sees corrected counters, corrected milestone scoping, and corrected archival in commands that write (state sync, state update-progress, milestone complete) while msd phase add still emits legacy ids. That asymmetry is the deliberate cost of read-before-write sequencing; it closes at PR-4. Repos on null / milestone-prefixed remain byte-identical throughout. The completion-seam threading (point 3 above) is a separate, later cost: until its follow-up PR lands, isPhaseArtifact/scopeToPhase and the phase-completion chain do not yet honor a resolved bracket convention, independent of anything PR-2 changes.

Decisions to ratify

Resolved items are normally folded into the body above; ratified items retained below preserve review history. Only items explicitly marked open still gate their named PR:

  1. Bare 02-04 resolution (gates PR-1 normalizePhaseName / 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) a surface: '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.
  2. phase_naming vs phase_id_convention axis relationship (gates PR-4 build-default). Confirm these are two independent axes before wiring the new-project 'bracket' default; the build-default object sets phase_naming but omits phase_id_convention.
  3. getMilestoneFromPhaseId return-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 follows vN.0. PR-1 (#2258) ships this return-form. The separate milestone-identity normalization arc is unaffected.
  4. 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.
  5. PR-2 / PR-4 phase boundary, the write-observability of gated reads, and the completion-seam split (PR-2, i.e. #2761) — RATIFIED by maintainer. The amendment note beneath the Phases table, dated 2026-08-18, is approved. It (a) moves state.cts ROADMAP-counting and retirement filtering into PR-2 while leaving emit and milestone: frontmatter in PR-4, (b) states that milestone complete and state update-progress now explicitly thread the resolved convention into the shared enumerator rather than inheriting its lazy default, pinned by tests, and (c) confirms the completion-seam threading (isPhaseArtifact/scopeToPhase) ships in a separate, stacked follow-up PR under epic #612, not in PR-2. Raised by the #2867 review (PR-2, round-11) and approved by the maintainer in #2867 review 5012940978 (2026-08-24), which withdrew the ratification blocker in these terms: "Maintainer has approved the amended PR-2 scope... There is no separate ratification process in this repo; maintainer approval is the standard." That review is the receipt for this item; maintainer approval is the repository's ratification standard, so no separate ratification step remains.

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 the PHASE_NUMBER_TOKEN_SOURCE constant (src/phase-id.cts:44-54).
  • ADR-1244, ADR-1016 — capability model (guardrail, Decision 9).