623 lines
23 KiB
Python
623 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Implementation Planner
|
|
======================
|
|
|
|
Generates implementation plans from specs by analyzing the task and codebase.
|
|
This replaces the initializer's test-generation with chunk-based planning.
|
|
|
|
The planner:
|
|
1. Reads the spec.md to understand what needs to be built
|
|
2. Reads project_index.json to understand the codebase structure
|
|
3. Reads context.json to know which files are relevant
|
|
4. Determines the workflow type (feature, refactor, investigation, etc.)
|
|
5. Generates phases and chunks with proper dependencies
|
|
6. Outputs implementation_plan.json
|
|
|
|
Usage:
|
|
python auto-build/planner.py --spec-dir auto-build/specs/001-feature/
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from implementation_plan import (
|
|
ImplementationPlan,
|
|
Phase,
|
|
Chunk,
|
|
Verification,
|
|
WorkflowType,
|
|
PhaseType,
|
|
ChunkStatus,
|
|
VerificationType,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class PlannerContext:
|
|
"""Context gathered for planning."""
|
|
spec_content: str
|
|
project_index: dict
|
|
task_context: dict
|
|
services_involved: list[str]
|
|
workflow_type: WorkflowType
|
|
files_to_modify: list[dict]
|
|
files_to_reference: list[dict]
|
|
|
|
|
|
class ImplementationPlanner:
|
|
"""Generates implementation plans from specs."""
|
|
|
|
def __init__(self, spec_dir: Path):
|
|
self.spec_dir = spec_dir
|
|
self.context: Optional[PlannerContext] = None
|
|
|
|
def load_context(self) -> PlannerContext:
|
|
"""Load all context files from spec directory."""
|
|
# Read spec.md
|
|
spec_file = self.spec_dir / "spec.md"
|
|
spec_content = spec_file.read_text() if spec_file.exists() else ""
|
|
|
|
# Read project_index.json
|
|
index_file = self.spec_dir / "project_index.json"
|
|
project_index = {}
|
|
if index_file.exists():
|
|
with open(index_file) as f:
|
|
project_index = json.load(f)
|
|
|
|
# Read context.json
|
|
context_file = self.spec_dir / "context.json"
|
|
task_context = {}
|
|
if context_file.exists():
|
|
with open(context_file) as f:
|
|
task_context = json.load(f)
|
|
|
|
# Determine services involved
|
|
services = task_context.get("scoped_services", [])
|
|
if not services:
|
|
services = list(project_index.get("services", {}).keys())
|
|
|
|
# Determine workflow type
|
|
workflow_type = self._detect_workflow_type(spec_content)
|
|
|
|
self.context = PlannerContext(
|
|
spec_content=spec_content,
|
|
project_index=project_index,
|
|
task_context=task_context,
|
|
services_involved=services,
|
|
workflow_type=workflow_type,
|
|
files_to_modify=task_context.get("files_to_modify", []),
|
|
files_to_reference=task_context.get("files_to_reference", []),
|
|
)
|
|
|
|
return self.context
|
|
|
|
def _detect_workflow_type(self, spec_content: str) -> WorkflowType:
|
|
"""Detect workflow type from spec content."""
|
|
content_lower = spec_content.lower()
|
|
|
|
# Investigation indicators
|
|
investigation_keywords = ["bug", "fix", "issue", "broken", "not working", "investigate", "debug"]
|
|
if any(kw in content_lower for kw in investigation_keywords):
|
|
# Check if it's clearly a bug investigation
|
|
if "unknown" in content_lower or "intermittent" in content_lower or "random" in content_lower:
|
|
return WorkflowType.INVESTIGATION
|
|
|
|
# Refactor indicators
|
|
refactor_keywords = ["migrate", "refactor", "convert", "upgrade", "replace", "move from", "transition"]
|
|
if any(kw in content_lower for kw in refactor_keywords):
|
|
return WorkflowType.REFACTOR
|
|
|
|
# Migration indicators (data)
|
|
migration_keywords = ["data migration", "migrate data", "import", "export", "batch"]
|
|
if any(kw in content_lower for kw in migration_keywords):
|
|
return WorkflowType.MIGRATION
|
|
|
|
# Simple enhancement indicators (single service, small scope)
|
|
# Will be determined more precisely after analyzing scope
|
|
|
|
return WorkflowType.FEATURE
|
|
|
|
def _extract_feature_name(self) -> str:
|
|
"""Extract feature name from spec."""
|
|
# Try to find title in spec
|
|
lines = self.context.spec_content.split("\n")
|
|
for line in lines[:10]:
|
|
if line.startswith("# "):
|
|
title = line[2:].strip()
|
|
# Remove common prefixes
|
|
for prefix in ["Specification:", "Spec:", "Feature:"]:
|
|
if title.startswith(prefix):
|
|
title = title[len(prefix):].strip()
|
|
return title
|
|
|
|
return "Unnamed Feature"
|
|
|
|
def _group_files_by_service(self) -> dict[str, list[dict]]:
|
|
"""Group files to modify by service."""
|
|
groups: dict[str, list[dict]] = {}
|
|
|
|
for file_info in self.context.files_to_modify:
|
|
path = file_info.get("path", "")
|
|
service = file_info.get("service", "unknown")
|
|
|
|
# Try to infer service from path if not specified
|
|
if service == "unknown":
|
|
for svc_name, svc_info in self.context.project_index.get("services", {}).items():
|
|
svc_path = svc_info.get("path", svc_name)
|
|
if path.startswith(svc_path) or path.startswith(f"{svc_name}/"):
|
|
service = svc_name
|
|
break
|
|
|
|
if service not in groups:
|
|
groups[service] = []
|
|
groups[service].append(file_info)
|
|
|
|
return groups
|
|
|
|
def _get_patterns_for_service(self, service: str) -> list[str]:
|
|
"""Get reference patterns for a service."""
|
|
patterns = []
|
|
for file_info in self.context.files_to_reference:
|
|
file_service = file_info.get("service", "")
|
|
if file_service == service or not file_service:
|
|
patterns.append(file_info.get("path", ""))
|
|
return patterns[:3] # Limit to top 3
|
|
|
|
def _create_verification(self, service: str, chunk_type: str) -> Verification:
|
|
"""Create appropriate verification for a chunk."""
|
|
service_info = self.context.project_index.get("services", {}).get(service, {})
|
|
port = service_info.get("port")
|
|
|
|
if chunk_type == "model":
|
|
return Verification(
|
|
type=VerificationType.COMMAND,
|
|
run="echo 'Model created - verify with migration'",
|
|
)
|
|
elif chunk_type == "endpoint":
|
|
return Verification(
|
|
type=VerificationType.API,
|
|
method="GET",
|
|
url=f"http://localhost:{port}/health" if port else "/health",
|
|
expect_status=200,
|
|
)
|
|
elif chunk_type == "component":
|
|
return Verification(
|
|
type=VerificationType.BROWSER,
|
|
scenario="Component renders without errors",
|
|
)
|
|
elif chunk_type == "task":
|
|
return Verification(
|
|
type=VerificationType.COMMAND,
|
|
run="echo 'Task registered - verify with celery inspect'",
|
|
)
|
|
else:
|
|
return Verification(type=VerificationType.MANUAL)
|
|
|
|
def generate_feature_plan(self) -> ImplementationPlan:
|
|
"""Generate a feature implementation plan."""
|
|
feature_name = self._extract_feature_name()
|
|
files_by_service = self._group_files_by_service()
|
|
|
|
phases = []
|
|
phase_num = 0
|
|
|
|
# Determine service order (backend first, then workers, then frontend)
|
|
service_order = []
|
|
for svc in ["backend", "api", "server"]:
|
|
if svc in files_by_service:
|
|
service_order.append(svc)
|
|
for svc in ["worker", "celery", "jobs", "tasks"]:
|
|
if svc in files_by_service:
|
|
service_order.append(svc)
|
|
for svc in ["frontend", "web", "client", "ui"]:
|
|
if svc in files_by_service:
|
|
service_order.append(svc)
|
|
# Add any remaining services
|
|
for svc in files_by_service:
|
|
if svc not in service_order:
|
|
service_order.append(svc)
|
|
|
|
backend_phase = None
|
|
worker_phase = None
|
|
|
|
for service in service_order:
|
|
files = files_by_service[service]
|
|
if not files:
|
|
continue
|
|
|
|
phase_num += 1
|
|
patterns = self._get_patterns_for_service(service)
|
|
|
|
# Create chunks for each file
|
|
chunks = []
|
|
for file_info in files:
|
|
path = file_info.get("path", "")
|
|
reason = file_info.get("reason", "")
|
|
|
|
# Determine chunk type from path
|
|
chunk_type = "code"
|
|
if "model" in path.lower() or "schema" in path.lower():
|
|
chunk_type = "model"
|
|
elif "route" in path.lower() or "endpoint" in path.lower() or "api" in path.lower():
|
|
chunk_type = "endpoint"
|
|
elif "component" in path.lower() or path.endswith(".tsx") or path.endswith(".jsx"):
|
|
chunk_type = "component"
|
|
elif "task" in path.lower() or "worker" in path.lower() or "celery" in path.lower():
|
|
chunk_type = "task"
|
|
|
|
chunk_id = Path(path).stem.replace(".", "-").lower()
|
|
|
|
chunks.append(Chunk(
|
|
id=f"{service}-{chunk_id}",
|
|
description=f"Modify {path}: {reason}" if reason else f"Update {path}",
|
|
service=service,
|
|
files_to_modify=[path],
|
|
patterns_from=patterns,
|
|
verification=self._create_verification(service, chunk_type),
|
|
))
|
|
|
|
# Determine dependencies
|
|
depends_on = []
|
|
service_type = self.context.project_index.get("services", {}).get(service, {}).get("type", "")
|
|
|
|
if service_type in ["worker", "celery", "jobs"] and backend_phase:
|
|
depends_on = [backend_phase]
|
|
elif service_type in ["frontend", "web", "client", "ui"] and backend_phase:
|
|
depends_on = [backend_phase]
|
|
|
|
phase = Phase(
|
|
phase=phase_num,
|
|
name=f"{service.title()} Implementation",
|
|
type=PhaseType.IMPLEMENTATION,
|
|
chunks=chunks,
|
|
depends_on=depends_on,
|
|
parallel_safe=len(chunks) > 1,
|
|
)
|
|
phases.append(phase)
|
|
|
|
# Track for dependencies
|
|
if service_type in ["backend", "api", "server"]:
|
|
backend_phase = phase_num
|
|
elif service_type in ["worker", "celery"]:
|
|
worker_phase = phase_num
|
|
|
|
# Add integration phase if multiple services
|
|
if len(service_order) > 1:
|
|
phase_num += 1
|
|
integration_depends = list(range(1, phase_num))
|
|
|
|
phases.append(Phase(
|
|
phase=phase_num,
|
|
name="Integration",
|
|
type=PhaseType.INTEGRATION,
|
|
depends_on=integration_depends,
|
|
chunks=[
|
|
Chunk(
|
|
id="integration-wiring",
|
|
description="Wire all services together",
|
|
all_services=True,
|
|
verification=Verification(
|
|
type=VerificationType.BROWSER,
|
|
scenario="End-to-end flow works",
|
|
),
|
|
),
|
|
Chunk(
|
|
id="integration-testing",
|
|
description="Verify complete feature works",
|
|
all_services=True,
|
|
verification=Verification(
|
|
type=VerificationType.BROWSER,
|
|
scenario="All acceptance criteria met",
|
|
),
|
|
),
|
|
],
|
|
))
|
|
|
|
# Extract final acceptance from spec
|
|
final_acceptance = self._extract_acceptance_criteria()
|
|
|
|
return ImplementationPlan(
|
|
feature=feature_name,
|
|
workflow_type=WorkflowType.FEATURE,
|
|
services_involved=self.context.services_involved,
|
|
phases=phases,
|
|
final_acceptance=final_acceptance,
|
|
spec_file=str(self.spec_dir / "spec.md"),
|
|
)
|
|
|
|
def generate_investigation_plan(self) -> ImplementationPlan:
|
|
"""Generate an investigation plan for debugging."""
|
|
feature_name = self._extract_feature_name()
|
|
|
|
phases = [
|
|
Phase(
|
|
phase=1,
|
|
name="Reproduce & Instrument",
|
|
type=PhaseType.INVESTIGATION,
|
|
chunks=[
|
|
Chunk(
|
|
id="add-logging",
|
|
description="Add detailed logging around suspected problem areas",
|
|
expected_output="Logs capture relevant state changes and events",
|
|
files_to_modify=[f.get("path", "") for f in self.context.files_to_modify[:3]],
|
|
),
|
|
Chunk(
|
|
id="create-repro",
|
|
description="Create reliable reproduction steps",
|
|
expected_output="Can reproduce issue on demand with documented steps",
|
|
),
|
|
],
|
|
),
|
|
Phase(
|
|
phase=2,
|
|
name="Investigate & Analyze",
|
|
type=PhaseType.INVESTIGATION,
|
|
depends_on=[1],
|
|
chunks=[
|
|
Chunk(
|
|
id="analyze-logs",
|
|
description="Analyze logs from multiple reproductions",
|
|
expected_output="Pattern identified in when/how issue occurs",
|
|
),
|
|
Chunk(
|
|
id="form-hypothesis",
|
|
description="Form and test hypotheses about root cause",
|
|
expected_output="Root cause identified with supporting evidence",
|
|
),
|
|
],
|
|
),
|
|
Phase(
|
|
phase=3,
|
|
name="Implement Fix",
|
|
type=PhaseType.IMPLEMENTATION,
|
|
depends_on=[2],
|
|
chunks=[
|
|
Chunk(
|
|
id="implement-fix",
|
|
description="[TO BE DETERMINED: Fix based on investigation findings]",
|
|
status=ChunkStatus.BLOCKED,
|
|
),
|
|
Chunk(
|
|
id="add-regression-test",
|
|
description="Add test to prevent issue from recurring",
|
|
status=ChunkStatus.BLOCKED,
|
|
),
|
|
],
|
|
),
|
|
Phase(
|
|
phase=4,
|
|
name="Verify & Harden",
|
|
type=PhaseType.INTEGRATION,
|
|
depends_on=[3],
|
|
chunks=[
|
|
Chunk(
|
|
id="verify-fix",
|
|
description="Verify issue no longer occurs",
|
|
verification=Verification(
|
|
type=VerificationType.MANUAL,
|
|
scenario="Run reproduction steps - issue should not occur",
|
|
),
|
|
),
|
|
Chunk(
|
|
id="add-monitoring",
|
|
description="Add alerting/monitoring to catch if issue returns",
|
|
),
|
|
],
|
|
),
|
|
]
|
|
|
|
return ImplementationPlan(
|
|
feature=feature_name,
|
|
workflow_type=WorkflowType.INVESTIGATION,
|
|
services_involved=self.context.services_involved,
|
|
phases=phases,
|
|
final_acceptance=[
|
|
"Issue no longer reproducible",
|
|
"Root cause documented",
|
|
"Regression test in place",
|
|
],
|
|
spec_file=str(self.spec_dir / "spec.md"),
|
|
)
|
|
|
|
def generate_refactor_plan(self) -> ImplementationPlan:
|
|
"""Generate a refactor plan with stage-based phases."""
|
|
feature_name = self._extract_feature_name()
|
|
|
|
# For refactors, stages are: Add new, Migrate, Remove old, Cleanup
|
|
phases = [
|
|
Phase(
|
|
phase=1,
|
|
name="Add New System",
|
|
type=PhaseType.IMPLEMENTATION,
|
|
chunks=[
|
|
Chunk(
|
|
id="add-new-implementation",
|
|
description="Implement new system alongside existing",
|
|
files_to_modify=[f.get("path", "") for f in self.context.files_to_modify],
|
|
patterns_from=[f.get("path", "") for f in self.context.files_to_reference[:3]],
|
|
verification=Verification(
|
|
type=VerificationType.COMMAND,
|
|
run="echo 'New system added - both old and new should work'",
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Phase(
|
|
phase=2,
|
|
name="Migrate Consumers",
|
|
type=PhaseType.IMPLEMENTATION,
|
|
depends_on=[1],
|
|
chunks=[
|
|
Chunk(
|
|
id="migrate-to-new",
|
|
description="Update consumers to use new system",
|
|
verification=Verification(
|
|
type=VerificationType.BROWSER,
|
|
scenario="All functionality works with new system",
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Phase(
|
|
phase=3,
|
|
name="Remove Old System",
|
|
type=PhaseType.CLEANUP,
|
|
depends_on=[2],
|
|
chunks=[
|
|
Chunk(
|
|
id="remove-old",
|
|
description="Remove old system code",
|
|
verification=Verification(
|
|
type=VerificationType.COMMAND,
|
|
run="echo 'Old system removed - verify no references remain'",
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Phase(
|
|
phase=4,
|
|
name="Polish",
|
|
type=PhaseType.CLEANUP,
|
|
depends_on=[3],
|
|
chunks=[
|
|
Chunk(
|
|
id="cleanup",
|
|
description="Final cleanup and documentation",
|
|
),
|
|
Chunk(
|
|
id="verify-complete",
|
|
description="Verify refactor is complete",
|
|
verification=Verification(
|
|
type=VerificationType.BROWSER,
|
|
scenario="All functionality works, no regressions",
|
|
),
|
|
),
|
|
],
|
|
),
|
|
]
|
|
|
|
return ImplementationPlan(
|
|
feature=feature_name,
|
|
workflow_type=WorkflowType.REFACTOR,
|
|
services_involved=self.context.services_involved,
|
|
phases=phases,
|
|
final_acceptance=[
|
|
"All functionality migrated to new system",
|
|
"Old system completely removed",
|
|
"No regressions in existing features",
|
|
],
|
|
spec_file=str(self.spec_dir / "spec.md"),
|
|
)
|
|
|
|
def _extract_acceptance_criteria(self) -> list[str]:
|
|
"""Extract acceptance criteria from spec."""
|
|
criteria = []
|
|
in_criteria_section = False
|
|
|
|
for line in self.context.spec_content.split("\n"):
|
|
# Look for success criteria or acceptance sections
|
|
if any(header in line.lower() for header in ["success criteria", "acceptance", "done when", "complete when"]):
|
|
in_criteria_section = True
|
|
continue
|
|
|
|
if in_criteria_section:
|
|
# Stop at next section
|
|
if line.startswith("##"):
|
|
break
|
|
|
|
# Extract criteria (lines starting with -, *, or [])
|
|
line = line.strip()
|
|
if line.startswith(("- ", "* ", "- [ ]", "- [x]")):
|
|
# Clean up the line
|
|
criterion = line.lstrip("-*[] x").strip()
|
|
if criterion:
|
|
criteria.append(criterion)
|
|
|
|
# If no criteria found, create generic ones
|
|
if not criteria:
|
|
criteria = [
|
|
"Feature works as specified",
|
|
"No console errors",
|
|
"No regressions in existing functionality",
|
|
]
|
|
|
|
return criteria
|
|
|
|
def generate_plan(self) -> ImplementationPlan:
|
|
"""Generate the appropriate plan based on workflow type."""
|
|
if not self.context:
|
|
self.load_context()
|
|
|
|
if self.context.workflow_type == WorkflowType.INVESTIGATION:
|
|
return self.generate_investigation_plan()
|
|
elif self.context.workflow_type == WorkflowType.REFACTOR:
|
|
return self.generate_refactor_plan()
|
|
else:
|
|
return self.generate_feature_plan()
|
|
|
|
def save_plan(self, plan: ImplementationPlan) -> Path:
|
|
"""Save plan to spec directory."""
|
|
output_path = self.spec_dir / "implementation_plan.json"
|
|
plan.save(output_path)
|
|
print(f"Implementation plan saved to: {output_path}")
|
|
return output_path
|
|
|
|
|
|
def generate_implementation_plan(spec_dir: Path) -> ImplementationPlan:
|
|
"""Main entry point for generating an implementation plan."""
|
|
planner = ImplementationPlanner(spec_dir)
|
|
planner.load_context()
|
|
plan = planner.generate_plan()
|
|
planner.save_plan(plan)
|
|
return plan
|
|
|
|
|
|
def main():
|
|
"""CLI entry point."""
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate implementation plan from spec"
|
|
)
|
|
parser.add_argument(
|
|
"--spec-dir",
|
|
type=Path,
|
|
required=True,
|
|
help="Directory containing spec.md, project_index.json, context.json",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
default=None,
|
|
help="Output path for implementation_plan.json (default: spec-dir/implementation_plan.json)",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Print plan without saving",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
planner = ImplementationPlanner(args.spec_dir)
|
|
planner.load_context()
|
|
plan = planner.generate_plan()
|
|
|
|
if args.dry_run:
|
|
print(json.dumps(plan.to_dict(), indent=2))
|
|
print("\n---\n")
|
|
print(plan.get_status_summary())
|
|
else:
|
|
output_path = args.output or (args.spec_dir / "implementation_plan.json")
|
|
plan.save(output_path)
|
|
print(f"Plan saved to: {output_path}")
|
|
print("\n" + plan.get_status_summary())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|