Artifex/agents/lifecycle.py

546 lines
39 KiB
Python
Raw Permalink Normal View History

2026-08-15 19:30:02 +07:00
from __future__ import annotations
import hashlib
import json
import subprocess
from pathlib import Path
from typing import Any
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from control_plane.agents.models import ProgenySignal
from control_plane.events.bus import EventBus
from control_plane.projects.models import (
CommitRecord,
Decision,
ExtensionCandidate,
ExtensionPlan,
Exploration,
ExplorationOpportunity,
EvolutionCandidate,
EvolutionPlan,
Feature,
Finding,
Milestone,
Project,
ProjectPlan,
RoadmapItem,
StewardFinding,
Task,
TaskDependency,
TaskStatus,
)
from control_plane.verification.models import Review, TestRun, Verification, VerificationLevel, VerificationResult
from graph.langgraph_runtime import LangGraphRuntime
from graph.models import GraphRun
from graph.task_nodes import TaskExecutionServices, task_execution_registry
from model_router.router import ModelCapability, ModelRequestContract, ModelRouter
from project_brain.planning import ProjectPlanContract, parse_project_plan_response
class LifecyclePlanningError(ValueError):
pass
class ProjectContextMixin:
def project_context(self, project: Project) -> dict[str, object]:
files: dict[str, str] = {}
tests: dict[str, str] = {}
if project.repository_path:
root = Path(project.repository_path)
for path in sorted(root.rglob("*.py"))[:40]:
if any(part in path.parts for part in [".git", "__pycache__", "migrations", ".venv", "venv", "node_modules", ".pytest_cache"]):
continue
relative = path.relative_to(root).as_posix()
content = path.read_text(encoding="utf-8", errors="ignore")[:3000]
if relative.startswith("tests") or "test" in path.name:
tests[relative] = content
else:
files[relative] = content
return {
"project": {"id": str(project.id), "name": project.name, "goal": project.goal, "architecture_summary": project.architecture_summary},
"decisions": list(project.decisions.order_by("-created_at").values("decision_type", "decision", "reason")[:20]),
"roadmap_items": list(project.roadmap_items.order_by("-created_at").values("title", "description", "status", "source")[:20]),
"findings": list(project.findings.order_by("-created_at").values("finding_type", "severity", "title", "status")[:20]),
"steward_findings": list(project.steward_findings.order_by("-created_at").values("finding_type", "severity", "title", "status", "recommended_action")[:20]),
"features": list(project.features.order_by("created_at").values("title", "description", "status", "acceptance_criteria")[:50]),
"tasks": list(project.tasks.order_by("-created_at").values("task_type", "status", "goal", "acceptance_criteria")[:50]),
"files": files,
"tests": tests,
}
def _json_from_sol(self, router: ModelRouter | None, prompt: str, *, fallback: dict[str, object], project: Project | None = None) -> dict[str, object]:
if router is None:
return fallback
2026-08-16 15:35:55 +07:00
response = router.complete(ModelRequestContract(purpose=ModelCapability.PLANNING, prompt=prompt, project=project))
2026-08-15 19:30:02 +07:00
try:
payload = json.loads(response.content)
except json.JSONDecodeError as exc:
raise LifecyclePlanningError("Sol lifecycle planning response must be JSON") from exc
if not isinstance(payload, dict):
raise LifecyclePlanningError("Sol lifecycle planning response must be a JSON object")
return payload
def _json_from_project_brain(self, router: ModelRouter | None, prompt: str, *, fallback: dict[str, object], project: Project, category: str) -> dict[str, object]:
try:
return self._json_from_sol(router, prompt, fallback=fallback, project=project)
except Exception as exc:
create_project_planning_signal(project, f"Sol {category} planning response invalid; using bounded fallback plan.", {"error": str(exc), "category": category})
return fallback
class ExtensionService(ProjectContextMixin):
def __init__(self, router: ModelRouter | None = None, bus: EventBus | None = None) -> None:
self.router = router
self.bus = bus or EventBus()
def create_candidate(
self,
project: Project,
*,
title: str,
description: str,
rationale: str = "",
source: str = "user",
expected_value: str = "",
affected_areas: list[str] | None = None,
estimated_complexity: str = "MEDIUM",
risk: str = "MEDIUM",
confidence: float = 0.5,
evidence: dict[str, object] | None = None,
source_steward_finding: StewardFinding | None = None,
source_opportunity: ExplorationOpportunity | None = None,
2026-08-15 19:46:07 +07:00
source_roadmap_item: RoadmapItem | None = None,
2026-08-15 19:30:02 +07:00
) -> ExtensionCandidate:
return ExtensionCandidate.objects.create(
project=project,
title=title,
description=description,
rationale=rationale,
source=source,
expected_value=expected_value,
affected_areas=affected_areas or [],
estimated_complexity=estimated_complexity,
risk=risk,
confidence=confidence,
evidence=evidence or {},
source_steward_finding=source_steward_finding,
source_opportunity=source_opportunity,
2026-08-15 19:46:07 +07:00
source_roadmap_item=source_roadmap_item,
2026-08-15 19:30:02 +07:00
)
def plan_with_project_brain(self, candidate: ExtensionCandidate) -> ExtensionPlan:
context = self.project_context(candidate.project)
fallback = self._fallback_extension_plan(candidate, context)
payload = self._json_from_project_brain(
self.router,
"Plan an EXTENSION for an existing project. Return JSON with extension_plan and project_plan. Do not treat this as greenfield.\n"
+ json.dumps({"candidate": self._candidate_payload(candidate), "context": context}, default=str),
fallback=fallback,
project=candidate.project,
category="extension",
)
raw_plan = payload.get("extension_plan", payload)
if not isinstance(raw_plan, dict):
raise LifecyclePlanningError("extension_plan must be an object")
project_plan_payload = raw_plan.get("project_plan") or payload.get("project_plan")
if not isinstance(project_plan_payload, dict):
create_project_planning_signal(candidate.project, "Sol extension plan omitted project_plan; using bounded fallback plan.", {"candidate_id": str(candidate.id)})
raw_plan = dict(fallback["extension_plan"])
project_plan_payload = dict(raw_plan["project_plan"])
try:
parse_project_plan_response(json.dumps(project_plan_payload))
except Exception as exc:
create_project_planning_signal(candidate.project, "Sol extension project_plan failed validation; using bounded fallback plan.", {"candidate_id": str(candidate.id), "error": str(exc)})
raw_plan = dict(fallback["extension_plan"])
project_plan_payload = dict(raw_plan["project_plan"])
parse_project_plan_response(json.dumps(project_plan_payload))
plan = ExtensionPlan.objects.create(
candidate=candidate,
project=candidate.project,
status="PLANNED",
strategy=str(raw_plan.get("strategy", candidate.description)),
plan=raw_plan,
acceptance_criteria=[str(item) for item in raw_plan.get("acceptance_criteria", project_plan_payload.get("acceptance_criteria", []))],
context_snapshot=context,
)
candidate.status = "PLANNING"
candidate.save(update_fields=["status", "updated_at"])
self.bus.publish("EXTENSION_PLAN_CREATED", project=candidate.project, payload={"candidate_id": str(candidate.id), "plan_id": str(plan.id)})
return plan
def approve_plan(self, plan: ExtensionPlan) -> ExtensionPlan:
plan.status = "APPROVED"
plan.approved_at = timezone.now()
plan.save(update_fields=["status", "approved_at", "updated_at"])
plan.candidate.status = "READY"
plan.candidate.save(update_fields=["status", "updated_at"])
return plan
def materialize_project_dag(self, plan: ExtensionPlan) -> ProjectPlan:
if plan.project_plan_id:
return plan.project_plan
contract = parse_project_plan_response(json.dumps(plan.plan.get("project_plan", {})))
project_plan = self._materialize_contract(plan.project, contract, prefix=f"EXT-{str(plan.id)[:8]}")
plan.project_plan = project_plan
plan.status = "MATERIALIZED"
plan.save(update_fields=["project_plan", "status", "updated_at"])
plan.candidate.status = "BUILDING"
plan.candidate.save(update_fields=["status", "updated_at"])
return project_plan
def execute(self, plan: ExtensionPlan, router: ModelRouter, *, test_command: list[str] | None = None) -> list[GraphRun]:
from graph.bootstrap import champion_task_execution_graph_v1
project_plan = self.materialize_project_dag(plan)
runs: list[GraphRun] = []
for task in Task.objects.filter(milestone__plan=project_plan).exclude(status=TaskStatus.COMPLETE).order_by("priority", "created_at"):
graph_version = champion_task_execution_graph_v1()
graph_run = GraphRun.objects.create(execution_graph_version=graph_version, project=task.project, milestone=task.milestone, feature=task.feature, task=task, current_node=graph_version.graph_spec["entry"], metadata={"extension_plan_id": str(plan.id), "extension_candidate_id": str(plan.candidate_id)})
LangGraphRuntime(task_execution_registry(TaskExecutionServices(router, bus=self.bus, test_command=test_command or ["python", "-m", "pytest"])), bus=self.bus).run_until_terminal_or_paused(graph_run)
for commit in task.commits.all():
commit.extension_candidate = plan.candidate
commit.save(update_fields=["extension_candidate", "updated_at"])
runs.append(graph_run)
return runs
def verify_extension(self, plan: ExtensionPlan) -> Verification:
project_plan = plan.project_plan
tasks = Task.objects.filter(milestone__plan=project_plan) if project_plan else Task.objects.none()
task_count = tasks.count()
completed = task_count > 0 and not tasks.exclude(status=TaskStatus.COMPLETE).exists()
tests_pass = not TestRun.objects.filter(task__in=tasks).exclude(status="PASS").exists()
review_failures = Review.objects.filter(task__in=tasks).exclude(status="PASS")
judge_failures = Verification.objects.filter(task__in=tasks, level=VerificationLevel.TASK).exclude(result=VerificationResult.PASS)
criteria = plan.acceptance_criteria or plan.plan.get("acceptance_criteria", [])
criteria_present = bool(criteria)
passed = completed and tests_pass and not review_failures.exists() and not judge_failures.exists() and criteria_present
verification = Verification.objects.create(
project=plan.project,
milestone=tasks.first().milestone if tasks.exists() else None,
level=VerificationLevel.MILESTONE,
result=VerificationResult.PASS if passed else VerificationResult.FAIL,
contract={"extension_plan_id": str(plan.id), "acceptance_criteria": criteria},
evidence=[{"task_count": task_count, "all_tasks_complete": completed, "tests_pass": tests_pass, "review_failures": review_failures.count(), "judge_failures": judge_failures.count()}],
summary="Extension acceptance contract satisfied" if passed else "Extension acceptance contract failed",
)
plan.verification = verification
plan.status = "COMPLETE" if passed else "FAILED"
plan.completed_at = timezone.now()
plan.save(update_fields=["verification", "status", "completed_at", "updated_at"])
plan.candidate.status = "COMPLETE" if passed else "BUILDING"
plan.candidate.save(update_fields=["status", "updated_at"])
return verification
def _fallback_extension_plan(self, candidate: ExtensionCandidate, context: dict[str, object]) -> dict[str, object]:
task_goal = candidate.metadata.get("task_goal") or candidate.description or candidate.title
acceptance = candidate.metadata.get("acceptance_criteria") or [f"{candidate.title} capability is present", "Deterministic tests pass"]
return {
"extension_plan": {
"strategy": f"Add bounded scope to existing project: {candidate.title}",
"acceptance_criteria": acceptance,
"project_plan": {
"goal": candidate.project.goal,
"scope": candidate.description,
"acceptance_criteria": acceptance,
"milestones": [
{
"key": "EXTEND",
"title": candidate.title,
"goal": candidate.description or candidate.title,
"verification_contract": {"extension_candidate_id": str(candidate.id)},
"features": [{"key": "F1", "title": candidate.title, "description": candidate.description, "acceptance_criteria": acceptance, "tasks": [{"id": "T1", "goal": str(task_goal), "type": "implementation", "acceptance_criteria": acceptance, "priority": 50, "dependencies": []}]}],
}
],
},
}
}
def _candidate_payload(self, candidate: ExtensionCandidate) -> dict[str, object]:
return {"id": str(candidate.id), "title": candidate.title, "description": candidate.description, "rationale": candidate.rationale, "expected_value": candidate.expected_value, "affected_areas": candidate.affected_areas, "evidence": candidate.evidence}
def _materialize_contract(self, project: Project, contract: ProjectPlanContract, *, prefix: str) -> ProjectPlan:
with transaction.atomic():
version = project.current_plan_version + 1
project_plan = ProjectPlan.objects.create(project=project, version=version, goal=project.goal, scope=contract.scope, stack=contract.stack, architecture=contract.architecture, constraints=contract.constraints, acceptance_criteria=contract.acceptance_criteria, permissions=contract.permissions, budget=contract.budget, open_decisions=contract.open_decisions, approved_at=timezone.now())
project.current_plan_version = version
project.save(update_fields=["current_plan_version", "updated_at"])
task_by_external_id: dict[str, Task] = {}
dependency_specs: list[tuple[Task, list[str]]] = []
for order, milestone_contract in enumerate(contract.milestones):
milestone = Milestone.objects.create(project=project, plan=project_plan, key=f"{prefix}-{milestone_contract.key}"[:50], title=milestone_contract.title, goal=milestone_contract.goal, verification_contract=milestone_contract.verification_contract, order=order)
for feature_contract in milestone_contract.features:
feature = Feature.objects.create(project=project, milestone=milestone, title=feature_contract.title, description=feature_contract.description, acceptance_criteria=feature_contract.acceptance_criteria)
for task_contract in feature_contract.tasks:
task = Task.objects.create(project=project, milestone=milestone, feature=feature, task_type=task_contract.task_type, status=TaskStatus.READY, priority=task_contract.priority, goal=task_contract.goal, acceptance_criteria=task_contract.acceptance_criteria)
task_by_external_id[task_contract.task_id] = task
dependency_specs.append((task, task_contract.dependencies))
for task, deps in dependency_specs:
for dep in deps:
TaskDependency.objects.create(task=task, depends_on=task_by_external_id[dep])
return project_plan
class EvolutionService(ProjectContextMixin):
def __init__(self, router: ModelRouter | None = None, bus: EventBus | None = None) -> None:
self.router = router
self.bus = bus or EventBus()
2026-08-15 19:46:07 +07:00
def create_candidate(self, project: Project, *, target: str, objective: str, baseline_measurement: dict[str, object], desired_direction: str, target_measurement: dict[str, object] | None = None, rationale: str = "", source: str = "user", evidence: dict[str, object] | None = None, risk: str = "MEDIUM", confidence: float = 0.5, source_steward_finding: StewardFinding | None = None, source_opportunity: ExplorationOpportunity | None = None, source_roadmap_item: RoadmapItem | None = None) -> EvolutionCandidate:
2026-08-15 19:30:02 +07:00
if not baseline_measurement:
raise ValidationError("EVOLVE requires a measurable baseline; route to INVESTIGATE or EXPLORE instead.")
2026-08-15 19:46:07 +07:00
return EvolutionCandidate.objects.create(project=project, target=target, objective=objective, baseline_measurement=baseline_measurement, desired_direction=desired_direction, target_measurement=target_measurement or {}, rationale=rationale, source=source, evidence=evidence or {}, risk=risk, confidence=confidence, source_steward_finding=source_steward_finding, source_opportunity=source_opportunity, source_roadmap_item=source_roadmap_item)
2026-08-15 19:30:02 +07:00
def plan_with_project_brain(self, candidate: EvolutionCandidate) -> EvolutionPlan:
if not candidate.baseline_measurement:
raise ValidationError("EvolutionCandidate has no baseline measurement.")
context = self.project_context(candidate.project)
fallback = self._fallback_evolution_plan(candidate)
payload = self._json_from_project_brain(self.router, "Plan an EVOLUTION for an existing project. Return JSON with evolution_plan and project_plan. Must preserve measurable baseline and threshold.\n" + json.dumps({"candidate": self._candidate_payload(candidate), "context": context}, default=str), fallback=fallback, project=candidate.project, category="evolution")
raw_plan = payload.get("evolution_plan", payload)
if not isinstance(raw_plan, dict):
raise LifecyclePlanningError("evolution_plan must be an object")
project_plan_payload = raw_plan.get("project_plan") or payload.get("project_plan")
if not isinstance(project_plan_payload, dict):
create_project_planning_signal(candidate.project, "Sol evolution plan omitted project_plan; using bounded fallback plan.", {"candidate_id": str(candidate.id)})
raw_plan = dict(fallback["evolution_plan"])
project_plan_payload = dict(raw_plan["project_plan"])
try:
parse_project_plan_response(json.dumps(project_plan_payload))
except Exception as exc:
create_project_planning_signal(candidate.project, "Sol evolution project_plan failed validation; using bounded fallback plan.", {"candidate_id": str(candidate.id), "error": str(exc)})
raw_plan = dict(fallback["evolution_plan"])
project_plan_payload = dict(raw_plan["project_plan"])
parse_project_plan_response(json.dumps(project_plan_payload))
plan = EvolutionPlan.objects.create(candidate=candidate, project=candidate.project, status="PLANNED", baseline=dict(raw_plan.get("baseline", candidate.baseline_measurement)), hypothesis=str(raw_plan.get("hypothesis", candidate.objective)), intervention=str(raw_plan.get("intervention", "Implement targeted project improvement")), measurement_method=dict(raw_plan.get("measurement_method", {"type": "metadata"})), success_threshold=dict(raw_plan.get("success_threshold", {"minimum_improvement_percent": 10})), regression_constraints=list(raw_plan.get("regression_constraints", ["Deterministic tests pass"])), affected_components=list(raw_plan.get("affected_components", [candidate.target])), task_plan=project_plan_payload, experiment_requirements=dict(raw_plan.get("experiment_requirements", {})), context_snapshot=context)
candidate.status = "PLANNING"
candidate.save(update_fields=["status", "updated_at"])
return plan
def approve_plan(self, plan: EvolutionPlan) -> EvolutionPlan:
plan.status = "APPROVED"
plan.approved_at = timezone.now()
plan.save(update_fields=["status", "approved_at", "updated_at"])
plan.candidate.status = "READY"
plan.candidate.save(update_fields=["status", "updated_at"])
return plan
def materialize_work(self, plan: EvolutionPlan) -> ProjectPlan:
if plan.project_plan_id:
return plan.project_plan
contract = parse_project_plan_response(json.dumps(plan.task_plan))
project_plan = ExtensionService(bus=self.bus)._materialize_contract(plan.project, contract, prefix=f"EVO-{str(plan.id)[:8]}")
plan.project_plan = project_plan
plan.status = "MATERIALIZED"
plan.save(update_fields=["project_plan", "status", "updated_at"])
plan.candidate.status = "BUILDING"
plan.candidate.save(update_fields=["status", "updated_at"])
return project_plan
def execute(self, plan: EvolutionPlan, router: ModelRouter, *, test_command: list[str] | None = None) -> list[GraphRun]:
from graph.bootstrap import champion_task_execution_graph_v1
project_plan = self.materialize_work(plan)
runs: list[GraphRun] = []
for task in Task.objects.filter(milestone__plan=project_plan).exclude(status=TaskStatus.COMPLETE).order_by("priority", "created_at"):
graph_version = champion_task_execution_graph_v1()
graph_run = GraphRun.objects.create(execution_graph_version=graph_version, project=task.project, milestone=task.milestone, feature=task.feature, task=task, current_node=graph_version.graph_spec["entry"], metadata={"evolution_plan_id": str(plan.id), "evolution_candidate_id": str(plan.candidate_id)})
LangGraphRuntime(task_execution_registry(TaskExecutionServices(router, bus=self.bus, test_command=test_command or ["python", "-m", "pytest"])), bus=self.bus).run_until_terminal_or_paused(graph_run)
for commit in task.commits.all():
commit.evolution_candidate = plan.candidate
commit.save(update_fields=["evolution_candidate", "updated_at"])
runs.append(graph_run)
return runs
def measure_candidate(self, plan: EvolutionPlan) -> dict[str, object]:
method = plan.measurement_method or {}
if "candidate_measurement" in plan.metadata:
measurement = dict(plan.metadata["candidate_measurement"])
elif method.get("type") == "command" and plan.project.repository_path:
completed = subprocess.run([str(part) for part in method.get("command", [])], cwd=plan.project.repository_path, capture_output=True, text=True, check=False, timeout=120)
measurement = {"returncode": completed.returncode, "stdout": completed.stdout[-4000:], "stderr": completed.stderr[-2000:]}
else:
measurement = dict(plan.candidate.target_measurement or plan.baseline)
plan.candidate_measurement = measurement
plan.save(update_fields=["candidate_measurement", "updated_at"])
return measurement
def compare_baseline(self, plan: EvolutionPlan) -> dict[str, object]:
metric = str(plan.success_threshold.get("metric", plan.baseline.get("metric", "value")))
baseline_value = float(plan.baseline.get(metric, plan.baseline.get("value", 0)) or 0)
candidate_value = float(plan.candidate_measurement.get(metric, plan.candidate_measurement.get("value", baseline_value)) or 0)
direction = plan.candidate.desired_direction
if baseline_value == 0:
improvement_percent = 0.0
elif direction == "DECREASE":
improvement_percent = ((baseline_value - candidate_value) / baseline_value) * 100
else:
improvement_percent = ((candidate_value - baseline_value) / baseline_value) * 100
threshold = float(plan.success_threshold.get("minimum_improvement_percent", 0))
delta = {"metric": metric, "baseline": baseline_value, "candidate": candidate_value, "improvement_percent": improvement_percent, "threshold": threshold}
plan.delta = delta
plan.save(update_fields=["delta", "updated_at"])
return delta
def judge_evolution(self, plan: EvolutionPlan) -> Verification:
if not plan.candidate_measurement:
self.measure_candidate(plan)
delta = self.compare_baseline(plan)
project_plan = plan.project_plan
tasks = Task.objects.filter(milestone__plan=project_plan) if project_plan else Task.objects.none()
tests_pass = not TestRun.objects.filter(task__in=tasks).exclude(status="PASS").exists()
implementation_complete = not tasks.exclude(status=TaskStatus.COMPLETE).exists() if tasks.exists() else True
improved = float(delta["improvement_percent"]) >= float(delta["threshold"])
verdict = "PASS" if implementation_complete and tests_pass and improved else "NOT_IMPROVED"
verification = Verification.objects.create(project=plan.project, milestone=tasks.first().milestone if tasks.exists() else None, level=VerificationLevel.MILESTONE, result=VerificationResult.PASS if verdict == "PASS" else VerificationResult.FAIL, contract={"evolution_plan_id": str(plan.id), "success_threshold": plan.success_threshold, "regression_constraints": plan.regression_constraints}, evidence=[{"implementation_complete": implementation_complete, "tests_pass": tests_pass, "delta": delta, "verdict": verdict}], summary="Evolution objective improved" if verdict == "PASS" else "Evolution implementation did not improve objective")
plan.verification = verification
plan.verdict = verdict
plan.status = "COMPLETE" if verdict == "PASS" else "NOT_IMPROVED"
plan.completed_at = timezone.now()
plan.save(update_fields=["verification", "verdict", "status", "completed_at", "updated_at"])
plan.candidate.status = "COMPLETE" if verdict == "PASS" else "PROPOSED"
plan.candidate.save(update_fields=["status", "updated_at"])
return verification
def _fallback_evolution_plan(self, candidate: EvolutionCandidate) -> dict[str, object]:
acceptance = ["Deterministic tests pass", f"Objective improves: {candidate.objective}"]
task_goal = candidate.metadata.get("task_goal") or f"Improve {candidate.target}: {candidate.objective}"
return {"evolution_plan": {"baseline": candidate.baseline_measurement, "hypothesis": candidate.objective, "intervention": str(task_goal), "measurement_method": {"type": "metadata"}, "success_threshold": {"metric": candidate.baseline_measurement.get("metric", "value"), "minimum_improvement_percent": 10}, "regression_constraints": ["Deterministic tests pass"], "affected_components": [candidate.target], "project_plan": {"goal": candidate.project.goal, "scope": candidate.objective, "acceptance_criteria": acceptance, "milestones": [{"key": "EVOLVE", "title": candidate.target, "goal": candidate.objective, "verification_contract": {"evolution_candidate_id": str(candidate.id)}, "features": [{"key": "F1", "title": candidate.target, "description": candidate.objective, "acceptance_criteria": acceptance, "tasks": [{"id": "T1", "goal": str(task_goal), "type": "implementation", "acceptance_criteria": acceptance, "priority": 50, "dependencies": []}]}]}]}}}
def _candidate_payload(self, candidate: EvolutionCandidate) -> dict[str, object]:
return {"id": str(candidate.id), "target": candidate.target, "objective": candidate.objective, "baseline_measurement": candidate.baseline_measurement, "desired_direction": candidate.desired_direction, "target_measurement": candidate.target_measurement, "evidence": candidate.evidence}
class ExplorerService(ProjectContextMixin):
def __init__(self, router: ModelRouter | None = None, bus: EventBus | None = None) -> None:
self.router = router
self.bus = bus or EventBus()
def start_exploration(self, project: Project, *, prompt: str = "") -> Exploration:
exploration = Exploration.objects.create(project=project, prompt=prompt, context_snapshot=self.project_context(project))
return exploration
def generate_opportunities(self, exploration: Exploration) -> list[ExplorationOpportunity]:
fallback = {"opportunities": self._fallback_opportunities(exploration)}
payload = self._json_from_project_brain(self.router, "Explore an existing project for valuable changes. Return JSON opportunities only; do not create build work.\n" + json.dumps(exploration.context_snapshot, default=str), fallback=fallback, project=exploration.project, category="exploration")
raw_items = payload.get("opportunities", [])
if not isinstance(raw_items, list):
raise LifecyclePlanningError("Explorer response opportunities must be a list")
opportunities: list[ExplorationOpportunity] = []
for raw in raw_items:
if not isinstance(raw, dict):
continue
opportunity = self.upsert_opportunity(exploration, raw)
opportunities.append(opportunity)
exploration.status = "COMPLETE"
exploration.completed_at = timezone.now()
exploration.save(update_fields=["status", "completed_at", "updated_at"])
return sorted(opportunities, key=lambda item: item.composite_score, reverse=True)
def upsert_opportunity(self, exploration: Exploration, raw: dict[str, object]) -> ExplorationOpportunity:
title = str(raw.get("title", "Untitled opportunity"))
grouping_key = self._grouping_key(exploration.project, title, str(raw.get("opportunity_type", "FEATURE")))
existing = self._known_duplicate(exploration.project, grouping_key, title)
scores = self.score(raw)
raw_evidence = raw.get("evidence", {})
evidence = raw_evidence if isinstance(raw_evidence, dict) else {"raw": raw_evidence}
if existing is not None:
existing.metadata = {**existing.metadata, "rediscovered_by": str(exploration.id)}
existing.save(update_fields=["metadata", "updated_at"])
return existing
return ExplorationOpportunity.objects.create(exploration=exploration, project=exploration.project, title=title, description=str(raw.get("description", "")), opportunity_type=str(raw.get("opportunity_type", "FEATURE")), evidence=evidence, rationale=str(raw.get("rationale", "")), expected_value=str(raw.get("expected_value", "")), effort_estimate=str(raw.get("effort_estimate", "MEDIUM")), risk=str(raw.get("risk", "MEDIUM")), confidence=scores["confidence"], technical_fit=scores["technical_fit"], strategic_fit=scores["strategic_fit"], value_score=scores["value"], effort_score=scores["effort"], risk_score=scores["risk"], composite_score=scores["composite"], recommended_action=str(raw.get("recommended_action", "DEFER")), grouping_key=grouping_key)
def score(self, raw: dict[str, object]) -> dict[str, float]:
value = self._score_value(raw.get("value", raw.get("value_score", 0.5)))
effort = self._score_value(raw.get("effort", raw.get("effort_score", 0.5)))
risk = self._score_value(raw.get("risk_score", 0.5))
confidence = self._score_value(raw.get("confidence", 0.5))
technical_fit = self._score_value(raw.get("technical_fit", 0.5))
strategic_fit = self._score_value(raw.get("strategic_fit", 0.5))
composite = (value * 0.35) + ((1 - effort) * 0.15) + ((1 - risk) * 0.15) + (confidence * 0.15) + (technical_fit * 0.1) + (strategic_fit * 0.1)
return {"value": value, "effort": effort, "risk": risk, "confidence": confidence, "technical_fit": technical_fit, "strategic_fit": strategic_fit, "composite": composite}
def _score_value(self, value: object) -> float:
try:
score = float(value)
except (TypeError, ValueError):
return 0.5
return max(0.0, min(1.0, score))
def convert_to_extension(self, opportunity: ExplorationOpportunity) -> ExtensionCandidate:
candidate = ExtensionService(bus=self.bus).create_candidate(opportunity.project, title=opportunity.title, description=opportunity.description, rationale=opportunity.rationale, source="Explorer", expected_value=opportunity.expected_value, affected_areas=[opportunity.opportunity_type], estimated_complexity=opportunity.effort_estimate, risk=opportunity.risk, confidence=opportunity.confidence, evidence=opportunity.evidence, source_opportunity=opportunity)
opportunity.converted_extension = candidate
opportunity.status = "CONVERTED"
opportunity.save(update_fields=["converted_extension", "status", "updated_at"])
return candidate
def convert_to_evolution(self, opportunity: ExplorationOpportunity, *, baseline_measurement: dict[str, object], desired_direction: str = "DECREASE") -> EvolutionCandidate:
candidate = EvolutionService(bus=self.bus).create_candidate(opportunity.project, target=opportunity.opportunity_type, objective=opportunity.description or opportunity.title, baseline_measurement=baseline_measurement, desired_direction=desired_direction, rationale=opportunity.rationale, source="Explorer", evidence=opportunity.evidence, risk=opportunity.risk, confidence=opportunity.confidence, source_opportunity=opportunity)
opportunity.converted_evolution = candidate
opportunity.status = "CONVERTED"
opportunity.save(update_fields=["converted_evolution", "status", "updated_at"])
return candidate
def defer(self, opportunity: ExplorationOpportunity) -> ExplorationOpportunity:
opportunity.status = "DEFERRED"
opportunity.save(update_fields=["status", "updated_at"])
return opportunity
def reject(self, opportunity: ExplorationOpportunity) -> ExplorationOpportunity:
opportunity.status = "REJECTED"
opportunity.save(update_fields=["status", "updated_at"])
return opportunity
def _fallback_opportunities(self, exploration: Exploration) -> list[dict[str, object]]:
context = exploration.context_snapshot
features = str(context.get("features", "")).lower()
opportunities: list[dict[str, object]] = []
if "dashboard" not in features:
opportunities.append({"title": "Add project health dashboard", "description": "Expose recent runs, findings, and lifecycle status in a project dashboard.", "opportunity_type": "FEATURE", "evidence": {"missing_feature": "dashboard"}, "rationale": "Operators need a fast project health view.", "expected_value": "Improves observability", "effort_estimate": "MEDIUM", "value": 0.8, "effort": 0.45, "risk_score": 0.3, "confidence": 0.7, "technical_fit": 0.8, "strategic_fit": 0.8, "recommended_action": "EXTEND"})
opportunities.append({"title": "Measure repository analysis latency", "description": "Establish and improve latency for repeated project inspection.", "opportunity_type": "PERFORMANCE", "evidence": {"source": "Explorer fallback"}, "rationale": "Faster analysis improves iteration speed.", "expected_value": "Reduces operational latency", "effort_estimate": "LOW", "value": 0.6, "effort": 0.25, "risk_score": 0.2, "confidence": 0.65, "technical_fit": 0.75, "strategic_fit": 0.65, "recommended_action": "EVOLVE"})
return opportunities
def _known_duplicate(self, project: Project, grouping_key: str, title: str) -> ExplorationOpportunity | None:
existing = ExplorationOpportunity.objects.filter(project=project, grouping_key=grouping_key).first()
if existing:
return existing
lowered = title.lower()
if ExtensionCandidate.objects.filter(project=project, title__iexact=title).exists() or RoadmapItem.objects.filter(project=project, title__iexact=title).exists():
return ExplorationOpportunity.objects.filter(project=project, title__iexact=title).first()
if any(item in lowered for item in ["authentication", "auth"]) and Decision.objects.filter(project=project, decision__icontains="authentication").exists():
return ExplorationOpportunity.objects.filter(project=project, title__icontains="auth").first()
return None
def _grouping_key(self, project: Project, title: str, opportunity_type: str) -> str:
fingerprint = hashlib.sha256(f"{title.lower()}:{opportunity_type.lower()}".encode("utf-8")).hexdigest()[:16]
return f"{project.id}:{opportunity_type}:{fingerprint}"[:240]
class LifecycleInspectionService:
def project_lifecycle_view(self, project: Project) -> dict[str, object]:
return {
"project_id": str(project.id),
"repairs": list(project.steward_findings.filter(recommended_action="REPAIR").values("id", "title", "status", "severity")),
"extensions": [self._extension(candidate) for candidate in project.extension_candidates.order_by("-created_at")],
"evolutions": [self._evolution(candidate) for candidate in project.evolution_candidates.order_by("-created_at")],
"explorations": [self._exploration(exploration) for exploration in project.explorations.order_by("-created_at")],
}
def _extension(self, candidate: ExtensionCandidate) -> dict[str, object]:
plan = candidate.plans.order_by("-created_at").first()
tasks = Task.objects.filter(milestone__plan=plan.project_plan) if plan and plan.project_plan_id else Task.objects.none()
return {"candidate": {"id": str(candidate.id), "title": candidate.title, "status": candidate.status}, "plan": str(plan.id) if plan else None, "tasks": list(tasks.values("id", "status", "goal")), "verification": str(plan.verification_id) if plan and plan.verification_id else None, "commits": list(candidate.commits.values("id", "sha", "task_id"))}
def _evolution(self, candidate: EvolutionCandidate) -> dict[str, object]:
plan = candidate.plans.order_by("-created_at").first()
return {"candidate": {"id": str(candidate.id), "target": candidate.target, "objective": candidate.objective, "status": candidate.status}, "baseline": candidate.baseline_measurement, "target": candidate.target_measurement, "measurement": plan.candidate_measurement if plan else {}, "delta": plan.delta if plan else {}, "verdict": plan.verdict if plan else ""}
def _exploration(self, exploration: Exploration) -> dict[str, object]:
return {"id": str(exploration.id), "status": exploration.status, "opportunities": list(exploration.opportunities.order_by("-composite_score").values("id", "title", "opportunity_type", "status", "recommended_action", "value_score", "effort_score", "risk_score", "confidence", "technical_fit", "strategic_fit", "composite_score"))}
def create_project_planning_signal(project: Project, summary: str, evidence: dict[str, object]) -> ProgenySignal:
return ProgenySignal.objects.create(project=project, source="project_lifecycle", severity="MEDIUM", failure_category="PROJECT_PLANNING", summary=summary, evidence=evidence, grouping_key=f"project_lifecycle:{project.id}:planning"[:120])