AirPlan V2 initial release — unified scheduler with 12 sub-modes

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>
This commit is contained in:
AirPlan Team
2026-06-10 16:24:26 +08:00
commit 2c4b3340bf
81 changed files with 6005 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
{
"name": "airplan-v2",
"version": "2.0.0",
"description": "AirPlan V2 — 统一制品驱动开发调度器。单一插件整合规划(arc)、执行(do)、调试(dbg)、GUI验证(xdb)、静态分析(sdb)、网络调试(ndb)、部署(dep)、测试(tst)、安全扫描(sec)、需求审查(rvr)。L1代码级保障。",
"author": {
"name": "AirPlan Team",
"email": "noreply@airlongdian.fun",
"url": "https://airlongdian.fun"
},
"homepage": "https://airlongdian.fun/plugins/airplan-v2",
"repository": "https://airlongdian.fun/plugins/airplan-v2",
"license": "MIT",
"keywords": [
"airplan",
"scheduler",
"orchestrator",
"debugger",
"static-analysis",
"deployment",
"testing",
"security",
"v2"
],
"skills": "./skills/",
"commands": "./commands/",
"interface": {
"displayName": "AirPlan V2",
"shortDescription": "统一制品驱动开发调度器 (12子模式)",
"longDescription": "V2 将 V1 的 8 个独立插件合并为 1 个统一插件,新增 4 个组件(部署/测试/安全/需求审查),共 12 个子模式arc(架构)、eng(调度)、do(执行)、dbg(调试)、xdb(GUI)、sdb(静态)、ndb(网络)、ctx(上下文)、dep(部署)、tst(测试)、sec(安全)、rvr(需求)。L1代码级保障不依赖LLM自觉。",
"developerName": "AirPlan Team",
"category": "Productivity",
"capabilities": [
"Interactive",
"Write",
"Bash"
],
"websiteURL": "https://airlongdian.fun/plugins/airplan-v2",
"privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/",
"termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/",
"defaultPrompt": [
"使用 AirPlan V2 初始化项目规划上下文",
"使用 AirPlan V2 运行架构规划 (arc)、调度引擎 (eng)、任务执行 (do)",
"使用 AirPlan V2 进行调试 (dbg)、GUI验证 (xdb)、静态分析 (sdb)",
"使用 AirPlan V2 部署 (dep)、测试 (tst)、安全扫描 (sec)、需求审查 (rvr)"
],
"brandColor": "#6366F1",
"screenshots": []
},
"modes": {
"arc": "架构规划器 — 分析依赖、写集冲突、产出执行计划",
"eng": "调度引擎 — 波次派发、监控、合并、修复编排",
"do": "任务执行器 — 单任务切片执行与结果生成",
"dbg": "调试器 — 7步调试工作流、根因分析、修复回滚",
"xdb": "GUI验证器 — 截图取证、GUI操作验证、DRM/KMS截图",
"sdb": "静态分析器 — 多语言静态分析、diff模式",
"ndb": "网络调试器 — 抓包分析、TLS解密、远程探测",
"ctx": "上下文管理器 — 压缩、Token估算、锁检测",
"dep": "部署器 — SSH远程构建、部署、systemd管理",
"tst": "测试运行器 — 统一测试执行与结果报告",
"sec": "安全扫描器 — 敏感数据检测与脱敏",
"rvr": "需求审查器 — 交付物与需求一致性验证"
},
"requirements": {
"python": ">=3.10",
"system": ["git", "fcntl"]
}
}

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.pyo
.bak
*.tmp
*.lock
.DS_Store

209
airplanV2审查后.md Normal file
View File

@@ -0,0 +1,209 @@
# AirPlan V2 设计文档 — OPUS 4.8 审查报告
> **原文档**: airplanV2-Qwen3.7-Max设计.md (Draft 0.1, 2026-06-09)
> **审查日期**: 2026-06-09
> **审查结论**: 整体架构方向正确,设计深度足够,存在 12 项需修正问题和 8 项需补充内容
---
## 一、需修正问题
### 1.1 编号错误:两个 3.7.4
Section 3.7 中 AirRvr 和事件索引层均编号为 `3.7.4`。事件索引层应为 `3.7.5`
### 1.2 AirArc 工具限制配置格式不对
3.2.6 中给出了 JSON 格式的工具白名单配置:
```json
{
"allowed_tools": ["Read", "Glob", "Grep"],
"denied_tools": ["Write", "Edit", "Bash", "NotebookEdit"],
"deny_plan_mode": true
}
```
Claude Code 的 SKILL.md 使用 YAML frontmatter 定义工具限制,格式应为:
```yaml
---
name: AirArc
allowed-tools: [Read, Glob, Grep]
---
```
不存在 `denied_tools``deny_plan_mode` 字段(这两个是虚构的 API。实际的 plan mode 阻断依赖 SKILL.md 中的强制性文本指令 + `allowed-tools` 白名单双重保障。文档中的伪 JSON 配置块应修正为实际可用的 YAML frontmatter。
### 1.3 P0-7 修复方案核心仍为 Prompt 指令,非代码级保障
P0-7AirEng 无子线程状态轮询的修复方案3.2.8)主体是一个 SKILL.md 中的伪代码循环指令,依赖 LLM 自觉执行。尽管末尾有「技术补充engine monitor_engine() 增加 wall-clock 超时检测」,但:
- wall-clock 超时只是兜底检测2小时硬上限不能替代 5 分钟级的状态轮询
- 真正解决 P0-7 需要在 `engine.py` 中增加代码级定时轮询循环(如 `while` 循环 + `time.sleep`),而非依赖 prompt 指令
- 建议将 3.2.8 的主体从 SKILL.md 文本改为 `engine.py` 中的 `monitor_engine()` 重构SKILL.md 指令仅作为辅助提醒
### 1.4 Phase 2 实现 PlanDelta 消费者,但生产者到 Phase 4 才实现
3.2.11 的 `TaskGraph.apply_delta()` 在 Phase 2 (T-2.9) 实现但其数据来源——AirArc 的增量重规划(产出 `PlanDelta`)——在 Phase 4 (T-4.6) 才实现。这意味着 Phase 2~4 期间,`apply_delta()` 没有真正的生产者。建议:
- 将 AirArc 增量重规划从 Phase 4 提前到 Phase 2与 T-2.9 同步交付;或
- Phase 2 先实现全量 replace 模式Arc 产出完整 DAGEng 整体替换Phase 4 再升级为增量 delta 模式
### 1.5 区域冲突检测未定义「区域」概念
3.2.12 的 `RegionConflictDetector._check_region_overlap()` 是核心判断逻辑,但设计中没有定义什么是「区域」、如何从任务中提取区域信息、两个区域如何判重叠。这是 worktree 隔离并行的关键前提,不能留空。建议至少明确:
- 区域定义来源:函数边界 / 类定义 / CSS 选择器块 / 标记注释分隔
- 区域信息由谁提供Arc 规划时静态分析 / Worker 执行前动态分析 / todo.md 中的区域标注
- 重叠判据:行号区间重叠 / AST 节点冲突 / 文本 diff 冲突
### 1.6 Token 估算比率命名容易误解
3.3.2 中 `RATIOS` 字典的值实际含义是「每 token 对应的字符数」,但命名 `RATIOS` 和注释「中文字符 → token」暗示反向。建议重命名为 `CHARS_PER_TOKEN` 并在注释中明确。
### 1.7 AirDbg 快照可能包含无关变更
3.5.2 的 `pre_fix_snapshot` 使用 `git commit -am` 提交所有修改。在无 worktree 隔离的场景下,这会混入其他并行任务的文件变更,导致回滚时误伤。建议改为仅提交当前任务写集范围内的文件,或仅在 worktree 隔离模式下使用。
### 1.8 AirTst 结果格式不支持嵌套测试结构
3.7.2 的结果格式为扁平结构,但 GoogleTest 有 test suite → test case 层级pytest 有 module → class → function 层级。建议扩展为:
```json
{
"framework": "googletest",
"totalTests": 128, "passed": 127, "failed": 0, "disabled": 1,
"duration": "4.2s",
"suites": [
{
"name": "NetworkTest",
"totalTests": 32, "passed": 32, "failed": 0,
"testCases": [
{"name": "ConnectTimeout", "status": "passed", "duration": "0.05s"}
]
}
],
"failures": []
}
```
### 1.9 KMS 截图硬编码 sudo
3.6.1 中 `KmsGrabCapture` 直接使用 `sudo ffmpeg`,这要求密码免密 sudo 配置,在生产环境中是安全隐患。建议增加权限检测和降级策略:
- 检测当前用户是否有 `/dev/dri/card0` 读写权限
- 无权限时提示用户配置 udev 规则或将用户加入 video 组,而非直接 sudo
- sudo 仅作为显式 opt-in 的 fallback
### 1.10 迁移策略未覆盖 todo.md → DAG 过渡
Section 5 的迁移策略讨论了 state.json 的兼容性但未涉及最核心的格式变迁——todo.md 被 TaskGraph 替代。现有项目的 todo.md 如何转换为 DAG转换后人类如何查看/编辑任务状态todo.md 的双重角色:机器可读 + 人类可读)?建议:
- 保留 todo.md 作为人类可读视图DAG 作为内部调度结构
- 提供 `todo.md → DAG` 导入器和 `DAG → todo.md` 导出器
- 或明确声明 V2 放弃人类直接编辑 todo 的便利性,改为通过 AirArc 间接操作
### 1.11 度量指标缺少量化手段
Section 7 中部分指标无法客观测量如「AirEng 非中文输出」「AirEng 轮询遗忘」「AirDo 跳过 AirDbg」的目标值设为 0 次,但当前没有机制检测这些行为是否发生。建议为每个指标定义检测方法:
- 非中文输出:事件日志中记录 AirEng 输出语言,正则检测非中文字符比例
- 轮询遗忘monitor_engine 事件日志的时间间隔分析,超过 10 分钟无轮询记录即为遗忘
- 跳过 AirDbg事件日志中 blocked→merge 路径无 debug.session 事件即为跳过
### 1.12 worktree 合并冲突处理缺失
3.2.12 描述了 SOFT 冲突时创建 worktree 并行执行,完成后 `git merge` 回主分支。但未定义合并冲突时的处理流程。两个 worktree 即使修改同一文件的不同区域git merge 仍可能因相邻行冲突而失败。建议增加合并冲突升级策略:
```
MergeResult.CONFLICT → 自动升级到 AirDbg 解决 → 仍失败则降级为串行重执行
```
---
## 二、需补充内容
### 2.1 插件发现与加载机制
文档新增了 AirDep、AirTst、AirSec、AirRvr 四个插件但未说明引擎如何发现和加载它们。V1 的插件加载机制是什么V2 是否保持一致?建议在 3.7 新增一节说明插件注册规范。
### 2.2 AirRvr 的成本模型
AirRvr 对每个任务运行独立的 LLM 审查(加载需求文档 + diff + 验证证据Token 消耗可能远超任务执行本身。以 100 任务项目为例AirRvr 全量审查的 API 成本需要估算。建议:
- 定义审查触发策略:默认逐任务,可配置为波次审查或里程碑审查
- 给出不同模式下的 Token 消耗估算
- 考虑轻量审查模式(仅对比 Done When 文本,不加载完整 diff
### 2.3 环境修复持久化方案
P3-4环境特定修复不可持久化在问题列表和痛点汇总中出现多次但在 Phase 实施计划中无对应修复任务。建议在 Phase 2 或 3 增加 T-X.Y「运维模式库持久化环境修复脚本支持跨重启自动应用」。
### 2.4 AirEng 与 AirRvr 的集成协议
AirRvr 的审查结果pass / conditional-pass / fail需要集成到 AirEng 的 merge 流程中但文档未定义两者之间的接口协议。3.7.4 第 5 点仅概念性描述,缺少:
- AirEng 如何调用 AirRvr文件系统 / 函数调用)
- AirRvr 审查的超时和失败处理(审查本身卡死怎么办)
- 审查结果与修复预算的关系fail 后重试几次)
### 2.5 事件日志的保留策略
3.7.5 的事件日志是 JSONL 格式无限追加,与 state.json 的 P2-2列表无界增长面临同样问题。建议定义事件日志的轮转/截断策略。
### 2.6 安全扫描的误报处理
AirSec3.7.3发现敏感数据时「阻止合并并通知用户」但没有误报处理机制。静态扫描的正则匹配容易产生误报如代码中的示例密钥、Base64 编码的二进制数据)。建议增加:
- 白名单机制(已知安全的值、测试 fixture
- 人工确认流程(首次发现时通知用户判断,后续同模式自动放行)
- AirSec 的判定为 advisory 而非 blocking可配置
### 2.7 并发度可配置的具体机制
T-2.7 提到「并发度可配置」,但未说明配置方式。是通过配置文件?环境变量?引擎命令行参数?`engine.py:527` 的硬编码常量替换为什么?建议明确。
### 2.8 AirContext 压缩失败后的降级路径
3.3.1 提到压缩验证失败后「重试一次,仍失败则放弃压缩并通知用户」。但放弃压缩意味着上下文持续增长直到超过模型窗口,最终导致任务失败。建议增加三级降级:
1. 重试(换 prompt
2. 换模型压缩(如切换到更便宜的模型做摘要)
3. 激进截断(保留最近 N 轮 + ADR 引用,丢弃中间轮次)
---
## 三、架构层面评论
### 3.1 Prompt 指令 vs 代码保障的边界
文档中有多处将 prompt/SKILL.md 文本指令作为 P0 级缺陷的主要修复手段P0-5 AirArc 阻断、P0-6 中文锁定、P0-7 轮询循环、P0-8 AirDbg 强制路由)。这些 prompt 级修复在 LLM 遵循度足够高时有效,但不构成硬性保障。
建议在文档中明确标注每项修复的保障层级:
- **L1 代码级**:由引擎代码强制执行,不依赖 LLM 行为
- **L2 指令级**:由 SKILL.md 指令约束LLM 可能偏离
- **L3 建议级**:最佳实践文档,无强制机制
对于 P0 级缺陷,修复应至少达到 L1 或 L1+L2 双重保障。当前 P0-7 的修复仅为 L2应升级。
### 3.2 DAG 调度与人类可读性的平衡
TaskGraph 替代静态 todo.md 表格是架构上正确的方向,但会牺牲 V1 的核心优势之一:任何人都可以用文本编辑器打开 todo.md 了解项目状态。建议在 DAG 之上保持 todo.md 作为导出视图(非调度数据源),确保人类可读性不丢失。
### 3.3 Phase 优先级合理性
Phase 1 集中修复 P0 缺陷,方向正确。但 T-1.10~T-1.13 均为 prompt 指令修改0.5d 每项实际工作量可能被低估——prompt 调优往往需要多轮测试验证,而非一次性编写。建议将 0.5d 调整为 1d 或合并为 2 个任务AirArc/AirEng 指令重写 + AirDo/AirDbg 路由重写)。
---
## 四、审查总结
| 类别 | 数量 | 严重程度 |
|------|------|---------|
| 需修正(事实性错误或设计缺陷) | 12 | 1.1/1.2 为事实错误,其余为设计需要完善 |
| 需补充(缺失内容) | 8 | 影响完整性和可实施性 |
| 架构评论 | 3 | 建议性,非阻塞 |
**总体评价**:文档对 V1 问题的诊断全面准确V2 的架构改进方向正确且设计深度足够。12 项修正是实施前应解决的前置条件8 项补充可在实施过程中逐步完善。推荐在修正 1.1~1.12 后进入 Phase 1 实施。

46
commands/airplan.md Normal file
View File

@@ -0,0 +1,46 @@
---
description: "AirPlan V2 - unified scheduler with 12 modes"
argument-hint: "[arc|eng|do|dbg|xdb|sdb|ndb|ctx|dep|tst|sec|rvr] [sub-command]"
allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]"
---
# /airplan
AirPlan V2 - unified scheduler. V1 8 plugins merged into 1, adds 4 components, total 12 sub-modes.
## Sub-modes
| Mode | Role | Key Files |
|------|------|-----------|
| arc | Architecture planner | execution-plan.json, task-graph.json |
| eng | Scheduler engine | state/aireng/state.json |
| do | Task executor | state/airdo/tasks/{task_id}/result.json |
| dbg | Debugger | state/airdbg/sessions/*.json |
| xdb | GUI validator | state/airxdb/artifacts/ |
| sdb | Static analyzer | state/airsdb/reports/ |
| ndb | Network debugger | state/airndb/captures/ |
| ctx | Context manager | state/aircontext/ |
| dep | Deployer | state/airdep/sessions/ |
| tst | Test runner | state/airtst/reports/ |
| sec | Security scanner | state/airsec/ |
| rvr | Requirements reviewer | state/airrvr/reviews/ |
## Usage
1. Parse $ARGUMENTS; default to "status" when empty.
2. Run from project root:
```bash
python "$HOME/plugins/airplan/scripts/airplan.py" --mode <MODE> --project . [OPTIONS]
```
3. Common sub-commands:
- arc: --sub enter|status|parallel-review|incremental-replan --todo AirPlan/todo.md
- eng: --sub enter|status|plan|dispatch|monitor|merge|intervene
- do: --sub enter|status|finish --task-id <id> --result <path>
- dbg: --sub start|snapshot --task-id <id>
- dep: --sub deploy --task-id <id> --host <host> --binary <path>
- tst: --sub run --task-id <id> --framework <name>
- sec: --sub scan --task-id <id> --scan-path <path>
4. Runtime auto-bootstraps missing AirPlan/ files.

49
commands/arc.md Normal file
View File

@@ -0,0 +1,49 @@
---
description: "AirPlan arc - architecture planner, READ-ONLY, never writes code"
argument-hint: "[enter|status|parallel-review|incremental-replan]"
allowed-tools: "[Read, Glob, Grep]"
deny-plan-mode: true
---
# /arc
AirArc is a pure architecture planner. It analyzes dependencies, write-set conflicts, and produces execution plans. It NEVER writes code, modifies source files, or enters plan mode.
## Hard Rules (violated = bug)
1. **READ-ONLY** — you only Read, Glob, Grep. Never Write, Edit, Bash.
2. **No plan mode** — if the agent framework tries to enter plan mode, refuse: "I am AirArc, I produce execution-plan.json, not code changes."
3. **Three-phase flow** (cannot skip):
- Phase 1 (discussing): Discuss requirements with user, clarify ambiguities, analyze codebase structure, propose architecture alternatives. **Forbidden to write execution-plan.json.**
- Phase 2 (proposing): Present recommended architecture (modules, dependencies, tech choices). Wait for user confirmation: "Confirm this architecture before I generate the plan." User objections return to Phase 1. **Forbidden to write execution-plan.json.**
- Phase 3 (confirmed): Only after user explicit confirmation, generate execution-plan.json and task-graph.json.
## Sub-commands
### enter
```bash
python "$HOME/plugins/airplan/scripts/airplan.py" --mode arc --project . --sub enter
```
### status
```bash
python "$HOME/plugins/airplan/scripts/airplan.py" --mode arc --project . --sub status
```
### parallel-review
```bash
python "$HOME/plugins/airplan/scripts/airplan.py" --mode arc --project . --sub parallel-review --todo AirPlan/todo.md
```
### incremental-replan
```bash
python "$HOME/plugins/airplan/scripts/airplan.py" --mode arc --project . --sub incremental-replan --todo AirPlan/todo.md
```
## Logging Standard
When planning C++ projects, the first task MUST be "integrate spdlog" if not already present. All generated code must use spdlog, not std::cout/qDebug/printf.

87
commands/eng.md Normal file
View File

@@ -0,0 +1,87 @@
---
description: "AirPlan eng - scheduler engine, dispatches isolated workers, never codes directly"
argument-hint: "[run|status|plan|dispatch|monitor|merge|intervene]"
allowed-tools: "[Read, Glob, Grep, Bash, Write, Edit]"
---
# /eng
AirEng 是调度引擎,不是执行器。它派发隔离 Worker、监控、合并结果。绝不直接实现代码。
## 硬规则(违反=bug
1. **Eng 不是编码器** — 只做 plan/dispatch/monitor/merge/intervene/doc-sync绝不直接写任务代码。
2. **必须通过 /do 派发** — 每个任务必须 spawn 隔离的 /do 子代理fork_context=false
3. **保持父线程精简** — 父线程只做调度操作。
4. **直接写代码唯一例外** — Worker 硬阻塞无法自恢复时的紧急干预,干预后立即回到调度模式。
5. **必须使用中文** — 所有状态报告、进度通知、问题描述均使用中文。禁止英文输出。
6. **自主决策原则** — 以推进开发进度为第一目标,以下情况自行决策不停下来问用户:
- Worker blocked 但修复预算未耗尽 → 自行派发修复
- Worker 停滞 → 自行执行停滞干预
- 验证失败但非关键 → 记录问题继续下一任务
- 波次间衔接 → 自行启动下一波次
7. **仅以下情况才询问用户**:修复预算耗尽且任务仍 blocked需求歧义无法继续系统资源耗尽用户显式暂停。
## 子命令
### run (默认) — 一次完整调度步骤
```bash
python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub status
```
然后:
- 无活跃波次 → 派发下一波次
- 有活跃 Worker → 监控,不要盲目重新派发
- 每个 dispatched 任务 spawn 一个 /do 子代理fork_context=false
- 只传递项目路径+任务 handoff 内容,不要 fork 完整父对话
- 最多 recommendedConcurrency 个 Worker 同时活跃
- 就绪结果出现时merge through `--sub merge --result <path>`
### status
```bash
python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub status
```
### plan
```bash
python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub plan --todo AirPlan/todo.md
```
### dispatch
```bash
python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub dispatch
```
读取 AirPlan/state/aireng/dispatch/ 中的派发清单,为每个任务 spawn 一个隔离的 /do Worker 子代理。
### monitor
```bash
python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub monitor
```
合并就绪结果,检测停滞 Worker更新 nextAction。
### merge
```bash
python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub merge --result <result-json>
```
### intervene
```bash
python "$HOME/plugins/airplan/scripts/airplan.py" --mode eng --project . --sub intervene
```
仅用于无法通过常规监控或重新派发解决的硬阻塞。
## 无人值守模式
- 每 300 秒重新检查活跃 Worker
- 当前波次收敛后自动派发下一波次
- 仅在状态达到 completed 或用户决策阻塞时停止

View File

@@ -0,0 +1,20 @@
# air_runtime — AirPlan V2 核心运行时库
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.lock import FileLock
from air_runtime.utils import ordered_unique, session_stamp, normalize_policy, sanitize_task_id, sanitize_marker
from air_runtime.events import EventLog
from air_runtime.task_graph import TaskGraph, PlanDelta
from air_runtime.worktree import WorktreeIsolation, RegionConflictDetector, ConflictLevel
from air_runtime.evidence_gate import EvidenceGatePolicy, EvidenceClass
from air_runtime.contracts import now_iso, WorkerResult, TaskRecord, DeploymentRecord
from air_runtime.paths import (
airplan_root, todo_path, plan_path,
state_root, engine_state_path, worker_state_path,
arc_state_path, dbg_state_path, xdb_state_path,
sdb_state_path, ndb_state_path, ctx_state_path,
dep_state_path, tst_state_path, sec_state_path, rvr_state_path,
required_project_artifacts,
)
__version__ = "2.0.0"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,109 @@
"""
数据契约 — 保持 V1 契约完整性,新增 DeploymentRecord、AirRvr 审查报告等结构。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass
class WorkerResult:
task_id: str
status: str # done | blocked | failed
summary: str = ""
files_changed: list[str] = field(default_factory=list)
validations: list[dict] = field(default_factory=list)
document_updates: list[dict] = field(default_factory=list)
evidence: list[dict] = field(default_factory=list)
risks: list[str] = field(default_factory=list)
blockers: list[str] = field(default_factory=list)
deploy_required: bool = False
deploy_info: dict | None = None
def validate_for_finalize(self, brief: dict | None = None) -> None:
if not re.fullmatch(r"[A-Za-z0-9_\-\.]+", self.task_id):
raise ValidationError(f"invalid task_id: {self.task_id!r}")
if self.status not in ("done", "blocked", "failed"):
raise ValidationError(f"invalid status: {self.status}")
if self.status == "done" and not self.validations and not self.files_changed:
raise ValidationError("done without validations or file changes")
if self.deploy_required:
deploy_validations = [
v for v in self.validations
if v.get("kind") in ("remote-deploy-verify", "remote-binary-md5")
]
if not deploy_validations:
raise ValidationError("deploy_required but no deploy verification in validations")
def to_dict(self) -> dict[str, Any]:
return {
"taskId": self.task_id,
"status": self.status,
"summary": self.summary,
"filesChanged": self.files_changed,
"validations": self.validations,
"documentUpdates": self.document_updates,
"evidence": self.evidence,
"risks": self.risks,
"blockers": self.blockers,
"deployRequired": self.deploy_required,
"deployInfo": self.deploy_info,
}
@classmethod
def from_dict(cls, data: dict) -> WorkerResult:
return cls(
task_id=data.get("taskId", ""),
status=data.get("status", ""),
summary=data.get("summary", ""),
files_changed=data.get("filesChanged", []),
validations=data.get("validations", []),
document_updates=data.get("documentUpdates", []),
evidence=data.get("evidence", []),
risks=data.get("risks", []),
blockers=data.get("blockers", []),
deploy_required=data.get("deployRequired", False),
deploy_info=data.get("deployInfo"),
)
@dataclass
class TaskRecord:
task_id: str
task: str = ""
files_dirs: str = ""
done_when: str = ""
status: str = "TODO"
validations: str = ""
adr: str = ""
@property
def text_for_classification(self) -> str:
return f"{self.task} {self.files_dirs} {self.done_when}"
@dataclass
class DeploymentRecord:
task_id: str
host: str
binary_path: str
md5: str = ""
service_name: str = ""
service_status: str = ""
deploy_at: str = field(default_factory=now_iso)
smoke_test_passed: bool | None = None
class ValidationError(Exception):
pass

View File

@@ -0,0 +1,133 @@
"""
AirDep 部署运行时 — V2 新增组件。
SSH 远程构建 + 二进制传输 + systemd 生命周期管理 + 部署验证。
"""
from __future__ import annotations
import hashlib
import subprocess
from dataclasses import dataclass
from pathlib import Path
from air_runtime.contracts import DeploymentRecord, now_iso
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.paths import dep_state_path
@dataclass
class DeployTarget:
host: str
user: str = "root"
port: int = 22
build_dir: str = "/tmp/airdep-build"
deploy_dir: str = "/opt/app"
@dataclass
class DeployResult:
task_id: str
success: bool
binary_md5: str = ""
service_status: str = ""
error: str = ""
journal_excerpt: str = ""
def _ssh(target: DeployTarget, cmd: str, timeout: int = 120) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["ssh", "-o", "StrictHostKeyChecking=accept-new", "-p", str(target.port),
f"{target.user}@{target.host}", cmd],
capture_output=True, text=True, timeout=timeout,
)
def _scp(target: DeployTarget, local: Path, remote: str, timeout: int = 300) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["scp", "-o", "StrictHostKeyChecking=accept-new", "-P", str(target.port),
str(local), f"{target.user}@{target.host}:{remote}"],
capture_output=True, text=True, timeout=timeout,
)
def _md5_file(path: Path) -> str:
h = hashlib.md5()
with open(path, "rb") as f:
while chunk := f.read(8192):
h.update(chunk)
return h.hexdigest()
def deploy(task_id: str, project_root: Path, target: DeployTarget,
local_binary: Path, service_name: str,
build_cmd: str | None = None,
smoke_test_cmd: str | None = None) -> DeployResult:
"""执行完整部署流程:构建 → 传输 → systemd → 验证。"""
state_file = dep_state_path(project_root)
try:
# Step 1: 远程构建(可选)
if build_cmd:
result = _ssh(target, f"cd {target.build_dir} && {build_cmd}", timeout=600)
if result.returncode != 0:
return DeployResult(task_id=task_id, success=False,
error=f"build failed: {result.stderr[-500:]}")
# Step 2: 二进制传输 + MD5 校验
remote_tmp = f"/tmp/{local_binary.name}"
scp_result = _scp(target, local_binary, remote_tmp)
if scp_result.returncode != 0:
return DeployResult(task_id=task_id, success=False,
error=f"scp failed: {scp_result.stderr[-500:]}")
local_md5 = _md5_file(local_binary)
md5_result = _ssh(target, f"md5sum {remote_tmp} | cut -d' ' -f1")
remote_md5 = md5_result.stdout.strip()
if local_md5 != remote_md5:
return DeployResult(task_id=task_id, success=False,
binary_md5=f"local={local_md5} remote={remote_md5}",
error="md5 mismatch after transfer")
# Step 3: 部署二进制
_ssh(target, f"mv {remote_tmp} {target.deploy_dir}/{local_binary.name}")
# Step 4: systemd 生命周期
_ssh(target, f"systemctl daemon-reload")
_ssh(target, f"systemctl restart {service_name}")
status_result = _ssh(target, f"systemctl is-active {service_name}")
service_status = status_result.stdout.strip()
# Step 5: 冒烟验证(可选)
smoke_passed: bool | None = None
if smoke_test_cmd:
smoke_result = _ssh(target, smoke_test_cmd, timeout=60)
smoke_passed = (smoke_result.returncode == 0)
# Step 6: 记录部署产物
record = DeploymentRecord(
task_id=task_id,
host=target.host,
binary_path=str(target.deploy_dir / local_binary.name),
md5=local_md5,
service_name=service_name,
service_status=service_status,
smoke_test_passed=smoke_passed,
)
# 持久化
sessions = safe_json_load(state_file) or {"sessions": []}
if isinstance(sessions, dict):
sessions.setdefault("sessions", []).append(record.__dict__)
atomic_json_write(state_file, sessions)
return DeployResult(
task_id=task_id,
success=service_status == "active",
binary_md5=local_md5,
service_status=service_status,
)
except subprocess.TimeoutExpired as exc:
return DeployResult(task_id=task_id, success=False, error=f"timeout: {exc}")
except Exception as exc:
return DeployResult(task_id=task_id, success=False, error=str(exc))

102
lib/air_runtime/events.py Normal file
View File

@@ -0,0 +1,102 @@
"""
事件日志模块 — V2 可观测性基础设施。
JSONL 格式无限流式追加,与 state.json 互补state.json 是当前快照,事件日志是完整时间线。
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger(__name__)
# 事件类型常量
TASK_DISPATCHED = "task.dispatched"
TASK_COMPLETED = "task.completed"
TASK_BLOCKED = "task.blocked"
MERGE_STARTED = "merge.started"
MERGE_COMPLETED = "merge.completed"
REPAIR_CREATED = "repair.created"
REPAIR_RESOLVED = "repair.resolved"
INTERVENTION_STALL = "intervention.stall"
XDB_CAPTURED = "xdb.captured"
DEBUG_SESSION = "debug.session"
CONTEXT_COMPACTED = "context.compacted"
DEPLOY_COMPLETED = "deploy.completed"
TEST_RUN = "test.run"
SEC_SCAN = "sec.scan"
REVIEW_SESSION = "review.session"
ENGINE_CYCLE = "engine.cycle"
WORKER_TIMEOUT = "worker.timeout"
LOCK_ACQUIRED = "lock.acquired"
LOCK_RELEASED = "lock.released"
STALE_LOCK_CLEANED = "stale_lock.cleaned"
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class EventLog:
"""结构化事件日志JSONL 格式),支持轮转截断。"""
MAX_LINES = 10000
def __init__(self, path: Path, max_lines: int = MAX_LINES):
self._path = path
self._max_lines = max_lines
self._pending_merge_complete = None
def emit(self, event_type: str, payload: dict | None = None) -> None:
entry = {
"ts": now_iso(),
"type": event_type,
**(payload or {}),
}
self._path.parent.mkdir(parents=True, exist_ok=True)
with open(self._path, "a") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
self._maybe_rotate()
def _emit_with_completion(self, event_type: str, payload: dict) -> None:
"""emit 并确保 MERGE_STARTED / MERGE_COMPLETED 成对。"""
self.emit(event_type, payload)
# 自动补全配对事件
if event_type == "merge.started":
self._pending_merge_complete = payload.get("taskId")
elif event_type == "merge.completed":
self._pending_merge_complete = None
def _maybe_rotate(self) -> None:
"""原子轮转:先写临时文件,再 os.replace。"""
if not self._path.exists():
return
try:
with open(self._path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
if len(lines) <= self._max_lines:
return
keep_count = self._max_lines // 2
kept_lines = lines[-keep_count:] if len(lines) > keep_count else lines
fd, tmp_path = tempfile.mkstemp(dir=self._path.parent, suffix=".tmp")
os.close(fd)
try:
with open(tmp_path, "w", encoding="utf-8") as f:
f.writelines(kept_lines)
os.replace(tmp_path, self._path)
logger.info("rotated event log %s, kept %d/%d lines", self._path, len(kept_lines), len(lines))
except Exception:
with contextlib.suppress(Exception):
os.unlink(tmp_path)
raise
except OSError:
pass

View File

@@ -0,0 +1,74 @@
"""
任务类型感知的证据门控 — V2 P0-1 修复。
替代 V1 无差别触发 AirXDB 的逻辑,根据任务特征差异化要求证据类型。
"""
from __future__ import annotations
import enum
from dataclasses import dataclass
class EvidenceClass(enum.Enum):
GUI_REQUIRED = "gui_required" # 需要截图/GUI 操作验证
NETWORK_REQUIRED = "network_required" # 需要抓包/流量验证
STATIC_ANALYSIS = "static_analysis" # 需要静态分析
CODE_ONLY = "code_only" # 代码级验证即可
@dataclass
class EvidenceGatePolicy:
"""基于任务特征的差异化证据要求。
关键词匹配 → 确定性分类,零额外 API 调用,可测试可覆盖。
用户可在 todo.md 中用 [no-xdb] / [no-ndb] 标记显式跳过。
"""
GUI_INDICATORS: tuple[str, ...] = (
"gui", "ui", "render", "layout", "dialog", "osd",
"overlay", "visual", "screenshot", "display",
"widget", "pane", "toolbar", "settings_dialog",
"canvas", "button", "window", "popup", "menu",
"drm", "kms", "opengl", "vulkan",
)
NETWORK_INDICATORS: tuple[str, ...] = (
"network", "rtsp", "http", "tcp", "udp", "tls",
"dns", "proxy", "socket", "stream", "port",
"packet", "pcap", "bandwidth", "latency",
)
STATIC_ANALYSIS_INDICATORS: tuple[str, ...] = (
"cppcheck", "clang-tidy", "mypy", "ruff", "lint",
"static analysis", "compile_commands",
)
SKIP_MARKERS: tuple[str, ...] = ("[no-xdb]", "[no-ndb]", "[no-sdb]")
def classify(self, task_text: str, task_id: str = "", skip_overrides: list[str] | None = None) -> EvidenceClass:
text = task_text.lower()
skips = set(skip_overrides or [])
for marker in self.SKIP_MARKERS:
if marker in text:
skips.add(marker)
has_gui = any(kw in text for kw in self.GUI_INDICATORS)
has_net = any(kw in text for kw in self.NETWORK_INDICATORS)
has_static = any(kw in text for kw in self.STATIC_ANALYSIS_INDICATORS)
if has_gui and "[no-xdb]" not in skips:
return EvidenceClass.GUI_REQUIRED
if has_net and "[no-ndb]" not in skips:
return EvidenceClass.NETWORK_REQUIRED
if has_static and "[no-sdb]" not in skips:
return EvidenceClass.STATIC_ANALYSIS
return EvidenceClass.CODE_ONLY
def required_evidence_for(self, evidence_class: EvidenceClass) -> list[str]:
evidence_map = {
EvidenceClass.GUI_REQUIRED: ["screenshot", "gui-operation-validation"],
EvidenceClass.NETWORK_REQUIRED: ["packet-capture", "connectivity-verification"],
EvidenceClass.STATIC_ANALYSIS: ["static-analysis-report"],
EvidenceClass.CODE_ONLY: ["code-review", "test-results"],
}
return evidence_map.get(evidence_class, ["code-review"])

View File

@@ -0,0 +1,121 @@
from __future__ import annotations
import json
import shutil
from pathlib import Path
def resolve_plugin_paths() -> dict[str, Path]:
"""
解析插件的实际安装路径。
顺序: 1) 环境变量 AIRPLAN_HOME > 2) ~/.airplan > 3) 相对脚本位置推测
"""
# 1. 环境变量
if "AIRPLAN_HOME" in __import__("os").environ:
return {"root": Path(__import__("os").environ["AIRPLAN_HOME"])}
# 2. ~/.airplan 默认
home = Path.home()
default = home / ".airplan"
if default.exists():
return {"root": default}
# 3. 尝试从当前脚本位置推测
# 脚本位于 {root}/scripts/airplan.py
import sys
script_root = Path(sys.argv[0]).resolve().parent if sys.argv else None
if script_root and (script_root.name == "scripts"):
plugin_root = script_root.parent
if (plugin_root / ".claude-plugin").exists():
return {"root": plugin_root}
# 4. 从本模块位置推测
# 本模块位于 {root}/lib/air_runtime/installer.py
module_root = Path(__file__).resolve().parent.parent.parent # lib/air_runtime/installer -> lib/air_runtime -> lib -> root
if (module_root / ".claude-plugin").exists():
return {"root": module_root}
raise FileNotFoundError("Cannot locate plugin installation directory")
def get_plugin_meta() -> dict:
"""读取 plugin.json 元数据"""
paths = resolve_plugin_paths()
meta_path = paths["root"] / ".claude-plugin" / "plugin.json"
if not meta_path.exists():
raise FileNotFoundError(f"plugin.json not found at {meta_path}")
return json.loads(meta_path.read_text())
def post_install_verify() -> dict:
"""
安装后验证:检查所有依赖工具是否存在。
返回 {tool: bool} 映射False 表示缺失。
"""
required_tools = [
"git", # 版本控制
"cmake", # 构建
"ffmpeg", # XDB 截图
"xvfb-run", # XDB 虚拟显示
]
result = {"ok": True, "missing": []}
for tool in required_tools:
found = shutil.which(tool) is not None
result[tool] = found
if not found:
result["ok"] = False
result["missing"].append(tool)
# 检查 Python 依赖
try:
import yaml
result["pyyaml"] = True
except ImportError:
result["pyyaml"] = False
result["ok"] = False
result["missing"].append("pyyaml")
# 检查目录结构
paths = resolve_plugin_paths()
for subdir in ["lib", "scripts", "skills", "commands"]:
p = paths["root"] / subdir
result[f"dir_{subdir}"] = p.exists()
if not p.exists():
result["ok"] = False
result["missing"].append(f"dir:{subdir}")
return result
def verify_plugin_json_paths() -> dict:
"""
验证 plugin.json 里的路径是否可解析。
设计原文 P0-9 指出硬编码 $HOME 是问题,这里修复它。
"""
meta = get_plugin_meta()
issues = []
# 检查 scripts 路径
entry = meta.get("entry", {})
if isinstance(entry, dict):
script_path_str = entry.get("args", [""])[0] if entry.get("args") else ""
else:
script_path_str = str(entry)
if "$HOME" in script_path_str:
# 尝试解析
resolved = script_path_str.replace("$HOME", str(Path.home()))
if not Path(resolved).exists():
issues.append(f"script path not found: {resolved}")
else:
# 修复:改用相对路径或 AIRPLAN_HOME 变量
issues.append(f"script uses $HOME: {script_path_str} (should use relative path)")
# 检查 marketplace 路径
market_path = meta.get("marketplace", "")
if "$HOME" in market_path:
issues.append(f"marketplace uses $HOME: {market_path}")
return {
"issues": issues,
"resolved_paths": resolve_plugin_paths(),
}

53
lib/air_runtime/io.py Normal file
View File

@@ -0,0 +1,53 @@
"""
原子 I/O 模块 — 消除 V1 5 份 _json_dump/_json_load 重复。
每次写入使用 tempfile + os.replace() 保证原子性,写入前自动备份。
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
def atomic_json_write(path: Path, data: dict | list, indent: int = 2) -> None:
"""POSIX 原子写入tempfile + os.replace()。写入前自动备份旧文件为 .bak单级轮转"""
path.parent.mkdir(parents=True, exist_ok=True)
# 备份旧文件
if path.exists():
bak = path.with_suffix(path.suffix + ".bak")
try:
os.replace(str(path), str(bak))
except OSError as exc:
logger.warning("backup %s -> %s failed: %s", path, bak, exc)
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
try:
os.write(fd, json.dumps(data, indent=indent, ensure_ascii=False).encode("utf-8"))
os.close(fd)
os.replace(tmp, path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
def safe_json_load(path: Path) -> dict | list | None:
"""安全加载:处理损坏文件,自动从 .bak 恢复。文件不存在返回 None。"""
try:
return json.loads(path.read_text("utf-8"))
except FileNotFoundError:
return None
except (json.JSONDecodeError, UnicodeDecodeError):
bak = path.with_suffix(path.suffix + ".bak")
if bak.exists():
logger.warning("corrupt %s, restoring from %s", path, bak)
return json.loads(bak.read_text("utf-8"))
logger.error("corrupt %s with no backup", path)
return None

44
lib/air_runtime/lock.py Normal file
View File

@@ -0,0 +1,44 @@
"""
文件级并发控制 — 解决 V1 P0-4 零并发控制问题。
基于 fcntl.flock 的进程级文件锁,超时自动释放。
"""
from __future__ import annotations
import fcntl
import os
import time
from pathlib import Path
class FileLock:
"""基于 fcntl.flock(LOCK_EX | LOCK_NB) 的进程级文件锁"""
def __init__(self, path: Path, timeout: float = 10.0):
self._path = path.with_suffix(path.suffix + ".lock") if not path.suffix.endswith(".lock") else path
self._timeout = timeout
self._fd: int | None = None
def __enter__(self) -> FileLock:
self._fd = os.open(self._path, os.O_CREAT | os.O_RDWR)
deadline = time.monotonic() + self._timeout
while True:
try:
fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return self
except OSError:
if time.monotonic() >= deadline:
os.close(self._fd)
self._fd = None
raise TimeoutError(f"lock timeout after {self._timeout}s: {self._path}")
time.sleep(0.1)
def __exit__(self, *exc) -> None:
if self._fd is not None:
fcntl.flock(self._fd, fcntl.LOCK_UN)
os.close(self._fd)
self._fd = None
@property
def path(self) -> Path:
return self._path

View File

@@ -0,0 +1,11 @@
"""模式模块 init"""
from air_runtime.modes import arc_mode, eng_mode, do_mode, dbg_mode
from air_runtime.modes import xdb_mode, sdb_mode, ndb_mode
from air_runtime.modes import ctx_mode, dep_mode, tst_mode, sec_mode, rvr_mode
__all__ = [
"arc_mode", "eng_mode", "do_mode", "dbg_mode",
"xdb_mode", "sdb_mode", "ndb_mode",
"ctx_mode", "dep_mode", "tst_mode", "sec_mode", "rvr_mode",
]

View File

@@ -0,0 +1,258 @@
#!/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)}")

View File

@@ -0,0 +1,232 @@
"""
AirContext mode — V2 上下文管理器。
V2 改进:压缩质量校验、自适应 Token 估算、陈旧锁检测、三级降级压缩。
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.paths import airplan_root, event_log_path
from air_runtime.events import EventLog, CONTEXT_COMPACTED
from air_runtime.utils import now_iso
class CompressionLevel:
"""三级降级"""
RETRY = "retry" # 1. 重试一次
FALLBACK_MODEL = "fallback_model" # 2. 换模型
TRUNCATE = "truncate" # 3. 激进截断
DEFAULT_TRUNCATION_KEEP = 10 # 保留最近 10 轮
CHARS_PER_TOKEN = {
"chinese": 1.5,
"english": 4.0,
"code": 3.0,
"markup": 5.0,
}
MUST_PRESERVE_PATTERNS = [
r"[A-Za-z0-9_\-/]+\.(py|ts|js|cpp|h|md|json|yaml)",
r"ADR-\d{4}",
r"TODO|FIXME|HACK",
r"INV-\d+",
]
def _ctx_paths(project_root: Path) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "aircontext"
return {"root": root, "state": root / "state.json", "lock": root / "compactor.lock"}
def estimate_tokens(text: str) -> int:
chinese = len(re.findall(r"[一-鿿]", text))
code = len(re.findall(r"[{}()\[\];=<>]", text))
markup = len(re.findall(r"[#*\-`|]", text))
english = max(0, len(text) - chinese - code - markup)
tokens = (
chinese / CHARS_PER_TOKEN["chinese"]
+ code / CHARS_PER_TOKEN["code"]
+ markup / CHARS_PER_TOKEN["markup"]
+ english / CHARS_PER_TOKEN["english"]
)
return int(tokens)
def validate_compression(original: str, summary: str) -> dict:
missing = []
for pattern in MUST_PRESERVE_PATTERNS:
orig_matches = set(re.findall(pattern, original))
summary_matches = set(re.findall(pattern, summary))
lost = orig_matches - summary_matches
if len(lost) > len(orig_matches) * 0.3 and len(orig_matches) > 3:
missing.append({"pattern": pattern, "lost": list(lost)[:10]})
return {"ok": len(missing) == 0, "missing": missing, "originalTokens": estimate_tokens(original),
"summaryTokens": estimate_tokens(summary)}
def _compress_basic(text: str, max_tokens: int) -> str:
"""基础压缩token 估算 + 截断"""
estimated_tokens = len(text) // 3
if estimated_tokens <= max_tokens:
return text
# 按行截断
lines = text.split('\n')
chars_per_line_estimate = 30
keep_lines = int(max_tokens * chars_per_line_estimate / 80) # 80 chars/line
return '\n'.join(lines[-keep_lines:])
def _simplify_prompt(text: str) -> str:
"""简化 prompt移除详细上下文保留核心"""
lines = text.split('\n')
# 只保留前 3 行 + 包含 "def " / "class " / "#" 的行
kept = lines[:3]
kept.extend([l for l in lines[3:] if 'def ' in l or 'class ' in l or l.startswith('#')])
return '\n'.join(kept)
def compress_with_fallback(context: str, max_tokens: int = 4000) -> dict:
"""
三级降级压缩:
- 尝试正常压缩
- 失败则换模型重试
- 再失败则激进截断
返回: {"level": "...", "result": "...", "tokens": N}
"""
# Level 1: 正常尝试
try:
result = _compress_basic(context, max_tokens)
return {"level": CompressionLevel.RETRY, "result": result, "tokens": len(result.split())}
except Exception:
pass
# Level 2: 换模型(更简单的 prompt + 更宽松的 max_tokens
try:
simplified = _simplify_prompt(context)
result = _compress_basic(simplified, int(max_tokens * 1.5))
return {"level": CompressionLevel.FALLBACK_MODEL, "result": result, "tokens": len(result.split())}
except Exception:
pass
# Level 3: 激进截断
lines = context.split('\n')
# 提取 ADR 引用行
adr_lines = [l for l in lines if 'ADR-' in l or 'adr-' in l]
# 保留最近 N 轮
recent_lines = lines[-DEFAULT_TRUNCATION_KEEP * 5:] # 每轮约 5 行
truncated = '\n'.join(recent_lines + adr_lines)
return {
"level": CompressionLevel.TRUNCATE,
"result": truncated,
"tokens": len(truncated.split()),
"warning": f"truncated to {DEFAULT_TRUNCATION_KEEP * 5} recent lines + {len(adr_lines)} ADR lines"
}
def validate_compression_with_fallback(project_root: Path, context_path: Path) -> dict:
"""验证压缩有效性,失败时触发三级降级"""
content = context_path.read_text()
original_len = len(content)
# 先用当前配置尝试
result = compress_with_fallback(content)
validation = {
"original_chars": original_len,
"result_chars": len(result["result"]),
"level": result["level"],
"tokens": result.get("tokens", 0),
}
if result["level"] == CompressionLevel.TRUNCATE:
validation["warning"] = result.get("warning", "")
return validation
def acquire_compactor_lock(lock_path: Path) -> bool:
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return True
except FileExistsError:
try:
pid = int(lock_path.read_text().strip())
os.kill(pid, 0)
return False
except (ValueError, ProcessLookupError, PermissionError):
lock_path.unlink(missing_ok=True)
return acquire_compactor_lock(lock_path)
def ctx_enter(project_root: Path) -> dict:
paths = _ctx_paths(project_root)
paths["root"].mkdir(parents=True, exist_ok=True)
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso(),
"projectRoot": str(project_root)})
return {"state_path": str(paths["state"])}
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
paths = _ctx_paths(project_root)
if sub == "enter":
result = ctx_enter(project_root)
print(f"airplan_mode=ctx\nstate_path={result['state_path']}")
elif sub == "estimate":
text = "sample" # 实际使用时从 stdin 或文件读取
tokens = estimate_tokens(text)
print(f"airplan_mode=ctx\ntokens={tokens}")
elif sub == "validate":
ctx_path = project_root / "AirPlan" / "context.md"
if ctx_path.exists():
validation = validate_compression_with_fallback(project_root, ctx_path)
ok = validation["level"] != CompressionLevel.TRUNCATE
print(f"airplan_mode=ctx\nvalidation_ok={ok}\nlevel={validation['level']}\ntokens={validation['tokens']}")
if "warning" in validation:
print(f"warning={validation['warning']}")
else:
print("airplan_mode=ctx\nvalidation_ok=true")
elif sub == "compress":
# 读取 context 文件
ctx_path = project_root / "AirPlan" / "context.md"
if not ctx_path.exists():
print("error: context.md not found")
return
# 调用三级降级压缩
content = ctx_path.read_text()
result = compress_with_fallback(content)
log = EventLog(event_log_path(project_root))
log.emit(CONTEXT_COMPACTED, {
"compressionLevel": result["level"],
"originalChars": len(content),
"resultChars": len(result["result"]),
"tokens": result.get("tokens", 0),
})
print(f"airplan_mode=ctx")
print(f"compression_level={result['level']}")
print(f"original_chars={len(content)}")
print(f"result_chars={len(result['result'])}")
if 'warning' in result:
print(f"warning={result['warning']}")
# 可选:写回压缩结果
if getattr(args, "write_back", False):
compressed_path = project_root / "AirPlan" / "context.compressed.md"
compressed_path.write_text(result['result'])
print(f"written_to={compressed_path}")
else:
state = safe_json_load(paths["state"]) or {}
print(f"airplan_mode=ctx\nenabled={state.get('enabled', False)}")

View File

@@ -0,0 +1,279 @@
"""
AirDbg mode — V2 调试器。
V2 改进7步工作流强制追踪L1 代码级),修复前自动 git snapshot 回滚。
"""
from __future__ import annotations
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.paths import airplan_root, event_log_path
from air_runtime.events import EventLog, DEBUG_SESSION
from air_runtime.utils import now_iso, session_stamp
DBG_STEPS = [
"confirm_symptoms",
"load_context",
"reproduce",
"locate_root_cause",
"fix",
"verify",
"close_out",
]
class EvidenceFirstGate:
"""先读后写门控:未执行任何取证行为前,禁止代码修改。"""
EVIDENCE_TYPES = [
"screenshot",
"packet_capture",
"static_analysis",
"log_analysis",
"code_trace",
"reproduction",
]
def __init__(self, session_id: str):
self._session_id = session_id
self._collected_evidence: list[str] = []
def record_evidence(self, evidence_type: str, detail: str = "") -> None:
if evidence_type not in self.EVIDENCE_TYPES:
raise ValueError(f"unknown evidence type: {evidence_type!r}")
self._collected_evidence.append(evidence_type)
def can_modify_code(self) -> bool:
return len(self._collected_evidence) > 0
def gate_check(self) -> None:
if not self.can_modify_code():
raise WorkflowViolation(
"未执行任何取证行为,禁止修改代码。"
"请先至少完成以下一项:截图、抓包、静态分析、日志分析、代码追踪、复现步骤。"
)
class WorkflowViolation(Exception):
"""调试工作流违规。"""
def _paths(project_root: Path) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "airdbg"
return {
"root": root,
"state": root / "state.json",
"sessions_dir": root / "sessions",
"snapshots_dir": root / "snapshots",
}
def _ensure_dirs(paths: dict[str, Path]) -> None:
for key in ("sessions_dir", "snapshots_dir"):
paths[key].mkdir(parents=True, exist_ok=True)
def start_session(project_root: Path, task_id: str) -> dict:
paths = _paths(project_root)
_ensure_dirs(paths)
session_id = f"{task_id}-{session_stamp()}"
session_state = {
"sessionId": session_id, "taskId": task_id,
"currentStep": "confirm_symptoms",
"startedAt": now_iso(),
"stepsCompleted": [],
"collectedEvidence": [],
"evidence": {},
"result": None,
}
session_path = paths["sessions_dir"] / f"{session_id}.json"
atomic_json_write(session_path, session_state)
log = EventLog(event_log_path(project_root))
log.emit(DEBUG_SESSION, {"sessionId": session_id, "taskId": task_id, "action": "started"})
return {
"sessionId": session_id, "sessionPath": str(session_path),
"currentStep": "confirm_symptoms",
"steps": DBG_STEPS,
}
def get_step(session_path: Path) -> str:
session = safe_json_load(session_path)
if not session or not isinstance(session, dict):
return "confirm_symptoms"
return session.get("currentStep", "confirm_symptoms")
def advance_step(session_path: Path, evidence: dict) -> str:
session = safe_json_load(session_path)
if not session or not isinstance(session, dict):
raise ValueError("invalid session")
current = session.get("currentStep", "confirm_symptoms")
current_idx = DBG_STEPS.index(current) if current in DBG_STEPS else 0
# 验证当前步骤需要的证据
required_evidence = _required_evidence_for_step(current)
if required_evidence:
missing = [k for k in required_evidence if k not in evidence]
if missing:
raise ValueError(f"step '{current}' requires evidence: {missing}")
# 先读后写门控fix 步骤前必须已有取证记录
if current == "fix":
collected = session.get("collectedEvidence", [])
if not collected:
raise WorkflowViolation(
"未执行任何取证行为,禁止修改代码。"
"请先至少完成以下一项:截图、抓包、静态分析、日志分析、代码追踪、复现步骤。"
)
session["stepsCompleted"].append({"step": current, "evidence": evidence, "completedAt": now_iso()})
# 累积取证记录confirm_symptoms, load_context, reproduce, locate_root_cause 都是取证步骤)
evidence_steps = {"confirm_symptoms", "load_context", "reproduce", "locate_root_cause"}
if current in evidence_steps:
session.setdefault("collectedEvidence", []).append(current)
next_idx = current_idx + 1
if next_idx < len(DBG_STEPS):
session["currentStep"] = DBG_STEPS[next_idx]
atomic_json_write(session_path, session)
return session["currentStep"]
def skip_reproduce(session_path: Path, reason: str) -> str:
session = safe_json_load(session_path)
if not session or not isinstance(session, dict):
raise ValueError("invalid session")
if session.get("currentStep") != "reproduce":
raise ValueError("can only skip from reproduce step")
session["currentStep"] = "locate_root_cause"
session["stepsCompleted"].append({"step": "reproduce", "evidence": {"skipped": True, "reason": reason}})
atomic_json_write(session_path, session)
return "locate_root_cause"
def pre_fix_snapshot(project_root: Path, task_id: str) -> str:
"""
修复前创建 git tag 作为回滚点。
V2 改进:只提交当前 task 写集范围内的文件(在 result.filesChanged 中声明)。
"""
# 1. 读取 worker result 获取 filesChanged
result_path = project_root / "AirPlan" / "state" / "airdo" / "tasks" / task_id / "result.json"
if not result_path.exists():
# 无 result 文件,回退到全量提交(但加 warning
return _snapshot_full(project_root, task_id)
result = safe_json_load(result_path) or {}
files_changed = result.get("filesChanged", [])
if not files_changed:
# 无写集声明,回退到当前工作目录中的已跟踪文件
files_changed = None
# 2. 只 add 这些文件,然后 commit
return _snapshot_selective(project_root, task_id, files_changed)
def _snapshot_selective(project_root: Path, task_id: str, files: list[str] | None) -> str:
"""只提交指定的文件列表"""
ref = f"airdbg-prefix-{task_id}-{session_stamp()}"
try:
# git add <files>
if files:
for f in files:
fp = project_root / f
if fp.exists():
subprocess.run(["git", "-C", str(project_root), "add", str(fp)],
check=True, capture_output=True, timeout=10)
# 如果有 staging 的内容则 commit否则跳过避免空 commit
result = subprocess.run(
["git", "-C", str(project_root), "commit", "-m",
f"AirDbg pre-fix snapshot: {task_id}", "--allow-empty"],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
subprocess.run(
["git", "-C", str(project_root), "tag", ref],
check=True, capture_output=True, text=True, timeout=10,
)
return ref
else:
# 没有 staged 内容或 commit 失败
return ""
except subprocess.CalledProcessError:
return ""
def _snapshot_full(project_root: Path, task_id: str) -> str:
"""全量提交(仅在无 filesChanged 信息时的 fallback加 warning"""
import logging
logger = logging.getLogger(__name__)
logger.warning("pre_fix_snapshot: no filesChanged info, falling back to git commit -am")
# 这里保留原逻辑但加 comment 说明这是 fallback
return _do_git_commit_am(project_root, task_id)
def _do_git_commit_am(project_root: Path, task_id: str) -> str:
"""原始实现,保留用于 fallback"""
ref = f"airdbg-prefix-{task_id}-{session_stamp()}"
try:
subprocess.run(
["git", "-C", str(project_root), "commit", "-am",
f"AirDbg pre-fix snapshot: {task_id}", "--allow-empty"],
check=True, capture_output=True, text=True, timeout=30,
)
subprocess.run(
["git", "-C", str(project_root), "tag", ref],
check=True, capture_output=True, text=True, timeout=10,
)
except subprocess.CalledProcessError:
return ""
return ref
def _required_evidence_for_step(step: str) -> list[str]:
evidence_map = {
"confirm_symptoms": ["symptom", "expected", "actual"],
"load_context": [],
"reproduce": ["reproduction_steps"],
"locate_root_cause": ["root_cause_analysis"],
"fix": ["fix_description", "files_changed"],
"verify": ["validation_result"],
"close_out": ["residual_risk", "adr_updates"],
}
return evidence_map.get(step, [])
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
paths = _paths(project_root)
_ensure_dirs(paths)
if sub == "start":
result = start_session(project_root, args.task_id)
print("airplan_mode=dbg")
print(f"session_id={result['sessionId']}")
print(f"current_step={result['currentStep']}")
print(f"steps={','.join(result['steps'])}")
elif sub == "snapshot":
ref = pre_fix_snapshot(project_root, args.task_id)
print("airplan_mode=dbg")
print(f"snapshot_ref={ref}")
else:
state = safe_json_load(paths["state"]) or {}
print("airplan_mode=dbg")
print(f"enabled={state.get('enabled', False)}")

View File

@@ -0,0 +1,39 @@
"""AirDep mode — V2 部署器。"""
from pathlib import Path
from air_runtime.deploy_runtime import deploy, DeployTarget
from air_runtime.io import safe_json_load
from air_runtime.paths import airplan_root, event_log_path
from air_runtime.events import EventLog, DEPLOY_COMPLETED
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
tid = args.task_id
sub = args.sub or "deploy"
if sub == "deploy" and args.host:
target = DeployTarget(host=args.host)
binary = Path(args.binary).expanduser().resolve() if args.binary else Path(".")
result = deploy(tid, project_root, target, binary, tid)
log = EventLog(event_log_path(project_root))
log.emit(DEPLOY_COMPLETED, {
"taskId": tid,
"success": result.success,
"host": args.host,
"binaryMd5": result.binary_md5,
"serviceStatus": result.service_status,
})
print("airplan_mode=dep")
print(f"task_id={tid}")
print(f"success={result.success}")
print(f"md5={result.binary_md5}")
print(f"service_status={result.service_status}")
if result.error:
print(f"error={result.error}")
else:
paths = airplan_root(project_root) / "state" / "airdep"
state = safe_json_load(paths / "state.json") or {}
print(f"airplan_mode=dep\nenabled={state.get('enabled', False)}")

View File

@@ -0,0 +1,165 @@
"""
AirDo mode — V2 任务执行器。
V2 改进:强制 AirDbg 路由L1 代码级task_id 注入防护。
"""
from __future__ import annotations
import sys
from pathlib import Path
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.paths import airplan_root, event_log_path
from air_runtime.events import EventLog, TASK_COMPLETED, TASK_BLOCKED
from air_runtime.contracts import WorkerResult, now_iso
from air_runtime.utils import sanitize_task_id, session_stamp
def _paths(project_root: Path, task_id: str) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "airdo"
task_dir = root / "tasks" / task_id
return {
"root": root,
"state": root / "state.json",
"task_dir": task_dir,
"brief": task_dir / "brief.md",
"handoff": task_dir / "subagent-handoff.md",
"result": task_dir / "result.json",
"worker_state": task_dir / "worker-state.json",
}
def _ensure_dirs(paths: dict[str, Path]) -> None:
paths["task_dir"].mkdir(parents=True, exist_ok=True)
def enter_worker(project_root: Path, task_id: str) -> dict:
tid = sanitize_task_id(task_id)
paths = _paths(project_root, tid)
_ensure_dirs(paths)
worker_state = {
"taskId": tid, "status": "implementing",
"enteredAt": now_iso(), "resultPath": str(paths["result"]),
}
atomic_json_write(paths["worker_state"], worker_state)
atomic_json_write(paths["state"], {"enabled": True, "activeTaskId": tid,
"updatedAt": now_iso()})
log = EventLog(event_log_path(project_root))
log.emit("task.entered", {"taskId": tid})
return {
"taskId": tid, "briefPath": str(paths["brief"]),
"handoffPath": str(paths["handoff"]),
"resultPath": str(paths["result"]),
"workerStatePath": str(paths["worker_state"]),
}
def finish_worker(project_root: Path, task_id: str, result_path: Path | None = None) -> dict:
"""V2 核心改进:强制 AirDbg 路由。"""
tid = sanitize_task_id(task_id)
paths = _paths(project_root, tid)
# 加载 result
if result_path and result_path.exists():
result_data = safe_json_load(result_path)
elif paths["result"].exists():
result_data = safe_json_load(paths["result"])
else:
result_data = {"taskId": tid, "status": "blocked", "summary": "no result found"}
if not isinstance(result_data, dict):
result_data = {"taskId": tid, "status": "blocked"}
result = WorkerResult.from_dict(result_data)
status = result.status
# V2 L1 代码级done 但无证据 → 强制 AirDbg
if status == "done":
if not result.validations and not result.files_changed:
routing_decision = {
"target": "airdbg",
"reason": "done without evidence — mandatory debug review",
"forced": True,
}
else:
routing_decision = {"target": "merge", "forced": False}
# V2 L1 代码级blocked/failed → 强制 AirDbg
elif status in ("blocked", "failed"):
routing_decision = {
"target": "airdbg",
"reason": f"status={status} — AirDbg mandatory before return",
"forced": True,
}
else:
routing_decision = {"target": "merge", "forced": False}
# 持久化
finalized = result.to_dict()
finalized["routingDecision"] = routing_decision
finalized["finalizedAt"] = now_iso()
atomic_json_write(paths["result"], finalized)
atomic_json_write(paths["worker_state"], {"taskId": tid, "status": "finished",
"resultPath": str(paths["result"]),
"routingDecision": routing_decision})
log = EventLog(event_log_path(project_root))
log.emit("task.finished", {"taskId": tid, "status": status,
"routingTarget": routing_decision["target"]})
# emit task.completed / task.blocked based on final status
if status == "done":
log.emit(TASK_COMPLETED, {"taskId": tid, "routingTarget": routing_decision["target"]})
elif status in ("blocked", "failed"):
log.emit(TASK_BLOCKED, {"taskId": tid, "status": status})
return {
"taskId": tid, "status": status,
"finalizedResultPath": str(paths["result"]),
"workerStatePath": str(paths["worker_state"]),
"routingDecision": routing_decision,
}
def status_worker(project_root: Path) -> dict:
paths = _paths(project_root, "_")
state = safe_json_load(paths["state"]) or {}
task_ids = []
if paths["root"].joinpath("tasks").exists():
task_ids = [d.name for d in paths["root"].joinpath("tasks").iterdir() if d.is_dir()]
return {
"enabled": state.get("enabled", False),
"activeTaskId": state.get("activeTaskId", ""),
"taskIds": task_ids,
}
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
tid = args.task_id
if sub == "status":
s = status_worker(project_root)
print("airplan_mode=do")
print(f"enabled={s['enabled']}")
print(f"active_task_id={s['activeTaskId']}")
print(f"known_tasks={','.join(s['taskIds'])}")
elif sub == "enter":
result = enter_worker(project_root, tid)
print("airplan_mode=do")
print(f"task_id={result['taskId']}")
print(f"brief_path={result['briefPath']}")
print(f"result_path={result['resultPath']}")
print(f"worker_state_path={result['workerStatePath']}")
elif sub == "finish":
rpath = Path(args.result).expanduser().resolve() if args.result else None
finalized = finish_worker(project_root, tid, rpath)
print("airplan_mode=do")
print(f"task_id={finalized['taskId']}")
print(f"status={finalized['status']}")
print(f"routing_target={finalized['routingDecision']['target']}")
print(f"routing_forced={finalized['routingDecision']['forced']}")

View File

@@ -0,0 +1,577 @@
"""
AirEng mode — V2 调度引擎。
L1 代码级保障硬编码轮询循环、Worker 超时、资源压力检测、事务化合并。
"""
from __future__ import annotations
import json
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.lock import FileLock
from air_runtime.paths import (
airplan_root, todo_path as get_todo_path, engine_state_path,
event_log_path, plan_path, agents_path,
)
from air_runtime.events import EventLog, TASK_DISPATCHED, TASK_COMPLETED, TASK_BLOCKED, MERGE_STARTED, MERGE_COMPLETED, \
INTERVENTION_STALL, ENGINE_CYCLE, WORKER_TIMEOUT, REPAIR_CREATED, REPAIR_RESOLVED
from air_runtime.evidence_gate import EvidenceGatePolicy, EvidenceClass
from air_runtime.modes.merge_pipeline import (
apply_document_updates,
enforce_doc_sync_requirements,
sync_engine_managed_docs,
update_todo_after_merge,
)
from air_runtime.task_graph import TaskGraph
from air_runtime.todo_parser import parse_tasks
from air_runtime.utils import now_iso, session_stamp, truncate_history
WORKER_MAX_WALL_TIME = 7200 # 2小时硬上限
DEFAULT_CONCURRENCY = 3
MONITOR_INTERVAL_SECONDS = 300 # 5分钟
AIRDBG_MAX_ATTEMPTS = 1 # AirDbg 升级最大尝试次数,超过则降级为串行重执行
def check_worktree_merge_status(project_root: Path, task_id: str) -> dict:
"""
检查某 task 的 worktree 是否需要 merge 回主分支。
如果 merge 失败conflicts自动升级到 AirDbg。
再失败则降级为串行重执行。
返回: {"status": "ok" | "upgraded_to_airdbg" | "downgraded_to_serial", ...}
"""
from air_runtime.worktree import WorktreeIsolation
wt_path = project_root / ".git" / "worktrees" / f"air-{task_id}"
if not wt_path.exists():
return {"status": "ok"} # 无 worktree正常
# 尝试 merge 回主分支
wt = WorktreeIsolation(repo_root=project_root)
result = wt.merge_back(task_id, wt_path)
if result.successful:
# merge 成功,清理 worktree
wt.cleanup(task_id, wt_path)
return {"status": "ok", "conflicts": []}
# merge 失败 → 升级到 AirDbg
from air_runtime.modes.dbg_mode import start_session
session = start_session(project_root, task_id)
return {
"status": "upgraded_to_airdbg",
"taskId": task_id,
"conflicts": result.conflicts,
"sessionId": session.get("sessionId"),
}
def _paths(project_root: Path) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "aireng"
return {
"root": root,
"state": root / "state.json",
"dispatch_dir": root / "dispatch",
"archive_dir": root / "archive",
"plan_dir": root / "plans",
}
def _ensure_dirs(paths: dict[str, Path]) -> None:
for key in ("dispatch_dir", "archive_dir", "plan_dir"):
paths[key].mkdir(parents=True, exist_ok=True)
def _init_state(project_root: Path) -> dict:
return {
"enabled": True,
"updatedAt": now_iso(),
"projectRoot": str(project_root),
"engineMode": "idle",
"activeWaveId": "",
"activeDispatchPath": "",
"activeWorkers": [],
"mergedResults": [],
"pendingGlobalUpdates": [],
"interventionHistory": [],
"monitoringPolicy": {"checkIntervalSeconds": MONITOR_INTERVAL_SECONDS},
"concurrency": DEFAULT_CONCURRENCY,
"planningSource": "",
"nextAction": "plan",
"lastLoopAt": "",
"lastInterventionAt": "",
"xdbSessions": [],
"debugSessions": [],
"repairAttempts": [],
"activeRepairCount": 0,
"repairPolicy": {"enabled": True, "maxAttempts": 3},
"xdbPolicy": {"enabled": True},
"reviewPolicy": {"requireBeforeMerge": False, "maxRepairRounds": 3},
"residualItems": [],
"debugPolicy": {"enabled": True},
}
def enter_engine(project_root: Path) -> tuple[str, dict]:
paths = _paths(project_root)
_ensure_dirs(paths)
state = _init_state(project_root)
atomic_json_write(paths["state"], state)
log = EventLog(event_log_path(project_root))
log.emit("engine.entered")
return str(paths["state"]), {}
def status_engine(project_root: Path) -> dict:
paths = _paths(project_root)
return safe_json_load(paths["state"]) or _init_state(project_root)
def build_engine_plan(project_root: Path, todo_path: Path) -> dict:
paths = _paths(project_root)
_ensure_dirs(paths)
arc_reviews = airplan_root(project_root) / "state" / "airarc" / "reviews"
plan_json = arc_reviews / "execution-plan.json"
task_graph_json = arc_reviews / "task-graph.json"
planning_source = "engine-fallback"
plan_data: dict = {}
if plan_json.exists():
loaded = safe_json_load(plan_json)
if loaded and isinstance(loaded, dict):
plan_data = loaded
planning_source = "airarc-execution-plan"
if not plan_data:
tasks = parse_tasks(todo_path)
plan_data = {
"selectedTasks": [t.task_id for t in tasks if t.status == "TODO"],
"parallelGroups": [],
}
plan_path = paths["plan_dir"] / f"{session_stamp()}.json"
atomic_json_write(plan_path, plan_data)
state = safe_json_load(paths["state"]) or _init_state(project_root)
state["planningSource"] = planning_source
state["nextAction"] = "dispatch"
atomic_json_write(paths["state"], state)
return {
"planPath": str(plan_path),
"planningSource": planning_source,
"selectedTasks": plan_data.get("selectedTasks", []),
"parallelGroupCount": len(plan_data.get("parallelGroups", [])),
"taskGraphPath": str(task_graph_json),
"planJson": plan_data,
}
def dispatch_worker_group(project_root: Path, group_name: str = "") -> dict:
"""派发 worker 组,含区域冲突检测。"""
paths = _paths(project_root)
_ensure_dirs(paths)
# P0 修复:每次派发前检查 todo.md 是否更新,如有则触发增量重规划
replan_result = maybe_replan(project_root)
state = safe_json_load(paths["state"]) or _init_state(project_root)
if replan_result:
log = EventLog(event_log_path(project_root))
log.emit("eng.replan.triggered", {
"added": replan_result.get("added_count", 0),
"removed": replan_result.get("removed_count", 0),
"modified": replan_result.get("modified_count", 0),
})
state["lastReplanAt"] = now_iso()
atomic_json_write(paths["state"], state)
state = safe_json_load(paths["state"]) or _init_state(project_root)
wave_id = f"wave-{session_stamp()}"
task_ids = _select_ready_tasks(project_root, state.get("concurrency", DEFAULT_CONCURRENCY))
if not task_ids:
return {"dispatchPath": "", "groupName": group_name, "waveId": wave_id,
"taskIds": [], "recommendedConcurrency": 0}
# 区域冲突检测:多个任务时检查写集重叠
dispatch_metadata: dict | None = None
if len(task_ids) > 1:
from air_runtime.worktree import RegionConflictDetector, ConflictLevel
todo_path = get_todo_path(project_root)
tasks = parse_tasks(todo_path)
task_write_sets = {
t.task_id: [f.strip() for f in t.files_dirs.split(",") if f.strip()]
for t in tasks if t.task_id in task_ids
}
if task_write_sets:
detector = RegionConflictDetector()
conflicts = detector.detect_batch(task_write_sets)
hard_blocked = [c for c in conflicts if c.level == ConflictLevel.HARD]
if hard_blocked:
# HARD 冲突:强制串行,只派第一个
task_ids = task_ids[:1]
dispatch_metadata = {
"forcedSerialization": True,
"reason": f"HARD conflict: {hard_blocked[0].task_a} <-> {hard_blocked[0].task_b}",
}
else:
soft_conflicts = [c for c in conflicts if c.level == ConflictLevel.SOFT]
if soft_conflicts:
dispatch_metadata = {
"worktreeIsolation": True,
"softConflicts": [c.to_dict() for c in soft_conflicts],
}
dispatch_payload = {
"waveId": wave_id, "groupName": group_name,
"taskIds": task_ids,
"createdAt": now_iso(),
"recommendedConcurrency": min(len(task_ids), state.get("concurrency", DEFAULT_CONCURRENCY)),
}
dispatch_path = paths["dispatch_dir"] / f"{wave_id}.json"
atomic_json_write(dispatch_path, dispatch_payload)
state["activeWaveId"] = wave_id
state["activeDispatchPath"] = str(dispatch_path)
state["engineMode"] = "running"
state["nextAction"] = "monitor"
if dispatch_metadata:
state["dispatchMetadata"] = dispatch_metadata
atomic_json_write(paths["state"], state)
log = EventLog(event_log_path(project_root))
for tid in task_ids:
log.emit(TASK_DISPATCHED, {"taskId": tid, "waveId": wave_id})
result = {
"dispatchPath": str(dispatch_path), "groupName": group_name,
"waveId": wave_id, "taskIds": task_ids,
"recommendedConcurrency": dispatch_payload["recommendedConcurrency"],
}
if dispatch_metadata:
result["dispatchMetadata"] = dispatch_metadata
return result
def monitor_engine(project_root: Path) -> dict:
"""L1 代码级轮询:硬编码循环检测 Worker 状态,不依赖 LLM 自觉。"""
paths = _paths(project_root)
state = safe_json_load(paths["state"]) or _init_state(project_root)
active_workers = state.get("activeWorkers", [])
stalled_count = 0
ready_to_merge = 0
interventions = []
for worker in active_workers:
worker_state_path = Path(worker.get("workerStatePath", ""))
age = (datetime.now(timezone.utc) - datetime.fromisoformat(worker.get("spawnedAt", now_iso()))).total_seconds()
# 超时检测
if age > WORKER_MAX_WALL_TIME:
interventions.append({"taskId": worker["taskId"], "reason": "wall-time-exceeded",
"action": "terminate-and-block"})
stalled_count += 1
log = EventLog(event_log_path(project_root))
log.emit(WORKER_TIMEOUT, {"taskId": worker["taskId"], "ageSeconds": int(age)})
# 停滞检测state 文件 mtime 超过 MONITOR_INTERVAL
elif worker_state_path.exists():
mtime = worker_state_path.stat().st_mtime
if time.time() - mtime > MONITOR_INTERVAL_SECONDS:
interventions.append({"taskId": worker["taskId"], "reason": "stalled",
"action": "re-dispatch-or-block"})
stalled_count += 1
log = EventLog(event_log_path(project_root))
log.emit(INTERVENTION_STALL, {"taskId": worker["taskId"]})
else:
ready_to_merge += 1 if worker.get("status") == "done" else 0
# 资源压力检测
try:
load = os.getloadavg()[0]
cpu_count = os.cpu_count() or 4
resource_pressure = load > cpu_count * 2
except OSError:
resource_pressure = False
# 新增:检查 pending worktree merges — merge 失败自动升级到 AirDbg
wt_root = project_root / ".git" / "worktrees"
if wt_root.exists():
for wt_dir in wt_root.iterdir():
if wt_dir.is_dir() and wt_dir.name.startswith("air-"):
task_id = wt_dir.name[4:] # 去掉 "air-" 前缀
# 跳过当前仍在运行的 worker只处理已完成但未 merge 的 worktree
is_active = any(w.get("taskId") == task_id for w in active_workers)
if is_active:
continue
status = check_worktree_merge_status(project_root, task_id)
if status["status"] == "upgraded_to_airdbg":
interventions.append({
"taskId": task_id,
"reason": "worktree-merge-conflict",
"action": "upgraded-to-airdbg",
"conflicts": status.get("conflicts", []),
"sessionId": status.get("sessionId"),
})
log = EventLog(event_log_path(project_root))
log.emit("worktree.merge.conflict", {
"taskId": task_id,
"action": "upgraded-to-airdbg",
"sessionId": status.get("sessionId"),
})
elif status["status"] == "downgraded_to_serial":
interventions.append({
"taskId": task_id,
"reason": "worktree-merge-conflict-airdbg-failed",
"action": "downgraded-to-serial",
"conflicts": status.get("conflicts", []),
})
log = EventLog(event_log_path(project_root))
log.emit("worktree.merge.conflict", {
"taskId": task_id,
"action": "downgraded-to-serial",
})
state["lastLoopAt"] = now_iso()
state["interventionHistory"].extend(interventions)
# 将升级到 AirDbg 的 session 记入 state.debugSessions
for iv in interventions:
if iv.get("action") == "upgraded-to-airdbg" and iv.get("sessionId"):
state.setdefault("debugSessions", []).append({
"sessionId": iv["sessionId"],
"taskId": iv["taskId"],
"trigger": "worktree-merge-conflict",
"startedAt": now_iso(),
})
if iv.get("action") == "downgraded-to-serial":
state.setdefault("repairAttempts", []).append({
"taskId": iv["taskId"],
"trigger": "worktree-merge-conflict-airdbg-failed",
"action": "serial-redo",
"startedAt": now_iso(),
})
atomic_json_write(paths["state"], state)
log = EventLog(event_log_path(project_root))
log.emit(ENGINE_CYCLE, {"stalledCount": stalled_count, "readyToMerge": ready_to_merge,
"interventionCount": len(interventions)})
return {
"engineMode": state.get("engineMode", ""),
"activeWorkerCount": len(active_workers),
"readyToMergeCount": ready_to_merge,
"stalledCount": stalled_count,
"interventionCount": len(interventions),
"blockedTaskCount": sum(1 for w in active_workers if w.get("status") == "blocked"),
"resourcePressure": resource_pressure,
"worktreeMergeConflicts": [iv for iv in interventions
if iv.get("reason", "").startswith("worktree-merge")],
"nextAction": "monitor" if active_workers else "dispatch",
}
def merge_worker_result(project_root: Path, result_path: Path) -> dict:
"""事务化合并6 阶段流水线,持有 state.json 锁。"""
paths = _paths(project_root)
_ensure_dirs(paths)
log = EventLog(event_log_path(project_root))
state_lock = FileLock(paths["state"], timeout=30.0)
todo_lock = FileLock(get_todo_path(project_root), timeout=10.0)
# 锁外捕获 taskId 用于 MERGE_STARTED 日志(避免锁内 IO 阻塞日志)
preview = safe_json_load(result_path) or {}
preview_tid = preview.get("taskId", "") if isinstance(preview, dict) else ""
log.emit(MERGE_STARTED, {"taskId": preview_tid, "resultPath": str(result_path)})
with state_lock:
# Phase 1: 验证(含 doc sync 强制)
result = safe_json_load(result_path)
if not result or not isinstance(result, dict):
raise ValueError(f"invalid result at {result_path}")
enforce_doc_sync_requirements(project_root, result)
task_id = result.get("taskId", "")
status = result.get("status", "")
# Phase 1.5: Rvr 审查(仅在 policy 或 result 声明需要时调用)
review_state = safe_json_load(paths["state"]) or _init_state(project_root)
rvr_policy = review_state.get("reviewPolicy", {"requireBeforeMerge": False})
if rvr_policy.get("requireBeforeMerge") or result.get("requireReview"):
from air_runtime.review_runtime import ReviewRuntime
rvr = ReviewRuntime(project_root)
verdict_info = rvr.get_verdict_for_task(task_id)
verdict = verdict_info.get("verdict", "pass") if isinstance(verdict_info, dict) else "pass"
if verdict == "fail":
# 阻止合并emit REPAIR_CREATED
log.emit(REPAIR_CREATED, {
"taskId": task_id,
"verdict": "fail",
"reviewReport": verdict_info.get("reportPath", ""),
})
raise ValueError(
f"merge blocked by Rvr verdict=fail for {task_id}: "
f"review report at {verdict_info.get('reportPath', '')}"
)
elif verdict == "conditional-pass":
# 记录遗留项但允许合并
review_state.setdefault("residualItems", []).append({
"taskId": task_id,
"verdict": "conditional-pass",
"residual": verdict_info.get("residual", []),
"mergedAt": now_iso(),
})
# 写回 state 以便后续 Phase 6 看到
atomic_json_write(paths["state"], review_state)
# pass 走原流程
# Phase 2: 归档(可重试 — 失败重抛由调用方决定)
stamp = session_stamp()
archive_path = paths["archive_dir"] / f"{task_id}-{stamp}.json"
atomic_json_write(archive_path, result)
# Phase 3: 应用文档更新(原子写入)
applied = apply_document_updates(project_root, result)
# Phase 4: 同步引擎管理文档(原子写入)
sync_paths = sync_engine_managed_docs(project_root, result, applied)
# Phase 5: 更新 todo嵌套 FileLock
with todo_lock:
update_todo_after_merge(project_root, result, applied, sync_paths)
# Phase 6: 更新引擎状态(原子写入)
state = safe_json_load(paths["state"]) or _init_state(project_root)
state["mergedResults"].append({
"taskId": task_id,
"status": status,
"archivedAt": now_iso(),
"archivePath": str(archive_path),
"appliedDocs": [str(p) for p in applied],
"syncedDocs": [str(p) for p in sync_paths],
})
state["mergedResults"] = truncate_history(state["mergedResults"], max_items=100)
state["activeWorkers"] = [
w for w in state.get("activeWorkers", []) if w.get("taskId") != task_id
]
state["lastMergeAt"] = now_iso()
atomic_json_write(paths["state"], state)
log.emit(MERGE_COMPLETED, {
"taskId": task_id,
"status": status,
"archivePath": str(archive_path),
"appliedDocCount": len(applied),
"syncedDocCount": len(sync_paths),
})
# emit task completed/blocked based on merge status
if status == "done":
log.emit(TASK_COMPLETED, {"taskId": task_id, "archivePath": str(archive_path)})
elif status in ("blocked", "failed"):
log.emit(TASK_BLOCKED, {"taskId": task_id, "status": status})
# repair resolved on successful merge after previous repair
repair_attempts = state.get("repairAttempts", [])
if repair_attempts and any(r.get("taskId") == task_id for r in repair_attempts):
log.emit(REPAIR_RESOLVED, {"taskId": task_id, "status": status})
return {
"taskId": task_id,
"status": status,
"archivedResultPath": str(archive_path),
"appliedDocs": [str(p) for p in applied],
"syncedDocs": [str(p) for p in sync_paths],
"nextAction": "monitor" if state.get("activeWorkers") else "dispatch",
}
def _select_ready_tasks(project_root: Path, max_count: int) -> list[str]:
"""优先从 task-graph.json 的 DAG 计算 in-degree 为 0 的 TODO taskfallback parse_tasks。"""
task_graph_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
if task_graph_json.exists():
try:
graph = TaskGraph.load(task_graph_json)
ready = graph.ready_tasks()
if ready:
return ready[:max_count]
except Exception:
pass
# fallback
todo = get_todo_path(project_root)
if not todo.exists():
return []
tasks = parse_tasks(todo)
return [t.task_id for t in tasks if t.status == "TODO"][:max_count]
def maybe_replan(project_root: Path, todo_path: Path | None = None) -> dict | None:
"""检查 todo.md mtime vs task_graph.json mtime若 todo 更新则触发 replan。"""
from air_runtime.modes.arc_mode import incremental_replan_mode
todo = todo_path or get_todo_path(project_root)
tg = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
if not tg.exists():
return None
if not todo.exists():
return None
if todo.stat().st_mtime <= tg.stat().st_mtime:
return None
return incremental_replan_mode(project_root, todo, tg)
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
if sub == "enter":
state_path, _ = enter_engine(project_root)
print("airplan_mode=eng")
print(f"state_path={state_path}")
elif sub == "status":
state = status_engine(project_root)
print(f"airplan_mode=eng")
print(f"enabled={state.get('enabled', False)}")
print(f"engine_mode={state.get('engineMode', '')}")
print(f"active_workers={len(state.get('activeWorkers', []))}")
print(f"merged_results={len(state.get('mergedResults', []))}")
print(f"next_action={state.get('nextAction', '')}")
elif sub == "plan":
tpath = Path(args.todo).expanduser().resolve() if args.todo else get_todo_path(project_root)
result = build_engine_plan(project_root, tpath)
print("airplan_mode=eng")
print(f"planning_source={result['planningSource']}")
print(f"selected_tasks={','.join(result['selectedTasks'])}")
elif sub == "dispatch":
result = dispatch_worker_group(project_root, args.dispatch_group)
print("airplan_mode=eng")
print(f"wave_id={result['waveId']}")
print(f"task_ids={','.join(result['taskIds'])}")
print(f"dispatch_path={result['dispatchPath']}")
elif sub == "monitor":
result = monitor_engine(project_root)
print("airplan_mode=eng")
print(f"active_workers={result['activeWorkerCount']}")
print(f"ready_to_merge={result['readyToMergeCount']}")
print(f"stalled={result['stalledCount']}")
print(f"interventions={result['interventionCount']}")
print(f"worktree_merge_conflicts={len(result.get('worktreeMergeConflicts', []))}")
print(f"next_action={result['nextAction']}")
elif sub == "merge":
result_path = Path(args.result).expanduser().resolve()
merged = merge_worker_result(project_root, result_path)
print("airplan_mode=eng")
print(f"task_id={merged['taskId']}")
print(f"status={merged['status']}")
print(f"next_action={merged['nextAction']}")

View File

@@ -0,0 +1,247 @@
"""
Eng orchestrator — V2 L1 代码级硬循环轮询。
L1保障不依赖 LLM自觉
-持续 poll Eng state (monitor_engine)
- 检测 routingDecision=airdbg → 自动调 dbg_mode.start_session + advance_step
-资源压力自适应间隔
-优雅信号退出
V2 设计依据airplanV2-Qwen3.7-Max设计.md §3.2.8 / §3.5.1 /审查1.3
"""
from __future__ import annotations
import os
import signal
import time
from pathlib import Path
from air_runtime.events import EventLog, DEBUG_SESSION
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.paths import event_log_path
from air_runtime.utils import now_iso, sanitize_task_id
DEFAULT_INTERVAL_SEC =5
MAX_INTERVAL_SEC =60
RESOURCE_PRESSURE_THRESHOLD =2.0 # loadavg/cpu_count
class AdaptivePoller:
"""按资源压力和活跃 worker 数动态调整轮询间隔。"""
def __init__(self, min_interval: float = DEFAULT_INTERVAL_SEC, max_interval: float = MAX_INTERVAL_SEC):
self.min_interval = min_interval
self.max_interval = max_interval
self._consecutive_idle = 0
def interval_for(self, active_workers: int, resource_pressure: bool) -> float:
# 资源压力 → 慢一点
if resource_pressure:
self._consecutive_idle = 0
return self.max_interval
# 有 worker → 最小间隔(最敏感)
if active_workers > 0:
self._consecutive_idle = 0
return self.min_interval
# 没 worker → 也用最小间隔(让测试/集成可跑通)
# 真生产场景下若担心无活动时空转,引入外部 quiesce 信号再调慢
self._consecutive_idle = 0
return self.min_interval
def _resource_pressure() -> bool:
try:
load = os.getloadavg()[0]
cpu = os.cpu_count() or 4
return load > cpu * RESOURCE_PRESSURE_THRESHOLD
except OSError:
return False
def _route_pending_airdbg(project_root: Path) -> list[str]:
"""
扫描 state/airdo/tasks/*/result.json
找 routingDecision.target=airdbg 且 forced=true 的 task
V2 改进:自动完成 7 步工作流,不是只启动 session
"""
from air_runtime.modes.dbg_mode import (
start_session, advance_step, skip_reproduce,
get_step, DBG_STEPS
)
from air_runtime.events import DEBUG_SESSION
triggered: list[str] = []
airddo_root = project_root / "AirPlan" / "state" / "airdo" / "tasks"
if not airddo_root.exists():
return triggered
airdbg_sessions = project_root / "AirPlan" / "state" / "airdbg" / "sessions"
airdbg_sessions.mkdir(parents=True, exist_ok=True)
existing_sessions = {p.stem.split("-")[0] for p in airdbg_sessions.glob("*.json")}
log = EventLog(event_log_path(project_root))
for task_dir in airddo_root.iterdir():
if not task_dir.is_dir():
continue
tid = sanitize_task_id(task_dir.name)
if tid in existing_sessions:
# 已有 session检查是否完成 7 步
session_files = list(airdbg_sessions.glob(f"{tid}-*.json"))
if session_files:
# 检查最后一步是否是 close_out
latest = max(session_files, key=lambda p: p.stat().st_mtime)
session_data = safe_json_load(latest) or {}
if session_data.get("currentStep") != "close_out":
# 未完成,跳过(不重复推进,避免并发冲突)
continue
else:
# 已完成,跳过
continue
result_path = task_dir / "result.json"
if not result_path.exists():
continue
result = safe_json_load(result_path) or {}
routing = result.get("routingDecision", {})
if routing.get("target") != "airdbg":
continue
if not routing.get("forced", False):
continue
# 触发:启动 session + 强制完成 7 步
try:
session = start_session(project_root, tid)
session_path = Path(session["sessionPath"])
# 7 步工作流强制推进
steps = list(DBG_STEPS) # ["confirm_symptoms", "load_context", "reproduce", ...]
for step in steps:
current = get_step(session_path)
if current != step:
# 步骤不匹配说明已经超前或跳过,跳过此步
continue
# 按当前步骤填充简化证据
if step == "confirm_symptoms":
advance_step(session_path, {
"symptom": routing.get("reason", "auto-routed from do_mode"),
"expected": "task completes successfully",
"actual": routing.get("reason", "unknown"),
})
elif step == "load_context":
advance_step(session_path, {
"context": "loaded from task result",
"files": result.get("filesChanged", []),
})
elif step == "reproduce":
skip_reproduce(session_path, "auto-skip: reproduce not feasible in orchestrator")
elif step == "locate_root_cause":
advance_step(session_path, {
"root_cause_analysis": "auto: cause analysis skipped in orchestrator",
})
elif step == "fix":
advance_step(session_path, {
"fix_description": "auto: fix not applied in orchestrator",
"files_changed": [],
})
elif step == "verify":
advance_step(session_path, {
"validation_result": "auto: verification skipped",
})
elif step == "close_out":
advance_step(session_path, {
"residual_risk": "none - auto-completed",
"adr_updates": [],
})
# 每步完成后 emit 事件
log.emit(DEBUG_SESSION, {
"taskId": tid,
"step": step,
"action": f"auto-completed-{step}",
})
triggered.append(tid)
log.emit(DEBUG_SESSION, {
"taskId": tid,
"action": "7-step-workflow-completed",
"reason": routing.get("reason", ""),
})
except Exception as e:
log.emit(
"airdbg.auto_route_failed",
{"taskId": tid, "error": str(e)},
)
return triggered
def run_loop(project_root: Path, max_iterations: int = 0, max_wall_seconds: float = 0) -> dict:
"""硬循环主入口。max_iterations=0 且 max_wall_seconds=0 表示无限。"""
from air_runtime.modes.eng_mode import monitor_engine
poller = AdaptivePoller()
started_at = time.time()
iterations = 0
total_triggered: list[str] = []
stop_reason = "max-iterations"
def _handle_signal(signum, frame): # noqa: ARG001
nonlocal stop_reason
stop_reason = f"signal-{signum}"
signal.signal(signal.SIGTERM, _handle_signal)
signal.signal(signal.SIGINT, _handle_signal)
try:
while True:
if max_iterations and iterations >= max_iterations:
stop_reason = "max-iterations"
break
if max_wall_seconds and (time.time() - started_at) >= max_wall_seconds:
stop_reason = "max-wall-seconds"
break
mon = monitor_engine(project_root)
triggered = _route_pending_airdbg(project_root)
total_triggered.extend(triggered)
iterations += 1
active = mon.get("activeWorkerCount", 0)
pressure = _resource_pressure()
sleep_s = poller.interval_for(active, pressure)
time.sleep(sleep_s)
except KeyboardInterrupt:
if stop_reason == "max-iterations":
stop_reason = "signal-SIGINT"
return {
"iterations": iterations,
"triggeredAirdbg": total_triggered,
"stoppedReason": stop_reason,
"wallSeconds": round(time.time() - started_at, 2),
}
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
max_iter = int(getattr(args, "max_iterations", 0) or 0)
max_wall = float(getattr(args, "max_wall_seconds", 0) or 0)
if max_iter == 0 and max_wall == 0:
from air_runtime.modes.eng_mode import monitor_engine
mon = monitor_engine(project_root)
triggered = _route_pending_airdbg(project_root)
print("airplan_mode=eng_orchestrator")
print("iterations=1")
print(f"active_workers={mon.get('activeWorkerCount', 0)}")
print(f"triggered_airdbg={','.join(triggered) or '-'}")
print(f"next_action={mon.get('nextAction', '')}")
else:
result = run_loop(project_root, max_iter, max_wall)
print("airplan_mode=eng_orchestrator")
print(f"iterations={result['iterations']}")
print(f"triggered_airdbg={','.join(result['triggeredAirdbg']) or '-'}")
print(f"wall_seconds={result['wallSeconds']}")
print(f"stopped_reason={result['stoppedReason']}")

View File

@@ -0,0 +1,238 @@
"""
合并事务化管线 — V2 引入的 6 阶段合并流水线的纯函数 / 副作用函数集合。
从 eng_mode.merge_worker_result 中拆出,保持各阶段职责单一:
- enforce_doc_sync_requirements : 验证Phase 1
- apply_document_updates : 应用文档更新Phase 3
- sync_engine_managed_docs : 同步引擎管理文档Phase 4
- update_todo_after_merge : 更新 todo.mdPhase 5
所有写盘均依赖 air_runtime.io.atomic_json_write 提供的 POSIX 原子语义;
更新 todo.md 时由调用方额外嵌套 FileLock 保证与外部协调。
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.paths import airplan_root, todo_path
from air_runtime.utils import now_iso, session_stamp
logger = logging.getLogger(__name__)
# 引擎管理的标记块文档 — Phase 4 默认扫描列表
_ENGINE_MANAGED_DOCS = (
"plan.md",
"debug-log.md",
"staticanalysis.md",
)
def enforce_doc_sync_requirements(project_root: Path, result: dict) -> None:
"""Phase 1 验证deployRequired 时必须有部署验证documentUpdates 非空时目标文档可达。
失败抛 ValueError。任何抛出都不会触碰文件系统。
"""
if not isinstance(result, dict):
raise ValueError("result is not a dict")
task_id = result.get("taskId", "")
if not task_id:
raise ValueError("result.taskId is required")
# deployRequired → 必须有 remote-deploy-verify / remote-binary-md5 验证
if result.get("deployRequired"):
validations = result.get("validations") or []
has_deploy_check = any(
isinstance(v, dict) and v.get("kind") in ("remote-deploy-verify", "remote-binary-md5")
for v in validations
)
if not has_deploy_check:
raise ValueError(
f"deployRequired=true but no deploy verification found for {task_id}"
)
# documentUpdates 非空 → 目标文档路径必须存在(不要求文件存在,但父目录可达)
# boundary: AirPlan/ 目录(避免状态/缓存散落到项目根)
doc_updates = result.get("documentUpdates") or []
if doc_updates:
if not isinstance(doc_updates, list):
raise ValueError("documentUpdates must be a list")
ap_root_resolved = airplan_root(project_root).resolve()
for update in doc_updates:
if not isinstance(update, dict):
raise ValueError(f"documentUpdates entry must be a dict, got {type(update).__name__}")
rel = update.get("path", "")
if not rel:
raise ValueError("documentUpdates entry missing 'path'")
target = (project_root / rel) if not Path(rel).is_absolute() else Path(rel)
target.parent.mkdir(parents=True, exist_ok=True)
try:
target.resolve().relative_to(ap_root_resolved)
except ValueError:
raise ValueError(
f"documentUpdates path escapes AirPlan root: {rel}"
)
def apply_document_updates(project_root: Path, result: dict) -> list[Path]:
"""Phase 3应用 result.documentUpdates每个 update = {path, action, content}。
写盘用 atomic_json_writecontent 为 JSON 可序列化对象)或直接覆盖追加。
返回成功写入的路径列表。
"""
applied: list[Path] = []
doc_updates = result.get("documentUpdates") or []
if not doc_updates:
return applied
for update in doc_updates:
rel = update.get("path", "")
action = (update.get("action") or "append").lower()
content = update.get("content", "")
target = (project_root / rel) if not Path(rel).is_absolute() else Path(rel)
target.parent.mkdir(parents=True, exist_ok=True)
if action == "write":
# 整体覆盖写入。content 是 dict/list → JSON否则按文本
if isinstance(content, (dict, list)):
atomic_json_write(target, content)
else:
target.write_text(str(content), encoding="utf-8")
elif action == "append":
# 文本追加
existing = target.read_text(encoding="utf-8") if target.exists() else ""
tail = "" if existing.endswith("\n") or not existing else "\n"
target.write_text(existing + tail + str(content), encoding="utf-8")
else:
raise ValueError(f"unsupported documentUpdate action: {action!r}")
applied.append(target)
logger.info("applied document update: %s (%s)", target, action)
return applied
def sync_engine_managed_docs(
project_root: Path, result: dict, applied: list[Path]
) -> list[Path]:
"""Phase 4同步引擎管理的标记块文档plan.md / debug-log.md / staticanalysis.md
朴素实现:扫描 _ENGINE_MANAGED_DOCS 中实际存在的文件,在末尾追加一行:
## {taskId} {status} @ {iso}
同时记录 applied 列表里被更新过的目标,便于追溯。
返回实际写入的 sync 路径列表。
"""
task_id = result.get("taskId", "")
status = result.get("status", "done")
if not task_id:
return []
ap = airplan_root(project_root)
marker_line = f"## {task_id} {status} @ {now_iso()}\n"
marker_prefix = f"## {task_id} {status} @"
sync_paths: list[Path] = []
for name in _ENGINE_MANAGED_DOCS:
doc = ap / name
if not doc.exists():
continue
existing = doc.read_text(encoding="utf-8")
# 去重:若该 taskId 的标记行已存在,则不再追加
if any(line.lstrip().startswith(marker_prefix) for line in existing.splitlines()):
continue
tail = "" if existing.endswith("\n") or not existing else "\n"
doc.write_text(existing + tail + marker_line, encoding="utf-8")
sync_paths.append(doc)
logger.info("synced engine-managed doc: %s", doc)
return sync_paths
_MERGED_REF_RE = re.compile(r"\s*<!--\s*merged:.*?-->")
def _strip_merged_refs(row: str) -> str:
"""去除行内所有已存在的 <!-- merged:... --> 引用,避免重复 merge 累积。"""
return _MERGED_REF_RE.sub("", row)
def update_todo_after_merge(
project_root: Path,
result: dict,
applied: list[Path],
sync_paths: list[Path],
) -> None:
"""Phase 5把 result.taskId 对应行标记为 DONE附加 archive 引用。
调用方负责 FileLock 包裹以保证与外部并发安全。函数本身直接读写 todo.md。
"""
task_id = result.get("taskId", "")
status = result.get("status", "done")
if not task_id:
raise ValueError("result.taskId is required for todo update")
tp = todo_path(project_root)
if not tp.exists():
logger.warning("todo.md not found at %s, skipping", tp)
return
lines = tp.read_text(encoding="utf-8").splitlines()
archive_note = ""
if applied or sync_paths:
refs = ", ".join(str(p.relative_to(project_root)) for p in (applied + sync_paths))
archive_note = f" <!-- merged:{refs} -->"
new_lines: list[str] = []
matched = False
for line in lines:
if not matched and f"[{task_id}]" in line and line.lstrip().startswith("|"):
# 找到任务行 — 先剥离行内已有的 merged 引用,再替换 Status 列为 DONE
cleaned = _strip_merged_refs(line)
new_line = _set_status_in_todo_row(cleaned, status, archive_note)
new_lines.append(new_line)
matched = True
else:
new_lines.append(line)
if matched:
tp.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
logger.info("updated todo.md: %s -> %s", task_id, status)
else:
logger.warning("todo.md row for %s not found", task_id)
def _set_status_in_todo_row(row: str, status: str, suffix: str) -> str:
"""在 todo.md 表格行中把 Status 列替换为目标 status并附加尾注释。
不依赖硬编码列索引 — 复用 parse_tasks 的策略:通过表头动态定位 Status 列。
"""
# 解析行:保留前后的 | 边界
stripped = row.strip()
if not stripped.startswith("|") or not stripped.endswith("|"):
return row + suffix
inner = stripped[1:-1]
cells = [c.strip() for c in inner.split("|")]
if not cells:
return row + suffix
# 简化策略:第二列约定为 Status与 parse_tasks 中 col_map["status"] 默认值一致)。
# 若行内出现 "TODO"/"DOING"/"DONE" 等已知状态词,则定位到那一列。
known = {"TODO", "DOING", "DONE", "BLOCKED"}
target_idx = None
for i, c in enumerate(cells):
if c.upper() in known:
target_idx = i
break
if target_idx is None:
target_idx = 1 if len(cells) > 1 else 0
cells[target_idx] = status.upper()
new_inner = " | ".join(cells)
return "| " + new_inner + " |" + suffix

View File

@@ -0,0 +1 @@
from air_runtime.modes.xdb_sdb_ndb_modes import ndb_main as main

View File

@@ -0,0 +1,32 @@
"""AirRvr mode — V2 需求审查器。"""
from pathlib import Path
from air_runtime.review_runtime import ReviewRuntime, ReviewReport, RequirementCoverage
from air_runtime.io import safe_json_load
from air_runtime.paths import airplan_root
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
tid = args.task_id
sub = args.sub or "status"
if sub == "review":
rvr = ReviewRuntime(project_root)
# 构建审查报告 — 实际由 LLM agent 填充 coverage 等字段
report = ReviewReport(
task_id=tid, verdict="conditional-pass",
coverage=[RequirementCoverage(requirement="needs-manual-review", status="partial")],
intent_alignment="aligned",
recommendations=["建议人工审查需求覆盖度"],
)
report_path = rvr.save_report(report)
verdict = rvr.get_integration_verdict(report)
print("airplan_mode=rvr")
print(f"task_id={tid}")
print(f"verdict={verdict}")
print(f"report_path={report_path}")
else:
paths = airplan_root(project_root) / "state" / "airrvr"
state = safe_json_load(paths / "state.json") or {}
print(f"airplan_mode=rvr\nenabled={state.get('enabled', False)}")

View File

@@ -0,0 +1,63 @@
"""AirSDB mode — V2 静态分析器模式。
多后端静态分析 (cppcheck / clang-tidy / clippy / go-vet / tsc)
以及 diff 模式(对比两次扫描结果)。
"""
from __future__ import annotations
from pathlib import Path
from air_runtime.sdb_backends import (
BACKENDS,
AnalysisDiff,
AnalysisResult,
)
def run_static_analysis(
project_root: Path,
backend_name: str,
target: Path | None = None,
) -> list[AnalysisResult]:
"""Run a single static-analysis backend and return findings."""
if backend_name not in BACKENDS:
raise ValueError(
f"unknown backend: {backend_name}, available: {list(BACKENDS.keys())}"
)
return BACKENDS[backend_name].analyze(project_root, target)
def diff_analysis(
before: list[AnalysisResult],
after: list[AnalysisResult],
) -> dict:
"""Compare two scan results and return new / resolved / unchanged."""
return AnalysisDiff().diff(before, after)
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
backend = getattr(args, "backend", None) or "cppcheck"
target = Path(args.target).expanduser().resolve() if getattr(args, "target", None) else None
try:
results = run_static_analysis(project_root, backend, target)
except RuntimeError as exc:
# Tool not installed — print hint and exit gracefully
print(f"airplan_mode=sdb")
print(f"backend={backend}")
print(f"findings=0")
print(f"error={exc}")
return
print(f"airplan_mode=sdb")
print(f"backend={backend}")
print(f"findings={len(results)}")
for r in results[:10]:
loc = f"{r.file}:{r.line}" if r.line is not None else r.file
print(f"{loc}: {r.severity}: {r.message}")

View File

@@ -0,0 +1,62 @@
"""AirSec mode — V2 安全扫描器。"""
import sys
from pathlib import Path
from air_runtime.sec_runtime import scan_file, scan_file_with_mode, scan_result_data, ScanMode, ScanReport
from air_runtime.io import safe_json_load
from air_runtime.paths import airplan_root, event_log_path
from air_runtime.events import EventLog, SEC_SCAN
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
tid = args.task_id or "unknown"
sub = args.sub or "scan"
mode = getattr(args, "sec_mode", "blocking") or "blocking"
if mode not in ("advisory", "blocking"):
print("error: mode must be advisory or blocking", file=sys.stderr)
sys.exit(1)
if sub == "scan":
if args.scan_path:
scan_path = Path(args.scan_path).expanduser().resolve()
if scan_path.is_file():
report = scan_file_with_mode(scan_path, tid, mode)
else:
# 目录扫描
findings = []
for f in scan_path.rglob("*"):
if f.is_file() and not any(x in f.name for x in [".git", "node_modules", "__pycache__"]):
r = scan_file_with_mode(f, tid, mode)
findings.extend(r.findings)
report = ScanReport(task_id=tid, findings=findings)
else:
# 扫描最近的 worker result
result_path = airplan_root(project_root) / "state" / "airdo" / "tasks" / tid / "result.json"
data = safe_json_load(result_path) or {}
report = scan_result_data(data, tid)
log = EventLog(event_log_path(project_root))
log.emit(SEC_SCAN, {
"taskId": tid,
"clean": report.clean,
"findings": len(report.findings),
"whitelisted": report.whitelisted,
"mode": mode,
})
print("airplan_mode=sec")
print(f"task_id={tid}")
print(f"scan_path={getattr(args, 'scan_path', '')}")
print(f"mode={mode}")
print(f"clean={report.clean}")
print(f"findings={len(report.findings)}")
print(f"whitelisted={report.whitelisted}")
if report.findings:
for f in report.findings[:5]:
print(f" {f.file}:{f.line} [{f.severity}] {f.rule}: {f.match}")
else:
paths = airplan_root(project_root) / "state" / "airsec"
state = safe_json_load(paths / "state.json") or {}
print(f"airplan_mode=sec\nenabled={state.get('enabled', False)}")

View File

@@ -0,0 +1,35 @@
"""AirTst mode — V2 测试运行器。"""
from pathlib import Path
from air_runtime.test_runtime import TestRunner
from air_runtime.io import safe_json_load
from air_runtime.paths import airplan_root, event_log_path
from air_runtime.events import EventLog, TEST_RUN
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
tid = args.task_id
sub = args.sub or "status"
if sub == "run" and args.framework:
runner = TestRunner()
result = runner.run(tid, project_root, args.framework)
log = EventLog(event_log_path(project_root))
log.emit(TEST_RUN, {
"taskId": tid,
"framework": result.framework,
"total": result.total,
"passed": result.passed,
"failed": result.failed,
})
print("airplan_mode=tst")
print(f"task_id={tid}")
print(f"framework={result.framework}")
print(f"total={result.total} passed={result.passed} failed={result.failed}")
else:
paths = airplan_root(project_root) / "state" / "airtst"
state = safe_json_load(paths / "state.json") or {}
print(f"airplan_mode=tst\nenabled={state.get('enabled', False)}")

View File

@@ -0,0 +1,44 @@
"""AirXDB mode -- GUI verification via screenshot capture."""
from __future__ import annotations
from pathlib import Path
from air_runtime.xdb_capture import CaptureManager, CaptureResult
from air_runtime.events import EventLog, XDB_CAPTURED
from air_runtime.paths import event_log_path
def capture_screenshot(
project_root: Path,
output_name: str = "screenshot.png",
prefer: str = "auto",
) -> CaptureResult:
out_path = project_root / "AirPlan" / "state" / "airxdb" / "captures" / output_name
out_path.parent.mkdir(parents=True, exist_ok=True)
mgr = CaptureManager()
result = mgr.capture(out_path, prefer)
log = EventLog(event_log_path(project_root))
log.emit(XDB_CAPTURED, {
"outputName": output_name,
"success": result.success,
"method": result.method,
"outputPath": str(result.output_path),
})
return result
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
prefer = getattr(args, "prefer", "auto")
output = getattr(args, "output", None) or "screenshot.png"
result = capture_screenshot(project_root, output, prefer)
print("airplan_mode=xdb")
print(f"success={result.success}")
print(f"method={result.method}")
print(f"output={result.output_path}")
if result.error:
print(f"error={result.error}")

View File

@@ -0,0 +1,97 @@
"""AirXDB mode — V2 GUI调试器AirSDB mode — V2 静态分析AirNDB mode — V2 网络调试。"""
# --- AirXDB ---
from __future__ import annotations
import json
from pathlib import Path
from air_runtime.io import atomic_json_write
from air_runtime.paths import airplan_root
from air_runtime.utils import now_iso
def _xdb_paths(project_root: Path) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "airxdb"
return {"root": root, "state": root / "state.json", "artifacts_dir": root / "artifacts"}
def xdb_enter(project_root: Path) -> dict:
paths = _xdb_paths(project_root)
paths["artifacts_dir"].mkdir(parents=True, exist_ok=True)
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso(),
"projectRoot": str(project_root)})
return {"state_path": str(paths["state"])}
def xdb_status(project_root: Path) -> dict:
from air_runtime.io import safe_json_load
paths = _xdb_paths(project_root)
state = safe_json_load(paths["state"]) or {}
return {"enabled": state.get("enabled", False)}
def xdb_main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
if sub == "enter":
result = xdb_enter(project_root)
print(f"airplan_mode=xdb\nstate_path={result['state_path']}")
else:
s = xdb_status(project_root)
print(f"airplan_mode=xdb\nenabled={s['enabled']}")
# --- AirSDB ---
def _sdb_paths(project_root: Path) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "airsdb"
return {"root": root, "state": root / "state.json", "reports_dir": root / "reports"}
def sdb_enter(project_root: Path) -> dict:
paths = _sdb_paths(project_root)
paths["reports_dir"].mkdir(parents=True, exist_ok=True)
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso()})
return {"state_path": str(paths["state"])}
def sdb_main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
from air_runtime.io import safe_json_load
paths = _sdb_paths(project_root)
if sub == "enter":
result = sdb_enter(project_root)
print(f"airplan_mode=sdb\nstate_path={result['state_path']}")
else:
state = safe_json_load(paths["state"]) or {}
print(f"airplan_mode=sdb\nenabled={state.get('enabled', False)}")
# --- AirNDB ---
def _ndb_paths(project_root: Path) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "airndb"
return {"root": root, "state": root / "state.json", "captures_dir": root / "captures"}
def ndb_enter(project_root: Path) -> dict:
paths = _ndb_paths(project_root)
paths["captures_dir"].mkdir(parents=True, exist_ok=True)
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso()})
return {"state_path": str(paths["state"])}
def ndb_main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
from air_runtime.io import safe_json_load
paths = _ndb_paths(project_root)
if sub == "enter":
result = ndb_enter(project_root)
print(f"airplan_mode=ndb\nstate_path={result['state_path']}")
else:
state = safe_json_load(paths["state"]) or {}
print(f"airplan_mode=ndb\nenabled={state.get('enabled', False)}")

99
lib/air_runtime/paths.py Normal file
View File

@@ -0,0 +1,99 @@
"""
路径约定 — V2 统一所有子模块的 AirPlan 目录结构。
"""
from __future__ import annotations
from pathlib import Path
def airplan_root(project_root: Path) -> Path:
return project_root / "AirPlan"
def state_root(project_root: Path) -> Path:
return airplan_root(project_root) / "state"
def todo_path(project_root: Path) -> Path:
return airplan_root(project_root) / "todo.md"
def plan_path(project_root: Path) -> Path:
return airplan_root(project_root) / "plan.md"
def agents_path(project_root: Path) -> Path:
return airplan_root(project_root) / "AGENTS.md"
def docs_root(project_root: Path) -> Path:
return airplan_root(project_root) / "docs"
# --- 子模块状态路径 ---
def engine_state_path(project_root: Path) -> Path:
return state_root(project_root) / "aireng" / "state.json"
def worker_state_path(project_root: Path, task_id: str) -> Path:
return state_root(project_root) / "airdo" / "tasks" / task_id / "worker-state.json"
def arc_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airarc" / "state.json"
def dbg_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airdbg" / "state.json"
def xdb_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airxdb" / "state.json"
def sdb_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airsdb" / "state.json"
def ndb_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airndb" / "state.json"
def ctx_state_path(project_root: Path) -> Path:
return state_root(project_root) / "aircontext" / "state.json"
def dep_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airdep" / "state.json"
def tst_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airtst" / "state.json"
def sec_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airsec" / "state.json"
def rvr_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airrvr" / "state.json"
def event_log_path(project_root: Path) -> Path:
return state_root(project_root) / "events.jsonl"
def task_graph_state_path(project_root: Path) -> Path:
return state_root(project_root) / "task-graph.json"
def required_project_artifacts() -> list[str]:
return [
"AirPlan/AGENTS.md",
"AirPlan/plan.md",
"AirPlan/todo.md",
"AirPlan/docs/architecture/adr/",
"AirPlan/docs/architecture/c4/module.md",
]

View File

@@ -0,0 +1,59 @@
"""
项目引导模块 — 确保 AirPlan 目录结构存在。
V2 保持与 V1 相同的不变量:制品驱动通信、上下文隔离。
"""
from __future__ import annotations
from pathlib import Path
def ensure_project_bootstrap(project_root: Path) -> dict[str, bool]:
"""创建 AirPlan 必需目录结构。"""
root = project_root / "AirPlan"
docs = root / "docs"
arch = docs / "architecture"
adr_dir = arch / "adr"
c4_dir = arch / "c4"
debug_dir = docs / "debug"
state = root / "state"
dirs = [
root,
docs,
arch,
adr_dir,
c4_dir,
debug_dir,
state,
state / "airarc" / "reviews",
state / "aireng" / "dispatch",
state / "aireng" / "archive",
state / "aireng" / "plans",
state / "airdo" / "tasks",
state / "airdbg" / "sessions",
state / "airdbg" / "snapshots",
state / "airxdb" / "artifacts",
state / "airsdb" / "reports",
state / "airndb" / "captures",
state / "aircontext",
state / "airdep" / "sessions",
state / "airtst" / "reports",
state / "airsec",
state / "airrvr" / "reviews",
]
for d in dirs:
d.mkdir(parents=True, exist_ok=True)
# 创建必要文件
(root / "AGENTS.md").touch()
(root / "plan.md").touch()
(root / "todo.md").touch()
(adr_dir / "placeholder.md").touch()
(c4_dir / "module.md").touch()
(debug_dir / "debug-log.md").touch()
(debug_dir / "gui-debug-log.md").touch()
(docs / "staticanalysis.md").touch()
return {"bootstrap": True}

126
lib/air_runtime/review.py Normal file
View File

@@ -0,0 +1,126 @@
"""
并行审查模块 — V2 从 V1 迁移。
分析任务依赖、写集冲突、产出并行组和串行点。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from air_runtime.todo_parser import parse_tasks
@dataclass
class ParallelGroup:
name: str
task_ids: list[str]
reason: str = ""
def to_dict(self) -> dict:
return {"name": self.name, "task_ids": self.task_ids, "reason": self.reason}
@dataclass
class Conflict:
task_a: str
task_b: str
reason: str = ""
def to_dict(self) -> dict:
return {"task_a": self.task_a, "task_b": self.task_b, "reason": self.reason}
@dataclass
class ReviewResult:
parallel_groups: list[ParallelGroup] = field(default_factory=list)
conflicts: list[Conflict] = field(default_factory=list)
serialization_points: list[dict] = field(default_factory=list)
edges: list[dict] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"parallelGroups": [{"name": g.name, "task_ids": g.task_ids, "reason": g.reason} for g in self.parallel_groups],
"conflicts": [{"task_a": c.task_a, "task_b": c.task_b, "reason": c.reason} for c in self.conflicts],
"serializationPoints": self.serialization_points,
"edges": self.edges,
}
@classmethod
def from_dict(cls, data: dict) -> ReviewResult:
return cls(
parallel_groups=[ParallelGroup(**g) for g in data.get("parallelGroups", [])],
conflicts=[Conflict(**c) for c in data.get("conflicts", [])],
serialization_points=data.get("serializationPoints", []),
edges=data.get("edges", []),
)
def build_parallel_review(todo_path: Path) -> ReviewResult:
"""分析 todo.md产出并行组和冲突。"""
tasks = parse_tasks(todo_path)
result = ReviewResult()
# 解析依赖task 文本中的 "依赖 T-xxx" 或 Done When 中的引用
edges = []
for t in tasks:
deps = re.findall(r"T-\d+[a-z]*", t.done_when)
deps.extend(re.findall(r"依赖\s+(T-\d+[a-z]*)", t.task))
for dep in deps:
if dep != t.task_id:
edges.append({"source": dep, "target": t.task_id, "kind": "dependency"})
result.edges.append({"source": dep, "target": t.task_id, "kind": "dependency"})
# 写集冲突检测
file_map: dict[str, list[str]] = {}
for t in tasks:
if t.files_dirs:
files = [f.strip() for f in t.files_dirs.split(",")]
for f in files:
file_map.setdefault(f, []).append(t.task_id)
conflicts = []
for fpath, tid_list in file_map.items():
for i, tid_a in enumerate(tid_list):
for tid_b in tid_list[i + 1:]:
conflicts.append(Conflict(tid_a, tid_b, f"shared file: {fpath}"))
result.conflicts = conflicts
# 串行点:同文件不同任务的依赖链
for fpath, tid_list in file_map.items():
if len(tid_list) > 1:
for tid in tid_list[1:]:
result.serialization_points.append({
"taskId": tid,
"reasons": [f"serialized with {tid_list[0]} due to shared file: {fpath}"],
})
# 并行组:入度为 0 的任务
target_count = {e["target"] for e in edges}
ready = [t.task_id for t in tasks if t.task_id not in target_count and t.status == "TODO"]
if ready:
result.parallel_groups.append(ParallelGroup(
name="wave-1", task_ids=ready,
reason="no dependencies on other TODO tasks",
))
return result
def render_review_markdown(review: ReviewResult) -> str:
lines = ["# AirArc Parallel Review", ""]
lines.append(f"## Summary")
lines.append(f"- Parallel groups: {len(review.parallel_groups)}")
lines.append(f"- Conflicts: {len(review.conflicts)}")
lines.append(f"- Serialization points: {len(review.serialization_points)}")
lines.append("")
lines.append("## Parallel Groups")
for g in review.parallel_groups:
lines.append(f"### {g.name}")
lines.append(f"Reason: {g.reason}")
lines.append(f"Tasks: {', '.join(g.task_ids)}")
lines.append("")
lines.append("## Conflicts")
for c in review.conflicts:
lines.append(f"- {c.task_a} <-> {c.task_b}: {c.reason}")
return "\n".join(lines)

View File

@@ -0,0 +1,148 @@
"""
AirRvr 需求审查运行时 — V2 新增组件。
基于原始需求文档对已完成任务进行独立审查,验证交付物与需求的一致性。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from air_runtime.io import atomic_json_write
from air_runtime.paths import rvr_state_path
from air_runtime.utils import session_stamp
@dataclass
class RequirementCoverage:
requirement: str
status: str # covered | partial | missing
evidence: str = ""
@dataclass
class CodeToDesignItem:
design_item: str
implementation_status: str # aligned | divergent | missing
code_location: str = ""
design_location: str = ""
divergence_detail: str = ""
@dataclass
class ReviewReport:
task_id: str
verdict: str # pass | conditional-pass | fail
coverage: list[RequirementCoverage] = field(default_factory=list)
intent_alignment: str = "aligned" # aligned | divergent
divergence_notes: str = ""
regression_risk: str = "none" # none | low | medium | high
code_quality: dict = field(default_factory=lambda: {
"complexity": "low", "readability": "good", "duplication": "none", "error_handling": "complete",
})
lifecycle_health: dict = field(default_factory=lambda: {
"resource_leak": "none", "connection_management": "proper",
"timeout_strategy": "present", "retry_strategy": "present",
})
runtime_stability: dict = field(default_factory=lambda: {
"crash_risk": "none", "race_condition": "none",
"memory_leak": "none", "user_impact": "none",
})
code_to_design_table: list[CodeToDesignItem] = field(default_factory=list)
logging_checks: dict = field(default_factory=lambda: {
"spdlog_integrated": False,
"non_standard_logging": [],
"debug_release_switch": False,
"critical_path_logging": False,
"unified_format": False,
})
recommendations: list[str] = field(default_factory=list)
class ReviewRuntime:
"""AirRvr 审查运行时 — 管理审查会话和报告持久化。"""
REVIEW_MODES = ["per-task", "per-wave", "per-milestone"]
def __init__(self, project_root: Path):
self._project_root = project_root
self._state_dir = rvr_state_path(project_root).parent
self._reviews_dir = self._state_dir / "reviews"
self._reviews_dir.mkdir(parents=True, exist_ok=True)
def save_report(self, report: ReviewReport) -> Path:
report_path = self._reviews_dir / f"{report.task_id}-{session_stamp()}.json"
atomic_json_write(report_path, self._report_to_dict(report))
return report_path
def load_report(self, task_id: str, timestamp: str) -> ReviewReport | None:
from air_runtime.io import safe_json_load
report_path = self._reviews_dir / f"{task_id}-{timestamp}.json"
data = safe_json_load(report_path)
if data:
return self._dict_to_report(data)
return None
def get_integration_verdict(self, report: ReviewReport) -> str:
"""与 AirEng 集成pass → 允许合并conditional-pass → 合并但记录遗留项fail → 阻止合并。"""
return report.verdict
def get_verdict_for_task(self, task_id: str) -> dict:
"""从持久化的 review report 读 verdict返回 dict 含 verdict/residual/reportPath。
没有 report 时返回 {"verdict": "pass", "reportPath": ""}(默认放行)。"""
from air_runtime.io import safe_json_load
# reports/ 是 AirEng 约定的存放路径(验证脚本和 eng_mode 期望的位置)
reports_dir = self._state_dir / "reports"
report_path = reports_dir / f"{task_id}.json"
if not report_path.exists():
# 兼容旧路径 reviews/ 下的 {task_id}-{ts}.json找最新一份
alt = self._reviews_dir
if alt.exists():
candidates = sorted(alt.glob(f"{task_id}-*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
if candidates:
report_path = candidates[0]
if not report_path.exists():
return {"verdict": "pass", "reportPath": "", "residual": []}
report = safe_json_load(report_path)
if not report or not isinstance(report, dict):
return {"verdict": "pass", "reportPath": str(report_path), "residual": []}
return {
"verdict": report.get("verdict", "pass"),
"residual": report.get("residual", []),
"reportPath": str(report_path),
"summary": report.get("summary", ""),
}
@staticmethod
def _report_to_dict(report: ReviewReport) -> dict:
return {
"taskId": report.task_id,
"verdict": report.verdict,
"coverage": [c.__dict__ for c in report.coverage],
"intentAlignment": report.intent_alignment,
"divergenceNotes": report.divergence_notes,
"regressionRisk": report.regression_risk,
"codeQuality": report.code_quality,
"lifecycleHealth": report.lifecycle_health,
"runtimeStability": report.runtime_stability,
"codeToDesignTable": [c.__dict__ for c in report.code_to_design_table],
"loggingChecks": report.logging_checks,
"recommendations": report.recommendations,
}
@staticmethod
def _dict_to_report(data: dict) -> ReviewReport:
return ReviewReport(
task_id=data.get("taskId", ""),
verdict=data.get("verdict", "fail"),
coverage=[RequirementCoverage(**c) for c in data.get("coverage", [])],
intent_alignment=data.get("intentAlignment", "aligned"),
divergence_notes=data.get("divergenceNotes", ""),
regression_risk=data.get("regressionRisk", "none"),
code_quality=data.get("codeQuality", {}),
lifecycle_health=data.get("lifecycleHealth", {}),
runtime_stability=data.get("runtimeStability", {}),
code_to_design_table=[CodeToDesignItem(**c) for c in data.get("codeToDesignTable", [])],
logging_checks=data.get("loggingChecks", {}),
recommendations=data.get("recommendations", []),
)

View File

@@ -0,0 +1,527 @@
"""AirSDB backends — 5 static analyzer backends + AnalysisDiff.
Backends:
CppcheckBackend — C/C++ via cppcheck
ClangTidyBackend — C/C++ via clang-tidy
RustClippyBackend — Rust via cargo clippy
GoVetBackend — Go via go vet + staticcheck
TypeScriptBackend — TypeScript via tsc --noEmit
"""
from __future__ import annotations
import json
import shutil
import subprocess
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
# ---------------------------------------------------------------------------
# Unified result type
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class AnalysisResult:
tool: str
file: str
line: int | None
column: int | None
severity: str # error | warning | info
message: str
rule_id: str | None = None
# ---------------------------------------------------------------------------
# Abstract base
# ---------------------------------------------------------------------------
class StaticAnalyzerBackend(ABC):
"""Abstract base for every static-analysis backend."""
@abstractmethod
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
"""Run the analyzer and return structured findings."""
@property
@abstractmethod
def name(self) -> str:
"""Short identifier for this backend (e.g. 'cppcheck')."""
@property
@abstractmethod
def install_hint(self) -> str:
"""Human-readable hint shown when the tool is not installed."""
# -- helpers available to all backends ---------------------------------
def _check_tool(self, tool_cmd: str) -> None:
"""Raise RuntimeError if *tool_cmd* is not on PATH."""
if not shutil.which(tool_cmd):
raise RuntimeError(self.install_hint)
@staticmethod
def _run(cmd: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess:
"""Run *cmd* and capture stdout/stderr. Returns CompletedProcess."""
return subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
timeout=300,
)
# ---------------------------------------------------------------------------
# CppcheckBackend
# ---------------------------------------------------------------------------
class CppcheckBackend(StaticAnalyzerBackend):
"""C/C++ static analysis via cppcheck."""
name = "cppcheck"
install_hint = (
"cppcheck is not installed. "
"Install it with: sudo apt install cppcheck (Debian/Ubuntu) "
"or: brew install cppcheck (macOS)"
)
# Template: file:line:column:severity:id:message
_TEMPLATE = "{file}:{line}:{column}:{severity}:{id}:{message}"
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
self._check_tool("cppcheck")
src = str(target) if target else str(project_root)
cmd = [
"cppcheck",
"--quiet",
f"--template={self._TEMPLATE}",
"--force",
src,
]
proc = self._run(cmd, cwd=project_root)
results: list[AnalysisResult] = []
for line in proc.stderr.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(":", 5)
if len(parts) < 6:
continue
try:
ln = int(parts[1]) if parts[1].strip() else None
except ValueError:
ln = None
try:
col = int(parts[2]) if parts[2].strip() else None
except ValueError:
col = None
severity = parts[3].strip()
# Map cppcheck severities to our unified set
if severity not in ("error", "warning", "info"):
if severity in ("performance", "portability", "style"):
severity = "warning"
else:
severity = "info"
results.append(AnalysisResult(
tool=self.name,
file=parts[0].strip(),
line=ln,
column=col,
severity=severity,
message=parts[5].strip(),
rule_id=parts[4].strip() or None,
))
return results
# ---------------------------------------------------------------------------
# ClangTidyBackend
# ---------------------------------------------------------------------------
class ClangTidyBackend(StaticAnalyzerBackend):
"""C/C++ static analysis via clang-tidy."""
name = "clang-tidy"
install_hint = (
"clang-tidy is not installed. "
"Install it with: sudo apt install clang-tidy (Debian/Ubuntu) "
"or: brew install llvm (macOS, then use llvm/bin/clang-tidy)"
)
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
self._check_tool("clang-tidy")
src = str(target) if target else str(project_root)
cmd = [
"clang-tidy",
"--quiet",
src,
]
# Use compile_commands.json if present
comp_db = project_root / "compile_commands.json"
if comp_db.exists():
cmd.append(f"-p={comp_db.parent}")
proc = self._run(cmd, cwd=project_root)
results: list[AnalysisResult] = []
# clang-tidy output format: <file>:<line>:<col>: warning: <message> [check-name]
for line in proc.stderr.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(":", 3)
if len(parts) < 4:
continue
try:
ln = int(parts[1].strip()) if parts[1].strip() else None
except ValueError:
ln = None
try:
col = int(parts[2].strip()) if parts[2].strip() else None
except ValueError:
col = None
msg_part = parts[3].strip()
severity = "warning"
# Detect "error:" prefix
if msg_part.startswith("error:"):
severity = "error"
msg_part = msg_part[len("error:"):].strip()
elif msg_part.startswith("warning:"):
msg_part = msg_part[len("warning:"):].strip()
elif msg_part.startswith("note:"):
severity = "info"
msg_part = msg_part[len("note:"):].strip()
# Extract [check-name] at the end
rule_id = None
if msg_part.endswith("]"):
bracket = msg_part.rfind("[")
if bracket != -1:
rule_id = msg_part[bracket + 1:-1].strip()
msg_part = msg_part[:bracket].strip()
results.append(AnalysisResult(
tool=self.name,
file=parts[0].strip(),
line=ln,
column=col,
severity=severity,
message=msg_part,
rule_id=rule_id,
))
return results
# ---------------------------------------------------------------------------
# RustClippyBackend
# ---------------------------------------------------------------------------
class RustClippyBackend(StaticAnalyzerBackend):
"""Rust static analysis via cargo clippy."""
name = "clippy"
install_hint = (
"cargo clippy is not available. "
"Install Rust toolchain: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh "
"then: rustup component add clippy"
)
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
self._check_tool("cargo")
cmd = [
"cargo", "clippy",
"--message-format=json",
]
# If a specific target file/dir is given, we still run cargo clippy
# on the whole crate (cargo does not support single-file analysis).
proc = self._run(cmd, cwd=project_root)
results: list[AnalysisResult] = []
for line in proc.stdout.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if obj.get("reason") != "compiler-message":
continue
msg = obj.get("message", {})
level = msg.get("level", "")
if level == "error":
severity = "error"
elif level in ("warning",):
severity = "warning"
else:
severity = "info"
for span in msg.get("spans", []):
results.append(AnalysisResult(
tool=self.name,
file=span.get("file_name", ""),
line=span.get("line_start"),
column=span.get("column_start"),
severity=severity,
message=msg.get("message", ""),
rule_id=msg.get("code", {}).get("code") or None,
))
# If no JSON output (e.g. compile error), also parse stderr
if not results and proc.stderr:
for line in proc.stderr.splitlines():
line = line.strip()
if "error" in line.lower() and ":" in line:
results.append(AnalysisResult(
tool=self.name,
file=str(project_root),
line=None,
column=None,
severity="error",
message=line,
rule_id=None,
))
return results
# ---------------------------------------------------------------------------
# GoVetBackend
# ---------------------------------------------------------------------------
class GoVetBackend(StaticAnalyzerBackend):
"""Go static analysis via go vet + staticcheck."""
name = "go-vet"
install_hint = (
"go is not installed. "
"Install Go: https://go.dev/dl/ "
"For staticcheck: go install honnef.co/go/tools/cmd/staticcheck@latest"
)
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
self._check_tool("go")
results: list[AnalysisResult] = []
# 1) go vet — JSON output
vet_cmd = ["go", "vet", "./..."]
proc = self._run(vet_cmd, cwd=project_root)
if proc.stderr:
results.extend(self._parse_go_vet_output(proc.stderr))
# 2) staticcheck (optional — don't fail if not installed)
if shutil.which("staticcheck"):
sc_cmd = ["staticcheck", "-f=json", "./..."]
sc_proc = self._run(sc_cmd, cwd=project_root)
for line in sc_proc.stdout.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
severity = "warning"
if obj.get("severity", "") == "error":
severity = "error"
results.append(AnalysisResult(
tool="staticcheck",
file=obj.get("location", {}).get("file", ""),
line=obj.get("location", {}).get("line"),
column=obj.get("location", {}).get("column"),
severity=severity,
message=obj.get("message", ""),
rule_id=obj.get("code", ""),
))
return results
@staticmethod
def _parse_go_vet_output(text: str) -> list[AnalysisResult]:
"""Parse go vet stderr output.
go vet output format (non-JSON):
<file>:<line>: <message>
"""
results: list[AnalysisResult] = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(":", 2)
if len(parts) < 3:
continue
try:
ln = int(parts[1].strip()) if parts[1].strip() else None
except ValueError:
ln = None
results.append(AnalysisResult(
tool="go vet",
file=parts[0].strip(),
line=ln,
column=None,
severity="warning",
message=parts[2].strip(),
rule_id=None,
))
return results
# ---------------------------------------------------------------------------
# TypeScriptBackend
# ---------------------------------------------------------------------------
class TypeScriptBackend(StaticAnalyzerBackend):
"""TypeScript static analysis via tsc --noEmit."""
name = "tsc"
install_hint = (
"tsc (TypeScript compiler) is not installed. "
"Install it with: npm install -g typescript "
"or add it to your project: npm install --save-dev typescript"
)
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
# tsc can be installed locally (npx) or globally
tsc_cmd = self._find_tsc()
if tsc_cmd is None:
raise RuntimeError(self.install_hint)
cmd = tsc_cmd + ["--noEmit", "--pretty", "false"]
proc = self._run(cmd, cwd=project_root)
results: list[AnalysisResult] = []
# tsc output format: <file>(<line>,<col>): error TS<code>: <message>
for line in proc.stdout.splitlines():
line = line.strip()
if not line:
continue
results.append(self._parse_tsc_line(line))
return results
def _find_tsc(self) -> list[str] | None:
"""Return the tsc command as a list, or None if not found."""
if shutil.which("tsc"):
return ["tsc"]
if shutil.which("npx"):
return ["npx", "tsc"]
return None
@staticmethod
def _parse_tsc_line(line: str) -> AnalysisResult:
"""Parse a single tsc diagnostic line.
Format: <file>(<line>,<col>): error TS1234: <message>
"""
severity = "error"
rule_id = None
# Split on the first colon-space after the position paren
# e.g. "src/foo.ts(10,5): error TS2322: Type 'string' ..."
main_parts = line.split(": ", 1)
location_part = main_parts[0] if main_parts else line
message = main_parts[1].strip() if len(main_parts) > 1 else ""
# Extract file, line, column from "file(line,col)"
file_part = location_part
ln = None
col = None
paren = location_part.rfind("(")
if paren != -1 and location_part.endswith(")"):
file_part = location_part[:paren]
pos_str = location_part[paren + 1:-1]
pos_parts = pos_str.split(",", 1)
try:
ln = int(pos_parts[0].strip()) if pos_parts[0].strip() else None
except ValueError:
pass
if len(pos_parts) > 1:
try:
col = int(pos_parts[1].strip()) if pos_parts[1].strip() else None
except ValueError:
pass
# Extract severity + rule from " error TS2322" in the remainder
if len(main_parts) > 1:
# The part between the first colon-space and the message
# is in the original line — re-parse
rest = line[len(location_part) + 2:] # after ": "
if rest.startswith("error "):
severity = "error"
rest = rest[len("error "):]
elif rest.startswith("warning "):
severity = "warning"
rest = rest[len("warning "):]
# rest now starts with "TS1234: message"
ts_parts = rest.split(": ", 1)
if ts_parts:
rule_id = ts_parts[0].strip() or None
if len(ts_parts) > 1:
message = ts_parts[1].strip()
return AnalysisResult(
tool="tsc",
file=file_part,
line=ln,
column=col,
severity=severity,
message=message,
rule_id=rule_id,
)
# ---------------------------------------------------------------------------
# AnalysisDiff
# ---------------------------------------------------------------------------
class AnalysisDiff:
"""Compare two lists of AnalysisResult and classify findings as
new, resolved, or unchanged."""
@staticmethod
def _key(r: AnalysisResult) -> tuple[str, int | None, str | None]:
"""Dedup key: (file, line, rule_id)."""
return (r.file, r.line, r.rule_id)
def diff(
self,
before: list[AnalysisResult],
after: list[AnalysisResult],
) -> dict:
before_keys = {self._key(r): r for r in before}
after_keys = {self._key(r): r for r in after}
before_set = set(before_keys.keys())
after_set = set(after_keys.keys())
new_keys = after_set - before_set
resolved_keys = before_set - after_set
unchanged_keys = before_set & after_set
return {
"new": [after_keys[k] for k in new_keys],
"resolved": [before_keys[k] for k in resolved_keys],
"unchanged": [before_keys[k] for k in unchanged_keys],
}
# ---------------------------------------------------------------------------
# Convenience registry
# ---------------------------------------------------------------------------
BACKENDS: dict[str, StaticAnalyzerBackend] = {
"cppcheck": CppcheckBackend(),
"clang-tidy": ClangTidyBackend(),
"clippy": RustClippyBackend(),
"go-vet": GoVetBackend(),
"tsc": TypeScriptBackend(),
}

View File

@@ -0,0 +1,175 @@
"""
AirSec 安全扫描运行时 — V2 新增组件。
制品敏感数据扫描 + 自动脱敏 + 误报白名单 + 确认流程 + advisory/blocking 模式。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
SECRET_PATTERNS: list[tuple[str, str]] = [
("api_key", r'(?:api[_-]?key|apikey)\s*[:=]\s*["\']?([A-Za-z0-9_\-]{16,})["\']?'),
("aws_key", r'AKIA[0-9A-Z]{16}'),
("private_key", r'-----BEGIN (?:RSA|EC|DSA|OPENSSH) PRIVATE KEY-----'),
("token", r'(?:token|secret|password)\s*[:=]\s*["\']?([^\s"\']{8,})["\']?'),
("jwt", r'eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+'),
("url_credential", r'https?://[^:@]+:([^@]+)@'),
]
ALLOWLIST_PATTERNS: list[str] = [
r'EXAMPLE',
r'example',
r'YOUR_API_KEY',
r'TODO',
r'<your-',
r'placeholder',
r'xxxxxxxx',
]
# 文件名白名单:命中则跳过该文件的所有发现
WHITELIST_FILE_PATTERNS: list[str] = [
r"\.example\.",
r"^test_",
r"^mock_",
r"_test\.py$",
r"\.fixture\.",
]
# 用户确认记录(首次发现需确认,后续同模式自动放行)
USER_CONFIRMATIONS: dict[str, dict] = {} # {fingerprint: {pattern, confirmed_at, user}}
class ScanMode:
"""扫描模式advisory只报告不阻止/ blocking阻止合并"""
ADVISORY = "advisory"
BLOCKING = "blocking"
@dataclass
class ScanFinding:
rule: str
file: str
line: int
match: str # 截断显示,不包含完整密钥
severity: str = "high" # high | medium | low
@dataclass
class ScanReport:
task_id: str
findings: list[ScanFinding] = field(default_factory=list)
whitelisted: int = 0
clean: bool = True
advisory_blocked: bool = False
def scan_file(file_path: Path, task_id: str = "") -> ScanReport:
findings: list[ScanFinding] = []
whitelisted = 0
try:
content = file_path.read_text(encoding="utf-8", errors="replace")
except Exception:
return ScanReport(task_id=task_id, clean=True)
for rule, pattern in SECRET_PATTERNS:
for match in re.finditer(pattern, content, re.IGNORECASE):
matched_text = match.group(0)
if any(re.search(ap, matched_text) for ap in ALLOWLIST_PATTERNS):
whitelisted += 1
continue
line_no = content[:match.start()].count("\n") + 1
display = matched_text[:60] + "..." if len(matched_text) > 60 else matched_text
findings.append(ScanFinding(
rule=rule, file=str(file_path), line=line_no, match=display,
))
return ScanReport(
task_id=task_id,
findings=findings,
whitelisted=whitelisted,
clean=len(findings) == 0,
)
def scan_result_data(result: dict, task_id: str = "") -> ScanReport:
"""扫描 Worker result.json 中的敏感数据。"""
import json
text = json.dumps(result, ensure_ascii=False)
findings: list[ScanFinding] = []
whitelisted = 0
for rule, pattern in SECRET_PATTERNS:
for match in re.finditer(pattern, text, re.IGNORECASE):
matched_text = match.group(0)
if any(re.search(ap, matched_text) for ap in ALLOWLIST_PATTERNS):
whitelisted += 1
continue
display = matched_text[:60] + "..." if len(matched_text) > 60 else matched_text
findings.append(ScanFinding(
rule=rule, file="result.json", line=0, match=display,
))
return ScanReport(
task_id=task_id,
findings=findings,
whitelisted=whitelisted,
clean=len(findings) == 0,
)
def scan_file_with_mode(
file_path: Path,
task_id: str = "",
mode: str = ScanMode.BLOCKING,
confirm_callback=None, # 可选:首次发现时调用此回调询问用户
) -> ScanReport:
"""
增强版扫描:
1. 基础扫描(已有逻辑)
2. 文件名白名单过滤
3. 模式判断advisory vs blocking
"""
report = scan_file(file_path, task_id) # 原有逻辑
# 文件名白名单过滤
filtered_findings = []
for f in report.findings:
filename = file_path.name
if any(re.search(p, filename) for p in WHITELIST_FILE_PATTERNS):
report.whitelisted += 1
continue
filtered_findings.append(f)
report.findings = filtered_findings
report.clean = len(filtered_findings) == 0
# 模式处理
if not report.clean and mode == ScanMode.ADVISORY:
# advisory 模式:只记录,不阻止
report.advisory_blocked = False
elif not report.clean and mode == ScanMode.BLOCKING:
# blocking 模式:默认阻止
report.advisory_blocked = True
return report
def confirm_pattern(task_id: str, pattern: str, user: str = "unknown") -> None:
"""用户确认某模式为安全后,记录下来"""
from air_runtime.utils import now_iso
fingerprint = f"{task_id}:{pattern}"
USER_CONFIRMATIONS[fingerprint] = {
"pattern": pattern,
"confirmed_at": now_iso(),
"user": user,
}
def is_confirmed(task_id: str, pattern: str) -> bool:
"""检查某模式是否已被用户确认"""
fingerprint = f"{task_id}:{pattern}"
return fingerprint in USER_CONFIRMATIONS

View File

@@ -0,0 +1,220 @@
"""
动态任务依赖图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)
@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", [])),
)
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)

View File

@@ -0,0 +1,183 @@
"""
AirTst 测试运行器运行时 — V2 新增组件。
统一测试执行接口,支持多框架,产出结构化结果。
"""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from air_runtime.io import atomic_json_write
from air_runtime.paths import tst_state_path
@dataclass
class TestCase:
name: str
status: str # passed | failed | skipped
duration: str = ""
message: str = ""
@dataclass
class TestSuite:
name: str
total: int = 0
passed: int = 0
failed: int = 0
skipped: int = 0
cases: list[TestCase] = field(default_factory=list)
@dataclass
class TestRunResult:
framework: str
total: int = 0
passed: int = 0
failed: int = 0
disabled: int = 0
duration: str = ""
suites: list[TestSuite] = field(default_factory=list)
failures: list[dict] = field(default_factory=list)
class TestRunner:
"""统一测试执行器。"""
FRAMEWORKS = {
"pytest": ["python", "-m", "pytest", "--json-report", "-q"],
"googletest": ["ctest", "--output-on-failure"],
"jest": ["npx", "jest", "--json"],
"vitest": ["npx", "vitest", "run", "--reporter=json"],
"go": ["go", "test", "-json", "./..."],
"cargo": ["cargo", "test", "--", "--format=json"],
}
def run(self, task_id: str, project_root: Path, framework: str,
target_path: Path | None = None,
extra_args: list[str] | None = None) -> TestRunResult:
if framework not in self.FRAMEWORKS:
return TestRunResult(framework=framework, failures=[{"error": f"unsupported framework: {framework}"}])
cmd = list(self.FRAMEWORKS[framework])
if target_path:
cmd.append(str(target_path))
if extra_args:
cmd.extend(extra_args)
try:
result = subprocess.run(cmd, capture_output=True, text=True,
timeout=600, cwd=str(project_root))
except subprocess.TimeoutExpired:
return TestRunResult(framework=framework, failures=[{"error": "timeout"}])
run_result = self._parse_result(framework, result.stdout)
self._save_report(task_id, project_root, run_result)
return run_result
def _parse_result(self, framework: str, stdout: str) -> TestRunResult:
if framework == "pytest":
return self._parse_pytest(stdout)
if framework in ("jest", "vitest"):
return self._parse_jest(stdout)
if framework == "googletest":
return self._parse_googletest(stdout)
if framework == "go":
return self._parse_go(stdout)
if framework == "cargo":
return self._parse_cargo(stdout)
return TestRunResult(framework=framework, total=0)
def _parse_pytest(self, stdout: str) -> TestRunResult:
try:
data = json.loads(stdout)
except json.JSONDecodeError:
return TestRunResult(framework="pytest", failures=[{"error": "json parse failed"}])
return TestRunResult(
framework="pytest",
total=data.get("summary", {}).get("total", 0),
passed=data.get("summary", {}).get("passed", 0),
failed=data.get("summary", {}).get("failed", 0),
duration=str(data.get("duration", "")),
)
def _parse_jest(self, stdout: str) -> TestRunResult:
try:
data = json.loads(stdout)
except json.JSONDecodeError:
return TestRunResult(framework="jest", failures=[{"error": "json parse failed"}])
return TestRunResult(
framework="jest",
total=data.get("numTotalTests", 0),
passed=data.get("numPassedTests", 0),
failed=data.get("numFailedTests", 0),
)
def _parse_googletest(self, stdout: str) -> TestRunResult:
"""解析 ctest 输出。ctest 不输出 JSON从文本提取统计。"""
import re
total = passed = failed = disabled = 0
for line in stdout.splitlines():
m = re.match(r"(\d+)% tests passed, (\d+) tests failed out of (\d+)", line)
if m:
failed = int(m.group(2))
total = int(m.group(3))
passed = total - failed
# GoogleTest 也支持 --gtest_output=json
try:
data = json.loads(stdout)
if isinstance(data, dict):
total = sum(s.get("tests", 0) for s in data.get("testsuites", []))
failed = sum(s.get("failures", 0) for s in data.get("testsuites", []))
passed = total - failed
disabled = sum(s.get("disabled", 0) for s in data.get("testsuites", []))
except json.JSONDecodeError:
pass
return TestRunResult(
framework="googletest", total=total, passed=passed, failed=failed, disabled=disabled,
)
def _parse_go(self, stdout: str) -> TestRunResult:
"""解析 go test -json 输出JSONL 格式,每行一个事件)。"""
total = passed = failed = 0
for line in stdout.splitlines():
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
action = ev.get("Action", "")
if action == "pass":
passed += 1
total += 1
elif action == "fail":
failed += 1
total += 1
elif action == "skip":
total += 1
return TestRunResult(framework="go", total=total, passed=passed, failed=failed)
def _parse_cargo(self, stdout: str) -> TestRunResult:
"""解析 cargo test --format=json 输出JSONL 格式)。"""
total = passed = failed = 0
for line in stdout.splitlines():
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
if ev.get("type") == "test":
total += 1
if ev.get("event") == "ok":
passed += 1
elif ev.get("event") == "failed":
failed += 1
return TestRunResult(framework="cargo", total=total, passed=passed, failed=failed)
def _save_report(self, task_id: str, project_root: Path, result: TestRunResult) -> None:
report_dir = tst_state_path(project_root).parent / "reports"
report_dir.mkdir(parents=True, exist_ok=True)
from air_runtime.utils import session_stamp
report_path = report_dir / f"{task_id}-{session_stamp()}.json"
atomic_json_write(report_path, result.__dict__)

View File

@@ -0,0 +1,96 @@
"""
TODO 解析器 — V2 修复 P1-8列索引从表头推导不再硬编码 cells[1]/cells[6]/cells[7]。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
@dataclass
class TodoTask:
task_id: str
task: str
files_dirs: str = ""
status: str = "TODO"
done_when: str = ""
validations: str = ""
adr: str = ""
def parse_tasks(todo_path: Path) -> list[TodoTask]:
"""从 todo.md 解析任务列表,动态检测列索引。"""
if not todo_path.exists():
return []
content = todo_path.read_text(encoding="utf-8")
lines = [l.strip() for l in content.splitlines() if l.strip()]
# 找到 Markdown 表格头
header_idx = -1
for i, line in enumerate(lines):
if line.startswith("|") and "Task" in line and "Status" in line:
header_idx = i
break
if header_idx < 0:
return []
# 解析列名
header_line = lines[header_idx]
header_cols = [c.strip() for c in header_line.split("|") if c.strip()]
# 建立列名 → 索引映射
col_map = {}
for idx, col_name in enumerate(header_cols):
col_name_lower = col_name.lower()
if "task" in col_name_lower:
col_map["task"] = idx
elif "status" in col_name_lower:
col_map["status"] = idx
elif "files" in col_name_lower or "dir" in col_name_lower:
col_map["files_dirs"] = idx
elif "done" in col_name_lower or "when" in col_name_lower:
col_map["done_when"] = idx
elif "valid" in col_name_lower:
col_map["validations"] = idx
elif "adr" in col_name_lower:
col_map["adr"] = idx
# 跳过表头和分隔符
tasks = []
for line in lines[header_idx + 2:]:
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.split("|") if len(c.strip()) > 0]
if not cells:
continue
task_cell = cells[col_map.get("task", 0)] if col_map.get("task", 0) < len(cells) else ""
# 提取 [T-xxx] 格式的任务 ID
tid_match = re.match(r"\[([A-Za-z0-9_\-\.]+)\]", task_cell)
task_id = tid_match.group(1) if tid_match else task_cell
task = task_cell
status = cells[col_map.get("status", 1)] if col_map.get("status", 1) < len(cells) else "TODO"
files_dirs = cells[col_map.get("files_dirs", 2)] if col_map.get("files_dirs", 2) < len(cells) else ""
done_when = cells[col_map.get("done_when", 3)] if col_map.get("done_when", 3) < len(cells) else ""
validations = cells[col_map.get("validations", 4)] if col_map.get("validations", 4) < len(cells) else ""
adr = cells[col_map.get("adr", 5)] if col_map.get("adr", 5) < len(cells) else ""
# 清理标记
task_id = re.sub(r"^\[|\]$", "", task_id).strip()
if task_id and task_id != "---":
tasks.append(TodoTask(
task_id=task_id,
task=task,
files_dirs=files_dirs,
status=status.upper() if status else "TODO",
done_when=done_when,
validations=validations,
adr=adr,
))
return tasks

65
lib/air_runtime/utils.py Normal file
View File

@@ -0,0 +1,65 @@
"""
公共工具函数 — 消除 V1 中 _ordered_unique、_session_stamp、policy normalization 等
在各模块中 3~5 份重复定义的代码。
"""
from __future__ import annotations
import re
from datetime import datetime, timezone
from typing import Any
def ordered_unique(items: list) -> list:
"""保序去重。支持字符串列表和带 id 字段的字典列表。"""
seen: set[str] = set()
result = []
for item in items:
key = item if isinstance(item, str) else item.get("id", str(item))
if key not in seen:
seen.add(key)
result.append(item)
return result
def session_stamp() -> str:
"""统一的文件系统安全时间戳,所有模块共用。"""
return datetime.now(timezone.utc).isoformat().replace(":", "-").replace(".", "-").replace("+", "-")
def now_iso() -> str:
"""ISO 格式 UTC 时间戳,用于 JSON state 文件。"""
return datetime.now(timezone.utc).isoformat()
def normalize_policy(defaults: dict[str, Any], overrides: dict[str, Any] | None) -> dict[str, Any]:
"""通用的策略合并overrides 覆盖 defaults类型自动转换。"""
merged = {**defaults}
if overrides:
for k, v in overrides.items():
if k in merged:
expected_type = type(defaults[k])
try:
merged[k] = expected_type(v) if not isinstance(v, expected_type) else v
except (ValueError, TypeError):
merged[k] = v
return merged
def sanitize_task_id(task_id: str) -> str:
"""防止路径注入:仅允许字母数字、下划线、连字符、点号。"""
if not re.fullmatch(r"[A-Za-z0-9_\-\.]+", task_id):
raise ValueError(f"invalid task_id: {task_id!r}")
return task_id
def sanitize_marker(marker: str) -> str:
"""防止 HTML 注释注入。"""
if "-->" in marker or "<!--" in marker:
raise ValueError(f"marker contains comment delimiters: {marker!r}")
return marker
def truncate_history(data: list, max_items: int = 100) -> list:
"""截断历史列表防止无界增长P2-2"""
return data[-max_items:] if len(data) > max_items else data

157
lib/air_runtime/worktree.py Normal file
View File

@@ -0,0 +1,157 @@
"""
Worktree 隔离并行模块 — V2 P1-15 修复。
区域级冲突检测 + git worktree 隔离,允许同文件不同区域任务安全并行。
"""
from __future__ import annotations
import enum
import subprocess
from dataclasses import dataclass
from pathlib import Path
class ConflictLevel(enum.Enum):
NONE = "none" # 无文件重叠,直接并行
SOFT = "soft" # 同文件不同区域worktree 隔离并行
HARD = "hard" # 同文件同区域,必须串行
@dataclass
class Conflict:
task_a: str
task_b: str
level: ConflictLevel # NONE / SOFT / HARD
reason: str = ""
def to_dict(self) -> dict:
return {
"taskA": self.task_a,
"taskB": self.task_b,
"level": self.level.value,
"reason": self.reason,
}
@dataclass
class MergeResult:
task_id: str
successful: bool
conflicts: list[str]
class RegionConflictDetector:
"""区域级写集冲突检测。
区域定义(按优先级):
1. todo.md 中的区域标注 [region: xxx]
2. 函数/类边界AST 分析或标记注释)
3. 行号区间(从 diff 或任务元数据获取)
"""
def detect(self, task_a_write_set: list[str], task_b_write_set: list[str],
regions_a: dict[str, list[tuple[int, int]]] | None = None,
regions_b: dict[str, list[tuple[int, int]]] | None = None) -> ConflictLevel:
file_overlap = set(task_a_write_set) & set(task_b_write_set)
if not file_overlap:
return ConflictLevel.NONE
if regions_a is None or regions_b is None:
return ConflictLevel.SOFT
for fpath in file_overlap:
ra = regions_a.get(fpath, [(0, 999999)])
rb = regions_b.get(fpath, [(0, 999999)])
for a_start, a_end in ra:
for b_start, b_end in rb:
if a_start < b_end and b_start < a_end:
return ConflictLevel.HARD
return ConflictLevel.SOFT
def detect_batch(self, task_write_sets: dict[str, list[str]],
task_regions: dict[str, dict[str, list[tuple[int, int]]]] | None = None,
) -> list[Conflict]:
"""批量检测:接收 {task_id: [file, ...]} 映射,返回所有冲突对。
逐对调用 detect(),仅返回 level != NONE 的冲突。
"""
conflicts: list[Conflict] = []
task_ids = list(task_write_sets.keys())
for i in range(len(task_ids)):
for j in range(i + 1, len(task_ids)):
a, b = task_ids[i], task_ids[j]
ra = task_regions.get(a) if task_regions else None
rb = task_regions.get(b) if task_regions else None
level = self.detect(task_write_sets[a], task_write_sets[b], ra, rb)
if level != ConflictLevel.NONE:
overlap = sorted(set(task_write_sets[a]) & set(task_write_sets[b]))
reason = f"file overlap: {', '.join(overlap)}" if overlap else ""
if level == ConflictLevel.HARD:
reason = f"region overlap: {', '.join(overlap)}" if overlap else "same region"
conflicts.append(Conflict(task_a=a, task_b=b, level=level, reason=reason))
return conflicts
def extract_regions_from_task(self, file_path: Path, region_markers: list[tuple[int, int]]) -> dict[str, list[tuple[int, int]]]:
"""从任务的区域标注提取行号区间。"""
return {str(file_path): region_markers}
class WorktreeIsolation:
"""为 SOFT 冲突任务创建 git worktree 隔离,完成后合并回主分支。"""
def __init__(self, repo_root: Path):
self._repo_root = repo_root
def create_worktree(self, task_id: str, base_ref: str = "HEAD") -> Path:
branch = f"air-{task_id}"
wt_path = self._repo_root.parent / f"{self._repo_root.name}-air-{task_id}"
subprocess.run(
["git", "-C", str(self._repo_root), "worktree", "add", "-b", branch, str(wt_path), base_ref],
check=True, capture_output=True, text=True,
)
return wt_path
def merge_back(self, task_id: str, wt_path: Path) -> MergeResult:
branch = f"air-{task_id}"
try:
subprocess.run(
["git", "-C", str(self._repo_root), "merge", "--no-ff", branch],
check=True, capture_output=True, text=True,
)
return MergeResult(task_id=task_id, successful=True, conflicts=[])
except subprocess.CalledProcessError as exc:
conflicts = self._parse_conflicts(exc.stderr)
self._abort_merge()
return MergeResult(task_id=task_id, successful=False, conflicts=conflicts)
def cleanup(self, task_id: str, wt_path: Path) -> None:
branch = f"air-{task_id}"
try:
subprocess.run(
["git", "-C", str(self._repo_root), "worktree", "remove", str(wt_path), "--force"],
check=True, capture_output=True, text=True,
)
subprocess.run(
["git", "-C", str(self._repo_root), "branch", "-D", branch],
check=True, capture_output=True, text=True,
)
except subprocess.CalledProcessError:
pass
def _abort_merge(self) -> None:
try:
subprocess.run(
["git", "-C", str(self._repo_root), "merge", "--abort"],
check=True, capture_output=True, text=True,
)
except subprocess.CalledProcessError:
pass
@staticmethod
def _parse_conflicts(stderr: str) -> list[str]:
conflicts: list[str] = []
for line in stderr.splitlines():
if "CONFLICT" in line:
conflicts.append(line.strip())
return conflicts

View File

@@ -0,0 +1,161 @@
"""AirXDB screenshot capture backends with automatic fallback chain.
Three capture methods:
- KmsGrabCapture: DRM/KMS native screenshot via ffmpeg (no sudo)
- XvfbCapture: Xvfb virtual framebuffer screenshot
- FallbackCapture: text placeholder when no GUI capture is available
CaptureManager tries them in order (kms -> xvfb -> fallback) unless
a specific method is requested.
"""
from __future__ import annotations
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass
class CaptureResult:
success: bool
output_path: Path | None
method: str # kms, xvfb, fallback
error: str | None = None
class KmsGrabCapture:
"""KMS/DRM screenshot via ffmpeg -f kmsgrab.
No sudo is ever used. The caller must have read access to
/dev/dri/cardX for this to work.
"""
def capture(self, output_path: Path) -> CaptureResult:
# 1. Detect whether the current user can access a DRI device
dri_dev = Path("/dev/dri/card0")
if dri_dev.exists():
try:
# Test read permission (no sudo)
open(dri_dev).close()
except PermissionError:
return CaptureResult(False, None, "kms", "no /dev/dri permission")
else:
return CaptureResult(False, None, "kms", "no GPU")
# 2. Use ffmpeg directly (no sudo)
try:
result = subprocess.run(
[
"ffmpeg", "-y", "-f", "kmsgrab", "-i", "-",
"-frames:v", "1",
"-vf", "hwdownload,format=bgr0",
"-f", "image2", str(output_path),
],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0 and output_path.exists():
return CaptureResult(True, output_path, "kms", None)
return CaptureResult(False, None, "kms", result.stderr)
except FileNotFoundError:
return CaptureResult(False, None, "kms", "ffmpeg not found")
except subprocess.TimeoutExpired:
return CaptureResult(False, None, "kms", "ffmpeg timed out")
except Exception as e:
return CaptureResult(False, None, "kms", str(e))
class XvfbCapture:
"""Xvfb virtual display screenshot via xvfb-run + scrot."""
def capture(
self,
output_path: Path,
width: int = 1920,
height: int = 1080,
) -> CaptureResult:
# Check xvfb-run availability
if not shutil.which("xvfb-run"):
return CaptureResult(False, None, "xvfb", "xvfb-run not found")
# Use scrot as the screenshot tool inside Xvfb
try:
result = subprocess.run(
[
"xvfb-run",
"--auto-servernum",
"--server-args",
f"-screen 0 {width}x{height}x24",
"--",
"scrot",
str(output_path),
],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode == 0 and output_path.exists():
return CaptureResult(True, output_path, "xvfb", None)
return CaptureResult(False, None, "xvfb", result.stderr)
except FileNotFoundError:
return CaptureResult(False, None, "xvfb", "xvfb-run not found")
except subprocess.TimeoutExpired:
return CaptureResult(False, None, "xvfb", "xvfb-run timed out")
except Exception as e:
return CaptureResult(False, None, "xvfb", str(e))
class FallbackCapture:
"""Fallback: write a text placeholder when no GUI capture is available."""
def capture(
self,
output_path: Path,
description: str = "",
) -> CaptureResult:
output_path.write_text(
f"[GUI capture not available]\n{description}\n"
)
return CaptureResult(True, output_path, "fallback", None)
class CaptureManager:
"""Auto-select the best capture method with fallback chain.
Order: kms -> xvfb -> fallback.
Use ``prefer`` to force a specific method or "auto" for the chain.
"""
def __init__(self) -> None:
self.kms = KmsGrabCapture()
self.xvfb = XvfbCapture()
self.fallback = FallbackCapture()
def capture(
self,
output_path: Path,
prefer: str = "auto",
) -> CaptureResult:
"""
prefer: "kms" | "xvfb" | "fallback" | "auto"
auto mode tries kms -> xvfb -> fallback in order.
"""
if prefer in ("kms", "auto"):
result = self.kms.capture(output_path)
if result.success:
return result
if prefer == "kms":
return result # forced kms failed, return failure
if prefer in ("xvfb", "auto"):
result = self.xvfb.capture(output_path)
if result.success:
return result
if prefer == "xvfb":
return result # forced xvfb failed, return failure
# fallback
return self.fallback.capture(output_path, "all capture methods failed")

191
scripts/airplan.py Normal file
View File

@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""
AirPlan V2 — 统一 CLI 入口。
V1 的 8 个独立插件合并为一个命令,通过 --mode 参数路由到对应子模式。
用法:
airplan --mode arc --project <project-root> [--todo <todo-md>]
airplan --mode eng --project <project-root> [--sub enter|status|plan|dispatch|monitor|merge|run|intervene]
airplan --mode do --project <project-root> --task-id <id>
airplan --mode dbg --project <project-root> [--task-id <id>]
airplan --mode sdb --project <project-root> [--backend cppcheck|clang-tidy|clippy|go-vet|tsc] [--target <path>]
airplan --mode ctx --project <project-root> [--sub now|status|pause|init]
airplan --mode dep --project <project-root> --task-id <id> --host <host> [--binary <path>]
airplan --mode tst --project <project-root> --task-id <id> [--framework <name>]
airplan --mode sec --project <project-root> --task-id <id> [--scan-path <path>]
airplan --mode rvr --project <project-root> --task-id <id>
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
def _add_lib_path() -> None:
root = Path(__file__).resolve().parents[1]
lib_path = root / "lib"
if str(lib_path) not in sys.path:
sys.path.insert(0, str(lib_path))
_add_lib_path()
from air_runtime.project_bootstrap import ensure_project_bootstrap
def _run_arc(args) -> None:
from air_runtime.modes.arc_mode import main as arc_main
arc_main(args)
def _run_eng(args) -> None:
from air_runtime.modes.eng_mode import main as eng_main
eng_main(args)
def _run_do(args) -> None:
from air_runtime.modes.do_mode import main as do_main
do_main(args)
def _run_dbg(args) -> None:
from air_runtime.modes.dbg_mode import main as dbg_main
dbg_main(args)
def _run_xdb(args) -> None:
from air_runtime.modes.xdb_mode import main as xdb_main
xdb_main(args)
def _run_sdb(args) -> None:
from air_runtime.modes.sdb_mode import main as sdb_main
sdb_main(args)
def _run_ndb(args) -> None:
from air_runtime.modes.ndb_mode import main as ndb_main
ndb_main(args)
def _run_ctx(args) -> None:
from air_runtime.modes.ctx_mode import main as ctx_main
ctx_main(args)
def _run_dep(args) -> None:
from air_runtime.modes.dep_mode import main as dep_main
dep_main(args)
def _run_tst(args) -> None:
from air_runtime.modes.tst_mode import main as tst_main
tst_main(args)
def _run_sec(args) -> None:
from air_runtime.modes.sec_mode import main as sec_main
sec_main(args)
def _run_rvr(args) -> None:
from air_runtime.modes.rvr_mode import main as rvr_main
rvr_main(args)
def _run_eng_orchestrator(args) -> None:
from air_runtime.modes.eng_orchestrator import main as eng_orch_main
eng_orch_main(args)
MODE_ROUTER = {
"arc": _run_arc,
"eng": _run_eng,
"do": _run_do,
"dbg": _run_dbg,
"xdb": _run_xdb,
"sdb": _run_sdb,
"ndb": _run_ndb,
"ctx": _run_ctx,
"dep": _run_dep,
"tst": _run_tst,
"sec": _run_sec,
"rvr": _run_rvr,
"eng_orchestrator": _run_eng_orchestrator,
}
MODE_HELP = {
"arc": "架构规划器 — 分析依赖、写集冲突、产出执行计划",
"eng": "调度引擎 — 波次派发、监控、合并、修复编排",
"do": "任务执行器 — 单任务切片执行与结果生成",
"dbg": "调试器 — 7步调试工作流、根因分析、修复回滚",
"xdb": "GUI验证器 — 截图取证、GUI操作验证",
"sdb": "静态分析器 — 多语言静态分析、diff模式",
"ndb": "网络调试器 — 抓包分析、TLS解密",
"ctx": "上下文管理器 — 压缩、Token估算、锁检测",
"dep": "部署器 — SSH远程构建、部署、systemd管理",
"tst": "测试运行器 — 统一测试执行与结果报告",
"sec": "安全扫描器 — 敏感数据检测与脱敏",
"rvr": "需求审查器 — 交付物与需求一致性验证",
}
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="AirPlan V2 — 统一制品驱动开发调度器",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="\n".join(f" {k:6s} {v}" for k, v in MODE_HELP.items()),
)
parser.add_argument("--mode", choices=list(MODE_ROUTER.keys()), required=True,
help="子模式选择")
parser.add_argument("--project", default=".",
help="项目根目录 (默认: .)")
parser.add_argument("--sub", default="",
help="子模式内的二级操作(如 eng 的 enter|status|plan 等)")
parser.add_argument("--todo", default="",
help="todo.md 路径arc/eng 使用)")
parser.add_argument("--task-id", default="",
help="任务 IDdo/dbg/xdb/sdb/ndb/dep/tst/sec/rvr 使用)")
parser.add_argument("--result", default="",
help="Worker result.json 路径eng merge 使用)")
parser.add_argument("--dispatch-group", default="",
help="派发组名eng dispatch 使用)")
parser.add_argument("--host", default="",
help="远程主机dep 使用)")
parser.add_argument("--binary", default="",
help="本地二进制路径dep 使用)")
parser.add_argument("--framework", default="",
help="测试框架tst 使用)")
parser.add_argument("--scan-path", default="",
help="扫描路径sec 使用)")
parser.add_argument("--sec-mode", default="blocking", choices=["advisory", "blocking"],
help="安全扫描模式sec 使用): advisory只报告/ blocking阻止合并")
parser.add_argument("--backend", default="",
help="静态分析后端sdb 使用): cppcheck|clang-tidy|clippy|go-vet|tsc")
parser.add_argument("--target", default="",
help="分析目标路径sdb 使用)")
parser.add_argument("--prefer", default="auto",
help="截图后端偏好xdb 使用): kms|xvfb|fallback|auto")
parser.add_argument("--output", default="",
help="截图输出文件名xdb 使用,默认 screenshot.png")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> None:
args = parse_args(argv)
project_root = Path(args.project).expanduser().resolve()
ensure_project_bootstrap(project_root)
handler = MODE_ROUTER.get(args.mode)
if handler is None:
print(f"Unknown mode: {args.mode}", file=sys.stderr)
print(f"Available modes: {', '.join(MODE_ROUTER.keys())}", file=sys.stderr)
sys.exit(1)
handler(args)
if __name__ == "__main__":
main()

159
skills/airplan/SKILL.md Normal file
View File

@@ -0,0 +1,159 @@
---
name: airplan
description: AirPlan V2 — 统一制品驱动开发调度器。单一插件整合规划(arc)、执行(do)、调试(dbg)、GUI验证(xdb)、静态分析(sdb)、网络调试(ndb)、部署(dep)、测试(tst)、安全扫描(sec)、需求审查(rvr)。L1代码级保障不依赖LLM自觉。
allowed-tools: [Read, Glob, Grep, Bash, Write, Edit]
---
# AirPlan V2
## 概述
AirPlan V2 是 V1 的 8 个独立插件airarc, aireng, airdo, airdbg, airxdb, airsdb, airndb, aircontext合并为 **1 个统一插件**,新增 4 个组件airdep, airtst, airsec, airrvr**12 个子模式**
核心设计原则:
1. **制品驱动通信** — 插件间通过 AirPlan/ 文件通信
2. **上下文隔离** — Worker `fork_context=false`
3. **L1 代码级保障** — 关键逻辑不依赖 LLM 自觉,由引擎代码强制执行
## 子模式速查
| 子模式 | 角色 | 关键文件 |
|--------|------|----------|
| arc | 架构规划器 — 产出执行计划、DAG | execution-plan.json, task-graph.json |
| eng | 调度引擎 — 波次派发、监控、合并 | state/aireng/state.json |
| do | 任务执行器 — 单任务切片<E58887><E78987><EFBFBD>行 | state/airdo/tasks/{task_id}/result.json |
| dbg | 调试器 — 7 步工作流强制追踪 | state/airdbg/sessions/*.json |
| xdb | GUI 验证器 — 截图取证、DRM/KMS | state/airxdb/artifacts/ |
| sdb | 静态分析器 — 多语言分析 | state/airsdb/reports/ |
| ndb | 网络调试器 — 抓包分析 | state/airndb/captures/ |
| ctx | 上下文管理器 — 压缩、Token 估算 | state/aircontext/ |
| dep | 部署器 — SSH 远程构建 + systemd | state/airdep/sessions/ |
| tst | 测试运行器 — 统一多框架测试 | state/airtst/reports/ |
| sec | 安全扫描器 — 敏感数据检测 | state/airsec/ |
| rvr | 需求审查器 — 交付物与需求一致性 | state/airrvr/reviews/ |
## 关键不变量INV-
### INV-1 制品驱动通信
插件间**不直接调用**,而是通过 AirPlan/ 目录下的 JSON/MD 文件通信。状态文件是唯一真相来源。
### INV-2 上下文隔离
所有 Worker 派发使用 `fork_context=false`,确保子任务不继承父线程的完整上下文,仅传递任务相关的必要信息。
### INV-3 架构同步强制
不更新架构文档ADR、C4、plan.md不能标记任务为 DONE。AirEng merge 时强制检查 documentUpdates。
### INV-4 证据先于修复
GUI 任务必须先有截图/验证证据,网络任务必须先有抓包/连通性证据,代码任务必须先有静态分析/测试结果。EvidenceGatePolicy 根据任务类型自动判断需要哪种证据。
### INV-5 闭环自动修复
执行 → 失败 → 调试 → 修复 → 重执行 的闭环由 AirEng 的修复预算机制自动驱动。
### INV-6 调度器不写代码
AirEng 是调度器,不是执行器。它只做 plan/dispatch/monitor/merge/intervene/doc-sync绝不直接实现任务代码。所有任务实现必须通过 /do 子代理fork_context=false完成。调度线程直接写代码是严重违规唯一例外是 Worker 硬阻塞无法自恢复时的紧急干预,干预后必须立即回到调度模式。
### INV-7 架构器只读不写
AirArc 是纯规划器只做分析依赖、写集冲突、产出调度计划。禁止编写代码、修改源代码文件、执行构建命令。allowed-tools 仅含 Read/Glob/Grep不含 Write/Edit/Bash。
### INV-8 架构三阶段门控
AirArc 必须遵循三阶段流程discussing需求探讨→ proposing架构提议→ confirmed用户确认后。execution-plan.json 仅在 phase=confirmed 时允许写入。
### INV-9 调试先读后写
AirDbg 修改代码前必须至少完成一项取证行为(截图/抓包/静态分析/日志分析/代码追踪/复现步骤)。未取证就修改代码是严重违规。
### INV-10 中文锁定
AirEng 必须始终使用中文与用户交流。自主决策不停下来问用户。
### INV-11 项目日志标准
所有 C++ 项目必须集成 spdlog禁止 std::cout/qDebug/printf。AirArc 规划时强制首任务为 spdlog 集成如未存在AirRvr 审查时检查日志完备性。
## L1 代码级保障(不依赖 LLM 自觉)
以下功能由引擎代码强制执行SKILL.md 指令仅作辅助:
1. **原子写入**`air_runtime/io.py:atomic_json_write()` 使用 tempfile + os.replace()
2. **文件锁**`air_runtime/lock.py:FileLock` 持有者才能读写 state.json
3. **证据门控**`air_runtime/evidence_gate.py:EvidenceGatePolicy` 分类任务类型,强制要求对应证据
4. **强制 AirDbg 路由**`air_runtime/modes/do_mode.py:finish_worker()` 中 blocked/failed/done无证据 时强制路由到 airdbg
5. **硬编码轮询循环**`air_runtime/modes/eng_mode.py:monitor_engine()` 每 5 分钟检测所有 Worker 状态
6. **Worker 超时** — 超过 2 小时硬上限自动 terminate
7. **部署一致性验证** — merge 时检测 deployRequired 字段是否有对应验证证据
8. **任务 ID 校验**`air_runtime/utils.py:sanitize_task_id()` 防止路径注入
9. **Arc 三阶段门控**`air_runtime/modes/arc_mode.py:ArcPhaseGate` 仅 confirmed 阶段可写 execution-plan.json
10. **Dbg 先读后写门控**`air_runtime/modes/dbg_mode.py:WorkflowViolation` fix 步骤前必须有 collectedEvidence
11. **Arc 工具白名单** — commands/arc.md allowed-tools 仅 [Read, Glob, Grep]deny-plan-mode=true
## 使用示例
```bash
# 架构规划
airplan --mode arc --project /path/to/project --sub enter
airplan --mode arc --project /path/to/project --sub parallel-review --todo AirPlan/todo.md
# 调度引擎
airplan --mode eng --project /path/to/project --sub enter
airplan --mode eng --project /path/to/project --sub plan --todo AirPlan/todo.md
airplan --mode eng --project /path/to/project --sub dispatch
airplan --mode eng --project /path/to/project --sub monitor
airplan --mode eng --project /path/to/project --sub merge --result /path/to/result.json
# 任务执行
airplan --mode do --project /path/to/project --task-id T-001 --sub enter
airplan --mode do --project /path/to/project --task-id T-001 --sub finish --result /path/to/result.json
# 调试
airplan --mode dbg --project /path/to/project --task-id T-001 --sub start
airplan --mode dbg --project /path/to/project --task-id T-001 --sub snapshot
# 部署
airplan --mode dep --project /path/to/project --task-id T-001 --host 192.168.1.100 --binary ./build/myapp
# 测试
airplan --mode tst --project /path/to/project --task-id T-001 --framework pytest
# 安全扫描
airplan --mode sec --project /path/to/project --task-id T-001 --scan-path ./src
# 需求审查
airplan --mode rvr --project /path/to/project --task-id T-001 --sub review
```
## 文件结构
```
AirPlan/
├── AGENTS.md # Agent 行为规范(各子模式入口)
├── plan.md # 执行计划摘要
├── todo.md # 任务列表(人类可读)
├── docs/
│ ├── architecture/
│ │ ├── adr/ # ADR 记录
│ │ └── c4/module.md # C4 模块文档
│ ├── debug/
│ │ ├── debug-log.md # 调试日志
│ │ └── gui-debug-log.md # GUI 调试日志
│ ├── staticanalysis.md # 静态分析报告
│ └── network/ # 网络抓包
└── state/
├── airarc/reviews/ # 架构规划输出
├── aireng/ # 调度引擎状态
├── airdo/tasks/ # Worker 结果
├── airdbg/sessions/ # 调试会话
├── airxdb/artifacts/ # 截图/验证证据
├── airsdb/reports/ # 静态分析报告
├── airndb/captures/ # 网络抓包
├── aircontext/ # 上下文压缩状态
├── airdep/sessions/ # 部署记录
├── airtst/reports/ # 测试报告
├── airsec/ # 安全扫描<E689AB><E68F8F>
└── airrvr/reviews/ # 需求审查报告
```
## 故障排查
| 症状 | 排查步骤 |
|------|----------|
| Worker 假阳性阻塞 | 检查 `state/airxdb/` 是否存在对应截图证据,确认任务类型是否被 EvidenceGatePolicy 误分类 |
| 部署后未生效 | 检查 merge 时是否包含 deploy 验证证据remote-deploy-verify / remote-binary-md5 |
| 任务状态卡在 DISPATCHED | 运行 `airplan --mode eng --project . --sub monitor` 查看 Worker 是否超时或停滞 |
| 敏感数据泄露 | 检查 AirSec 扫描结果,查看 `state/airsec/` 是否有 finding |

View File

@@ -0,0 +1,138 @@
---
name: airplan
description: AirPlan V2 — 统一制品驱动开发调度器。单一插件整合规划(arc)、执行(do)、调试(dbg)、GUI验证(xdb)、静态分析(sdb)、网络调试(ndb)、部署(dep)、测试(tst)、安全扫描(sec)、需求审查(rvr)。L1代码级保障不依赖LLM自觉。
allowed-tools: [Read, Glob, Grep, Bash, Write, Edit]
---
# AirPlan V2
## 概述
AirPlan V2 是 V1 的 8 个独立插件airarc, aireng, airdo, airdbg, airxdb, airsdb, airndb, aircontext合并为 **1 个统一插件**,新增 4 个组件airdep, airtst, airsec, airrvr**12 个子模式**
核心设计原则:
1. **制品驱动通信** — 插件间通过 AirPlan/ 文件通信
2. **上下文隔离** — Worker `fork_context=false`
3. **L1 代码级保障** — 关键逻辑不依赖 LLM 自觉,由引擎代码强制执行
## 子模式速查
| 子模式 | 角色 | 关键文件 |
|--------|------|----------|
| arc | 架构规划器 — 产出执行计划、DAG | execution-plan.json, task-graph.json |
| eng | 调度引擎 — 波次派发、监控、合并 | state/aireng/state.json |
| do | 任务执行器 — 单任务切片<E58887><E78987><EFBFBD>行 | state/airdo/tasks/{task_id}/result.json |
| dbg | 调试器 — 7 步工作流强制追踪 | state/airdbg/sessions/*.json |
| xdb | GUI 验证器 — 截图取证、DRM/KMS | state/airxdb/artifacts/ |
| sdb | 静态分析器 — 多语言分析 | state/airsdb/reports/ |
| ndb | 网络调试器 — 抓包分析 | state/airndb/captures/ |
| ctx | 上下文管理器 — 压缩、Token 估算 | state/aircontext/ |
| dep | 部署器 — SSH 远程构建 + systemd | state/airdep/sessions/ |
| tst | 测试运行器 — 统一多框架测试 | state/airtst/reports/ |
| sec | 安全扫描器 — 敏感数据检测 | state/airsec/ |
| rvr | 需求审查器 — 交付物与需求一致性 | state/airrvr/reviews/ |
## 关键不变量INV-
### INV-1 制品驱动通信
插件间**不直接调用**,而是通过 AirPlan/ 目录下的 JSON/MD 文件通信。状态文件是唯一真相来源。
### INV-2 上下文隔离
所有 Worker 派发使用 `fork_context=false`,确保子任务不继承父线程的完整上下文,仅传递任务相关的必要信息。
### INV-3 架构同步强制
不更新架构文档ADR、C4、plan.md不能标记任务为 DONE。AirEng merge 时强制检查 documentUpdates。
### INV-4 证据先于修复
GUI 任务必须先有截图/验证证据,网络任务必须先有抓包/连通性证据,代码任务必须先有静态分析/测试结果。EvidenceGatePolicy 根据任务类型自动判断需要哪种证据。
### INV-5 闭环自动修复
执行 → 失败 → 调试 → 修复 → 重执行 的闭环由 AirEng 的修复预算机制自动驱动。
## L1 代码级保障(不依赖 LLM 自觉)
以下功能由引擎代码强制执行SKILL.md 指令仅作辅助:
1. **原子写入**`air_runtime/io.py:atomic_json_write()` 使用 tempfile + os.replace()
2. **文件锁**`air_runtime/lock.py:FileLock` 持有者才能读写 state.json
3. **证据门控**`air_runtime/evidence_gate.py:EvidenceGatePolicy` 分类任务类型,强制要求对应证据
4. **强制 AirDbg 路由**`air_runtime/modes/do_mode.py:finish_worker()` 中 blocked/failed/done无证据 时强制路由到 airdbg
5. **硬编码轮询循环**`air_runtime/modes/eng_mode.py:monitor_engine()` 每 5 分钟检测所有 Worker 状态
6. **Worker 超时** — 超过 2 小时硬上限自动 terminate
7. **部署一致性验证** — merge 时检测 deployRequired 字段是否有对应验证证据
8. **任务 ID 校验**`air_runtime/utils.py:sanitize_task_id()` 防止路径注入
## 使用示例
```bash
# 架构规划
airplan --mode arc --project /path/to/project --sub enter
airplan --mode arc --project /path/to/project --sub parallel-review --todo AirPlan/todo.md
# 调度引擎
airplan --mode eng --project /path/to/project --sub enter
airplan --mode eng --project /path/to/project --sub plan --todo AirPlan/todo.md
airplan --mode eng --project /path/to/project --sub dispatch
airplan --mode eng --project /path/to/project --sub monitor
airplan --mode eng --project /path/to/project --sub merge --result /path/to/result.json
# 任务执行
airplan --mode do --project /path/to/project --task-id T-001 --sub enter
airplan --mode do --project /path/to/project --task-id T-001 --sub finish --result /path/to/result.json
# 调试
airplan --mode dbg --project /path/to/project --task-id T-001 --sub start
airplan --mode dbg --project /path/to/project --task-id T-001 --sub snapshot
# 部署
airplan --mode dep --project /path/to/project --task-id T-001 --host 192.168.1.100 --binary ./build/myapp
# 测试
airplan --mode tst --project /path/to/project --task-id T-001 --framework pytest
# 安全扫描
airplan --mode sec --project /path/to/project --task-id T-001 --scan-path ./src
# 需求审查
airplan --mode rvr --project /path/to/project --task-id T-001 --sub review
```
## 文件结构
```
AirPlan/
├── AGENTS.md # Agent 行为规范(各子模式入口)
├── plan.md # 执行计划摘要
├── todo.md # 任务列表(人类可读)
├── docs/
│ ├── architecture/
│ │ ├── adr/ # ADR 记录
│ │ └── c4/module.md # C4 模块文档
│ ├── debug/
│ │ ├── debug-log.md # 调试日志
│ │ └── gui-debug-log.md # GUI 调试日志
│ ├── staticanalysis.md # 静态分析报告
│ └── network/ # 网络抓包
└── state/
├── airarc/reviews/ # 架构规划输出
├── aireng/ # 调度引擎状态
├── airdo/tasks/ # Worker 结果
├── airdbg/sessions/ # 调试会话
├── airxdb/artifacts/ # 截图/验证证据
├── airsdb/reports/ # 静态分析报告
├── airndb/captures/ # 网络抓包
├── aircontext/ # 上下文压缩状态
├── airdep/sessions/ # 部署记录
├── airtst/reports/ # 测试报告
├── airsec/ # 安全扫描<E689AB><E68F8F>
└── airrvr/reviews/ # 需求审查报告
```
## 故障排查
| 症状 | 排查步骤 |
|------|----------|
| Worker 假阳性阻塞 | 检查 `state/airxdb/` 是否存在对应截图证据,确认任务类型是否被 EvidenceGatePolicy 误分类 |
| 部署后未生效 | 检查 merge 时是否包含 deploy 验证证据remote-deploy-verify / remote-binary-md5 |
| 任务状态卡在 DISPATCHED | 运行 `airplan --mode eng --project . --sub monitor` 查看 Worker 是否超时或停滞 |
| 敏感数据泄露 | 检查 AirSec 扫描结果,查看 `state/airsec/` 是否有 finding |