Files
AirPlan-V2/lib/air_runtime/modes/arc_mode.py
AirLongDian 3dcb87f941 feat: INV-16 弱模型安全任务描述 — Arc 危险词检测 + 否定约束强制
问题:Do Worker 可能是廉价模型,字面理解任务。"清理旧产品实现"+src/ = 全删。
根因:Arc 用强模型规划,没考虑 Do Worker 推理能力弱。

修复:
- SKILL.md: 新增 INV-16 — 禁止歧义词、否定约束显式化、文件范围精确化、完成标准可验证
- commands/arc.md: 新增"弱模型安全"章节,4 条硬规则
- arc_mode.py: _audit_task_safety() 代码级扫描危险词和目录级文件范围
  - 危险词: 清理/清除/删除所有/重构整个/重写全部 等
  - 目录级范围(src/)+无否定约束 → 警告
  - 警告写入 execution-plan.json 的 safetyWarnings 字段
  - parallel_review_mode 在写 plan 前自动执行

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 09:36:28 +08:00

383 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
# INV-16: 弱模型安全 — 危险词列表
DANGEROUS_TASK_WORDS = [
"清理", "清除", "删除所有", "重构整个", "重写全部",
"clear", "delete all", "remove all", "rewrite entire", "refactor whole",
]
DIR_LEVEL_FILE_SCOPE_PATTERNS = [
"src/", "lib/", "include/", "tests/", "modules/",
]
def _audit_task_safety(task_id: str, task_text: str, files_dirs: str, done_when: str) -> list[str]:
"""INV-16: 扫描单个任务的危险词和宽泛文件范围,返回警告列表。"""
warnings = []
text_lower = task_text.lower()
for word in DANGEROUS_TASK_WORDS:
if word.lower() in text_lower:
warnings.append(f"[{task_id}] 含危险词 '{word}' — 建议改为精确描述(如'修改 CMakeLists.txt 去掉 sipclient 依赖'而非'清理{word}'")
for pattern in DIR_LEVEL_FILE_SCOPE_PATTERNS:
if pattern in files_dirs and not any(f.endswith(ext) for ext in [".cpp", ".hpp", ".h", ".py", ".ts", ".md", ".json", ".txt", ".cmake"] for f in files_dirs.split(",")):
if not done_when or "" not in done_when:
warnings.append(f"[{task_id}] 文件范围含目录级 '{pattern}' 但 done_when 无否定约束 — Worker 可能误解文件范围")
break
return warnings
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 = {
"dispatchFrozen": graph.dispatch_frozen, # P1-21
"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, "testRequired": n.test_required,
"adrRefs": n.adr_refs} # P1-21
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) -> tuple[TaskGraph, list[str]]:
"""从 todo.md 构建初始 DAG返回图和未满足 Done When 条件的任务列表。"""
from air_runtime.todo_parser import parse_tasks
tasks = parse_tasks(todo_path)
graph = TaskGraph()
violations = [] # P1-19.1: 记录 Done When 不含"测试通过"的任务
for t in tasks:
# P1-21: 从 todo.md ADR 列提取 adr_refs
adr_refs = []
if hasattr(t, "adr") and t.adr:
adr_refs = [a.strip() for a in t.adr.split(",") if a.strip()]
node = TaskNode(
id=t.task_id, status=t.status, task=t.task,
files_dirs=t.files_dirs, done_when=t.done_when,
adr_refs=adr_refs,
)
graph.add_node(node)
# P1-19.1: Done When 必须包含"测试通过"
if t.done_when and "测试通过" not in t.done_when:
violations.append(t.task_id)
# P1-19.1: 注入边界测试任务
_inject_boundary_tests(graph, tasks)
return graph, violations
def _inject_boundary_tests(graph: TaskGraph, tasks: list) -> None:
"""P1-19.1: 为每个模块边界注入测试任务。
规则:
- 每个模块的公共接口必须有对应的接口测试任务
- 每个模块的核心逻辑必须有对应的单元测试任务
- 测试任务标记 test_required=True
- Done When 必须包含"测试通过"
"""
# 从 tasks 提取模块信息(通过 files_dirs 推断模块)
module_files: dict[str, set[str]] = {}
for t in tasks:
if t.files_dirs:
for fd in t.files_dirs.split(","):
fd = fd.strip()
if fd:
# 取第一级目录作为模块名
parts = fd.split("/")
if len(parts) > 1:
module = parts[0]
else:
module = fd.split(".")[0] if "." in fd else fd
module_files.setdefault(module, set()).add(fd)
# 为每个模块创建测试任务
test_counter = 0
for module, files in module_files.items():
if not files:
continue
# 接口测试任务(模块边界)
test_counter += 1
interface_test_id = f"T-TEST-{test_counter:03d}"
interface_test_node = TaskNode(
id=interface_test_id,
status="TODO",
task=f"[边界测试] {module} 模块接口测试",
files_dirs=",".join(sorted(files)),
done_when="测试通过",
test_required=True,
)
graph.add_node(interface_test_node)
# 单元测试任务(核心逻辑)
test_counter += 1
unit_test_id = f"T-TEST-{test_counter:03d}"
unit_test_node = TaskNode(
id=unit_test_id,
status="TODO",
task=f"[单元测试] {module} 模块核心逻辑测试",
files_dirs=",".join(sorted(files)),
done_when="测试通过",
test_required=True,
)
graph.add_node(unit_test_node)
# 添加依赖边:实现任务 → 测试任务
for tid, node in graph.nodes.items():
if tid.startswith("T-TEST-"):
continue
# 检查是否属于同一模块
node_files = set(node.files_dirs.split(",")) if node.files_dirs else set()
if node_files & files: # 有交集,说明是同一模块的任务
graph.add_edge(Edge(source=tid, target=interface_test_id, kind="dependency"))
graph.add_edge(Edge(source=tid, target=unit_test_id, kind="dependency"))
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, done_when_violations = _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")))
# INV-16: 弱模型安全审计 — 扫描所有 TODO 任务的危险词和宽泛文件范围
safety_warnings = []
for nid, node in graph.nodes.items():
if node.status == "TODO":
safety_warnings.extend(
_audit_task_safety(nid, node.task, node.files_dirs, node.done_when)
)
_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,
"doneWhenViolations": done_when_violations, # P1-19.1: Done When 不含"测试通过"的任务
"boundaryTestTasks": [n.id for n in graph.nodes.values() if n.test_required],
"safetyWarnings": safety_warnings, # INV-16: 弱模型安全警告
}
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")
# 保留 arcPhase 字段,避免 phase 被 reset 为 discussing
existing_state = safe_json_load(paths["state"]) or {}
state_payload = {**existing_state, "enabled": True, "updatedAt": now_iso(),
"projectRoot": str(project_root)}
atomic_json_write(paths["state"], state_payload)
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),
"done_when_violations": done_when_violations,
"boundary_test_task_count": len([n for n in graph.nodes.values() if n.test_required])}
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)}")