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.
| Dimension | v1 (Apr 15) | v2 (Apr 16) | v3 (Apr 16) | Net Δ |
|---|---|---|---|---|
| Encapsulation / Information Hiding | 4/10 | 5/10 | 6/10 | +2 |
| Cohesion | 5/10 | 5/10 | 5/10 | — |
| Coupling Discipline | 4/10 | 5/10 | 5/10 | +1 |
| Contract Stability | 6/10 | 6/10 | 6/10 | — |
| Testability | 5/10 | 6/10 | 7/10 | +2 |
| Volatility Alignment | 4/10 | 5/10 | 5/10 | +1 |
| Module Size Distribution | 6/10 | 6/10 | 6/10 | — |
| Dependency Direction | 4/10 | 5/10 | 5/10 | +1 |
| Overall | 4.8/10 | 5.4/10 | 5.6/10 | +0.8 |
| Issue | Change | Status |
|---|---|---|
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_end | Replaced terminal_poll_state.clear_seen_status with reset_window_polling_state | ✅ Resolved |
has_prompt_marker hardwires tmux_manager | Injectable capture_fn parameter | ✅ Resolved |
setup_shell_prompt hardwires tmux_manager | Injectable capture_fn + send_keys_fn parameters | ✅ Resolved |
Dead _SessionMapError constant in session_lifecycle.py | Removed along with unused json import | ✅ Resolved |
claude_task_state is now written from three modules, each owning a distinct trigger type:
| Module | Trigger type | Methods called |
|---|---|---|
session_lifecycle | Hook events + lifecycle cleanup | set_wait_header, clear_wait_header, mark_task_completed, clear_window, clear_subagents |
window_tick | Poll-cycle transitions | clear_wait_header (×2), set_last_status |
transcript_reader | Transcript parsing | rebuild_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).
| Integration | Strength | Distance | Volatility | Balanced? |
|---|---|---|---|---|
30 handler modules → SessionManager (89 call sites) | Functional | Same service | High | No — dominant remaining issue |
window_tick → polling_strategies internals (30 calls) | Functional | Same service, low distance | High | Tolerable — low distance balances high strength |
| 3 remaining deferred-import cycles | Functional (bidirectional) | Same service | High | No — masks true dependency graph |
window_tick → claude_task_state (3 mutations) | Functional | Same service | High | Yes — intentional poll-cycle authority |
transcript_reader → claude_task_state (2 mutations) | Functional | Same service | High | Yes — intentional transcript-parse authority |
hook_events → session_lifecycle (5 method calls) | Contract | Same service | High | Yes — named facade methods are the contract |
reset_window_polling_state() facade — fully adopted | Contract | Same service | High | Yes — no bypasses remain |
has_prompt_marker + setup_shell_prompt (injectable) | Contract | Same service | Moderate | Yes — injectable callables make the boundary explicit |
monitor_events.py, IdleTracker, event_reader, providers/base.py | Contract | Same service | Low/Moderate | Yes — correctly isolated design exemplars |
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.
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.
WindowState, getter/setter in SessionManager, serialization changes, and additions across all handler modules that consume the setting.session.py.sync_command.py, recovery_callbacks.py, transcript_discovery.py, resume_command.py, restore_command.py, directory_callbacks.py, message_routing.py, window_tick.py, topic_orchestration.py, topic_lifecycle.py.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.
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.
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.
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.
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.