1616
1717import json
1818import os
19+ import re
1920from pathlib import Path
2021from typing import Dict , List , Optional
2122
22- from hud_state import update_hud_state
23+ from hud_state import read_hud_state , update_hud_state
24+
25+ # Council lifecycle stages — forward-only progression (#1368)
26+ COUNCIL_STAGES = ("opening" , "reviewing" , "consensus" , "done" )
27+
28+ # Tool completions that signal specialist analysis is underway
29+ _SPECIALIST_SIGNAL_TOOLS = frozenset ({
30+ "Agent" ,
31+ "mcp__codingbuddy__analyze_task" ,
32+ "mcp__codingbuddy__prepare_parallel_agents" ,
33+ "mcp__codingbuddy__dispatch_agents" ,
34+ "mcp__codingbuddy__generate_checklist" ,
35+ })
36+
37+ # Tool completions that signal the council is converging on decisions
38+ _CONSENSUS_SIGNAL_TOOLS = frozenset ({
39+ "Edit" ,
40+ "Write" ,
41+ "mcp__codingbuddy__update_context" ,
42+ })
43+
44+ # Quality-check command patterns for blocker detection
45+ _TEST_CMD_PATTERNS = ("pytest" , "vitest" , "jest" , "yarn test" , "npm test" )
46+ _TYPECHECK_CMD_PATTERNS = ("tsc" , "type-check" , "typecheck" )
47+ _LINT_CMD_PATTERNS = ("eslint" , "prettier --check" , "yarn lint" , "npm run lint" )
2348
2449_DEFAULT_PLUGINS_FILE = str (
2550 Path .home () / ".claude" / "plugins" / "installed_plugins.json"
@@ -164,8 +189,9 @@ def on_tool_end(
164189) -> None :
165190 """Record stable post-action state after a tool completes.
166191
167- Called from PostToolUse. Captures agent handoffs and phase
168- transitions that are evident from tool outputs.
192+ Called from PostToolUse. Captures agent handoffs, phase
193+ transitions, council stage advancement, and blocker counts
194+ evident from tool outputs (#1368).
169195
170196 Args:
171197 tool_name: Name of the completed tool.
@@ -190,6 +216,25 @@ def on_tool_end(
190216 updates ["currentMode" ] = mode
191217 updates ["phase" ] = phase
192218
219+ # Council stage advancement (#1368) — only read state when the
220+ # tool could actually trigger a transition, avoiding disk I/O
221+ # on every Read/Grep/Glob call.
222+ _council_tools = _SPECIALIST_SIGNAL_TOOLS | _CONSENSUS_SIGNAL_TOOLS | {"mcp__codingbuddy__parse_mode" }
223+ if tool_name in _council_tools :
224+ sf_kwargs = {"state_file" : state_file } if state_file else {}
225+ state = read_hud_state (fill_defaults = True , ** sf_kwargs )
226+
227+ if state .get ("councilActive" ):
228+ current_stage = state .get ("councilStage" , "" )
229+ next_stage = _infer_council_advance (tool_name , current_stage )
230+ if next_stage :
231+ updates ["councilStage" ] = next_stage
232+
233+ # Blocker detection (#1368)
234+ blocker_count = _detect_blocker_count (tool_name , tool_input , tool_output )
235+ if blocker_count is not None :
236+ updates ["blockerCount" ] = blocker_count
237+
193238 if updates :
194239 if state_file :
195240 update_hud_state (state_file = state_file , ** updates )
@@ -307,6 +352,80 @@ def on_council_update(
307352# ---- private helpers ----
308353
309354
355+ def _infer_council_advance (
356+ tool_name : str ,
357+ current_stage : str ,
358+ ) -> Optional [str ]:
359+ """Infer the next council stage from a completed tool.
360+
361+ Stage transitions are forward-only:
362+ opening → reviewing → consensus → done
363+
364+ Returns the new stage name, or None if no transition applies.
365+ """
366+ if not current_stage or current_stage not in COUNCIL_STAGES :
367+ return None
368+
369+ if current_stage == "done" :
370+ return None
371+
372+ if current_stage == "opening" and tool_name in _SPECIALIST_SIGNAL_TOOLS :
373+ return "reviewing"
374+
375+ if current_stage == "reviewing" and tool_name in _CONSENSUS_SIGNAL_TOOLS :
376+ return "consensus"
377+
378+ if current_stage == "consensus" and tool_name == "mcp__codingbuddy__parse_mode" :
379+ return "done"
380+
381+ return None
382+
383+
384+ def _detect_blocker_count (
385+ tool_name : str ,
386+ tool_input : dict ,
387+ tool_output : str ,
388+ ) -> Optional [int ]:
389+ """Detect blocker count from quality-check Bash output.
390+
391+ Returns:
392+ int >= 0 when the tool is a quality-check command
393+ (0 means all checks passed — clear blockers).
394+ None for non-quality tools (meaning "don't touch blockerCount").
395+ """
396+ if tool_name != "Bash" :
397+ return None
398+
399+ cmd = tool_input .get ("command" , "" )
400+ output = (tool_output or "" ).lower ()
401+
402+ # Test runner
403+ if any (p in cmd for p in _TEST_CMD_PATTERNS ):
404+ match = re .search (r"(\d+)\s+failed" , output )
405+ if match :
406+ return int (match .group (1 ))
407+ if "failed" in output or "fail" in output :
408+ return 1
409+ return 0
410+
411+ # Type checker
412+ if any (p in cmd for p in _TYPECHECK_CMD_PATTERNS ):
413+ errors = re .findall (r"error ts\d+" , output )
414+ if errors :
415+ return len (errors )
416+ if "error" in output :
417+ return 1
418+ return 0
419+
420+ # Linter
421+ if any (p in cmd for p in _LINT_CMD_PATTERNS ):
422+ if "error" in output :
423+ return 1
424+ return 0
425+
426+ return None
427+
428+
310429def _detect_focus (tool_name : str , tool_input : dict ) -> Optional [str ]:
311430 """Infer a human-readable focus label from the current tool call."""
312431 if tool_name == "Edit" or tool_name == "Write" :
0 commit comments