from __future__ import annotations import hashlib import json from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any from .task_runtime import TaskRuntime DEFAULT_PLAN_RUNTIME_PATH = Path('.port_sessions') / 'plan_runtime.json' VALID_PLAN_STATUSES = ( 'pending', 'in_progress', 'completed', 'blocked', 'cancelled', ) @dataclass(frozen=True) class PlanStep: step: str status: str = 'pending' task_id: str | None = None description: str | None = None priority: str | None = None active_form: str | None = None owner: str | None = None depends_on: tuple[str, ...] = () def to_dict(self) -> dict[str, Any]: return { 'step': self.step, 'status': self.status, 'task_id': self.task_id, 'description': self.description, 'priority': self.priority, 'active_form': self.active_form, 'owner': self.owner, 'depends_on': list(self.depends_on), } @classmethod def from_dict(cls, payload: dict[str, Any]) -> 'PlanStep': return cls( step=str(payload.get('step') or ''), status=_normalize_plan_status(payload.get('status')), task_id=( str(payload.get('task_id')) if isinstance(payload.get('task_id'), str) and payload.get('task_id') else None ), description=( str(payload.get('description')) if isinstance(payload.get('description'), str) and payload.get('description').strip() else None ), priority=( str(payload.get('priority')) if isinstance(payload.get('priority'), str) and payload.get('priority').strip() else None ), active_form=( str(payload.get('active_form')) if isinstance(payload.get('active_form'), str) and payload.get('active_form').strip() else None ), owner=( str(payload.get('owner')) if isinstance(payload.get('owner'), str) and payload.get('owner').strip() else None ), depends_on=_normalize_id_list(payload.get('depends_on')), ) @dataclass(frozen=True) class PlanMutation: explanation: str | None store_path: str before_sha256: str | None after_sha256: str before_preview: str | None after_preview: str before_count: int after_count: int synced_tasks: int = 0 synced_task_store_path: str | None = None synced_task_sha256: str | None = None @dataclass class PlanRuntime: steps: tuple[PlanStep, ...] = field(default_factory=tuple) explanation: str | None = None updated_at: str | None = None storage_path: Path = field(default_factory=lambda: DEFAULT_PLAN_RUNTIME_PATH.resolve()) @classmethod def from_workspace(cls, cwd: Path) -> 'PlanRuntime': storage_path = (cwd.resolve() / DEFAULT_PLAN_RUNTIME_PATH).resolve() if not storage_path.exists(): return cls(storage_path=storage_path) try: payload = json.loads(storage_path.read_text(encoding='utf-8')) except (OSError, json.JSONDecodeError): return cls(storage_path=storage_path) raw_steps = payload.get('steps') if not isinstance(raw_steps, list): return cls(storage_path=storage_path) steps: list[PlanStep] = [] for item in raw_steps: if not isinstance(item, dict): continue step = PlanStep.from_dict(item) if step.step: steps.append(step) explanation = payload.get('explanation') updated_at = payload.get('updated_at') return cls( steps=tuple(steps), explanation=( explanation.strip() if isinstance(explanation, str) and explanation.strip() else None ), updated_at=str(updated_at) if isinstance(updated_at, str) and updated_at else None, storage_path=storage_path, ) def update_plan( self, items: list[dict[str, Any]], *, explanation: str | None = None, task_runtime: TaskRuntime | None = None, sync_tasks: bool = True, ) -> PlanMutation: normalized_steps: list[PlanStep] = [] for index, item in enumerate(items, start=1): if not isinstance(item, dict): continue step_text = item.get('step') if not isinstance(step_text, str) or not step_text.strip(): continue task_id = item.get('task_id') normalized_steps.append( PlanStep( step=step_text.strip(), status=_normalize_plan_status(item.get('status')), task_id=( task_id.strip() if isinstance(task_id, str) and task_id.strip() else f'plan_{index}' ), description=( item.get('description').strip() if isinstance(item.get('description'), str) and item.get('description').strip() else None ), priority=( item.get('priority').strip() if isinstance(item.get('priority'), str) and item.get('priority').strip() else None ), active_form=( item.get('active_form').strip() if isinstance(item.get('active_form'), str) and item.get('active_form').strip() else None ), owner=( item.get('owner').strip() if isinstance(item.get('owner'), str) and item.get('owner').strip() else None ), depends_on=_normalize_id_list(item.get('depends_on')), ) ) mutation = self._persist( tuple(normalized_steps), explanation=( explanation.strip() if isinstance(explanation, str) and explanation.strip() else None ), ) if sync_tasks and task_runtime is not None: dependents: dict[str, list[str]] = {} for step in self.steps: for dependency in step.depends_on: dependents.setdefault(dependency, []).append(step.task_id or '') task_items = [ { 'task_id': step.task_id or f'plan_{index}', 'title': step.step, 'description': step.description, 'status': _plan_status_to_task_status(step.status), 'priority': step.priority, 'active_form': step.active_form, 'owner': step.owner, 'blocked_by': list(step.depends_on), 'blocks': sorted( dependency for dependency in dependents.get(step.task_id or f'plan_{index}', []) if dependency ), } for index, step in enumerate(self.steps, start=1) ] task_mutation = task_runtime.replace_tasks(task_items) return PlanMutation( explanation=mutation.explanation, store_path=mutation.store_path, before_sha256=mutation.before_sha256, after_sha256=mutation.after_sha256, before_preview=mutation.before_preview, after_preview=mutation.after_preview, before_count=mutation.before_count, after_count=mutation.after_count, synced_tasks=task_mutation.after_count, synced_task_store_path=task_mutation.store_path, synced_task_sha256=task_mutation.after_sha256, ) return mutation def clear_plan(self, *, task_runtime: TaskRuntime | None = None) -> PlanMutation: mutation = self._persist((), explanation=None) if task_runtime is not None: task_mutation = task_runtime.replace_tasks([]) return PlanMutation( explanation=None, store_path=mutation.store_path, before_sha256=mutation.before_sha256, after_sha256=mutation.after_sha256, before_preview=mutation.before_preview, after_preview=mutation.after_preview, before_count=mutation.before_count, after_count=mutation.after_count, synced_tasks=task_mutation.after_count, synced_task_store_path=task_mutation.store_path, synced_task_sha256=task_mutation.after_sha256, ) return mutation def render_summary(self) -> str: lines = [ f'Local plan runtime file: {self.storage_path}', f'Total plan steps: {len(self.steps)}', ] if self.explanation: lines.append(f'- Explanation: {self.explanation}') counts: dict[str, int] = {} for step in self.steps: counts[step.status] = counts.get(step.status, 0) + 1 if counts: lines.append( '- Status counts: ' + ', '.join(f'{name}={count}' for name, count in sorted(counts.items())) ) if self.updated_at: lines.append(f'- Updated: {self.updated_at}') return '\n'.join(lines) def render_plan(self) -> str: if not self.steps: return '# Plan\n\nNo stored plan is currently available.' lines = ['# Plan', ''] if self.explanation: lines.extend(['## Explanation', self.explanation, '']) lines.append('## Steps') for index, step in enumerate(self.steps, start=1): details = [f'{index}. {step.step}', f'status={step.status}'] if step.task_id: details.append(f'task_id={step.task_id}') if step.priority: details.append(f'priority={step.priority}') lines.append('- ' + '; '.join(details)) if step.description: lines.append(f' description: {step.description}') if step.active_form: lines.append(f' active_form: {step.active_form}') if step.owner: lines.append(f' owner: {step.owner}') if step.depends_on: lines.append(f" depends_on: {', '.join(step.depends_on)}") return '\n'.join(lines) def _persist( self, steps: tuple[PlanStep, ...], *, explanation: str | None, ) -> PlanMutation: before_count = len(self.steps) before_text = self._serialize_payload(self.steps, self.explanation, self.updated_at) before_preview = _snapshot_text(before_text) before_sha256 = ( hashlib.sha256(before_text.encode('utf-8')).hexdigest() if self.storage_path.exists() or self.steps or self.explanation else None ) updated_at = datetime.now(timezone.utc).isoformat() payload_text = self._serialize_payload(steps, explanation, updated_at) self.storage_path.parent.mkdir(parents=True, exist_ok=True) self.storage_path.write_text(payload_text, encoding='utf-8') self.steps = steps self.explanation = explanation self.updated_at = updated_at after_sha256 = hashlib.sha256(payload_text.encode('utf-8')).hexdigest() return PlanMutation( explanation=explanation, store_path=str(self.storage_path), before_sha256=before_sha256, after_sha256=after_sha256, before_preview=before_preview if before_text.strip() else None, after_preview=_snapshot_text(payload_text), before_count=before_count if before_text.strip() else 0, after_count=len(steps), ) def _serialize_payload( self, steps: tuple[PlanStep, ...], explanation: str | None, updated_at: str | None, ) -> str: payload = { 'explanation': explanation, 'updated_at': updated_at, 'steps': [step.to_dict() for step in steps], } return json.dumps(payload, ensure_ascii=True, indent=2) def _normalize_plan_status(value: Any) -> str: if isinstance(value, str): lowered = value.strip().lower() aliases = { 'todo': 'pending', 'open': 'pending', 'done': 'completed', 'complete': 'completed', 'in-progress': 'in_progress', 'in progress': 'in_progress', 'blocked_on': 'blocked', } lowered = aliases.get(lowered, lowered) if lowered in VALID_PLAN_STATUSES: return lowered return 'pending' def _plan_status_to_task_status(status: str) -> str: if status == 'completed': return 'completed' if status == 'in_progress': return 'in_progress' if status == 'blocked': return 'blocked' if status == 'cancelled': return 'cancelled' return 'pending' def _normalize_id_list(value: Any) -> tuple[str, ...]: if not isinstance(value, list): return () normalized: list[str] = [] seen: set[str] = set() for item in value: if not isinstance(item, str): continue text = item.strip() if not text or text in seen: continue normalized.append(text) seen.add(text) return tuple(normalized) def _snapshot_text(text: str, limit: int = 240) -> str: normalized = ' '.join(text.split()) if len(normalized) <= limit: return normalized return normalized[: limit - 3] + '...'