Research — Decompose agent/mission.py god-module (#2056)

Mission: decompose-mission-god-module-01KVXHF8 Target: src/specify_cli/cli/commands/agent/mission.py — 4125 LOC, 62 top-level defs (61 def + 1 class), 8 @app.commands. Base: worktree sk-2056 on origin/main c3814ec5a (commit 4d8d3af0c). Goal: decompose the remainder into cohesive, independently-testable seams + a thin command-registration shim, preserving the public agent mission CLI surface byte-for-byte. Research artifacts only — no source changes.

0. Prior slice + sibling-mission context (read first)

  • Already extracted (mission 01KVMBD6): the planning-commit pipeline now lives in src/specify_cli/coordination/commit_router.py (commit_for_mission + _resolve_primary_target_branch, _materialise_coord_worktree, _resolve_mid8, _stage_artifacts_in_coord_worktree, _any_path_absent, _is_empty_changeset_error, _try_advance_ref). Do NOT re-extract that.
  • Sibling #2058 (branch kitty/mission-decompose-agent-tasks-god-module-01KVWVAR) routed agent/tasks.py away from mission.py._planning_commit_worktree. That branch is NOT on this worktree's base. On THIS base, tasks.py still imports 3 symbols from mission.py (see §4). Treat that import edge as live; coordinate disposition with #2058 at merge time.

1. Public CLI surface — frozen contract (golden characterization test)

app = typer.Typer(name="mission", ..., no_args_is_help=True) (mission.py:91). 8 subcommands. Every name + flag below is the byte-for-byte contract a golden CLI characterization test must pin BEFORE any extraction.

Subcommand (CLI name)deflinePositionalOptions (exact flags)
branch-contextbranch_context1443--json, --target-branch
createcreate_mission1519mission_slug (arg)--mission-type, --mission (hidden/deprecated), --json, --target-branch, --friendly-name, --purpose-tldr, --purpose-context, --pr-bound/--no-pr-bound, --branch-strategy, --start-branch, --force-recreate-coordination-branch
check-prerequisitescheck_prerequisites1800--mission, --json, --paths-only, --include-tasks, --require-tasks
record-analysisrecord_analysis1897--mission, --input-file (default -), --agent, --json
setup-plansetup_plan2043--mission, --json
acceptaccept_feature2606--mission, --mode, --json, --lenient, --no-commit, --diagnose
mergemerge_feature2661--mission, --target, --strategy, --push, --dry-run, --keep-branch, --keep-worktree, --auto-retry/--no-auto-retry
finalize-tasksfinalize_tasks2805--mission, --json, --validate-only, --target-branch

Contract notes the golden test must also pin (NOT just flag names):

  • JSON envelope shape under --json for success AND error paths. Every command funnels errors through _emit_json({...}) + typer.Exit(1); _with_cli_version / _with_mission_aliases decorate payloads (mission.py:408-421). tests/integration/test_json_envelope_strict.py already guards this — extend, don't replace.
  • Exit codes: all error paths raise typer.Exit(1); success is implicit 0.
  • --mission alias: the option flag is --mission but the parameter is named feature in most defs — alias surface, do not rename.
  • accept / merge are thin delegators to top_level_accept (cli/commands/accept.py) and top_level_merge (cli/commands/merge.py) imported at mission.py:41-42.

2. Function inventory (62 defs, LOC, purpose, complexity flag)

Mega = >150 LOC (needs internal decomposition); big = 60-150 LOC.

lineLOCnamepurposeflag
10310_extract_wp_ids_from_task_filesWP-id list from filenames
11312_branch_tree_relative_pathrepo-relative path
12580_stage_finalize_artifacts_in_coord_worktreecopy planning artifacts into coord worktree, skip coord-owned status filesbig
2058_emit_console_or_json_errordual-channel error emit
21337_emit_check_prerequisites_detection_errorcheck-prereq detection error payload
25018_paths_only_payload--paths-only projection
26836_emit_check_prerequisites_resultcheck-prereq success payload
30428_collect_finalize_artifactsgather artifacts for finalize commit
3328_normalize_owned_file_pathnormalize owned-file path
34012_is_mission_specs_owned_fileowned-file is under kitty-specs?
35224_owned_files_yaml_is_explicit_empty_listYAML owned_files: [] detect
37617_raw_frontmatter_has_fieldraw frontmatter field present?
39315_invalid_mission_specs_owned_filesowned-files validation rule
4089_with_cli_versioninject cli_version into payload
4175_with_mission_aliasesinject mission aliases into payload
4225_emit_jsonstdout JSON emit shim
4275_ensure_branch_checked_outno-op stub (*args)
4325_utc_now_isotimestamp
43715_read_feature_metaread meta.json
45214_read_meta_for_pr_boundmeta read for pr-bound
46616_read_meta_for_emissionmeta read for event emission
4829_resolve_feature_target_branchtarget-branch resolution
491103_inject_branch_contractbuild branch-contract payload (current/base/target/strategy)big
59418_git_local_or_remote_branch_existsbranch existence check
61239_resolve_primary_branch_for_recommendationprimary-branch recommendation
65143_switch_to_start_branchcheckout/create start branch
69424_enforce_git_preflightgit preflight gate
71826_git_dirty_pathsdirty-path list
74426_resolve_record_analysis_placement_refplacement ref for record-analysis (via resolve_action_context)
77022_resolve_planning_placementplanning-commit residue — only tasks.py calls it (dead in mission.py)
79271_planning_commit_worktreeplanning-commit residue — only tasks.py calls it (dead in mission.py)big
86329_safe_load_metaload meta.json safely
89260_enforce_analysis_report_write_preflightrecord-analysis dirty-tree preflightbig
95229_show_branch_contextbranch-context render (heavily test-patched)
98147_resolve_planning_branch(wraps imported _resolve_planning_branch) planning base resolution
102818_artifact_has_no_git_changesno-diff check
104619_artifact_absent_at_placementartifact presence at placement
10655_print_artifact_unchangedunchanged notice
10707_warn_commit_failedcommit-failure warning
107736CommitToBranchResult (class)typed result for _commit_to_branch (FR-006/D-5) — exported, imported by tests
111315_kind_for_artifactartifact_type → MissionArtifactKind
112883_commit_to_branchwraps commit_router.commit_for_mission for one artifact (used by setup-plan plan commit)big
121158_find_feature_directorycoord-aware mission-dir resolution (most test-patched helper)
126958_resolve_mission_dir_name_primary_anchoredprimary-anchored dir-name resolution
132732_primary_anchored_feature_dirprimary-anchored feature dir
135926_list_feature_spec_candidatesenumerate spec candidates
138516_sole_mission_slug_or_nonesingle-mission detection
140143_build_setup_plan_detection_errordetection-error payload (also used by lifecycle.py)
144476branch_contextCMD branch-contextbig
1520281create_missionCMD create missionMEGA
180197check_prerequisitesCMD check-prerequisitesbig
1898146record_analysisCMD record-analysisbig
2044507setup_planCMD setup-planMEGA
255133_find_latest_feature_worktreelatest worktree
25845_find_feature_worktreeworktree by slug
258918_get_current_branchcurrent branch
260755accept_featureCMD accept (delegates to top_level_accept)
2662144merge_featureCMD merge (delegates to top_level_merge)big
28061227finalize_tasksCMD finalize-tasksMEGA (x8 over ceiling)
403319_parse_wp_sections_from_tasks_mdparse WP sections (pure)
405230_parse_dependencies_from_tasks_mdparse deps (pure)
408218_parse_requirement_refs_from_tasks_mdparse req-refs from tasks.md (pure) — imported by tasks.py
410021_parse_requirement_refs_from_wp_filesparse req-refs from WP frontmatter (pure-ish)
41215_parse_requirement_ids_from_spec_mdparse req-ids from spec.md (wraps requirement_mapping)

Mega-functions needing INTERNAL decomposition (complexity ceiling = 15)

1. finalize_tasks (1227 LOC, 2806-4032) — by far the worst. Internal phases (only 4 in-body markers exist; phases inferred from reading):

This single function carries the --validate-only read-only invariant (zero on-disk mutation) — that invariant MUST survive decomposition and be pinned by a regression test (tests/specify_cli/cli/commands/test_finalize_tasks_validate_only_readonly.py already exists). 2. setup_plan (507 LOC, 2044-2550) — mostly monolithic; phases: preflight → feature-dir resolution → branch-contract injection (3 _inject_branch_contract calls at 2291/2532) → plan commit via _commit_to_branch (mission.py:2350) → coord commits via commit_for_mission (2431/2480). 3. create_mission (281 LOC, 1520-1800) — mission scaffold, meta.json write, coordination-branch creation (--force-recreate-coordination-branch), _inject_branch_contract (1776), event emit.

  • boundary/SaaS preflight (run_preflight, mission.py:2885)
  • feature-dir resolution (_find_feature_directory, ~2905)
  • target-branch resolution (_resolve_planning_branch, ~2995)
  • pre-loop frontmatter read for conflict detection (T004, mission.py:3257)
  • dependency-conflict detection "disagree-loud" (T004, mission.py:3270)
  • charter-activation gate (T044/FR-017, mission.py:3322)
  • dependency resolution preserve-existing (T004, mission.py:3343)
  • 8-field bootstrap-mutation loop (documented in docstring mission.py:2839-2850: dependencies, planning_base_branch, merge_target_branch, branch_strategy, requirement_refs, execution_mode, owned_files, authoritative_surface) — writes guarded by frontmatter_changed and not validate_only
  • manifest build + ownership/overlap validation
  • commit via commit_for_mission (mission.py:3922-3927)
  • SaaS WPCreated/TasksCompleted emit + dossier sync

3. Proposed seams

Validating/refining the issue's 3 clusters. The shim re-exports everything (see §4 invariant) so existing mission.<symbol> patch points keep working.

Seam A — mission_analysis (record-analysis surface) [~260 LOC]

  • record_analysis (CMD, 146 LOC), _enforce_analysis_report_write_preflight (60), _resolve_record_analysis_placement_ref (26).
  • Cohesive: single command + its 2 dedicated helpers. Lowest-risk first slice. Depends on commit_router.commit_for_mission, analysis_report.write_analysis_report, _find_feature_directory, _build_setup_plan_detection_error (shared — see Seam D).

Seam B — mission_subcommands (lifecycle commands) [~1900 LOC, will need sub-files]

  • Commands: branch_context, create_mission, check_prerequisites, setup_plan, accept_feature, merge_feature, finalize_tasks.
  • Helpers: _inject_branch_contract, _resolve_primary_branch_for_recommendation, _git_local_or_remote_branch_exists, _switch_to_start_branch, _resolve_primary_branch..., _show_branch_context, _resolve_planning_branch, _find_latest_feature_worktree, _find_feature_worktree, _get_current_branch, _resolve_feature_target_branch, _read_meta_for_pr_bound, _read_meta_for_emission, check-prereq emit helpers (_emit_check_prerequisites_, _paths_only_payload), finalize helpers (_collect_finalize_artifacts, _stage_finalize_artifacts_in_coord_worktree, _commit_to_branch, CommitToBranchResult, _kind_for_artifact, _artifact_, _print_artifact_unchanged, _warn_commit_failed).
  • This is too big as one module — split per command-family: mission_create.py, mission_setup_plan.py, mission_finalize.py, mission_branch_context.py, mission_accept_merge.py, mission_check_prereq.py. The 3 mega-functions must each be internally decomposed (phase helpers) as part of their move to satisfy the 15-complexity ceiling. Recommend: extract phase helpers FIRST (behavior-preserving, in place), then move.

Seam C — mission_parsing (parsing/validation helpers) [~280 LOC]

  • tasks.md/spec.md parsing (pure): _parse_wp_sections_from_tasks_md, _parse_dependencies_from_tasks_md, _parse_requirement_refs_from_tasks_md, _parse_requirement_refs_from_wp_files, _parse_requirement_ids_from_spec_md, _extract_wp_ids_from_task_files.
  • owned-files validation: _normalize_owned_file_path, _is_mission_specs_owned_file, _owned_files_yaml_is_explicit_empty_list, _raw_frontmatter_has_field, _invalid_mission_specs_owned_files.
  • JSON emit shims: _emit_json, _with_cli_version, _with_mission_aliases, _emit_console_or_json_error, _utc_now_iso.
  • Cohesive, mostly pure → ideal for direct unit tests. Highest test-leverage, low coupling. Recommend as the second slice after Seam A.

Seam D — shared feature-dir resolution (mission_feature_resolution) [~250 LOC]

The issue folds this into "parsing-validation" but it is a distinct cohesion: _find_feature_directory (the single most test-patched helper — 39 references), _resolve_mission_dir_name_primary_anchored, _primary_anchored_feature_dir, _list_feature_spec_candidates, _sole_mission_slug_or_none, _build_setup_plan_detection_error (also imported by lifecycle.py), _safe_load_meta, _read_feature_meta. Consumed by Seams A, B, C. Extract this seam FIRST of all so the others import a stable resolution surface rather than each other.

Planning-commit residue disposition (CRITICAL)

  • _planning_commit_worktree (71 LOC, 792) and _resolve_planning_placement (22 LOC, 770) are DEAD inside mission.py — only referenced in comments (mission.py:874,1054,2423,3916). Their only live caller is tasks.py (tasks.py:3704 imports _resolve_planning_placement; tasks.py:3928/3936 imports + calls _planning_commit_worktree).
  • _planning_commit_worktree is conceptually a sibling of commit_router._materialise_coord_worktree / _stage_artifacts_in_coord_worktree. Safest disposition: relocate both into coordination/commit_router.py (where the rest of the planning-commit pipeline already lives), updating tasks.py's import to the new home. _planning_commit_worktree depends on _stage_finalize_artifacts_in_coord_worktree (mission.py:854) and _safe_load_meta (mission.py:834) — those move/duplicate decisions belong with the commit_router (note commit_router already has _stage_artifacts_in_coord_worktree, likely a near-duplicate — reconcile, don't fork).
  • Coordination risk with #2058: sibling #2058 already moved tasks.py off _planning_commit_worktree. If #2058 merges first, the tasks.py import edge disappears and _planning_commit_worktree/_resolve_planning_placement become fully dead → delete them rather than relocate. Open question O-1: sequence vs #2058. Plan must branch on merge order.

4. Coupling & import map

  • Inbound (external consumers of mission.py symbols):
  • cli/commands/lifecycle.py:19import ... mission as agent_feature; calls create_mission, setup_plan, finalize_tasks, _build_setup_plan_detection_error (lifecycle.py:72,148,185,232,286). Public-ish API edge.
  • cli/commands/agent/tasks.py — imports _resolve_planning_placement (3704), _parse_requirement_refs_from_tasks_md (3836), _planning_commit_worktree (3928). Live edge on this base (see §0/#2058).
  • Tests: ~50 test files reference commands.agent.mission; heavy @patch("...mission.<name>") of module-level imports (locate_project_root 76×, _find_feature_directory 39×, _show_branch_context 22×, get_emitter, run_command, is_saas_sync_enabled, etc.). test_agent_feature.py imports CommitToBranchResult, app. test_create_feature_branch.py imports app. test_kind_for_artifact.py exercises _kind_for_artifact. test_agent_mission_commit_to_branch.py exercises _commit_to_branch.
  • Outbound: ~40 imports (mission.py:10-87) — mission_runtime (CommitTarget, MissionArtifactKind, resolve_topology, routes_through_coordination, is_coordination_artifact_residue_path), coordination.commit_router.commit_for_mission (lazy, inside functions), coordination.workspace.CoordinationWorkspace (lazy), status package (COORD_OWNED_STATUS_FILES, WPMetadata, read_wp_frontmatter, bootstrap_canonical_state), ownership, core., sync., merge.config, missions._resolve_planning_branch, runtime.resolver.
  • One-way-import feasibility: YES, achievable. The shim (mission.py) imports FROM the new seam modules and re-exports; seam modules import from core/status/coordination/mission_runtime (lower layers) — never back into mission.py. Keep commit_for_mission / CoordinationWorkspace imports LAZY (already are) to avoid import-time cycles.
  • Circular-import risk:
  • coordination/commit_router.py ↔ mission.py: TODAY one-way (mission → commit_router, lazy). If _planning_commit_worktree moves INTO commit_router, ensure commit_router does NOT import back from mission/seams. _stage_finalize_artifacts_in_coord_worktree + _safe_load_meta must travel with it (or be duplicated into commit_router's existing helpers). Low risk if lazy imports retained.
  • tasks.py ↔ mission.py: tasks.py imports parsing helper + planning-commit residue lazily (function-local imports at 3704/3836/3928). Moving those symbols only requires updating tasks.py's import target. No cycle.
  • lifecycle.py → mission.py: top-level import. If create_mission/setup_plan/finalize_tasks move to sub-modules, the shim MUST re-export them at mission.<name> so agent_feature.<name> keeps resolving. Shim re-export is the invariant that holds lifecycle + all test patches stable.
  • status packages: no direct cycle observed; mission.py is a downstream consumer.

5. Test-coverage baseline

  • Breadth: ~50 test files touch the module (see file list in commit). Strong coverage of finalize_tasks (≥6 dedicated files: test_mission_finalize_tasks.py, test_finalize_tasks_owned_files_validation.py, test_finalize_tasks_validate_only_readonly.py, test_finalize_tasks_explicit_empty_owned_files.py, test_feature_finalize_bootstrap.py, test_finalize_coord_staging.py, test_finalize_clobber_e2e.py, tests/tasks/test_finalize_), create_mission (test_mission_create.py, test_create_feature_branch.py), _commit_to_branch (test_agent_mission_commit_to_branch.py), _kind_for_artifact (test_kind_for_artifact.py), record-analysis (test_record_analysis_coord_worktree.py, test_analysis_report.py), branch-context / _show_branch_context (test_agent_feature.py), JSON envelope (test_json_envelope_strict.py).
  • Per-seam coverage verdict:
  • Seam A (record-analysis): GOOD (2 dedicated files).
  • Seam B (subcommands): GOOD for finalize/create/setup-plan; THIN for the standalone happy-path of branch_context/check_prerequisites JSON shapes at the CLI boundary.
  • Seam C (parsing): GAP — pure parsers (_parse_dependencies_from_tasks_md, _parse_wp_sections_from_tasks_md, _parse_requirement_refs_*) are exercised indirectly via finalize, NOT directly. Add direct unit tests when extracting (Sonar: every new helper needs a focused test).
  • Seam D (feature resolution): GOOD via the 39 _find_feature_directory patches, but those are mocks — add direct resolution tests on extraction.
  • Mocking fragility: tests patch mission.<imported-name> extensively. Any extraction that moves an imported name (e.g. run_command, get_emitter) off the mission namespace breaks ~100 patch targets. The shim MUST re-export every currently-patched name at the mission module path, OR the patch targets get bulk-updated to the new module (a bulk-edit; see §6). Recommend: shim re-exports preserve patch targets → zero test churn.
  • GOLDEN CLI CHARACTERIZATION TEST: REQUIRED. No single test currently pins the full 8-command × all-flags surface + JSON envelope as one frozen artifact. Before extraction, add a typer.testing.CliRunner-based golden test that asserts: --help for app lists all 8 commands; each subcommand's --help lists exact flags; representative success+error JSON envelopes. This is the safety net for "byte-for-byte preserved."

6. Risks / open questions

  • O-1 (planning-commit residue + #2058 sequencing): disposition of _planning_commit_worktree / _resolve_planning_placement depends on whether #2058 merges first (→ delete as dead) or this mission merges first (→ relocate into commit_router and repoint tasks.py). The plan must encode both branches. Decision required.
  • O-2 (_planning_commit_worktree ownership): if relocated, it belongs in coordination/commit_router.py (sibling of the existing pipeline). Reconcile its _stage_finalize_artifacts_in_coord_worktree dependency with commit_router's existing _stage_artifacts_in_coord_worktree (likely near-duplicate — verify before forking).
  • R-1 (test patch-target churn): the dominant risk. Mitigate with shim re-exports (zero churn) rather than mass patch-target rewrites.
  • R-2 (mega-function complexity ceiling): finalize_tasks (1227), setup_plan (507), create_mission (281) MUST be internally decomposed (<=15 complexity) as part of their move, each new phase-helper with its own focused test. This is the bulk of the engineering effort, not the file split itself.
  • R-3 (--validate-only read-only invariant): finalize_tasks' zero-mutation guarantee under --validate-only (in-memory bootstrap, _inmemory_frontmatter/_inmemory_bodies) must survive phase extraction. Pin with existing readonly test + a new assertion that the write phase is unreachable when validate_only.
  • R-4 (lazy-import discipline): keep commit_for_mission / CoordinationWorkspace imports function-local in seam modules to avoid import-time cycles with coordination.
  • O-3 (accept/merge thinness): both already delegate to top-level commands — confirm they can move to a mission_accept_merge seam without re-importing the whole merge/accept graph at module top level.