ccgram is a single-process Python bot (~47k lines) that routes Telegram messages to AI coding agent CLIs running in tmux panes. Four refactoring passes have moved the overall modularity score from 4.8 to 6.3/10 (+1.5). The final pass — extracting window_query.py — was the structural breakthrough that broke through the 5.6 plateau: handler files importing SessionManager dropped from 30 to 15 (–50%), and call sites from 85 to 57 (–33%). The god-object is now split along its natural read/write boundary: window_query owns reads, session_lifecycle owns mutations, and SessionManager is retained only as the coordinator for writes, lifecycle operations, and session resolution.
The codebase has moved from "needs attention" to "healthy with known debt". No Significant or Critical issues remain. The three Minor issues — 15 handler files that still import SessionManager, 3 deferred-import cycles, and window_tick's 30 polling strategy calls — are all either at their natural floor or tolerable at low distance. The refactoring series is complete.
| Dimension | v1 | v2 | v3 | v4 | v5 | Net Δ |
|---|---|---|---|---|---|---|
| Encapsulation / Information Hiding | 4 | 5 | 6 | 6 | 7 | +3 |
| Cohesion | 5 | 5 | 5 | 5 | 5 | — |
| Coupling Discipline | 4 | 5 | 5 | 5 | 6 | +2 |
| Contract Stability | 6 | 6 | 6 | 6 | 7 | +1 |
| Testability | 5 | 6 | 7 | 7 | 7 | +2 |
| Volatility Alignment | 4 | 5 | 5 | 5 | 6 | +2 |
| Module Size Distribution | 6 | 6 | 6 | 6 | 6 | — |
| Dependency Direction | 4 | 5 | 5 | 5 | 6 | +2 |
| Overall | 4.8 | 5.4 | 5.6 | 5.6 | 6.3 | +1.5 |
| Metric | v1 (baseline) | v4 (pre-extraction) | v5 (post-extraction) | Total Δ |
|---|---|---|---|---|
Handler files importing SessionManager | 30 | 30 | 15 | –50% |
session_manager.* call sites in handlers | 89 | 85 | 57 | –36% |
| Fully decoupled handler files | 0 | 0 | 14 | +14 |
| Partially migrated handler files | 0 | 0 | 3 | +3 |
| Concern | Module | Coupling level | Depends on |
|---|---|---|---|
| Reads | window_query.py | Contract | window_state_store only |
| Hook-event mutations | session_lifecycle.py | Contract | claude_task_state, session_manager |
| Poll-cycle mutations | window_tick.py | Functional (intentional) | claude_task_state, polling_strategies |
| Transcript-parse mutations | transcript_reader.py | Functional (intentional) | claude_task_state |
| Writes + lifecycle | session.py | Functional | Full state graph |
| Polling state | reset_window_polling_state() | Contract | polling_strategies internals |
| Integration | Strength | Distance | Volatility | Balanced? |
|---|---|---|---|---|
14 handler modules → window_query (read-only) | Contract | Same service | High | Yes — narrow read contract |
15 handler modules → SessionManager (writes + lifecycle) | Functional | Same service | High | Tolerable — legitimate consumers |
| 3 handler modules → both (partial migration) | Mixed | Same service | High | Tolerable |
hook_events → session_lifecycle (5 facade calls) | Contract | Same service | High | Yes |
window_tick → polling_strategies (30 calls) | Functional | Same service, low distance | High | Tolerable |
| 3 deferred-import cycles | Functional (bidirectional) | Same service | High | No — low priority |
monitor_events.py, IdleTracker, event_reader, base.py | Contract | Same service | Low/Moderate | Yes — design exemplars |
The remaining 15 handler files import SessionManager because they genuinely need write, lifecycle, or query capabilities: 7 files call write methods, 4 call lifecycle methods, 4 call session resolution. These are functional coupling call sites that cannot be reduced to contract coupling without introducing artificial abstractions.
Accept as the natural floor. The balance rule tolerates high strength at low distance. If a future pass targets this, the highest-leverage move is extracting set_window_provider (11 call sites across 7 files) into session_lifecycle.
Three bidirectional dependency cycles survive, suppressed by deferred imports. The monitor_events.py extraction proved the fix pattern. Not blocking feature work.
Address opportunistically. Extract shared types to dependency-free modules following the established pattern.
window_tick.py is the poll cycle executor. Its 30 calls are high-strength functional coupling at very low distance. The balance rule tolerates this.
Low priority. Group related transitions into compound methods on strategy objects if window_tick.py grows further.
| Dimension | v1→v5 | What moved it | Current ceiling |
|---|---|---|---|
| Encapsulation (4→7) | +3 | window_query extraction, session_lifecycle write authority, reset_window_polling_state facade, injectable capture_fn | 15 legitimate SessionManager consumers |
| Coupling Discipline (4→6) | +2 | Layer violation removed, cycle broken, read/write boundary split | 3 remaining import cycles |
| Testability (5→7) | +2 | Injectable callables, monitor_events.py, window_query simplifies mocking | shell_infra internal functions |
| Volatility Alignment (4→6) | +2 | Mutations per trigger type, volatile handler reads now contract-coupled | Natural floor |
| Dependency Direction (4→6) | +2 | Inversion resolved, window_query depends only on window_state_store | claude.py lazy tmux import |
| Contract Stability (6→7) | +1 | WindowView, window_query functions, facades | Protocol-level contracts needed for 8+ |
| Cohesion (5→5) | — | — | Large files are legitimate |
| Module Size (6→6) | — | — | Splitting adds complexity |
v1 ──(+0.6)──▸ v2 ──(+0.2)──▸ v3 ──(+0.0)──▸ v4 ──(+0.7)──▸ v5 4.8 5.4 5.6 5.6 6.3
The lesson: incremental fixes converge to a local optimum; breaking through requires an architectural insight. In this case: "reads don't need the coordinator."