Modularity Review

Scope: Entire ccgram codebase — third pass, 2026-04-16
Date: 2026-04-16 (v3 — follows 2026-04-16/ and 2026-04-16-v2/)

Executive Summary

ccgram is a single-process Python bot (~47k lines) that routes Telegram messages to AI coding agent CLIs running in tmux panes. Two consecutive refactoring passes have moved the overall modularity score from 4.8 to 5.6/10 (+0.8 over the baseline). The second pass completed the claude_task_state write-authority work: hook_events.py now has zero direct mutations — all hook-triggered task state changes route through session_lifecycle via three new named methods. Mutation authority is now cleanly partitioned by trigger type across three modules. The polling reset facade bypass was also closed, and shell_infra.py gained the same injectable-parameters pattern already present in claude.py.

The dominant remaining issue — SessionManager accessed directly from 30 handler files at 89 call sites — was not addressed in either pass and remains the single largest driver of coupling density in the codebase. Addressing it is the highest-impact next step. Three deferred-import cycles and window_tick.py's direct polling calls are minor issues that are tolerable at their current distance.

Three-Version Progression

Dimensionv1 (Apr 15)v2 (Apr 16)v3 (Apr 16)Net Δ
Encapsulation / Information Hiding4/105/106/10+2
Cohesion5/105/105/10
Coupling Discipline4/105/105/10+1
Contract Stability6/106/106/10
Testability5/106/107/10+2
Volatility Alignment4/105/105/10+1
Module Size Distribution6/106/106/10
Dependency Direction4/105/105/10+1
Overall4.8/105.4/105.6/10+0.8

What Was Resolved This Pass

IssueChangeStatus
hook_events directly mutating claude_task_state (5 call sites)3 new methods on SessionLifecycle: handle_notification_wait, handle_stop_task_state, handle_task_completed✅ Resolved
Polling reset facade bypass in _handle_session_endReplaced terminal_poll_state.clear_seen_status with reset_window_polling_state✅ Resolved
has_prompt_marker hardwires tmux_managerInjectable capture_fn parameter✅ Resolved
setup_shell_prompt hardwires tmux_managerInjectable capture_fn + send_keys_fn parameters✅ Resolved
Dead _SessionMapError constant in session_lifecycle.pyRemoved along with unused json import✅ Resolved

Mutation Authority — Current State

claude_task_state is now written from three modules, each owning a distinct trigger type:

ModuleTrigger typeMethods called
session_lifecycleHook events + lifecycle cleanupset_wait_header, clear_wait_header, mark_task_completed, clear_window, clear_subagents
window_tickPoll-cycle transitionsclear_wait_header (×2), set_last_status
transcript_readerTranscript parsingrebuild_from_entries, apply_entries

This is the intended end state per the "one authority per trigger type" recommendation from the v2 review. hook_events.py is now a read-only consumer of claude_task_state (two reads: format_completion_text, has_snapshot).

Coupling Overview

Integration Strength Distance Volatility Balanced?
30 handler modules → SessionManager (89 call sites)FunctionalSame serviceHighNo — dominant remaining issue
window_tickpolling_strategies internals (30 calls)FunctionalSame service, low distanceHighTolerable — low distance balances high strength
3 remaining deferred-import cyclesFunctional (bidirectional)Same serviceHighNo — masks true dependency graph
window_tickclaude_task_state (3 mutations)FunctionalSame serviceHighYes — intentional poll-cycle authority
transcript_readerclaude_task_state (2 mutations)FunctionalSame serviceHighYes — intentional transcript-parse authority
hook_eventssession_lifecycle (5 method calls)ContractSame serviceHighYes — named facade methods are the contract
reset_window_polling_state() facade — fully adoptedContractSame serviceHighYes — no bypasses remain
has_prompt_marker + setup_shell_prompt (injectable)ContractSame serviceModerateYes — injectable callables make the boundary explicit
monitor_events.py, IdleTracker, event_reader, providers/base.pyContractSame serviceLow/ModerateYes — correctly isolated design exemplars

Issue 1: SessionManager Remains a Dependency Hub

Integration: 30 handler modules → session.py:SessionManager (89 call sites)  |  Severity: Significant — unchanged across all three passes

Knowledge Leakage

SessionManager is directly imported by every handler module. Despite the WindowView read-only projection covering 10 fields, the 89 call sites still acquire functional coupling to specific SessionManager methods: get_window_provider, get_session_id_for_window, clear_window_session, prune_stale_state, resolve_stale_ids, set_window_provider, set_window_cwd, and others. The _wire_singletons() initialization pattern still creates a hidden ordering constraint: any new sub-singleton must be wired into this chain or will silently break persistence.

Complexity Impact

With 89 call sites across 30 files, any change to the session state model requires auditing the entire handler layer. The WindowView contract covers the read path for the fields it includes, but modules that call methods not yet on WindowView still depend on the SessionManager API directly. This is the primary reason the Encapsulation and Volatility Alignment scores have not moved above 6 despite two refactoring passes — the complexity imposed by this coupling is persistent.

Cascading Changes

Recommended Improvement

Extend WindowView to cover the remaining commonly-read fields (session_id is already there; add get_window_provider-equivalent). Migrate the 10 highest-call-count handlers to use view_window() for reads. The goal is to reduce the number of files that import session_manager at all — limit direct imports to modules that genuinely need to write state. Reads go through WindowView (contract coupling); writes go through narrow command methods. This converts functional coupling to contract coupling at the read boundary.

The trade-off: migrating 10 files is a day's work with mechanical changes. The benefit compounds with every subsequent feature addition.


Issue 2: Three Deferred-Import Cycles Remain

Integration: session.pysession_resolver.py, session_map.pywindow_state_store.py / thread_router.py  |  Severity: Minor

Knowledge Leakage

Three bidirectional dependency cycles survive, each suppressed by function-level deferred imports. The suppression works at runtime but makes the true dependency graph invisible to static analysis. session_map.py defers imports of both window_store and thread_router inside nearly every method, meaning its full dependency set cannot be determined from the file's import block alone. The monitor_events.py extraction in v1 demonstrated the fix pattern; applying it here would break all three remaining cycles.

Recommended Improvement

For the session.py ↔ session_resolver cycle: the ClaudeSession dataclass (4 fields, zero internal dependencies) is the shared type. Extract it to session_types.py. Both modules import from session_types; neither needs to import the other's singleton. For session_map.py's cycles: identify the shared data types and extract them the same way. Each extraction is mechanical and follows the established monitor_events.py pattern.


Issue 3: window_tick Has 30 Direct Polling Strategy Calls

Integration: window_tick.pyterminal_poll_state, lifecycle_strategy, terminal_screen_buffer (30 calls)  |  Severity: Minor — tolerable given low distance

Knowledge Leakage

window_tick.py is the per-window poll cycle executor and its relationship to polling_strategies.py is architecturally close. The balance rule tolerates high integration strength at low distance. hook_events.py no longer bypasses the reset_window_polling_state facade — that contract is clean. The 30 calls in window_tick are legitimately internal: window_tick IS the poll executor and owns these transitions.

Recommended Improvement

Low priority. If window_tick.py grows further, group related state transitions into compound methods on the strategy objects (e.g., lifecycle_strategy.complete_dead_window_transition(user_id, thread_id, window_id) instead of 3–4 separate calls). Address opportunistically during feature work on the polling subsystem.