Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion packages/claude-code-plugin/hooks/lib/hud_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import json
import os
from pathlib import Path
from typing import Optional
from typing import List, Optional

from hud_state import update_hud_state

Expand Down Expand Up @@ -79,6 +79,10 @@ def on_mode_entry(
"executionStrategy": None,
"councilStatus": None,
"lastHandoff": None,
# Council UX reset (#1364)
"councilActive": False,
"councilStage": "",
"councilCast": [],
}
if state_file:
update_hud_state(state_file=state_file, **kwargs)
Expand Down Expand Up @@ -194,6 +198,10 @@ def on_session_stop(
"executionStrategy": None,
"councilStatus": None,
"blockerCount": 0,
# Council UX reset (#1364)
"councilActive": False,
"councilStage": "",
"councilCast": [],
}
if state_file:
update_hud_state(state_file=state_file, **kwargs)
Expand Down Expand Up @@ -240,6 +248,42 @@ def init_baseline(
pass


def on_council_update(
*,
active: Optional[bool] = None,
stage: Optional[str] = None,
cast: Optional[List[str]] = None,
state_file: Optional[str] = None,
) -> None:
"""Update council-related HUD fields (#1364).

Only supplied arguments are written; omitted fields are preserved.
Allows callers to start, advance, or end a council session.

Args:
active: Whether a council is currently active.
stage: Current council stage (opening, reviewing, consensus, done).
cast: List of specialist agent names participating in the council.
state_file: Optional explicit path; uses default when None.
"""
try:
updates: dict = {}
if active is not None:
updates["councilActive"] = active
if stage is not None:
updates["councilStage"] = stage
if cast is not None:
updates["councilCast"] = cast

if updates:
if state_file:
update_hud_state(state_file=state_file, **updates)
else:
update_hud_state(**updates)
except Exception:
pass


# ---- private helpers ----


Expand Down
10 changes: 9 additions & 1 deletion packages/claude-code-plugin/hooks/lib/hud_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@
from datetime import datetime, timezone
from typing import Any, Dict

# Default values for extended schema fields (#1326).
# Default values for extended schema fields (#1326, #1364).
_EXTENDED_DEFAULTS: Dict[str, Any] = {
"phase": "ready",
"focus": None,
"executionStrategy": None,
"councilStatus": None,
"blockerCount": 0,
"lastHandoff": None,
# Council UX fields (#1364)
"councilActive": False,
"councilStage": "",
"councilCast": [],
}

try:
Expand Down Expand Up @@ -85,6 +89,10 @@ def init_hud_state(
"councilStatus": None,
"blockerCount": 0,
"lastHandoff": None,
# Council UX fields (#1364)
"councilActive": False,
"councilStage": "",
"councilCast": [],
"updatedAt": now,
}
_locked_write(state_file, data)
Expand Down
101 changes: 100 additions & 1 deletion packages/claude-code-plugin/hooks/tests/test_hud_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
on_tool_start,
on_tool_end,
on_session_stop,
on_council_update,
read_installed_version,
_detect_focus,
_detect_strategy,
Expand Down Expand Up @@ -133,6 +134,9 @@ def test_resets_all_stale_workflow_fields(self, state_file, mode, expected_phase
executionStrategy="subagent",
councilStatus="voting",
lastHandoff="Frontend Developer",
councilActive=True,
councilStage="reviewing",
councilCast=["arch", "security"],
)

on_mode_entry(mode, state_file=state_file)
Expand All @@ -146,6 +150,10 @@ def test_resets_all_stale_workflow_fields(self, state_file, mode, expected_phase
assert state["executionStrategy"] is None
assert state["councilStatus"] is None
assert state["lastHandoff"] is None
# Council fields reset (#1364)
assert state["councilActive"] is False
assert state["councilStage"] == ""
assert state["councilCast"] == []

def test_unknown_mode_defaults_to_ready(self, state_file):
on_mode_entry("UNKNOWN", state_file=state_file)
Expand Down Expand Up @@ -259,6 +267,9 @@ def test_clears_agent_and_sets_completed(self, state_file):
focus="app.tsx",
executionStrategy="subagent",
blockerCount=2,
councilActive=True,
councilStage="reviewing",
councilCast=["arch"],
)

on_session_stop(state_file=state_file)
Expand All @@ -270,6 +281,10 @@ def test_clears_agent_and_sets_completed(self, state_file):
assert state["executionStrategy"] is None
assert state["councilStatus"] is None
assert state["blockerCount"] == 0
# Council fields cleared (#1364)
assert state["councilActive"] is False
assert state["councilStage"] == ""
assert state["councilCast"] == []

def test_preserves_session_metadata(self, state_file):
on_session_stop(state_file=state_file)
Expand Down Expand Up @@ -355,6 +370,64 @@ def test_returns_none_for_missing_prompt(self):
assert _extract_mode_from_parse_mode({}) is None


# ---- on_council_update ----

class TestOnCouncilUpdate:
"""Council update helper (#1364)."""

def test_starts_council(self, state_file):
on_council_update(
active=True,
stage="opening",
cast=["security-specialist", "arch-specialist"],
state_file=state_file,
)
state = _read(state_file)
assert state["councilActive"] is True
assert state["councilStage"] == "opening"
assert state["councilCast"] == ["security-specialist", "arch-specialist"]

def test_advances_stage(self, state_file):
on_council_update(
active=True,
stage="opening",
cast=["a", "b"],
state_file=state_file,
)
on_council_update(stage="reviewing", state_file=state_file)
state = _read(state_file)
assert state["councilStage"] == "reviewing"
assert state["councilActive"] is True # preserved
assert state["councilCast"] == ["a", "b"] # preserved

def test_ends_council(self, state_file):
on_council_update(
active=True,
stage="opening",
cast=["a"],
state_file=state_file,
)
on_council_update(
active=False,
stage="done",
cast=[],
state_file=state_file,
)
state = _read(state_file)
assert state["councilActive"] is False
assert state["councilStage"] == "done"
assert state["councilCast"] == []

def test_noop_when_no_args(self, state_file):
"""No-op when called with no arguments."""
old_state = _read(state_file)
on_council_update(state_file=state_file)
new_state = _read(state_file)
assert new_state["councilActive"] == old_state["councilActive"]
assert new_state["councilStage"] == old_state["councilStage"]
assert new_state["councilCast"] == old_state["councilCast"]


# ---- Full lifecycle transition test ----

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

# 7. Stop: clear active state
# 7. Council activity during session
on_council_update(
active=True, stage="opening",
cast=["security-specialist", "arch-specialist"],
state_file=sf,
)
state = _read(sf)
assert state["councilActive"] is True
assert state["councilStage"] == "opening"
assert state["councilCast"] == ["security-specialist", "arch-specialist"]

# 8. Council advances
on_council_update(stage="consensus", state_file=sf)
state = _read(sf)
assert state["councilStage"] == "consensus"

# 9. Mode re-entry resets council
on_mode_entry("EVAL", state_file=sf)
state = _read(sf)
assert state["councilActive"] is False
assert state["councilStage"] == ""
assert state["councilCast"] == []

# 10. Stop: clear active state
on_session_stop(state_file=sf)
state = _read(sf)
assert state["activeAgent"] is None
assert state["phase"] == "completed"
assert state["focus"] is None
assert state["executionStrategy"] is None
assert state["councilActive"] is False
assert state["councilStage"] == ""
assert state["councilCast"] == []
# Session metadata survives
assert state["sessionId"] == "lifecycle-test"

Expand Down
111 changes: 111 additions & 0 deletions packages/claude-code-plugin/tests/test_hud_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ def test_init_includes_extended_fields(self, tmp_path):
assert data["councilStatus"] is None
assert data["blockerCount"] == 0
assert data["lastHandoff"] is None
# Council fields (#1364)
assert data["councilActive"] is False
assert data["councilStage"] == ""
assert data["councilCast"] == []

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


class TestCouncilFields:
"""Tests for council UX fields (#1364)."""

def test_init_includes_council_defaults(self, tmp_path):
path = str(tmp_path / "hud-state.json")
init_hud_state("s1", "5.4.0", state_file=path)
data = read_hud_state(path)

assert data["councilActive"] is False
assert data["councilStage"] == ""
assert data["councilCast"] == []

def test_update_council_active(self, tmp_path):
path = str(tmp_path / "hud-state.json")
init_hud_state("s1", "5.4.0", state_file=path)

update_hud_state(state_file=path, councilActive=True)
data = read_hud_state(path)
assert data["councilActive"] is True

def test_update_council_stage(self, tmp_path):
path = str(tmp_path / "hud-state.json")
init_hud_state("s1", "5.4.0", state_file=path)

update_hud_state(state_file=path, councilStage="reviewing")
data = read_hud_state(path)
assert data["councilStage"] == "reviewing"

def test_update_council_cast(self, tmp_path):
path = str(tmp_path / "hud-state.json")
init_hud_state("s1", "5.4.0", state_file=path)

cast = ["security-specialist", "frontend-developer"]
update_hud_state(state_file=path, councilCast=cast)
data = read_hud_state(path)
assert data["councilCast"] == cast

def test_council_full_lifecycle(self, tmp_path):
"""Council start -> review -> consensus -> done."""
path = str(tmp_path / "hud-state.json")
init_hud_state("s1", "5.4.0", state_file=path)

# Start council
update_hud_state(
state_file=path,
councilActive=True,
councilStage="opening",
councilCast=["arch", "security"],
)
data = read_hud_state(path)
assert data["councilActive"] is True
assert data["councilStage"] == "opening"
assert data["councilCast"] == ["arch", "security"]

# Advance to reviewing
update_hud_state(state_file=path, councilStage="reviewing")
data = read_hud_state(path)
assert data["councilStage"] == "reviewing"
assert data["councilActive"] is True # preserved

# Consensus
update_hud_state(state_file=path, councilStage="consensus")
data = read_hud_state(path)
assert data["councilStage"] == "consensus"

# Done
update_hud_state(
state_file=path,
councilActive=False,
councilStage="done",
councilCast=[],
)
data = read_hud_state(path)
assert data["councilActive"] is False
assert data["councilStage"] == "done"
assert data["councilCast"] == []

def test_fill_defaults_includes_council_fields(self, tmp_path):
"""Old state files get council defaults via fill_defaults."""
path = str(tmp_path / "old.json")
old_data = {"sessionId": "old-1", "version": "5.1.0"}
with open(path, "w") as f:
json.dump(old_data, f)

result = read_hud_state(path, fill_defaults=True)
assert result["councilActive"] is False
assert result["councilStage"] == ""
assert result["councilCast"] == []

def test_fill_defaults_does_not_overwrite_council(self, tmp_path):
"""fill_defaults must not overwrite existing council fields."""
path = str(tmp_path / "partial.json")
partial = {
"sessionId": "p-1",
"councilActive": True,
"councilStage": "reviewing",
"councilCast": ["agent-a"],
}
with open(path, "w") as f:
json.dump(partial, f)

result = read_hud_state(path, fill_defaults=True)
assert result["councilActive"] is True
assert result["councilStage"] == "reviewing"
assert result["councilCast"] == ["agent-a"]


class TestBackwardCompat:
"""Backward compatibility with older state files missing extended keys (#1326)."""

Expand Down
Loading