Merges 8 V1 plugins into 1 unified plugin, adds 4 new components (dep, tst, sec, rvr). 12 sub-modes: arc, eng, do, dbg, xdb, sdb, ndb, ctx, dep, tst, sec, rvr. L1 code-level guarantees: atomic writes, file locks, evidence gating, forced debug routing, 3-phase arc gate, evidence-first debug gate, Chinese language lock, scheduler boundary. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
259 lines
11 KiB
Python
259 lines
11 KiB
Python
#!/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)}")
|