Files
FreeTLab 41b95fed44
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m45s
docs(openspec): archive native-alarm-ring and update the native-alarms spec
Close the SDD cycle for the ring architecture replacement: verified
with one critical (channel silence by omission) fixed and re-checked
before archive, delta merged into the main native-alarms spec (2
requirements removed, 5 added), artifacts archived byte-for-byte.
Phase 3 on-device QA (9 items) remains the mandatory human gate.
2026-07-12 12:36:22 +02:00

148 lines
22 KiB
Markdown

# Tasks: Native Alarm Ring
Change: native-alarm-ring
Reads: `spec.md` (delta), `design.md` (11 ADRs, 2-unit split). Grounding: every file:line below was
re-verified against live code this phase (not copied blind from design) — see per-task notes for the
handful of spots where live code required a correction to design's phrasing.
## Review Workload Forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | WU1 (Dart) ~550-650; WU2 (Kotlin) ~300-390; total ~870-1050 |
| 400-line budget risk | High (WU1 alone, atomicity-locked exception below); Medium-High (WU2, likely near/under 400 alone) |
| Chained PRs recommended | Yes |
| Suggested split | PR 1 (Dart pure-UI + port) -> PR 2 (Kotlin native rebuild) |
| Delivery strategy | stacked-to-main work-unit commits (resolved by orchestrator) |
| Chain strategy | stacked-to-main |
Decision needed before apply: No
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: High
The 2-unit split (Dart -> Kotlin) IS the chaining answer, already locked by design Sec.8 with an
explicit reversal-justification (Dart-first, not the proposal's original 3-unit/Kotlin-first order).
WU1 alone is likely to exceed 400 changed lines; this is a **documented, pre-approved exception**, not
a fresh decision — design Sec.8 proves WU1 cannot be split further: removing `audioPrearrancado` + the
3 port methods breaks compilation of every test referencing them, and Dart test compilation is
whole-suite, so lib and tests must land in the SAME commit for a green PR. No further split is
possible without shipping a broken intermediate state. No user decision needed before `sdd-apply`.
### Suggested Work Units
| Unit | Goal | Likely PR | Notes |
|------|------|-----------|-------|
| 1 | Dart pure-UI ring screen + reduced Android port + rewritten tests | PR 1 | Base: `main`. Atomic (design Sec.8) — lib+tests in one commit. Intermediate state (new Dart + OLD native) is shippable: no double audio, no `notImplemented`. |
| 2 | Kotlin native audio rebuild (`PluriWaveAlarmService`) + `MainActivity` surface deletion | PR 2 | Base: PR-1 branch. On-device QA gate (Phase 3). No JVM/instrumented test harness exists for this module (confirmed: no `android/app/src/test*` or `src/androidTest*` dirs) — code-inspection + static `rg` checks only. |
Strict TDD Mode is active project-wide, but WU1's RED/GREEN staging collapses into one atomic
boundary by construction (see Phase 1 header note) — this is design's own justified exception, not a
process skip.
---
## Phase 1 — Work Unit 1: Dart pure-UI ring screen + port reduction (PR 1, base: `main`)
**Atomicity note (Strict TDD applicability):** classic per-file RED-then-GREEN is impossible here —
deleting the 3 `PuertoAlarmasAndroid` methods and `audioPrearrancado` breaks compilation of every test
still referencing them, and `flutter test` compiles the whole suite as one unit (design Sec.7-8). The
"RED" state is therefore "the suite won't compile," which never gets its own commit; 1.1 fixes the
*known-good* baseline, 1.2-1.11 make the atomic edit (production + tests together), 1.12 is the
RED->GREEN collapse point. The strongest guarantee for "screen never touches audio" becomes
COMPILE-TIME: the fake has no override methods left, so no screen code can call them.
- [x] **1.1** [Baseline] `flutter test test/pantallas/pantalla_alarma_sonando_test.dart test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart test/pantallas/pantalla_alarma_sonando_scaffold_test.dart test/servicios/servicio_alarmas_android_test.dart` — confirm all green BEFORE any edit (reference state the atomic rewrite must return to).
### Production edits
- [x] **1.2** `lib/servicios/servicio_alarmas_android.dart` — delete from the `PuertoAlarmasAndroid` interface: `confirmarAudioFlutter` (L145), `forzarVolumenMediaParaAlarma` + doc (L147-152), `restaurarVolumenMedia` + doc (L154-157); delete the matching `ServicioAlarmasAndroid` overrides (L294-295, L297-299, L301-303). Satisfies Requirement "Ring screen is pure UI" / Scenario "Reduced Android port surface" (static/compile-verifiable).
- [x] **1.3** `lib/pantallas/pantalla_alarma_sonando.dart` — remove the `audioPrearrancado` ctor param + field (L24,28); delete fields `_volumenInicialFadeIn`/`_fadeStep`/`_fallbackPlayer`/`_estadoSub`/`_fallbackTimer`/`_fadeInTimer`/`_fallbackActivo`/`_radioIntentada`/`_audioFlutterConfirmado`/`_volumenMediaForzado`/`_volumenMediaRestaurado`/`_fadeInArrancado`/`_estadoAlarmas` (L35-50, incl. the "Captured while mounted" doc comment — `_estadoAlarmas` has no remaining caller once 1.3/1.5 below land, since `_detener`/`_posponer` already `context.read<EstadoAlarmas>()` inline); delete methods `_iniciarAlarma` (L59-119), `_iniciarFallback` (L121-131), `_forzarVolumenMediaUnaVez` (L133-148), `_iniciarFadeIn` (L150-188), `_aplicarVolumenGlobal` (L190-195), `_confirmarAudioFlutterListo` (L197-218), `_restaurarVolumenMediaUnaVez` (L220-235), `_liberarAudioLocal` (L237-248), `_silenciarAudio` (L306-332), `_assetFallback` (L470-474, top-level fn); reduce `initState` to `super.initState()` only (drop the `_estadoAlarmas` capture + post-frame `_iniciarAlarma` call, L52-57 — native already started the ring before this screen ever mounts, nothing to bootstrap); drop `final radio = context.read<EstadoRadio>();` + `await _silenciarAudio(radio);` from `_detener` (L259,261) and `_posponer` (L280,286) per D9; reduce `dispose()` to `super.dispose();` only (L356-364 — no player/timer/subscription left to tear down); drop now-unused imports `package:just_audio/just_audio.dart` (L5), `../estado/estado_radio.dart` (L9), `../servicios/servicio_audio.dart` (L13, `EstadoReproduccion` had no other caller). Keep verbatim: `_salidaEnCurso` guard, `PopScope`/`build()`, `_dismissScreen`, `_opcionesSnooze`, `_hora`, layout.
- [x] **1.4** Same file — rewrite the status `Text` (L427-434) per D8: replace the `_fallbackActivo ? ... : _radioIntentada ? ... : ...` tri-state with `alarma.emisora != null ? localizedStationName(l10n, alarma.emisora!.nombre) : l10n.alarmRingingNotificationTitle`. Grounding: `display_names.dart` (imported already, L10) exports `localizedStationName` (verified, same call shape already used in `servicio_alarmas_android.dart:265,270`); `alarmRingingNotificationTitle` ("PluriWave alarm"/"Alarma PluriWave") is the deliberately NEUTRAL existing key — design Sec.4 D8 states all 3 old tri-state keys (`alarmRingingFallbackActive/TryingStation/PreparingFallback`) become unused, so none of them should be reused even for the no-station case. No new l10n key (spec Non-Functional Notes; 13 arb files confirmed, none touched).
- [x] **1.5** `lib/app.dart` — delete `_volumenInicialFadeInAlarmas` const (L92) and `_prearrancarAudioAlarma` method (L376-391); remove its call site `await _prearrancarAudioAlarma(alarma);` (L356) and the `audioPrearrancado: alarma.emisora != null,` arg (L363) from the `PantallaAlarmaSonando(...)` push inside `_mostrarAlarmaSonando`. Keep SKIP/POSTPONE/PRE_NOTICE routing + duplicate-delivery guard (`_alarmaSonandoActiva`/`_alarmaSonandoId`) untouched — confirmed no other symbol overlap via repo-wide grep.
- [x] **1.6** `test/helpers/fakes_alarmas.dart` — delete fields `volumenForzado` (L20-22), `volumenRestaurado` (L24-26), `puertaConfirmarAudioFlutter` (L33-38), `fallaConfirmarAudioFlutter` (L40-43) + their doc comments; delete overrides `confirmarAudioFlutter` (L77-86), `forzarVolumenMediaParaAlarma` (L88-91), `restaurarVolumenMedia` (L93-96). Keep `programadas`/`canceladas`/`detenidas`/`ocultadas` untouched (still part of the interface). This is the compile-time enforcement for "Ring screen is pure UI."
### Test edits (same unit as production — compile-coupled)
- [x] **1.7** `test/servicios/servicio_alarmas_android_test.dart` — delete tests `forzarVolumenMediaParaAlarma invoca overrideMediaVolumeForRing con fraction` (L110-122) and `restaurarVolumenMedia invoca restoreMediaVolume sin argumentos` (L124-136). Keep both `programar` tests + `solicitarExencionBateria` test.
- [x] **1.8** `test/pantallas/pantalla_alarma_sonando_scaffold_test.dart` — drop `audioPrearrancado: true,` (L94) from `_montarPantalla`'s push call. No other change (both scaffold/animation tests already assert no audio).
- [x] **1.9** `test/pantallas/pantalla_alarma_sonando_test.dart` — in `_montarPantalla`: remove the `audioYaReproduciendo` param + its `if` gate (L40, L47-50), make `audio.emitirEstado(EstadoReproduccion.reproduciendo)` unconditional (matches the other two sibling helpers, already unconditional); drop `audioPrearrancado: true,` (L109). Delete groups `rampa anclada y override diferido al primer audio` (L172-222, 2 tests) and `handoff con audio prearrancado ya reproduciendo (regresion)` (L224-246, 1 test) entirely — fade-ramp/handoff-specific, compile-dead once 1.2/1.3 land. Delete test `salir a mitad de rampa restaura la ganancia del reproductor...` (L273-291) and group `restore de volumen de medios con dispose como unico llamador` (L294-308) entirely.
- [x] **1.10** Same file — trim survivors: `posponer 5 min ...` (L150-170) drop the `entorno.audio.pausas` assertion (L160), keep `snoozeHasta`/screen-dismissed/`ocultadas contains id`/`programadas.last.snoozeHasta`; `el boton atras del sistema se comporta como Detener...` (L249-271) drop the `entorno.audio.pausas` assertion (L261-265), keep `findsNothing` + `ocultadas contains id`. Both descriptions currently claim the screen "detiene el audio"/"para la radio" — reword to drop that now-false claim (e.g. "posponer 5 min pospone y cierra (S2-R1-B)"; "el boton atras del sistema se comporta como Detener: finaliza y cierra").
- [x] **1.11** `test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart` — drop `audioPrearrancado: true,` from `_montarComoRaiz` (L64) and `_montarConHistorial` (L111); delete group `PantallaAlarmaSonando media-volume override restore (Slice 2)` (L360-429, 3 tests) entirely. Rewrite the decoupling regression (L431-445, group `EstadoRadio reproduccion normal nunca dispara el override de volumen...`): **grounding correction vs. design**`_buildEnv()` (L120-157, same file) already seeds one `android.programar` call via `estadoAlarmas.guardarAlarma(...)` -> `EstadoAlarmas.guardarAlarma` (`lib/estado/estado_alarmas.dart:110`) calls `android.programar(guardada)`, so `programadas` is NOT empty at test start; design's "programadas/detenidas/ocultadas all empty" only holds for `detenidas`/`ocultadas`. Correct rewrite: capture `programadas.length` BEFORE `radio.reproducir(emisora)` + `radio.audio.pausar()`, assert it is UNCHANGED after, plus `detenidas`/`ocultadas` stay empty. Rename group/test description to drop "el override de volumen" framing (concept no longer exists) in favor of "nunca toca el puerto de alarmas (guardia de regresion, decoupling)".
### Verification
- [x] **1.12** [RED->GREEN collapse] `flutter test test/pantallas/pantalla_alarma_sonando_test.dart test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart test/pantallas/pantalla_alarma_sonando_scaffold_test.dart test/servicios/servicio_alarmas_android_test.dart` — all green. `rg -n "confirmarAudioFlutter|forzarVolumenMediaParaAlarma|restaurarVolumenMedia|audioPrearrancado" lib test` -> 0 matches (repo-wide grep already confirmed these symbols exist ONLY in the 6 files touched by 1.2-1.11 plus the Kotlin files touched in Phase 2).
- [x] **1.13** `flutter analyze` — must be 0 issues. If `_Entorno.audio` (`pantalla_alarma_sonando_test.dart`) is flagged `unused_field` after 1.10's trim (no surviving test reads `entorno.audio` once `audio.pausas` is dropped everywhere in this file), remove the field + its constructor/return plumbing then; keep `estadoAlarmas`/`android` (both still read).
- [x] **1.14** [Commit] Work-unit commit (stacked-to-main, base: `main`): "Dart pure-UI ring screen + reduced Android alarm port." WU1's size exceeds 400 lines by construction (atomicity, see forecast above) — do not re-litigate the split.
---
## Phase 2 — Work Unit 2: Kotlin native audio rebuild (PR 2, base: PR-1 branch)
**No test harness exists for this module** (confirmed: no `android/app/src/test*` or `src/androidTest*`
directories in this project). Verification per task is code-inspection + static `rg` checks; the real
behavioral gate is the on-device QA checklist in Phase 3 (proposal Risk table: "Kotlin not
agent-compilable -> on-device QA is the gate").
### `PluriWaveAlarmService.kt`
- [x] **2.1** Add imports `android.os.SystemClock`, `android.media.AudioManager`, `android.media.AudioFocusRequest` (none of the 3 currently imported — confirmed against current import block L1-23).
- [x] **2.2** Add companion consts `FADE_TICK_MILLIS = 50L`, `FADE_RANGE_DB = 40.0f` and pure fn `computeFadeVolume(elapsedMs: Long, fadeMs: Long, ceiling: Float): Float` exactly per design D1 (`fadeMs<=0 -> ceiling.coerceIn(0f,1f)`; else `fraction=(elapsedMs/fadeMs).coerceIn(0f,1f)`, `gainDb=fraction*FADE_RANGE_DB-FADE_RANGE_DB`, `curve=10^(gainDb/20)`, return `(ceiling*curve).coerceIn(0f,1f)`). Delete `initialVolume()` (L309-314), `startFadeIn()` (L316-343), `fadeInRunnable` field (L42) — replaced by 2.3-2.5.
- [x] **2.3** Add fields `fadeAnchorElapsedMs: Long` + `fadeLoopRunnable: Runnable?`; in `startAlarm()` set `fadeAnchorElapsedMs = SystemClock.elapsedRealtime()` right after `activeAlarmId = alarmId` (L92), replacing the deleted `flutterOwnsRing = false` reset (L93-97, see 2.8) — captured BEFORE `startForeground`/`startAudio` per D2.
- [x] **2.4** In `startAudio()` (L148, before the 3-stage fallback chain begins): start the single ring-anchored fade loop — `mainHandler.postDelayed` every `FADE_TICK_MILLIS`, reads `player` fresh each tick (survives the 3-stage swap per D1), guarded by `activeAlarmId == alarmId`, stops rescheduling once `elapsed >= fadeInSegundos*1000L`. Replace `initialVolume(volume, fadeInSegundos)` at `startStationAudio` (L209) and `startFallbackAudio` (L267) with `computeFadeVolume(SystemClock.elapsedRealtime()-fadeAnchorElapsedMs, fadeInSegundos*1000L, volume)`.
- [x] **2.5** In both `setOnPreparedListener` callbacks (`startStationAudio` L220-229, `startFallbackAudio` L274-279): replace `it.start(); startFadeIn(alarmId, it, volume, fadeInSegundos)` with recomputing `computeFadeVolume(...)` at prepare-time and `it.setVolume(current, current)` BEFORE `it.start()` (Requirement "No-fade path starts pop-free"; D2 — a source joining late enters click-free at the elapsed level instead of restarting at 1%). Rename `cancelFadeIn()` (L345-348) -> `cancelFadeLoop()`, cancelling `fadeLoopRunnable` via `mainHandler.removeCallbacks`; keep its call site in `stopAlarm()` (L382).
- [x] **2.6** Add `requestAlarmAudioFocus()` (called once, top of `startAudio()`, before the fallback chain) and `abandonAlarmAudioFocus()` (called in `stopAlarm()`'s full-teardown branch, next to `releaseWakeLock()` L391) per D3: O+ uses `AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT).setAudioAttributes(alarmAudioAttributes()).setOnAudioFocusChangeListener(noop)`, stored to abandon the exact request; below O uses the deprecated 3-arg `requestAudioFocus(noop, STREAM_ALARM, AUDIOFOCUS_GAIN_TRANSIENT)`. Reuse `alarmAudioAttributes()` (L355-359) unchanged (D10 — `USAGE_ALARM` is load-bearing).
- [x] **2.7** Channel silence + migration (D4): rename `CHANNEL_ID` (L554) `"pluriwave_alarm_fire_v2"` -> `"pluriwave_alarm_fire_v3"`; add `LEGACY_CHANNEL_FIRE_V2 = "pluriwave_alarm_fire_v2"`; rename `KEY_CHANNELS_MIGRATED_V2` (L558) -> `KEY_CHANNELS_MIGRATED_V3 = "channels_migrated_v3"`; in `ensureChannel()` (L623-647) delete the `setSound(Settings.System.DEFAULT_ALARM_ALERT_URI, ...)` block (L638-644) entirely (no `setSound` call = silent), keep `enableVibration(true)` (D5) + `IMPORTANCE_HIGH` (kept for FSI); in `migrateLegacyChannels()` (L652-660) add a 3rd `runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE_V2) }` alongside the existing native+fire deletes, guarded by the renamed `KEY_CHANNELS_MIGRATED_V3` flag (`deleteNotificationChannel` is a safe no-op when absent — correct on both fresh installs and v2 devices).
- [x] **2.8** Kill the handoff surface (D7): delete companion `flutterOwnsRing` field + its doc comment (L567-589); delete the backstop `if (!flutterOwnsRing) { runCatching { MainActivity.restoreMediaVolumeBestEffort() } }` in `stopAlarm()` (L392-404) — `abandonAlarmAudioFocus()` (2.6) takes its place, unconditionally; delete the matching backstop+guard in `onDestroy()` (L541-549), keep the `stopAlarm(activeAlarmId)` call (L538). Rewrite the class-level doc comment (L25-35) — it currently describes the OLD split-ownership/`confirmFlutterAudio` model; replace with sole-ownership description.
### `MainActivity.kt`
- [x] **2.9** Delete cases `"confirmFlutterAudio"` (L156-173), `"overrideMediaVolumeForRing"` (L228-233), `"restoreMediaVolume"` (L234-238) from the `alarmMethodChannel` handler; delete `overrideMediaVolumeForRing()` private fn + its section-header comment block (L310-348), `restoreMediaVolume()` private fn (L350-375); delete companion fields `mediaVolumeOverridden` + doc (L917-924), `capturedMediaVolume` (L926-927), fn `restoreMediaVolumeBestEffort()` + doc (L946-972). Keep the `AudioManager` import (L11) — still required by `getActiveAudioDevice()`/audio-devices channel (unrelated feature; confirmed no other overlap via repo-wide grep). 2.8 and 2.9 are mutually coupled (deleting `flutterOwnsRing` and its only setter/reader must land together) — order between them doesn't matter, both must complete before 2.10.
### Verification
- [x] **2.10** [Static, code-inspection] `rg -n "flutterOwnsRing|confirmFlutterAudio|overrideMediaVolumeForRing|restoreMediaVolume|restoreMediaVolumeBestEffort|startFadeIn|initialVolume\(" android/` -> 0 matches. `rg -n "setStreamVolume" android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt` -> 0 matches (Requirement "no system volume writes, ever"). `rg -n "pluriwave_alarm_fire_v2" android/` -> exactly 1 match, the new `LEGACY_CHANNEL_FIRE_V2` constant value.
- [x] **2.11** `flutter analyze` — must stay 0 (Dart side untouched by WU2; sanity re-check only).
- [x] **2.12** [Commit] Work-unit commit (stacked-to-main, base: PR-1 branch): "Kotlin native ring audio rebuild — dB fade, manual focus, silent channel v3."
---
## Phase 3 — Final sweep + on-device QA gate (closes the change, spans both units)
- [ ] **3.1** Attempt full `flutter test`. Known intermittent hang, pre-existing/unrelated to this change (implicated: `test/estado/estado_alarmas_ejecuciones_test.dart`, `test/servicios/servicio_grabacion_radio_test.dart` — per `sdd-init/pluriwave` cache and confirmed precedent in `eq-audiofocus-reapply`/`persistence-corruption-guard`). If it hangs, fall back to the targeted list: `flutter test test/pantallas/pantalla_alarma_sonando_test.dart test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart test/pantallas/pantalla_alarma_sonando_scaffold_test.dart test/servicios/servicio_alarmas_android_test.dart` — must be green. Do not block delivery on the hang.
- [ ] **3.2** `flutter analyze` repo-wide — 0 issues.
### On-device QA checklist — MANDATORY human gate, leave UNCHECKED (real device, e.g. POCO X7 Pro)
- [ ] **3.3** Audible exponential dB fade (not linear/late) at a configured fade of 15-30s (Requirement "Exponential dB fade-in ceiling").
- [ ] **3.4** Volume ceiling correct: configured "N%" is approx N% of the device ALARM stream at full ramp.
- [ ] **3.5** No start pop on any of the 3 sources (primary station, fallback station, bundled WAV).
- [ ] **3.6** No second audible source: Dart plays nothing; the native `STREAM_ALARM` player is the only audible source; the fire notification (`pluriwave_alarm_fire_v3`) posts with no sound (Scenario "Fire notification posts with no sound").
- [ ] **3.7** Dismiss, snooze, and system-back all stop the ring, abandon audio focus, and the user's other-app audio (music/podcast/another radio app) resumes.
- [ ] **3.8** Media volume (`STREAM_MUSIC`) at 0 still rings audibly; `STREAM_ALARM` at 0 is silently accepted (documented, not a regression).
- [ ] **3.9** Screen-off full-screen-intent delivery + ring fires reliably from a killed app on POCO X7 Pro after HyperOS Autostart is enabled.
- [ ] **3.10** Real upgrade-path channel migration: install a build with the v2 channel, upgrade to this build, fire an alarm — notification posts silently, and Ajustes > Notificaciones shows `pluriwave_alarm_fire_v3` present with v2 gone (Scenario "v2 deleted exactly once on upgrade"; added post-verify per WARNING-1).
- [ ] **3.11** Focus hygiene: during the ring `adb shell dumpsys audio` shows a TRANSIENT focus holder for the app on STREAM_ALARM; after dismiss the entry is gone (added post-verify per WARNING-1).
## Requirement Traceability
| Spec Requirement | Satisfied by |
|---|---|
| Sole native ring-audio ownership | 1.3, 1.5, 2.4-2.6, 3.6 |
| Exponential dB fade-in ceiling | 2.2, 2.4-2.5, 3.3-3.4 |
| Manual transient focus; no system volume writes | 2.6, 2.8, 2.10, 3.7 |
| Notification channel migration v2->v3 | 2.7, 2.10, 3.6 |
| Ring screen is pure UI | 1.2-1.3, 1.6, 1.12 |
| REMOVED: Ring-scoped device-volume override | 1.2-1.3, 1.6, 2.6, 2.8-2.9, 2.10 (regression-proof by deletion + compile) |
| REMOVED: Single fade-in driver across handoff | 1.3, 2.2-2.5 |
## Dependency Summary
Phase 1 -> Phase 2 (stacked-to-main: PR 2 bases on the PR 1 branch) -> Phase 3 (closes the change,
spans both units). Within Phase 1: 1.2 (port interface) before 1.6 (fake implements it); 1.3-1.5
independent files, can proceed in any order; 1.7-1.11 (test rewrites) depend on 1.2/1.3/1.6 existing;
1.12-1.14 sequential. Within Phase 2: 2.1-2.3 sequential (imports/consts/anchor before use); 2.4-2.6
sequential (loop+focus depend on 2.2's consts and 2.3's anchor); 2.7 is independent, can run in
parallel with 2.1-2.6; 2.8/2.9 mutually coupled (see 2.9's note), both required before 2.10-2.12.