|
| 1 | +# HUD State Module Implementation Plan |
| 2 | + |
| 3 | +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. |
| 4 | +
|
| 5 | +**Goal:** Create `hud_state.py` module for managing `~/.codingbuddy/hud-state.json` — the shared state file for statusLine and mode-detect hooks. |
| 6 | + |
| 7 | +**Architecture:** Functional module (no class) with file-locked JSON read/write, following `stats.py` patterns. All functions return safe defaults on error. |
| 8 | + |
| 9 | +**Tech Stack:** Python 3, `fcntl.flock()`, `json`, `datetime` |
| 10 | + |
| 11 | +**Issue:** #1087 |
| 12 | + |
| 13 | +## Alternatives |
| 14 | + |
| 15 | +### Decision: Module Style — Class vs Functions |
| 16 | + |
| 17 | +| Criteria | Class (like stats.py) | Functions (standalone) | |
| 18 | +|---|---|---| |
| 19 | +| Complexity | More boilerplate, init required | Simpler, direct calls | |
| 20 | +| Usage pattern | Long-lived instance in one process | Called from multiple hooks independently | |
| 21 | +| State management | In-memory + disk | Disk only (stateless) | |
| 22 | +| Testability | Requires fixture setup | Simple function calls | |
| 23 | + |
| 24 | +**Decision:** Functions — HUD state is read/written by different processes (session-start, mode-detect, statusLine script). No single process holds a long-lived instance. Stateless functions are simpler and safer for cross-process access. |
| 25 | + |
| 26 | +--- |
| 27 | + |
| 28 | +## Steps |
| 29 | + |
| 30 | +### Step 1: Write failing test — `test_read_hud_state_missing_file` |
| 31 | + |
| 32 | +**File:** `packages/claude-code-plugin/tests/test_hud_state.py` |
| 33 | + |
| 34 | +```python |
| 35 | +"""Tests for HUD state management module (#1087).""" |
| 36 | +import json |
| 37 | +import os |
| 38 | +import sys |
| 39 | +import pytest |
| 40 | + |
| 41 | +_tests_dir = os.path.dirname(os.path.abspath(__file__)) |
| 42 | +_lib_dir = os.path.join(os.path.dirname(_tests_dir), "hooks", "lib") |
| 43 | +if _lib_dir not in sys.path: |
| 44 | + sys.path.insert(0, _lib_dir) |
| 45 | + |
| 46 | +from hud_state import read_hud_state |
| 47 | + |
| 48 | + |
| 49 | +class TestReadHudState: |
| 50 | + def test_returns_empty_dict_when_file_missing(self, tmp_path): |
| 51 | + path = str(tmp_path / "nonexistent.json") |
| 52 | + result = read_hud_state(path) |
| 53 | + assert result == {} |
| 54 | +``` |
| 55 | + |
| 56 | +**Run:** `cd packages/claude-code-plugin && python -m pytest tests/test_hud_state.py::TestReadHudState::test_returns_empty_dict_when_file_missing -v` |
| 57 | +**Expected:** FAIL (ImportError — module doesn't exist yet) |
| 58 | + |
| 59 | +### Step 2: Create minimal `hud_state.py` — make Step 1 pass |
| 60 | + |
| 61 | +**File:** `packages/claude-code-plugin/hooks/lib/hud_state.py` |
| 62 | + |
| 63 | +```python |
| 64 | +"""HUD state management for CodingBuddy statusLine (#1087). |
| 65 | +
|
| 66 | +Manages ~/.codingbuddy/hud-state.json shared between hooks. |
| 67 | +Uses fcntl.flock() for file-level locking on every IO operation. |
| 68 | +""" |
| 69 | +import json |
| 70 | +import os |
| 71 | +from typing import Any, Dict |
| 72 | + |
| 73 | +try: |
| 74 | + import fcntl |
| 75 | + HAS_FCNTL = True |
| 76 | +except ImportError: |
| 77 | + HAS_FCNTL = False |
| 78 | + |
| 79 | +DEFAULT_STATE_FILE = os.path.join( |
| 80 | + os.path.expanduser("~"), ".codingbuddy", "hud-state.json" |
| 81 | +) |
| 82 | + |
| 83 | + |
| 84 | +def read_hud_state(state_file: str = DEFAULT_STATE_FILE) -> Dict[str, Any]: |
| 85 | + """Read HUD state from JSON file with shared lock. |
| 86 | +
|
| 87 | + Returns empty dict on any error (missing file, parse error). |
| 88 | + """ |
| 89 | + try: |
| 90 | + with open(state_file, "r", encoding="utf-8") as f: |
| 91 | + if HAS_FCNTL: |
| 92 | + fcntl.flock(f.fileno(), fcntl.LOCK_SH) |
| 93 | + return json.load(f) |
| 94 | + except (json.JSONDecodeError, OSError): |
| 95 | + return {} |
| 96 | +``` |
| 97 | + |
| 98 | +**Run:** Same test |
| 99 | +**Expected:** PASS |
| 100 | + |
| 101 | +### Step 3: Write failing test — `test_read_hud_state_corrupted_json` |
| 102 | + |
| 103 | +```python |
| 104 | + def test_returns_empty_dict_when_json_corrupted(self, tmp_path): |
| 105 | + path = str(tmp_path / "bad.json") |
| 106 | + with open(path, "w") as f: |
| 107 | + f.write("{invalid json") |
| 108 | + result = read_hud_state(path) |
| 109 | + assert result == {} |
| 110 | +``` |
| 111 | + |
| 112 | +**Run:** `python -m pytest tests/test_hud_state.py::TestReadHudState -v` |
| 113 | +**Expected:** PASS (already handled by except clause) |
| 114 | + |
| 115 | +### Step 4: Write failing test — `test_read_hud_state_valid` |
| 116 | + |
| 117 | +```python |
| 118 | + def test_reads_valid_state(self, tmp_path): |
| 119 | + path = str(tmp_path / "state.json") |
| 120 | + data = {"sessionId": "abc", "currentMode": "PLAN"} |
| 121 | + with open(path, "w") as f: |
| 122 | + json.dump(data, f) |
| 123 | + result = read_hud_state(path) |
| 124 | + assert result == data |
| 125 | +``` |
| 126 | + |
| 127 | +**Expected:** PASS |
| 128 | + |
| 129 | +### Step 5: Write failing test — `test_init_hud_state` |
| 130 | + |
| 131 | +```python |
| 132 | +class TestInitHudState: |
| 133 | + def test_creates_file_with_correct_schema(self, tmp_path): |
| 134 | + path = str(tmp_path / "hud-state.json") |
| 135 | + from hud_state import init_hud_state |
| 136 | + init_hud_state("session-123", "5.1.1", state_file=path) |
| 137 | + |
| 138 | + with open(path, "r") as f: |
| 139 | + data = json.load(f) |
| 140 | + |
| 141 | + assert data["sessionId"] == "session-123" |
| 142 | + assert data["version"] == "5.1.1" |
| 143 | + assert data["currentMode"] is None |
| 144 | + assert data["activeAgent"] is None |
| 145 | + assert "sessionStartTimestamp" in data |
| 146 | + assert "updatedAt" in data |
| 147 | +``` |
| 148 | + |
| 149 | +**Expected:** FAIL (init_hud_state doesn't exist) |
| 150 | + |
| 151 | +### Step 6: Implement `init_hud_state` — make Step 5 pass |
| 152 | + |
| 153 | +Add to `hud_state.py`: |
| 154 | + |
| 155 | +```python |
| 156 | +from datetime import datetime, timezone |
| 157 | + |
| 158 | + |
| 159 | +def init_hud_state( |
| 160 | + session_id: str, |
| 161 | + version: str, |
| 162 | + state_file: str = DEFAULT_STATE_FILE, |
| 163 | +) -> None: |
| 164 | + """Initialize HUD state for a new session. |
| 165 | +
|
| 166 | + Creates parent directory if needed. Overwrites existing state. |
| 167 | + """ |
| 168 | + now = datetime.now(timezone.utc).isoformat() |
| 169 | + data = { |
| 170 | + "sessionStartTimestamp": now, |
| 171 | + "sessionId": session_id, |
| 172 | + "version": version, |
| 173 | + "currentMode": None, |
| 174 | + "activeAgent": None, |
| 175 | + "updatedAt": now, |
| 176 | + } |
| 177 | + _locked_write(state_file, data) |
| 178 | + |
| 179 | + |
| 180 | +def _locked_write(state_file: str, data: Dict[str, Any]) -> None: |
| 181 | + """Write state file with exclusive lock.""" |
| 182 | + os.makedirs(os.path.dirname(state_file), mode=0o700, exist_ok=True) |
| 183 | + with open(state_file, "w", encoding="utf-8") as f: |
| 184 | + if HAS_FCNTL: |
| 185 | + fcntl.flock(f.fileno(), fcntl.LOCK_EX) |
| 186 | + json.dump(data, f) |
| 187 | +``` |
| 188 | + |
| 189 | +**Run:** `python -m pytest tests/test_hud_state.py -v` |
| 190 | +**Expected:** PASS |
| 191 | + |
| 192 | +### Step 7: Write failing test — `test_init_creates_parent_directory` |
| 193 | + |
| 194 | +```python |
| 195 | + def test_creates_parent_directory(self, tmp_path): |
| 196 | + path = str(tmp_path / "nested" / "deep" / "hud-state.json") |
| 197 | + from hud_state import init_hud_state |
| 198 | + init_hud_state("s1", "5.1.1", state_file=path) |
| 199 | + assert os.path.isfile(path) |
| 200 | +``` |
| 201 | + |
| 202 | +**Expected:** PASS (already handled by `os.makedirs`) |
| 203 | + |
| 204 | +### Step 8: Write failing test — `test_update_hud_state` |
| 205 | + |
| 206 | +```python |
| 207 | +class TestUpdateHudState: |
| 208 | + def test_merges_kwargs_into_existing_state(self, tmp_path): |
| 209 | + path = str(tmp_path / "hud-state.json") |
| 210 | + from hud_state import init_hud_state, update_hud_state |
| 211 | + init_hud_state("s1", "5.1.1", state_file=path) |
| 212 | + update_hud_state(state_file=path, currentMode="ACT") |
| 213 | + |
| 214 | + result = read_hud_state(path) |
| 215 | + assert result["currentMode"] == "ACT" |
| 216 | + assert result["sessionId"] == "s1" # preserved |
| 217 | + assert result["version"] == "5.1.1" # preserved |
| 218 | +``` |
| 219 | + |
| 220 | +**Expected:** FAIL (update_hud_state doesn't exist) |
| 221 | + |
| 222 | +### Step 9: Implement `update_hud_state` — make Step 8 pass |
| 223 | + |
| 224 | +Add to `hud_state.py`: |
| 225 | + |
| 226 | +```python |
| 227 | +def update_hud_state( |
| 228 | + state_file: str = DEFAULT_STATE_FILE, |
| 229 | + **kwargs: Any, |
| 230 | +) -> None: |
| 231 | + """Update HUD state by merging kwargs into existing state. |
| 232 | +
|
| 233 | + Read-modify-write with exclusive lock. Silently no-ops on error. |
| 234 | + """ |
| 235 | + try: |
| 236 | + data = read_hud_state(state_file) |
| 237 | + data.update(kwargs) |
| 238 | + data["updatedAt"] = datetime.now(timezone.utc).isoformat() |
| 239 | + _locked_write(state_file, data) |
| 240 | + except Exception: |
| 241 | + pass |
| 242 | +``` |
| 243 | + |
| 244 | +**Run:** `python -m pytest tests/test_hud_state.py -v` |
| 245 | +**Expected:** PASS |
| 246 | + |
| 247 | +### Step 10: Write failing test — `test_update_updates_timestamp` |
| 248 | + |
| 249 | +```python |
| 250 | + def test_updates_timestamp(self, tmp_path): |
| 251 | + path = str(tmp_path / "hud-state.json") |
| 252 | + from hud_state import init_hud_state, update_hud_state |
| 253 | + init_hud_state("s1", "5.1.1", state_file=path) |
| 254 | + |
| 255 | + before = read_hud_state(path)["updatedAt"] |
| 256 | + import time; time.sleep(0.01) |
| 257 | + update_hud_state(state_file=path, currentMode="EVAL") |
| 258 | + |
| 259 | + after = read_hud_state(path)["updatedAt"] |
| 260 | + assert after > before |
| 261 | +``` |
| 262 | + |
| 263 | +**Expected:** PASS |
| 264 | + |
| 265 | +### Step 11: Write failing test — `test_update_on_missing_file` |
| 266 | + |
| 267 | +```python |
| 268 | + def test_noop_when_file_missing(self, tmp_path): |
| 269 | + path = str(tmp_path / "nonexistent.json") |
| 270 | + from hud_state import update_hud_state |
| 271 | + # Should not raise |
| 272 | + update_hud_state(state_file=path, currentMode="PLAN") |
| 273 | + # File may or may not be created — just ensure no crash |
| 274 | +``` |
| 275 | + |
| 276 | +**Expected:** PASS (try/except in update_hud_state) |
| 277 | + |
| 278 | +### Step 12: Write failing test — `test_roundtrip` |
| 279 | + |
| 280 | +```python |
| 281 | +class TestRoundtrip: |
| 282 | + def test_init_read_update_read(self, tmp_path): |
| 283 | + path = str(tmp_path / "hud-state.json") |
| 284 | + from hud_state import init_hud_state, update_hud_state |
| 285 | + |
| 286 | + init_hud_state("rt-1", "5.1.1", state_file=path) |
| 287 | + state1 = read_hud_state(path) |
| 288 | + assert state1["currentMode"] is None |
| 289 | + |
| 290 | + update_hud_state(state_file=path, currentMode="PLAN", activeAgent="architect") |
| 291 | + state2 = read_hud_state(path) |
| 292 | + assert state2["currentMode"] == "PLAN" |
| 293 | + assert state2["activeAgent"] == "architect" |
| 294 | + assert state2["sessionId"] == "rt-1" |
| 295 | + |
| 296 | + update_hud_state(state_file=path, currentMode="ACT") |
| 297 | + state3 = read_hud_state(path) |
| 298 | + assert state3["currentMode"] == "ACT" |
| 299 | + assert state3["activeAgent"] == "architect" # preserved |
| 300 | +``` |
| 301 | + |
| 302 | +**Expected:** PASS |
| 303 | + |
| 304 | +### Step 13: Run all tests + commit |
| 305 | + |
| 306 | +**Run:** `cd packages/claude-code-plugin && python -m pytest tests/test_hud_state.py -v` |
| 307 | +**Expected:** All PASS |
| 308 | + |
| 309 | +**Commit:** `git add packages/claude-code-plugin/hooks/lib/hud_state.py packages/claude-code-plugin/tests/test_hud_state.py && git commit -m "feat(plugin): add HUD state module (#1087)"` |
| 310 | + |
| 311 | +--- |
| 312 | + |
| 313 | +## Verification |
| 314 | + |
| 315 | +```bash |
| 316 | +cd packages/claude-code-plugin |
| 317 | +python -m pytest tests/test_hud_state.py -v |
| 318 | +``` |
| 319 | + |
| 320 | +All tests must pass. The module is ready for consumption by: |
| 321 | +- `codingbuddy-hud.py` (#1088) — reads state |
| 322 | +- `session-start.py` (#1089) — calls `init_hud_state()` |
| 323 | +- `user-prompt-submit.py` (#1090) — calls `update_hud_state(currentMode=...)` |
0 commit comments