Skip to content

Commit c556c1d

Browse files
committed
feat(plugin): add HUD state module for statusLine integration
- Create hud_state.py with read/init/update functions and fcntl file locking - Atomic read-modify-write in update_hud_state via single exclusive lock - Add 9 tests covering read/init/update/roundtrip scenarios - Add TDD implementation plan document Closes #1087
1 parent 9863253 commit c556c1d

3 files changed

Lines changed: 520 additions & 0 deletions

File tree

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
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

Comments
 (0)