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
261 changes: 261 additions & 0 deletions packages/claude-code-plugin/hooks/lib/hud_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
"""HUD state helper functions for consistent updates across hooks (#1324).

Provides high-level helpers that multiple hooks can call to update the
HUD state file with standard field semantics. All functions silently
no-op on any error so they never block Claude Code.

Field ownership:
SessionStart -> init_baseline()
UserPromptSubmit -> on_mode_entry()
PreToolUse -> on_tool_start()
PostToolUse -> on_tool_end()
Stop -> on_session_stop()
"""

from __future__ import annotations

import os
from typing import Optional

from hud_state import update_hud_state

# Map mode -> initial phase value
_MODE_PHASE_MAP = {
"PLAN": "planning",
"ACT": "executing",
"EVAL": "evaluating",
"AUTO": "cycling",
}


def on_mode_entry(
mode: str,
*,
state_file: Optional[str] = None,
) -> None:
"""Reset workflow fields when a new mode is entered.

Called from UserPromptSubmit after mode keyword detection.

Args:
mode: The detected mode (PLAN, ACT, EVAL, AUTO).
state_file: Optional explicit path; uses default when None.
"""
try:
phase = _MODE_PHASE_MAP.get(mode, "ready")
kwargs = {
"currentMode": mode,
"phase": phase,
"focus": None,
"blockerCount": 0,
}
if state_file:
update_hud_state(state_file=state_file, **kwargs)
else:
update_hud_state(**kwargs)
except Exception:
pass


def on_tool_start(
tool_name: str,
tool_input: dict,
*,
state_file: Optional[str] = None,
) -> None:
"""Update HUD when a tool invocation begins.

Called from PreToolUse. Only updates when the information is
meaningfully stable (e.g. active agent change, MCP parse_mode).

Args:
tool_name: Name of the tool being invoked.
tool_input: The tool_input dict from the hook payload.
state_file: Optional explicit path; uses default when None.
"""
try:
updates: dict = {}

# Detect active agent from environment (set by parse_mode MCP)
agent = os.environ.get("CODINGBUDDY_ACTIVE_AGENT", "")
if agent:
updates["activeAgent"] = agent

# Detect focus from meaningful tool patterns
focus = _detect_focus(tool_name, tool_input)
if focus is not None:
updates["focus"] = focus

# Detect execution strategy from Agent/Task tool use
strategy = _detect_strategy(tool_name, tool_input)
if strategy is not None:
updates["executionStrategy"] = strategy

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


def on_tool_end(
tool_name: str,
tool_input: dict,
tool_output: str,
*,
state_file: Optional[str] = None,
) -> None:
"""Record stable post-action state after a tool completes.

Called from PostToolUse. Captures agent handoffs and phase
transitions that are evident from tool outputs.

Args:
tool_name: Name of the completed tool.
tool_input: The tool_input dict from the hook payload.
tool_output: The tool_output string from the hook payload.
state_file: Optional explicit path; uses default when None.
"""
try:
updates: dict = {}

# Track agent handoffs via environment changes
agent = os.environ.get("CODINGBUDDY_ACTIVE_AGENT", "")
if agent:
updates["activeAgent"] = agent
updates["lastHandoff"] = agent

# Detect phase changes from parse_mode MCP calls
if tool_name == "mcp__codingbuddy__parse_mode":
mode = _extract_mode_from_parse_mode(tool_input)
if mode:
phase = _MODE_PHASE_MAP.get(mode, "ready")
updates["currentMode"] = mode
updates["phase"] = phase

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


def on_session_stop(
*,
state_file: Optional[str] = None,
) -> None:
"""Clear active workflow state when the session ends.

Called from Stop hook.

Args:
state_file: Optional explicit path; uses default when None.
"""
try:
kwargs = {
"activeAgent": None,
"phase": "completed",
"focus": None,
"executionStrategy": None,
"councilStatus": None,
"blockerCount": 0,
}
if state_file:
update_hud_state(state_file=state_file, **kwargs)
else:
update_hud_state(**kwargs)
except Exception:
pass


def init_baseline(
pending_context: Optional[dict] = None,
*,
state_file: Optional[str] = None,
) -> None:
"""Enrich the freshly-initialised HUD state with baseline context.

Called from SessionStart *after* ``init_hud_state()``. If a pending
context.md was detected, seeds currentMode and phase so the status
bar immediately reflects the resuming session.

Args:
pending_context: Dict with optional ``mode``/``status`` keys
from ``_read_pending_context()``.
state_file: Optional explicit path; uses default when None.
"""
if not pending_context:
return

try:
mode = pending_context.get("mode")
if not mode:
return

updates: dict = {"currentMode": mode}
phase = _MODE_PHASE_MAP.get(mode)
if phase:
updates["phase"] = phase

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


# ---- private helpers ----


def _detect_focus(tool_name: str, tool_input: dict) -> Optional[str]:
"""Infer a human-readable focus label from the current tool call."""
if tool_name == "Edit" or tool_name == "Write":
path = tool_input.get("file_path", "")
if path:
# Return just the filename for brevity
return os.path.basename(path)

if tool_name == "Bash":
cmd = tool_input.get("command", "")
if cmd.startswith("git commit"):
return "committing"
if cmd.startswith("git push"):
return "pushing"
if "pytest" in cmd or "vitest" in cmd or "jest" in cmd:
return "testing"
if "yarn build" in cmd or "npm run build" in cmd:
return "building"

if tool_name == "mcp__codingbuddy__parse_mode":
prompt = tool_input.get("prompt", "")
if prompt:
# First 40 chars of the prompt as focus
return prompt[:40].strip()

return None


def _detect_strategy(tool_name: str, tool_input: dict) -> Optional[str]:
"""Detect execution strategy from Agent/Task tool patterns."""
if tool_name == "Agent":
return "subagent"
if tool_name == "Bash":
cmd = tool_input.get("command", "")
if "tmux" in cmd:
return "taskmaestro"
return None


def _extract_mode_from_parse_mode(tool_input: dict) -> Optional[str]:
"""Extract the mode keyword from a parse_mode tool_input."""
prompt = tool_input.get("prompt", "")
if not prompt:
return None
first_word = prompt.strip().split()[0].upper().rstrip(":")
valid_modes = {"PLAN", "ACT", "EVAL", "AUTO"}
return first_word if first_word in valid_modes else None
12 changes: 12 additions & 0 deletions packages/claude-code-plugin/hooks/post-tool-use.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ def handle_post_tool_use(data: dict):
except Exception:
pass # Never block tool execution

# Update HUD state with post-action information (#1324)
try:
from hud_helpers import on_tool_end

on_tool_end(
data.get("tool_name", ""),
data.get("tool_input", {}),
str(data.get("tool_output", "")),
)
except Exception:
pass # Never block tool execution

return None


Expand Down
8 changes: 8 additions & 0 deletions packages/claude-code-plugin/hooks/pre-tool-use.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,14 @@ def _handle(data: dict) -> Optional[dict]:
if checklist_warning:
contexts.append(checklist_warning)

# Update HUD state with active agent, focus, strategy (#1324)
try:
from hud_helpers import on_tool_start

on_tool_start(tool_name, data.get("tool_input", {}))
except Exception:
pass

# Build response — include statusMessage and/or additionalContext
if not status_msg and not contexts:
return None
Expand Down
12 changes: 12 additions & 0 deletions packages/claude-code-plugin/hooks/session-start.py
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,7 @@ def main():
pass # Never block session start

# Step 4.5: Initialize HUD state for statusLine (#1089)
_pending_ctx_for_hud = None
try:
_ensure_lib_path()

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

# Step 4.5b: Enrich HUD baseline with pending context (#1324)
try:
cwd_hud = os.environ.get("CLAUDE_PROJECT_DIR", str(Path.cwd()))
_pending_ctx_for_hud = _read_pending_context(cwd_hud)
if _pending_ctx_for_hud:
from hud_helpers import init_baseline

init_baseline(_pending_ctx_for_hud)
except Exception:
pass # Never block session start

# Step 4.6: Detect recent briefings and suggest recovery (#1125)
try:
_check_briefing_recovery()
Expand Down
8 changes: 8 additions & 0 deletions packages/claude-code-plugin/hooks/stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ def handle_stop(data: dict):
except Exception:
pass # Never block session stop

# Clear active HUD state (#1324)
try:
from hud_helpers import on_session_stop

on_session_stop()
except Exception:
pass # Never block session stop

# End session in history database (#823)
try:
from history_db import HistoryDB
Expand Down
Loading
Loading