Files
Aperant/auto-claude/spec/pipeline/orchestrator.py
T
2025-12-16 21:57:45 +01:00

587 lines
20 KiB
Python

"""
Spec Orchestrator
=================
Main orchestration logic for spec creation with dynamic complexity adaptation.
"""
import json
from collections.abc import Callable
from pathlib import Path
from review import run_review_checkpoint
from task_logger import (
LogEntryType,
LogPhase,
get_task_logger,
)
from ui import (
Icons,
box,
highlight,
icon,
muted,
print_key_value,
print_section,
print_status,
)
from .. import complexity, phases, requirements
from ..validate_pkg.spec_validator import SpecValidator
from .agent_runner import AgentRunner
from .models import (
PHASE_DISPLAY,
cleanup_orphaned_pending_folders,
create_spec_dir,
get_specs_dir,
rename_spec_dir_from_requirements,
)
class SpecOrchestrator:
"""Orchestrates the spec creation process with dynamic complexity adaptation."""
def __init__(
self,
project_dir: Path,
task_description: str | None = None,
spec_name: str | None = None,
spec_dir: Path
| None = None, # Use existing spec directory (for UI integration)
model: str = "claude-opus-4-5-20251101",
complexity_override: str | None = None, # Force a specific complexity
use_ai_assessment: bool = True, # Use AI for complexity assessment (vs heuristics)
dev_mode: bool = False, # Dev mode: specs in gitignored folder, code changes to auto-claude/
):
"""Initialize the spec orchestrator.
Args:
project_dir: The project root directory
task_description: Optional task description
spec_name: Optional spec name (for existing specs)
spec_dir: Optional existing spec directory (for UI integration)
model: The model to use for agent execution
complexity_override: Force a specific complexity level
use_ai_assessment: Whether to use AI for complexity assessment
dev_mode: Deprecated, kept for API compatibility
"""
self.project_dir = Path(project_dir)
self.task_description = task_description
self.model = model
self.complexity_override = complexity_override
self.use_ai_assessment = use_ai_assessment
self.dev_mode = dev_mode
# Get the appropriate specs directory (within the project)
self.specs_dir = get_specs_dir(self.project_dir, dev_mode)
# Clean up orphaned pending folders before creating new spec
cleanup_orphaned_pending_folders(self.specs_dir)
# Complexity assessment (populated during run)
self.assessment: complexity.ComplexityAssessment | None = None
# Create/use spec directory
if spec_dir:
# Use provided spec directory (from UI)
self.spec_dir = Path(spec_dir)
elif spec_name:
self.spec_dir = self.specs_dir / spec_name
else:
self.spec_dir = create_spec_dir(self.specs_dir)
self.spec_dir.mkdir(parents=True, exist_ok=True)
self.validator = SpecValidator(self.spec_dir)
# Agent runner (initialized when needed)
self._agent_runner: AgentRunner | None = None
def _get_agent_runner(self) -> AgentRunner:
"""Get or create the agent runner.
Returns:
The agent runner instance
"""
if self._agent_runner is None:
task_logger = get_task_logger(self.spec_dir)
self._agent_runner = AgentRunner(
self.project_dir, self.spec_dir, self.model, task_logger
)
return self._agent_runner
async def _run_agent(
self,
prompt_file: str,
additional_context: str = "",
interactive: bool = False,
) -> tuple[bool, str]:
"""Run an agent with the given prompt.
Args:
prompt_file: The prompt file to use
additional_context: Additional context to add
interactive: Whether to run in interactive mode
Returns:
Tuple of (success, response_text)
"""
runner = self._get_agent_runner()
return await runner.run_agent(prompt_file, additional_context, interactive)
async def run(self, interactive: bool = True, auto_approve: bool = False) -> bool:
"""Run the spec creation process with dynamic phase selection.
Args:
interactive: Whether to run in interactive mode for requirements gathering
auto_approve: Whether to skip human review checkpoint and auto-approve
Returns:
True if spec creation and review completed successfully, False otherwise
"""
# Import UI module for use in phases
import ui
# Initialize task logger for planning phase
task_logger = get_task_logger(self.spec_dir)
task_logger.start_phase(LogPhase.PLANNING, "Starting spec creation process")
print(
box(
f"Spec Directory: {self.spec_dir}\n"
f"Project: {self.project_dir}"
+ (f"\nTask: {self.task_description}" if self.task_description else ""),
title="SPEC CREATION ORCHESTRATOR",
style="heavy",
)
)
# Create phase executor
phase_executor = phases.PhaseExecutor(
project_dir=self.project_dir,
spec_dir=self.spec_dir,
task_description=self.task_description,
spec_validator=self.validator,
run_agent_fn=self._run_agent,
task_logger=task_logger,
ui_module=ui,
)
results = []
phase_num = 0
def run_phase(name: str, phase_fn: Callable) -> phases.PhaseResult:
"""Run a phase with proper numbering and display.
Args:
name: The phase name
phase_fn: The phase function to execute
Returns:
The phase result
"""
nonlocal phase_num
phase_num += 1
display_name, display_icon = PHASE_DISPLAY.get(
name, (name.upper(), Icons.GEAR)
)
print_section(f"PHASE {phase_num}: {display_name}", display_icon)
task_logger.log(
f"Starting phase {phase_num}: {display_name}", LogEntryType.INFO
)
return phase_fn()
# === PHASE 1: DISCOVERY ===
result = await run_phase("discovery", phase_executor.phase_discovery)
results.append(result)
if not result.success:
print_status("Discovery failed", "error")
task_logger.end_phase(
LogPhase.PLANNING, success=False, message="Discovery failed"
)
return False
# === PHASE 2: REQUIREMENTS GATHERING ===
result = await run_phase(
"requirements", lambda: phase_executor.phase_requirements(interactive)
)
results.append(result)
if not result.success:
print_status("Requirements gathering failed", "error")
task_logger.end_phase(
LogPhase.PLANNING,
success=False,
message="Requirements gathering failed",
)
return False
# Rename spec folder with better name from requirements
rename_spec_dir_from_requirements(self.spec_dir)
# Update task description from requirements
req = requirements.load_requirements(self.spec_dir)
if req:
self.task_description = req.get("task_description", self.task_description)
# Update phase executor's task description
phase_executor.task_description = self.task_description
# === CREATE LINEAR TASK (if enabled) ===
await self._create_linear_task_if_enabled()
# === PHASE 3: AI COMPLEXITY ASSESSMENT ===
result = await run_phase(
"complexity_assessment",
lambda: self._phase_complexity_assessment_with_requirements(),
)
results.append(result)
if not result.success:
print_status("Complexity assessment failed", "error")
task_logger.end_phase(
LogPhase.PLANNING, success=False, message="Complexity assessment failed"
)
return False
# Map of all available phases
all_phases = {
"historical_context": phase_executor.phase_historical_context,
"research": phase_executor.phase_research,
"context": phase_executor.phase_context,
"spec_writing": phase_executor.phase_spec_writing,
"self_critique": phase_executor.phase_self_critique,
"planning": phase_executor.phase_planning,
"validation": phase_executor.phase_validation,
"quick_spec": phase_executor.phase_quick_spec,
}
# Get remaining phases to run based on complexity
all_phases_to_run = self.assessment.phases_to_run()
phases_to_run = [
p for p in all_phases_to_run if p not in ["discovery", "requirements"]
]
print()
print(
f" Running {highlight(self.assessment.complexity.value.upper())} workflow"
)
print(f" {muted('Remaining phases:')} {', '.join(phases_to_run)}")
print()
phases_executed = ["discovery", "requirements", "complexity_assessment"]
for phase_name in phases_to_run:
if phase_name not in all_phases:
print_status(f"Unknown phase: {phase_name}, skipping", "warning")
continue
result = await run_phase(phase_name, all_phases[phase_name])
results.append(result)
phases_executed.append(phase_name)
if not result.success:
print()
print_status(
f"Phase '{phase_name}' failed after {result.retries} retries",
"error",
)
print(f" {muted('Errors:')}")
for err in result.errors:
print(f" {icon(Icons.ARROW_RIGHT)} {err}")
print()
print_status(
"Spec creation incomplete. Fix errors and retry.", "warning"
)
task_logger.log(
f"Phase '{phase_name}' failed: {'; '.join(result.errors)}",
LogEntryType.ERROR,
)
task_logger.end_phase(
LogPhase.PLANNING,
success=False,
message=f"Phase {phase_name} failed",
)
return False
# Summary
self._print_completion_summary(results, phases_executed)
# End planning phase successfully
task_logger.end_phase(
LogPhase.PLANNING, success=True, message="Spec creation complete"
)
# === HUMAN REVIEW CHECKPOINT ===
return self._run_review_checkpoint(auto_approve)
async def _create_linear_task_if_enabled(self) -> None:
"""Create a Linear task if Linear integration is enabled."""
from linear_updater import create_linear_task, is_linear_enabled
if not is_linear_enabled():
return
print_status("Creating Linear task...", "progress")
linear_state = await create_linear_task(
spec_dir=self.spec_dir,
title=self.task_description or self.spec_dir.name,
description=f"Auto-build spec: {self.spec_dir.name}",
)
if linear_state:
print_status(f"Linear task created: {linear_state.task_id}", "success")
else:
print_status("Linear task creation failed (continuing without)", "warning")
async def _phase_complexity_assessment_with_requirements(
self,
) -> phases.PhaseResult:
"""Assess complexity after requirements are gathered (with full context).
Returns:
The phase result
"""
task_logger = get_task_logger(self.spec_dir)
assessment_file = self.spec_dir / "complexity_assessment.json"
requirements_file = self.spec_dir / "requirements.json"
# Load requirements for full context
requirements_context = self._load_requirements_context(requirements_file)
if self.complexity_override:
# Manual override
self.assessment = self._create_override_assessment()
elif self.use_ai_assessment:
# Run AI assessment
self.assessment = await self._run_ai_assessment(task_logger)
else:
# Use heuristic assessment
self.assessment = self._heuristic_assessment()
self._print_assessment_info()
# Show what phases will run
self._print_phases_to_run()
# Save assessment
if not assessment_file.exists():
complexity.save_assessment(self.spec_dir, self.assessment, self.dev_mode)
return phases.PhaseResult(
"complexity_assessment", True, [str(assessment_file)], [], 0
)
def _load_requirements_context(self, requirements_file: Path) -> str:
"""Load requirements context from file.
Args:
requirements_file: Path to the requirements file
Returns:
Formatted requirements context string
"""
if not requirements_file.exists():
return ""
with open(requirements_file) as f:
req = json.load(f)
self.task_description = req.get("task_description", self.task_description)
return f"""
**Task Description**: {req.get("task_description", "Not provided")}
**Workflow Type**: {req.get("workflow_type", "Not specified")}
**Services Involved**: {", ".join(req.get("services_involved", []))}
**User Requirements**:
{chr(10).join(f"- {r}" for r in req.get("user_requirements", []))}
**Acceptance Criteria**:
{chr(10).join(f"- {c}" for c in req.get("acceptance_criteria", []))}
**Constraints**:
{chr(10).join(f"- {c}" for c in req.get("constraints", []))}
"""
def _create_override_assessment(self) -> complexity.ComplexityAssessment:
"""Create a complexity assessment from manual override.
Returns:
The complexity assessment
"""
comp = complexity.Complexity(self.complexity_override)
assessment = complexity.ComplexityAssessment(
complexity=comp,
confidence=1.0,
reasoning=f"Manual override: {self.complexity_override}",
)
print_status(f"Complexity override: {comp.value.upper()}", "success")
return assessment
async def _run_ai_assessment(self, task_logger) -> complexity.ComplexityAssessment:
"""Run AI-based complexity assessment.
Args:
task_logger: The task logger instance
Returns:
The complexity assessment
"""
print_status("Running AI complexity assessment...", "progress")
task_logger.log(
"Analyzing task complexity with AI...",
LogEntryType.INFO,
LogPhase.PLANNING,
)
assessment = await complexity.run_ai_complexity_assessment(
self.spec_dir,
self.task_description,
self._run_agent,
)
if assessment:
self._print_assessment_info(assessment)
return assessment
else:
# Fall back to heuristic assessment
print_status(
"AI assessment failed, falling back to heuristics...", "warning"
)
return self._heuristic_assessment()
def _print_assessment_info(
self, assessment: complexity.ComplexityAssessment | None = None
) -> None:
"""Print complexity assessment information.
Args:
assessment: The assessment to print (defaults to self.assessment)
"""
if assessment is None:
assessment = self.assessment
print_status(
f"AI assessed complexity: {highlight(assessment.complexity.value.upper())}",
"success",
)
print_key_value("Confidence", f"{assessment.confidence:.0%}")
print_key_value("Reasoning", assessment.reasoning)
if assessment.needs_research:
print(f" {muted('→ Research phase enabled')}")
if assessment.needs_self_critique:
print(f" {muted('→ Self-critique phase enabled')}")
def _print_phases_to_run(self) -> None:
"""Print the list of phases that will be executed."""
phase_list = self.assessment.phases_to_run()
print()
print(f" Phases to run ({highlight(str(len(phase_list)))}):")
for i, phase in enumerate(phase_list, 1):
print(f" {i}. {phase}")
def _heuristic_assessment(self) -> complexity.ComplexityAssessment:
"""Fall back to heuristic-based complexity assessment.
Returns:
The complexity assessment
"""
project_index = {}
auto_build_index = self.project_dir / "auto-claude" / "project_index.json"
if auto_build_index.exists():
with open(auto_build_index) as f:
project_index = json.load(f)
analyzer = complexity.ComplexityAnalyzer(project_index)
return analyzer.analyze(self.task_description or "")
def _print_completion_summary(
self, results: list[phases.PhaseResult], phases_executed: list[str]
) -> None:
"""Print the completion summary.
Args:
results: List of phase results
phases_executed: List of executed phase names
"""
files_created = []
for r in results:
for f in r.output_files:
files_created.append(Path(f).name)
print(
box(
f"Complexity: {self.assessment.complexity.value.upper()}\n"
f"Phases run: {len(phases_executed) + 1}\n"
f"Spec saved to: {self.spec_dir}\n\n"
f"Files created:\n"
+ "\n".join(f" {icon(Icons.SUCCESS)} {f}" for f in files_created),
title=f"{icon(Icons.SUCCESS)} SPEC CREATION COMPLETE",
style="heavy",
)
)
def _run_review_checkpoint(self, auto_approve: bool) -> bool:
"""Run the human review checkpoint.
Args:
auto_approve: Whether to auto-approve without human review
Returns:
True if approved, False otherwise
"""
print()
print_section("HUMAN REVIEW CHECKPOINT", Icons.SEARCH)
try:
review_state = run_review_checkpoint(
spec_dir=self.spec_dir,
auto_approve=auto_approve,
)
if not review_state.is_approved():
print()
print_status("Build will not proceed without approval.", "warning")
return False
except SystemExit as e:
if e.code != 0:
return False
return False
except KeyboardInterrupt:
print()
print_status("Review interrupted. Run again to continue.", "info")
return False
return True
# Backward compatibility methods for tests
def _generate_spec_name(self, task_description: str) -> str:
"""Generate a spec name from task description (backward compatibility).
This method is kept for backward compatibility with existing tests.
The functionality has been moved to models.generate_spec_name.
Args:
task_description: The task description
Returns:
Generated spec name
"""
from .models import generate_spec_name
return generate_spec_name(task_description)
def _rename_spec_dir_from_requirements(self) -> bool:
"""Rename spec directory from requirements (backward compatibility).
This method is kept for backward compatibility with existing tests.
The functionality has been moved to models.rename_spec_dir_from_requirements.
Returns:
True if successful or not needed, False on error
"""
result = rename_spec_dir_from_requirements(self.spec_dir)
# Update self.spec_dir if it was renamed
if result and self.spec_dir.name.endswith("-pending"):
# Find the renamed directory
parent = self.spec_dir.parent
prefix = self.spec_dir.name[:4] # e.g., "001-"
for candidate in parent.iterdir():
if (
candidate.name.startswith(prefix)
and "pending" not in candidate.name
):
self.spec_dir = candidate
break
return result