Phase 4 of epic #2121 (ADR-2121 Decision 7), closing the recurrence loop that
produced #2111 / #2114 / #2104: no module outside src/phase-id.cts may
re-implement phase-ID parsing without failing CI.
- phase-id.cts: add PHASE_NUMBER_TOKEN_SOURCE — the canonical phase-number-token
grammar (\d+[A-Z]?(?:\.\d+)*) for enumeration/scan call sites, the ANY-phase
counterpart to phaseMarkdownRegexSource(n)'s known-number lookup. Extend-only
(never touches normalizePhaseName; blast radius 79 fns / CRITICAL).
- scripts/lint-phase-id-drift.cjs: pure findPhaseIdRegexDrift(text) + scanRepo(root),
wired to `npm run check:phase-id-drift`. Flags a literal re-derivation of the
canonical token (both /\d/ and new-RegExp `\\d` escaping, plus the [A-Za-z] and
[.-] near-variants) anywhere in src/** outside phase-id.cts, unless sanctioned
with `// phase-id-owner: <reason>`. Narrow by design: bare \d+, digits-only
captures, \w ids, status-message text and pipe-tables are not flagged.
- tests/phase-id-drift-guard.test.cjs: fail-first drift cases (AC1) + live
scanRepo(ROOT) zero-drift (AC3) + identity guard — phase-id.cjs exports the
complete locked surface and no consumer re-exports a divergent copy (AC2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>