366 lines
11 KiB
Python
Executable File
366 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
SubagentStop Hook - Auto-Update PROJECT.md Progress After Pipeline
|
|
|
|
This hook automatically updates PROJECT.md goal progress after the doc-master
|
|
agent completes, marking the end of the /auto-implement pipeline.
|
|
|
|
Hook Type: SubagentStop
|
|
Trigger: After doc-master agent completes
|
|
Condition: All 7 agents completed successfully
|
|
|
|
Workflow:
|
|
1. Check if doc-master just completed (trigger condition)
|
|
2. Verify pipeline is complete (all 7 agents ran)
|
|
3. Invoke project-progress-tracker agent to assess progress
|
|
4. Parse YAML output from agent
|
|
5. Update PROJECT.md atomically with new progress
|
|
6. Create backup and handle rollback on failure
|
|
|
|
Relevant Skills:
|
|
- project-alignment-validation: GOALS validation patterns (see alignment-checklist.md)
|
|
|
|
Environment Variables (provided by Claude Code):
|
|
CLAUDE_AGENT_NAME - Name of the subagent that completed
|
|
CLAUDE_AGENT_OUTPUT - Output from the subagent
|
|
CLAUDE_AGENT_STATUS - Status: "success" or "error"
|
|
|
|
Output:
|
|
Updates PROJECT.md with goal progress
|
|
Logs actions to session file
|
|
|
|
Date: 2025-11-04
|
|
Feature: PROJECT.md auto-update
|
|
Agent: implementer
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, Optional, Any
|
|
|
|
# Add project root to path for imports
|
|
project_root = Path(__file__).resolve().parents[3]
|
|
sys.path.insert(0, str(project_root / "scripts"))
|
|
sys.path.insert(0, str(project_root / "plugins" / "autonomous-dev" / "lib"))
|
|
|
|
try:
|
|
from agent_tracker import AgentTracker
|
|
from project_md_updater import ProjectMdUpdater
|
|
except ImportError as e:
|
|
print(f"Warning: Required module not found: {e}", file=sys.stderr)
|
|
sys.exit(0)
|
|
|
|
|
|
def should_trigger_update(agent_name: str) -> bool:
|
|
"""Check if hook should trigger for this agent.
|
|
|
|
Args:
|
|
agent_name: Name of agent that completed
|
|
|
|
Returns:
|
|
True if should trigger (doc-master only), False otherwise
|
|
"""
|
|
return agent_name == "doc-master"
|
|
|
|
|
|
def check_pipeline_complete(session_file: Path) -> bool:
|
|
"""Check if all 7 agents in pipeline completed.
|
|
|
|
Args:
|
|
session_file: Path to session JSON file
|
|
|
|
Returns:
|
|
True if pipeline complete, False otherwise
|
|
"""
|
|
if not session_file.exists():
|
|
return False
|
|
|
|
try:
|
|
session_data = json.loads(session_file.read_text())
|
|
except (json.JSONDecodeError, OSError):
|
|
return False
|
|
|
|
# Check if all expected agents completed
|
|
expected_agents = [
|
|
"researcher",
|
|
"planner",
|
|
"test-master",
|
|
"implementer",
|
|
"reviewer",
|
|
"security-auditor",
|
|
"doc-master"
|
|
]
|
|
|
|
completed_agents = {
|
|
entry["agent"] for entry in session_data.get("agents", [])
|
|
if entry.get("status") == "completed"
|
|
}
|
|
|
|
return set(expected_agents).issubset(completed_agents)
|
|
|
|
|
|
def invoke_progress_tracker(timeout: int = 30) -> Optional[str]:
|
|
"""Invoke project-progress-tracker agent to assess progress.
|
|
|
|
Args:
|
|
timeout: Timeout in seconds (default 30)
|
|
|
|
Returns:
|
|
Agent output (YAML), or None on timeout/error
|
|
"""
|
|
try:
|
|
# Invoke agent via scripts/invoke_agent.py
|
|
invoke_script = project_root / "plugins" / "autonomous-dev" / "scripts" / "invoke_agent.py"
|
|
|
|
if not invoke_script.exists():
|
|
# Fallback: direct invocation not available
|
|
print("Warning: invoke_agent.py not found, skipping progress update", file=sys.stderr)
|
|
return None
|
|
|
|
result = subprocess.run(
|
|
[sys.executable, str(invoke_script), "project-progress-tracker"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
cwd=str(project_root)
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
return result.stdout
|
|
else:
|
|
print(f"Warning: progress tracker failed: {result.stderr}", file=sys.stderr)
|
|
return None
|
|
|
|
except subprocess.TimeoutExpired:
|
|
print(f"Warning: progress tracker timed out after {timeout}s", file=sys.stderr)
|
|
return None
|
|
except Exception as e:
|
|
print(f"Warning: progress tracker error: {e}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def parse_agent_output(output: str) -> Optional[Dict[str, Any]]:
|
|
"""Parse YAML output from progress tracker agent.
|
|
|
|
Args:
|
|
output: YAML string from agent
|
|
|
|
Returns:
|
|
Parsed dict, or None on error
|
|
"""
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
# Fallback to simple parsing if PyYAML not available
|
|
return parse_simple_yaml(output)
|
|
|
|
try:
|
|
data = yaml.safe_load(output)
|
|
return data if isinstance(data, dict) else None
|
|
except yaml.YAMLError:
|
|
return parse_simple_yaml(output)
|
|
|
|
|
|
def parse_simple_yaml(output: str) -> Optional[Dict[str, Any]]:
|
|
"""Simple YAML parser for basic assessment format.
|
|
|
|
Handles format:
|
|
assessment:
|
|
goal_1: 25
|
|
goal_2: 50
|
|
|
|
Args:
|
|
output: YAML-like string
|
|
|
|
Returns:
|
|
Parsed dict with "assessment" key, or None on error
|
|
"""
|
|
try:
|
|
result = {}
|
|
current_section = None
|
|
lines = output.strip().split('\n')
|
|
|
|
for line in lines:
|
|
# Skip empty lines
|
|
if not line.strip():
|
|
continue
|
|
|
|
# Check for section header
|
|
if ':' in line and not line.startswith(' '):
|
|
section_name = line.split(':')[0].strip()
|
|
current_section = section_name
|
|
result[current_section] = {}
|
|
# Check for key-value under section
|
|
elif ':' in line and line.startswith(' ') and current_section:
|
|
parts = line.strip().split(':', 1) # Split on first : only
|
|
if len(parts) == 2:
|
|
key = parts[0].strip()
|
|
value = parts[1].strip()
|
|
# Try to parse as int
|
|
try:
|
|
value = int(value)
|
|
except ValueError:
|
|
pass
|
|
result[current_section][key] = value
|
|
|
|
# Return None if no valid assessment data found
|
|
# (invalid YAML with multiple colons creates empty sections)
|
|
if not result or "assessment" not in result or not result.get("assessment"):
|
|
return None
|
|
|
|
return result
|
|
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def update_project_with_rollback(
|
|
project_file: Path,
|
|
updates: Dict[str, int]
|
|
) -> bool:
|
|
"""Update PROJECT.md with rollback on failure.
|
|
|
|
Args:
|
|
project_file: Path to PROJECT.md
|
|
updates: Dict mapping goal names to progress percentages
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
updater = None
|
|
try:
|
|
updater = ProjectMdUpdater(project_file)
|
|
|
|
# Update all goals in a single operation
|
|
updater.update_goal_progress(updates)
|
|
|
|
return True
|
|
|
|
except ValueError as e:
|
|
# Validation error (merge conflict, invalid percentage, etc.)
|
|
print(f"Warning: Cannot update PROJECT.md: {e}", file=sys.stderr)
|
|
# Try to rollback if we created a backup
|
|
if updater and updater.backup_file:
|
|
try:
|
|
updater.rollback()
|
|
print("Rolled back PROJECT.md to backup", file=sys.stderr)
|
|
except Exception as rollback_error:
|
|
print(f"Warning: Rollback failed: {rollback_error}", file=sys.stderr)
|
|
return False
|
|
|
|
except Exception as e:
|
|
# Unexpected error - try to rollback
|
|
print(f"Error updating PROJECT.md: {e}", file=sys.stderr)
|
|
if updater and updater.backup_file:
|
|
try:
|
|
updater.rollback()
|
|
print("Rolled back PROJECT.md to backup", file=sys.stderr)
|
|
except Exception as rollback_error:
|
|
print(f"Warning: Rollback failed: {rollback_error}", file=sys.stderr)
|
|
return False
|
|
|
|
|
|
def run_hook(
|
|
agent_name: str,
|
|
session_file: Path,
|
|
project_file: Path
|
|
):
|
|
"""Main hook entry point.
|
|
|
|
Args:
|
|
agent_name: Name of agent that completed
|
|
session_file: Path to session tracking file
|
|
project_file: Path to PROJECT.md
|
|
"""
|
|
# Check if we should trigger
|
|
if not should_trigger_update(agent_name):
|
|
return
|
|
|
|
# Check if pipeline is complete
|
|
if not check_pipeline_complete(session_file):
|
|
print("Pipeline not complete, skipping PROJECT.md update", file=sys.stderr)
|
|
return
|
|
|
|
# Check if PROJECT.md exists
|
|
if not project_file.exists():
|
|
print(f"Warning: PROJECT.md not found at {project_file}", file=sys.stderr)
|
|
return
|
|
|
|
# Invoke progress tracker agent
|
|
print("Invoking project-progress-tracker agent...", file=sys.stderr)
|
|
agent_output = invoke_progress_tracker()
|
|
|
|
if not agent_output:
|
|
print("Warning: No output from progress tracker", file=sys.stderr)
|
|
return
|
|
|
|
# Parse agent output
|
|
parsed = parse_agent_output(agent_output)
|
|
if not parsed or "assessment" not in parsed:
|
|
print("Warning: Invalid output format from progress tracker", file=sys.stderr)
|
|
return
|
|
|
|
# Extract goal updates
|
|
assessment = parsed["assessment"]
|
|
updates = {}
|
|
|
|
for key, value in assessment.items():
|
|
# Convert goal_1 -> Goal 1, goal_2 -> Goal 2, etc.
|
|
if key.startswith("goal_"):
|
|
goal_num = key.replace("goal_", "").replace("_", " ").title()
|
|
goal_name = f"Goal {goal_num}"
|
|
if isinstance(value, int):
|
|
updates[goal_name] = value
|
|
|
|
if not updates:
|
|
print("No goal updates found in assessment", file=sys.stderr)
|
|
return
|
|
|
|
# Update PROJECT.md
|
|
print(f"Updating PROJECT.md with {len(updates)} goal(s)...", file=sys.stderr)
|
|
success = update_project_with_rollback(project_file, updates)
|
|
|
|
if success:
|
|
print("✅ PROJECT.md updated successfully", file=sys.stderr)
|
|
else:
|
|
print("❌ PROJECT.md update failed", file=sys.stderr)
|
|
|
|
|
|
def main():
|
|
"""Main entry point for SubagentStop hook."""
|
|
# Get agent info from environment
|
|
agent_name = os.environ.get("CLAUDE_AGENT_NAME", "unknown")
|
|
|
|
# Find session file
|
|
session_dir = project_root / "docs" / "sessions"
|
|
session_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Find most recent session file
|
|
json_files = sorted(session_dir.glob("*-pipeline.json"))
|
|
if not json_files:
|
|
print("Warning: No session file found", file=sys.stderr)
|
|
return
|
|
|
|
session_file = json_files[-1]
|
|
|
|
# Find PROJECT.md
|
|
project_file = project_root / ".claude" / "PROJECT.md"
|
|
|
|
# Run hook
|
|
try:
|
|
run_hook(agent_name, session_file, project_file)
|
|
except Exception as e:
|
|
# Don't fail the hook - just log error
|
|
print(f"Warning: PROJECT.md update hook failed: {e}", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except Exception as e:
|
|
print(f"Warning: Hook execution failed: {e}", file=sys.stderr)
|
|
sys.exit(0) # Exit 0 so we don't block workflow
|