Skip to content

Commit 5f9fef3

Browse files
committed
feat(plugin): wire hook-driven workflow state updates into HUD snapshot (#1324)
Add hud_helpers.py module with consistent state update functions called from each hook lifecycle event: - SessionStart: init_baseline() seeds currentMode/phase from pending context - UserPromptSubmit: on_mode_entry() sets mode, phase, resets focus/blockers - PreToolUse: on_tool_start() updates activeAgent, focus, executionStrategy - PostToolUse: on_tool_end() records agent handoffs and parse_mode transitions - Stop: on_session_stop() clears active state, sets phase=completed 45 new tests covering all state transitions and a full lifecycle test.
1 parent e154ec1 commit 5f9fef3

7 files changed

Lines changed: 682 additions & 4 deletions

File tree

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
"""HUD state helper functions for consistent updates across hooks (#1324).
2+
3+
Provides high-level helpers that multiple hooks can call to update the
4+
HUD state file with standard field semantics. All functions silently
5+
no-op on any error so they never block Claude Code.
6+
7+
Field ownership:
8+
SessionStart -> init_baseline()
9+
UserPromptSubmit -> on_mode_entry()
10+
PreToolUse -> on_tool_start()
11+
PostToolUse -> on_tool_end()
12+
Stop -> on_session_stop()
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import os
18+
from typing import Optional
19+
20+
from hud_state import update_hud_state
21+
22+
# Map mode -> initial phase value
23+
_MODE_PHASE_MAP = {
24+
"PLAN": "planning",
25+
"ACT": "executing",
26+
"EVAL": "evaluating",
27+
"AUTO": "cycling",
28+
}
29+
30+
31+
def on_mode_entry(
32+
mode: str,
33+
*,
34+
state_file: Optional[str] = None,
35+
) -> None:
36+
"""Reset workflow fields when a new mode is entered.
37+
38+
Called from UserPromptSubmit after mode keyword detection.
39+
40+
Args:
41+
mode: The detected mode (PLAN, ACT, EVAL, AUTO).
42+
state_file: Optional explicit path; uses default when None.
43+
"""
44+
try:
45+
phase = _MODE_PHASE_MAP.get(mode, "ready")
46+
kwargs = {
47+
"currentMode": mode,
48+
"phase": phase,
49+
"focus": None,
50+
"blockerCount": 0,
51+
}
52+
if state_file:
53+
update_hud_state(state_file=state_file, **kwargs)
54+
else:
55+
update_hud_state(**kwargs)
56+
except Exception:
57+
pass
58+
59+
60+
def on_tool_start(
61+
tool_name: str,
62+
tool_input: dict,
63+
*,
64+
state_file: Optional[str] = None,
65+
) -> None:
66+
"""Update HUD when a tool invocation begins.
67+
68+
Called from PreToolUse. Only updates when the information is
69+
meaningfully stable (e.g. active agent change, MCP parse_mode).
70+
71+
Args:
72+
tool_name: Name of the tool being invoked.
73+
tool_input: The tool_input dict from the hook payload.
74+
state_file: Optional explicit path; uses default when None.
75+
"""
76+
try:
77+
updates: dict = {}
78+
79+
# Detect active agent from environment (set by parse_mode MCP)
80+
agent = os.environ.get("CODINGBUDDY_ACTIVE_AGENT", "")
81+
if agent:
82+
updates["activeAgent"] = agent
83+
84+
# Detect focus from meaningful tool patterns
85+
focus = _detect_focus(tool_name, tool_input)
86+
if focus is not None:
87+
updates["focus"] = focus
88+
89+
# Detect execution strategy from Agent/Task tool use
90+
strategy = _detect_strategy(tool_name, tool_input)
91+
if strategy is not None:
92+
updates["executionStrategy"] = strategy
93+
94+
if updates:
95+
if state_file:
96+
update_hud_state(state_file=state_file, **updates)
97+
else:
98+
update_hud_state(**updates)
99+
except Exception:
100+
pass
101+
102+
103+
def on_tool_end(
104+
tool_name: str,
105+
tool_input: dict,
106+
tool_output: str,
107+
*,
108+
state_file: Optional[str] = None,
109+
) -> None:
110+
"""Record stable post-action state after a tool completes.
111+
112+
Called from PostToolUse. Captures agent handoffs and phase
113+
transitions that are evident from tool outputs.
114+
115+
Args:
116+
tool_name: Name of the completed tool.
117+
tool_input: The tool_input dict from the hook payload.
118+
tool_output: The tool_output string from the hook payload.
119+
state_file: Optional explicit path; uses default when None.
120+
"""
121+
try:
122+
updates: dict = {}
123+
124+
# Track agent handoffs via environment changes
125+
agent = os.environ.get("CODINGBUDDY_ACTIVE_AGENT", "")
126+
if agent:
127+
updates["activeAgent"] = agent
128+
updates["lastHandoff"] = agent
129+
130+
# Detect phase changes from parse_mode MCP calls
131+
if tool_name == "mcp__codingbuddy__parse_mode":
132+
mode = _extract_mode_from_parse_mode(tool_input)
133+
if mode:
134+
phase = _MODE_PHASE_MAP.get(mode, "ready")
135+
updates["currentMode"] = mode
136+
updates["phase"] = phase
137+
138+
if updates:
139+
if state_file:
140+
update_hud_state(state_file=state_file, **updates)
141+
else:
142+
update_hud_state(**updates)
143+
except Exception:
144+
pass
145+
146+
147+
def on_session_stop(
148+
*,
149+
state_file: Optional[str] = None,
150+
) -> None:
151+
"""Clear active workflow state when the session ends.
152+
153+
Called from Stop hook.
154+
155+
Args:
156+
state_file: Optional explicit path; uses default when None.
157+
"""
158+
try:
159+
kwargs = {
160+
"activeAgent": None,
161+
"phase": "completed",
162+
"focus": None,
163+
"executionStrategy": None,
164+
"councilStatus": None,
165+
"blockerCount": 0,
166+
}
167+
if state_file:
168+
update_hud_state(state_file=state_file, **kwargs)
169+
else:
170+
update_hud_state(**kwargs)
171+
except Exception:
172+
pass
173+
174+
175+
def init_baseline(
176+
pending_context: Optional[dict] = None,
177+
*,
178+
state_file: Optional[str] = None,
179+
) -> None:
180+
"""Enrich the freshly-initialised HUD state with baseline context.
181+
182+
Called from SessionStart *after* ``init_hud_state()``. If a pending
183+
context.md was detected, seeds currentMode and phase so the status
184+
bar immediately reflects the resuming session.
185+
186+
Args:
187+
pending_context: Dict with optional ``mode``/``status`` keys
188+
from ``_read_pending_context()``.
189+
state_file: Optional explicit path; uses default when None.
190+
"""
191+
if not pending_context:
192+
return
193+
194+
try:
195+
mode = pending_context.get("mode")
196+
if not mode:
197+
return
198+
199+
updates: dict = {"currentMode": mode}
200+
phase = _MODE_PHASE_MAP.get(mode)
201+
if phase:
202+
updates["phase"] = phase
203+
204+
if state_file:
205+
update_hud_state(state_file=state_file, **updates)
206+
else:
207+
update_hud_state(**updates)
208+
except Exception:
209+
pass
210+
211+
212+
# ---- private helpers ----
213+
214+
215+
def _detect_focus(tool_name: str, tool_input: dict) -> Optional[str]:
216+
"""Infer a human-readable focus label from the current tool call."""
217+
if tool_name == "Edit" or tool_name == "Write":
218+
path = tool_input.get("file_path", "")
219+
if path:
220+
# Return just the filename for brevity
221+
return os.path.basename(path)
222+
223+
if tool_name == "Bash":
224+
cmd = tool_input.get("command", "")
225+
if cmd.startswith("git commit"):
226+
return "committing"
227+
if cmd.startswith("git push"):
228+
return "pushing"
229+
if "pytest" in cmd or "vitest" in cmd or "jest" in cmd:
230+
return "testing"
231+
if "yarn build" in cmd or "npm run build" in cmd:
232+
return "building"
233+
234+
if tool_name == "mcp__codingbuddy__parse_mode":
235+
prompt = tool_input.get("prompt", "")
236+
if prompt:
237+
# First 40 chars of the prompt as focus
238+
return prompt[:40].strip()
239+
240+
return None
241+
242+
243+
def _detect_strategy(tool_name: str, tool_input: dict) -> Optional[str]:
244+
"""Detect execution strategy from Agent/Task tool patterns."""
245+
if tool_name == "Agent":
246+
return "subagent"
247+
if tool_name == "Bash":
248+
cmd = tool_input.get("command", "")
249+
if "tmux" in cmd:
250+
return "taskmaestro"
251+
return None
252+
253+
254+
def _extract_mode_from_parse_mode(tool_input: dict) -> Optional[str]:
255+
"""Extract the mode keyword from a parse_mode tool_input."""
256+
prompt = tool_input.get("prompt", "")
257+
if not prompt:
258+
return None
259+
first_word = prompt.strip().split()[0].upper().rstrip(":")
260+
valid_modes = {"PLAN", "ACT", "EVAL", "AUTO"}
261+
return first_word if first_word in valid_modes else None

packages/claude-code-plugin/hooks/post-tool-use.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ def handle_post_tool_use(data: dict):
5656
except Exception:
5757
pass # Never block tool execution
5858

59+
# Update HUD state with post-action information (#1324)
60+
try:
61+
from hud_helpers import on_tool_end
62+
63+
on_tool_end(
64+
data.get("tool_name", ""),
65+
data.get("tool_input", {}),
66+
str(data.get("tool_output", "")),
67+
)
68+
except Exception:
69+
pass # Never block tool execution
70+
5971
return None
6072

6173

packages/claude-code-plugin/hooks/pre-tool-use.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,14 @@ def _handle(data: dict) -> Optional[dict]:
237237
if checklist_warning:
238238
contexts.append(checklist_warning)
239239

240+
# Update HUD state with active agent, focus, strategy (#1324)
241+
try:
242+
from hud_helpers import on_tool_start
243+
244+
on_tool_start(tool_name, data.get("tool_input", {}))
245+
except Exception:
246+
pass
247+
240248
# Build response — include statusMessage and/or additionalContext
241249
if not status_msg and not contexts:
242250
return None

packages/claude-code-plugin/hooks/session-start.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,7 @@ def main():
773773
pass # Never block session start
774774

775775
# Step 4.5: Initialize HUD state for statusLine (#1089)
776+
_pending_ctx_for_hud = None
776777
try:
777778
_ensure_lib_path()
778779

@@ -784,6 +785,17 @@ def main():
784785
except Exception:
785786
pass # Never block session start
786787

788+
# Step 4.5b: Enrich HUD baseline with pending context (#1324)
789+
try:
790+
cwd_hud = os.environ.get("CLAUDE_PROJECT_DIR", str(Path.cwd()))
791+
_pending_ctx_for_hud = _read_pending_context(cwd_hud)
792+
if _pending_ctx_for_hud:
793+
from hud_helpers import init_baseline
794+
795+
init_baseline(_pending_ctx_for_hud)
796+
except Exception:
797+
pass # Never block session start
798+
787799
# Step 4.6: Detect recent briefings and suggest recovery (#1125)
788800
try:
789801
_check_briefing_recovery()

packages/claude-code-plugin/hooks/stop.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ def handle_stop(data: dict):
117117
except Exception:
118118
pass # Never block session stop
119119

120+
# Clear active HUD state (#1324)
121+
try:
122+
from hud_helpers import on_session_stop
123+
124+
on_session_stop()
125+
except Exception:
126+
pass # Never block session stop
127+
120128
# End session in history database (#823)
121129
try:
122130
from history_db import HistoryDB

0 commit comments

Comments
 (0)