03 — roadmap.py Decomposition: Prep + Clean Leaves
Goal
Begin decomposing scripts/plan_orchestrator/roadmap.py (11,875 lines, 30
cohesive clusters identified by full-file research on 2026-07-06) into a
scripts/plan_orchestrator/roadmap/ SUBPACKAGE — the largest and most
heavily test-coupled of the three files (119 of 395 test files import it by
qualified name, many reaching into underscore-private functions directly).
Do this LAST among the three files (Sections 03-05, after Section 02
stabilizes route_walk.py’s import surface) since roadmap.py is the
heaviest consumer of route_walk’s ~30 named symbols — splitting the
consumer after the dependency is stable minimizes moving parts.
Given the scale (30 clusters, two >600-line “god functions”), this
decomposition spans THREE sections (03/04/05) rather than one, per
routing.md §4 promotion discipline (a single section accumulating this
many distinct extraction batches would blow every section-body budget).
The _PHASE_SCRATCH_ROOT invariant — MUST preserve
tests/conftest.py carries an autouse fixture that
monkeypatch.setattr(roadmap, "_PHASE_SCRATCH_ROOT", tmp_path / ...) for
EVERY test in the 395-file suite. Two existing consumer modules
(route_progress_guard.py, review_chain.py) already read this back via
FUNCTION-SCOPED from scripts.plan_orchestrator.roadmap import _PHASE_SCRATCH_ROOT (not a top-of-file import) specifically so the
monkeypatch is visible at call time, not import time.
- The constant MUST be defined directly in
roadmap/__init__.py(the package’s own top-level module object —monkeypatch.setattr(roadmap, "_PHASE_SCRATCH_ROOT", ...)operates on that module object regardless of whetherroadmapis a flat file or a package__init__.py; packages ARE modules). - Any NEW submodule that reads
_PHASE_SCRATCH_ROOTat call time (the function_phase_scratch_dir, moving toroadmap_dispatch_utils.pyper the mapping below) MUST dofrom scripts.plan_orchestrator import roadmap as _roadmap_pkgthen reference_roadmap_pkg._PHASE_SCRATCH_ROOT(module-qualified, late-bound) — NEVERfrom scripts.plan_orchestrator.roadmap import _PHASE_SCRATCH_ROOTat its own top-of-file, which binds a stale copy before any test monkeypatch runs and silently breaks the fixture. This is the SAME patternroute_progress_guard.py/review_chain.pyalready use — extend it, do not invent a new one. - Add a regression test asserting
_phase_scratch_dir()reflects a monkeypatchedroadmap._PHASE_SCRATCH_ROOTafter the extraction (pin against silent regression).
Cluster -> submodule mapping — this section’s scope (leaves only)
Subpackage skeleton + the 5 clean-leaf clusters (each called by roadmap’s other clusters but calling nothing back — safest to extract first):
| Submodule | Responsibility | ~Lines | Key symbols |
|---|---|---|---|
roadmap/__init__.py | Stable public surface: orchestrate() (unchanged signature — cli_shared.py does from .roadmap import orchestrate at module scope) + _PHASE_SCRATCH_ROOT + re-exports of every underscore-private name any package module or test imports directly (full list finalized in Section 05) | ~450 (orchestrate body, unchanged) | orchestrate, _PHASE_SCRATCH_ROOT |
roadmap_dispatch_utils.py | Dispatch-arg composition, path/slug normalization, phase-scratch-dir resolution | ~230 | _compose_autopilot_dispatch_arg, _review_plan_pre_work_target, _section_path_slugs, _phase_scratch_dir, _resolve_plan_dir, _normalize_section_file_path |
roadmap_ask_user_options.py | Generic AskUserQuestion option default/normalize helpers | ~115 | _default_options, _normalize_option |
roadmap_step4_pacing.py | Step 4 pacing | ~67 | _step_4_pacing |
roadmap_special_plan_shapes.py | Section-less / research-phase plan-shape detection + terminus emission | ~146 | _is_section_less_plan, _is_research_phase_plan, _emit_section_less_route, _emit_research_phase_terminus |
roadmap_terminal_emitters.py | Generic terminal next_action builders (roadmap’s own inline-dict style — NOTE: next_action_builders.py already owns this responsibility for review_plan.py/fix_bug.py; roadmap.py’s parallel implementation is a pre-existing DRY gap, OUT OF SCOPE to unify here — flag as a follow-up bug if not resolved by Section 05, do not silently unify mid-split) | ~270 | _emit_dispatch, _emit_halt, _emit_ask_user_plan_candidates, _emit_section_terminus_advance |
Work items
- Create
scripts/plan_orchestrator/roadmap/subpackage:roadmap/__init__.pyinitially re-exports everything from a temporarily-retainedroadmap/_legacy.py(verbatim copy of today’sroadmap.py) soscripts.plan_orchestrator.roadmap.orchestrateand every existing import path resolve identically before any real extraction; delete the old top-levelscripts/plan_orchestrator/roadmap.pyfile in the same commit (replaced by the package). Fullscripts/plan_orchestrator/testssuite green; commit. - Extract the 5 clean-leaf clusters
(
roadmap_dispatch_utils.py,roadmap_ask_user_options.py,roadmap_step4_pacing.py,roadmap_special_plan_shapes.py,roadmap_terminal_emitters.py) out ofroadmap/_legacy.py; apply the_PHASE_SCRATCH_ROOTinvariant above to_phase_scratch_dir; add the regression test for the monkeypatch;roadmap/__init__.pyre-exports every moved name; full suite green; commit.