#!/usr/bin/env python3 """ AirArc mode — V2 架构规划器。 L1 代码级保障:allowed-tools 限制为只读。 产出 execution-plan.json(完整 DAG)+ plan-delta.json(增量重规划)。 """ from __future__ import annotations import json import sys from pathlib import Path from air_runtime.contracts import now_iso from air_runtime.io import atomic_json_write, safe_json_load from air_runtime.paths import airplan_root, todo_path as get_todo_path from air_runtime.review import build_parallel_review, render_review_markdown from air_runtime.task_graph import TaskGraph, TaskNode, Edge, PlanDelta from air_runtime.events import EventLog from air_runtime.paths import event_log_path from air_runtime.utils import ordered_unique class ArcPhaseGate: """三阶段门控:discussing → proposing → confirmed。 execution-plan.json 仅在 phase=confirmed 时允许写入。 """ PHASES = ["discussing", "proposing", "confirmed"] def __init__(self, state_path: Path): self._state_path = state_path @property def current_phase(self) -> str: data = safe_json_load(self._state_path) or {} return data.get("arcPhase", "discussing") def advance_to(self, phase: str) -> None: if phase not in self.PHASES: raise ValueError(f"invalid phase: {phase!r}") idx_current = self.PHASES.index(self.current_phase) idx_target = self.PHASES.index(phase) if idx_target <= idx_current: return data = safe_json_load(self._state_path) or {} data["arcPhase"] = phase data[f"arcPhase_{phase}At"] = now_iso() atomic_json_write(self._state_path, data) def can_write_plan(self) -> bool: return self.current_phase == "confirmed" def confirm_architecture(self, user_confirmation: str) -> bool: """检查用户确认文本中的关键词,确认后推进到 confirmed。""" confirm_keywords = ["确认", "可以", "同意", "confirm", "yes", "ok", "好的", "没问题"] if any(kw in user_confirmation.lower() for kw in confirm_keywords): self.advance_to("confirmed") return True return False def _paths(project_root: Path) -> dict[str, Path]: root = airplan_root(project_root) / "state" / "airarc" return { "root": root, "state": root / "state.json", "reviews_dir": root / "reviews", "execution_plan_json": root / "reviews" / "execution-plan.json", "execution_plan_md": root / "reviews" / "execution-plan.md", "plan_delta_json": root / "reviews" / "plan-delta.json", "task_graph_json": root / "reviews" / "task-graph.json", } def _ensure_dirs(paths: dict[str, Path]) -> None: paths["reviews_dir"].mkdir(parents=True, exist_ok=True) def _export_task_graph_json(graph: TaskGraph, path: Path) -> None: data = { "nodes": {nid: {"id": n.id, "status": n.status, "task": n.task, "filesDirs": n.files_dirs, "doneWhen": n.done_when, "inDegree": n.in_degree, "outEdges": n.out_edges, "writeSet": n.write_set} for nid, n in graph.nodes.items()}, "edges": [{"source": e.source, "target": e.target, "kind": e.kind} for e in graph.edges], } atomic_json_write(path, data) def _build_graph_from_todo(todo_path: Path) -> TaskGraph: """从 todo.md 构建初始 DAG。""" from air_runtime.todo_parser import parse_tasks tasks = parse_tasks(todo_path) graph = TaskGraph() for t in tasks: node = TaskNode( id=t.task_id, status=t.status, task=t.task, files_dirs=t.files_dirs, done_when=t.done_when, ) graph.add_node(node) return graph def enter_mode(project_root: Path) -> dict: paths = _paths(project_root) _ensure_dirs(paths) payload = {"enabled": True, "updatedAt": now_iso(), "projectRoot": str(project_root)} atomic_json_write(paths["state"], payload) return {"state_path": str(paths["state"])} def parallel_review_mode(project_root: Path, todo_path: Path) -> dict: paths = _paths(project_root) _ensure_dirs(paths) # 三阶段门控:仅在 confirmed 阶段允许写入 execution-plan.json gate = ArcPhaseGate(paths["state"]) if not gate.can_write_plan(): return { "blocked": True, "reason": f"arc phase is '{gate.current_phase}', must be 'confirmed' before generating plan", "currentPhase": gate.current_phase, } review = build_parallel_review(todo_path) review_json_path = paths["reviews_dir"] / "parallel-review.json" review_md_path = paths["reviews_dir"] / "parallel-review.md" atomic_json_write(review_json_path, review.to_dict()) review_md_path.write_text(render_review_markdown(review), encoding="utf-8") # 构建 DAG graph = _build_graph_from_todo(todo_path) for edge_info in review.to_dict().get("edges", []): graph.add_edge(Edge(source=edge_info["source"], target=edge_info["target"], kind=edge_info.get("kind", "dependency"))) _export_task_graph_json(graph, paths["task_graph_json"]) # 生成执行计划 selected_tasks = review.parallel_groups[0].task_ids if review.parallel_groups else [] execution_plan = { "generatedAt": now_iso(), "projectRoot": str(project_root), "todoPath": str(todo_path), "planSource": "airarc-post-plan-review", "parallelReview": review.to_dict(), "selectedTasks": selected_tasks, "parallelGroups": [g.to_dict() for g in review.parallel_groups], "conflicts": [c.to_dict() for c in review.conflicts], "serializationPoints": review.serialization_points, } atomic_json_write(paths["execution_plan_json"], execution_plan) markdown_lines = [ "# AirArc Execution Plan", "", f"- Generated: `{execution_plan['generatedAt']}`", f"- Plan Source: `{execution_plan['planSource']}`", "", "## Selected Tasks", ] for tid in selected_tasks: markdown_lines.append(f"- `{tid}`") markdown_lines.extend(["", "## Parallel Groups"]) for g in review.parallel_groups: markdown_lines.append(f"- `{g.name}`: {', '.join(g.task_ids)} — {g.reason}") paths["execution_plan_md"].write_text("\n".join(markdown_lines) + "\n", encoding="utf-8") atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso(), "projectRoot": str(project_root)}) return {"json_path": str(review_json_path), "markdown_path": str(review_md_path), "execution_plan_json_path": str(paths["execution_plan_json"]), "parallel_group_count": len(review.parallel_groups), "conflict_count": len(review.conflicts)} def incremental_replan_mode(project_root: Path, todo_path: Path, previous_graph_path: Path | None = None) -> dict: """增量重规划:产出 PlanDelta 并喂回 prev graph,再写 plan-delta.json。""" paths = _paths(project_root) _ensure_dirs(paths) review = build_parallel_review(todo_path) new_graph = _build_graph_from_todo(todo_path) for edge_info in review.to_dict().get("edges", []): new_graph.add_edge(Edge(source=edge_info["source"], target=edge_info["target"], kind=edge_info.get("kind", "dependency"))) delta = PlanDelta() if previous_graph_path and previous_graph_path.exists(): prev_graph = TaskGraph.load(previous_graph_path) delta = new_graph.diff(prev_graph) # 关键:把 delta 喂回去给 prev graph,保留已调度状态 if previous_graph_path.exists(): prev_graph.apply_delta(delta) _export_task_graph_json(prev_graph, previous_graph_path) else: delta.added_tasks = list(new_graph.nodes.values()) delta.edge_changes.added = list(new_graph.edges) atomic_json_write(paths["plan_delta_json"], { "generatedAt": now_iso(), "removedTasks": delta.removed_tasks, "addedTasks": [{"id": n.id, "task": n.task, "filesDirs": n.files_dirs, "doneWhen": n.done_when, "writeSet": n.write_set} for n in delta.added_tasks], "modifiedTasks": [{"id": n.id, "task": n.task, "filesDirs": n.files_dirs, "doneWhen": n.done_when, "writeSet": n.write_set} for n in delta.modified_tasks], "edgeChanges": { "added": [{"source": e.source, "target": e.target, "kind": e.kind} for e in delta.edge_changes.added], "removed": [{"source": e.source, "target": e.target, "kind": e.kind} for e in delta.edge_changes.removed], }, }) _export_task_graph_json(new_graph, paths["task_graph_json"]) log = EventLog(event_log_path(project_root)) log.emit("arc.replanned", {"delta_added": len(delta.added_tasks), "delta_removed": len(delta.removed_tasks), "delta_modified": len(delta.modified_tasks), "edges_added": len(delta.edge_changes.added), "edges_removed": len(delta.edge_changes.removed)}) return {"plan_delta_json_path": str(paths["plan_delta_json"]), "task_graph_json_path": str(paths["task_graph_json"]), "added_count": len(delta.added_tasks), "removed_count": len(delta.removed_tasks), "modified_count": len(delta.modified_tasks), "edges_added": len(delta.edge_changes.added), "edges_removed": len(delta.edge_changes.removed)} def main(args) -> None: project_root = Path(args.project).expanduser().resolve() sub = args.sub or "status" if sub == "enter": result = enter_mode(project_root) print("airplan_mode=arc") print(f"state_path={result['state_path']}") elif sub == "parallel-review": tpath = Path(args.todo).expanduser().resolve() if args.todo else get_todo_path(project_root) result = parallel_review_mode(project_root, tpath) print("airplan_mode=arc") print(f"json_path={result['json_path']}") print(f"parallel_group_count={result['parallel_group_count']}") print(f"conflict_count={result['conflict_count']}") elif sub == "incremental-replan": tpath = Path(args.todo).expanduser().resolve() if args.todo else get_todo_path(project_root) prev = _paths(project_root)["task_graph_json"] result = incremental_replan_mode(project_root, tpath, prev) print("airplan_mode=arc") print(f"plan_delta_path={result['plan_delta_json_path']}") print(f"added={result['added_count']} removed={result['removed_count']}") else: paths = _paths(project_root) state = safe_json_load(paths["state"]) or {} print(f"airplan_mode=arc") print(f"enabled={state.get('enabled', False)}")