Skip to content

Commit 05e3e0a

Browse files
committed
feat(plugin): add council UX fields to HUD/state model (#1364)
Add councilActive, councilStage, councilCast fields to hud_state.py defaults and init. Add on_council_update helper to hud_helpers.py for start/advance/end semantics. Reset council fields on mode entry and session stop. Tests cover init, update, reset, backward compat, and full lifecycle.
1 parent 33f93ca commit 05e3e0a

4 files changed

Lines changed: 265 additions & 3 deletions

File tree

packages/claude-code-plugin/hooks/lib/hud_helpers.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import json
1818
import os
1919
from pathlib import Path
20-
from typing import Optional
20+
from typing import List, Optional
2121

2222
from hud_state import update_hud_state
2323

@@ -79,6 +79,10 @@ def on_mode_entry(
7979
"executionStrategy": None,
8080
"councilStatus": None,
8181
"lastHandoff": None,
82+
# Council UX reset (#1364)
83+
"councilActive": False,
84+
"councilStage": "",
85+
"councilCast": [],
8286
}
8387
if state_file:
8488
update_hud_state(state_file=state_file, **kwargs)
@@ -194,6 +198,10 @@ def on_session_stop(
194198
"executionStrategy": None,
195199
"councilStatus": None,
196200
"blockerCount": 0,
201+
# Council UX reset (#1364)
202+
"councilActive": False,
203+
"councilStage": "",
204+
"councilCast": [],
197205
}
198206
if state_file:
199207
update_hud_state(state_file=state_file, **kwargs)
@@ -240,6 +248,42 @@ def init_baseline(
240248
pass
241249

242250

251+
def on_council_update(
252+
*,
253+
active: Optional[bool] = None,
254+
stage: Optional[str] = None,
255+
cast: Optional[List[str]] = None,
256+
state_file: Optional[str] = None,
257+
) -> None:
258+
"""Update council-related HUD fields (#1364).
259+
260+
Only supplied arguments are written; omitted fields are preserved.
261+
Allows callers to start, advance, or end a council session.
262+
263+
Args:
264+
active: Whether a council is currently active.
265+
stage: Current council stage (opening, reviewing, consensus, done).
266+
cast: List of specialist agent names participating in the council.
267+
state_file: Optional explicit path; uses default when None.
268+
"""
269+
try:
270+
updates: dict = {}
271+
if active is not None:
272+
updates["councilActive"] = active
273+
if stage is not None:
274+
updates["councilStage"] = stage
275+
if cast is not None:
276+
updates["councilCast"] = cast
277+
278+
if updates:
279+
if state_file:
280+
update_hud_state(state_file=state_file, **updates)
281+
else:
282+
update_hud_state(**updates)
283+
except Exception:
284+
pass
285+
286+
243287
# ---- private helpers ----
244288

245289

packages/claude-code-plugin/hooks/lib/hud_state.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@
88
from datetime import datetime, timezone
99
from typing import Any, Dict
1010

11-
# Default values for extended schema fields (#1326).
11+
# Default values for extended schema fields (#1326, #1364).
1212
_EXTENDED_DEFAULTS: Dict[str, Any] = {
1313
"phase": "ready",
1414
"focus": None,
1515
"executionStrategy": None,
1616
"councilStatus": None,
1717
"blockerCount": 0,
1818
"lastHandoff": None,
19+
# Council UX fields (#1364)
20+
"councilActive": False,
21+
"councilStage": "",
22+
"councilCast": [],
1923
}
2024

2125
try:
@@ -85,6 +89,10 @@ def init_hud_state(
8589
"councilStatus": None,
8690
"blockerCount": 0,
8791
"lastHandoff": None,
92+
# Council UX fields (#1364)
93+
"councilActive": False,
94+
"councilStage": "",
95+
"councilCast": [],
8896
"updatedAt": now,
8997
}
9098
_locked_write(state_file, data)

packages/claude-code-plugin/hooks/tests/test_hud_helpers.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
on_tool_start,
2525
on_tool_end,
2626
on_session_stop,
27+
on_council_update,
2728
read_installed_version,
2829
_detect_focus,
2930
_detect_strategy,
@@ -133,6 +134,9 @@ def test_resets_all_stale_workflow_fields(self, state_file, mode, expected_phase
133134
executionStrategy="subagent",
134135
councilStatus="voting",
135136
lastHandoff="Frontend Developer",
137+
councilActive=True,
138+
councilStage="reviewing",
139+
councilCast=["arch", "security"],
136140
)
137141

138142
on_mode_entry(mode, state_file=state_file)
@@ -146,6 +150,10 @@ def test_resets_all_stale_workflow_fields(self, state_file, mode, expected_phase
146150
assert state["executionStrategy"] is None
147151
assert state["councilStatus"] is None
148152
assert state["lastHandoff"] is None
153+
# Council fields reset (#1364)
154+
assert state["councilActive"] is False
155+
assert state["councilStage"] == ""
156+
assert state["councilCast"] == []
149157

150158
def test_unknown_mode_defaults_to_ready(self, state_file):
151159
on_mode_entry("UNKNOWN", state_file=state_file)
@@ -259,6 +267,9 @@ def test_clears_agent_and_sets_completed(self, state_file):
259267
focus="app.tsx",
260268
executionStrategy="subagent",
261269
blockerCount=2,
270+
councilActive=True,
271+
councilStage="reviewing",
272+
councilCast=["arch"],
262273
)
263274

264275
on_session_stop(state_file=state_file)
@@ -270,6 +281,10 @@ def test_clears_agent_and_sets_completed(self, state_file):
270281
assert state["executionStrategy"] is None
271282
assert state["councilStatus"] is None
272283
assert state["blockerCount"] == 0
284+
# Council fields cleared (#1364)
285+
assert state["councilActive"] is False
286+
assert state["councilStage"] == ""
287+
assert state["councilCast"] == []
273288

274289
def test_preserves_session_metadata(self, state_file):
275290
on_session_stop(state_file=state_file)
@@ -355,6 +370,64 @@ def test_returns_none_for_missing_prompt(self):
355370
assert _extract_mode_from_parse_mode({}) is None
356371

357372

373+
# ---- on_council_update ----
374+
375+
class TestOnCouncilUpdate:
376+
"""Council update helper (#1364)."""
377+
378+
def test_starts_council(self, state_file):
379+
on_council_update(
380+
active=True,
381+
stage="opening",
382+
cast=["security-specialist", "arch-specialist"],
383+
state_file=state_file,
384+
)
385+
state = _read(state_file)
386+
assert state["councilActive"] is True
387+
assert state["councilStage"] == "opening"
388+
assert state["councilCast"] == ["security-specialist", "arch-specialist"]
389+
390+
def test_advances_stage(self, state_file):
391+
on_council_update(
392+
active=True,
393+
stage="opening",
394+
cast=["a", "b"],
395+
state_file=state_file,
396+
)
397+
on_council_update(stage="reviewing", state_file=state_file)
398+
state = _read(state_file)
399+
assert state["councilStage"] == "reviewing"
400+
assert state["councilActive"] is True # preserved
401+
assert state["councilCast"] == ["a", "b"] # preserved
402+
403+
def test_ends_council(self, state_file):
404+
on_council_update(
405+
active=True,
406+
stage="opening",
407+
cast=["a"],
408+
state_file=state_file,
409+
)
410+
on_council_update(
411+
active=False,
412+
stage="done",
413+
cast=[],
414+
state_file=state_file,
415+
)
416+
state = _read(state_file)
417+
assert state["councilActive"] is False
418+
assert state["councilStage"] == "done"
419+
assert state["councilCast"] == []
420+
421+
def test_noop_when_no_args(self, state_file):
422+
"""No-op when called with no arguments."""
423+
old_state = _read(state_file)
424+
on_council_update(state_file=state_file)
425+
new_state = _read(state_file)
426+
assert new_state["councilActive"] == old_state["councilActive"]
427+
assert new_state["councilStage"] == old_state["councilStage"]
428+
assert new_state["councilCast"] == old_state["councilCast"]
429+
430+
358431
# ---- Full lifecycle transition test ----
359432

360433
class TestFullLifecycle:
@@ -400,13 +473,39 @@ def test_session_lifecycle(self, tmp_path, monkeypatch):
400473
state = _read(sf)
401474
assert state["focus"] == "testing"
402475

403-
# 7. Stop: clear active state
476+
# 7. Council activity during session
477+
on_council_update(
478+
active=True, stage="opening",
479+
cast=["security-specialist", "arch-specialist"],
480+
state_file=sf,
481+
)
482+
state = _read(sf)
483+
assert state["councilActive"] is True
484+
assert state["councilStage"] == "opening"
485+
assert state["councilCast"] == ["security-specialist", "arch-specialist"]
486+
487+
# 8. Council advances
488+
on_council_update(stage="consensus", state_file=sf)
489+
state = _read(sf)
490+
assert state["councilStage"] == "consensus"
491+
492+
# 9. Mode re-entry resets council
493+
on_mode_entry("EVAL", state_file=sf)
494+
state = _read(sf)
495+
assert state["councilActive"] is False
496+
assert state["councilStage"] == ""
497+
assert state["councilCast"] == []
498+
499+
# 10. Stop: clear active state
404500
on_session_stop(state_file=sf)
405501
state = _read(sf)
406502
assert state["activeAgent"] is None
407503
assert state["phase"] == "completed"
408504
assert state["focus"] is None
409505
assert state["executionStrategy"] is None
506+
assert state["councilActive"] is False
507+
assert state["councilStage"] == ""
508+
assert state["councilCast"] == []
410509
# Session metadata survives
411510
assert state["sessionId"] == "lifecycle-test"
412511

packages/claude-code-plugin/tests/test_hud_state.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ def test_init_includes_extended_fields(self, tmp_path):
100100
assert data["councilStatus"] is None
101101
assert data["blockerCount"] == 0
102102
assert data["lastHandoff"] is None
103+
# Council fields (#1364)
104+
assert data["councilActive"] is False
105+
assert data["councilStage"] == ""
106+
assert data["councilCast"] == []
103107

104108
def test_update_extended_fields(self, tmp_path):
105109
path = str(tmp_path / "hud-state.json")
@@ -135,6 +139,113 @@ def test_partial_update_preserves_other_extended_fields(self, tmp_path):
135139
assert data["blockerCount"] == 1
136140

137141

142+
class TestCouncilFields:
143+
"""Tests for council UX fields (#1364)."""
144+
145+
def test_init_includes_council_defaults(self, tmp_path):
146+
path = str(tmp_path / "hud-state.json")
147+
init_hud_state("s1", "5.4.0", state_file=path)
148+
data = read_hud_state(path)
149+
150+
assert data["councilActive"] is False
151+
assert data["councilStage"] == ""
152+
assert data["councilCast"] == []
153+
154+
def test_update_council_active(self, tmp_path):
155+
path = str(tmp_path / "hud-state.json")
156+
init_hud_state("s1", "5.4.0", state_file=path)
157+
158+
update_hud_state(state_file=path, councilActive=True)
159+
data = read_hud_state(path)
160+
assert data["councilActive"] is True
161+
162+
def test_update_council_stage(self, tmp_path):
163+
path = str(tmp_path / "hud-state.json")
164+
init_hud_state("s1", "5.4.0", state_file=path)
165+
166+
update_hud_state(state_file=path, councilStage="reviewing")
167+
data = read_hud_state(path)
168+
assert data["councilStage"] == "reviewing"
169+
170+
def test_update_council_cast(self, tmp_path):
171+
path = str(tmp_path / "hud-state.json")
172+
init_hud_state("s1", "5.4.0", state_file=path)
173+
174+
cast = ["security-specialist", "frontend-developer"]
175+
update_hud_state(state_file=path, councilCast=cast)
176+
data = read_hud_state(path)
177+
assert data["councilCast"] == cast
178+
179+
def test_council_full_lifecycle(self, tmp_path):
180+
"""Council start -> review -> consensus -> done."""
181+
path = str(tmp_path / "hud-state.json")
182+
init_hud_state("s1", "5.4.0", state_file=path)
183+
184+
# Start council
185+
update_hud_state(
186+
state_file=path,
187+
councilActive=True,
188+
councilStage="opening",
189+
councilCast=["arch", "security"],
190+
)
191+
data = read_hud_state(path)
192+
assert data["councilActive"] is True
193+
assert data["councilStage"] == "opening"
194+
assert data["councilCast"] == ["arch", "security"]
195+
196+
# Advance to reviewing
197+
update_hud_state(state_file=path, councilStage="reviewing")
198+
data = read_hud_state(path)
199+
assert data["councilStage"] == "reviewing"
200+
assert data["councilActive"] is True # preserved
201+
202+
# Consensus
203+
update_hud_state(state_file=path, councilStage="consensus")
204+
data = read_hud_state(path)
205+
assert data["councilStage"] == "consensus"
206+
207+
# Done
208+
update_hud_state(
209+
state_file=path,
210+
councilActive=False,
211+
councilStage="done",
212+
councilCast=[],
213+
)
214+
data = read_hud_state(path)
215+
assert data["councilActive"] is False
216+
assert data["councilStage"] == "done"
217+
assert data["councilCast"] == []
218+
219+
def test_fill_defaults_includes_council_fields(self, tmp_path):
220+
"""Old state files get council defaults via fill_defaults."""
221+
path = str(tmp_path / "old.json")
222+
old_data = {"sessionId": "old-1", "version": "5.1.0"}
223+
with open(path, "w") as f:
224+
json.dump(old_data, f)
225+
226+
result = read_hud_state(path, fill_defaults=True)
227+
assert result["councilActive"] is False
228+
assert result["councilStage"] == ""
229+
assert result["councilCast"] == []
230+
231+
def test_fill_defaults_does_not_overwrite_council(self, tmp_path):
232+
"""fill_defaults must not overwrite existing council fields."""
233+
path = str(tmp_path / "partial.json")
234+
partial = {
235+
"sessionId": "p-1",
236+
"councilActive": True,
237+
"councilStage": "reviewing",
238+
"councilCast": ["agent-a"],
239+
}
240+
with open(path, "w") as f:
241+
json.dump(partial, f)
242+
243+
result = read_hud_state(path, fill_defaults=True)
244+
assert result["councilActive"] is True
245+
assert result["councilStage"] == "reviewing"
246+
assert result["councilCast"] == ["agent-a"]
247+
248+
138249
class TestBackwardCompat:
139250
"""Backward compatibility with older state files missing extended keys (#1326)."""
140251

0 commit comments

Comments
 (0)