feat: P1-19 P1-20 实现 - 边界测试强制 + 高风险审计 + UI Skill 路由

P1-19.1: Arc 边界测试强制
- TaskNode 新增 test_required 字段
- _inject_boundary_tests() 为每个模块注入接口测试和单元测试任务
- Done When 验证必须包含"测试通过"

P1-19.2: AirRvr 高风险审计
- 新增 HighRiskAudit, HighRiskFinding 数据类
- ReviewReport 新增 highRiskAudit 字段,含 lifecycle/nullPointer/danglingPointer/exceptionSafety/concurrency + overallRisk + deliveryVerdict
- 序列化/反序列化支持

P1-19.3: block-release 集成
- dispatch_worker_group() 派发前扫描最新审查报告
- deliveryVerdict=block-release 时阻止所有后续派发
- 记录 eng.blocked 事件

P1-20: frontend-design Skill 集成
- is_ui_task() UI 任务检测
- ensure_frontend_design_skill() 自动安装 Skill
- route_ui_task() UI 任务路由决策
- enter_worker() 集成 UI 检测,skill 不可用时阻止执行
- commands/do.md 更新 UI 处理说明
- SKILL.md 新增 INV-12/INV-13/INV-14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AirLongDian
2026-06-10 17:06:48 +08:00
commit e73a4da354
54 changed files with 6054 additions and 0 deletions

View File

@@ -0,0 +1,222 @@
"""
动态任务依赖图DAG— V2 P1-14 修复。
替代 V1 静态 todo.md 表格,支持 Arc 增量重规划Eng 增量吸收。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class TaskNode:
id: str
status: str = "TODO" # TODO | DISPATCHED | DONE | BLOCKED
task: str = ""
files_dirs: str = ""
done_when: str = ""
in_degree: int = 0
out_edges: list[str] = field(default_factory=list)
write_set: list[str] = field(default_factory=list)
meta: dict[str, Any] = field(default_factory=dict)
test_required: bool = False # P1-19.1: 边界测试强制标记
@dataclass
class Edge:
source: str
target: str
kind: str = "dependency" # dependency | conflict | sync
@dataclass
class EdgeChange:
added: list[Edge] = field(default_factory=list)
removed: list[Edge] = field(default_factory=list)
@dataclass
class PlanDelta:
"""Arc 重规划产出的增量差异,替代全量覆盖 todo.md。"""
removed_tasks: list[str] = field(default_factory=list)
added_tasks: list[TaskNode] = field(default_factory=list)
modified_tasks: list[TaskNode] = field(default_factory=list)
edge_changes: EdgeChange = field(default_factory=EdgeChange)
class TaskGraph:
"""动态任务依赖图,支持增量更新和全量替换。"""
def __init__(self):
self.nodes: dict[str, TaskNode] = {}
self.edges: list[Edge] = []
def add_node(self, node: TaskNode) -> None:
self.nodes[node.id] = node
def add_edge(self, edge: Edge) -> None:
self.edges.append(edge)
if edge.target in self.nodes:
self.nodes[edge.target].in_degree += 1
if edge.source in self.nodes:
self.nodes[edge.source].out_edges.append(edge.target)
def apply_delta(self, delta: PlanDelta) -> None:
"""增量吸收 Arc 的重规划结果,保留已调度任务不受影响。"""
for task_id in delta.removed_tasks:
self._remove_node(task_id)
for node in delta.added_tasks:
self._add_node(node)
for node in delta.modified_tasks:
self._update_node(node)
for edge in delta.edge_changes.removed:
self._remove_edge(edge)
for edge in delta.edge_changes.added:
self._add_edge(edge)
def apply_full_replace(self, nodes: list[TaskNode], edges: list[Edge]) -> None:
"""全量替换模式Arc 产出完整 DAG保留已完成任务状态。"""
done_status = {tid: n.status for tid, n in self.nodes.items() if n.status == "DONE"}
self.nodes = {n.id: n for n in nodes}
self.edges = list(edges)
for tid, status in done_status.items():
if tid in self.nodes:
self.nodes[tid].status = status
for edge in self.edges:
if edge.target in self.nodes:
self.nodes[edge.target].in_degree += 1
if edge.source in self.nodes:
self.nodes[edge.source].out_edges.append(edge.target)
def ready_tasks(self) -> list[str]:
"""返回当前入度为 0 且状态为 TODO 的任务。"""
return [nid for nid, n in self.nodes.items() if n.in_degree == 0 and n.status == "TODO"]
def diff(self, other: TaskGraph) -> PlanDelta:
"""对比自身与 other产出 PlanDeltaadd/remove/modify node + edge changes
self = 新图, other = 旧图before replan
"""
delta = PlanDelta()
old_ids = set(other.nodes.keys())
new_ids = set(self.nodes.keys())
# 移除
delta.removed_tasks = list(old_ids - new_ids)
# 新增
delta.added_tasks = [self.nodes[tid] for tid in (new_ids - old_ids)]
# 修改
for tid in old_ids & new_ids:
old_n = other.nodes[tid]
new_n = self.nodes[tid]
if (old_n.task != new_n.task
or old_n.files_dirs != new_n.files_dirs
or old_n.done_when != new_n.done_when
or old_n.write_set != new_n.write_set):
delta.modified_tasks.append(new_n)
# Edge 差异
old_edges = {(e.source, e.target, e.kind) for e in other.edges}
new_edges = {(e.source, e.target, e.kind) for e in self.edges}
for s, t, k in (new_edges - old_edges):
delta.edge_changes.added.append(Edge(source=s, target=t, kind=k))
for s, t, k in (old_edges - new_edges):
delta.edge_changes.removed.append(Edge(source=s, target=t, kind=k))
return delta
@classmethod
def load(cls, path) -> TaskGraph:
"""从 _export_task_graph_json 写的格式还原 TaskGraph。"""
from pathlib import Path
from air_runtime.io import safe_json_load
p = Path(path)
data = safe_json_load(p)
graph = cls()
if not data or not isinstance(data, dict):
return graph
for nid, nd in data.get("nodes", {}).items():
graph.nodes[nid] = TaskNode(
id=nd.get("id", nid),
status=nd.get("status", "TODO"),
task=nd.get("task", ""),
files_dirs=nd.get("filesDirs", ""),
done_when=nd.get("doneWhen", ""),
in_degree=nd.get("inDegree", 0),
out_edges=list(nd.get("outEdges", [])),
write_set=list(nd.get("writeSet", [])),
test_required=nd.get("testRequired", False),
)
for ed in data.get("edges", []):
graph.edges.append(Edge(
source=ed["source"], target=ed["target"],
kind=ed.get("kind", "dependency"),
))
return graph
def task_ids_by_status(self, status: str) -> list[str]:
return [nid for nid, n in self.nodes.items() if n.status == status]
def find_cycles(self) -> list[list[str]]:
"""检测依赖环DFS"""
visited: set[str] = set()
rec_stack: set[str] = set()
cycles: list[list[str]] = []
def dfs(node_id: str, path: list[str]) -> None:
visited.add(node_id)
rec_stack.add(node_id)
path.append(node_id)
for target in self.nodes.get(node_id, TaskNode(id=node_id)).out_edges:
if target not in visited:
dfs(target, path.copy())
elif target in rec_stack:
cycle_start = path.index(target)
cycles.append(path[cycle_start:])
rec_stack.discard(node_id)
for nid in self.nodes:
if nid not in visited:
dfs(nid, [])
return cycles
def export_todo_md(self) -> str:
"""导出为人可读的 todo.md 表格,保留 V1 的可见性优势。"""
lines = ["| Task | Status | Files/Dirs | Done When | Validation | ADR |",
"|------|--------|------------|-----------|------------|-----|"]
for nid, node in self.nodes.items():
lines.append(f"| {node.task} | {node.status} | {node.files_dirs} | "
f"{node.done_when} | | |")
return "\n".join(lines) + "\n"
def _remove_node(self, task_id: str) -> None:
if task_id in self.nodes:
del self.nodes[task_id]
self.edges = [e for e in self.edges if e.source != task_id and e.target != task_id]
def _add_node(self, node: TaskNode) -> None:
self.nodes[node.id] = node
def _update_node(self, node: TaskNode) -> None:
if node.id in self.nodes:
existing_status = self.nodes[node.id].status
self.nodes[node.id] = node
if existing_status in ("DISPATCHED", "DONE"):
self.nodes[node.id].status = existing_status
def _remove_edge(self, edge: Edge) -> None:
self.edges = [e for e in self.edges
if not (e.source == edge.source and e.target == edge.target)]
if edge.target in self.nodes:
self.nodes[edge.target].in_degree = max(0, self.nodes[edge.target].in_degree - 1)
def _add_edge(self, edge: Edge) -> None:
self.edges.append(edge)
if edge.target in self.nodes:
self.nodes[edge.target].in_degree += 1
if edge.source in self.nodes:
self.nodes[edge.source].out_edges.append(edge.target)