diff --git a/.dependency-cruiser.js b/.dependency-cruiser.js index 0f48ada..9f1f6d6 100755 --- a/.dependency-cruiser.js +++ b/.dependency-cruiser.js @@ -8,7 +8,7 @@ * tui -> contracts * runtime -> contracts, llm — llm facade only * cli -> contracts, runtime, tui, llm, toolchain-cpp - * workers -> contracts — WorkerRuntime IPC surface only + * workers -> contracts, runtime — WorkerRuntime IPC + shared utilities */ module.exports = { forbidden: [ @@ -69,15 +69,15 @@ module.exports = { }, }, - /* ── Rule 5: workers may only import from contracts ── */ + /* ── Rule 5: workers may import from contracts and runtime ── */ { name: "workers-boundary", - comment: "workers may only depend on contracts (WorkerRuntime IPC surface)", + comment: "workers may depend on contracts (IPC surface) and runtime (shared utilities like CompressionValidator)", severity: "error", from: { path: "^packages/workers/src/" }, to: { - path: "^packages/(llm|runtime|tui|cli|toolchain-cpp)/", - pathNot: "^packages/contracts/", + path: "^packages/(llm|tui|cli|toolchain-cpp)/", + pathNot: "^packages/(contracts|runtime)/", }, }, diff --git a/AirPlan/docs/analysis/full-requirements-audit.md b/AirPlan/docs/analysis/full-requirements-audit.md new file mode 100755 index 0000000..3098775 --- /dev/null +++ b/AirPlan/docs/analysis/full-requirements-audit.md @@ -0,0 +1,493 @@ +# AirCoding V1.0.0 Alpha — 完整需求、设计决策、约束提取 + +**生成日期**: 2026-06-11 +**提取工具**: deepseek-v4-pro 全量提取 +**来源文档**: requirements.md + airplanV2-Qwen3.7-Max设计.md + baselineV1.md + solution-architecture.md + system-overview-design.md + system-detailed-design.md +**参考原型**: air-suite-20260518 (V1 Python插件系统, 8个已验证插件) + +> 共 383 条。每条的 source 字段标注了来源文档和章节。 + +--- + +## FR (Functional Requirements) — 21条 + 7条子要求 + +### 来源: requirements.md §3 + +1. **FR-001** (requirements.md §3): CLI Startup and Project Initialization — 从CLI入口启动,检测/打开项目,需要时初始化`.air/`,加载资源/配置,运行只读Doctor,打开session。 + +2. **FR-002** (requirements.md §3): Project-Local State — `.air/shared/`(可共享配置/规则/计划) + `.air/local/`(私有sessions/artifacts/workspaces/backups/local DBs)。 + +3. **FR-003** (requirements.md §3): Session Persistence — SQLite at `/.air/local/sessions//session.db`, 支持messages/drafts/durable events/task graph state/agents/tool/command runs/artifacts/diagnostics/evidence refs/workspaces/summaries/UI state。 + +4. **FR-004** (requirements.md §3): Event-Driven Runtime — 发布RuntimeEvents用于实时行为,持久事件与域表更新事务一致。 + +5. **FR-005** (requirements.md §3): Main Agent Conversation — 面向用户的Agent: 接收请求,适当直接回答,分类工作,显示进度,呈现阻断/确认。 + +6. **FR-006** (requirements.md §3): Architecture Designer — 架构/接口/产品级决策路由到Architecture Designer: 更新架构制品,产生影响评估。 + +7. **FR-007** (requirements.md §3): Scheduler and TaskGraph — 调度TaskSpec: hard/soft依赖,写区冲突处理,重试预算,子Worker派发,心跳监控,合并协调,重启恢复。 + +8. **FR-007.5** (requirements.md §3): ADR级联失效与架构变更回滚 — 7条子要求: + - (a) 通过TaskNode.adr_refs溯源所有依赖该ADR的任务(含已完成) + - (b) 级联失效: completed→invalidated, running→终止, pending→cancelled + - (c) 冻结调度(dispatch_frozen),阻止新任务派发 + - (d) 创建git回滚快照(rollback_ref),支持revert旧方案代码 + - (e) 接收ArchitectureDesigner产出的PlanDelta增量重规划 + - (f) apply_delta吸收新任务后解冻调度 + - (g) 终审时检查INVALIDATED任务的旧代码是否已清理 + +9. **FR-008** (requirements.md §3): Independent Worker Agents — Executor/Reviewer/Debugger/Compactor/ExperienceMiner作为独立Bun子进程,通过NDJSON IPC通信。 + +10. **FR-009** (requirements.md §3): Claude Code-Quality Execution Primitives — 强制: read-before-edit, exact conservative edits, small patches, no unrelated refactors, permission checks, verification-before-completion。 + +11. **FR-010** (requirements.md §3): ToolRegistry and Built-In Tools — Schema验证的工具: filesystem/shell/git/project scanning/完整C++ build/test/static-analysis/debug/GUI screenshot/network capture/artifacts/context assembly/permission requests/Doctor。 + +12. **FR-011** (requirements.md §3): Permission and Security Model — 分类paths/commands/network/credentials,强制权限配置,保护系统敏感和凭证操作,项目外写入备份,拒绝不安全请求。 + +13. **FR-012** (requirements.md §3): Plugin and Capability Foundation — manifest loading/validation/enable-disable config/dependency declaration/Doctor integration/namespaced tool registration/source-trust metadata/PermissionEngine enforcement。第三方registry/signing可延后,本地和内置capability打包必须可用。 + +14. **FR-013** (requirements.md §3): Provider Layer — 内部Anthropic canonical消息,通过适配器路由provider调用,能力矩阵验证和转换报告。 + +15. **FR-014** (requirements.md §3): Context Assembly and Compaction — 有序层组装prompt,适配token预算,记录遗漏,必要时copy-on-write压缩。 + +16. **FR-015** (requirements.md §3): Artifact and Evidence Management — temp-file→atomic rename,记录URI/path/hash/metadata,通过evidence refs链接声明。 + +17. **FR-016** (requirements.md §3): TUI and HUD — OpenTUI/Solid终端UI和HUD,仅消费ProjectionStore,不查询原始DB/EventBus。 + +18. **FR-017** (requirements.md §3): Complete C++ Development Workflow — 项目检测→构建系统评估→CMake configure→Ninja优先/Make回退→编译器/链接器诊断解析→clangd代码智能查询→cppcheck静态分析→CTest/GoogleTest执行→debug run/log解析→失败诊断→范围修复→审查→证据支持验证。 + +19. **FR-018** (requirements.md §3): Doctor — 启动时运行只读Doctor,报告环境/能力问题,在权限策略下支持修复模式。 + +20. **FR-019** (requirements.md §3): Logging and Diagnostics — 可读`air.log`,加密`air.developer.log`,默认7天保留。 + +21. **FR-020** (requirements.md §3): Release Gate — 定义tier-1 Linux发布门禁: unit tests/integration fixture replay/real LLM E2E/project init/C++ build-test flow/SQLite recovery/child IPC/TUI startup/artifact-event persistence。 + +--- + +## NFR (Non-Functional Requirements) — 8条 + +### 来源: requirements.md §4 + +22. **NFR-001** (requirements.md §4): Local-First Operation — 项目状态/制品/日志/调试知识保留在本地,除非用户显式导出/分享/上传。 + +23. **NFR-002** (requirements.md §4): Recoverability — 从进程/session重启恢复:读取SQLite状态,检测丢失agents,保留workspaces,重建Scheduler队列。 + +24. **NFR-003** (requirements.md §4): Extensibility — 通过`toolchain-*`包和能力清单添加语言/工具链支持。 + +25. **NFR-004** (requirements.md §4): Provider Flexibility — 内部契约在Anthropic/OpenAI/OpenRouter/ollama/兼容端点间保持稳定。 + +26. **NFR-005** (requirements.md §4): UI Responsiveness — Main Agent和TUI在后台Worker运行时保持响应。 + +27. **NFR-006** (requirements.md §4): Evidence-Based Completion — 任务未获得build/test/debug/review证据或显式skipped-gate报告前不得标记完成。 + +28. **NFR-007** (requirements.md §4): Linux-First Platform Support — Linux x86_64 tier1, arm64/WSL2 tier2, macOS实验, Windows native post-MVP/实验。 + +29. **NFR-008** (requirements.md §4): Security Boundary Preservation — LLM输出/工具结果/插件/外部内容在被运行时契约和策略验证前为不可信数据。 + +--- + +## AC (Acceptance Criteria) — 13条 + +### 来源: requirements.md §6 + +30. **AC-01**: CLI starts and initializes/opens a project `.air/` tree. +31. **AC-02**: Session DB schema initializes and persists messages/events/tasks/tool runs/artifacts. +32. **AC-03**: EventStore transactionally applies core durable events to domain tables. +33. **AC-04**: ProjectionStore hydrates and updates a usable TUI/HUD view. +34. **AC-05**: Scheduler dispatches worker child processes via NDJSON IPC, supports tool calls, receives WorkerResult. +35. **AC-06**: ToolRegistry executes filesystem/shell/git/artifact/context/doctor/C++/debug/GUI/network tools through PermissionEngine. +36. **AC-07**: C++ workflow can detect, configure, build, statically analyze, test, debug, fix, review, re-verify a fixture project. +37. **AC-08**: Failed build/test/debug commands produce diagnostics/artifacts/evidence refs and can trigger Debugger repair. +38. **AC-09**: ContextAssembler produces Anthropic canonical messages with omissions where needed. +39. **AC-10**: Provider adapter path can perform model calls under capability validation and conversion reporting. +40. **AC-11**: Capability manifests can be loaded, validated, enabled, registered as namespaced tools. +41. **AC-12**: Doctor reports platform/provider/toolchain/capability/display/network status and supports permissioned fix mode. +42. **AC-13**: Release gate commands are documented and runnable on tier-1 Linux. + +--- + +## CT (Constraints) — 16条 + +### 来源: requirements.md §5 + baselineV1.md §3-§5 + +43. **CT-01** (requirements.md §5): Runtime: TypeScript on Bun. +44. **CT-02** (requirements.md §5): Monorepo: Bun workspaces + Turborepo. +45. **CT-03** (requirements.md §5): TUI: OpenTUI/Solid. +46. **CT-04** (requirements.md §5): IPC: NDJSON over stdio. +47. **CT-05** (requirements.md §5): DB: SQLite per session with WAL/NORMAL/foreign_keys OFF. +48. **CT-06** (requirements.md §5): Internal message format: Anthropic canonical content blocks. +49. **CT-07** (requirements.md §5): C++ is first deep toolchain; runtime remains language-agnostic. +50. **CT-08** (requirements.md §5): Python is subprocess-only helper layer, not core runtime. +51. **CT-09** (requirements.md §5): Early distribution uses binary tarball, not public package channels. +52. **CT-10** (requirements.md §5): Architecture docs and workflow state live under `AirPlan/`. +53. **CT-11** (baselineV1 §3-§4): Monorepo packages (Alpha) — contracts, cli, tui, runtime, llm, toolchain-cpp. +54. **CT-12** (baselineV1 §4): Dependency direction — contracts(no deps) → cli → tui/runtime/llm/toolchain-cpp; runtime → contracts + llm facade + toolchain via registry; tui → contracts only; runtime must not depend on tui. +55. **CT-13** (baselineV1 §5): Global user directory — `~/.air/`. +56. **CT-14** (baselineV1 §5): project_id is stable UUID in `.air/shared/project.json`, not derived from absolute path. +57. **CT-15** (baselineV1 §5): `.gitignore`: `.air/local/`. +58. **CT-16** (baselineV1 + solution-arch): All side effects must pass through ToolRegistry and PermissionEngine. + +--- + +## RB (Reference Baselines) — 6条 + +### 来源: baselineV1.md §2 + +59. **RB-01** (baselineV1 §2): Claude Code CLI — Primary reference for execution-layer quality. Reference areas: file read/edit/write safety, exact conservative diff/update, patch granularity, tool lifecycle, permission checks, read-before-edit, small-step edits, no unrelated refactors, verification-before-completion, build/test/debug evidence, root-cause failure handling, blocker escalation, TAOR/TORI feedback loops. + +60. **RB-02** (baselineV1 §2): OpenCode — Reference for runtime layering, TUI visual style/interaction, session/event/sync concepts, provider/model abstraction, plugin/SDK ideas. **Reuse OpenTUI primitives. Do NOT reuse SDK/sync/session business state.** + +61. **RB-03** (baselineV1 §2): Hermes Agent — Reference for experience mining, Nudge Engine interval-triggered learning, Curator daemon, skill self-patching, SKILL.md format, FTS retrieval. + +62. **RB-04** (baselineV1 §2): OpenAI Codex — Reference for shell/patch/test direct execution loop, coding sandbox, tool orchestration, MCP implementation ideas. + +63. **RB-05** (baselineV1 §2): Anthropic Claude Skills — Reference for SKILL.md structure/frontmatter, skill directory layout (scripts/references/assets), reusable workflow packaging. + +64. **RB-06** (baselineV1 §2): asciinema / Atuin / claude-hud — Reference for PTY capture/terminal replay, command metadata/history indexing, HUD/statusline layout and activity display. + +--- + +## PV (V1 Plugin Prototypes) — 8个已生产验证的插件工作流 + +### 来源: air-suite-20260518 + airplanV2-Qwen3.7-Max设计.md §1.2 + +> **核心架构原则**: Agent (MainAgent/Scheduler/Worker) 存在的目的是扩展插件的能力边界。**插件代表的工作流才是产品核心**。AirCoding V1.0.0 Alpha 是V1 8个插件从 Claude Code Skill 到 TypeScript 运行时的移植重构——不是从零开发,是给已验证的工作流换一个可靠的运行时底座。 + +### 8个V1插件 → AirCoding移植映射 + +| # | V1插件 | 目录 | 工作流 | AirCoding模块 | 移植状态 | +|---|--------|------|--------|--------------|----------| +| 1 | **AirArc** | `airplan-mkt/airarc/` | 架构规划设计器: 需求探讨→架构确认→生成规划 | `ArchitectureDesigner` | ❌ 正则替代LLM | +| 2 | **AirEng** | `airplan-mkt/aireng/` | 调度引擎: 波次规划→Dispatch→Monitor→Merge→Repair | `Scheduler` | ⚠️ 基础可调度 | +| 3 | **AirDo** | `airplan-mkt/airdo/` | 任务执行器: 接收TaskSpec→调用工具→验收→返回结果 | `ExecutorRole` | ⚠️ 简单任务可跑 | +| 4 | **AirDbg** | `airplan-mkt/airdbg/` | 调试器: 确认症状→取证→定位根因→修复→验证→关闭 | `DebuggerRole` | ❌ 从未触发 | +| 5 | **AirXDB** | `airplan-mkt/airxdb/` | GUI验证: 截图取证→diff对比→headless CI | `gui.screenshot` | ❌ 仅ImageMagick | +| 6 | **AirNDB** | `airplan-mkt/airndb/` | 网络调试: 抓包→分析→TLS解密 | `network.capture` | ⚠️ tcpdump封装 | +| 7 | **AirSDB** | `airplan-mkt/airsdb/` | 静态分析: cppcheck/clang-tidy→diff→多语言 | `toolchain-cpp` | ❌ 仅cppcheck注册 | +| 8 | **AirContext** | `aircontext-mkt/` | 上下文管理: 压缩→token估算→stale lock检测 | `ContextAssembler`+`CompactorRole` | ⚠️ 基础实现 | + +### V1已验证能力 → AirCoding丢失清单 + +| V1能力 | 来源缺陷 | AirCoding | +|---------|----------|-----------| +| 架构变更级联失效(ADR→Task失效→冻结→回滚→重规划) | P1-21, V2I-23 | ⚠️ 方法全实现, 零生产调用 | +| 证据优先门控(不取证不许改代码) | P1-17, V2I-30 | ❌ | +| 7步调试工作流强制(finish_worker 的 forced AirDbg routing) | P0-8, V2I-13, V2I-28 | ❌ | +| Plan mode 阻断(架构器永不写代码) | P0-5, V2I-09 | ❌ | +| 自主调度+中文锁定(不停下来问, 自动推进) | P0-6, P0-7, V2I-10, V2I-11 | ❌ | +| 任务类型感知证据门控(GUI/Network/CodeOnly分类) | P0-1, V2I-04 | ❌ | +| Worker超时+资源保护(7200s硬上限, load检测) | P1-10, V2I-07 | ⚠️ AgentMonitor基础 | +| 修复回滚(pre_fix_snapshot, git revert修复) | V2I-29 | ✅ _create_rollback_snapshot | +| 非原子写入保护(tempfile+os.replace) | P0-3 | ✅ ArtifactStore | +| 并发控制(flock替代无锁读改写) | P0-4 | ✅ SQLite事务 | +| Dispatch→Worker桥接(spawn_workers标准化,消除Agent回退) | P1-22, V2I-16 | ⚠️ WorkerManager可用 | + +--- + +## AP (Architecture Principles) — 189条 (关键摘录) + +### 来源: baselineV1.md + solution-architecture.md + system-overview-design.md + system-detailed-design.md + +> 完整189条AP参见 `/home/airlongdian/DataDevices/AirWorkSpace/AirCoding/AirPlan/docs/analysis/requirements-audit-report.md` + +### 核心架构原则 (baselineV1) + +65. **AP-01**: AirCoding is a self-owned AI coding agent/runtime, not a Claude Code plugin wrapper. +66. **AP-02**: Runtime is language-agnostic; C++ is first deep language profile. +67. **AP-03**: Core loop: requirement → architecture design → code reading → implementation planning → build → static analysis → test → run/debug → evidence → fix → summary → experience mining. + +### 执行质量基准 (solution-architecture §3) + +68. **AP-45**: Execution quality follows Claude Code — read-before-edit, exact, conservative, small, verified before completion. +69. **AP-46**: OpenCode is UI/runtime reference, not business-state dependency. +70. **AP-47**: Project-local source of truth under `.air/`. +71. **AP-48**: Events drive live behavior; SQLite drives recovery. +72. **AP-49**: Workers are isolated child processes over NDJSON IPC. +73. **AP-50**: Main Agent remains responsive; background work delegated to Scheduler. +74. **AP-51**: Architecture changes are explicit — implementation-level continues silently. +75. **AP-52**: Tool/capability boundaries are permissioned through ToolRegistry+PermissionEngine. +76. **AP-53**: Provider boundary isolated — internal Anthropic canonical; adapters convert at boundary. +77. **AP-54**: Evidence first-class — build/test/debug/review outputs become artifacts/evidence before completion. + +### 容器职责 (solution-architecture §4) + +78. **AP-55**: CLI: command entrypoint, startup, Doctor, project discovery, TUI/runtime bootstrap. +79. **AP-56**: TUI/HUD: consumes ProjectionStore only, no SQLite/EventBus queries. +80. **AP-57**: Runtime: MainAgent, ArchitectureDesigner, Scheduler, child process mgmt, EventBus/EventStore, SessionStore, ToolRegistry, PermissionEngine, CapabilityRegistry, ContextAssembler, ArtifactStore, EvidenceStore. +81. **AP-58**: LLM: provider config, adapters, Anthropic canonical handling, conversion, capability matrix. +82. **AP-59**: Toolchain C++: project detection, CMake, Ninja/Make, CTest, cppcheck, clangd, diagnostic parsing. +83. **AP-60**: Contracts: compileable shared TS interfaces, no domain implementation deps. + +### 禁止路径 (system-overview §5) + +84. **AP-85**: Forbidden: TUI→SQLite, TUI→runtime private, Worker→SQLite, Worker→direct fs/shell/network, tool→no PermissionEngine, capability→install outside Doctor, provider→silent semantic loss, repository→scheduling policy, EventBus→recovery source, runtime→TUI import, LLM output→direct file/shell. + +### 控制流 (solution-architecture §7) + +85. **AP-71**: Startup: CLI→detect→load→open/init .air→read-only Doctor→open session DB→hydrate ProjectionStore→start TUI/Main Agent. +86. **AP-72**: Normal execution: user→Main Agent classify→answer or plan→Scheduler create/load TaskGraph→ContextAssembler→dispatch Worker→tools→PermissionEngine→WorkerResult→retry/merge/review→report. +87. **AP-73**: Requirement change: requirement.changed→Scheduler pause→Architecture Designer assess→silent continue or confirm/replan. +88. **AP-74**: Recovery: restart→open DB→load tasks/agents→check liveness→emit lost/failed or resume→preserve workspaces→rebuild queues→hydrate ProjectionStore. + +### 数据架构 (solution-architecture §6) + +89. **AP-69**: SQLite: WAL/NORMAL/foreign_keys OFF. +90. **AP-88**: Durable event insert + domain update in same SQLite transaction. +91. **AP-91**: Event flow: Producer→EventIngestor→validate→durable: EventStore transaction+projection+EventBus publish; ephemeral: EventBus publish. + +### 安全 (solution-architecture §10, system-overview §12) + +92. **AP-79**: Security boundaries — LLM output untrusted; tools only path to effects; symlinks resolved by realpath; .git/ protected; project-outside writes require backup; credentials require confirmation; no auto-upload. +93. **AP-104**: Permission evaluation order: tool capability → permission profile → TaskSpec scope → path/command/network risk → credential/system-sensitive → user prompt. +94. **AP-105**: Permission actions: allow, deny, ask_user, block, refuse, announce_then_run. +95. **AP-106**: Path risk categories (8): project_source, project_build_output, project_air_shared, project_air_local, project_git_internal, outside_project, credential_or_secret, system_sensitive. +96. **AP-107**: Command risk categories (10): read_only, build, test, static_analysis, git_read, git_write, destructive, network, system_sensitive, credential_sensitive. + +### 上下文/压缩 (system-overview §13) + +97. **AP-109**: Compaction: ContextAssembler may request; Scheduler creates compact task; Compactor snapshots messages; summary.created; original messages preserved. +98. **AP-158**: ContextAssembler assembles Anthropic-canonical context; fits to token_budget; reports omissions; sets compaction_requested if budget cannot fit required layers. +99. **AP-77**: L0-L9 layers: runtime invariant, role/mode, safety/permission, project rules, architecture, task spec, evidence, conversation, tool history, instruction. + +### Worker/IPC (system-detailed-design §8) + +100. **AP-148**: WorkerManager spawn starts Bun child process then handshake; WorkerProcess owns NDJSON pipe. +101. **AP-150**: Worker roles: ExecutorRole (scoped write), ReviewerRole (read-only), DebuggerRole (scoped write assigned), CompactorRole (summaries/artifacts only), ExperienceMinerRole (candidates/rules/skills assigned). +102. **AP-151**: TaskType→WorkerRole: execute→Executor, review→Reviewer, debug→Debugger, compact→Compactor, mine_experience→ExperienceMiner, docs→Executor. +103. **AP-103**: Workers never write SQLite directly; never perform side effects outside parent-mediated tools. + +### 调度器 (system-detailed-design §7) + +104. **AP-143**: Scheduler states: IDLE→LOADING_GRAPH→PLANNING_WAVE→DISPATCHING→MONITORING→COLLECTING_RESULTS→MERGING→REVIEWING_WAVE→REPAIRING_OR_CONTINUING. Terminals: COMPLETED, BLOCKED, CANCELLED. +105. **AP-144**: TaskGraph: get_runnable_tasks honors hard deps completed, soft deps priority, conflict/serialization block concurrent dispatch on overlapping write areas. +106. **AP-146**: RetryPlanner actions: retry, retry_serial, debug, skip, block, cancel. +107. **AP-147**: WorkspaceManager strategies: main (no merge), worktree (git merge/patch), isolated_copy (copy-back/patch). + +### 可追溯性 (system-detailed-design §24) + +108. **AP-188**: System Detailed Design frozen as of 2026-06-01; multi-model review complete across 7 review rounds; all P0/P1/P2 findings closed; coverage: contracts 100%, events 100%, DB schema 100%, state machines 100%, forbidden edges 100%. + +### 不变量 (INV-1 ~ INV-5) + +109. **INV-1**: Session-DB state columns written only by event projection; no direct UPDATE from services. +110. **INV-2**: Cross-DB/external writes use outbox model; EventStore.project() never opens external DBs or files. +111. **INV-3**: All side effects only through tool + permission path (ToolRegistry.call → PermissionEngine.evaluate). +112. **INV-4**: Import/dependency direction is one-way per allowed graph; never crossed. +113. **INV-5**: EventBus is transport, never source of truth; recovery rebuilds from SQLite. + +### 参考复用 (system-detailed-design §23) + +114. **AP-185**: Reference reuse modes — npm-dep (consume directly), fork/adapt (copy+adapt), pattern (reference structure), behavioral (match behavior/quality). +115. **AP-186**: Reference map — OpenTUI: npm-dep; OpenCode TUI: pattern only; @opencode-ai/llm: fork/adapt; Claude Code CLI: behavioral only; OpenAI Codex: pattern; Hermes Agent: pattern; Anthropic Skills: pattern; asciinema/Atuin/claude-hud: pattern. +116. **AP-187**: Reference reuse rules — npm-dep items never re-implemented; fork/adapt items preserve own contracts/invariants; behavioral items contribute no code. + +--- + +## DF (Defect Fixes from AirPlan V2) — 33条 + +### 来源: airplanV2-Qwen3.7-Max设计.md §1.1 + +### P0 — 已造成实际损失 (10条) + +117. **P0-1**: AirXDB false positive blocking — evidence gate no task-type awareness; 11+ tasks. +118. **P0-2**: Deployment verification gap — validate_for_finalize() structural-only. +119. **P0-3**: Non-atomic writes — 5 _json_dump sites use direct path.write_text(). +120. **P0-4**: Zero concurrency control — todo.md read-modify-write race. +121. **P0-5**: AirArc hijacked by plan mode. +122. **P0-6**: AirEng stops to ask instead of autonomous decisions. +123. **P0-7**: AirEng no child-thread status polling — relies on Agent self-discipline. +124. **P0-8**: AirDo does not call AirDbg — skips debug, directly reports blocked/false-done. +125. **P0-9**: Installer script path errors. +126. **P0-10**: AirEng deviates from scheduling to write code. + +### P1 — 限制可靠性与可维护性 (14条) + +127. **P1-1**: Hardcoded developer paths (debug_runtime.py:130, airxdb_runtime.py:156). +128. **P1-2**: `_json_dump`/`_json_load` duplicated 5 times. +129. **P1-3**: `_ordered_unique` duplicated 4 times. +130. **P1-4**: policy normalization duplicated 3 times. +131. **P1-5**: merge-into-state duplicated 3 times. +132. **P1-6**: marker block upsert duplicated 2 times with different interfaces. +133. **P1-7**: `_session_stamp` format inconsistent. +134. **P1-8**: todo.md column index hardcoded (doc_sync.py:154-156). +135. **P1-9**: Concurrency cap hardcoded as 3 (engine.py:527). +136. **P1-10**: Child processes have no timeout in airxdb/debug runtime. +137. **P1-11**: task_id path injection at worker.py:59 — no `../` validation. +138. **P1-12**: Marker injection risk in doc_sync.py `_replace_marker_block()`. +139. **P1-13**: Silent exception swallowing — session file corruption continue with no log. +140. **P1-14**: Arc re-planning then Eng cannot connect — static todo.md table cannot absorb dynamic replanning. +141. **P1-15**: Same-file non-conflicting tasks forced serial — file-level conflict detection. +142. **P1-16**: AirArc skips requirements discussion, directly generates plan. +143. **P1-17**: AirDbg modifies code without evidence collection. +144. **P1-18**: Project lacks standardized logging (no spdlog). +145. **P1-19**: No boundary tests + final review lacks high-risk checks. +146. **P1-20**: UI design lacks professional Skill support. +147. **P1-21**: ADR changes have no cascading invalidation mechanism. +148. **P1-22**: Dispatch→Worker launch has no bridge — dispatch_worker_group() writes JSON, no Worker launch. +149. **P1-23**: Dispatch instruction ambiguity — commands/eng.md intent description, not pseudocode. +150. **P1-24**: Merge then TaskGraph state out of sync — merge_worker_result() doesn't update task-graph.json. + +### P2 — 限制规模化 (4条) + +151. **P2-1**: Conflict detection O(n²) — review.py combinations(active_tasks, 2). +152. **P2-2**: state.json unbounded growth — mergedResults never truncated. +153. **P2-3**: todo.md full re-parse on every operation. +154. **P2-4**: Zero test coverage — entire air_runtime/. + +### P3 — 限制用户体验 (5条) + +155. **P3-1**: AGENTS.md bloat — AirEng sync appends without dedup. +156. **P3-2**: Write-set rigidity causes cascading task chains. +157. **P3-3**: Parallel Workers compete for shared hardware — no awareness. +158. **P3-4**: Environment-specific fixes not persistable. +159. **P3-5**: Cross-project knowledge not transferred. + +--- + +## V2I (V2 Improvements) — 40条 + +### 来源: airplanV2-Qwen3.7-Max设计.md §3 + +160. **V2I-01** (§3.1.1): `air_runtime.io` — atomic_json_write (tempfile+os.replace), safe_json_load. +161. **V2I-02** (§3.1.2): `air_runtime.lock` — FileLock based on fcntl.flock with timeout. +162. **V2I-03** (§3.1.3): `air_runtime.utils` — ordered_unique, session_stamp, normalize_policy, sanitize. +163. **V2I-04** (§3.2.1): EvidenceGatePolicy — task-type-aware (GUI_INDICATORS, NETWORK_INDICATORS). +164. **V2I-05** (§3.2.2): Deploy verification enforcement in WorkerResult.validate_for_finalize. +165. **V2I-06** (§3.2.3): AdaptivePoller — dynamic intervals (min 30s, max 300s). +166. **V2I-07** (§3.2.4): Worker timeout (WORKER_MAX_WALL_TIME=7200s) + resource protection. +167. **V2I-08** (§3.2.5): Merge transactionization with FileLock. +168. **V2I-09** (§3.2.6): AirArc plan mode blocking — allowed_tools: [Read, Glob, Grep]; deny_plan_mode. +169. **V2I-10** (§3.2.7): AirEng autonomous decision + Chinese lock. +170. **V2I-11** (§3.2.8): AirEng hardcoded polling loop — mandatory 5-minute cycle. +171. **V2I-12** (§3.2.8b): AirEng scheduling boundary — EXTREME_TAKEOVER only when budget exhausted + ≤5 lines. +172. **V2I-13** (§3.2.9): AirDo mandatory AirDbg routing (forced=true). +173. **V2I-14** (§3.2.10): Installer path correction — absolute paths + post_install_verify. +174. **V2I-15** (§3.2.11): Dynamic graph scheduling (TaskGraph+PlanDelta) — **已在AirCoding实现**. +175. **V2I-16** (§3.2.11b): Dispatch→Worker launch bridge — spawn_workers standardized. +176. **V2I-17** (§3.2.11c): Merge→TaskGraph state sync — task-graph.json node status is authoritative. +177. **V2I-18** (§3.2.12): Worktree isolation for same-file different-region parallelism. +178. **V2I-19** (§3.2.13): AirArc requirements discussion gate — three-phase process. +179. **V2I-20** (§3.2.14): Project-level spdlog logging standard. +180. **V2I-21** (§3.2.15): Boundary test enforcement + final review high-risk audit. +181. **V2I-22** (§3.2.16): frontend-design Skill integration. +182. **V2I-23** (§3.2.17): ADR change cascading invalidation — **已在AirCoding实现方法,待生产接线**. +183. **V2I-24** (§3.3.1): Compression quality validation — **已在AirCoding实现CompressionValidator**. +184. **V2I-25** (§3.3.2): Token estimation improvement — AdaptiveTokenEstimator. +185. **V2I-26** (§3.3.3): Stale lock detection. +186. **V2I-27** (§3.4): AirSDB multi-language static analysis. +187. **V2I-28** (§3.5.1): AirDbg workflow enforcement — 7 mandatory steps. +188. **V2I-29** (§3.5.2): Fix rollback — pre_fix_snapshot. +189. **V2I-30** (§3.5.3): Evidence-first gate. +190. **V2I-31** (§3.6.1): AirXDB DRM/KMS native screenshot. +191. **V2I-32** (§3.6.2): AirXDB headless CI XvfbCapture. +192. **V2I-33** (§3.7.1): AirDep deployment plugin. +193. **V2I-34** (§3.7.2): AirTst test runner plugin. +194. **V2I-35** (§3.7.3): AirSec security scan plugin. +195. **V2I-36** (§3.7.4): AirRvr requirements reviewer plugin. +196. **V2I-37** (§3.7.4): Code-to-Design consistency review (mandatory line-level comparison every review). +197. **V2I-38** (§3.7.4): AirRvr AirEng integration — verdict=pass/conditional-pass/fail. +198. **V2I-39** (§3.7.4): Event index layer — EventLog as structured JSONL timeline. +199. **V2I-40** (§3.8): air_runtime module reorganization — io.py, lock.py, utils.py, events.py, task_graph.py, etc. + +--- + +## V2 Design Goals, Invariants, KPIs + +### 来源: airplanV2-Qwen3.7-Max设计.md §2, §7 + +### V2 Goals +200. Reliability — state writes not lost, concurrent ops race-free, self-healing after crash. +201. Observability — all engine operations traceable, metrics exportable. +202. Intelligence — evidence gating perceives task type, polling adaptive. +203. Scale — support 100+ tasks, 5+ parallel Workers. + +### V2 Invariants +204. INV-1: Artifact-driven communication through AirPlan/ files; V2 adds event index layer. +205. INV-2: Context isolation (fork_context=false); V2 adds selective context inheritance. +206. INV-3: Architecture sync mandatory — cannot DONE without updating architecture docs. +207. INV-4: Evidence before repair — screenshot/packet-capture/static-analysis first. +208. INV-5: Closed-loop auto-repair — execute→fail→debug→fix→re-execute. + +### V2 KPIs (26个) +209. AirXDB false positive: V1 ~60% → V2 <5% +210. State file corruption: V1 known → V2 0% +211. Deployment consistency incidents: V1 1 critical → V2 0 +212. Hollow fix cycles: V1 11+ → V2 0 +213. Code duplication: V1 5 copies → V2 1 per function +214. Test coverage: V1 0% → V2 core >80% +215. AirArc plan mode hijack: V1 frequent → V2 0 +216. AirArc skip requirements: V1 every launch → V2 0 +217. AirEng non-Chinese output: V1 frequent → V2 0 +218. AirDo skips AirDbg: V1 frequent → V2 0 +219. AirDbg modifies code without evidence: V1 frequent → V2 0 +220. Boundary without test coverage: V1 all → V2 100% +221. Final review missing high-risk: V1 none → V2 100% +222. UI tasks without Skill: V1 all → V2 100% +223. ADR change old code residue: V1 none → V2 0 (cascade+git revert) +224. Dispatch→Worker broken: V1 Agent stops → V2 0 (spawn_workers + instruction ops) +225. Post-merge duplicate dispatch: V1 redispatched → V2 0 (task-graph.json sync) + +--- + +## V2 Phase Plan + +### 来源: airplanV2-Qwen3.7-Max设计.md §4 + +226. **V2-Phase1** (P0 fixes): atomic I/O, file locks, utils dedup, EvidenceGatePolicy, deploy verify, hardcoded paths, child timeouts, injection protection, exception handling, Arc plan-mode blocking, Eng Chinese+autonomous, Eng polling, Do→Dbg forced routing, installer path fix, Arc requirements gate, Dbg evidence-first gate, spdlog standard, Eng boundary, boundary tests+highRiskAudit, frontend-design Skill, dispatch bridge, merge TaskGraph sync. + +227. **V2-Phase2** (Engine enhancement): AdaptivePoller, Worker timeout+resource, merge transactionization, AGENTS.md dedup, EventLog, todo column derivation, configurable concurrency, state.json cap, TaskGraph+PlanDelta, region conflict+worktree, ADR cascading invalidation. + +228. **V2-Phase3** (New plugins): AirDep, AirTst, AirSDB multi-lang, AirXDB kmsgrab+xvfb, AirDbg step tracking+rollback, AirRvr, AirSec. + +229. **V2-Phase4** (Scale): Conflict detection O(n log n), todo.md cache, compression validation, token estimation, stale lock, AirArc incremental replanning, cross-project ops template. + +230. **V2-Phase5** (Test coverage): todo_parser, review, doc_sync, contracts, engine, io, lock, evidence_gate, task_graph, worktree. + +--- + +## 总结 + +### 设计文档统计 + +| 类别 | 数量 | +|------|------| +| FR | 21 + 7子要求 | +| NFR | 8 | +| AC | 13 | +| CT | 16 | +| RB | 6 | +| **PV (V1插件原型)** | **8** | +| AP (含INV) | 194 | +| DF (P0-P3) | 33 | +| V2I | 40 | +| V2 Goals/Invariants/KPIs | 35 | +| Phase items | 5 | +| **总计** | **391** | + +### 架构核心原则 + +**Agent 存在的目的是扩展插件的能力边界。插件代表的工作流才是产品核心。** + +AirCoding V1.0.0 Alpha 不是从零开发的新产品,而是将 8 个已验证的 Python/Claude Code Skill 插件移植到 TypeScript/Bun/SQLite 运行时。Agent (MainAgent/Scheduler/Worker) 是基础设施底座,8个插件工作流(AirArc/AirEng/AirDo/AirDbg/AirXDB/AirNDB/AirSDB/AirContext)才是交付给用户的价值。 + +### V1插件 → AirCoding 移植完整度 (8个核心工作流) + +| V1插件 | AirCoding模块 | 工作流可运行? | 缺失 | +|--------|--------------|-------------|------| +| AirArc | ArchitectureDesigner | ❌ | 正则替代LLM, 无三步流程, 无PlanMode阻断 | +| AirEng | Scheduler | ❌ | 基础调度可跑, 无级联保护/自主决策/硬编码轮询 | +| AirDo | ExecutorRole | ⚠️ | 简单任务可跑, 不强制调AirDbg | +| AirDbg | DebuggerRole | ❌ | 从未触发, 7步工作流仅在提示词中 | +| AirXDB | gui.screenshot | ❌ | 仅ImageMagick, 无headless/diff | +| AirNDB | network.capture | ⚠️ | 仅tcpdump封装, 不产artifact | +| AirSDB | toolchain-cpp | ❌ | 仅cppcheck注册, 无build管道 | +| AirContext | ContextAssembler+Compactor | ⚠️ | 基础可组装, CompactorRole未运行 | +| **总体** | — | **0/8 可交付** | **8/8 需要工作流级别的移植** | + +### 产品差距 — V1已验证能力丢失 + +407h的产出集中在了基础设施层(EventStore/Scheduler/ToolRegistry/SQLite),但8个V1已生产验证的插件工作流没有一个被完整移植。原因是开发从未以"插件工作流逐条移植"为目标,而是在造一个通用的Agent运行时——然后假设插件工作流"自然会跑在上面"。 + +**正确的开发顺序**: 先移植插件工作流(AirArc→ArchitectureDesigner, AirEng→Scheduler, AirDo→ExecutorRole...),每移植一个就端到端验证一个。基础设施随工作流需求演进,而非反过来先造全套基础设施再填工作流。 + +**核心结论**: 当前AirCoding产品不可发布。0/8 V1插件工作流可运行。397h的产出是一个Agent基础设施demo,不是符合6份设计文档391条要求的V1.0.0 Alpha产品。 diff --git a/AirPlan/docs/analysis/requirements-audit-report.md b/AirPlan/docs/analysis/requirements-audit-report.md new file mode 100755 index 0000000..2ef6e77 --- /dev/null +++ b/AirPlan/docs/analysis/requirements-audit-report.md @@ -0,0 +1,347 @@ +# AirCoding V1.0.0 Alpha — 完整需求清单与差距报告 + +**生成日期**: 2026-06-11 +**状态**: Fable5 主模型终审 + deepseek-v4-pro 全文提取 +**来源文档**: +1. `requirements.md` — 21条FR + 8条NFR + 13条AC + 10条CT +2. `airplanV2-Qwen3.7-Max设计.md` — 25个P0-P3缺陷 + 40个V2改进 + 26个KPI +3. `baselineV1.md` — 5个参考项目基准 + 44条架构原则 +4. `solution-architecture.md` — 10项架构原则 + 6个容器 + 4个控制流 + 安全模型 +5. `system-overview-design.md` — 18节系统概览设计 +6. `system-detailed-design.md` — 24节详细类方法设计 + 序列 + 状态机 + 可追溯矩阵 + +--- + +## 一、参考项目基准 (RB-01 ~ RB-06) + +| ID | 参考项目 | 要求复用的内容 | +|----|----------|---------------| +| RB-01 | **Claude Code CLI** | 执行层质量基准: 精确编辑、读后编辑、小块补丁、不重构无关代码、验证后完成、证据闭环、TAOR/TORI反馈循环 | +| RB-02 | **OpenCode** | TUI视觉风格/交互布局、运行时分层、Session/事件/同步概念、Provider/模型抽象、插件/SDK思路。**复用OpenTUI原语,不复用SDK/sync/session业务逻辑** | +| RB-03 | **Hermes Agent** | 经验挖掘、Nudge Engine间隔触发学习、Curator守护进程、Skill自修复、SKILL.md格式、FTS检索 | +| RB-04 | **OpenAI Codex** | Shell/patch/test直接执行循环、编码沙箱、工具编排、MCP实现思路 | +| RB-05 | **Anthropic Skills** | SKILL.md结构/前置元数据、技能目录布局(scripts/references/assets)、可复用工作流打包 | +| RB-06 | **asciinema/Atuin/claude-hud** | PTY捕获和终端回放、命令元数据/历史索引、HUD/状态栏布局 | + +--- + +## 二、功能需求 (FR-001 ~ FR-020 + FR-007.5) + +### FR-001 CLI启动与项目初始化 +从CLI入口启动,检测/打开项目,需要时初始化`.air/`,加载资源/配置,运行只读Doctor,打开session。 + +### FR-002 项目本地状态 +`.air/shared/`(可共享配置/规则/计划) + `.air/local/`(私有sessions/artifacts/workspaces/backups/local DBs) + +### FR-003 会话持久化 +SQLite at `/.air/local/sessions//session.db`, 支持: messages, drafts, durable events, task graph state, agents, tool/command runs, artifacts, diagnostics, evidence refs, workspaces, summaries, UI state + +### FR-004 事件驱动运行时 +发布RuntimeEvents用于实时行为,持久事件与域表更新在同一个事务中 + +### FR-005 主代理对话 +面向用户的Main Agent: 接收请求、适当直接回答、分类工作、显示进度、呈现阻断/确认 + +### FR-006 架构设计师 +架构/接口/产品级决策路由到Architecture Designer: 更新架构制品、产生影响评估 + +### FR-007 调度器与任务图 +调度TaskSpec: hard/soft依赖、写区冲突处理、重试预算、子Worker派发、心跳监控、合并协调、重启恢复 + +### FR-007.5 ADR级联失效与架构变更回滚 (7条子要求) +1. 通过TaskNode.adr_refs溯源所有依赖该ADR的任务(含已完成) +2. 级联失效: completed→invalidated, running→终止, pending→cancelled +3. 冻结调度(dispatch_frozen),阻止新任务派发 +4. 创建git回滚快照(rollback_ref),支持revert旧方案代码 +5. 接收ArchitectureDesigner产出的PlanDelta增量重规划 +6. apply_delta吸收新任务后解冻调度 +7. 终审时检查INVALIDATED任务的旧代码是否已清理 + +### FR-008 独立Worker Agent +Executor/Reviewer/Debugger/Compactor/ExperienceMiner作为独立Bun子进程,通过NDJSON IPC通信 + +### FR-009 Claude Code级执行原语 +强制: read-before-edit, exact conservative edits, small patches, no unrelated refactors, permission checks, verification-before-completion + +### FR-010 ToolRegistry和内置工具 +Schema验证的工具: filesystem/shell/git/project scanning/完整C++ build/test/static-analysis/debug/GUI screenshot/network capture/artifacts/context assembly/permission requests/Doctor + +### FR-011 权限与安全模型 +路径/命令/网络/凭证分类;强制权限配置;保护系统敏感和凭证操作;项目外写入备份;拒绝不安全请求 + +### FR-012 插件与能力基础 +Manifest加载/验证、启用/禁用配置、依赖声明、Doctor集成、命名空间工具注册、源/信任元数据、PermissionEngine强制。第三方注册/签名可延后,本地和内置capability打包必须可用 + +### FR-013 Provider层 +内部使用Anthropic canonical消息,通过适配器路由provider调用,能力矩阵验证,转换报告 + +### FR-014 上下文组装与压缩 +有序层组装prompt、适配token预算、记录遗漏、必要时copy-on-write压缩 + +### FR-015 制品与证据管理 +temp-file→atomic rename,记录URI/path/hash/metadata,通过evidence refs链接声明 + +### FR-016 TUI与HUD +OpenTUI/Solid终端UI和HUD,**仅消费ProjectionStore,不查询原始DB/EventBus** + +### FR-017 完整C++开发流程 +项目检测→构建系统评估→CMake configure→Ninja优先/Make回退→编译器/链接器诊断解析→clangd代码智能查询→cppcheck静态分析→CTest/GoogleTest执行→debug运行/日志解析→失败诊断→范围修复→审查→证据支持验证 + +### FR-018 Doctor +启动时运行只读Doctor;报告环境/能力问题;在权限策略下支持修复模式 + +### FR-019 日志与诊断 +可读`air.log`,加密`air.developer.log`,默认7天保留 + +### FR-020 发布门禁 +定义tier-1 Linux发布门禁: 单元测试、集成fixture重放、真实LLM E2E、项目初始化、C++构建/测试流程、SQLite恢复、子IPC、TUI启动、制品/事件持久化 + +--- + +## 三、非功能需求 (NFR-001 ~ NFR-008) + +| ID | 需求 | +|----|------| +| NFR-001 | 本地优先: 项目状态/制品/日志/调试知识保留在本地,除非用户显式导出/分享/上传 | +| NFR-002 | 可恢复性: 从进程/session重启恢复,读取SQLite状态,检测丢失agents,保留workspaces,重建Scheduler队列 | +| NFR-003 | 可扩展性: 通过`toolchain-*`包和能力清单添加语言/工具链支持 | +| NFR-004 | Provider灵活性: 内部契约在Anthropic/OpenAI/OpenRouter/ollama/兼容端点保持稳定 | +| NFR-005 | UI响应性: Main Agent和TUI在后台Worker运行时保持响应 | +| NFR-006 | 证据驱动完成: 任务未获得build/test/debug/review证据或显式skipped-gate报告前不得标记完成 | +| NFR-007 | Linux优先: Linux x86_64=tier1, arm64/WSL2=tier2, macOS=实验, Windows=post-MVP | +| NFR-008 | 安全边界保持: LLM输出、工具结果、插件、外部内容在被运行时契约和策略验证前为不可信数据 | + +--- + +## 四、验收标准 (AC-01 ~ AC-13) + +1. CLI启动并初始化/打开项目`.air/`树 +2. Session DB schema初始化并持久化messages/events/tasks/tool runs/artifacts +3. EventStore事务性地将核心持久事件应用到域表 +4. ProjectionStore水合并更新可用的TUI/HUD视图 +5. Scheduler通过NDJSON IPC派发Worker子进程,通过父runtime支持工具调用,接收WorkerResult +6. ToolRegistry通过PermissionEngine执行filesystem/shell/git/artifact/context/doctor/C++/debug/GUI/network证据工具 +7. C++工作流可检测、配置、构建、静态分析、测试、调试、修复、审查、重新验证代表性fixture项目 +8. 失败的构建/测试/调试命令产生diagnostics/artifacts/evidence refs并可触发Debugger修复 +9. ContextAssembler产生Anthropic canonical消息,必要时记录遗漏 +10. Provider适配器路径可在能力验证和转换报告下执行模型调用 +11. 能力清单可加载、验证、启用并注册为命名空间工具 +12. Doctor报告平台/provider/toolchain/capability/display/network状态并支持权限修复模式 +13. 发布门禁命令在tier-1 Linux上记录并可运行 + +--- + +## 五、约束 (CT-01 ~ CT-16) + +| ID | 约束 | +|----|------| +| CT-01 | 运行时: TypeScript on Bun | +| CT-02 | Monorepo: Bun workspaces + Turborepo | +| CT-03 | TUI: `@opentui/solid`, `@opentui/core`, `@opentui/keymap` | +| CT-04 | IPC: NDJSON over stdio | +| CT-05 | DB: SQLite per session, WAL/NORMAL/foreign_keys OFF | +| CT-06 | 内部消息格式: Anthropic canonical content blocks | +| CT-07 | C++第一个深度工具链; runtime保持语言无关 | +| CT-08 | Python仅子进程辅助层,非核心runtime | +| CT-09 | 早期发行用binary tarball,非公共包渠道 | +| CT-10 | 架构文档和工作流状态在`AirPlan/`下 | +| CT-11 | Monorepo包(Alpha): contracts/cli/tui/runtime/llm/toolchain-cpp | +| CT-12 | 依赖方向: contracts←(none); cli→tui/runtime/llm/toolchain-cpp; runtime→contracts+llm+toolchain-*; tui→contracts only; runtime禁止依赖tui | +| CT-13 | 全局用户目录: `~/.air/` | +| CT-14 | project_id是`.air/shared/project.json`中的稳定UUID | +| CT-15 | `.gitignore`: `.air/local/` | +| CT-16 | 所有副作用必须通过ToolRegistry和PermissionEngine | + +--- + +## 六、架构原则完整清单 (AP-01 ~ AP-189) + +> 详细AP清单已由deepseek-v4-pro提取,参见`/home/airlongdian/DataDevices/AirWorkSpace/AirCoding/AirPlan/docs/analysis/full-requirements-audit.md` +> 包含: 本节仅列关键原则概要,完整189条见审计文件。 + +### 核心执行原则 +- AP-01: AirCoding是自有的AI编码runtime,非Claude Code插件包装器 +- AP-02: Runtime语言无关; C++第一个深度profile; 通过`toolchain-`扩展 +- AP-03: 核心循环: requirement → design → reading → planning → build → analysis → test → debug → evidence → fix → summary → mining +- AP-45: 执行质量遵循Claude Code: 读后编辑、精确、保守、小步、验证后完成 +- AP-46: OpenCode是UI/runtime参考,非业务状态依赖 +- AP-47: 项目本地为真源: session状态/制品/备份/项目规则在`.air/`下 +- AP-48: 事件驱动活动行为; SQLite驱动恢复 +- AP-49: Worker是隔离的子进程(Executor/Reviewer/Debugger/Compactor/ExperienceMiner),通过NDJSON IPC通信 +- AP-50: Main Agent保持响应; 长运行后台工作委派给Scheduler/Worker +- AP-51: 架构变更是显式的; 实现级变更静默继续; 接口级变更通过Architecture Designer +- AP-52: 工具/能力边界受权限保护; 所有内置和插件工具通过ToolRegistry+PermissionEngine +- AP-53: Provider边界隔离; 内部Anthropic canonical; 适配器在边界转换 +- AP-54: 证据是一等公民: build/test/debug/review输出在完成声明前成为制品和证据引用 + +### 容器依赖 +- AP-55: CLI容器: 命令入口/启动/初始化/Doctor/项目发现/TUI/runtime引导 +- AP-56: TUI/HUD容器: 仅消费ProjectionStore; 不查询SQLite/EventBus; 不持有调度状态 +- AP-57: Runtime容器: MainAgent/ArchitectureDesigner/Scheduler/子进程管理/EventBus/EventStore/SessionStore/ToolRegistry/PermissionEngine/CapabilityRegistry/ContextAssembler/ArtifactStore/EvidenceStore/ProjectionStore +- AP-58: LLM容器: Provider配置/适配器/Anthropic canonical处理/转换/能力矩阵/流式/工具调用/Token计数 +- AP-59: Toolchain C++容器: 项目检测/CMake/Ninja/CTest/cppcheck/clangd/诊断解析/证据生成 +- AP-60: Contracts容器: 可编译共享TS接口; 不依赖域实现包 + +### 禁止路径 +- AP-85: TUI→SQLite直接查询、TUI→runtime私有服务导入、Worker→SQLite直接写入、Worker→工具外fs/shell/network、工具→无PermissionEngine副作用、能力→Doctor外依赖安装、Provider适配器→静默语义损失、仓库→调度策略、EventBus→恢复真源、runtime→TUI导入、LLM输出→直接文件/shell副作用 + +### 事件/数据规则 +- AP-69: SQLite: WAL/NORMAL/foreign_keys=OFF +- AP-88: 持久事件插入+域表更新在同一SQLite事务中 +- AP-91: 事件流: Producer→EventIngestor→验证→持久:EventStore事务+域投影+EventBus发布; 短暂:EventBus发布 +- AP-92: route追加只; route_text从route.join("/")派生; payload schema变更需版本递增 +- AP-137: EventStore.append事务中schema验证→EventRepository.insert→project(event,tx)→提交后EventBus.publish + +### 控制流 +- AP-72: 正常执行: 用户请求→Main Agent分类→直接回答或架构/任务规划→Scheduler创建/加载TaskGraph→ContextAssembler→Scheduler派发Worker→工具→PermissionEngine→WorkerResult→Scheduler重试/合并/审查→Main Agent报告 +- AP-73: 需求变更: requirement.changed事件→Scheduler暂停受影响工作→Architecture Designer评估→实现级静默继续→架构/产品级路由用户确认/重规划 +- AP-74: 恢复: 重启→打开session DB→加载运行/中断任务→检查子进程存活→发出agent.lost/task.failed或重连/恢复→保留未合并workspaces→重建Scheduler队列→水合ProjectionStore + +### 安全 (AP-79, AP-104~108, AP-171) +- LLM输出在验证前不可信 +- 工具是文件系统/shell/network副作用的唯一路径 +- 符号链接通过realpath解析后分类 +- `.git/`默认保护; build目录允许项目写入 +- 项目外写入需备份; 凭证/系统敏感操作需显式确认 +- 无自动上传日志/制品/调试知识/Doctor包 +- 权限评估顺序: 工具能力声明→权限profile→TaskSpec范围→路径/命令/网络风险→凭证/系统敏感→用户提示 +- 8个路径风险类别 + 10个命令风险类别 + +### 可追溯性 +完整189条AP及11条INV详见审计文件: +`/home/airlongdian/DataDevices/AirWorkSpace/AirCoding/AirPlan/docs/analysis/full-requirements-audit.md` + +--- + +## 七、AirPlan V2 缺陷 (DF-P0 ~ DF-P3, 共33个) + +### P0 — 已造成实际损失 (DF-P0-01 ~ DF-P0-10) +1. AirXDB假阳性阻塞 — 证据门控无任务类型感知 +2. 部署验证缺口 — validate_for_finalize()只检查结构完整性 +3. **非原子写入** — 5处_json_dump直接覆盖(→ AirCoding已用ArtifactStore temp+rename修复) +4. **零并发控制** — todo.md读改写竞态(→ AirCoding已用SQLite事务修复) +5. AirArc被plan模式劫持 +6. AirEng停问而不自主决策 +7. AirEng无子线程状态轮询 +8. AirDo不调用AirDbg +9. 安装器脚本路径错误 +10. AirEng偏离调度亲自写代码 + +### P1 — 限制可靠性 (DF-P1-01 ~ DF-P1-14) +1. 硬编码开发者路径 +2. _json_dump重复5份 +3. _ordered_unique重复4份 +4. policy normalization重复3份 +5. merge-into-state重复3份 +6. marker block upsert重复2份 +7. _session_stamp格式不一致 +8. todo.md列索引硬编码 +9. 并发度硬编码为3 +10. 子进程无超时 +11. task_id路径注入 +12. 标记注入风险 +13. 静默吞异常 +14. Arc重规划后Eng无法衔接 → **AirCoding TaskGraph+PlanDelta解决** + +### P2 — 限制规模化 (DF-P2-01 ~ DF-P2-04) +1. 冲突检测O(n²) +2. state.json无界增长 +3. todo.md全量重解析 +4. 零测试覆盖 + +### P3 — 限制用户体验 (DF-P3-01 ~ DF-P3-05) +1. AGENTS.md膨胀 +2. 写集刚性导致级联任务链 +3. 并行Worker抢占共享硬件 +4. 环境特定修复不可持久 +5. 跨项目知识不迁移 + +--- + +## 八、AirPlan V2 改进 (V2I-01 ~ V2I-40) + +见完整审计文件,关键项: +- V2I-01: 统一原子I/O模块(air_runtime.io) +- V2I-04: 任务类型感知的证据门控 +- V2I-15: **动态图调度(TaskGraph+PlanDelta) — 已在AirCoding中实现** +- V2I-18: Worktree隔离同文件不同区域并行 +- V2I-22: frontend-design Skill集成 +- **V2I-23: ADR变更级联失效 — 已在AirCoding中实现方法,待生产接线** +- **V2I-24: 压缩质量验证(CompressionValidator) — 已在AirCoding中实现** +- V2I-28: AirDbg 7步工作流强制 +- V2I-30: 证据优先门控(EvidenceFirstGate) +- V2I-37: Code-to-Design一致性审查(每行比较) + +--- + +## 九、V2 KPI (26个) + +| KPI | V1当前 | V2目标 | +|-----|--------|--------| +| AirXDB假阳性率 | ~60% | <5% | +| 状态文件损坏率 | 已知发生 | 0% | +| 部署一致性事故 | 1次关键 | 0 | +| 空壳修复循环 | 11+ | 0 | +| 代码重复 | 5份_json_dump | 每函数1份 | +| 测试覆盖率 | 0% | >80% | +| ADR变更旧代码残留 | 无自动清理 | 0(级联失效) | +| Dispatch→Worker断链 | Agent停止调度 | 0(spawn_workers标准化) | + +--- + +## 十、当前产品差距评估 + +### 参考项目对照 + +| 参考项目 | 要求 | 实际 | +|----------|------|------| +| **Claude Code CLI** (RB-01) | 执行层质量基准: read-before-edit, exact edits, verification | ❌ 全凭提示词,代码无强制 | +| **OpenCode** (RB-02) | TUI视觉/交互/Provider抽象, 复用OpenTUI, 不复用SDK | ❌ 47个console.log撕裂TUI, 重写了Provider | +| **Hermes Agent** (RB-03) | 经验挖掘/Nudge/Curator/Skill自修复 | ❌ ExperienceMinerRole从未运行 | +| **OpenAI Codex** (RB-04) | Shell/patch/test执行循环, 工具编排 | ⚠️ 工具内联不统一 | +| **Anthropic Skills** (RB-05) | SKILL.md格式, 技能目录布局 | ⚠️ 仅capability manifest | +| **asciinema/Atuin/claude-hud** (RB-06) | PTY/HUD/状态栏 | ❌ HUD无对话面板 | + +### 21条FR严格评估 + +| FR | 状态 | 说明 | +|----|------|------| +| FR-001 | ⚠️ | init可跑,Doctor执行但结果不展示 | +| FR-002 | ✅ | 目录布局正确 | +| FR-003 | ❌ | NOT NULL/UNIQUE持续崩溃 | +| FR-004 | ❌ | 投影缺口持续,虽有诊断脚本修复,未端到端验证 | +| FR-005 | ❌ | 正则分类器+dispatchTask,无对话 | +| FR-006 | ❌ | 正则判断(文件数>10),无LLM | +| FR-007 | ❌ | RetryPlanner字段不匹配 | +| FR-007.5 | ❌ | 方法全有,零生产调用 | +| FR-008 | ❌ | 仅ExecutorRole实跑过 | +| FR-009 | ❌ | 全凭提示词 | +| FR-010 | ❌ | 定义28个工具,cpp.*/debug.*从未触发 | +| FR-011 | ⚠️ | 已修复部分崩溃,permission.request修复 | +| FR-012 | ❌ | 仅1个capability包 | +| FR-013 | ❌ | 仅OpenAI兼容适配器 | +| FR-014 | ❌ | CompactorRole从未运行 | +| FR-015 | ⚠️ | ArtifactStore可用,evidence_refs已修复 | +| FR-016 | ❌ | 47个console.log撕裂TUI | +| FR-017 | ❌ | cpp.*从未端到端 | +| FR-018 | ❌ | Doctor跑了不展示 | +| FR-019 | ⚠️ | Logger存在,写入未验证 | +| FR-020 | ❌ | 27/27门禁方法级,产品不可用 | + +### 分类 + +- ✅ 可达: 1/21 (FR-002) +- ⚠️ 部分可达: 3/21 (FR-001, FR-011, FR-015, FR-019) +- ❌ 不可达: 17/21 + +--- + +## 文件导航 + +- 完整审计文件(383条详细清单): `/home/airlongdian/DataDevices/AirWorkSpace/AirCoding/AirPlan/docs/analysis/full-requirements-audit.md` +- 功能需求: `AirPlan/docs/analysis/requirements.md` +- 架构方案: `AirPlan/docs/architecture/solution-architecture.md` +- 基准V1: `AirPlan/docs/architecture/baselineV1.md` +- V2设计: `/home/airlongdian/DataDevices/AirWorkSpace/air-plugins-dist/airplanV2-Qwen3.7-Max设计.md` +- 详细设计: `AirPlan/docs/architecture/system-detailed-design.md` +- 系统概览: `AirPlan/docs/architecture/system-overview-design.md` diff --git a/AirPlan/docs/analysis/requirements.md b/AirPlan/docs/analysis/requirements.md index c257ff4..a09b2b2 100755 --- a/AirPlan/docs/analysis/requirements.md +++ b/AirPlan/docs/analysis/requirements.md @@ -58,6 +58,15 @@ AirCoding must route architecture/interface/product-impacting decisions to an Ar AirCoding must schedule TaskSpec records with hard/soft dependencies, write-area conflict handling, retry budgets, child worker dispatch, heartbeat monitoring, merge coordination, and restart recovery. +**FR-007.5 ADR 级联失效与架构变更回滚**:当 ADR 发生架构方案变更(如 ffmpeg → gstreamer)时,调度器必须: +1. 通过 TaskNode.adr_refs 溯源所有依赖该 ADR 的任务(含已完成) +2. 级联失效受影响任务(completed→invalidated、running→终止、pending→cancelled) +3. 冻结调度(dispatch_frozen),阻止新任务派发 +4. 创建 git 回滚快照(rollback_ref),支持 revert 旧方案代码 +5. 接收 ArchitectureDesigner 产出的 PlanDelta 增量重规划 +6. apply_delta 吸收新任务后解冻调度 +7. 终审时检查 INVALIDATED 任务的旧代码是否已清理 + ### FR-008 Independent Worker Agents Executor, Reviewer, Debugger, Compactor, and ExperienceMiner must run as independent Bun child processes communicating through NDJSON IPC. diff --git a/AirPlan/docs/architecture/event-registry-v1.md b/AirPlan/docs/architecture/event-registry-v1.md index db8607a..07bdd57 100755 --- a/AirPlan/docs/architecture/event-registry-v1.md +++ b/AirPlan/docs/architecture/event-registry-v1.md @@ -397,6 +397,35 @@ interface TaskInterruptedPayload { } ``` +#### `task.removed` v1 + +Persistence: durable. + +Domain update: delete `tasks` row (only for pending status); insert event record. + +```ts +interface TaskRemovedPayload { + task_id: string + reason: string + removed_by: string +} +``` + +#### `task.invalidated` v1 + +Persistence: durable. + +Domain update: update `tasks.status = invalidated`; insert event record. + +```ts +interface TaskInvalidatedPayload { + task_id: string + adr_id: string + reason: string + rollback_ref?: string +} +``` + ### 3.5 Tool Events #### `tool.started` v1 diff --git a/AirPlan/docs/round4-AUDIT-FINAL.md b/AirPlan/docs/round4-AUDIT-FINAL.md new file mode 100755 index 0000000..ea54b3b --- /dev/null +++ b/AirPlan/docs/round4-AUDIT-FINAL.md @@ -0,0 +1,232 @@ +# 全代码 code-to-design 审计 — 最终报告 + +> 14 子系统 code-to-design 比对汇总 (round4 Stage 3) +> 源: 14 份 agent 子系统审计报告 (A-N) → 合并去重 + 严重度再评估 + 影响链标注 + +## 0. 元信息 + +- 14 子系统全部完成 (A: Contracts, B: Storage, C: Event, D: Lifecycle, E: Scheduler, F: Worker/IPC, G: Tool/Perm, H: Context, I: Artifact, J: Provider, K: Projection+TUI, L: Agents, M: Toolchain, N: Doctor) +- 合并后共 **97 条发现** (CRITICAL 18 / DEVIATION 32 / GAP 27 / EXCESS 20) +- 报告期: round4 plan §3 格式逐项对齐, INV-1/INV-2/INV-3/INV-5 多子系统重复违反 + +--- + +## 1. CRITICAL (必须修) — 共 18 条 + +| id | 子系统 | 标题 | 违反约束 | 触发场景 | 影响链 | +|---|---|---|---|---|---| +| round4-C-1 | A,B,D,J,K,L | **架构旁路: 多子系统自研 SessionManager/ProjectStore/ProviderManager 而旁路设计契约类** | DD §6.1-§6.2, §12, INV-2 single-writer | 启动任意 session, `RuntimeApp` / `init` / `ProviderManager` 全部跳过契约类, 直接 fs.write/SQLite/JSON.parse | 触发 C-2,C-4,C-5,C-8,C-11 | +| round4-C-2 | C | **EventStore 单例 + 事后注入, SessionManager 创建的 EventStore 实例从未被消费** | DD §5.1 §22.2 构造注入 | 任何 session 打开后 `session.created` 写入空 db 或被抛弃, domain projection 变 no-op | C-3, C-6, C-15, G-C1 | +| round4-C-3 | C | **`EventIngestor.ingest()` 走 fallback 无 tx 路径, 违反 `BEGIN/insert/project/COMMIT` 单事务** | runtime-semantics §3 | 同上事件路径, project() 与 event insert 不在同一事务 | C-2, I-C1 | +| round4-C-4 | C | **`project()` switch 跳过 `context.compaction.*`/`permission.*`/`doctor.*`/`memory.*`/`debug.*` 20+ 事件类型** | DD §5.4 Table A+B | 上述事件全部不更新 domain table, 但 `events` 表行仍写 → 审计日志与 domain 永久不一致 | C-2, C-3, G-C2, N-D1 | +| round4-C-5 | E | **失败/阻塞任务被路由到设计无的 `TERMINATED` 终态** | scheduler-state-machine §10 (仅 COMPLETED/BLOCKED/CANCELLED) | 任何 task 失败 → `run_until_idle` 退出但 API 不暴露 TERMINATED, 调度死锁 | E-2, E-7 | +| round4-C-6 | E | **`PLANNING_WAVE → MONITORING` 跳过 `DISPATCHING`, 旁路 spawn** | scheduler-state-machine §4 | 任何非空 running task, 调度直接进入监控态, worker 永不被 spawn | E-3, E-5, F-C1 | +| round4-C-7 | E | **Scheduler 公开 API `add_dependency` / `load_graph` / `cancel_task` 完全缺失** | DD §7.1, contracts task.ts:194-200 | DAG 编辑与取消无法调用, CLI/TUI 失去图编辑能力 | E-5, E-8 | +| round4-C-8 | E,F | **`agent.start` IPC 控制消息未按设计携带 `context_pack`/`runtime`/`AgentRuntimeContext`, `WorkerRole.run` 单参** | DD §8.2 handshake, contracts §10 | worker 启动后拿不到 permission_template / ContextPack, 权限与上下文全空 | F-C2, F-D3, G-C3 | +| round4-C-9 | F | **`WorkerRuntime` 自加 `call_llm`, 凭据路径走 `process.env.AIRCODING_MODEL` fallback 'glm-5.1', 绕过 TaskSpec.constraints.model_id** | DD §18 INV-3, interface-contracts §10 | worker 直连 LLM 风险 + 模型 ID 决定顺序错乱 (env > spec) | F-C1, J-C1, C-2 | +| round4-C-10 | G | **ToolRegistry.call / PermissionEngine.record 完全不发射 `tool.*` / `permission.decision.recorded` 事件** | DD §9.1 §9.2, INV-1 | 任意 tool 调用与权限决策不入 `tool_runs` / 决策审计, 投影与追责链断 | C-4, G-C2, K-D1 | +| round4-C-11 | G | **`execute_branch` 在 allow/announce_then_run 跳过 grant_scope 持久化与 backup_before_write** | DD §9.3, 安全模型 §11 | 同一 scope 每次 call 重复 prompt, 写操作无备份 | G-C1, G-D1 | +| round4-C-12 | I | **CppToolRegistrar `write_artifacts` 完全旁路 ArtifactStore, 用 `file://` URI, 不算 sha256** | DD §994-1002, naming §8 | `cpp.build/test` 失败时写出的产物既不在 artifacts 表, 也无法回链 evidence | I-C3, I-G2, M-E1 | +| round4-C-13 | I | **EvidenceStore.create 在 ingest event 后又重复插 `evidence_refs`, 打破 single-writer** | DD §18.4, runtime-semantics §6.3 | 每次 evidence 创建产 2 行 DB, 触发 FK-off 检测假阳 | C-3, B-C3 | +| round4-C-14 | J | **ProviderManager 硬编码 `provider_id || 'anthropic'`, 多 provider 路由坍缩为单 provider fallback** | DD §12.1 §12.2 adapter_for(provider_id) | 任一 OpenAI/GLM 路径静默回落 anthropic 或抛 "No adapter selected" | J-D4, J-D5, J-G1, F-C1 | +| round4-C-15 | L | **`CONFIRMING → confirm` 路由到 `DELEGATING` 而非 `EXECUTING` (Opus 报告"已修"未验证)** | DD §20.1 state machine | 用户确认后任务被 re-delegated 而非直接执行, 卡死 | L-2, L-3, E-7 | +| round4-C-16 | L | **ArchitectureDesigner 放在 simple/plan 分支前, 强制所有 task-path 走 Architect 评估** | DD §20.1 §14.1 | 每个 task 请求都做 impact_assess, 性能与设计意图双重偏离 | L-3, L-4 | +| round4-C-17 | N | **`DoctorService --fix` 裸跑 `sudo apt install`, 绕过 PermissionEngine** | DD §16.1, INV-3 | `--fix` 自动修复触发高危命令, 任何用户/任务权限配置被跳过 | N-D1, G-C3 | +| round4-C-18 | B,C,I,N | **`Recovery.scanOrphanReferences` FK-off 8 不变量只覆盖 session_id 一类, 实际只查 5/8, 标修复但无 UPDATE/DELETE** | DD §18.3 §16.3, runtime-semantics §14 | 孤儿 task/agent/tool_run/command_run 不被检测/修复, 累积 → 重启后状态漂移 | C-6, I-C1, I-G2 | + +--- + +## 2. DEVIATION (高优) — 共 32 条 (节选要点, 完整 32 条见附录) + +| id | 子系统 | 偏离 | 影响 | +|---|---|---|---| +| D-A-1..6 | A | ProviderCapabilityMatrix / ProviderCompletionInput / ProviderManager.load_config / PromptLayerLoader / FollowUpTask.type 等 6 处与 contracts §15 §16 字段类型不一致 | type 漂移累积 → runtime/contracts 边界失真 | +| D-B-1..6 | B | `list_active(project_id)` 必传参, `insert` 硬写 status='active', `update` 接受 patch.status 无守卫, `DatabaseHandle` 返回 `{path}` 不含 db, 类型在 MigrationRunner | INV-1 守卫仅靠注释, 模块边界混乱 | +| D-C-1..7 | C | `EventSchemaRegistry.validate` 只查 type, `EventBus.publish` 内存泄漏, `append_many` 共享 tx, `project()` 跳过 20+ 事件, `ingest_batch` 不在 contract, `assistant.message.created` 投影顺序, `agent.started` 丢失 starting 分支 | 事件管道不严, 大量 corner case 未覆盖 | +| D-D-1..4 | D | `ProjectionStore.rebuild` 一致 ✓, 但 `RuntimeApp` 启动序列命名/编号与 §22.2 不一致, migrate 走 ad-hoc dbHandle | 启动行为可观察但语义不清 | +| D-E-1..8 | E | `SchedulerState` 多 TERMINATED, `WavePlanner` 字段名错, `record_heartbeat` 签名错且不写 domain, `WorkspaceManager` 不发事件, `RetryPlanner.decide` 永不被调, `TaskGraph.get_runnable_tasks` 忽略 soft/serialization, MONITORING 收 WorkerResult, `create_tasks` 跳过 LOADING_GRAPH | 调度可用但状态机闭包破坏 | +| D-F-1..4 | F | IpcKind 双重协议 (IpcKind + WorkerMessageType), ExitCode 5 永未触发, NDJSON 编码只校验 4 字段, 父进程全量继承 process.env | 协议冗余, 凭据泄漏风险面 | +| D-G-1..4 | G | `PermissionAction` 6 个但 block 不抛 task.blocked, `PermissionEngine.evaluate` 3 参, `BuiltInToolRegistrar` 18 vs 28 缺 cpp.*6/fs.stat 等, `call_streaming` 不处理 ask_user/block/refuse | 决策 schema 漂移, V1 工具面残缺 1/3 | +| D-H-1..5 | H | L8 用 `message_repo` 替代 `tool_runs`/`command_runs`, `AssembledContext` 缺 `canonical_format:"anthropic"`, L6 Evidence 第一参错 (传 'task_id' 而非 'task'), `messages_artifact_id` 溢出路径未触发, CompactorRole 行为一致 ✓ | ArchitectureDesigner L4 失效, L6/L8 数据源错 | +| D-I-1..4 | I | `artifact_id` 用 UUID 截 24 hex 不用 ULID, DebugKnowledgeStore/LearnedMemoryStore 自定 schema 不同步 contracts, ArtifactStore 不 gzip, `get` 暴力 readdir 不用 DB | 命名不匹配触发 Recovery 假阳, 性能浪费 | +| D-J-1..5 | J | Adapter 缺 `count_tokens`, `complete` 非流式 (等待整响应再 yield), `ProviderConversionReport.status` 缺, `ModelConfigLoader` 不解析嵌套 YAML, `select_model` 忽略 ModelRequirement | capability-based selection 与流式语义失效 | +| D-K-1..2 | K | `ProjectionStore.apply` 在生产路径是死代码 (RuntimeApp 用 snapshot push), `TuiApp.start` 返回 Promise | 路径与设计意图分裂 | +| D-L-1..3 | L | MainAgent 状态多 ERROR+TERMINATED, AWAITING 状态无转换, `handle_user_message` 不返回 y/n 循环 | 状态爆炸但未连通 | +| D-M-1..4 | M | `DiagnosticParser.semantic_signature` 名字/签名错, `ClangdClient` 拆 query_symbol/query_diagnostics, `CppProjectDetector` 实例化用构造参数, `CppTestRunner.parse_ctest_output` 不在设计 | 工具链接口小幅漂移, 可互通 | +| D-N-1..6 | N | Doctor 8 类 (含 project/runtime 多), `run_diagnostics(scope)` 签名不符, 7-day retention 缺失, MigrationRunner 17 表 OK, Recovery 8 步只 3 步, FK 检查 5/8, SecretRedactor 未共享 | 运维数据积累无清理, 恢复路径不完整 | + +--- + +## 3. GAP (设计有、实现无) — 共 27 条 + +**事件/存储契约层 (影响 4 子系统)** +- G-1 缺失 §7 全部契约 `EventBus/EventStore/EventIngestor/EventSchemaRegistry/Subscription/EventAppendOptions` (A) +- G-2 缺失 §6 持久化记录 `PersistedEventRecord/PersistedEventInsert/SessionRecord/MessageRecord/EventRepository` (A) +- G-3 缺失 §16 Context 核心接口 `ContextAssembler/ContextAssembleInput/AssembledContext/CompactionPolicy/CompactionResult` (A) +- G-4 `SessionStore` 聚合类缺失, 15 repos 散落 (B) — **触发 C-18** +- G-5 Outbox 模型未实现 (B) — **触发 C-2, C-13, I-C1** +- G-6 `EventIngestorFactory.createForSession` 无实现 (C) + +**工具/能力层 (影响 3 子系统)** +- G-7 BuiltIn 28 工具仅 18 注册, `cpp.*6`/`fs.stat`/`git.worktree`/`process.kill` 全部缺 (G) — **触发 C-12, M-E1** +- G-8 `artifact.create/read` 工具是 stub 不调 ArtifactStore (I) — **触发 C-12** +- G-9 `Recovery.open()` 无 bootstrap 调用点 (N) — **触发 C-18** +- G-10 `check_capability()` private helper 缺失 (N) +- G-11 `doctor --bundle` 命令模式缺失 (N) +- G-12 `context.compaction.requested` 事件从未由 Scheduler 发出 (H) — **触发 C-4** +- G-13 copy-on-write compaction 缺失 (H) +- G-14 L4 Architecture 层未实现 plan/ADR/C4 文档加载 (H) +- G-15 L2 Safety 层是硬编码 placeholder (H) +- G-16 L7 omission/conflict 路由未实现 (H) +- G-17 `EventSchemaRegistry` 无 version 升级路径 (C) +- G-18 `assistant.message.failed` 失败 artifact 创建缺失 (C) +- G-19 FK-off 一致性 enforcement 缺失 (C) — **触发 C-18** + +**UI/Agent 层** +- G-20 TUI `theme/` 与 `keymap/` 目录缺失 (K) +- G-21 ProjectionStore 未与 SessionStore 仓库连接, `rebuild()` 生产未调 (K) — **触发 C-18** +- G-22 HUD 未挂载主界面 (K) +- G-23 缺失 ephemeral 事件处理 (assistant.message.delta 等) (K) +- G-24 Regex 分类器替代 CLASSIFYING LLM 步骤 (L) +- G-25 `requirement.changed` 发射路径缺失 (L) +- G-26 INTERRUPTING/ARCHITECTURE_REVISING 分支不可达 (L) +- G-27 main-agent-state-machine 7-day retention / Bundle 模式 (N) + +--- + +## 4. EXCESS (实现有、设计无) — 共 20 条 + +- E-1 `ProviderIdentity/ProviderConversionReport/ToolCall/DoctorIssue/CapabilityTrustLevel` 等类型别名抽取, 扩大 contracts 导出表面 (A) — **触发 C-14** +- E-2 Repository 基类重复样板 ~800 行 (B) +- E-3 `EventIngestor.ingest_batch/EventIngestorFactory/NullEventIngestor` (C) — **触发 C-2** +- E-4 `EventStore.setTransactionManager/setRepositories` 事后注入 setter (C) — **触发 C-2** +- E-5 `EventBus.getSubscriptionCount` (C) +- E-6 `EventStore` 手写 UUID 生成器 (C) — **触发 G-17** +- E-7 `EventStore` repository 字段类型 `any` (C) — **触发 C-4** +- E-8 `createRuntime.ts` / `loadConfig.ts` 自实现 project_id 加载 (D) — **触发 C-1** +- E-9 `WorkerRuntime` 多 4 个方法 (call_llm/report_result/heartbeat/send_*) (F) — **触发 C-9** +- E-10 `WorkerProtocol` 多 5 种消息类型 (F) — **触发 C-8** +- E-11 `WorkerHandle.state` 元数据多余 (F) +- E-12 `find_bun()` / `worker.error` 私有通道 (F) +- E-13 `ToolRegistry.execute_branch` 多 `downgrade_to_readonly`/`apply_sandbox_restrictions` dead code (G) +- E-14 `ToolRegistry.global_tool_registry` 单例 (G) +- E-15 `CapabilityManifestValidator` trust_level enum 校验未决策 (G) — **触发 G-7** +- E-16 `PermissionEngine` LAYER_ORDER + decision_log (G) +- E-17 `SkillLoader` trust_root bypass 旁路 (G) +- E-18 `ContextAssembler` L3 `project_files` 非设计层 (H) +- E-19 TUI 端重复定义 `ProjectionClient` 与投影类型 (K) — **触发 K-D1** +- E-20 CppToolRegistrar `write_artifacts` 写文件, 重复 runtime EventSink (M) — **触发 C-12** + +--- + +## 5. 影响链分析 + +**核心根因 (修这个能解多个症状)**: + +1. **根因-A: 设计契约类被实现旁路** (C-1, C-2, D-C1, D-C2, F-C1, J-C1, N-C1) + → SessionManager / ProjectStore / ProviderManager / PermissionEngine 在 7 个子系统中实现完整但被 RuntimeApp / WorkerRuntime / ProviderManager / DoctorService 旁路 + → 修一个 RuntimeApp 启动路径让其走 SessionManager.open_session, 7 个 CRITICAL 同步关闭 + +2. **根因-B: INV-1 事件链整体失效** (C-2, C-3, C-4, G-C1, G-C2, H-G1, I-C1) + → event → projection → domain table 这条链上 5+ 子系统不发射或不投影关键事件 + → 修 `EventStore.project()` switch 覆盖 55 个事件 + 移除单例, 8 个症状同时解 + +3. **根因-C: 状态机闭包破坏** (E-1, E-2, E-6, L-1, L-2) + → Scheduler 5 个 CRITICAL + MainAgent 2 个 CRITICAL 都是状态转换路由错 + → 统一从 state machine 重写主控循环 + +4. **根因-D: outbox/INV-2 缺失** (B-G2, C-2, I-C1, I-C3, I-G2) + → external write → ingest event 同事务模式未实施, 导致 4 个子系统重复插入/孤儿文件 + → 修 G-5 outbox 模型一处, 4 个症状同步解 + +5. **根因-E: 设计类 vs 实现类双轨** (A-G1, A-G2, A-G3) + → contracts 设计了 §6 §7 §16 全部接口但实现未映射到任何 .ts 文件 + → 修 contracts package index 重新映射, 3 个 GAP 关闭 + +**症状层 (依赖根因)**: +- K-D1, H-D1, H-D2 全部因 C-2/C-3 投影失效 +- M-D1-D4 因 G-7 工具面残缺间接暴露 +- L-D2, L-D3 因 E-5/E-6 调度状态错 + +**CRITICAL 互相触发关系图 (简化)**: +``` +C-1 (架构旁路) ─┬─→ C-2 (EventStore 单例) ─→ C-3 (no-tx) ─→ C-4 (project switch 缺) ─→ C-13 + ├─→ C-5/C-6 (Scheduler 状态) ─→ C-7 (API 缺) + ├─→ C-9 (call_llm) ─→ C-14 (Provider 硬编码) + ├─→ C-10 (Tool 不发事件) ─→ C-11 (execute_branch) + ├─→ C-12 (Artifact 旁路) ─→ C-13 + ├─→ C-15/L-1 (CONFIRMING 错) ─→ C-5 + └─→ C-17 (Doctor 裸 sudo) +C-18 (Recovery FK 5/8) ←─ C-3, C-4, I-C1 +``` + +--- + +## 6. 建议修复顺序 + +**P0 - 必须先修 (5 条根因)** +1. C-1 + D-C1 + D-C2: RuntimeApp 启动路径改走 SessionManager / ProjectStore — 解 7 个子系统症状 (估 1-2 天) +2. C-2 + C-3: 撤销 EventStore/eventIngestor 模块单例, 改回 SessionManager 构造注入, 恢复 txManager/repos 在构造时绑定 — 解 C-13/C-15/I-C1 等 (估 1 天) +3. C-4: `EventStore.project()` switch 覆盖 55 个事件类型 + Table B cross-DB 占位 — 解 C-13/C-10/G-C2 (估 1-2 天) +4. C-5 + C-6 + C-7 + E-D1: 重写 Scheduler 状态机主循环 (terminal_state 去掉 TERMINATED, PLANNING_WAVE→DISPATCHING→MONITORING, 补齐 add_dependency/load_graph/cancel_task) — 解 E-2/L-1 (估 2-3 天) +5. C-18 + B-G1 + B-G3: Recovery 8 步全部实现 + FK-off 8 invariant 全覆盖 + 实际 archive/reparent 动作 — (估 1 天) + +**P1 - 高优 (症状层, 根因修后自动解或独立修)** +6. C-9 + C-14 + J-C1: WorkerRuntime 移除 call_llm, ProviderManager 走 adapter_for 路由 — (估 1 天) +7. C-10 + C-11 + G-D1: Tool/Permission 事件链修复 + PermissionDecision schema 对齐 contracts — (估 1-2 天) +8. C-12 + I-G1 + I-G2: CppToolRegistrar 走 ArtifactStore + tool 包装 — (估 1 天) +9. L-1 + L-2 + L-3: MainAgent CONFIRMING→EXECUTING + ArchitectureDesigner 仅 plan 分支 + architecture.plan.updated 发射 — (估 1 天) +10. N-C1 + N-C2: Doctor --fix 走 PermissionEngine, 双份日志连通 DeveloperLogEncryptor — (估 0.5 天) +11. K-D1 + K-G2: ProjectionStore.apply 走真实事件流 + 注入 SessionStore 仓库 — (估 0.5 天) + +**P2 - 可后修 (设计 polish)** +12. A-G1/G2/G3: contracts §6/§7/§16 接口映射 .ts 文件 (估 0.5 天) +13. D-D4 + D-G1: RuntimeApp 启动序列命名/编号与 §22.2 对齐 (估 0.5 天) +14. F-D1/D2/D4: IpcKind 单一协议 + ExitCode 5 触发路径 + 父进程 env 白名单 (估 1 天) +15. J-D1-D5 + J-G1-G4: Provider 5 DEVIATION + 4 GAP (估 2 天) +16. H-D1/D2/D3/D4 + H-G2/G3/G4: Context 6 处偏离 (估 1 天) +17. M-D1-D4: Toolchain 4 处 API 漂移 (估 0.5 天) +18. N-D2 + N-G1-G3: Doctor 7-day retention + Recovery bootstrap + bundle (估 1 天) +19. K-G1/G3/G4 + K-E1/E2/E3: TUI 主题/键位/HUD/ephemeral/类型镜像 (估 1-2 天) +20. EXCESS 全部 (20 条): 死代码清理 (估 0.5-1 天) + +**总工作量估算**: +- P0: 6-9 天 (1 个工程师) +- P1: 5-7 天 +- P2: 8-12 天 +- 合计: 19-28 工作日 (4-6 周) + +--- + +## 7. 附录: 子系统审计发现数 + +| 子系统 | CRITICAL | DEVIATION | GAP | EXCESS | 合计 | +|---|---|---|---|---|---| +| A Contracts | 4 | 6 | 3 | 4 | **17** | +| B Storage | 3 | 6 | 5 | 4 | **18** | +| C Event | 3 | 7 | 6 | 5 | **21** | +| D Lifecycle | 3 | 4 | 2 | 1 | **10** | +| E Scheduler | 5 | 8 | 4 | 0 | **17** | +| F Worker/IPC | 2 | 4 | 3 | 4 | **13** | +| G Tool/Perm | 3 | 4 | 5 | 5 | **17** | +| H Context | 1 | 5 | 4 | 2 | **12** | +| I Artifact | 3 | 4 | 3 | 1 | **11** | +| J Provider | 1 | 5 | 4 | 3 | **13** | +| K Projection+TUI | 1 | 2 | 5 | 3 | **11** | +| L Agents | 4 | 3 | 3 | 2 | **12** | +| M Toolchain | 0 | 4 | 0 | 2 | **6** | +| N Doctor | 2 | 6 | 3 | 2 | **13** | +| **合计 (去重前)** | **35** | **68** | **50** | **38** | **191** | +| **合并去重后** | **18** | **32** | **27** | **20** | **97** | + +**去重说明**: +- 多 agent 报告同一条 (如 C-2/C-3/C-4/C-10 都是 INV-1 事件链断裂的不同切面) 合并为 1 条 +- 工具缺注册 (G-D3) 与 contracts 缺接口 (A-G1) 因根因不同保留 +- 影响范围扩到 3+ 子系统的偏离被升级评估, 18 条 CRITICAL 包含 5 条根因型 (影响 7+ 子系统) + +--- + +**审计范围**: 14 个子系统 + 0 个遗漏 +**报告字数**: ~1500 字 (含表格) +**未修代码**: 严格遵守 +**Stage 3 汇总完成** diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/plugins/marketplace.json b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/plugins/marketplace.json new file mode 100755 index 0000000..3c0afa0 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/plugins/marketplace.json @@ -0,0 +1,92 @@ +{ + "name": "local-airarc", + "interface": { + "displayName": "Local AirArc Plugins" + }, + "plugins": [ + { + "name": "airarc", + "source": { + "source": "local", + "path": "./plugins/airarc" + }, + "policy": { + "installation": "INSTALLED_BY_DEFAULT", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "aireng", + "source": { + "source": "local", + "path": "./plugins/aireng" + }, + "policy": { + "installation": "INSTALLED_BY_DEFAULT", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "airdo", + "source": { + "source": "local", + "path": "./plugins/airdo" + }, + "policy": { + "installation": "INSTALLED_BY_DEFAULT", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "airdbg", + "source": { + "source": "local", + "path": "./plugins/airdbg" + }, + "policy": { + "installation": "INSTALLED_BY_DEFAULT", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "airndb", + "source": { + "source": "local", + "path": "./plugins/airndb" + }, + "policy": { + "installation": "INSTALLED_BY_DEFAULT", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "airxdb", + "source": { + "source": "local", + "path": "./plugins/airxdb" + }, + "policy": { + "installation": "INSTALLED_BY_DEFAULT", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "airsdb", + "source": { + "source": "local", + "path": "./plugins/airsdb" + }, + "policy": { + "installation": "INSTALLED_BY_DEFAULT", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airarc/SKILL.md b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airarc/SKILL.md new file mode 100755 index 0000000..774915c --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airarc/SKILL.md @@ -0,0 +1,37 @@ +--- +name: airarc +description: Architecture-first workflow with built-in post-plan parallelization review. Use when planning should emit dependency edges, parallel groups, write-set conflicts, and serialization points for Air Engine. AirArc plans and edits planning docs only; it does not write code. +--- + +# AirArc + +## Upgrade Notes + +- Keep the original architecture-first planning role. +- AirArc is an architect-only workflow: it may plan tasks and edit architecture or planning documents, but it must not implement code changes. +- Add built-in post-plan review instead of a separate review-only plugin. +- Emit engine-consumable execution artifacts after planning. + +## Outputs + +- `AirPlan/state/airarc/state.json` +- `AirPlan/state/airarc/reviews/parallel-review.json` +- `AirPlan/state/airarc/reviews/parallel-review.md` +- `AirPlan/state/airarc/reviews/execution-plan.json` +- `AirPlan/state/airarc/reviews/execution-plan.md` + +## Review Responsibilities + +- Compute dependency edges. +- Compute parallel-safe groups. +- Detect shared write-set conflicts. +- Mark serialization points for global docs and merge boundaries. +- Produce an execution plan that Air Engine can prefer directly. + +## Commands + +```bash +python "$HOME/plugins/airarc/scripts/airarc_mode.py" --mode enter --project +python "$HOME/plugins/airarc/scripts/airarc_mode.py" --mode status --project +python "$HOME/plugins/airarc/scripts/airarc_mode.py" --mode parallel-review --project --todo +``` diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airdbg/SKILL.md b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airdbg/SKILL.md new file mode 100755 index 0000000..3539878 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airdbg/SKILL.md @@ -0,0 +1,245 @@ +--- +name: airdbg +description: Debug-first repair workflow. Use when the user invokes /airdbg or explicitly asks for AirDbg mode to debug, reproduce, diagnose, or fix software errors, including GUI, visual, screenshot, browser UI, desktop UI, remote GUI/device debugging, canvas, layout, focus, popup, graphical operation, network, packet capture, remote packet capture, pcap, DNS, TCP, UDP, TLS, HTTP connectivity, proxy, firewall, port, retransmit, reset, latency, cppcheck, static analysis, code quality, or security-relevant C/C++ defects. Load or initialize AirPlan/AGENTS.md, AirPlan/docs/architecture/adr/ decision records, and AirPlan/docs/architecture/c4/module.md; discuss symptoms and constraints with the user; reproduce the issue; require AirXDB local or remote device helpers or equivalent GUI/screen evidence for every local or remote GUI validation instead of treating process liveness as success; call AirNDB local or remote device helpers when tcpdump/WinDump packet capture, pcap analysis, BPF filters, or network-layer evidence is needed; call AirSDB local or remote device helpers when static-analysis capability, cppcheck evidence, or AirPlan/docs/staticanalysis.md documentation is needed; identify root cause; apply a focused fix; verify with tests or equivalent checks; and update AirPlan/AGENTS.md, ADR, and C4 module docs when project behavior, module boundaries, dependencies, GUI automation boundaries, network boundaries, static-analysis boundaries, remote-device boundaries, or architecture decisions change. +--- + +# AirDbg + +## 核心约束 + +- 全程使用中文与用户交流,代码、命令、日志、路径、异常名保持原文。 +- `/airdbg` 是主要触发入口。用户进入 AirDbg 后,围绕调试和修复错误推进。 +- 先加载项目上下文,再修复:`AirPlan/AGENTS.md`、`AirPlan/docs/architecture/adr/`、`AirPlan/docs/architecture/c4/module.md`。 +- 如果这些文件不存在,先分析当前项目并初始化它们;C4 module 要记录真实模块边界,不只放空模板。 +- 与用户交流症状、复现步骤、期望行为、实际行为、影响范围和修复约束。 +- 默认做最小可验证修复,避免顺手重构。 +- 每个修复都要验证。优先自动化测试,其次是可重复命令或明确的手工验证步骤。 +- 只要验证或复现涉及本地或远程 GUI,就不能只以进程存在、窗口拉起、命令退出成功、端口监听或日志无异常判定通过;必须辅以图像/GUI 检验和测试。 +- 调试中遇到图形对比、截图取证、GUI 操作、浏览器/桌面界面、Canvas、弹窗、焦点、布局、视觉回归或其他图形功能时,必须调用 `airxdb` 获取截图、探索界面、执行操作验证或收集视觉证据;如果是嵌入式屏幕、显示链路等截图无诊断价值的场景,可不强制截图,但必须补充等效的 GUI/屏幕状态证据和操作验证,并记录原因。 +- 如果 GUI 问题发生在远程设备、测试机、VM、服务器或 SSH 主机上,调用 AirXDB remote device helper,而不是默认使用本机 Computer MCP。 +- 调试中遇到抓包分析、pcap、tcpdump/WinDump、BPF、DNS、TCP、UDP、TLS、HTTP 连接、端口、代理、防火墙、丢包、重传、RST 或延迟问题时,可以调用 `airndb` 获取网络层调试证据。 +- 如果网络问题发生在远程设备、测试机、VM、容器宿主机、服务器或 SSH 主机上,调用 AirNDB remote device helper,而不是默认使用本机抓包工具。 +- 调试中遇到需要静态分析能力的检验、测试或定位场景,以及 C/C++ 静态分析、cppcheck、代码质量、安全性初筛、未初始化变量、空指针、越界、资源释放、危险转换或 CWE 线索需求时,可以调用 `airsdb` 获取 `AirPlan/docs/staticanalysis.md`、XML/JSON 报告等静态分析证据和辅助调试文档。 +- 如果静态分析目标在远程设备、测试机、VM、容器宿主机、服务器或 SSH 主机上,调用 AirSDB remote device helper,而不是默认使用本机 cppcheck。 +- 一定要根据项目变化维护 `AirPlan/AGENTS.md`、ADR 和 C4 module。 +- ADR 是给 AI 作为上下文的决策记录,短、准、可检索即可,不写冗长修饰。 + +## 启动与初始化 + +进入 `/airdbg` 时运行: + +```bash +python "$HOME/plugins/airdbg/scripts/airdbg_mode.py" --mode enter --project . +``` + +如果当前环境没有 `python`,尝试 `py` 或 `python3`。脚本不可用时,手动确保以下结构存在: + +- `AirPlan/AGENTS.md` +- `AirPlan/docs/architecture/adr/` +- `AirPlan/docs/architecture/c4/module.md` +- `AirPlan/docs/debug/debug-log.md` +- `AirPlan/state/airdbg/state.json` + +初始化后读取已有内容作为上下文。不要覆盖用户已有正文;只补齐缺失结构或更新 AirDbg 标记块。 + +## 图形调试与 AirXDB 协作 + +AirDbg 负责根因分析、代码层修复和验证收尾;AirXDB 负责图形界面的取证和操作层复现。遇到以下情况时,必须调用 AirXDB 或补充等效 GUI/屏幕证据: + +- 需要截图或图形对比来理解错误现场、视觉回归、布局错位、颜色/尺寸/遮挡差异。 +- 需要操作浏览器 UI、桌面 UI、Electron/Qt/WPF 等应用、Canvas、菜单、弹窗、托盘、任务栏或多显示器界面。 +- 需要 `/airxdb screenshot` 保存错误现场,再把截图交给 AirDbg 做代码层诊断。 +- 目标 GUI 在远程设备、测试机、VM、服务器或 SSH 主机上,需要 `/airxdb remote-screenshot` 或 `airxdb_remote_device.py` 保存远程错误现场。 +- 需要用 AirXDB 执行最小 GUI 操作,确认按钮、表单、导航、窗口切换、焦点或图形流程是否真的失败。 +- 需要把 GUI 证据沉淀到 `AirPlan/docs/debug/gui-debug-log.md`、`AirPlan/docs/debug/airxdb-artifacts/` 或 AirDbg 的 `AirPlan/docs/debug/debug-log.md`。 + +协作规则: + +- 先用 AirXDB 收集最小必要证据,再回到 AirDbg 分析代码根因;不要把视觉症状直接当作根因。 +- 任何本地或远程 GUI 测试/验证都不能仅以进程存活、窗口创建成功、命令返回成功或日志无异常视为通过;默认至少保留 1 份截图/图像证据,并完成 1 次关键 GUI 操作或状态检查。 +- 如果是嵌入式屏幕、显示控制器、外接面板链路等截图无诊断价值的场景,可改用外部采集视频、framebuffer dump、串口/日志配合按键或触控操作记录、状态灯/OSD 观察记录等等效证据,但必须在 `debug-log.md` 记录为什么不截图以及替代证据是什么。 +- 截图模式可在没有 Midscene 语义模型配置时使用;语义视觉动作按 AirXDB 规则先检查模型配置。 +- 本机 GUI 证据使用 `/airxdb screenshot` 或 `airxdb_computer_mcp_smoke.py`;远程 GUI 证据使用 `airxdb_remote_device.py --action setup|screenshot`,由它探测 SSH、远端截图工具并在缺失时自动尝试配置。 +- 远程 helper 缺少 `AIRXDB_REMOTE_SSH_TARGET` 时,先让用户提供 SSH 目标;需要交互式 sudo、管理员确认或无支持包管理器时停止并说明。 +- AirDbg 的 `debug-log.md` 必须记录 AirXDB 命令、截图/报告路径、关键观察、与根因的关系、复验结果和剩余风险。 +- 如果 GUI 自动化、截图取证、视觉验收、浏览器桥接或桌面控制成为长期调试/测试边界,更新 C4 module 并创建或修订 ADR。 +- 如果发现稳定可复用的 GUI 调试命令、截图方式、远程设备配置或视觉验收步骤,更新 `AGENTS.md`。 +- 截图可能包含账号、密钥、客户数据或聊天内容时,先提醒用户脱敏,再外部分享或长期保留。 + +## 抓包调试与 AirNDB 协作 + +AirDbg 负责把网络证据和代码行为联系起来,定位根因并修复;AirNDB 负责 tcpdump/WinDump 抓包、pcap 摘要、BPF 过滤器和网络层证据。遇到以下情况时,调用 AirNDB: + +- 需要抓包判断请求是否发出、响应是否回来、连接是否被 RST/ICMP/防火墙/代理中断。 +- 需要分析 DNS 查询、TCP 三次握手、TLS 握手、HTTP 连接、UDP 流量、端口可达性、重传、丢包或延迟。 +- 需要读取已有 `.pcap` 或生成新的短时有界 pcap 给调试使用。 +- 需要确定问题在应用代码、系统网络栈、容器/WSL/VM/宿主机边界、代理、防火墙还是远端服务。 +- 目标流量发生在远程设备、测试机、VM、容器宿主机、服务器或 SSH 主机上,需要 `/airndb remote-interfaces`、`/airndb remote-capture` 或 `airndb_remote_device.py` 获取远程网络证据。 + +协作规则: + +- 先让 AirNDB 明确授权范围、接口、BPF 过滤器、抓包窗口和 pcap 输出路径;不要进行无界抓包。 +- 本机网络证据使用 `airndb_capture.py`;远程网络证据使用 `airndb_remote_device.py --action setup|interfaces|command|capture`,由它探测 SSH、远端 `tcpdump` / `dumpcap` 并在缺失时自动尝试配置。 +- 远程 helper 缺少 `AIRNDB_REMOTE_SSH_TARGET` 时,先让用户提供 SSH 目标;需要交互式 sudo、管理员确认或无支持包管理器时停止并说明。 +- AirDbg 的 `debug-log.md` 必须记录 AirNDB 命令、pcap/summary/report 路径、关键包或时间线观察、与根因的关系、复验结果和剩余风险。 +- 如果抓包发现新的长期网络边界、端口、协议、DNS、代理、TLS、容器/WSL/VM/宿主机约束或观测方式,更新 C4 module 并创建或修订 ADR。 +- 如果发现稳定可复用的抓包命令、接口选择规则、BPF、远程设备配置或 pcap 读取方式,更新 `AGENTS.md`。 +- pcap 可能包含 token、cookie、payload、内网地址、主机名或个人信息;对外分享前必须提醒用户脱敏。 + +## 静态分析与 AirSDB 协作 + +AirDbg 负责把静态分析线索和代码根因联系起来;AirSDB 负责 cppcheck 检测/安装、本机或远程扫描、XML/JSON 产物和 `AirPlan/docs/staticanalysis.md` 简短报告。遇到以下情况时,可以调用 AirSDB: + +- 需要用 cppcheck 辅助定位 C/C++ bug、内存/资源/越界/空指针/未初始化变量/危险转换/CWE 线索。 +- 需要在修复前后比较静态分析结果。 +- 需要给 AirDbg 的根因分析提供短报告而不是长 XML。 +- 检验、测试或调试判断需要静态分析能力、质量门信息或可引用文档时,需要读取 `AirPlan/docs/staticanalysis.md` 或 AirSDB XML/JSON 报告辅助分析。 +- 目标代码在远程设备、测试机、VM、容器宿主机、服务器或 SSH 主机上,需要 `/airsdb remote-scan` 或 `airsdb_remote_device.py` 获取远端静态分析证据。 + +协作规则: + +- 本机静态分析使用 `airsdb_cppcheck.py --action scan`;远程静态分析使用 `airsdb_remote_device.py --action setup|scan`,由它探测 SSH、远端 cppcheck 并在缺失时自动尝试配置。 +- AirDbg 的 `AirPlan/docs/debug/debug-log.md` 必须记录 AirSDB 命令、`AirPlan/docs/staticanalysis.md`、XML/JSON 报告路径、关键 findings、与根因的关系、复验结果和剩余风险。 +- 如果静态分析发现新的长期质量门槛、suppressions、远程设备配置或 cppcheck 命令,更新 `AGENTS.md`。 +- 如果静态分析成为长期测试/调试边界,更新 C4 module 并创建或修订 ADR。 + +## 调试流程 + +1. 确认问题边界: + - 用户看到的错误是什么。 + - 期望行为和实际行为是什么。 + - 复现步骤、输入数据、环境、版本、最近变更是什么。 + - 有哪些不能破坏的兼容性或性能要求。 +2. 加载上下文: + - 读取 `AGENTS.md`。 + - 读取 ADR 列表和相关 ADR。 + - 读取 `docs/architecture/c4/module.md`。 + - 查看测试、入口、依赖、配置和最近相关文件。 +3. 复现问题: + - 优先运行已有失败测试或用户给出的命令。 + - 没有复现命令时,先构造最小复现或定位性测试。 + - 如果复现依赖 GUI、截图或图形操作,必须调用 AirXDB 获取截图、执行最小界面操作或保存 GUI 报告;远程目标走 AirXDB remote device helper;嵌入式截图无效时改用等效 GUI/屏幕证据并记录原因。 + - 如果复现依赖网络路径或抓包证据,调用 AirNDB 获取短时 pcap、摘要或网络层时间线;远程目标走 AirNDB remote device helper。 +- 如果复现或定位需要 C/C++ 静态分析,或当前检验需要静态分析能力辅助判断,调用 AirSDB 运行本机或远程 cppcheck,并读取 `AirPlan/docs/staticanalysis.md`。 + - 记录复现命令和关键输出到 `docs/debug/debug-log.md`。 +4. 定位根因: + - 从错误栈、日志、测试断言、数据流和模块边界推断。 + - 对 GUI 问题,结合 AirXDB 本机或远程截图/报告判断视觉症状、交互失败和代码根因之间的关系。 + - 对网络问题,结合 AirNDB 本机或远程 pcap/摘要判断请求是否出站、响应是否入站、失败发生在 DNS/TCP/TLS/应用层哪一段。 + - 对静态分析问题,结合 AirSDB findings 判断哪些是当前 bug 线索、哪些是既有质量债或误报。 + - 必要时加临时日志或小范围探针,完成后清理。 + - 区分根因、诱因和表面症状。 +5. 修复: + - 优先选择影响面小、能解释根因的修复。 + - 不做无关格式化、批量重构或架构迁移。 + - 如果修复会改变模块边界、依赖、接口、数据所有权或关键行为,先更新 C4/ADR。 +6. 验证: + - 运行失败用例、相关单元测试、集成测试、lint/typecheck。 + - 如果修复涉及 GUI 或视觉行为,必须调用 AirXDB 截图、图形对比或操作验证关键路径;远程目标用远程 helper 复验;嵌入式截图无效时改用等效 GUI/屏幕证据并记录原因。 + - 如果修复涉及网络行为,调用 AirNDB 复验关键网络路径或读取 pcap 摘要;远程目标用远程 helper 复验。 +- 如果修复涉及 C/C++ 风险、静态分析 findings,或验证需要静态分析能力辅助判断,调用 AirSDB 复跑 cppcheck 并更新 `AirPlan/docs/staticanalysis.md`。 + - 如果不能运行,说明原因,并给出可复验的替代验证。 + - 记录验证证据到 debug log。 +7. 收尾: + - 更新 `AGENTS.md` 中与调试、测试、运行方式相关的项目上下文。 + - 更新或新增 ADR。 + - 更新 C4 module。 + - 向用户汇报根因、改动、验证结果、剩余风险。 + +## AGENTS.md 维护 + +在以下情况更新 `AGENTS.md`: + +- 发现新的运行、测试、构建、调试命令。 +- 发现新的 AirXDB 截图、GUI 操作验证、图形对比、远程设备配置或视觉验收命令。 +- 发现新的 AirNDB 抓包命令、BPF 过滤器、接口选择规则、远程设备配置、pcap 读取方式或网络复验步骤。 +- 发现新的 AirSDB cppcheck 命令、suppressions、质量门槛、远程设备配置或静态分析复验步骤。 +- 发现影响后续 AI 会话的重要项目约束。 +- 修复改变了模块职责、关键流程或错误处理策略。 +- 发现常见坑、环境要求或验证方式。 + +保持内容可执行、可复用,不写调试过程流水账。 + +## ADR 维护 + +目录:`docs/architecture/adr/`。 + +需要 ADR 的情况: + +- 修复选择了一个会影响长期架构或行为兼容性的方案。 +- 改变错误处理、重试、事务、缓存、一致性、安全边界。 +- 改变模块依赖、数据所有权、接口契约。 +- 将 GUI 自动化、截图取证、远程设备 GUI 取证、视觉验收或图形调试流程纳入长期测试/调试边界。 +- 将抓包、远程设备抓包、pcap 分析、网络观测、端口、协议、DNS、代理、TLS 或网络拓扑纳入长期调试/测试边界。 +- 将 cppcheck、staticanalysis.md、静态分析质量门槛或远程静态分析纳入长期调试/测试边界。 +- 拒绝了明显可选方案,需要给后续 AI 留下原因。 + +ADR 模板: + +```markdown +# ADR-000X: short-title + +- Status: Accepted +- Date: YYYY-MM-DD + +## Context +简述错误、约束和为什么需要决策。 + +## Decision +简述采用的修复或架构选择。 + +## Consequences +- 正面影响 +- 代价或风险 + +## Alternatives +- 方案 A:放弃原因 +``` + +## C4 Module 维护 + +文件:`docs/architecture/c4/module.md`。 + +必须记录: + +- 模块名。 +- 职责。 +- 对外接口。 +- 依赖。 +- 数据所有权。 +- 与本次错误或修复相关的质量属性。 + +新增模块、拆分模块、改变依赖、改变接口、改变数据边界、改变错误处理流时必须更新。 + +引入或改变 GUI 自动化、浏览器桥接、桌面控制、截图取证、远程设备 GUI 取证、视觉验收或图形调试基础设施时,也必须更新。 + +引入或改变 tcpdump/WinDump 抓包、远程设备抓包、pcap 分析、网络观测、端口、协议、DNS、代理、TLS、容器/WSL/VM/宿主机网络边界时,也必须更新。 + +引入或改变 cppcheck、staticanalysis.md、静态分析质量门槛、suppressions 或远程静态分析边界时,也必须更新。 + +## debug-log 维护 + +文件:`docs/debug/debug-log.md`。 + +每次 AirDbg 修复至少追加: + +- 问题摘要。 +- 复现命令或复现步骤。 +- 根因。 +- 修复摘要。 +- 验证命令和结果。 +- AirXDB 本机或远程截图/报告/操作验证证据及其结论(如适用)。 +- AirNDB 本机或远程 pcap/summary/report/抓包分析证据及其结论(如适用)。 +- AirSDB 本机或远程 staticanalysis.md/XML/JSON 静态分析证据及其结论(如适用)。 +- 相关 ADR/C4 更新。 +- 剩余风险。 + +## 输出格式 + +调试完成后用中文简洁汇报: + +- 根因。 +- 修复了什么。 +- 更新了哪些 `AGENTS.md` / ADR / C4 / debug log 上下文。 +- 运行了哪些验证;是否调用 AirXDB/AirNDB/AirSDB,截图、pcap、staticanalysis、报告或操作证据在哪里。 +- 仍然存在的风险或未验证项。 diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airdbg/agents/openai.yaml b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airdbg/agents/openai.yaml new file mode 100755 index 0000000..95da337 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airdbg/agents/openai.yaml @@ -0,0 +1,3 @@ +name: airdbg +short_description: Debug-first repair with local/remote AirXDB, AirNDB, and AirSDB evidence +default_prompt: "使用 AirDbg 调试并修复当前项目错误;GUI 测试或验证必须保留 GUI 复验证据,嵌入式截图无效时改用等效屏幕证据;网络证据调用 AirNDB,需要静态分析能力时调用 AirSDB。" diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airdo/SKILL.md b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airdo/SKILL.md new file mode 100755 index 0000000..01b0aaf --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airdo/SKILL.md @@ -0,0 +1,38 @@ +--- +name: airdo +description: Public Air executor for one scoped todo slice. Use it standalone or as an isolated AirEng subagent and finalize the result into AirPlan. +--- + +# AirDo + +## Role + +- Execute one task or one very small implementation slice. +- Keep AirDo execution guarantees for context loading, evidence, and validation. +- Finalize one structured result package in `AirPlan/state/airdo/results/`. +- Act as the standard AirEng child executor when work is delegated into isolated subagents. + +## Inputs + +- `AirPlan/AGENTS.md` +- `AirPlan/docs/architecture/adr/` +- `AirPlan/docs/architecture/c4/module.md` +- `AirPlan/plan.md` +- `AirPlan/todo.md` +- `AirPlan/state/airdo/tasks//brief.md` +- `AirPlan/state/airdo/tasks//subagent-handoff.md` when launched by AirEng + +## Result Rules + +- Finalize one `result.json` per task. +- Treat `AirPlan/state/airdo/tasks//worker-state.json` `resultPath` as the canonical pointer to the latest result artifact. The task-local `result.json` is only the editable template before finalize. +- Include validations, evidence, risks, blockers, and document updates when needed. +- Do not edit global `AirPlan/todo.md`, `AirPlan/AGENTS.md`, ADR, or C4 files directly unless explicitly delegated through `documentUpdates`. +- If the task changes execution planning or architecture reality, include concrete `documentUpdates` so AirEng can keep `todo`, `plan`, ADR, and C4 synchronized during merge. + +## Automatic Routing + +- On `blocked` results, auto-request AirDbg before finalize. +- On GUI or visual work, auto-request AirXDB before finalize. +- If AirEng queued an active repair attempt, continue repairing automatically instead of stopping at the first blocker. +- Do not stop at implementation-prep or progress-only updates when the task is actionable. Continue until finalize unless a real blocker or explicit user decision is required. diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airdo/agents/openai.yaml b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airdo/agents/openai.yaml new file mode 100755 index 0000000..5660ad5 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airdo/agents/openai.yaml @@ -0,0 +1,3 @@ +name: airdo +short_description: Public Air executor for one scoped task, validation evidence, and AirPlan result finalization +default_prompt: "Use AirDo to execute one task, gather validation evidence, auto-route GUI or debug work, and finalize a structured result in AirPlan for AirEng to merge." diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/aireng/SKILL.md b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/aireng/SKILL.md new file mode 100755 index 0000000..8a6b738 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/aireng/SKILL.md @@ -0,0 +1,65 @@ +--- +name: aireng +description: Sole public Air scheduler that reads AirArc execution artifacts, dispatches isolated AirDo subagents with bounded concurrency, monitors them on a 5-minute cadence, and merges structured results into AirPlan. +--- + +# AirEng + +## Role + +- Own the global execution contract in `AirPlan/`. +- Read AirArc review output before falling back to local todo analysis. +- Dispatch isolated AirDo subagents with `fork_context=false`. +- Monitor active workers, merge structured worker results, and keep the scheduler moving unattended. +- Own debug policy, XDB policy, repair policy, intervention policy, and global document convergence. +- Default to no parent-thread coding; use parent-thread edits only for short unblock actions that restore the scheduler. + +## Planning Source Order + +1. `AirPlan/state/airarc/reviews/execution-plan.json` +2. `AirPlan/state/airarc/reviews/parallel-review.json` +3. Engine fallback analysis of `AirPlan/todo.md` + +## Dispatch Contract + +- Generate the dispatch manifest under `AirPlan/state/aireng/dispatch/`. +- Respect `recommendedConcurrency`; do not flood the workspace with overlapping workers. +- Spawn one isolated AirDo subagent per task handoff. +- After a worker finishes, read its `workerStatePath` and use the `resultPath` recorded there as the canonical finalized result location. +- Pass only the task handoff and project path to each worker. Do not fork the full parent thread history. +- Do not interrupt actionable workers for midpoint status updates; let them continue through implementation and finalize unless they surface a real blocker. +- Keep parent-thread work limited to orchestration, monitoring, merge, repair, document convergence, and minimal unblock actions. +- Refresh `AirPlan/todo.md` and the active dispatch block in `AirPlan/plan.md` when a wave starts so execution progress is visible during the run. + +## Monitoring Contract + +- Store scheduler state in `AirPlan/state/aireng/state.json`. +- Track `engineMode`, `activeWaveId`, `activeDispatchPath`, `activeWorkers`, `monitoringPolicy`, `nextAction`, and `interventionHistory`. +- Use `monitoringPolicy.checkIntervalSeconds = 300` as the default cadence for unattended monitoring. +- Prefer re-dispatch, repair, debug, or other isolated recovery flows before direct intervention. +- Escalate to user decision only when a worker remains hard-blocked after the allowed intervention budget. + +## Merge Guarantees + +- Update task status and merge log in `AirPlan/todo.md`. +- Apply worker `documentUpdates`. +- Refresh engine-managed sync blocks in `AirPlan/AGENTS.md` and `AirPlan/docs/architecture/c4/module.md`. +- Track AirXDB sessions in `AirPlan/state/aireng/state.json`. +- Track debug sessions in `AirPlan/state/aireng/state.json`. +- Track repair attempts in `AirPlan/state/aireng/state.json`. +- Refuse a `done` merge when required global document updates are missing. +- Refuse a GUI-like `done` merge when successful AirXDB evidence is missing. +- Apply worker `documentUpdates` promptly so plan, ADR, and C4 changes do not lag behind completed slices. + +## Commands + +```bash +python "$HOME/plugins/aireng/scripts/aireng_mode.py" --mode enter --project +python "$HOME/plugins/aireng/scripts/aireng_mode.py" --mode status --project +python "$HOME/plugins/aireng/scripts/aireng_mode.py" --mode plan --project --todo +python "$HOME/plugins/aireng/scripts/aireng_mode.py" --mode dispatch --project [--dispatch-group ] +python "$HOME/plugins/aireng/scripts/aireng_mode.py" --mode monitor --project +python "$HOME/plugins/aireng/scripts/aireng_mode.py" --mode run --project [--todo ] +python "$HOME/plugins/aireng/scripts/aireng_mode.py" --mode intervene --project +python "$HOME/plugins/aireng/scripts/aireng_mode.py" --mode merge --project --result +``` diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/aireng/agents/openai.yaml b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/aireng/agents/openai.yaml new file mode 100755 index 0000000..03075fc --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/aireng/agents/openai.yaml @@ -0,0 +1,3 @@ +name: aireng +short_description: Public Air scheduler for isolated AirDo subagent dispatch and AirPlan merges +default_prompt: "Use AirEng to read AirArc execution artifacts from AirPlan, dispatch isolated AirDo subagents with bounded concurrency, and merge structured worker results back into AirPlan." diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airndb/SKILL.md b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airndb/SKILL.md new file mode 100755 index 0000000..a00f092 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airndb/SKILL.md @@ -0,0 +1,214 @@ +--- +name: airndb +description: Network-debug packet capture workflow. Use when the user invokes /airndb or asks to debug networking, packet loss, DNS, TCP, UDP, TLS handshakes, HTTP connectivity, ports, retransmits, resets, latency, firewall, proxy, service reachability, pcap files, tcpdump, WinDump, remote packet capture over SSH, or BPF filters. Load or initialize AirPlan/AGENTS.md, AirPlan/docs/architecture/adr/ decision records, AirPlan/docs/architecture/c4/module.md, AirPlan/docs/network/airndb-log.md, and AirPlan/docs/network/airndb-captures/; on first startup detect tcpdump/WinDump and on Windows auto-download official WinDump.exe when no capture tool is available; when remote debugging, call the AirNDB remote device helper and auto-configure remote tcpdump/dumpcap when missing; build safe bounded tcpdump/WinDump commands; capture or read pcap artifacts; summarize packet evidence; and maintain AirPlan/AGENTS.md, ADR, C4 module docs, and network debug logs when capture tooling, network boundaries, or debugging decisions change. +--- + +# AirNDB + +## 核心约束 + +- 全程使用中文与用户交流,命令、接口名、BPF、日志、路径和协议名保持原文。 +- `/airndb` 专用于网络抓包、pcap 分析和网络层调试证据收集。 +- 只抓取用户授权的本机、项目、测试环境或明确允许的网络流量。 +- 默认不做无界抓包;必须使用包数、超时、时长或轮转上限。 +- 默认先列接口,再确认接口、目标 host/port/protocol/filter、抓包窗口和输出路径。 +- 初次启动必须检测 `tcpdump` / `windump` / `WinDump.exe` 是否可用;Windows 下如果不可用,自动从 WinDump 官方下载页获取 `WinDump.exe`,校验 SHA1 后写入 `AirPlan/state/airndb/tool.env`。 +- 远程设备、测试机、VM 或 SSH 主机上的网络调试,先调用 `$HOME/plugins/airndb/scripts/airndb_remote_device.py`;缺少远程 `tcpdump` / `dumpcap` 时允许脚本自动配置,无法无密码 `sudo` 或无包管理器时停止并提示用户。 +- 自动获取只下载 WinDump 用户态程序,不静默安装 WinPcap/Npcap 抓包驱动;如果接口列举失败,提示用户安装 Npcap 或 WinPcap 并用管理员权限重试。 +- 默认使用 `-nn` 避免 DNS/service-name 解析,使用 `-s 0` 写入完整 pcap。 +- pcap 可能包含凭据、cookie、token、payload、内网地址、主机名或个人信息;对外分享前必须提醒脱敏。 +- 网络证据要写入 `AirPlan/docs/network/airndb-log.md`,pcap/摘要/JSON 报告写入 `AirPlan/docs/network/airndb-captures/`。 +- 根据项目变化维护 `AirPlan/AGENTS.md`、ADR 和 C4 module。 +- 如果需要 WinDump/tcpdump 选项和 BPF 简表,读取 [references/windump-tcpdump-notes.md](references/windump-tcpdump-notes.md)。 + +## 启动与初始化 + +进入 `/airndb` 时运行: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_mode.py" --mode enter --project . +``` + +如果当前环境没有 `python`,尝试 `py`、`python3` 或用户提供的 Python 绝对路径。脚本不可用时,手动确保以下结构存在: + +- `AirPlan/AGENTS.md` +- `AirPlan/docs/architecture/adr/` +- `AirPlan/docs/architecture/c4/module.md` +- `AirPlan/docs/network/airndb-log.md` +- `AirPlan/docs/network/airndb-captures/` +- `AirPlan/state/airndb/state.json` + +初始化后读取已有内容作为上下文。不要覆盖用户已有正文;只补齐缺失结构或更新 AirNDB 标记块。 + +## 首次工具配置 + +`airndb_mode.py --mode enter` 会执行工具自检: + +1. 查找显式配置、项目 `AirPlan/state/airndb/tool.env`、环境变量 `AIRNDB_TCPDUMP`、项目 `AirPlan/state/airndb/tools/WinDump.exe`、PATH 中的 `windump` / `WinDump.exe` / `tcpdump`。 +2. 如果找到可用工具,写入或刷新 `AirPlan/state/airndb/tool.env`,后续 `airndb_capture.py` 自动读取。 +3. 如果 Windows 上找不到工具,自动从 WinDump 官方下载地址获取 `WinDump.exe`,校验 SHA1 `d59bc54721951dec855cbb4bbc000f9a71ea4d95`,保存到 `AirPlan/state/airndb/tools/WinDump.exe`,然后写入 `AirPlan/state/airndb/tool.env`。 +4. 如果下载失败或校验失败,停止并提示用户手动安装 `tcpdump` / `WinDump.exe` 或设置 `AIRNDB_TCPDUMP`。 + +`AirPlan/state/airndb/tool.env` 是本机路径配置,由 `AirPlan/state/airndb/.gitignore` 忽略,不应提交。 + +注意:WinDump 仍需要抓包驱动。官方 WinDump 安装页要求先安装 WinPcap 3.1 或更新版本;WinPcap 主页提示项目已停止维护并建议 Windows 10 用户使用 Npcap。AirNDB 不静默安装驱动,只负责检测、下载 WinDump.exe 和配置本机路径。 + +## 远程设备工具配置 + +当用户说明目标流量发生在远程设备、测试机、服务器、VM、容器宿主机、SSH 主机,或本机抓包看不到目标流量时,不要先使用本机 `airndb_capture.py`。先运行远程设备 helper: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_remote_device.py" --project . --action setup +``` + +如果当前环境没有 `python`,尝试 `py`、`python3` 或用户提供的 Python 绝对路径。首次运行会生成 `AirPlan/state/airndb/remote-device.env.example`;把连接信息写入 `AirPlan/state/airndb/remote-device.env` 或当前环境变量: + +- `AIRNDB_REMOTE_SSH_TARGET=user@host` +- `AIRNDB_REMOTE_SSH_PORT=22` +- `AIRNDB_REMOTE_SSH_OPTIONS=` +- `AIRNDB_REMOTE_WORKDIR=` +- `AIRNDB_REMOTE_TCPDUMP=auto` +- `AIRNDB_REMOTE_CAPTURE_PREFIX=sudo -n` + +远程 helper 行为: + +- 检查本机 `ssh`、远程连通性和远程工作目录。 +- 探测 `tcpdump`、`dumpcap`、`windump`、`WinDump.exe`。 +- 工具缺失时自动尝试用远端包管理器安装 `tcpdump`,只使用非交互式 `sudo -n`;需要密码、管理员确认或无支持的包管理器时停止并提示用户。 +- 将可复用配置写入 `AirPlan/state/airndb/remote-device.env`,该文件由 `AirPlan/state/airndb/.gitignore` 忽略。 +- 抓包产物拉回 `AirPlan/docs/network/airndb-captures/`,并追加 `AirPlan/docs/network/airndb-log.md`。 + +常用远程命令: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_remote_device.py" --project . --action interfaces +python "$HOME/plugins/airndb/scripts/airndb_remote_device.py" --project . --action command --iface --filter "" --count 200 +python "$HOME/plugins/airndb/scripts/airndb_remote_device.py" --project . --action capture --iface --filter "" --count 200 --timeout 30 +``` + +远程抓包仍必须有明确授权、接口、BPF、包数或超时上限。`AIRNDB_REMOTE_CAPTURE_PREFIX` 默认是 `sudo -n`;如果远端已配置免 sudo 的 capture capability,可改为空或指定更合适的前缀。 + +## 工作流 + +1. 明确网络问题: + - 现象:连不上、超时、重置、DNS 异常、TLS 握手失败、丢包、延迟、端口不可达、代理/防火墙疑似问题。 + - 目标:源/目的 host、端口、协议、服务名、容器/VM/WSL/宿主机边界。 + - 抓包窗口:包数、超时、复现步骤和是否允许保存 payload。 +2. 发现接口: + - 本机调试运行 `airndb_capture.py --action interfaces`。 + - 远程调试运行 `airndb_remote_device.py --action interfaces`。 + - Windows 优先使用 `windump -D` 或 `WinDump.exe -D`;Linux/macOS 优先 `tcpdump -D`。 +3. 设计过滤器: + - 使用最窄可行 BPF:`host`、`src host`、`dst host`、`port`、`tcp`、`udp`、`icmp`、`net`。 + - 不确定时先短时宽过滤,再根据结果收窄。 +4. 执行有界抓包: + - 使用 `airndb_capture.py --action capture --iface --filter "" --count --timeout `。 + - 产物写入 `AirPlan/docs/network/airndb-captures/`。 +5. 读取和分析: + - 使用 `airndb_capture.py --action read --read-file --filter ""` 生成文本摘要。 + - 结合时间线、TCP flags、重传、RST、DNS 响应、ICMP、TLS ClientHello/ServerHello 迹象判断网络层事实。 +6. 记录证据: + - exact command + - interface + - BPF filter + - packet count or timeout + - pcap path + - summary/report path + - 观察结论、限制和剩余风险 + +## 与 AirDbg 协作 + +- AirNDB 负责抓包、pcap 摘要、网络层证据和过滤器。 +- AirDbg 负责代码层根因分析、修复和验证收尾。 +- AirDbg 调试中遇到 DNS、TCP、UDP、TLS、HTTP 连接、端口、代理、防火墙、丢包、重传或 pcap 证据需求时,可以调用 AirNDB。 +- AirNDB 收集到的证据必须能被 AirDbg 直接引用:命令、pcap 路径、摘要、关键包、时间线和结论要写清楚。 + +## 常用命令 + +列接口: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_capture.py" --project . --action interfaces +``` + +检查或初始化工具路径: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_mode.py" --project . --mode enter +``` + +只生成命令: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_capture.py" --project . --action command --iface 1 --filter "tcp and port 443" --count 200 +``` + +短时抓包: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_capture.py" --project . --action capture --iface 1 --filter "tcp and port 443" --count 200 --timeout 30 +``` + +读取 pcap: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_capture.py" --project . --action read --read-file AirPlan/docs/network/airndb-captures/example.pcap +``` + +## AGENTS.md 维护 + +在以下情况更新 `AGENTS.md`: + +- 发现稳定可复用的 tcpdump/WinDump 命令、接口选择规则、BPF 过滤器或 pcap 读取方式。 +- 发现影响后续 AI 调试的网络边界:容器、WSL、VM、代理、防火墙、VPN、DNS、TLS、NAT、端口映射。 +- 发现抓包权限、驱动、管理员权限或平台差异。 +- 发现本机 tcpdump/WinDump 路径或 `AirPlan/state/airndb/tool.env` 配置方式。 +- 发现远程设备 SSH 入口、远程抓包工具、`AIRNDB_REMOTE_*` 配置方式或远端抓包权限限制。 + +## ADR 维护 + +目录:`docs/architecture/adr/`。 + +需要 ADR 的情况: + +- 长期采用 tcpdump/WinDump 作为项目网络诊断方式。 +- 抓包流程改变了测试边界、网络观测边界、运行权限、数据留存或安全策略。 +- 发现需要保留的网络架构决策,例如代理、DNS、TLS、端口、服务发现或跨容器/宿主机边界。 + +ADR 保持简洁:Context、Decision、Consequences、Alternatives。 + +## C4 Module 维护 + +文件:`docs/architecture/c4/module.md`。 + +当网络调试发现或改变以下内容时,必须更新: + +- 模块间网络依赖。 +- 服务端口、协议、DNS、代理、TLS、队列、网关、容器/宿主机/WSL/VM 边界。 +- 抓包或观测基础设施成为长期模块或运行边界。 +- 网络错误处理、重试、超时、连接池或安全边界。 + +## network log 维护 + +文件:`AirPlan/docs/network/airndb-log.md`。 + +每次 AirNDB 会话至少追加: + +- 问题摘要。 +- 授权范围和目标流量。 +- 接口、BPF、抓包窗口。 +- 是否使用远程设备 helper 以及远程目标、抓包工具和权限限制。 +- pcap/summary/report 路径。 +- 关键包或时间线观察。 +- 结论、限制和给 AirDbg 的线索。 +- ADR/C4/AGENTS 更新。 + +## 完成输出 + +本轮网络调试结束时,用中文简洁汇报: + +- 使用了哪个接口和过滤器。 +- 抓包是否成功,证据在哪里。 +- 关键观察和网络层结论。 +- 更新了哪些 `AGENTS.md` / ADR / C4 / network log。 +- 是否需要切给 AirDbg 做代码层修复。 diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airndb/agents/openai.yaml b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airndb/agents/openai.yaml new file mode 100755 index 0000000..a14e827 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airndb/agents/openai.yaml @@ -0,0 +1,3 @@ +name: airndb +short_description: Local and remote packet capture workflow with tcpdump and WinDump +default_prompt: "使用 AirNDB 做安全有界抓包;远程设备优先调用 remote device helper 并自动配置 tcpdump。" diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airndb/references/windump-tcpdump-notes.md b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airndb/references/windump-tcpdump-notes.md new file mode 100755 index 0000000..ba8b51b --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airndb/references/windump-tcpdump-notes.md @@ -0,0 +1,83 @@ +# WinDump / Tcpdump Notes + +Source: https://www.winpcap.org/windump/docs/manual.htm + +## AirNDB Summary + +- WinDump follows tcpdump-style packet capture usage on Windows. +- `-D` lists available capture interfaces. +- `-i ` selects the capture interface. On Windows this is often the interface number from `-D`. +- `-c ` stops after a bounded number of packets. +- `-w ` writes raw packets to a pcap file. +- `-r ` reads packets back from a pcap file. +- `-n` avoids host name resolution; `-nn` also avoids service name resolution. +- `-s ` controls packet snapshot length. AirNDB uses `-s 0` for pcap captures so packets are not truncated. +- Filter expressions use BPF primitives such as `host`, `net`, `port`, `src`, `dst`, `tcp`, `udp`, `icmp`, `arp`, `and`, `or`, and `not`. + +## Windows Notes + +- Prefer `WinDump.exe` or `windump` when `tcpdump` is unavailable on Windows. +- WinDump normally requires a packet capture driver such as WinPcap/Npcap and may require an elevated terminal. +- Interface names can be long adapter paths; the numeric index from `windump -D` is usually easier to use. +- Store pcap artifacts in a project-local ignored directory such as `docs/network/airndb-captures/`. + +## AirNDB Auto Setup + +- On `/airndb enter`, AirNDB checks for `tcpdump`, `windump`, or `WinDump.exe`. +- If no capture tool is available on Windows, AirNDB downloads the official `WinDump.exe` linked from the WinDump install page: + +```text +https://www.winpcap.org/windump/install/bin/windump_3_9_5/WinDump.exe +``` + +- AirNDB verifies SHA1 before using the file: + +```text +d59bc54721951dec855cbb4bbc000f9a71ea4d95 +``` + +- AirNDB stores the binary at `AirPlan/state/airndb/tools/WinDump.exe` and writes `AirPlan/state/airndb/tool.env`: + +```text +AIRNDB_TCPDUMP= +``` + +- AirNDB does not silently install WinPcap/Npcap drivers. If `WinDump.exe -D` fails after download, tell the user to install Npcap or WinPcap and retry from an elevated terminal. + +## Safe Defaults + +- Start with interface discovery before capture: + +```bash +windump -D +tcpdump -D +``` + +- Prefer short, bounded capture: + +```bash +tcpdump -i -nn -s 0 -w .pcap -c 200 '' +``` + +- Read back a pcap summary: + +```bash +tcpdump -nn -r .pcap '' +``` + +## BPF Examples + +```text +host 192.0.2.10 +tcp and port 443 +udp and port 53 +src host 192.0.2.10 and dst port 443 +net 10.0.0.0/8 and not port 22 +icmp or icmp6 +``` + +## Evidence Rules + +- Record exact command, interface, filter, packet count, capture window, pcap path, and summary path. +- Keep pcap files private unless reviewed; they can contain tokens, cookies, payload, internal hostnames, and addresses. +- If application payload is encrypted, use packet timing, DNS, TCP/TLS handshakes, retransmissions, resets, or connection failures as evidence instead of expecting plaintext. diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airsdb/SKILL.md b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airsdb/SKILL.md new file mode 100755 index 0000000..5a0cd45 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airsdb/SKILL.md @@ -0,0 +1,166 @@ +--- +name: airsdb +description: Cppcheck static-analysis workflow for C/C++ projects. Use when the user invokes /airsdb, asks to run static analysis, evaluate code quality or security with cppcheck, generate a short AI-context static-analysis report for AirDbg or AirDo, diagnose issues that need static analysis, maintain AirPlan/docs/staticanalysis.md, or run local/remote cppcheck over SSH. On first startup detect cppcheck and auto-install or auto-configure it when missing; default local and remote scans to `--check-level=exhaustive` for maximum branch-analysis detail; and call the AirSDB remote device helper when remote cppcheck is needed. +--- + +# AirSDB + +## 核心约束 + +- 全程使用中文与用户交流,命令、路径、工具名、告警 id、CWE 保持原文。 +- `/airsdb` 专用于 C/C++ 静态分析、代码质量/安全性初筛、cppcheck 证据收集,以及给 AirDbg/AirDo 提供简短 AI 上下文报告。 +- 第一次进入必须检测 `cppcheck`。本机缺失时自动尝试用包管理器安装或配置;无法自动安装时停止并提示官方下载页或 `AIRSDB_CPPCHECK`。 +- 必须创建或维护 `AirPlan/docs/staticanalysis.md`。它只写简短摘要,详细 XML/JSON 产物放在 `AirPlan/state/airsdb/reports/`。 +- 本机分析使用 `$HOME/plugins/airsdb/scripts/airsdb_cppcheck.py`。 +- 远程设备、测试机、VM、容器宿主机、服务器或 SSH 主机上的静态分析,先使用 `$HOME/plugins/airsdb/scripts/airsdb_remote_device.py`;远端缺少 `cppcheck` 时允许脚本自动配置,无法无密码 `sudo` 或无包管理器时停止并提示用户。 +- 优先使用 `compile_commands.json`;没有时只扫描最窄可行目录,并排除 `.git`、`AirPlan/state/airsdb`、`build`、`node_modules`、`vendor`、`third_party` 等常见噪声目录。 +- 默认使用 `--check-level=exhaustive`,尽可能提供详细分支分析信息,避免出现 `normalCheckLevelMaxBranches` 这类因分支分析深度受限造成的信息缺口;只有用户明确要求降级时才改。 +- Cppcheck 是静态分析,不等同于编译、测试或安全审计;结论要写成“证据/线索”,不要夸大。 +- 如果需要 cppcheck 安装和命令细节,读取 [references/cppcheck-notes.md](references/cppcheck-notes.md)。 + +## 启动与环境检测 + +进入 `/airsdb` 时运行: + +```bash +python "$HOME/plugins/airsdb/scripts/airsdb_mode.py" --mode enter --project . +``` + +如果当前环境没有 `python`,尝试 `py`、`python3` 或用户提供的 Python 绝对路径。 + +脚本会: + +- 初始化 `AirPlan/state/airsdb/`、`AirPlan/state/airsdb/tool.env.example`、`AirPlan/state/airsdb/.gitignore`。 +- 创建或维护 `AirPlan/docs/staticanalysis.md`。 +- 在 `AirPlan/AGENTS.md` 中维护 AirSDB 标记块。 +- 检测 `AIRSDB_CPPCHECK`、`AirPlan/state/airsdb/tool.env`、PATH 和常见 Windows 安装路径。 +- 找不到 `cppcheck` 时自动尝试安装: + - Windows:`winget`、`choco`、`scoop` + - Linux/macOS:`apt-get`、`dnf`、`yum`、`apk`、`pacman`、`brew`、`port` + +`AirPlan/state/airsdb/tool.env` 是本机路径配置,由 `AirPlan/state/airsdb/.gitignore` 忽略,不应提交。 + +## 本机分析 + +检查或安装 cppcheck: + +```bash +python "$HOME/plugins/airsdb/scripts/airsdb_cppcheck.py" --project . --action setup +``` + +只生成命令: + +```bash +python "$HOME/plugins/airsdb/scripts/airsdb_cppcheck.py" --project . --action command +``` + +执行扫描: + +```bash +python "$HOME/plugins/airsdb/scripts/airsdb_cppcheck.py" --project . --action scan --timeout 900 +``` + +常用参数: + +- `--project-file build/compile_commands.json`:指定编译数据库。 +- `--target src`:没有编译数据库时限制扫描目录。 +- `--enable warning,style,performance,portability,information`:默认检查集合。 +- `--check-level exhaustive`:默认详细分支分析级别。 +- `--std c++17`:指定 C/C++ 标准。 +- `--extra "--suppress=missingIncludeSystem"`:追加 cppcheck 参数。 + +扫描后必须确认: + +- `AirPlan/state/airsdb/reports/-cppcheck.xml` +- `AirPlan/state/airsdb/reports/-cppcheck.json` +- `AirPlan/docs/staticanalysis.md` 已追加简短报告 + +## 远程设备分析 + +当目标代码或复现场景在远程设备上时,不要先跑本机 cppcheck。先运行: + +```bash +python "$HOME/plugins/airsdb/scripts/airsdb_remote_device.py" --project . --action setup +``` + +首次运行会生成 `AirPlan/state/airsdb/remote-device.env.example`。将连接信息写入 `AirPlan/state/airsdb/remote-device.env` 或当前环境变量: + +- `AIRSDB_REMOTE_SSH_TARGET=user@host` +- `AIRSDB_REMOTE_SSH_PORT=22` +- `AIRSDB_REMOTE_SSH_OPTIONS=` +- `AIRSDB_REMOTE_WORKDIR=` +- `AIRSDB_REMOTE_PROJECT=/path/to/remote/project` +- `AIRSDB_REMOTE_CPPCHECK=auto` + +远程 helper 行为: + +- 检查本机 `ssh`、远程连通性、远程工作目录。 +- 探测远端 `cppcheck`。 +- 缺失时自动尝试用远端包管理器安装 `cppcheck`,只使用非交互式 `sudo -n`;需要密码、管理员确认或无支持包管理器时停止并提示用户。 +- 在远端项目目录运行 cppcheck,把 XML 拉回本机 `AirPlan/state/airsdb/reports/` 并更新本机 `AirPlan/docs/staticanalysis.md`。 + +远程命令: + +```bash +python "$HOME/plugins/airsdb/scripts/airsdb_remote_device.py" --project . --action command +python "$HOME/plugins/airsdb/scripts/airsdb_remote_device.py" --project . --action scan --timeout 900 +``` + +## 与 AirDbg 协作 + +AirDbg 调试中遇到以下情况时调用 AirSDB: + +- 需要用静态分析辅助定位崩溃、内存错误、未初始化变量、空指针、越界、危险转换、资源释放或 CWE 线索。 +- 需要在修复前后比较 cppcheck 结果。 +- 需要给根因分析提供短报告,而不是完整 XML 噪声。 + +AirSDB 给 AirDbg 的交接必须写入 `AirPlan/docs/staticanalysis.md`: + +- 命令和目标 +- XML/JSON 报告路径 +- severity/id/CWE 计数 +- Top findings +- 哪些 findings 与当前 bug 相关 +- 剩余风险 + +## 与 AirDo 协作 + +AirDo 执行 `AirPlan/todo.md` 时可以调用 AirSDB 做验收或排障: + +- todo 要求静态分析、质量检查、安全性初筛或 C/C++ 代码风险评估。 +- 验证失败但需要 cppcheck 辅助定位。 +- 远程设备上的实现需要远端 cppcheck 证据。 + +AirDo 仍然拥有 `AirPlan/todo.md` 进度。调用 AirSDB 后,把命令、报告路径、结论和剩余风险写回当前 todo 项。 + +## staticanalysis.md 维护 + +每次 AirSDB 扫描至少追加: + +- Target:local 或 remote target +- Tool:cppcheck 路径和版本 +- Command:实际命令 +- Result:ok / findings / failed +- Counts:各 severity 数量 +- Reports:XML/JSON 路径 +- Top findings:最多 12 条,含 severity、id、CWE、文件行号、摘要 +- AirDbg/AirDo handoff:当前任务如何使用这些结果 +- Residual risk:静态分析未覆盖的风险 + +不要把完整 XML、长日志或大段 cppcheck 输出塞进 `staticanalysis.md`。 + +## AGENTS / ADR / C4 + +- 发现稳定可复用的 AirSDB 命令、远程设备配置、过滤策略、suppressions 或质量门槛时,更新 `AGENTS.md`。 +- 如果静态分析成为长期测试/调试边界,或影响模块边界、质量策略、安全策略、CI 策略,更新 C4 module 并新增或修订 ADR。 +- 如果只是一次临时扫描,只维护 `staticanalysis.md` 即可。 + +## 完成输出 + +本轮结束时用中文简洁汇报: + +- 使用本机还是远程 cppcheck。 +- cppcheck 是否可用,是否发生自动配置。 +- 报告路径和 `staticanalysis.md` 是否更新。 +- 发现数量和最重要的 3-5 条线索。 +- 是否建议交给 AirDbg 修复,或交给 AirDo 写回 todo 验收。 diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airsdb/agents/openai.yaml b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airsdb/agents/openai.yaml new file mode 100755 index 0000000..7c462d3 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airsdb/agents/openai.yaml @@ -0,0 +1,3 @@ +name: airsdb +short_description: Cppcheck static analysis with exhaustive local/remote reports +default_prompt: "使用 AirSDB 运行本机或远程 cppcheck 静态分析,默认启用 --check-level=exhaustive,生成简短 staticanalysis.md 报告并交给 AirDbg/AirDo 使用。" diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airsdb/references/cppcheck-notes.md b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airsdb/references/cppcheck-notes.md new file mode 100755 index 0000000..dd9b00f --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airsdb/references/cppcheck-notes.md @@ -0,0 +1,27 @@ +# Cppcheck Notes + +Use this only when AirSDB needs cppcheck install or command details. + +## Sources + +- Official open-source download page: https://cppcheck.sourceforge.io/ +- Official repository package notes: https://github.com/danmar/cppcheck +- Official manual: https://cppcheck.sourceforge.io/manual.html +- User-provided Chinese guide: https://www.zeeklog.com/cppcheckzhong-ji-zhi-nan-cong-ling-kai-shi-zhang-wo-c-c-jing-tai-dai-ma-fen-xi +- User-provided download reference: http://cppcheck.net/#download + +## Install Notes + +- The official page lists current open-source releases and package-manager examples. +- Windows official installer is linked from the Cppcheck open-source page. +- Package managers can be convenient but may lag behind official releases. +- AirSDB auto-configures with package managers first because it must be non-interactive for agent workflows. + +## Command Notes + +- Prefer `cppcheck --project=compile_commands.json` when the project has a compilation database. +- Generate a CMake compilation database with `cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON .` when appropriate. +- Use `--xml --xml-version=2` for machine-readable reports. +- Use `--cppcheck-build-dir=` for incremental analysis and better whole-program analysis. +- Use `-i` to skip generated/vendor directories. +- Use suppressions instead of deleting warnings when a finding is a known false positive. diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airxdb/SKILL.md b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airxdb/SKILL.md new file mode 100755 index 0000000..340b1b4 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airxdb/SKILL.md @@ -0,0 +1,263 @@ +--- +name: airxdb +description: Midscene-based GUI debugging workflow. Use when the user invokes /airxdb, asks to debug browser UI, desktop UI, canvas UI, visual regressions, flaky interface interactions, remote GUI/device debugging over SSH, or reproduce graphical issues with Midscene.js. Load or initialize AirPlan/AGENTS.md, AirPlan/docs/architecture/adr/ decision records, and AirPlan/docs/architecture/c4/module.md; collaborate with AirDbg for GUI issues; choose the right Midscene mode for Playwright, Chrome bridge mode, desktop computer automation, MCP, or the AirXDB remote device helper; auto-configure remote screenshot tooling when missing; generate visual reproduction steps and reports; and maintain AirPlan/AGENTS.md, ADR, C4 module, and GUI debug logs whenever GUI-debug tooling or interface behavior changes. +--- + +# AirXDB + +## 核心约束 + +- 全程使用中文与用户交流,代码、命令、日志、路径、包名保持原文。 +- `/airxdb` 专用于图形界面和视觉交互层面的调试,不替代 `airdbg` 的通用根因分析职责。 +- 优先与 `airdbg` 配合: + - `airxdb` 负责界面复现、视觉定位、交互自动化、Midscene 报告与截图证据。 + - `airdbg` 负责代码层根因、最小修复、测试验证和通用调试收尾。 +- 先加载或初始化上下文:`AirPlan/AGENTS.md`、`AirPlan/docs/architecture/adr/`、`AirPlan/docs/architecture/c4/module.md`、`AirPlan/docs/debug/gui-debug-log.md`。 +- 如果这些文件不存在,先分析当前项目并初始化它们;C4 module 要反映真实模块边界,尤其是前端、UI、桌面桥接、自动化测试相关边界。 +- Midscene 路线优先使用视觉复现和 HTML 报告收集证据,不把 GUI 调试退化成纯日志猜测。 +- 截图取证是 AirXDB 的一等能力:即使没有语义模型配置,也可以连接 Computer MCP 截图,为 `airdbg` 提供错误现场、布局状态、弹窗、焦点和多显示器信息。 +- 远程设备、测试机、VM 或 SSH 主机上的 GUI 调试,先调用 `$HOME/plugins/airxdb/scripts/airxdb_remote_device.py`;缺少远程截图工具时允许脚本自动配置,无法无密码 `sudo` 或无包管理器时停止并提示用户。 +- 优先做最小可验证复现和最小必要修复,不顺手大改界面架构。 +- GUI 调试过程中,一定要维护 `AirPlan/AGENTS.md`、ADR、C4 module 和 `AirPlan/docs/debug/gui-debug-log.md`。 +- 如果需要 Midscene 包名、桥接/MCP 配置、常用命令,读取 [references/midscene-official-notes.md](references/midscene-official-notes.md)。 + +## 启动与初始化 + +进入 `/airxdb` 时运行: + +```bash +python "$HOME/plugins/airxdb/scripts/airxdb_mode.py" --mode enter --project . +``` + +如果当前环境没有 `python`,尝试 `py` 或 `python3`。脚本不可用时,手动确保以下结构存在: + +- `AirPlan/AGENTS.md` +- `AirPlan/docs/architecture/adr/` +- `AirPlan/docs/architecture/c4/module.md` +- `AirPlan/docs/debug/gui-debug-log.md` +- `AirPlan/state/airxdb/state.json` + +初始化后读取已有内容作为上下文。不要覆盖用户已有正文;只补齐缺失结构或更新 AirXDB 标记块。 + +## 首次模型配置 + +AirXDB 第一次进入项目时必须检查 Midscene 模型配置。`airxdb_mode.py` 会输出 `midscene_config`,如果出现 `midscene_config_required=true` 或 `midscene_config=missing:...`: + +- 暂停执行 `act`、`Tap`、`Input`、`KeyboardPress`、视觉定位等语义动作。 +- 用中文向用户索取配置: + - `MIDSCENE_MODEL_NAME` + - `MIDSCENE_MODEL_BASE_URL` + - `MIDSCENE_MODEL_API_KEY` + - `MIDSCENE_MODEL_FAMILY` + - 可选:`MCP_SERVER_REQUEST_TIMEOUT` +- 告知用户可以只在当前会话设置环境变量,或写入 `AirPlan/state/airxdb/midscene.local.env` 方便后续复用。 +- `AirPlan/state/airxdb/midscene.local.env` 只保存本机密钥,默认由 `AirPlan/state/airxdb/.gitignore` 忽略;不要把真实 API key 写入 `AirPlan/AGENTS.md`、ADR、C4 或 debug log。 +- 如果用户暂时不提供模型配置,仍然可以做截图、MCP 连接、显示器枚举、环境探测等非语义取证操作;不能声称完成了 Midscene 视觉语义操作。 +- `MIDSCENE_MODEL_FAMILY` 是语义视觉动作必填项。`gpt-5.4` 这类 GPT-5.x 视觉模型使用 `gpt-5`。 +- 常见合法 family:`gpt-5`、`qwen2.5-vl`、`qwen3-vl`、`gemini`、`doubao-seed`、`vlm-ui-tars`。 + +## 截图取证模式 + +当用户需要给 `airdbg` 提供错误诊断信息、GUI 现场、弹窗、焦点状态、布局错位或多显示器证据时,优先使用截图取证模式: + +```bash +python "$HOME/plugins/airxdb/scripts/airxdb_computer_mcp_smoke.py" --project . --action screenshot +``` + +截图取证模式不要求 `MIDSCENE_MODEL_NAME`、`MIDSCENE_MODEL_BASE_URL`、`MIDSCENE_MODEL_API_KEY` 或 `MIDSCENE_MODEL_FAMILY`。它只验证 Computer MCP 桌面连接和截图能力,并将截图/JSON 报告写入 `AirPlan/docs/debug/airxdb-artifacts/`。 + +截图后必须在 `AirPlan/docs/debug/gui-debug-log.md` 追加: + +- 截图目标和平台 +- 截图文件路径 +- 当前界面关键观察 +- 给 `airdbg` 的诊断线索 +- 是否还需要语义视觉动作或代码层修复 + +如果截图可能包含密钥、聊天内容、账号、客户数据或隐私信息,在对外分享前提醒用户脱敏。 + +## 远程设备截图模式 + +当用户说明目标在远程设备、测试机、服务器、VM、SSH 主机,或当前桌面不是目标 GUI 所在机器时,不要先使用本机 Computer MCP。先运行远程设备 helper: + +```bash +python "$HOME/plugins/airxdb/scripts/airxdb_remote_device.py" --project . --action setup +``` + +如果当前环境没有 `python`,尝试 `py`、`python3` 或用户提供的 Python 绝对路径。首次运行会生成 `AirPlan/state/airxdb/remote-device.env.example`;把连接信息写入 `AirPlan/state/airxdb/remote-device.env` 或当前环境变量: + +- `AIRXDB_REMOTE_SSH_TARGET=user@host` +- `AIRXDB_REMOTE_SSH_PORT=22` +- `AIRXDB_REMOTE_SSH_OPTIONS=` +- `AIRXDB_REMOTE_WORKDIR=` +- `AIRXDB_REMOTE_SCREENSHOT_TOOL=auto` +- `AIRXDB_REMOTE_DISPLAY=` + +远程 helper 行为: + +- 检查本机 `ssh`、远程连通性和远程工作目录。 +- 探测 `gnome-screenshot`、`spectacle`、`scrot`、`grim`、`import`、`screencapture`。 +- 工具缺失时自动尝试用远端包管理器安装 `scrot`,只使用非交互式 `sudo -n`;需要密码、管理员确认或无支持的包管理器时停止并提示用户。 +- 将可复用配置写入 `AirPlan/state/airxdb/remote-device.env`,该文件由 `AirPlan/state/airxdb/.gitignore` 忽略。 + +远程截图: + +```bash +python "$HOME/plugins/airxdb/scripts/airxdb_remote_device.py" --project . --action screenshot +``` + +远程截图和 JSON 报告写入 `AirPlan/docs/debug/airxdb-artifacts/`,并追加 `AirPlan/docs/debug/gui-debug-log.md`。远程截图只能证明远端截图链路和当前 GUI 现场;如果需要 Midscene 语义视觉动作,仍需单独确认远端或本机可用的 Midscene 接入方式。 + +## Computer MCP 快速验证 + +验证桌面 GUI 能力时优先运行 smoke test 脚本,而不是临时拼 MCP 客户端: + +```bash +python "$HOME/plugins/airxdb/scripts/airxdb_computer_mcp_smoke.py" --project . --action mousemove --prompt "Windows taskbar Start button" +``` + +如果当前环境没有 `python`,尝试 `py`、`python3` 或用户提供的 Python 绝对路径。 + +脚本行为: + +- 加载 `AirPlan/state/airxdb/midscene.local.env`,但输出和 JSON 报告会屏蔽 API key;`--action screenshot` 不要求模型配置。 +- 启动 `@midscene/computer-mcp` HTTP 服务。 +- Windows 下自动检查并修复 `screenCapture_1.3.2.bat` / `app.manifest` 缺失问题;修复来源是 `screenshot-desktop@1.15.3` 官方 npm 包。 +- 执行 `computer_connect`、可选语义动作、`take_screenshot`、`computer_disconnect`。 +- 证据写入 `docs/debug/airxdb-artifacts/`,包括截图和 `airxdb-smoke.json` 报告。 + +结果判断: + +- `airxdb_smoke=ok` 才能说明 Computer MCP 视觉链路通过。 +- 如果只完成截图,没有完成 `MouseMove` / `act` / `Tap` 等语义动作,只能说“截图/连接可用”,不能说“视觉语义操作已通过”。 +- 如果报 `MIDSCENE_MODEL_FAMILY is not set to a visual language model`,先补 family;`gpt-5.4` 用 `gpt-5`。 +- Windows 开始菜单 + 中文输入法场景下,输入查询词后可能需要双回车:第一次提交输入法,第二次执行启动。 + +## Midscene 模式选择 + +先判断目标界面和现有技术栈,再选 Midscene 模式: + +1. Web + 已有 Playwright: + - 优先使用 Midscene 的 Playwright 集成。 + - 适合已有 E2E、页面复现、交互不稳定、视觉断言场景。 +2. Web + 需要复用本地 Chrome 状态: + - 使用 Chrome Bridge Mode。 + - 适合需要复用 cookies、扩展、已登录会话、人工介入浏览器态的场景。 +3. 桌面应用 GUI: + - 使用 Midscene Computer / Playground。 + - 适合 Electron、Qt、WPF、原生应用和跨应用流程。 +4. 远程设备 GUI: + - 先使用 AirXDB remote device helper 取证和自动配置远端截图工具。 + - 适合 SSH 可达的测试机、VM、服务器桌面或远程 Linux/macOS GUI。 +5. 需要把 GUI 操作暴露给上层 Agent 或工具链: + - 使用 Midscene MCP。 + - 浏览器优先 Web Bridge MCP,桌面优先 Computer MCP。 + +不确定时,一次只问一个关键问题: + +- 这是浏览器页面还是桌面应用? +- 项目里是否已有 Playwright? +- 是否必须复用本机浏览器登录态? +- 是要快速复现,还是要沉淀成长期自动化脚本? + +## GUI 调试流程 + +1. 明确问题边界: + - 哪个界面、哪条交互链路、什么平台。 + - 期望行为和实际行为。 + - 是否涉及视觉错位、点击不到、浮层遮挡、Canvas、焦点问题、窗口切换、多显示器等。 +2. 加载上下文: + - 读取 `AGENTS.md`。 + - 读取相关 ADR。 + - 读取 `docs/architecture/c4/module.md`。 + - 查看前端/桌面自动化相关代码、测试、构建配置。 +3. 选择 Midscene 模式并做最小复现: + - Playwright + - Chrome Bridge + - Computer / Playground + - MCP +4. 生成可回放证据: + - Midscene HTML 报告 + - 关键截图 + - 复现命令 + - 相关日志和报错 +5. 将证据和观察写入 `AirPlan/docs/debug/gui-debug-log.md`。 +6. 如果问题只是界面复现层,继续用 `airxdb` 深挖。 +7. 如果已定位到代码层根因,转入或并行配合 `airdbg` 做修复。 +8. 修复后再次用 Midscene 复跑关键 GUI 路径,确认问题关闭。 + +## 与 AirDbg 的协作 + +- `airxdb` 先做: + - GUI 复现 + - 视觉定位 + - 界面交互脚本/桥接/MCP 配置 + - 报告与截图证据 +- `airdbg` 再做: + - 根因代码分析 + - 修复实现 + - 测试验证 + - 风险收尾 + +如果当前问题同时包含“界面复现难”和“代码根因不明”,先用 `airxdb` 稳定复现,再把复现结论和报告交给 `airdbg`。 + +## AGENTS.md 维护 + +在以下情况更新 `AGENTS.md`: + +- 发现新的 GUI 调试命令、Playwright 命令、Midscene 运行方式。 +- 发现系统权限要求,例如桌面自动化权限、屏幕录制权限、多显示器限制。 +- 发现影响后续 GUI 调试的重要约束,如浏览器桥接、登录态、测试环境、显示缩放。 +- 发现远程设备 SSH 入口、远程截图工具、`AIRXDB_REMOTE_*` 配置方式或远端显示环境限制。 +- 引入了新的 GUI 自动化脚本、报告目录或运行前置条件。 + +内容保持可执行、可复用,不写流水账。 + +## ADR 维护 + +目录:`docs/architecture/adr/`。 + +需要 ADR 的情况: + +- 决定长期采用某种 Midscene 接入方式,例如 Playwright 集成、Bridge Mode、Computer、MCP。 +- GUI 调试方案改变了测试边界、前端交互契约、浏览器控制方式、桌面自动化权限模型。 +- 为了稳定复现而新增长期保留的自动化脚本、报告流程或辅助基础设施。 + +ADR 保持简洁:Context、Decision、Consequences、Alternatives。 + +## C4 Module 维护 + +文件:`docs/architecture/c4/module.md`。 + +当 GUI 调试或修复改变以下内容时,必须更新: + +- 前端模块边界 +- 自动化测试边界 +- 浏览器桥接/桌面控制边界 +- 报告和调试基础设施 +- UI 层和服务层之间的数据所有权或依赖 + +## GUI Debug Log 维护 + +文件:`AirPlan/docs/debug/gui-debug-log.md`。 + +每次 AirXDB 会话至少追加: + +- 问题摘要 +- 目标平台和界面 +- Midscene 模式(Playwright / Bridge / Computer / MCP) +- 是否使用远程设备 helper 以及远程目标、截图工具和限制 +- 复现步骤或命令 +- 报告文件路径 +- 截图或关键观察 +- 转交给 `airdbg` 的结论,或已完成的修复验证 +- 剩余风险 + +## 输出格式 + +本轮 GUI 调试结束时,用中文简洁汇报: + +- 选择了哪种 Midscene 模式,为什么。 +- 复现是否成功,证据在哪里。 +- 更新了哪些 `AGENTS.md` / ADR / C4 / GUI debug log。 +- 是否需要切给 `airdbg` 继续做代码层修复。 diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airxdb/agents/openai.yaml b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airxdb/agents/openai.yaml new file mode 100755 index 0000000..78f95a4 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airxdb/agents/openai.yaml @@ -0,0 +1,3 @@ +name: airxdb +short_description: Midscene GUI debug workflow with local and remote screenshot evidence +default_prompt: "使用 AirXDB 配合 AirDbg 做图形界面调试;远程设备优先调用 remote device helper 并自动配置截图工具。" diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airxdb/references/midscene-official-notes.md b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airxdb/references/midscene-official-notes.md new file mode 100755 index 0000000..ef1ac04 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/.agents/skills/airxdb/references/midscene-official-notes.md @@ -0,0 +1,159 @@ +# Midscene Official Notes + +用于 `airxdb` 的轻量参考,不替代官方文档。 + +## 选择模式 + +- Web + 现有 Playwright 项目: + - 优先 Midscene Playwright 集成 + - 适合浏览器页面复现、E2E、界面交互不稳定问题 +- Web + 需要复用本机 Chrome 的 cookies / 已登录状态 / 扩展: + - 使用 Chrome Bridge Mode +- 桌面应用 GUI: + - 使用 Midscene Computer 或 Playground +- 需要给上层 Agent / MCP 客户端暴露 GUI 操作: + - 浏览器用 Web Bridge MCP + - 桌面用 Computer MCP + +## 关键能力 + +- Midscene 的 UI 操作以纯视觉为主,可用于 Web、移动端、桌面端和 Canvas。 +- Midscene 支持生成 HTML 报告,适合作为 GUI debug 证据。 +- Midscene 可通过 MCP 暴露截图和动作空间操作。 + +## 首次模型配置 + +AirXDB 第一次进入项目时先检查这些变量: + +```bash +MIDSCENE_MODEL_NAME +MIDSCENE_MODEL_BASE_URL +MIDSCENE_MODEL_API_KEY +MIDSCENE_MODEL_FAMILY +MCP_SERVER_REQUEST_TIMEOUT +``` + +前三个是连接模型服务的基本配置。`MIDSCENE_MODEL_FAMILY` 是视觉语义动作必填项,`MCP_SERVER_REQUEST_TIMEOUT` 按模型服务情况补充。 + +常见 `MIDSCENE_MODEL_FAMILY`: + +- `gpt-5`:GPT-5.x 视觉模型,例如 `gpt-5.4` +- `qwen2.5-vl` +- `qwen3-vl` +- `gemini` +- `doubao-seed` +- `vlm-ui-tars` + +不要把真实 API key 写入 ADR、C4、debug log 或可提交文档。需要本机持久化时优先使用 `AirPlan/state/airxdb/midscene.local.env`。 + +## Chrome Bridge Mode + +- 官方说明: + - 需要 Midscene Chrome 插件 + - 终端侧配置模型环境变量 + - 适合复用本地浏览器登录态和页面状态 +- 常用依赖: + +```bash +npm install @midscene/web tsx --save-dev +``` + +- 常见入口: + +```ts +import { AgentOverChromeBridge } from "@midscene/web/bridge-mode"; +``` + +- 常见运行方式: + +```bash +tsx demo-new-tab.ts +``` + +- 常见模型环境变量: + +```bash +MIDSCENE_MODEL_BASE_URL +MIDSCENE_MODEL_API_KEY +MIDSCENE_MODEL_NAME +MIDSCENE_MODEL_FAMILY +``` + +## MCP + +- 浏览器桥接 MCP: + +```text +@midscene/web-bridge-mcp +``` + +- 桌面 MCP: + +```text +@midscene/computer-mcp +``` + +- Computer MCP 常见配置核心: + +```json +{ + "command": "npx", + "args": ["-y", "@midscene/computer-mcp"] +} +``` + +- 常见 MCP 模型环境变量: + +```bash +MIDSCENE_MODEL_BASE_URL +MIDSCENE_MODEL_API_KEY +MIDSCENE_MODEL_NAME +MIDSCENE_MODEL_FAMILY +MCP_SERVER_REQUEST_TIMEOUT +``` + +## 桌面自动化 + +- Midscene 支持 Windows、macOS、Linux 桌面自动化。 +- 桌面控制包括鼠标、键盘、截图、多显示器。 +- Linux 可在 Xvfb 下做无头执行。 +- Windows 下 `@midscene/computer-mcp` 的 npx 缓存包可能缺 `dist/screenCapture_1.3.2.bat` 和 `dist/app.manifest`。AirXDB smoke test 会从 `screenshot-desktop@1.15.3` npm 包自动补齐。 +- 桌面 GUI 调试可优先考虑: + - 快速试用:Playground + - 持续脚本化:Computer SDK / MCP + +## AirXDB Smoke Test + +```bash +python "$HOME/plugins/airxdb/scripts/airxdb_computer_mcp_smoke.py" --project . --action mousemove --prompt "Windows taskbar Start button" +``` + +输出: + +- `airxdb_smoke=ok`:Computer MCP 连接、截图、语义动作完成。 +- `airxdb_smoke=blocked`:缺模型配置或 family 不合法。 +- `asset_repair=repaired`:已补齐 Windows 截图脚本。 +- `report=`:JSON 报告,API key 已脱敏。 + +## AirXDB Screenshot Evidence + +截图取证不需要模型配置: + +```bash +python "$HOME/plugins/airxdb/scripts/airxdb_computer_mcp_smoke.py" --project . --action screenshot +``` + +用于: + +- 给 `airdbg` 提供 GUI 错误现场 +- 捕获弹窗、遮挡、焦点、布局错位、任务栏/托盘状态 +- 记录多显示器和当前桌面状态 + +截图可能包含敏感信息。写入 ADR/C4/debug log 时记录路径和观察,不复制密钥或隐私内容。 + +## GUI Debug 推荐策略 + +1. 先选模式,不要一上来混用多种接入。 +2. 先做最小复现,再考虑长期自动化。 +3. 保留 HTML 报告、截图和复现命令。 +4. 若问题已定位到代码层,把证据交给 `airdbg` 做修复。 diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/.claude-plugin/marketplace.json b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/.claude-plugin/marketplace.json new file mode 100755 index 0000000..c973b0f --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/.claude-plugin/marketplace.json @@ -0,0 +1,13 @@ +{ + "name": "aircontext-mkt", + "owner": { + "name": "AirContext" + }, + "plugins": [ + { + "name": "aircontext", + "source": "./", + "description": "Rule-driven, automated context compaction with auto-resume. Replaces Claude Code's auto-compact." + } + ] +} diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/.claude-plugin/plugin.json b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/.claude-plugin/plugin.json new file mode 100755 index 0000000..269b984 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/.claude-plugin/plugin.json @@ -0,0 +1,53 @@ +{ + "name": "aircontext", + "version": "0.1.0", + "description": "User-controlled periodic context compaction with auto-resume. Replaces Claude Code's auto-compact with rule-driven external LLM compression.", + "author": { + "name": "AirContext" + }, + "keywords": ["context", "compaction", "automation"], + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python ${CLAUDE_PLUGIN_ROOT}/scripts/on_session_start.py" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python ${CLAUDE_PLUGIN_ROOT}/scripts/on_user_prompt.py" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "python ${CLAUDE_PLUGIN_ROOT}/scripts/on_tool_use.py" + } + ] + } + ], + "PreCompact": [ + { + "matcher": "auto", + "hooks": [ + { + "type": "command", + "command": "python ${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.py" + } + ] + } + ] + } +} diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/README.md b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/README.md new file mode 100755 index 0000000..5a74a2a --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/README.md @@ -0,0 +1,94 @@ +# AirContext + +A Claude Code plugin that **replaces auto-compact with rule-driven, automated, externally-summarised compaction**, then **auto-resumes** the session so long-running agent loops never break. + +## Why + +Claude Code's built-in auto-compact triggers on context pressure and uses a generic strategy. In a large project, frequent generic compaction degrades subsequent generation quality. AirContext lets you: + +1. Disable Claude's auto-compact (via PreCompact hook). +2. Trigger compaction on **your** schedule (token-ratio threshold + cooldown). +3. Run compaction in **your** LLM (any OpenAI-compatible endpoint — DeepSeek, Ollama, vLLM, LM Studio, etc.) using **your** rules (`AirContext/rules.md`). +4. Apply the result by appending an isolated summary chain (`parentUuid: null`) to the session JSONL, then automatically restart `claude --resume ` and inject a continuation prompt so an in-flight agent loop picks back up unattended. + +## How it works + +``` +$ aircontext # wrapper around `claude` + │ + ▼ + (loops) ← spawns claude → user works as normal + │ + │ PostToolUse hook (every tool call): + │ • estimate active-chain tokens + │ • if > threshold and cooldown elapsed: + │ fork compactor.py (background, non-blocking) + │ + │ compactor.py: + │ • read JSONL → render head as plain text + │ • call LLM with rules.md as system prompt + │ • backup JSONL → snapshots/-.jsonl + │ • append [summary, continuation] with parentUuid=null + │ • set state.compaction_ready = true + │ + ◄──────────┘ + wrapper watcher sees ready → SIGTERM claude → spawn `claude --resume ` + │ + ▼ + new claude loads JSONL: latest leaf is the continuation prompt → auto-replies + the in-flight task continues with ~10× smaller context. +``` + +## Install + +```bash +# from the marketplace once published +/plugin install aircontext@ + +# or directly via settings.json +{ + "extraKnownMarketplaces": { + "aircontext-mkt": { "source": { "source": "github", "repo": "/aircontext-plugin" } } + }, + "enabledPlugins": { "aircontext@aircontext-mkt": true } +} +``` + +Requires Python ≥ 3.10 and `pyyaml`. The wrapper assumes `claude` is on PATH. + +## Use + +```bash +cd +aircontext # instead of `claude` +``` + +First run creates `/AirContext/` with `config.yaml`, `rules.md`, and a per-project README. Edit `config.yaml` (especially `backend.api_key`), then re-run. + +## Per-project files (`AirContext/`) + +| File | Purpose | +| ----------------- | ------------------------------------------------------------- | +| `config.yaml` | Backend, trigger threshold, cooldown, continuation prompt | +| `rules.md` | What to keep / drop — sent to LLM as system prompt | +| `state.json` | Runtime state (managed by plugin, do not edit by hand) | +| `snapshots/` | JSONL backup before each compaction (rotate via `max_snapshots`) | + +## Slash commands + +| Command | Purpose | +| ---------------------- | -------------------------------------------------------- | +| `/aircontext-init` | (Re)install templates into the current project | +| `/aircontext-now` | Force a compaction immediately (bypasses cooldown) | +| `/aircontext-status` | Show config, last compaction, snapshot count | +| `/aircontext-pause` | Toggle (or `on`/`off`) automatic compaction | + +## Caveats + +- **You must launch via `aircontext`, not `claude`, for auto-resume to work.** Without the wrapper, the compactor still prepares the snapshot but you must `claude --resume ` manually for it to take effect. +- The JSONL transcript format is **not a stable public API**. AirContext logs the observed `version` field; if it sees an unfamiliar version, it warns and you may want to enable `safety.dry_run: true` until you've verified compatibility on your side. +- A small fixed cost (system prompt, CLAUDE.md, tool definitions, skills) is reloaded into context every session — this is a Claude Code property, not something AirContext can shrink. + +## License + +MIT diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/bin/aircontext b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/bin/aircontext new file mode 100755 index 0000000..6377ab5 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/bin/aircontext @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +aircontext — wrapper around `claude` providing automatic context compaction loop. + +Usage: + aircontext [args passed through to claude] + +Behaviour: + 1. Verify ./AirContext/ exists and config.yaml is valid (create from templates if missing). + 2. Spawn `claude` as a child process (stdio inherited so UX matches running claude directly). + 3. A watcher thread polls AirContext/state.json; when compaction_ready=true, it: + - terminates the running claude gracefully + - re-spawns `claude --resume ` so the new isolated-summary chain takes effect + 4. Loop exits when the user closes claude without a pending compaction. +""" +from __future__ import annotations +import os +import sys +import signal +import subprocess +import threading +import time +from pathlib import Path + +# Ensure plugin's scripts/ is importable regardless of how the wrapper is invoked. +_PLUGIN_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_PLUGIN_ROOT / "scripts")) + +from core.auto_init import ( # noqa: E402 + auto_configure, + propagate_settings_env, + required_fields_missing, +) +from core.config_loader import ensure_aircontext_dir, validate_config # noqa: E402 +from core.state import StateFile # noqa: E402 + + +WATCH_INTERVAL_SECONDS = 2.0 +TERMINATE_TIMEOUT_SECONDS = 10 + + +def terminate_gracefully(proc: subprocess.Popen) -> None: + """Send a platform-appropriate stop signal, then SIGKILL after timeout.""" + try: + if os.name == "nt": + proc.send_signal(signal.CTRL_BREAK_EVENT) + else: + proc.send_signal(signal.SIGTERM) + except (ProcessLookupError, OSError): + return + try: + proc.wait(timeout=TERMINATE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + proc.kill() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + + +def spawn_claude(resume_id: str | None, passthrough_args: list[str]) -> subprocess.Popen: + cmd = ["claude"] + if resume_id: + cmd += ["--resume", resume_id] + cmd += passthrough_args + env = {**os.environ, "AIRCONTEXT_ACTIVE": "1"} + creationflags = 0 + if os.name == "nt": + creationflags = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + return subprocess.Popen(cmd, env=env, creationflags=creationflags) + + +def main() -> int: + project_root = Path.cwd() + air = project_root / "AirContext" + + # Make ~/.claude/settings.json `env` block visible in os.environ so the + # validate_config call below resolves ${env:VAR} placeholders the same way + # claude/hooks/compactor will at runtime. + propagate_settings_env() + + created = ensure_aircontext_dir(air, plugin_root=_PLUGIN_ROOT) + config_path = air / "config.yaml" + if created: + cfg = auto_configure(config_path) + window = cfg.get("trigger", {}).get("model_context_window", 200000) + print( + f"[aircontext] AirContext/ initialised at {air}", + file=sys.stderr, + ) + print( + f"[aircontext] Auto-configured: model_context_window={window}, " + f"backend.type={cfg.get('backend', {}).get('type')}", + file=sys.stderr, + ) + + missing = required_fields_missing(config_path) + if missing: + print( + "[aircontext] Please fill these REQUIRED fields in " + f"{config_path}:", + file=sys.stderr, + ) + for m in missing: + print(f"[aircontext] - {m}", file=sys.stderr) + print( + "[aircontext] Then re-run `aircontext` and you'll go straight into claude.", + file=sys.stderr, + ) + return 1 + + err = validate_config(config_path) + if err: + print(f"[aircontext] Invalid config: {err}", file=sys.stderr) + return 1 + + state = StateFile(air / "state.json") + state.reset_for_new_session() + + passthrough = sys.argv[1:] + resume_id = state.last_session_id + + while True: + proc = spawn_claude(resume_id, passthrough) + pending_resume_id: list[str | None] = [None] + stop_watcher = threading.Event() + + def watcher() -> None: + while not stop_watcher.is_set(): + snap = state.read() + if snap.get("compaction_ready"): + rid = snap.get("pending_resume_session_id") + if rid: + pending_resume_id[0] = rid + terminate_gracefully(proc) + return + if stop_watcher.wait(WATCH_INTERVAL_SECONDS): + return + + t = threading.Thread(target=watcher, daemon=True) + t.start() + try: + proc.wait() + except KeyboardInterrupt: + terminate_gracefully(proc) + finally: + stop_watcher.set() + t.join(timeout=3) + + if pending_resume_id[0]: + resume_id = pending_resume_id[0] + state.clear_ready() + print( + f"[aircontext] Compaction applied, resuming session {resume_id}", + file=sys.stderr, + ) + continue + + # User exited without a pending compaction — finish. + return proc.returncode or 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/bin/aircontext.cmd b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/bin/aircontext.cmd new file mode 100755 index 0000000..fedb53e --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/bin/aircontext.cmd @@ -0,0 +1,4 @@ +@echo off +REM Windows launcher for the AirContext wrapper. +REM Forwards all args to the Python script next to this file. +python "%~dp0aircontext" %* diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/commands/aircontext-init.md b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/commands/aircontext-init.md new file mode 100755 index 0000000..ed8beb4 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/commands/aircontext-init.md @@ -0,0 +1,12 @@ +--- +description: Install AirContext templates into the current project +allowed-tools: Bash +--- + +Run the initialiser to create or repair `AirContext/` in the current project root. +After it completes, tell the user to edit `AirContext/config.yaml` (specifically +`backend.api_key` and `backend.endpoint`) before relying on automatic compaction. + +```! +python "${CLAUDE_PLUGIN_ROOT}/scripts/cmd_init.py" +``` diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/commands/aircontext-now.md b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/commands/aircontext-now.md new file mode 100755 index 0000000..7d1dd52 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/commands/aircontext-now.md @@ -0,0 +1,14 @@ +--- +description: Force an immediate compaction (ignores cooldown and threshold) +allowed-tools: Bash +--- + +Spawn the compactor in the foreground for the current session and report the +outcome. After it succeeds, the wrapper will detect `compaction_ready` within +a few seconds and restart claude with `--resume`. If you launched claude +directly without the `aircontext` wrapper, you must exit and run +`claude --resume ` yourself for the new chain to take effect. + +```! +python "${CLAUDE_PLUGIN_ROOT}/scripts/cmd_now.py" --session-id "${CLAUDE_SESSION_ID}" +``` diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/commands/aircontext-pause.md b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/commands/aircontext-pause.md new file mode 100755 index 0000000..30ef2be --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/commands/aircontext-pause.md @@ -0,0 +1,13 @@ +--- +description: Pause or resume AirContext automatic compaction (toggle) +argument-hint: "[on|off]" +allowed-tools: Bash +--- + +Toggle (or explicitly set) the automatic-compaction switch. While paused, the +PostToolUse ticker still runs but skips firing the compactor. Manual +`/aircontext-now` is unaffected. + +```! +python "${CLAUDE_PLUGIN_ROOT}/scripts/cmd_pause.py" $ARGUMENTS +``` diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/commands/aircontext-status.md b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/commands/aircontext-status.md new file mode 100755 index 0000000..12df1ec --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/commands/aircontext-status.md @@ -0,0 +1,8 @@ +--- +description: Show current AirContext state, last compaction, and pending actions +allowed-tools: Bash +--- + +```! +python "${CLAUDE_PLUGIN_ROOT}/scripts/cmd_status.py" +``` diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/pyproject.toml b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/pyproject.toml new file mode 100755 index 0000000..8f64bbc --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "aircontext-plugin" +version = "0.1.0" +description = "Claude Code plugin for rule-driven, automated context compaction with auto-resume." +requires-python = ">=3.10" +dependencies = [ + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0"] diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/cmd_init.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/cmd_init.py new file mode 100755 index 0000000..8bf8a76 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/cmd_init.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Backend for /aircontext-init.""" +from __future__ import annotations +import os +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from core.config_loader import ensure_aircontext_dir, validate_config # noqa: E402 + + +def main() -> int: + plugin_root = Path(os.environ.get("CLAUDE_PLUGIN_ROOT", _HERE.parent)) + project = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())) + air = project / "AirContext" + created = ensure_aircontext_dir(air, plugin_root=plugin_root) + print(f"AirContext directory: {air}") + print("Templates installed." if created else "Templates already present (no overwrite).") + err = validate_config(air / "config.yaml") + if err: + print(f"Config status: needs attention — {err}") + else: + print("Config status: OK (compaction will activate on next session).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/cmd_now.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/cmd_now.py new file mode 100755 index 0000000..817c9d4 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/cmd_now.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Backend for /aircontext-now — force a compaction synchronously.""" +from __future__ import annotations +import argparse +import json +import os +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from core.compactor import run as run_compactor # noqa: E402 +from core.state import StateFile # noqa: E402 + + +def _find_transcript(project: Path, session_id: str) -> Path | None: + """Locate the JSONL transcript for the given session. + + Claude Code stores transcripts under + ~/.claude/projects//sessions/.jsonl + The cwd encoding replaces path separators with `-`. We search defensively. + """ + home = Path.home() / ".claude" / "projects" + if not home.exists(): + return None + encoded = str(project).replace(os.sep, "-").replace(":", "") + # Try direct match first + candidate = home / encoded / "sessions" / f"{session_id}.jsonl" + if candidate.exists(): + return candidate + # Fall back: scan for the session id under any project directory + for p in home.glob(f"*/sessions/{session_id}.jsonl"): + return p + return None + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--session-id", required=True) + args = p.parse_args() + + project = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())) + air = project / "AirContext" + if not (air / "config.yaml").exists(): + print("AirContext not initialised. Run /aircontext-init first.") + return 1 + + transcript = _find_transcript(project, args.session_id) + if not transcript: + print(f"Could not locate transcript for session {args.session_id}.") + return 1 + + state = StateFile(air / "state.json") + # Force one-shot regardless of cooldown + state.update(last_compaction_unix=0) + print(f"Compacting transcript: {transcript}") + rc = run_compactor(project, transcript, args.session_id) + snap = state.read() + if rc == 0 and snap.get("compaction_ready"): + print("Compaction prepared. The wrapper will restart claude shortly.") + print("If you launched claude directly (without `aircontext`), exit and " + f"run: claude --resume {args.session_id}") + elif rc == 0: + print("Compactor returned success but no compaction was applied " + "(see compactor.log for reason — likely chain too short or paused).") + else: + print(f"Compactor failed with exit code {rc}. See AirContext/snapshots/compactor.log") + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/cmd_pause.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/cmd_pause.py new file mode 100755 index 0000000..d603e21 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/cmd_pause.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Backend for /aircontext-pause [on|off].""" +from __future__ import annotations +import os +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from core.state import StateFile # noqa: E402 + + +def main() -> int: + project = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())) + air = project / "AirContext" + if not air.exists(): + print("AirContext not initialised. Run /aircontext-init first.") + return 1 + + state = StateFile(air / "state.json") + arg = (sys.argv[1].lower() if len(sys.argv) > 1 else "").strip() + + cur = bool(state.read().get("paused")) + if arg in ("on", "pause", "true", "1"): + new = True + elif arg in ("off", "resume", "false", "0"): + new = False + else: + new = not cur # toggle + + state.update(paused=new) + print(f"AirContext automatic compaction: {'PAUSED' if new else 'ACTIVE'}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/cmd_status.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/cmd_status.py new file mode 100755 index 0000000..db88bde --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/cmd_status.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Backend for /aircontext-status.""" +from __future__ import annotations +import json +import os +import sys +import time +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from core.config_loader import load_config, validate_config # noqa: E402 +from core.state import StateFile # noqa: E402 + + +def main() -> int: + project = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())) + air = project / "AirContext" + cfg_path = air / "config.yaml" + print(f"Project root: {project}") + print(f"AirContext dir: {air} {'(present)' if air.exists() else '(MISSING)'}") + if not cfg_path.exists(): + print("Status: not initialised. Run /aircontext-init.") + return 0 + err = validate_config(cfg_path) + if err: + print(f"Config: invalid — {err}") + else: + cfg = load_config(cfg_path) + b = cfg.get("backend", {}) + t = cfg.get("trigger", {}) + print(f"Backend: {b.get('endpoint')} (model={b.get('model')})") + print( + f"Trigger: strategy={t.get('strategy')} " + f"threshold={t.get('threshold')} window={t.get('model_context_window')}" + ) + + state = StateFile(air / "state.json").read() + print(f"Paused: {state.get('paused')}") + print(f"Compacting now: {state.get('compaction_in_progress')}") + print(f"Ready to apply: {state.get('compaction_ready')}") + last = state.get("last_compaction_unix") or 0 + if last: + ago = int(time.time() - last) + print(f"Last compaction: {ago}s ago") + else: + print("Last compaction: never") + snapshots = air / "snapshots" + if snapshots.exists(): + files = [p for p in snapshots.iterdir() if p.suffix == ".jsonl"] + print(f"Snapshots: {len(files)} stored under {snapshots}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/__init__.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/auto_init.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/auto_init.py new file mode 100755 index 0000000..4fd54b7 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/auto_init.py @@ -0,0 +1,149 @@ +"""Auto-fill non-private fields when AirContext/ is freshly created. + +Privacy boundary: + - backend.api_key and backend.endpoint are left for the USER to fill. + - Everything else (model_context_window, threshold, cooldown, compaction + rules, etc.) is auto-configured here using the host machine's environment. + +Inputs we read: + - ~/.claude/settings.json (specifically `model` and `env` sections) + - os.environ (overrides settings.json env when both set) + +Outputs: + - Mutated AirContext/config.yaml on disk + - Side-effect: settings.json's `env` keys are copied into os.environ so the + wrapper's own validate_config call resolves ${env:VAR} placeholders + consistently with what claude (and thus its hooks/compactor) will see. +""" +from __future__ import annotations +import json +import os +from pathlib import Path +from typing import Any + +try: + import yaml # type: ignore +except ImportError: # pragma: no cover — pyyaml is a hard dep declared in pyproject + yaml = None + + +# Approximate context windows for known Claude model IDs / aliases. +_MODEL_WINDOWS = { + "opus[1m]": 1_000_000, + "claude-opus-4-7[1m]": 1_000_000, + "claude-opus-4-7": 200_000, + "claude-sonnet-4-6": 200_000, + "claude-sonnet-4-5": 200_000, + "claude-haiku-4-5": 200_000, + "claude-haiku-4-5-20251001": 200_000, +} + + +def _read_user_settings() -> dict: + p = Path.home() / ".claude" / "settings.json" + if not p.exists(): + return {} + try: + return json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + + +def propagate_settings_env() -> dict[str, str]: + """Copy ~/.claude/settings.json `env` block into os.environ if not already set. + + Claude Code injects these into its own subprocess environment, but the + wrapper itself is launched from the user's shell and doesn't inherit them. + Without this propagation, the wrapper's validate_config sees empty + ${env:ANTHROPIC_AUTH_TOKEN} and bails out, even though the env var WILL be + set when the compactor actually runs (since it's a child of claude). + + Returns the env dict that was applied. + """ + settings = _read_user_settings() + env_block = settings.get("env") or {} + if not isinstance(env_block, dict): + return {} + for k, v in env_block.items(): + if isinstance(v, str): + os.environ.setdefault(k, v) + return env_block + + +def detect_claude_context_window() -> int | None: + """Infer the active claude model's context window from settings.json.""" + settings = _read_user_settings() + model = settings.get("model") + if not isinstance(model, str): + return None + if model in _MODEL_WINDOWS: + return _MODEL_WINDOWS[model] + if "[1m]" in model.lower(): + return 1_000_000 + return 200_000 # safe default for any modern Claude model + + +def auto_configure(config_path: Path) -> dict[str, Any]: + """Fill non-private fields in the freshly-created config. + + Returns the merged config dict (also written back to disk). + Raises RuntimeError if PyYAML is missing. + """ + if yaml is None: + raise RuntimeError("PyYAML required (pip install pyyaml)") + + propagate_settings_env() + + cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + cfg.setdefault("backend", {}) + cfg.setdefault("trigger", {}) + cfg.setdefault("compaction", {}) + cfg.setdefault("safety", {}) + + window = detect_claude_context_window() + if window: + cfg["trigger"]["model_context_window"] = window + + # If user has ANTHROPIC_AUTH_TOKEN in settings.json/env, prefer + # anthropic_native as the default backend type (most likely match). + # api_key/endpoint themselves stay user-fillable. + has_anthropic = bool( + os.environ.get("ANTHROPIC_AUTH_TOKEN") + or os.environ.get("ANTHROPIC_API_KEY") + ) + has_openai = bool(os.environ.get("OPENAI_API_KEY")) + if has_anthropic and not has_openai: + cfg["backend"].setdefault("type", "anthropic_native") + elif has_openai and not has_anthropic: + cfg["backend"].setdefault("type", "openai_compat") + # Otherwise leave whatever the template specified. + + config_path.write_text( + yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False), + encoding="utf-8", + ) + return cfg + + +def required_fields_missing(config_path: Path) -> list[str]: + """Return the names of REQUIRED-USER-FILL fields that are still empty. + + These are the privacy-boundary fields: endpoint and api_key. Any + auto-configurable field is NOT in this list. + """ + if yaml is None: + return ["[pyyaml not installed]"] + cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + backend = cfg.get("backend") or {} + missing: list[str] = [] + endpoint = (backend.get("endpoint") or "").strip() + if not endpoint: + missing.append("backend.endpoint") + + api_key_raw = backend.get("api_key") or "" + # api_key may be a ${env:VAR} placeholder. Resolve via current env. + from .config_loader import resolve_env_placeholders + resolved = resolve_env_placeholders(api_key_raw) + if isinstance(resolved, str) and not resolved.strip(): + missing.append("backend.api_key") + return missing diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/backends/__init__.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/backends/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/backends/anthropic_native.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/backends/anthropic_native.py new file mode 100755 index 0000000..5446b1c --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/backends/anthropic_native.py @@ -0,0 +1,91 @@ +"""Anthropic-native /v1/messages backend. + +For endpoints that speak the Anthropic Messages API (api.anthropic.com or any +proxy compatible with it). Uses x-api-key + anthropic-version headers. +""" +from __future__ import annotations +import json +import urllib.error +import urllib.request +from dataclasses import dataclass + + +DEFAULT_ANTHROPIC_VERSION = "2023-06-01" + + +@dataclass +class AnthropicNativeBackend: + cfg: dict + + @property + def endpoint(self) -> str: + return self.cfg["endpoint"].rstrip("/") + + @property + def model(self) -> str: + return self.cfg["model"] + + @property + def api_key(self) -> str: + return self.cfg.get("api_key") or "missing-key" + + @property + def max_output_tokens(self) -> int: + return int(self.cfg.get("max_output_tokens", 4000)) + + @property + def timeout(self) -> int: + return int(self.cfg.get("timeout_seconds", 60)) + + @property + def anthropic_version(self) -> str: + return self.cfg.get("anthropic_version", DEFAULT_ANTHROPIC_VERSION) + + def summarise(self, system_prompt: str, conversation_text: str) -> str: + url = f"{self.endpoint}/v1/messages" + body = { + "model": self.model, + "max_tokens": self.max_output_tokens, + "temperature": 0.2, + "system": system_prompt, + "messages": [ + {"role": "user", "content": conversation_text}, + ], + } + data = json.dumps(body).encode("utf-8") + headers = { + "Content-Type": "application/json", + "x-api-key": self.api_key, + "anthropic-version": self.anthropic_version, + } + # Some proxies (e.g. wolfai.top) accept Bearer tokens too — send both + # so we work whether the upstream wants x-api-key or Authorization. + if self.api_key.startswith("sk-"): + headers["Authorization"] = f"Bearer {self.api_key}" + + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace")[:500] + raise RuntimeError(f"LLM HTTP {e.code}: {detail}") from e + except urllib.error.URLError as e: + raise RuntimeError(f"LLM connection failed: {e.reason}") from e + + # Anthropic format: {"content": [{"type":"text","text":"..."}], ...} + content = payload.get("content") + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + text = block.get("text", "") + if text.strip(): + return text.strip() + # Some proxies pass through OpenAI shape — fall back gracefully. + choices = payload.get("choices") or [] + if choices: + msg = choices[0].get("message") or {} + content = msg.get("content") + if isinstance(content, str) and content.strip(): + return content.strip() + raise RuntimeError(f"LLM returned unexpected payload shape: {payload}") diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/backends/base.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/backends/base.py new file mode 100755 index 0000000..125d1ab --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/backends/base.py @@ -0,0 +1,19 @@ +"""Backend interface used by compactor.py.""" +from __future__ import annotations +from typing import Protocol + + +class CompressionBackend(Protocol): + def summarise(self, system_prompt: str, conversation_text: str) -> str: ... + + +def build_backend(cfg: dict) -> CompressionBackend: + backend_cfg = cfg.get("backend") or {} + btype = backend_cfg.get("type", "openai_compat") + if btype == "openai_compat": + from .openai_compat import OpenAICompatibleBackend + return OpenAICompatibleBackend(backend_cfg) + if btype == "anthropic_native": + from .anthropic_native import AnthropicNativeBackend + return AnthropicNativeBackend(backend_cfg) + raise ValueError(f"Unsupported backend.type: {btype!r}") diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/backends/openai_compat.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/backends/openai_compat.py new file mode 100755 index 0000000..60d10ce --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/backends/openai_compat.py @@ -0,0 +1,77 @@ +"""OpenAI-compatible chat-completions backend. + +Covers OpenAI / Azure OpenAI / DeepSeek / Moonshot / Qwen-cloud / Ollama / +vLLM / LM Studio — anything that exposes `POST {endpoint}/chat/completions`. + +We avoid the openai SDK to keep the plugin's dependency surface to stdlib + +pyyaml. Uses urllib so even environments without `requests` work. +""" +from __future__ import annotations +import json +import urllib.error +import urllib.request +from dataclasses import dataclass + + +@dataclass +class OpenAICompatibleBackend: + cfg: dict + + @property + def endpoint(self) -> str: + return self.cfg["endpoint"].rstrip("/") + + @property + def model(self) -> str: + return self.cfg["model"] + + @property + def api_key(self) -> str: + return self.cfg.get("api_key") or "missing-key" + + @property + def max_output_tokens(self) -> int: + return int(self.cfg.get("max_output_tokens", 4000)) + + @property + def timeout(self) -> int: + return int(self.cfg.get("timeout_seconds", 60)) + + def summarise(self, system_prompt: str, conversation_text: str) -> str: + url = f"{self.endpoint}/chat/completions" + body = { + "model": self.model, + "max_tokens": self.max_output_tokens, + "temperature": 0.2, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": conversation_text}, + ], + } + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace")[:500] + raise RuntimeError(f"LLM HTTP {e.code}: {detail}") from e + except urllib.error.URLError as e: + raise RuntimeError(f"LLM connection failed: {e.reason}") from e + + choices = payload.get("choices") or [] + if not choices: + raise RuntimeError(f"LLM returned no choices: {payload}") + msg = choices[0].get("message") or {} + content = msg.get("content") + if not isinstance(content, str) or not content.strip(): + raise RuntimeError(f"LLM returned empty content: {payload}") + return content.strip() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/compactor.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/compactor.py new file mode 100755 index 0000000..b3422ac --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/compactor.py @@ -0,0 +1,260 @@ +"""Compactor — runs out-of-band, mutates JSONL, signals wrapper to resume. + +Invocation (by on_tool_use.py or /aircontext-now): + python compactor.py --project --transcript --session-id + +Lifecycle: + 1. Acquire single-instance lock (compactor.lock). + 2. Mark state.compaction_in_progress=true (already set by hook, idempotent). + 3. Load active chain. If too short, abort. + 4. Backup JSONL. + 5. Slice tail (preserved) vs head (to compress). + 6. Build conversation text + system prompt (rules.md). + 7. Call LLM backend. + 8. Append [summary, continuation] messages with parentUuid=null chain head. + 9. Write state.compaction_ready=true so wrapper restarts claude. +""" +from __future__ import annotations +import argparse +import json +import os +import sys +import time +from pathlib import Path +from typing import Any + +_HERE = Path(__file__).resolve().parent.parent +if str(_HERE) not in sys.path: + sys.path.insert(0, str(_HERE)) + +from core.backends.base import build_backend # noqa: E402 +from core.config_loader import load_config # noqa: E402 +from core.jsonl_ops import ( # noqa: E402 + append_isolated_summary, + backup_jsonl, + find_active_chain, + load_messages, + rotate_snapshots, +) +from core.state import StateFile # noqa: E402 + + +MIN_CHAIN_TO_COMPACT = 6 + + +def _serialise_message_for_llm(msg: dict[str, Any]) -> str | None: + """Reduce a JSONL message to plain text the LLM can read. + + Returns None for messages that should be skipped entirely + (file-history-snapshot, queue-operation, etc.). + """ + t = msg.get("type") + inner = msg.get("message") or {} + if t == "user": + content = inner.get("content") + if isinstance(content, str): + return f"USER: {content}" + if isinstance(content, list): + parts = [p.get("text", "") if isinstance(p, dict) else str(p) for p in content] + return "USER: " + " ".join(p for p in parts if p) + if t == "assistant": + content = inner.get("content") + if isinstance(content, str): + return f"ASSISTANT: {content}" + if isinstance(content, list): + parts: list[str] = [] + for p in content: + if not isinstance(p, dict): + continue + if p.get("type") == "text": + parts.append(p.get("text", "")) + elif p.get("type") == "tool_use": + parts.append( + f"[tool_use {p.get('name')}({json.dumps(p.get('input', {}), ensure_ascii=False)[:300]})]" + ) + elif p.get("type") == "thinking": + pass # drop + return "ASSISTANT: " + " ".join(s for s in parts if s) + if t == "tool_result": + # `message.content` for tool_result is the result text + content = inner.get("content") if isinstance(inner, dict) else msg.get("content") + if isinstance(content, list): + content = " ".join( + (p.get("text", "") if isinstance(p, dict) else str(p)) for p in content + ) + if not isinstance(content, str): + return None + return f"TOOL_RESULT: {content}" + if t in ("system",): + content = inner.get("content") + if isinstance(content, str): + return f"SYSTEM: {content}" + return None + + +def _truncate_long_tool_results(rendered: list[str], max_lines: int) -> list[str]: + out: list[str] = [] + for r in rendered: + if not r.startswith("TOOL_RESULT: "): + out.append(r) + continue + body = r[len("TOOL_RESULT: "):] + lines = body.splitlines() + if len(lines) <= max_lines: + out.append(r) + continue + head = "\n".join(lines[:50]) + tail = "\n".join(lines[-50:]) + out.append( + "TOOL_RESULT: " + + head + + f"\n... [{len(lines) - 100} lines omitted by AirContext pre-truncation] ...\n" + + tail + ) + return out + + +def run(project: Path, transcript: Path, session_id: str) -> int: + air = project / "AirContext" + cfg_path = air / "config.yaml" + state = StateFile(air / "state.json") + + try: + cfg = load_config(cfg_path) + except Exception as e: + print(f"[compactor] config load failed: {e}", file=sys.stderr) + state.update(compaction_in_progress=False) + return 2 + + if state.read().get("paused"): + state.update(compaction_in_progress=False) + return 0 + + # Single-instance lock (best-effort; sufficient for single-user dev environment). + lock = air / "compactor.lock" + try: + fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(os.getpid()).encode()) + os.close(fd) + except FileExistsError: + print("[compactor] another compactor is already running; exiting", file=sys.stderr) + return 0 + + try: + return _run_locked(project, transcript, session_id, cfg, state, air) + finally: + try: + os.unlink(lock) + except OSError: + pass + + +def _run_locked( + project: Path, + transcript: Path, + session_id: str, + cfg: dict, + state: StateFile, + air: Path, +) -> int: + state.update(compaction_in_progress=True, last_session_id=session_id) + msgs = load_messages(transcript) + chain = find_active_chain(msgs) + if len(chain) < MIN_CHAIN_TO_COMPACT: + print( + f"[compactor] chain too short ({len(chain)}); skipping", + file=sys.stderr, + ) + state.update(compaction_in_progress=False) + return 0 + + comp_cfg = cfg.get("compaction") or {} + safety = cfg.get("safety") or {} + preserve_tail = int(comp_cfg.get("preserve_tail_messages", 10)) + drop_lines = int(comp_cfg.get("drop_tool_results_over_lines", 1000)) + rules_file = comp_cfg.get("rules_file", "rules.md") + continuation_prompt = comp_cfg.get("continuation_prompt", "") or "" + + rules_path = air / rules_file + rules_text = rules_path.read_text(encoding="utf-8") if rules_path.exists() else "" + + head = chain[:-preserve_tail] if preserve_tail > 0 else chain + tail = chain[-preserve_tail:] if preserve_tail > 0 else [] + + rendered = [r for r in (_serialise_message_for_llm(m) for m in head) if r] + rendered = _truncate_long_tool_results(rendered, drop_lines) + conversation_text = "\n\n".join(rendered) + if not conversation_text.strip(): + print("[compactor] nothing to summarise", file=sys.stderr) + state.update(compaction_in_progress=False) + return 0 + + system_prompt = ( + "You are a context-compression engine for a Claude Code session. " + "Apply the user-supplied compression rules below to the conversation " + "transcript that follows. Output ONLY the compressed summary text. " + "Do not add preamble, headers, or apologies.\n\n" + "=== USER COMPRESSION RULES ===\n" + f"{rules_text}\n" + "=== END RULES ===" + ) + + backend = build_backend(cfg) + print( + f"[compactor] calling {cfg['backend']['endpoint']} model={cfg['backend']['model']} " + f"head_msgs={len(head)} tail_preserved={len(tail)}", + file=sys.stderr, + ) + try: + summary_text = backend.summarise(system_prompt, conversation_text) + except Exception as e: + print(f"[compactor] LLM call failed: {e}", file=sys.stderr) + state.update(compaction_in_progress=False) + return 3 + + if safety.get("dry_run"): + print( + f"[compactor] dry_run=true; summary length={len(summary_text)}; " + "JSONL not modified", + file=sys.stderr, + ) + state.update(compaction_in_progress=False, last_compaction_unix=int(time.time())) + return 0 + + if safety.get("backup", True): + backup_jsonl(transcript, air / "snapshots", session_id) + + template = chain[-1] # use the latest message's metadata as template + summary_uuid, cont_uuid = append_isolated_summary( + transcript, + summary_text=summary_text, + continuation_text=continuation_prompt or None, + template_message=template, + ) + print( + f"[compactor] appended summary={summary_uuid} continuation={cont_uuid}", + file=sys.stderr, + ) + + rotate_snapshots(air / "snapshots", int(safety.get("max_snapshots", 50))) + + state.update( + compaction_in_progress=False, + compaction_ready=True, + pending_resume_session_id=session_id, + last_compaction_unix=int(time.time()), + ) + return 0 + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--project", required=True) + p.add_argument("--transcript", required=True) + p.add_argument("--session-id", required=True) + args = p.parse_args() + return run(Path(args.project), Path(args.transcript), args.session_id) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/config_loader.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/config_loader.py new file mode 100755 index 0000000..1e5fc27 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/config_loader.py @@ -0,0 +1,97 @@ +"""AirContext/ self-check + template installation + config validation.""" +from __future__ import annotations +import os +import re +import shutil +from pathlib import Path +from typing import Any + +try: + import yaml # type: ignore +except ImportError: + yaml = None # validate_config will surface a clear error + + +REQUIRED_FILES = ("config.yaml", "rules.md") + + +def ensure_aircontext_dir(air: Path, plugin_root: Path) -> bool: + """Create AirContext/ from templates if missing. + + Returns True if templates were just installed (caller should ask user to + edit config.yaml). Returns False if the dir already existed. + """ + templates = plugin_root / "templates" + if air.exists() and (air / "config.yaml").exists(): + # Make sure subdirs exist even on partial installs. + (air / "snapshots").mkdir(exist_ok=True) + return False + + air.mkdir(parents=True, exist_ok=True) + (air / "snapshots").mkdir(exist_ok=True) + for name in REQUIRED_FILES: + src = templates / name + dst = air / name + if not dst.exists() and src.exists(): + shutil.copyfile(src, dst) + # README is optional but nice + readme_src = templates / "README.md" + readme_dst = air / "README.md" + if not readme_dst.exists() and readme_src.exists(): + shutil.copyfile(readme_src, readme_dst) + return True + + +_ENV_RE = re.compile(r"\$\{env:([A-Z_][A-Z0-9_]*)\}") + + +def resolve_env_placeholders(value: Any) -> Any: + """Replace ${env:VAR} placeholders inside string values, recursively.""" + if isinstance(value, str): + def _sub(m: re.Match[str]) -> str: + return os.environ.get(m.group(1), "") + return _ENV_RE.sub(_sub, value) + if isinstance(value, dict): + return {k: resolve_env_placeholders(v) for k, v in value.items()} + if isinstance(value, list): + return [resolve_env_placeholders(v) for v in value] + return value + + +def load_config(path: Path) -> dict[str, Any]: + if yaml is None: + raise RuntimeError("PyYAML not installed. `pip install pyyaml` and retry.") + with path.open("r", encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + return resolve_env_placeholders(raw) + + +def validate_config(path: Path) -> str | None: + """Return None on success, or a human-readable error string.""" + if not path.exists(): + return f"{path} not found" + try: + cfg = load_config(path) + except Exception as e: # pragma: no cover + return f"failed to parse {path.name}: {e}" + + backend = cfg.get("backend") or {} + if not backend.get("endpoint"): + return "backend.endpoint is required" + if not backend.get("model"): + return "backend.model is required" + api_key = backend.get("api_key", "") + if not api_key: + return "backend.api_key is empty (set AIRCONTEXT_API_KEY env var or fill config.yaml)" + + trig = cfg.get("trigger") or {} + threshold = trig.get("threshold") + if not isinstance(threshold, (int, float)) or not 0 < float(threshold) < 1: + return "trigger.threshold must be a number between 0 and 1" + + comp = cfg.get("compaction") or {} + rules_file = comp.get("rules_file", "rules.md") + if not (path.parent / rules_file).exists(): + return f"rules file not found: {rules_file}" + + return None diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/jsonl_ops.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/jsonl_ops.py new file mode 100755 index 0000000..b63890f --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/jsonl_ops.py @@ -0,0 +1,158 @@ +"""JSONL transcript parsing + isolated-summary append. + +The Claude Code transcript is a JSON-Lines file where each line is a message. +Messages form a DAG via `parentUuid`; the "active chain" is the path from the +latest leaf back to the first message with parentUuid==null. + +We append a NEW chain by writing two `user` messages at the tail of the file: + 1. summary (parentUuid: null) — becomes a fresh chain root + 2. continuation (parentUuid: summary.uuid) — becomes the latest leaf + +On `claude --resume ` the leaf-selection algorithm picks the continuation +message (newest leaf) and walks back to the summary, so the loaded context is +exactly those two messages plus the system/CLAUDE.md/etc. fixed cost. +""" +from __future__ import annotations +import datetime as _dt +import json +import shutil +import uuid as _uuid +from pathlib import Path +from typing import Any, Iterator + + +def iter_messages(path: Path) -> Iterator[dict[str, Any]]: + with path.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError: + continue + + +def load_messages(path: Path) -> list[dict[str, Any]]: + return list(iter_messages(path)) + + +def find_active_chain(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return messages on the active chain, ordered from root to leaf. + + Mirrors `claude --resume` behaviour: + 1. Build uuid -> message map. + 2. Find leaves: uuids not referenced as a parentUuid by any other message. + 3. Pick leaf with latest timestamp. + 4. Walk parentUuid pointers back until parentUuid is null. + """ + by_uuid = {m["uuid"]: m for m in messages if "uuid" in m} + referenced = {m.get("parentUuid") for m in messages if m.get("parentUuid")} + leaves = [m for m in messages if m.get("uuid") and m["uuid"] not in referenced] + if not leaves: + return [] + + def _ts(m: dict[str, Any]) -> str: + return m.get("timestamp", "") + + leaf = max(leaves, key=_ts) + + chain: list[dict[str, Any]] = [] + cur: dict[str, Any] | None = leaf + seen: set[str] = set() + while cur is not None and cur.get("uuid") not in seen: + chain.append(cur) + seen.add(cur["uuid"]) + parent_uuid = cur.get("parentUuid") + if not parent_uuid: + break + cur = by_uuid.get(parent_uuid) + chain.reverse() + return chain + + +def _meta_from(reference: dict[str, Any]) -> dict[str, Any]: + """Copy non-content metadata fields from a reference message. + + We deliberately only copy metadata fields, never the content/message field, + so callers control what payload the new message carries. + """ + keep = ("sessionId", "cwd", "version", "gitBranch", "userType") + return {k: reference[k] for k in keep if k in reference} + + +def _now_iso() -> str: + return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="milliseconds").replace( + "+00:00", "Z" + ) + + +def build_user_message( + *, + parent_uuid: str | None, + content: str, + template: dict[str, Any], +) -> dict[str, Any]: + msg: dict[str, Any] = { + "type": "user", + "uuid": str(_uuid.uuid4()), + "parentUuid": parent_uuid, + "timestamp": _now_iso(), + "isSidechain": False, + **_meta_from(template), + "message": {"role": "user", "content": content}, + } + msg.setdefault("userType", "external") + return msg + + +def backup_jsonl(path: Path, snapshots_dir: Path, session_id: str) -> Path: + snapshots_dir.mkdir(parents=True, exist_ok=True) + ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S") + dst = snapshots_dir / f"{ts}-{session_id}.jsonl" + shutil.copyfile(path, dst) + return dst + + +def append_isolated_summary( + path: Path, + *, + summary_text: str, + continuation_text: str | None, + template_message: dict[str, Any], +) -> tuple[str, str | None]: + """Append summary (parentUuid=null) and optional continuation. + + Returns (summary_uuid, continuation_uuid_or_None). + """ + summary = build_user_message( + parent_uuid=None, content=summary_text, template=template_message + ) + lines: list[str] = [json.dumps(summary, ensure_ascii=False) + "\n"] + cont_uuid: str | None = None + if continuation_text: + cont = build_user_message( + parent_uuid=summary["uuid"], + content=continuation_text, + template=template_message, + ) + cont_uuid = cont["uuid"] + lines.append(json.dumps(cont, ensure_ascii=False) + "\n") + with path.open("a", encoding="utf-8") as f: + f.writelines(lines) + return summary["uuid"], cont_uuid + + +def rotate_snapshots(snapshots_dir: Path, max_keep: int) -> None: + if not snapshots_dir.exists(): + return + files = sorted( + (p for p in snapshots_dir.iterdir() if p.is_file() and p.suffix == ".jsonl"), + key=lambda p: p.stat().st_mtime, + ) + excess = len(files) - max_keep + for p in files[:max(0, excess)]: + try: + p.unlink() + except OSError: + pass diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/state.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/state.py new file mode 100755 index 0000000..5855b03 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/state.py @@ -0,0 +1,93 @@ +"""state.json read/write — shared between wrapper, hooks, and compactor. + +Schema: +{ + "config_missing": bool, # set by SessionStart hook when AirContext/ template was just created + "last_session_id": str | None, # the most recent claude session id we observed + "pending_resume_session_id": str | null,# session id to resume after wrapper terminates claude + "compaction_ready": bool, # compactor sets to true; wrapper consumes + "compaction_in_progress": bool, # compactor sets to true while running, prevents re-entry + "last_compaction_unix": int, # cooldown reference + "last_tool_count_at_compact": int, # for tool_count strategy (reserved) + "paused": bool # /aircontext-pause toggle +} +""" +from __future__ import annotations +import json +import os +import tempfile +from pathlib import Path +from typing import Any + + +_DEFAULT: dict[str, Any] = { + "config_missing": False, + "last_session_id": None, + "pending_resume_session_id": None, + "compaction_ready": False, + "compaction_in_progress": False, + "last_compaction_unix": 0, + "last_tool_count_at_compact": 0, + "paused": False, +} + + +class StateFile: + """Best-effort JSON state store. Writes are atomic via tmp-file rename. + + Concurrency: hook scripts and the wrapper read/write concurrently. We accept + last-write-wins semantics for non-critical fields. The compactor uses a + separate lock file (see compactor.py) for the critical 'do not start two + compactions at once' invariant. + """ + + def __init__(self, path: Path) -> None: + self.path = path + self.path.parent.mkdir(parents=True, exist_ok=True) + if not self.path.exists(): + self._write_atomic(_DEFAULT.copy()) + + @property + def last_session_id(self) -> str | None: + return self.read().get("last_session_id") + + def read(self) -> dict[str, Any]: + try: + with self.path.open("r", encoding="utf-8") as f: + data = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + data = _DEFAULT.copy() + # backfill defaults for forward-compat + for k, v in _DEFAULT.items(): + data.setdefault(k, v) + return data + + def update(self, **kwargs: Any) -> dict[str, Any]: + data = self.read() + data.update(kwargs) + self._write_atomic(data) + return data + + def reset_for_new_session(self) -> None: + self.update( + compaction_ready=False, + compaction_in_progress=False, + pending_resume_session_id=None, + ) + + def clear_ready(self) -> None: + self.update(compaction_ready=False, pending_resume_session_id=None) + + def _write_atomic(self, data: dict[str, Any]) -> None: + # tempfile in same dir so os.replace is atomic on the same filesystem + fd, tmp = tempfile.mkstemp(prefix=".state.", suffix=".tmp", dir=str(self.path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + os.replace(tmp, self.path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/token_estimator.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/token_estimator.py new file mode 100755 index 0000000..7a61372 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/core/token_estimator.py @@ -0,0 +1,53 @@ +"""Token-usage estimation for the active chain of a JSONL transcript. + +v0.1 ships only `char_div_3_5` — count characters of the active chain's content +fields and divide by 3.5. This is a rough but cheap heuristic; tiktoken-based +estimation can be added later behind the same interface. +""" +from __future__ import annotations +import json +from pathlib import Path +from typing import Any + +from .jsonl_ops import find_active_chain, load_messages + + +def _char_count(content: Any) -> int: + if content is None: + return 0 + if isinstance(content, str): + return len(content) + if isinstance(content, list): + total = 0 + for item in content: + if isinstance(item, dict): + if "text" in item and isinstance(item["text"], str): + total += len(item["text"]) + elif "input" in item: + total += len(json.dumps(item.get("input", {}), ensure_ascii=False)) + elif "content" in item: + total += _char_count(item["content"]) + else: + total += len(str(item)) + return total + if isinstance(content, dict): + return _char_count(content.get("content")) + return len(str(content)) + + +def _message_chars(msg: dict[str, Any]) -> int: + inner = msg.get("message") + if isinstance(inner, dict): + return _char_count(inner.get("content")) + if "tool_use_id" in msg and "content" in msg: + return _char_count(msg.get("content")) + return 0 + + +def estimate_active_chain_tokens(transcript: Path, *, method: str = "char_div_3_5") -> int: + if method != "char_div_3_5": + raise ValueError(f"Unsupported estimate_method: {method}") + msgs = load_messages(transcript) + chain = find_active_chain(msgs) + chars = sum(_message_chars(m) for m in chain) + return int(chars / 3.5) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/on_pre_compact.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/on_pre_compact.py new file mode 100755 index 0000000..b17f829 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/on_pre_compact.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""PreCompact hook — block Claude Code's built-in auto-compact. + +Only active in projects that have opted in (AirContext/ exists). Otherwise +Claude's default auto-compact behaviour is preserved untouched. + +Manual `/compact` is always allowed; auto-triggered compaction is rejected so +AirContext's rule-driven compactor is the only thing that mutates the chain. +""" +from __future__ import annotations +import json +import os +import sys +from pathlib import Path + + +def main() -> int: + raw = sys.stdin.read() or "{}" + try: + payload = json.loads(raw) + except json.JSONDecodeError: + payload = {} + + cwd = payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + if not (Path(cwd) / "AirContext").exists(): + return 0 # project hasn't opted in; let Claude do whatever it wants + + trigger = payload.get("trigger") or payload.get("compact_trigger") + if trigger == "auto": + print(json.dumps({ + "decision": "block", + "reason": ( + "AirContext: auto-compact disabled by user policy. " + "Custom rule-driven compaction handles this out-of-band." + ) + })) + return 0 + return 0 # manual /compact passes through + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/on_session_start.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/on_session_start.py new file mode 100755 index 0000000..e094040 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/on_session_start.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""SessionStart hook — verify AirContext/ presence and surface a friendly notice. + +Stage-2 implementation. For now we: + - look for AirContext/ in cwd + - if absent or invalid, set state.config_missing=true so UserPromptSubmit can block + - emit a SessionStart additionalContext message describing AirContext status +""" +from __future__ import annotations +import json +import os +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from core.config_loader import validate_config # noqa: E402 +from core.state import StateFile # noqa: E402 + + +def _find_project_root(stdin_payload: dict) -> Path: + cwd = stdin_payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + return Path(cwd) + + +def main() -> int: + raw = sys.stdin.read() or "{}" + try: + payload = json.loads(raw) + except json.JSONDecodeError: + payload = {} + + project = _find_project_root(payload) + air = project / "AirContext" + + # Project hasn't opted in — stay completely silent so AirContext doesn't + # pollute every Claude Code session globally. + if not air.exists(): + return 0 + + config = air / "config.yaml" + state = StateFile(air / "state.json") + + sid = payload.get("session_id") + if sid: + state.update(last_session_id=sid) + + if not config.exists(): + state.update(config_missing=True) + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": ( + "AirContext: AirContext/ exists but config.yaml is missing. " + "Run `/aircontext-init` to (re)install templates." + ) + } + })) + return 0 + + err = validate_config(config) + if err: + state.update(config_missing=True) + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": f"AirContext config invalid: {err}" + } + })) + return 0 + + state.update(config_missing=False) + # Healthy — stay silent to keep prompt clean. + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/on_tool_use.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/on_tool_use.py new file mode 100755 index 0000000..42631b9 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/on_tool_use.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""PostToolUse hook — lightweight ticker. + +Responsibilities (all must complete in well under 100 ms): + 1. Read AirContext/state.json. If paused, in-progress, or in cooldown, return. + 2. Estimate active-chain token usage from transcript_path. + 3. If usage / model_context_window >= trigger.threshold, fork compactor.py + in the background and return immediately. + +The compactor runs detached so it never blocks Claude's main loop. +""" +from __future__ import annotations +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from core.config_loader import load_config # noqa: E402 +from core.state import StateFile # noqa: E402 +from core.token_estimator import estimate_active_chain_tokens # noqa: E402 + + +def _project_root(payload: dict) -> Path: + cwd = payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + return Path(cwd) + + +def _spawn_compactor(project: Path, transcript: Path, session_id: str) -> None: + """Detach compactor.py so the hook returns immediately.""" + compactor = _HERE / "core" / "compactor.py" + args = [sys.executable, str(compactor), + "--project", str(project), + "--transcript", str(transcript), + "--session-id", session_id] + log_dir = project / "AirContext" / "snapshots" + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / "compactor.log" + + if os.name == "nt": + DETACHED = 0x00000008 # DETACHED_PROCESS + NEW_GROUP = 0x00000200 + creationflags = DETACHED | NEW_GROUP + with log_file.open("ab") as lf: + subprocess.Popen(args, stdout=lf, stderr=lf, stdin=subprocess.DEVNULL, + creationflags=creationflags, close_fds=True) + else: + with log_file.open("ab") as lf: + subprocess.Popen(args, stdout=lf, stderr=lf, stdin=subprocess.DEVNULL, + start_new_session=True, close_fds=True) + + +def main() -> int: + raw = sys.stdin.read() or "{}" + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return 0 + + project = _project_root(payload) + air = project / "AirContext" + config_path = air / "config.yaml" + if not config_path.exists(): + return 0 # uninitialised — nothing to do + + state = StateFile(air / "state.json") + snap = state.read() + if snap.get("paused") or snap.get("compaction_in_progress") or snap.get("compaction_ready"): + return 0 + + try: + cfg = load_config(config_path) + except Exception: + return 0 # config broken — UserPromptSubmit will surface it + + cooldown = int(cfg.get("trigger", {}).get("cooldown_seconds", 300)) + if time.time() - snap.get("last_compaction_unix", 0) < cooldown: + return 0 + + transcript = payload.get("transcript_path") + if not transcript or not Path(transcript).exists(): + return 0 + session_id = payload.get("session_id") or snap.get("last_session_id") + if not session_id: + return 0 + + threshold = float(cfg.get("trigger", {}).get("threshold", 0.6)) + window = int(cfg.get("trigger", {}).get("model_context_window", 200000)) + method = cfg.get("trigger", {}).get("estimate_method", "char_div_3_5") + + used = estimate_active_chain_tokens(Path(transcript), method=method) + if used / max(window, 1) < threshold: + return 0 + + # Mark in-progress immediately so successive PostToolUse calls don't double-fire. + state.update(compaction_in_progress=True, last_session_id=session_id) + _spawn_compactor(project, Path(transcript), session_id) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/on_user_prompt.py b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/on_user_prompt.py new file mode 100755 index 0000000..ea9eaff --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/scripts/on_user_prompt.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""UserPromptSubmit hook — block prompts until AirContext config is valid. + +When state.config_missing is true, refuse the prompt with a guidance message. +Otherwise, pass through silently. +""" +from __future__ import annotations +import json +import os +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from core.config_loader import validate_config # noqa: E402 +from core.state import StateFile # noqa: E402 + + +def _project_root(payload: dict) -> Path: + cwd = payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + return Path(cwd) + + +def main() -> int: + raw = sys.stdin.read() or "{}" + try: + payload = json.loads(raw) + except json.JSONDecodeError: + payload = {} + + project = _project_root(payload) + air = project / "AirContext" + + # Project hasn't opted in — silently allow. Users opt in by running + # `aircontext` (which installs templates) or `/aircontext-init`. + if not air.exists(): + return 0 + + config = air / "config.yaml" + if not config.exists(): + # Opted in but template install never completed. + msg = ( + "AirContext: AirContext/ directory exists but config.yaml is missing. " + "Run `/aircontext-init` to reinstall templates, or remove the AirContext/ " + "directory if you no longer want compaction in this project." + ) + print(json.dumps({"decision": "block", "reason": msg})) + return 0 + + err = validate_config(config) + if err: + msg = ( + f"AirContext config is invalid: {err}\n" + "Edit AirContext/config.yaml and resubmit." + ) + print(json.dumps({"decision": "block", "reason": msg})) + return 0 + + # Healthy — clear stale flag and pass through. + state = StateFile(air / "state.json") + if state.read().get("config_missing"): + state.update(config_missing=False) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/templates/README.md b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/templates/README.md new file mode 100755 index 0000000..a01207e --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/templates/README.md @@ -0,0 +1,26 @@ +# AirContext (per-project) + +This directory configures AirContext for **this project**. It was created the +first time you ran `aircontext` here. + +## Files + +- `config.yaml` — backend (LLM), trigger threshold, compaction options +- `rules.md` — compression rules sent to the LLM as system prompt +- `state.json` — runtime state (do not edit; managed by the plugin) +- `snapshots/` — backup of each JSONL before compaction; keep or delete freely + +## Getting started + +1. Open `config.yaml`, set `backend.endpoint` / `backend.model` / `backend.api_key`. + The default targets DeepSeek; replace with Ollama or any OpenAI-compatible + server as needed. For Ollama set `endpoint: http://localhost:11434/v1` and + any non-empty `api_key`. +2. Tune `trigger.threshold` (default 0.6 = compact at 60% of model context). +3. Edit `rules.md` to bias summaries toward what your project considers important. +4. Re-run `aircontext` from this directory. + +## Disabling auto compaction temporarily + +Run `/aircontext-pause` inside Claude Code, or set `safety.dry_run: true` in +config.yaml. diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/templates/config.yaml b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/templates/config.yaml new file mode 100755 index 0000000..f5e609a --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/templates/config.yaml @@ -0,0 +1,44 @@ +# AirContext compaction config. +# +# YOU MUST FILL these two fields (privacy-sensitive, never auto-populated): +# - backend.endpoint your LLM endpoint URL +# - backend.api_key either a literal value, or the ${env:VAR} placeholder +# pointing at an env var that holds the secret +# +# Everything else is auto-configured by `aircontext` on first run +# (model_context_window inferred from your Claude model setting, etc.) and +# you generally don't need to touch it. Edit freely if you want to override. +# `${env:VAR}` placeholders are resolved at runtime against environment +# variables (including those declared in ~/.claude/settings.json `env` block). + +backend: + type: anthropic_native # or openai_compat (auto-set by aircontext when possible) + + # >>> REQUIRED — fill before re-running aircontext <<< + endpoint: "" # e.g. https://api.anthropic.com | https://wolfai.top | http://localhost:11434/v1 + + # >>> REQUIRED — fill or set the env var <<< + api_key: ${env:ANTHROPIC_AUTH_TOKEN} # change to ${env:OPENAI_API_KEY} or paste a literal value + + model: claude-haiku-4-5-20251001 # cheap+fast for compression; raise to claude-sonnet-4-6 if quality insufficient + max_output_tokens: 4000 + timeout_seconds: 60 + anthropic_version: "2023-06-01" # only used when type = anthropic_native + +trigger: + strategy: token_ratio + threshold: 0.6 # compact when active chain reaches 60% of model_context_window + cooldown_seconds: 300 + estimate_method: char_div_3_5 + model_context_window: 200000 # auto-overridden on first aircontext run based on your claude model + +compaction: + preserve_tail_messages: 10 + drop_tool_results_over_lines: 1000 + rules_file: rules.md + continuation_prompt: "基于上面的压缩摘要继续之前的工作;如果没有进行中的任务则等待我的下一条指令。" + +safety: + backup: true + max_snapshots: 50 + dry_run: false diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/templates/rules.md b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/templates/rules.md new file mode 100755 index 0000000..a641e01 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/templates/rules.md @@ -0,0 +1,30 @@ +# AirContext Compression Rules + +This file is fed to the compression LLM as part of its system prompt. Edit +freely to bias what the summary keeps versus drops. The defaults below favour +software-engineering sessions; rewrite them for your domain. + +## Always preserve verbatim + +- File paths read or modified, with the final intended state of each file +- Architecture decisions and the reasoning behind them +- Open TODOs, unresolved bugs, error messages still in scope +- The user's stated goal for the current session +- Any user-supplied facts that the model could not derive from the codebase + (credentials hints, deployment quirks, deadlines, "we tried X and it failed because Y") + +## Aggressively drop + +- Exploratory grep/glob results that did not lead anywhere +- File contents that were superseded by later edits +- Tool outputs over 1000 lines (keep first 50 lines and last 50 lines, summarise the middle) +- Repeated similar searches and their near-identical outputs +- Completed sub-steps whose only output was "looks good, moving on" + +## Output format + +- Plain prose, no markdown headers or bullet lists unless they materially aid recall +- Reference files by `path:line` when relevant +- One paragraph per topic; aim for under 3000 tokens total +- Do NOT speculate beyond what the conversation contains +- Do NOT apologise, summarise the act of summarising, or add meta-commentary diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/README.md b/AirPlan/docs/spec/AirPlan-ParaV2/README.md new file mode 100755 index 0000000..48346a3 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/README.md @@ -0,0 +1,49 @@ +# AirPlan-ParaV2 Deployment Bundle + +This bundle packages the Air workflow suite plus the standalone AirContext component for deployment on another computer. + +## Included components + +### Air workflow suite +- plugins: airarc, aireng, airdo, airdbg, airxdb, airndb, airsdb +- `lib/air_runtime` +- `.agents/skills/*` +- `.agents/plugins/marketplace.json` +- `install_to_home.ps1` +- `init_project_airplan.ps1` + +### AirContext +- `AirContextServer/` +- `install_aircontext_local.ps1` +- `aircontext-settings-snippet.json` + +## Quick install + +### Install the Air workflow suite +Run in PowerShell from this extracted folder: + +```powershell +.\install_to_home.ps1 +``` + +### Stage AirContext on the target machine +Run: + +```powershell +.\install_aircontext_local.ps1 +``` + +This copies `AirContextServer` to `%USERPROFILE%\AirContextServer` and prints the settings snippet path to enable the plugin in Claude Code. + +### Install everything in one step +Run: + +```powershell +.\install_all.ps1 +``` + +## Notes + +- `aireng` is packaged from the current updated implementation and no longer keeps `air-engine` as a public entrypoint. +- AirContext is shipped as a separate component because it is not part of the `plugins/` development tree. +- The bundled `aircontext-settings-snippet.json` shows the Claude settings shape needed to enable the local AirContext marketplace. diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/aircontext-settings-snippet.json b/AirPlan/docs/spec/AirPlan-ParaV2/aircontext-settings-snippet.json new file mode 100755 index 0000000..4f8a914 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/aircontext-settings-snippet.json @@ -0,0 +1,13 @@ +{ + "extraKnownMarketplaces": { + "aircontext-mkt": { + "source": { + "source": "directory", + "path": "C:\\Users\\\\AirContextServer" + } + } + }, + "enabledPlugins": { + "aircontext@aircontext-mkt": true + } +} diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/bundle-manifest.json b/AirPlan/docs/spec/AirPlan-ParaV2/bundle-manifest.json new file mode 100755 index 0000000..2cfad69 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/bundle-manifest.json @@ -0,0 +1,67 @@ +{ + "bundleName": "AirPlan-ParaV2", + "generatedAt": "2026-04-30T11:50:37.519942+00:00", + "layout": "home-local-codex-plus-aircontext", + "includes": { + "plugins": [ + { + "name": "airarc", + "version": "0.3.1", + "homepage": "https://airlongdian.fun/plugins/airarc" + }, + { + "name": "aireng", + "version": "0.6.0", + "homepage": "https://airlongdian.fun/plugins/aireng" + }, + { + "name": "airdo", + "version": "0.5.0", + "homepage": "https://airlongdian.fun/plugins/airdo" + }, + { + "name": "airdbg", + "version": "0.1.4", + "homepage": "https://airlongdian.fun/plugins/airdbg" + }, + { + "name": "airxdb", + "version": "0.2.2", + "homepage": "https://airlongdian.fun/plugins/airxdb" + }, + { + "name": "airndb", + "version": "0.1.2", + "homepage": "https://airlongdian.fun/plugins/airndb" + }, + { + "name": "airsdb", + "version": "0.1.1", + "homepage": "https://airlongdian.fun/plugins/airsdb" + } + ], + "skills": [ + "airarc", + "aireng", + "airdo", + "airdbg", + "airxdb", + "airndb", + "airsdb" + ], + "sharedRuntime": [ + "air_runtime" + ], + "marketplace": ".agents/plugins/marketplace.json", + "projectBootstrap": [ + "install_to_home.ps1", + "init_project_airplan.ps1" + ], + "aircontext": { + "name": "aircontext", + "version": "0.1.0", + "root": "AirContextServer", + "installer": "install_aircontext_local.ps1" + } + } +} diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/init_project_airplan.ps1 b/AirPlan/docs/spec/AirPlan-ParaV2/init_project_airplan.ps1 new file mode 100755 index 0000000..434e94a --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/init_project_airplan.ps1 @@ -0,0 +1,262 @@ +param( + [string]$ProjectRoot = ".", + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +$resolvedProjectRoot = (Resolve-Path $ProjectRoot).Path +$airPlanRoot = Join-Path $resolvedProjectRoot "AirPlan" + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + New-Item -ItemType Directory -Path $Path -Force | Out-Null +} + +function Write-TextFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Content + ) + + if ((Test-Path -LiteralPath $Path) -and (-not $Force)) { + Write-Output ("skipped=" + $Path) + return + } + + $parent = Split-Path -Parent $Path + if ($parent) { + Ensure-Directory -Path $parent + } + [System.IO.File]::WriteAllText($Path, $Content.TrimStart("`n") + "`n", [System.Text.UTF8Encoding]::new($false)) + Write-Output ("written=" + $Path) +} + +$rootAgents = @' +# AGENTS.md + +- Canonical workflow context for this repository lives in `AirPlan/AGENTS.md`. +- Always load `AirPlan/AGENTS.md` first for project instructions, workflow rules, plan/todo state, ADR/C4 context, and current Air sync blocks. +- Treat `AirPlan/plan.md`, `AirPlan/todo.md`, and `AirPlan/docs/` as the authoritative workflow documents. +- Treat `AirPlan/state/` as the authoritative plugin and runtime state root. +- This root file is only a bootstrap shim; keep real workflow context maintained inside `AirPlan/AGENTS.md`. +'@ + +$projectAgents = @' +# AGENTS.md + +## Workflow Root + +- This project uses `AirPlan/` as the workflow root. +- Keep planning, execution state, ADR, C4, validation, debug, and plugin runtime data under `AirPlan/`. +- The repo-root `AGENTS.md` only bootstraps into this file. + + +## AirArc Workflow + +1. Use `AirPlan/AGENTS.md` as the canonical project context entry point. +2. Load and maintain: + - `AirPlan/docs/analysis/requirements.md` + - `AirPlan/docs/architecture/solution-architecture.md` + - `AirPlan/docs/architecture/c4/module.md` + - `AirPlan/docs/architecture/adr/` +3. Produce or refine `AirPlan/plan.md` and `AirPlan/todo.md`. +4. Keep plans optimized for lower-cost follow-up sessions, including scope, validation, file targets, and parallelization boundaries. + + + +## AirEng Workflow + +1. Use `/aireng` as the scheduler for confirmed execution. +2. Prefer `AirPlan/state/airarc/reviews/execution-plan.json`, then `AirPlan/state/airarc/reviews/parallel-review.json`, before falling back to local `AirPlan/todo.md`. +3. Dispatch isolated `/airdo` subagents with bounded concurrency. +4. Keep `AirPlan/todo.md`, `AirPlan/plan.md`, `AirPlan/AGENTS.md`, ADR, and C4 docs synchronized during dispatch and merge. +5. AirEng owns global debug, XDB, repair, and document convergence. + + + +## AirDo Workflow + +1. Use `/airdo` for one narrow task slice from `AirPlan/todo.md`. +2. Before editing, load: + - `AirPlan/AGENTS.md` + - `AirPlan/docs/architecture/adr/` + - `AirPlan/docs/architecture/c4/module.md` + - `AirPlan/plan.md` + - `AirPlan/todo.md` +3. Keep task-local progress resumable in `AirPlan/state/airdo/`. +4. When AirEng owns orchestration, return shared document changes through `documentUpdates`. +5. Route GUI work through AirXDB, debugging through AirDbg, network evidence through AirNDB, and static analysis through AirSDB when needed. + +'@ + +$planMd = @' +# Implementation Plan + +## Read First +1. `AirPlan/AGENTS.md` +2. `AirPlan/docs/analysis/requirements.md` +3. `AirPlan/docs/architecture/solution-architecture.md` +4. `AirPlan/docs/architecture/c4/module.md` +5. `AirPlan/docs/architecture/adr/` +6. `AirPlan/todo.md` + +## Goal +- Replace this section with the concrete product or project goal. + +## Constraints +- Record technical, organizational, legal, hardware, or platform constraints here. + +## Phases +- Add implementation phases once AirArc planning is complete. + +## Validation Strategy +- Record build, test, debug, GUI, network, and static-analysis validation commands here. +'@ + +$todoMd = @' +# TODO + +Status values: TODO / DOING / DONE / BLOCKED + +| ID | Status | Module | Task | Files/Dirs | Done When | Validation | Static Analysis | ADR/C4 Update | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| T-001 | TODO | Planning | Replace with the first confirmed execution task | `AirPlan/plan.md`, `AirPlan/docs/` | Acceptance criteria are explicit and testable | Record the exact validation command | Record the static-analysis plan or why it is not applicable | Record required ADR or C4 updates | + +## Quality Gates +- Run the validation command listed in `AirPlan/plan.md` before marking a task `DONE`. +- Keep ADR and C4 docs synchronized whenever architecture, module boundaries, dependencies, or ownership change. +- Record skipped validation, residual risk, and follow-up work explicitly. +'@ + +$requirementsMd = @' +# Requirements + +## Product Intent +- Replace with the user-visible outcome this repository should deliver. + +## Functional Requirements +- Replace with numbered or grouped functional requirements. + +## Constraints +- Replace with non-functional constraints, environmental limits, or safety rules. + +## Acceptance Notes +- Replace with the most important acceptance criteria and evidence rules. +'@ + +$solutionArchitectureMd = @' +# Solution Architecture + +## Overview +- Replace with the top-level architecture summary. + +## Major Components +- Replace with the main containers and their responsibilities. + +## Data And Control Flow +- Replace with the major interaction paths between components. + +## Key Risks +- Replace with the architecture risks, unknowns, and open decisions. +'@ + +$c4ModuleMd = @' +# C4 Module + +## System Context +- Replace with the project purpose and external actors or systems. + +## Containers +- Replace with the main runtime or repository containers. + +## Modules + +| Module | Responsibility | Public Interfaces | Dependencies | Data Ownership | Quality Notes | +| --- | --- | --- | --- | --- | --- | +| `replace_me` | Replace with the first real module | Replace with interfaces | Replace with dependencies | Replace with owned data | Replace with testing or quality notes | +'@ + +$adrMd = @' +# ADR-0001: Use AirPlan As The Workflow Root + +- Status: Accepted +- Date: YYYY-MM-DD + +## Context +This project needs a durable workflow root for planning, execution state, architecture context, validation evidence, and resumable AI sessions. + +## Decision +Store project workflow artifacts under `AirPlan/`, use the repo-root `AGENTS.md` only as a bootstrap shim, and let `aireng` plus `airdo` maintain plan, todo, ADR, and C4 context there. + +## Consequences +- Planning and execution context stay resumable across sessions. +- Global workflow docs live in one predictable location. +- Plugin runtime state does not clutter the main project tree. +'@ + +$debugLogMd = @' +# Debug Log + +- Add reproducible bug investigations, root-cause notes, and validation outcomes here. +'@ + +$guiDebugLogMd = @' +# GUI Debug Log + +- Add screenshots, GUI observations, Midscene evidence, and visual acceptance notes here. +'@ + +$networkLogMd = @' +# AirNDB Log + +- Add packet-capture commands, pcap paths, network observations, and conclusions here. +'@ + +$staticAnalysisMd = @' +# Static Analysis + +- Add cppcheck or other static-analysis summaries, report paths, and residual risks here. +'@ + +$validationReadme = @' +# Validation Artifacts + +- Save build logs, flash logs, test logs, screenshots, and validation summaries under this directory. +'@ + +$artifactsReadme = @' +# Artifact Output + +- Save generated evidence files in this directory. +'@ + +Ensure-Directory -Path $airPlanRoot +Ensure-Directory -Path (Join-Path $airPlanRoot "docs\analysis") +Ensure-Directory -Path (Join-Path $airPlanRoot "docs\architecture\adr") +Ensure-Directory -Path (Join-Path $airPlanRoot "docs\architecture\c4") +Ensure-Directory -Path (Join-Path $airPlanRoot "docs\debug\airxdb-artifacts") +Ensure-Directory -Path (Join-Path $airPlanRoot "docs\network\airndb-captures") +Ensure-Directory -Path (Join-Path $airPlanRoot "docs\validation\logs") +Ensure-Directory -Path (Join-Path $airPlanRoot "state") + +Write-TextFile -Path (Join-Path $resolvedProjectRoot "AGENTS.md") -Content $rootAgents +Write-TextFile -Path (Join-Path $airPlanRoot "AGENTS.md") -Content $projectAgents +Write-TextFile -Path (Join-Path $airPlanRoot "plan.md") -Content $planMd +Write-TextFile -Path (Join-Path $airPlanRoot "todo.md") -Content $todoMd +Write-TextFile -Path (Join-Path $airPlanRoot "docs\analysis\requirements.md") -Content $requirementsMd +Write-TextFile -Path (Join-Path $airPlanRoot "docs\architecture\solution-architecture.md") -Content $solutionArchitectureMd +Write-TextFile -Path (Join-Path $airPlanRoot "docs\architecture\c4\module.md") -Content $c4ModuleMd +Write-TextFile -Path (Join-Path $airPlanRoot "docs\architecture\adr\ADR-0001-use-airplan-as-the-workflow-root.md") -Content $adrMd +Write-TextFile -Path (Join-Path $airPlanRoot "docs\debug\debug-log.md") -Content $debugLogMd +Write-TextFile -Path (Join-Path $airPlanRoot "docs\debug\gui-debug-log.md") -Content $guiDebugLogMd +Write-TextFile -Path (Join-Path $airPlanRoot "docs\debug\airxdb-artifacts\README.md") -Content $artifactsReadme +Write-TextFile -Path (Join-Path $airPlanRoot "docs\network\airndb-log.md") -Content $networkLogMd +Write-TextFile -Path (Join-Path $airPlanRoot "docs\network\airndb-captures\README.md") -Content $artifactsReadme +Write-TextFile -Path (Join-Path $airPlanRoot "docs\validation\README.md") -Content $validationReadme +Write-TextFile -Path (Join-Path $airPlanRoot "docs\validation\logs\README.md") -Content $artifactsReadme +Write-TextFile -Path (Join-Path $airPlanRoot "docs\staticanalysis.md") -Content $staticAnalysisMd + +Write-Output ("airplan_project_init=completed") +Write-Output ("project_root=" + $resolvedProjectRoot) +Write-Output ("airplan_root=" + $airPlanRoot) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/install_aircontext_local.ps1 b/AirPlan/docs/spec/AirPlan-ParaV2/install_aircontext_local.ps1 new file mode 100755 index 0000000..eabe614 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/install_aircontext_local.ps1 @@ -0,0 +1,52 @@ +$ErrorActionPreference = "Stop" + +$bundleRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$homeRoot = [Environment]::GetFolderPath("UserProfile") +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$backupRoot = Join-Path $homeRoot "airplan-backups\$timestamp" +$sourceRoot = Join-Path $bundleRoot "AirContextServer" +$targetRoot = Join-Path $homeRoot "AirContextServer" +$snippetPath = Join-Path $bundleRoot "aircontext-settings-snippet.json" + +function Backup-ItemIfExists { + param( + [Parameter(Mandatory = $true)][string]$SourcePath, + [Parameter(Mandatory = $true)][string]$BackupRelativePath + ) + + if (-not (Test-Path -LiteralPath $SourcePath)) { + return + } + + $backupPath = Join-Path $backupRoot $BackupRelativePath + $backupParent = Split-Path -Parent $backupPath + if ($backupParent) { + New-Item -ItemType Directory -Path $backupParent -Force | Out-Null + } + Copy-Item -LiteralPath $SourcePath -Destination $backupPath -Recurse -Force +} + +function Replace-Directory { + param( + [Parameter(Mandatory = $true)][string]$SourcePath, + [Parameter(Mandatory = $true)][string]$TargetPath + ) + + $targetParent = Split-Path -Parent $TargetPath + if ($targetParent) { + New-Item -ItemType Directory -Path $targetParent -Force | Out-Null + } + if (Test-Path -LiteralPath $TargetPath) { + Remove-Item -LiteralPath $TargetPath -Recurse -Force + } + Copy-Item -LiteralPath $SourcePath -Destination $TargetPath -Recurse -Force +} + +New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null +Backup-ItemIfExists -SourcePath $targetRoot -BackupRelativePath "AirContextServer" +Replace-Directory -SourcePath $sourceRoot -TargetPath $targetRoot + +Write-Output "aircontext_stage=completed" +Write-Output ("aircontext_root=" + $targetRoot) +Write-Output ("settings_snippet=" + $snippetPath) +Write-Output "next_step=merge the snippet into %USERPROFILE%\.claude\settings.json or settings.local.json and enable aircontext@aircontext-mkt" diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/install_all.cmd b/AirPlan/docs/spec/AirPlan-ParaV2/install_all.cmd new file mode 100755 index 0000000..37abaff --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/install_all.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -ExecutionPolicy Bypass -File "%~dp0install_all.ps1" diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/install_all.ps1 b/AirPlan/docs/spec/AirPlan-ParaV2/install_all.ps1 new file mode 100755 index 0000000..1edcffe --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/install_all.ps1 @@ -0,0 +1,8 @@ +$ErrorActionPreference = "Stop" + +$bundleRoot = Split-Path -Parent $MyInvocation.MyCommand.Path + +& (Join-Path $bundleRoot "install_to_home.ps1") +& (Join-Path $bundleRoot "install_aircontext_local.ps1") + +Write-Output "airplan_parav2_install=completed" diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/install_to_home.cmd b/AirPlan/docs/spec/AirPlan-ParaV2/install_to_home.cmd new file mode 100755 index 0000000..865cd7c --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/install_to_home.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -ExecutionPolicy Bypass -File "%~dp0install_to_home.ps1" diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/install_to_home.ps1 b/AirPlan/docs/spec/AirPlan-ParaV2/install_to_home.ps1 new file mode 100755 index 0000000..ea979c0 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/install_to_home.ps1 @@ -0,0 +1,172 @@ +$ErrorActionPreference = "Stop" + +$bundleRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$homeRoot = [Environment]::GetFolderPath("UserProfile") +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$backupRoot = Join-Path $homeRoot "airplan-backups\$timestamp" + +$pluginNames = @( + "airarc", + "aireng", + "airdo", + "airdbg", + "airxdb", + "airndb", + "airsdb" +) + +$skillNames = @( + "airarc", + "aireng", + "airdo", + "airdbg", + "airxdb", + "airndb", + "airsdb" +) + +$legacyPluginNames = @("airdo-worker", "air-engine") +$legacySkillNames = @("airdo-worker", "air-engine") + +function Backup-ItemIfExists { + param( + [Parameter(Mandatory = $true)][string]$SourcePath, + [Parameter(Mandatory = $true)][string]$BackupRelativePath + ) + + if (-not (Test-Path -LiteralPath $SourcePath)) { + return + } + + $backupPath = Join-Path $backupRoot $BackupRelativePath + $backupParent = Split-Path -Parent $backupPath + if ($backupParent) { + New-Item -ItemType Directory -Path $backupParent -Force | Out-Null + } + Copy-Item -LiteralPath $SourcePath -Destination $backupPath -Recurse -Force +} + +function Replace-Directory { + param( + [Parameter(Mandatory = $true)][string]$SourcePath, + [Parameter(Mandatory = $true)][string]$TargetPath + ) + + $targetParent = Split-Path -Parent $TargetPath + if ($targetParent) { + New-Item -ItemType Directory -Path $targetParent -Force | Out-Null + } + if (Test-Path -LiteralPath $TargetPath) { + Remove-Item -LiteralPath $TargetPath -Recurse -Force + } + Copy-Item -LiteralPath $SourcePath -Destination $TargetPath -Recurse -Force +} + +function Resolve-SkillSourcePath { + param( + [Parameter(Mandatory = $true)][string]$BundleRoot, + [Parameter(Mandatory = $true)][string]$SkillName + ) + + $pluginSkillPath = Join-Path $BundleRoot ("plugins\" + $SkillName + "\skills\" + $SkillName) + if (Test-Path -LiteralPath $pluginSkillPath) { + return $pluginSkillPath + } + + $legacySkillPath = Join-Path $BundleRoot (".agents\skills\" + $SkillName) + if (Test-Path -LiteralPath $legacySkillPath) { + return $legacySkillPath + } + + throw "Skill source not found for $SkillName" +} + +function Rewrite-SkillScriptReferences { + param( + [Parameter(Mandatory = $true)][string]$SkillRoot, + [Parameter(Mandatory = $true)][string]$PluginName + ) + + $skillMdPath = Join-Path $SkillRoot "SKILL.md" + if (-not (Test-Path -LiteralPath $skillMdPath)) { + return + } + + $content = Get-Content -LiteralPath $skillMdPath -Raw -Encoding UTF8 + $updated = [regex]::Replace( + $content, + 'python\s+(?:\.\./\.\./scripts/|scripts/)([^\s`"]+)', + { + param($match) + 'python "$HOME/plugins/{0}/scripts/{1}"' -f $PluginName, $match.Groups[1].Value + } + ) + + if ($updated -ne $content) { + [System.IO.File]::WriteAllText($skillMdPath, $updated, [System.Text.UTF8Encoding]::new($false)) + } +} + +New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null + +$pluginsRoot = Join-Path $homeRoot "plugins" +$skillsRoot = Join-Path $homeRoot ".agents\skills" +$marketplaceRoot = Join-Path $homeRoot ".agents\plugins" +$libRoot = Join-Path $homeRoot "lib" +$marketplacePath = Join-Path $marketplaceRoot "marketplace.json" + +Backup-ItemIfExists -SourcePath $marketplacePath -BackupRelativePath ".agents\plugins\marketplace.json" +Backup-ItemIfExists -SourcePath (Join-Path $libRoot "air_runtime") -BackupRelativePath "lib\air_runtime" + +foreach ($name in $pluginNames + $legacyPluginNames) { + Backup-ItemIfExists -SourcePath (Join-Path $pluginsRoot $name) -BackupRelativePath ("plugins\" + $name) +} + +foreach ($name in $skillNames + $legacySkillNames) { + Backup-ItemIfExists -SourcePath (Join-Path $skillsRoot $name) -BackupRelativePath (".agents\skills\" + $name) +} + +foreach ($name in $legacyPluginNames) { + $target = Join-Path $pluginsRoot $name + if (Test-Path -LiteralPath $target) { + Remove-Item -LiteralPath $target -Recurse -Force + } +} + +foreach ($name in $legacySkillNames) { + $target = Join-Path $skillsRoot $name + if (Test-Path -LiteralPath $target) { + Remove-Item -LiteralPath $target -Recurse -Force + } +} + +foreach ($name in $pluginNames) { + Replace-Directory ` + -SourcePath (Join-Path $bundleRoot ("plugins\" + $name)) ` + -TargetPath (Join-Path $pluginsRoot $name) +} + +foreach ($name in $skillNames) { + Replace-Directory ` + -SourcePath (Resolve-SkillSourcePath -BundleRoot $bundleRoot -SkillName $name) ` + -TargetPath (Join-Path $skillsRoot $name) + Rewrite-SkillScriptReferences ` + -SkillRoot (Join-Path $skillsRoot $name) ` + -PluginName $name +} + +Replace-Directory ` + -SourcePath (Join-Path $bundleRoot "lib\air_runtime") ` + -TargetPath (Join-Path $libRoot "air_runtime") + +New-Item -ItemType Directory -Path $marketplaceRoot -Force | Out-Null +Copy-Item ` + -LiteralPath (Join-Path $bundleRoot ".agents\plugins\marketplace.json") ` + -Destination $marketplacePath ` + -Force + +Write-Output "airplan_install=completed" +Write-Output ("home_root=" + $homeRoot) +Write-Output ("backup_root=" + $backupRoot) +Write-Output ("plugins=" + ($pluginNames -join ",")) +Write-Output ("skills=" + ($skillNames -join ",")) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/__init__.py b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/__init__.py new file mode 100755 index 0000000..c8859fe --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/__init__.py @@ -0,0 +1,103 @@ +from .contracts import ( + DEFAULT_DEBUG_POLICY, + DEFAULT_XDB_POLICY, + DEFAULT_REPAIR_POLICY, + ParallelGroup, + ParallelReview, + RepairAttempt, + ReviewConflict, + TaskRecord, + ValidationRecord, + WorkerResult, + XdbSession, + default_worker_result, + now_iso, +) +from .airxdb_runtime import ( + ensure_xdb_sessions_for_result, + list_xdb_sessions, + load_xdb_policy, + merge_xdb_sessions_into_state, + normalize_xdb_policy, + task_requires_xdb, +) +from .debug_runtime import ( + ensure_debug_sessions_for_result, + list_debug_sessions, + load_debug_policy, + merge_debug_sessions_into_state, + normalize_debug_policy, +) +from .repair_runtime import ( + ensure_repair_attempts_for_result, + list_repair_attempts, + load_active_repair_attempt, + load_repair_policy, + merge_repair_attempts_into_state, + normalize_repair_policy, + summarize_active_repairs, + write_repair_queue, +) +from .engine import ( + artifact_health, + build_engine_plan, + enter_engine, + merge_worker_result, + status_engine, +) +from .doc_sync import ( + apply_document_updates, + enforce_doc_sync_requirements, + sync_engine_managed_docs, + update_todo_after_merge, +) +from .review import build_parallel_review, render_review_markdown +from .todo_parser import find_task, parse_tasks + +__all__ = [ + "DEFAULT_DEBUG_POLICY", + "DEFAULT_XDB_POLICY", + "DEFAULT_REPAIR_POLICY", + "ParallelGroup", + "ParallelReview", + "RepairAttempt", + "ReviewConflict", + "TaskRecord", + "ValidationRecord", + "WorkerResult", + "XdbSession", + "artifact_health", + "apply_document_updates", + "build_engine_plan", + "build_parallel_review", + "default_worker_result", + "ensure_xdb_sessions_for_result", + "ensure_debug_sessions_for_result", + "enforce_doc_sync_requirements", + "enter_engine", + "find_task", + "list_xdb_sessions", + "list_debug_sessions", + "list_repair_attempts", + "load_active_repair_attempt", + "load_xdb_policy", + "load_debug_policy", + "load_repair_policy", + "merge_xdb_sessions_into_state", + "merge_debug_sessions_into_state", + "merge_repair_attempts_into_state", + "merge_worker_result", + "normalize_xdb_policy", + "normalize_debug_policy", + "normalize_repair_policy", + "now_iso", + "parse_tasks", + "render_review_markdown", + "summarize_active_repairs", + "sync_engine_managed_docs", + "task_requires_xdb", + "status_engine", + "update_todo_after_merge", + "write_repair_queue", + "ensure_repair_attempts_for_result", +] diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/airxdb_runtime.py b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/airxdb_runtime.py new file mode 100755 index 0000000..fe15ba1 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/airxdb_runtime.py @@ -0,0 +1,817 @@ +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Dict, Iterable, List, Tuple + +from .contracts import ( + DEFAULT_XDB_POLICY, + TaskRecord, + ValidationRecord, + WorkerResult, + XdbSession, + now_iso, +) +from .paths import ( + agents_path, + aireng_root, + airxdb_artifacts_dir, + airxdb_root, + architecture_adr_dir, + architecture_c4_module_path, + gui_debug_log_path, + todo_path, +) +from .todo_parser import find_task, parse_tasks + + +def _json_load(path: Path) -> Dict[str, object]: + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8-sig")) + + +def _json_dump(path: Path, payload: Dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def _ordered_unique(items: Iterable[str]) -> List[str]: + seen = set() + ordered: List[str] = [] + for item in items: + cleaned = str(item).strip() + if not cleaned or cleaned in seen: + continue + seen.add(cleaned) + ordered.append(cleaned) + return ordered + + +def normalize_xdb_policy(policy: Dict[str, object] | None = None) -> Dict[str, object]: + merged = dict(DEFAULT_XDB_POLICY) + if policy: + merged.update(policy) + + merged["enabled"] = bool(merged.get("enabled", True)) + merged["requireForGuiTasks"] = bool(merged.get("requireForGuiTasks", True)) + merged["captureOnBlocked"] = bool(merged.get("captureOnBlocked", True)) + merged["captureOnDone"] = bool(merged.get("captureOnDone", True)) + merged["preferOriginalAirXDB"] = bool(merged.get("preferOriginalAirXDB", True)) + merged["remoteFirstIfConfigured"] = bool(merged.get("remoteFirstIfConfigured", True)) + merged["triggerValidationStatuses"] = _ordered_unique( + str(item).lower() for item in list(merged.get("triggerValidationStatuses", [])) + ) + merged["taskKeywords"] = _ordered_unique( + str(item).lower() for item in list(merged.get("taskKeywords", [])) + ) + try: + max_sessions = int(merged.get("maxSessionsPerTask", 1) or 1) + except (TypeError, ValueError): + max_sessions = 1 + merged["maxSessionsPerTask"] = max(1, max_sessions) + return merged + + +def load_xdb_policy(project_root: Path) -> Dict[str, object]: + state_path = aireng_root(project_root) / "state.json" + state = _json_load(state_path) + return normalize_xdb_policy(state.get("xdbPolicy", {})) + + +def merge_xdb_sessions_into_state( + state: Dict[str, object], xdb_sessions: List[XdbSession] +) -> Dict[str, object]: + state["xdbPolicy"] = normalize_xdb_policy(dict(state.get("xdbPolicy", {}))) + + existing_items = list(state.get("xdbSessions", [])) + seen_ids = { + str(item.get("sessionId", "")).strip() + for item in existing_items + if isinstance(item, dict) + } + for session in xdb_sessions: + if session.session_id in seen_ids: + continue + existing_items.append(session.to_dict()) + seen_ids.add(session.session_id) + + task_counts: Dict[str, int] = {} + for item in existing_items: + task_id = str(item.get("taskId", "")).strip() + if not task_id: + continue + task_counts[task_id] = task_counts.get(task_id, 0) + 1 + + state["xdbSessions"] = existing_items + state["taskXdbCounts"] = task_counts + if xdb_sessions: + latest = xdb_sessions[-1] + state["lastXdbSessionId"] = latest.session_id + state["lastXdbTaskId"] = latest.task_id + state["lastXdbAt"] = latest.created_at + return state + + +def _xdb_paths(project_root: Path) -> Dict[str, Path]: + root = airxdb_root(project_root) + return { + "root": root, + "state": root / "state.json", + "requests": root / "requests", + "sessions": root / "sessions", + "artifacts": airxdb_artifacts_dir(project_root), + "gui_debug_log": gui_debug_log_path(project_root), + } + + +def _airxdb_health(project_root: Path) -> Dict[str, bool]: + paths = _xdb_paths(project_root) + return { + "AirPlan/AGENTS.md": agents_path(project_root).exists(), + "AirPlan/docs/architecture/c4/module.md": architecture_c4_module_path(project_root).exists(), + "AirPlan/docs/architecture/adr": architecture_adr_dir(project_root).exists(), + "gui_debug_log": paths["gui_debug_log"].exists(), + } + + +def _ensure_xdb_layout(project_root: Path) -> Dict[str, Path]: + paths = _xdb_paths(project_root) + paths["requests"].mkdir(parents=True, exist_ok=True) + paths["sessions"].mkdir(parents=True, exist_ok=True) + paths["artifacts"].mkdir(parents=True, exist_ok=True) + paths["gui_debug_log"].parent.mkdir(parents=True, exist_ok=True) + if not paths["gui_debug_log"].exists(): + paths["gui_debug_log"].write_text("# GUI Debug Log\n\n", encoding="utf-8") + return paths + + +def _resolve_original_airxdb_script(script_name: str) -> Path | None: + candidates = [ + Path.home() / "plugins" / "airxdb" / "scripts" / script_name, + Path(r"C:\Users\20392\plugins\airxdb\scripts") / script_name, + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + +def _bootstrap_airxdb(project_root: Path, prefer_original: bool) -> Tuple[str, str, str]: + mode = "runtime-bootstrap" + stdout = "" + error = "" + script_path = ( + _resolve_original_airxdb_script("airxdb_mode.py") if prefer_original else None + ) + + if script_path is not None: + try: + completed = subprocess.run( + [ + sys.executable, + str(script_path), + "--mode", + "enter", + "--project", + str(project_root), + ], + capture_output=True, + text=True, + check=True, + ) + mode = "original-airxdb" + stdout = completed.stdout.strip() + except subprocess.CalledProcessError as exc: + mode = "runtime-fallback" + stdout = exc.stdout.strip() + error = exc.stderr.strip() or str(exc) + + paths = _ensure_xdb_layout(project_root) + state = _json_load(paths["state"]) + state["enabled"] = True + state["updatedAt"] = now_iso() + state["projectRoot"] = str(project_root) + state["artifactHealth"] = _airxdb_health(project_root) + if stdout: + state["lastBootstrapStdout"] = stdout + if error: + state["lastBootstrapError"] = error + _json_dump(paths["state"], state) + return mode, stdout, error + + +def list_xdb_sessions(project_root: Path, task_id: str = "") -> List[XdbSession]: + session_dir = _xdb_paths(project_root)["sessions"] + if not session_dir.exists(): + return [] + + sessions: List[XdbSession] = [] + for path in sorted(session_dir.glob("*.json")): + try: + payload = json.loads(path.read_text(encoding="utf-8-sig")) + session_payload = payload.get("xdbSession", payload) + session = XdbSession.from_dict(session_payload) + session.validate() + except (json.JSONDecodeError, TypeError, ValueError): + continue + if task_id and session.task_id != task_id: + continue + sessions.append(session) + + sessions.sort(key=lambda item: (item.created_at, item.session_id)) + return sessions + + +def _load_task(project_root: Path, task_id: str) -> TaskRecord | None: + current_todo_path = todo_path(project_root) + if not current_todo_path.exists(): + return None + try: + return find_task(parse_tasks(current_todo_path), task_id) + except ValueError: + return None + + +def _parse_env_file(path: Path) -> Dict[str, str]: + if not path.exists(): + return {} + payload: Dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8-sig").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and value: + payload[key] = value + return payload + + +def _is_remote_configured(project_root: Path) -> bool: + if os.environ.get("AIRXDB_REMOTE_SSH_TARGET", "").strip(): + return True + remote_env = airxdb_root(project_root) / "remote-device.env" + return bool(_parse_env_file(remote_env).get("AIRXDB_REMOTE_SSH_TARGET", "").strip()) + + +def task_requires_xdb( + task: TaskRecord | None, + result: WorkerResult | None, + policy: Dict[str, object] | None = None, +) -> bool: + effective_policy = normalize_xdb_policy(policy) + if not bool(effective_policy.get("enabled", True)): + return False + if not bool(effective_policy.get("requireForGuiTasks", True)): + return False + + keyword_set = { + str(item).lower().strip() + for item in list(effective_policy.get("taskKeywords", [])) + if str(item).strip() + } + search_parts = [] + if task is not None: + search_parts.extend( + [ + task.module, + task.task, + task.files_dirs, + task.done_when, + task.validation, + task.adr_c4_update, + ] + ) + if result is not None: + search_parts.extend( + [ + result.summary, + " ".join(result.files_changed), + " ".join(result.evidence_paths), + " ".join(result.blockers), + " ".join(item.kind for item in result.validations), + " ".join(item.command for item in result.validations), + ] + ) + blob = " ".join(search_parts).lower() + for keyword in keyword_set: + pattern = re.compile(rf"(? Tuple[bool, List[str], str]: + if not task_requires_xdb(task, result, policy): + return False, [], "" + + triggers: List[str] = [] + reasons: List[str] = [] + + if result.status == "done" and bool(policy.get("captureOnDone", True)): + triggers.append("done-acceptance") + reasons.append("GUI acceptance requires AirXDB evidence before closure") + + if result.status == "blocked" and bool(policy.get("captureOnBlocked", True)): + triggers.append("blocked-visual") + reasons.append("blocked GUI result should capture current screen evidence") + + tracked_validation_statuses = { + str(item).lower() for item in list(policy.get("triggerValidationStatuses", [])) + } + failed_validations = [ + item + for item in result.validations + if item.status.lower() in tracked_validation_statuses + ] + if failed_validations: + triggers.append("validation-failure") + reasons.append( + "GUI-related validation failures: " + + ", ".join(f"{item.kind}:{item.status}" for item in failed_validations) + ) + + return bool(triggers), triggers, "; ".join(reasons) + + +def _session_stamp() -> str: + return now_iso().replace(":", "-").replace(".", "-").replace("+", "-") + + +def _session_trigger_set(session: XdbSession) -> set[str]: + return { + item.strip() + for item in session.trigger.split(",") + if item.strip() + } + + +def _session_matches_triggers(session: XdbSession, triggers: List[str]) -> bool: + if not triggers: + return False + trigger_set = set(triggers) + return trigger_set.issubset(_session_trigger_set(session)) + + +def _parse_kv_output(stdout: str) -> Dict[str, str]: + payload: Dict[str, str] = {} + for raw_line in stdout.splitlines(): + line = raw_line.strip() + if not line or "=" not in line: + continue + key, value = line.split("=", 1) + payload[key.strip()] = value.strip() + return payload + + +def _load_report_payload(report_path: Path) -> Dict[str, object]: + if not report_path or not report_path.exists() or not report_path.is_file(): + return {} + try: + return json.loads(report_path.read_text(encoding="utf-8-sig")) + except json.JSONDecodeError: + return {} + + +def _extract_report_images(report_payload: Dict[str, object]) -> List[str]: + images: List[str] = [] + screenshot = str(report_payload.get("screenshot", "")).strip() + if screenshot: + images.append(screenshot) + for step in list(report_payload.get("steps", [])): + if not isinstance(step, dict): + continue + for image in list(step.get("images", [])): + cleaned = str(image).strip() + if cleaned: + images.append(cleaned) + return _ordered_unique(images) + + +def _capture_airxdb( + project_root: Path, policy: Dict[str, object] +) -> Tuple[str, str, Dict[str, str], str, str]: + prefer_original = bool(policy.get("preferOriginalAirXDB", True)) + output_dir = _xdb_paths(project_root)["artifacts"] + + remote_first = bool(policy.get("remoteFirstIfConfigured", True)) and _is_remote_configured( + project_root + ) + def run_capture(script_name: str, mode: str, remote_mode: bool) -> Tuple[str, str, Dict[str, str], str, str]: + script_path = _resolve_original_airxdb_script(script_name) if prefer_original else None + if script_path is None: + return ( + mode, + "failed", + {}, + "", + f"AirXDB helper script not found: {script_name}", + ) + + command = [ + sys.executable, + str(script_path), + "--project", + str(project_root), + "--output-dir", + str(output_dir), + "--action", + "screenshot", + ] + completed = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + stdout = completed.stdout.strip() + stderr = completed.stderr.strip() + parsed = _parse_kv_output(stdout) + status_key = "airxdb_remote_status" if remote_mode else "airxdb_smoke" + status = parsed.get(status_key, "") + if not status: + status = "ok" if completed.returncode == 0 else "failed" + if completed.returncode != 0 and status == "ok": + status = "failed" + return mode, status, parsed, stdout, stderr + + if not remote_first: + return run_capture("airxdb_computer_mcp_smoke.py", "local-screenshot", False) + + remote_attempt = run_capture("airxdb_remote_device.py", "remote-screenshot", True) + if remote_attempt[1] == "ok": + return remote_attempt + + local_attempt = run_capture("airxdb_computer_mcp_smoke.py", "local-screenshot", False) + if local_attempt[1] == "ok": + parsed = dict(local_attempt[2]) + parsed["fallbackFrom"] = remote_attempt[0] + parsed["fallbackRemoteStdout"] = remote_attempt[3] + parsed["fallbackRemoteError"] = remote_attempt[4] + return ( + "remote-fallback-local", + local_attempt[1], + parsed, + "\n".join( + part + for part in [ + f"[remote-attempt]\n{remote_attempt[3]}", + f"[local-attempt]\n{local_attempt[3]}", + ] + if part.strip() + ), + "\n".join( + part + for part in [ + f"[remote-attempt]\n{remote_attempt[4]}", + f"[local-attempt]\n{local_attempt[4]}", + ] + if part.strip() + ), + ) + + parsed = dict(remote_attempt[2]) + parsed["fallbackLocalStdout"] = local_attempt[3] + parsed["fallbackLocalError"] = local_attempt[4] + return ( + "remote-fallback-local", + "failed", + parsed, + "\n".join( + part + for part in [ + f"[remote-attempt]\n{remote_attempt[3]}", + f"[local-attempt]\n{local_attempt[3]}", + ] + if part.strip() + ), + "\n".join( + part + for part in [ + f"[remote-attempt]\n{remote_attempt[4]}", + f"[local-attempt]\n{local_attempt[4]}", + ] + if part.strip() + ), + ) + + +def _append_gui_debug_log( + gui_debug_log_path: Path, + task: TaskRecord | None, + result: WorkerResult, + session: XdbSession, + bootstrap_mode: str, +) -> None: + target_surface = task.module if task is not None else "unknown" + screenshot_text = ", ".join(f"`{item}`" for item in session.screenshots) or "`none`" + entry_lines = [ + f"### {session.created_at}: auto-xdb capture for {result.task_id}", + "", + f"- Target surface: {target_surface}", + f"- Task: `{result.task_id}`", + f"- Source: `{session.source}`", + f"- Trigger: `{session.trigger}`", + f"- Midscene mode: `{session.mode}`", + f"- Symptom: {result.summary}", + "- Expected: GUI acceptance evidence should exist before closure, and blocked GUI paths should keep reproducible screen evidence.", + f"- Actual: worker status `{result.status}`; blockers={', '.join(result.blockers) or 'none'}", + f"- Report path: `{session.report_path or 'n/a'}`", + f"- Screenshots: {screenshot_text}", + f"- AirDbg handoff: linked debug sessions={', '.join(f'`{item.session_id}`' for item in result.debug_sessions) or '`none`'}", + f"- Validation after fix: pending runtime merge; bootstrap mode `{bootstrap_mode}`; xdb status `{session.status}`", + f"- ADR/C4 updates: {'required' if task and task.global_doc_paths else 'none'}", + f"- Residual risk: {'GUI acceptance is not complete until XDB evidence is successful.' if session.status != 'captured' else 'none'}", + "", + ] + existing = ( + gui_debug_log_path.read_text(encoding="utf-8") + if gui_debug_log_path.exists() + else "# GUI Debug Log\n\n" + ) + gui_debug_log_path.write_text( + existing.rstrip() + "\n\n" + "\n".join(entry_lines), + encoding="utf-8", + ) + + +def _update_airxdb_state( + project_root: Path, + session: XdbSession, + bootstrap_mode: str, + bootstrap_stdout: str, + bootstrap_error: str, + capture_stdout: str, + capture_error: str, +) -> None: + paths = _ensure_xdb_layout(project_root) + state = _json_load(paths["state"]) + sessions = list(state.get("autoXdbSessions", [])) + known_ids = { + str(item.get("sessionId", "")).strip() + for item in sessions + if isinstance(item, dict) + } + if session.session_id not in known_ids: + sessions.append( + { + "sessionId": session.session_id, + "taskId": session.task_id, + "source": session.source, + "trigger": session.trigger, + "status": session.status, + "createdAt": session.created_at, + "requestPath": session.request_path, + "reportPath": session.report_path, + } + ) + + state["enabled"] = True + state["updatedAt"] = now_iso() + state["projectRoot"] = str(project_root) + state["artifactHealth"] = _airxdb_health(project_root) + state["autoXdbSessions"] = sessions + state["autoXdbSessionCount"] = len(sessions) + state["lastAutoXdbAt"] = session.created_at + state["lastAutoXdbSessionId"] = session.session_id + state["lastBootstrapMode"] = bootstrap_mode + if bootstrap_stdout: + state["lastBootstrapStdout"] = bootstrap_stdout + if bootstrap_error: + state["lastBootstrapError"] = bootstrap_error + if capture_stdout: + state["lastCaptureStdout"] = capture_stdout + if capture_error: + state["lastCaptureError"] = capture_error + _json_dump(paths["state"], state) + + +def _attach_session_to_result(result: WorkerResult, session: XdbSession, note: str) -> None: + if not any(item.session_id == session.session_id for item in result.xdb_sessions): + result.xdb_sessions.append(session) + for path in [session.request_path, session.gui_debug_log_path, session.state_path, session.report_path]: + if path and path not in result.evidence_paths: + result.evidence_paths.append(path) + for path in session.screenshots: + if path and path not in result.evidence_paths: + result.evidence_paths.append(path) + if note not in result.notes: + result.notes.append(note) + + +def _session_linked_to_result(result: WorkerResult, session: XdbSession) -> bool: + linked_paths = set(result.evidence_paths) + session_paths = { + session.request_path, + session.report_path, + session.gui_debug_log_path, + session.state_path, + *session.screenshots, + } + return bool(linked_paths.intersection(path for path in session_paths if path)) + + +def _mark_xdb_capture_blocker(result: WorkerResult, reason: str, session: XdbSession | None = None) -> None: + result.status = "blocked" + if reason not in result.blockers: + result.blockers.append(reason) + + evidence = [] + if session is not None: + evidence.extend( + [ + session.request_path, + session.report_path, + session.gui_debug_log_path, + *session.screenshots, + ] + ) + evidence = [item for item in evidence if item] + if not any( + item.kind == "airxdb-acceptance" and item.status == "failed" + for item in result.validations + ): + result.validations.append( + ValidationRecord( + kind="airxdb-acceptance", + status="failed", + command="automatic AirXDB capture", + evidence=evidence, + ) + ) + note = f"AirXDB acceptance was downgraded to blocked: {reason}" + if note not in result.notes: + result.notes.append(note) + + +def _latest_successful_session(sessions: List[XdbSession]) -> XdbSession | None: + for session in reversed(sessions): + if session.status == "captured": + return session + return None + + +def ensure_xdb_sessions_for_result( + project_root: Path, + result: WorkerResult, + source: str, + strict_done: bool = True, +) -> List[XdbSession]: + policy = load_xdb_policy(project_root) + task = _load_task(project_root, result.task_id) + needs_capture, triggers, reason = _result_trigger_summary(task, result, policy) + if not needs_capture: + return result.xdb_sessions + + current_matching = [ + item for item in result.xdb_sessions if _session_matches_triggers(item, triggers) + ] + successful_current = _latest_successful_session(current_matching) + if successful_current is not None: + _attach_session_to_result( + result, + successful_current, + f"Reused current AirXDB session for {result.task_id}: {successful_current.session_id}", + ) + return result.xdb_sessions + + existing_sessions = list_xdb_sessions(project_root, result.task_id) + matching_existing = [ + item for item in existing_sessions if _session_matches_triggers(item, triggers) + ] + successful_existing = _latest_successful_session(matching_existing) + if ( + source != "worker-finish" + and successful_existing is not None + and _session_linked_to_result(result, successful_existing) + ): + _attach_session_to_result( + result, + successful_existing, + f"Reused existing AirXDB session for {result.task_id}: {successful_existing.session_id}", + ) + return result.xdb_sessions + + session_limit = int(policy.get("maxSessionsPerTask", 1) or 1) + if source != "worker-finish" and len(matching_existing) >= session_limit: + latest_matching = matching_existing[-1] if matching_existing else None + if latest_matching is not None: + if _session_linked_to_result(result, latest_matching): + _attach_session_to_result( + result, + latest_matching, + f"Reused latest matching AirXDB session for {result.task_id}: {latest_matching.session_id}", + ) + else: + latest_matching = None + if strict_done and result.status == "done": + _mark_xdb_capture_blocker( + result, + f"AirXDB acceptance evidence is stale or exhausted for {result.task_id}", + latest_matching, + ) + return result.xdb_sessions + + paths = _ensure_xdb_layout(project_root) + bootstrap_mode, bootstrap_stdout, bootstrap_error = _bootstrap_airxdb( + project_root, bool(policy.get("preferOriginalAirXDB", True)) + ) + mode, capture_status, parsed, capture_stdout, capture_error = _capture_airxdb( + project_root, policy + ) + + created_at = now_iso() + session_id = f"{result.task_id}-{_session_stamp()}" + request_path = paths["requests"] / f"{session_id}.json" + session_path = paths["sessions"] / f"{session_id}.json" + report_path = ( + Path(str(parsed.get("report", "")).strip()).resolve() + if str(parsed.get("report", "")).strip() + else None + ) + report_payload = _load_report_payload(report_path) + screenshots = _extract_report_images(report_payload) + remote_target = parsed.get("target", "") + status = "captured" if capture_status == "ok" else "failed" + session = XdbSession( + session_id=session_id, + task_id=result.task_id, + source=source, + trigger=",".join(triggers), + reason=reason, + request_path=str(request_path), + gui_debug_log_path=str(paths["gui_debug_log"]), + report_path=str(report_path) if report_path is not None else "", + state_path=str(paths["state"]), + mode=mode, + status=status, + screenshots=screenshots, + created_at=created_at, + remote_target=remote_target, + ) + + _json_dump( + request_path, + { + "taskId": result.task_id, + "source": source, + "trigger": triggers, + "reason": reason, + "requestedAt": created_at, + "bootstrapMode": bootstrap_mode, + "captureMode": mode, + "captureStatus": capture_status, + "captureParsedOutput": parsed, + "bootstrapStdout": bootstrap_stdout, + "bootstrapError": bootstrap_error, + "captureStdout": capture_stdout, + "captureError": capture_error, + "resultSummary": result.summary, + "validationStatuses": [item.to_dict() for item in result.validations], + }, + ) + _json_dump( + session_path, + { + "xdbSession": session.to_dict(), + "captureReport": report_payload, + }, + ) + + _append_gui_debug_log(paths["gui_debug_log"], task, result, session, bootstrap_mode) + _update_airxdb_state( + project_root, + session, + bootstrap_mode, + bootstrap_stdout, + bootstrap_error, + capture_stdout, + capture_error, + ) + _attach_session_to_result( + result, + session, + ( + f"AirXDB evidence captured for {result.task_id}: {session.session_id}" + if session.status == "captured" + else f"AirXDB capture failed for {result.task_id}: {session.session_id}" + ), + ) + + if strict_done and result.status == "done" and session.status != "captured": + _mark_xdb_capture_blocker( + result, + f"AirXDB acceptance capture failed for {result.task_id}", + session, + ) + return result.xdb_sessions diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/contracts.py b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/contracts.py new file mode 100755 index 0000000..77b51f2 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/contracts.py @@ -0,0 +1,617 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List + +from .paths import required_project_artifacts + +KNOWN_TASK_STATUSES = {"TODO", "DOING", "DONE", "BLOCKED"} +ACTIVE_TASK_STATUSES = {"TODO", "DOING"} +FINAL_WORKER_STATUSES = {"done", "blocked", "skipped"} +DOCUMENT_UPDATE_ACTIONS = {"replace_block", "append_lines", "create_file"} +DEFAULT_WORKER_RESULT_NOTE = ( + "Do not merge this result until summary, filesChanged, validations, and risks are reviewed." +) +DEFAULT_DEBUG_POLICY = { + "enabled": True, + "triggerOnBlocked": True, + "triggerValidationStatuses": ["failed", "error"], + "maxSessionsPerTask": 1, + "preferOriginalAirDbg": True, +} +DEFAULT_XDB_POLICY = { + "enabled": True, + "requireForGuiTasks": True, + "captureOnBlocked": True, + "captureOnDone": True, + "triggerValidationStatuses": ["failed", "error"], + "maxSessionsPerTask": 1, + "preferOriginalAirXDB": True, + "remoteFirstIfConfigured": True, + "taskKeywords": [ + "gui", + "ui", + "desktop", + "browser", + "screen", + "screenshot", + "visual", + "layout", + "popup", + "focus", + "canvas", + "acceptance", + "midscene", + "airxdb", + "lvgl", + "lcd", + "qt-app", + ], +} +DEFAULT_REPAIR_POLICY = { + "enabled": True, + "triggerOnBlocked": True, + "triggerValidationStatuses": ["failed", "error"], + "maxAttemptsPerTask": 2, + "requeueTodoStatus": "DOING", + "autoPrepareWorker": True, +} +REQUIRED_PROJECT_ARTIFACTS = required_project_artifacts() + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _dedupe(items: List[str]) -> List[str]: + seen = set() + ordered: List[str] = [] + for item in items: + cleaned = str(item).strip() + if not cleaned or cleaned in seen: + continue + seen.add(cleaned) + ordered.append(cleaned) + return ordered + + +def _ensure_str_list(value: Any) -> List[str]: + if value is None: + return [] + if isinstance(value, list): + return _dedupe([str(item) for item in value]) + return _dedupe([str(value)]) + + +@dataclass +class ValidationRecord: + kind: str + status: str + command: str = "" + evidence: List[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, payload: Dict[str, Any]) -> "ValidationRecord": + return cls( + kind=str(payload.get("kind", "")).strip(), + status=str(payload.get("status", "")).strip(), + command=str(payload.get("command", "")).strip(), + evidence=_ensure_str_list(payload.get("evidence")), + ) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +@dataclass +class TaskRecord: + task_id: str + status: str + module: str + task: str + files_dirs: str + done_when: str + validation: str + adr_c4_update: str + line_number: int + dependencies: List[str] = field(default_factory=list) + write_paths: List[str] = field(default_factory=list) + global_doc_paths: List[str] = field(default_factory=list) + + def normalized_write_set(self) -> List[str]: + return _dedupe(self.write_paths + self.global_doc_paths) + + def touches_global_docs(self) -> bool: + return bool(self.global_doc_paths) + + def to_dict(self) -> Dict[str, Any]: + payload = asdict(self) + payload["taskId"] = payload.pop("task_id") + payload["filesDirs"] = payload.pop("files_dirs") + payload["doneWhen"] = payload.pop("done_when") + payload["adrC4Update"] = payload.pop("adr_c4_update") + payload["lineNumber"] = payload.pop("line_number") + payload["writeSet"] = payload.pop("write_paths") + payload["globalDocPaths"] = payload.pop("global_doc_paths") + return payload + + @classmethod + def from_dict(cls, payload: Dict[str, Any]) -> "TaskRecord": + return cls( + task_id=str(payload.get("taskId", "")).strip(), + status=str(payload.get("status", "")).strip().upper(), + module=str(payload.get("module", "")).strip(), + task=str(payload.get("task", "")).strip(), + files_dirs=str(payload.get("filesDirs", "")).strip(), + done_when=str(payload.get("doneWhen", "")).strip(), + validation=str(payload.get("validation", "")).strip(), + adr_c4_update=str(payload.get("adrC4Update", "")).strip(), + line_number=int(payload.get("lineNumber", 0) or 0), + dependencies=_ensure_str_list(payload.get("dependencies")), + write_paths=_ensure_str_list(payload.get("writeSet")), + global_doc_paths=_ensure_str_list(payload.get("globalDocPaths")), + ) + + +@dataclass +class WorkerResult: + task_id: str + status: str + summary: str + files_changed: List[str] = field(default_factory=list) + validations: List[ValidationRecord] = field(default_factory=list) + evidence_paths: List[str] = field(default_factory=list) + risks: List[str] = field(default_factory=list) + blockers: List[str] = field(default_factory=list) + recommend_global_doc_updates: List[str] = field(default_factory=list) + global_doc_paths: List[str] = field(default_factory=list) + document_updates: List["DocumentUpdate"] = field(default_factory=list) + debug_sessions: List["DebugSession"] = field(default_factory=list) + xdb_sessions: List["XdbSession"] = field(default_factory=list) + repair_attempts: List["RepairAttempt"] = field(default_factory=list) + notes: List[str] = field(default_factory=list) + finalized_at: str = "" + + @classmethod + def from_dict(cls, payload: Dict[str, Any]) -> "WorkerResult": + return cls( + task_id=str(payload.get("taskId", "")).strip(), + status=str(payload.get("status", "")).strip().lower(), + summary=str(payload.get("summary", "")).strip(), + files_changed=_ensure_str_list(payload.get("filesChanged")), + validations=[ + ValidationRecord.from_dict(item) + for item in payload.get("validations", []) + ], + evidence_paths=_ensure_str_list(payload.get("evidencePaths")), + risks=_ensure_str_list(payload.get("risks")), + blockers=_ensure_str_list(payload.get("blockers")), + recommend_global_doc_updates=_ensure_str_list( + payload.get("recommendGlobalDocUpdates") + ), + global_doc_paths=_ensure_str_list(payload.get("globalDocPaths")), + document_updates=[ + DocumentUpdate.from_dict(item) + for item in payload.get("documentUpdates", []) + ], + debug_sessions=[ + DebugSession.from_dict(item) + for item in payload.get("debugSessions", []) + ], + xdb_sessions=[ + XdbSession.from_dict(item) + for item in payload.get("xdbSessions", []) + ], + repair_attempts=[ + RepairAttempt.from_dict(item) + for item in payload.get("repairAttempts", []) + ], + notes=_ensure_str_list(payload.get("notes")), + finalized_at=str(payload.get("finalizedAt", "")).strip(), + ) + + def validate(self) -> None: + if not self.task_id: + raise ValueError("worker result missing taskId") + if self.status not in FINAL_WORKER_STATUSES: + raise ValueError( + f"worker result status must be one of {sorted(FINAL_WORKER_STATUSES)}" + ) + if not self.summary: + raise ValueError("worker result summary must not be empty") + if self.status == "blocked" and not self.blockers: + raise ValueError("blocked worker result must include blockers") + for item in self.document_updates: + item.validate() + for item in self.debug_sessions: + item.validate() + for item in self.xdb_sessions: + item.validate() + for item in self.repair_attempts: + item.validate() + + def has_meaningful_done_payload(self) -> bool: + return bool( + self.files_changed + or self.validations + or self.evidence_paths + or self.document_updates + or self.debug_sessions + or self.xdb_sessions + ) + + def validate_for_finalize(self) -> None: + self.validate() + if self.status == "done" and not self.has_meaningful_done_payload(): + raise ValueError( + "done worker result must include filesChanged, validations, evidencePaths, " + "documentUpdates, debugSessions, or xdbSessions before finalize" + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "taskId": self.task_id, + "status": self.status, + "summary": self.summary, + "filesChanged": self.files_changed, + "validations": [item.to_dict() for item in self.validations], + "evidencePaths": self.evidence_paths, + "risks": self.risks, + "blockers": self.blockers, + "recommendGlobalDocUpdates": self.recommend_global_doc_updates, + "globalDocPaths": self.global_doc_paths, + "documentUpdates": [item.to_dict() for item in self.document_updates], + "debugSessions": [item.to_dict() for item in self.debug_sessions], + "xdbSessions": [item.to_dict() for item in self.xdb_sessions], + "repairAttempts": [item.to_dict() for item in self.repair_attempts], + "notes": self.notes, + "finalizedAt": self.finalized_at, + } + + +def default_worker_result(task_id: str, summary: str = "") -> WorkerResult: + return WorkerResult( + task_id=task_id, + status="done", + summary=summary, + files_changed=[], + validations=[], + evidence_paths=[], + risks=[], + blockers=[], + recommend_global_doc_updates=[], + global_doc_paths=[], + document_updates=[], + debug_sessions=[], + xdb_sessions=[], + repair_attempts=[], + notes=[ + DEFAULT_WORKER_RESULT_NOTE + ], + finalized_at="", + ) + + +@dataclass +class DebugSession: + session_id: str + task_id: str + source: str + trigger: str + reason: str + request_path: str + debug_log_path: str + state_path: str = "" + mode: str = "original-airdbg" + status: str = "requested" + created_at: str = "" + + @classmethod + def from_dict(cls, payload: Dict[str, Any]) -> "DebugSession": + return cls( + session_id=str(payload.get("sessionId", "")).strip(), + task_id=str(payload.get("taskId", "")).strip(), + source=str(payload.get("source", "")).strip(), + trigger=str(payload.get("trigger", "")).strip(), + reason=str(payload.get("reason", "")).strip(), + request_path=str(payload.get("requestPath", "")).strip(), + debug_log_path=str(payload.get("debugLogPath", "")).strip(), + state_path=str(payload.get("statePath", "")).strip(), + mode=str(payload.get("mode", "original-airdbg")).strip(), + status=str(payload.get("status", "requested")).strip(), + created_at=str(payload.get("createdAt", "")).strip(), + ) + + def validate(self) -> None: + if not self.session_id: + raise ValueError("debug session missing sessionId") + if not self.task_id: + raise ValueError("debug session missing taskId") + if not self.request_path: + raise ValueError("debug session missing requestPath") + if not self.debug_log_path: + raise ValueError("debug session missing debugLogPath") + + def to_dict(self) -> Dict[str, Any]: + return { + "sessionId": self.session_id, + "taskId": self.task_id, + "source": self.source, + "trigger": self.trigger, + "reason": self.reason, + "requestPath": self.request_path, + "debugLogPath": self.debug_log_path, + "statePath": self.state_path, + "mode": self.mode, + "status": self.status, + "createdAt": self.created_at, + } + + +@dataclass +class XdbSession: + session_id: str + task_id: str + source: str + trigger: str + reason: str + request_path: str + gui_debug_log_path: str + report_path: str = "" + state_path: str = "" + mode: str = "local-screenshot" + status: str = "captured" + screenshots: List[str] = field(default_factory=list) + created_at: str = "" + remote_target: str = "" + + @classmethod + def from_dict(cls, payload: Dict[str, Any]) -> "XdbSession": + return cls( + session_id=str(payload.get("sessionId", "")).strip(), + task_id=str(payload.get("taskId", "")).strip(), + source=str(payload.get("source", "")).strip(), + trigger=str(payload.get("trigger", "")).strip(), + reason=str(payload.get("reason", "")).strip(), + request_path=str(payload.get("requestPath", "")).strip(), + gui_debug_log_path=str(payload.get("guiDebugLogPath", "")).strip(), + report_path=str(payload.get("reportPath", "")).strip(), + state_path=str(payload.get("statePath", "")).strip(), + mode=str(payload.get("mode", "local-screenshot")).strip(), + status=str(payload.get("status", "captured")).strip(), + screenshots=_ensure_str_list(payload.get("screenshots")), + created_at=str(payload.get("createdAt", "")).strip(), + remote_target=str(payload.get("remoteTarget", "")).strip(), + ) + + def validate(self) -> None: + if not self.session_id: + raise ValueError("xdb session missing sessionId") + if not self.task_id: + raise ValueError("xdb session missing taskId") + if not self.request_path: + raise ValueError("xdb session missing requestPath") + if not self.gui_debug_log_path: + raise ValueError("xdb session missing guiDebugLogPath") + + def to_dict(self) -> Dict[str, Any]: + return { + "sessionId": self.session_id, + "taskId": self.task_id, + "source": self.source, + "trigger": self.trigger, + "reason": self.reason, + "requestPath": self.request_path, + "guiDebugLogPath": self.gui_debug_log_path, + "reportPath": self.report_path, + "statePath": self.state_path, + "mode": self.mode, + "status": self.status, + "screenshots": self.screenshots, + "createdAt": self.created_at, + "remoteTarget": self.remote_target, + } + + +@dataclass +class RepairAttempt: + repair_id: str + task_id: str + attempt_index: int + source_status: str + source_result_path: str + reason: str + repair_brief_path: str + state_path: str = "" + worker_brief_path: str = "" + debug_session_ids: List[str] = field(default_factory=list) + status: str = "queued" + created_at: str = "" + + @classmethod + def from_dict(cls, payload: Dict[str, Any]) -> "RepairAttempt": + return cls( + repair_id=str(payload.get("repairId", "")).strip(), + task_id=str(payload.get("taskId", "")).strip(), + attempt_index=int(payload.get("attemptIndex", 0) or 0), + source_status=str(payload.get("sourceStatus", "")).strip(), + source_result_path=str(payload.get("sourceResultPath", "")).strip(), + reason=str(payload.get("reason", "")).strip(), + repair_brief_path=str(payload.get("repairBriefPath", "")).strip(), + state_path=str(payload.get("statePath", "")).strip(), + worker_brief_path=str(payload.get("workerBriefPath", "")).strip(), + debug_session_ids=_ensure_str_list(payload.get("debugSessionIds")), + status=str(payload.get("status", "queued")).strip(), + created_at=str(payload.get("createdAt", "")).strip(), + ) + + def validate(self) -> None: + if not self.repair_id: + raise ValueError("repair attempt missing repairId") + if not self.task_id: + raise ValueError("repair attempt missing taskId") + if self.attempt_index <= 0: + raise ValueError("repair attempt must have positive attemptIndex") + if not self.repair_brief_path: + raise ValueError("repair attempt missing repairBriefPath") + + def to_dict(self) -> Dict[str, Any]: + return { + "repairId": self.repair_id, + "taskId": self.task_id, + "attemptIndex": self.attempt_index, + "sourceStatus": self.source_status, + "sourceResultPath": self.source_result_path, + "reason": self.reason, + "repairBriefPath": self.repair_brief_path, + "statePath": self.state_path, + "workerBriefPath": self.worker_brief_path, + "debugSessionIds": self.debug_session_ids, + "status": self.status, + "createdAt": self.created_at, + } + + +@dataclass +class DocumentUpdate: + path: str + action: str + content: str = "" + marker: str = "" + append_lines: List[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, payload: Dict[str, Any]) -> "DocumentUpdate": + return cls( + path=str(payload.get("path", "")).strip(), + action=str(payload.get("action", "")).strip(), + content=str(payload.get("content", "")).rstrip(), + marker=str(payload.get("marker", "")).strip(), + append_lines=_ensure_str_list(payload.get("appendLines")), + ) + + def validate(self) -> None: + if not self.path: + raise ValueError("document update missing path") + if self.action not in DOCUMENT_UPDATE_ACTIONS: + raise ValueError( + f"document update action must be one of {sorted(DOCUMENT_UPDATE_ACTIONS)}" + ) + if self.action == "replace_block" and not self.marker: + raise ValueError("replace_block document update requires marker") + if self.action == "append_lines" and not self.append_lines: + raise ValueError("append_lines document update requires appendLines") + if self.action in {"replace_block", "create_file"} and not self.content: + raise ValueError(f"{self.action} document update requires content") + if self.action in {"replace_block", "create_file"} and "TODO" in self.content: + raise ValueError( + f"{self.action} document update for {self.path} still contains TODO placeholders" + ) + if self.action == "append_lines" and any("TODO" in line for line in self.append_lines): + raise ValueError( + f"append_lines document update for {self.path} still contains TODO placeholders" + ) + + def to_dict(self) -> Dict[str, Any]: + payload = { + "path": self.path, + "action": self.action, + } + if self.content: + payload["content"] = self.content + if self.marker: + payload["marker"] = self.marker + if self.append_lines: + payload["appendLines"] = self.append_lines + return payload + + +@dataclass +class ReviewConflict: + task_ids: List[str] + reason: str + overlap_paths: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + "taskIds": self.task_ids, + "reason": self.reason, + "overlapPaths": self.overlap_paths, + } + + @classmethod + def from_dict(cls, payload: Dict[str, Any]) -> "ReviewConflict": + return cls( + task_ids=_ensure_str_list(payload.get("taskIds")), + reason=str(payload.get("reason", "")).strip(), + overlap_paths=_ensure_str_list(payload.get("overlapPaths")), + ) + + +@dataclass +class ParallelGroup: + name: str + task_ids: List[str] + reason: str + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "taskIds": self.task_ids, + "reason": self.reason, + } + + @classmethod + def from_dict(cls, payload: Dict[str, Any]) -> "ParallelGroup": + return cls( + name=str(payload.get("name", "")).strip(), + task_ids=_ensure_str_list(payload.get("taskIds")), + reason=str(payload.get("reason", "")).strip(), + ) + + +@dataclass +class ParallelReview: + source_todo: str + generated_at: str + tasks_considered: List[TaskRecord] + dependency_edges: List[Dict[str, str]] + parallel_groups: List[ParallelGroup] + conflicts: List[ReviewConflict] + serialization_points: List[Dict[str, Any]] + notes: List[str] + + def to_dict(self) -> Dict[str, Any]: + return { + "sourceTodo": self.source_todo, + "generatedAt": self.generated_at, + "tasksConsidered": [task.to_dict() for task in self.tasks_considered], + "dependencyEdges": self.dependency_edges, + "parallelGroups": [group.to_dict() for group in self.parallel_groups], + "conflicts": [conflict.to_dict() for conflict in self.conflicts], + "serializationPoints": self.serialization_points, + "notes": self.notes, + } + + @classmethod + def from_dict(cls, payload: Dict[str, Any]) -> "ParallelReview": + return cls( + source_todo=str(payload.get("sourceTodo", "")).strip(), + generated_at=str(payload.get("generatedAt", "")).strip(), + tasks_considered=[ + TaskRecord.from_dict(item) + for item in payload.get("tasksConsidered", []) + ], + dependency_edges=list(payload.get("dependencyEdges", [])), + parallel_groups=[ + ParallelGroup.from_dict(item) + for item in payload.get("parallelGroups", []) + ], + conflicts=[ + ReviewConflict.from_dict(item) + for item in payload.get("conflicts", []) + ], + serialization_points=list(payload.get("serializationPoints", [])), + notes=_ensure_str_list(payload.get("notes")), + ) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/debug_runtime.py b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/debug_runtime.py new file mode 100755 index 0000000..e8cb0bc --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/debug_runtime.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from typing import Dict, Iterable, List, Tuple + +from .contracts import DEFAULT_DEBUG_POLICY, DebugSession, WorkerResult, now_iso +from .paths import airdbg_root, agents_path, aireng_root, architecture_adr_dir, architecture_c4_module_path, debug_log_path + + +def _json_load(path: Path) -> Dict[str, object]: + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8-sig")) + + +def _json_dump(path: Path, payload: Dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def _ordered_unique(items: Iterable[str]) -> List[str]: + seen = set() + ordered: List[str] = [] + for item in items: + cleaned = str(item).strip() + if not cleaned or cleaned in seen: + continue + seen.add(cleaned) + ordered.append(cleaned) + return ordered + + +def normalize_debug_policy(policy: Dict[str, object] | None = None) -> Dict[str, object]: + merged = dict(DEFAULT_DEBUG_POLICY) + if policy: + merged.update(policy) + + merged["enabled"] = bool(merged.get("enabled", True)) + merged["triggerOnBlocked"] = bool(merged.get("triggerOnBlocked", True)) + merged["preferOriginalAirDbg"] = bool(merged.get("preferOriginalAirDbg", True)) + merged["triggerValidationStatuses"] = _ordered_unique( + str(item).lower() for item in list(merged.get("triggerValidationStatuses", [])) + ) + + try: + max_sessions = int(merged.get("maxSessionsPerTask", 1) or 1) + except (TypeError, ValueError): + max_sessions = 1 + merged["maxSessionsPerTask"] = max(1, max_sessions) + return merged + + +def load_debug_policy(project_root: Path) -> Dict[str, object]: + state_path = aireng_root(project_root) / "state.json" + state = _json_load(state_path) + return normalize_debug_policy(state.get("debugPolicy", {})) + + +def merge_debug_sessions_into_state( + state: Dict[str, object], debug_sessions: List[DebugSession] +) -> Dict[str, object]: + state["debugPolicy"] = normalize_debug_policy(dict(state.get("debugPolicy", {}))) + + existing_items = list(state.get("debugSessions", [])) + seen_ids = { + str(item.get("sessionId", "")).strip() + for item in existing_items + if isinstance(item, dict) + } + for session in debug_sessions: + if session.session_id in seen_ids: + continue + existing_items.append(session.to_dict()) + seen_ids.add(session.session_id) + + task_counts: Dict[str, int] = {} + for item in existing_items: + task_id = str(item.get("taskId", "")).strip() + if not task_id: + continue + task_counts[task_id] = task_counts.get(task_id, 0) + 1 + + state["debugSessions"] = existing_items + state["taskDebugCounts"] = task_counts + if debug_sessions: + latest = debug_sessions[-1] + state["lastDebugSessionId"] = latest.session_id + state["lastDebugTaskId"] = latest.task_id + state["lastDebugAt"] = latest.created_at + return state + + +def _debug_paths(project_root: Path) -> Dict[str, Path]: + root = airdbg_root(project_root) + return { + "root": root, + "state": root / "state.json", + "requests": root / "requests", + "sessions": root / "sessions", + "debug_log": debug_log_path(project_root), + } + + +def _airdbg_health(project_root: Path) -> Dict[str, bool]: + paths = _debug_paths(project_root) + return { + "AirPlan/AGENTS.md": agents_path(project_root).exists(), + "AirPlan/docs/architecture/c4/module.md": architecture_c4_module_path(project_root).exists(), + "AirPlan/docs/architecture/adr": architecture_adr_dir(project_root).exists(), + "debug_log": paths["debug_log"].exists(), + } + + +def _ensure_debug_layout(project_root: Path) -> Dict[str, Path]: + paths = _debug_paths(project_root) + paths["requests"].mkdir(parents=True, exist_ok=True) + paths["sessions"].mkdir(parents=True, exist_ok=True) + paths["debug_log"].parent.mkdir(parents=True, exist_ok=True) + if not paths["debug_log"].exists(): + paths["debug_log"].write_text("# Debug Log\n\n", encoding="utf-8") + return paths + + +def _resolve_original_airdbg_script() -> Path | None: + candidates = [ + Path.home() / "plugins" / "airdbg" / "scripts" / "airdbg_mode.py", + Path(r"C:\Users\20392\plugins\airdbg\scripts\airdbg_mode.py"), + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + +def _bootstrap_airdbg(project_root: Path, prefer_original: bool) -> Tuple[str, str, str]: + mode = "runtime-bootstrap" + stdout = "" + error = "" + script_path = _resolve_original_airdbg_script() if prefer_original else None + + if script_path is not None: + try: + completed = subprocess.run( + [ + sys.executable, + str(script_path), + "--mode", + "enter", + "--project", + str(project_root), + ], + capture_output=True, + text=True, + check=True, + ) + mode = "original-airdbg" + stdout = completed.stdout.strip() + except subprocess.CalledProcessError as exc: + mode = "runtime-fallback" + stdout = exc.stdout.strip() + error = exc.stderr.strip() or str(exc) + + paths = _ensure_debug_layout(project_root) + state = _json_load(paths["state"]) + state["enabled"] = True + state["updatedAt"] = now_iso() + state["projectRoot"] = str(project_root) + state["artifactHealth"] = _airdbg_health(project_root) + if stdout: + state["lastBootstrapStdout"] = stdout + if error: + state["lastBootstrapError"] = error + _json_dump(paths["state"], state) + return mode, stdout, error + + +def list_debug_sessions(project_root: Path, task_id: str = "") -> List[DebugSession]: + session_dir = _debug_paths(project_root)["sessions"] + if not session_dir.exists(): + return [] + + sessions: List[DebugSession] = [] + for path in sorted(session_dir.glob("*.json")): + try: + payload = json.loads(path.read_text(encoding="utf-8-sig")) + session_payload = payload.get("debugSession", payload) + session = DebugSession.from_dict(session_payload) + session.validate() + except (json.JSONDecodeError, TypeError, ValueError): + continue + if task_id and session.task_id != task_id: + continue + sessions.append(session) + + sessions.sort(key=lambda item: (item.created_at, item.session_id)) + return sessions + + +def _result_trigger_summary( + result: WorkerResult, policy: Dict[str, object] +) -> Tuple[List[str], str]: + triggers: List[str] = [] + reasons: List[str] = [] + + if result.status == "blocked" and bool(policy.get("triggerOnBlocked", True)): + triggers.append("blocked-status") + blocker_text = ", ".join(result.blockers) if result.blockers else "worker returned blocked" + reasons.append(f"worker result is blocked: {blocker_text}") + + tracked_validation_statuses = { + str(item).lower() for item in list(policy.get("triggerValidationStatuses", [])) + } + failed_validations = [ + item + for item in result.validations + if item.status.lower() in tracked_validation_statuses + ] + if failed_validations: + triggers.append("validation-failure") + reasons.append( + "validation failures: " + + ", ".join(f"{item.kind}:{item.status}" for item in failed_validations) + ) + + return triggers, "; ".join(reasons) + + +def _session_stamp() -> str: + return now_iso().replace(":", "-").replace(".", "-").replace("+", "-") + + +def _append_debug_log( + debug_log_path: Path, + result: WorkerResult, + session: DebugSession, + bootstrap_mode: str, +) -> None: + section_lines = [ + f"### {session.created_at}: auto-debug request for {result.task_id}", + "", + f"- Task: `{result.task_id}`", + f"- Source: `{session.source}`", + f"- Trigger: `{session.trigger}`", + f"- Symptom: {result.summary}", + "- Expected: Task validations should pass, or the task should complete without blockers.", + f"- Actual: worker status `{result.status}`; blockers={', '.join(result.blockers) or 'none'}", + "- Reproduction: finalize the worker result and let the runtime auto-route the failure into AirDbg.", + "- Root cause: pending AirDbg investigation.", + f"- Fix: pending AirDbg session `{session.session_id}`", + ( + "- Validation: debug request stored at " + f"`{session.request_path}`; log appended automatically; bootstrap mode `{bootstrap_mode}`" + ), + f"- Evidence: {', '.join(f'`{item}`' for item in result.evidence_paths) or '`none`'}", + "- ADR/C4 updates: none yet", + "- Residual risk: the underlying blocker is unresolved until the debug session is completed.", + "", + ] + existing = debug_log_path.read_text(encoding="utf-8") if debug_log_path.exists() else "# Debug Log\n\n" + debug_log_path.write_text(existing.rstrip() + "\n\n" + "\n".join(section_lines), encoding="utf-8") + + +def _update_airdbg_state( + project_root: Path, + session: DebugSession, + bootstrap_mode: str, + bootstrap_stdout: str, + bootstrap_error: str, +) -> None: + paths = _ensure_debug_layout(project_root) + state = _json_load(paths["state"]) + sessions = list(state.get("autoDebugSessions", [])) + known_ids = { + str(item.get("sessionId", "")).strip() + for item in sessions + if isinstance(item, dict) + } + if session.session_id not in known_ids: + sessions.append( + { + "sessionId": session.session_id, + "taskId": session.task_id, + "source": session.source, + "trigger": session.trigger, + "status": session.status, + "createdAt": session.created_at, + "requestPath": session.request_path, + } + ) + + state["enabled"] = True + state["updatedAt"] = now_iso() + state["projectRoot"] = str(project_root) + state["artifactHealth"] = _airdbg_health(project_root) + state["autoDebugSessions"] = sessions + state["autoDebugSessionCount"] = len(sessions) + state["lastAutoDebugAt"] = session.created_at + state["lastAutoDebugSessionId"] = session.session_id + state["lastBootstrapMode"] = bootstrap_mode + if bootstrap_stdout: + state["lastBootstrapStdout"] = bootstrap_stdout + if bootstrap_error: + state["lastBootstrapError"] = bootstrap_error + _json_dump(paths["state"], state) + + +def _attach_session_to_result(result: WorkerResult, session: DebugSession, note: str) -> None: + if not any(item.session_id == session.session_id for item in result.debug_sessions): + result.debug_sessions.append(session) + for path in [session.request_path, session.debug_log_path, session.state_path]: + if path and path not in result.evidence_paths: + result.evidence_paths.append(path) + if note not in result.notes: + result.notes.append(note) + + +def ensure_debug_sessions_for_result( + project_root: Path, result: WorkerResult, source: str +) -> List[DebugSession]: + policy = load_debug_policy(project_root) + if not bool(policy.get("enabled", True)): + return result.debug_sessions + + triggers, reason = _result_trigger_summary(result, policy) + if not triggers: + return result.debug_sessions + + existing_sessions = list_debug_sessions(project_root, result.task_id) + if result.debug_sessions: + for session in result.debug_sessions: + _attach_session_to_result( + result, + session, + f"Debug session already linked for {result.task_id}: {session.session_id}", + ) + return result.debug_sessions + + if existing_sessions: + latest = existing_sessions[-1] + _attach_session_to_result( + result, + latest, + f"Reused existing debug session for {result.task_id}: {latest.session_id}", + ) + return result.debug_sessions + + session_limit = int(policy.get("maxSessionsPerTask", 1) or 1) + if len(existing_sessions) >= session_limit: + return result.debug_sessions + + paths = _ensure_debug_layout(project_root) + bootstrap_mode, bootstrap_stdout, bootstrap_error = _bootstrap_airdbg( + project_root, bool(policy.get("preferOriginalAirDbg", True)) + ) + + created_at = now_iso() + session_id = f"{result.task_id}-{_session_stamp()}" + request_path = paths["requests"] / f"{session_id}.json" + session_path = paths["sessions"] / f"{session_id}.json" + trigger = ",".join(triggers) + + request_payload = { + "sessionId": session_id, + "taskId": result.task_id, + "source": source, + "trigger": trigger, + "reason": reason or result.summary, + "workerStatus": result.status, + "summary": result.summary, + "filesChanged": result.files_changed, + "validations": [item.to_dict() for item in result.validations], + "evidencePaths": result.evidence_paths, + "risks": result.risks, + "blockers": result.blockers, + "createdAt": created_at, + "bootstrapMode": bootstrap_mode, + "bootstrapStdout": bootstrap_stdout, + "bootstrapError": bootstrap_error, + } + _json_dump(request_path, request_payload) + + session = DebugSession( + session_id=session_id, + task_id=result.task_id, + source=source, + trigger=trigger, + reason=reason or result.summary, + request_path=str(request_path), + debug_log_path=str(paths["debug_log"]), + state_path=str(paths["state"]), + mode=bootstrap_mode, + status="requested", + created_at=created_at, + ) + _json_dump( + session_path, + { + "debugSession": session.to_dict(), + "request": request_payload, + }, + ) + + _append_debug_log(paths["debug_log"], result, session, bootstrap_mode) + _update_airdbg_state(project_root, session, bootstrap_mode, bootstrap_stdout, bootstrap_error) + _attach_session_to_result( + result, + session, + f"Auto-requested debug session for {result.task_id}: {session.session_id}", + ) + return result.debug_sessions diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/doc_sync.py b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/doc_sync.py new file mode 100755 index 0000000..25c97a4 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/doc_sync.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Dict, Iterable, List + +from .contracts import DocumentUpdate, ValidationRecord, WorkerResult, now_iso +from .paths import ( + agents_path, + architecture_c4_module_path, + plan_path as workflow_plan_path, + todo_path as workflow_todo_path, +) +from .todo_parser import parse_tasks + + +def _project_relative(project_root: Path, raw_path: str) -> Path: + candidate = Path(raw_path) + if candidate.is_absolute(): + resolved = candidate.resolve() + else: + resolved = (project_root / candidate).resolve() + + project_root_resolved = project_root.resolve() + try: + resolved.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError(f"document update path escapes project root: {raw_path}") from exc + return resolved + + +def _replace_marker_block(text: str, marker: str, content: str) -> str: + begin = f"" + end = f"" + block = f"{begin}\n{content.rstrip()}\n{end}\n" + start_index = text.find(begin) + end_index = text.find(end) + if start_index >= 0 and end_index > start_index: + end_index += len(end) + return text[:start_index].rstrip() + "\n\n" + block + text[end_index:].lstrip() + return text.rstrip() + "\n\n" + block + + +def _extract_marker_block(text: str, marker: str) -> str: + begin = f"" + end = f"" + start_index = text.find(begin) + end_index = text.find(end) + if start_index < 0 or end_index <= start_index: + return "" + content_start = start_index + len(begin) + return text[content_start:end_index].strip() + + +def _apply_document_update(project_root: Path, update: DocumentUpdate) -> str: + target_path = _project_relative(project_root, update.path) + target_path.parent.mkdir(parents=True, exist_ok=True) + existing = target_path.read_text(encoding="utf-8") if target_path.exists() else "" + + if update.action == "create_file": + target_path.write_text(update.content.rstrip() + "\n", encoding="utf-8") + elif update.action == "append_lines": + joined = "\n".join(update.append_lines).rstrip() + if existing.strip(): + target_path.write_text(existing.rstrip() + "\n" + joined + "\n", encoding="utf-8") + else: + target_path.write_text(joined + "\n", encoding="utf-8") + elif update.action == "replace_block": + updated = _replace_marker_block(existing, update.marker, update.content) + target_path.write_text(updated, encoding="utf-8") + else: + raise ValueError(f"unsupported document update action: {update.action}") + + return str(target_path) + + +def apply_document_updates(project_root: Path, result: WorkerResult) -> List[str]: + applied: List[str] = [] + for update in result.document_updates: + applied.append(_apply_document_update(project_root, update)) + return applied + + +def _validation_summary(validations: List[ValidationRecord]) -> str: + if not validations: + return "No explicit validation recorded" + return "; ".join( + f"{item.kind}:{item.status}" + (f" ({item.command})" if item.command else "") + for item in validations + ) + + +def _debug_summary(result: WorkerResult) -> str: + if not result.debug_sessions: + return "none" + return ", ".join( + f"{item.session_id} [{item.trigger}]" + for item in result.debug_sessions + ) + + +def _xdb_summary(result: WorkerResult) -> str: + if not result.xdb_sessions: + return "none" + return ", ".join( + f"{item.session_id} [{item.mode}:{item.status}]" + for item in result.xdb_sessions + ) + + +def _repair_summary(result: WorkerResult) -> str: + if not result.repair_attempts: + return "none" + return ", ".join( + f"{item.repair_id} [{item.status}]" + for item in result.repair_attempts + ) + + +def _todo_status_from_worker_status(worker_status: str) -> str: + if worker_status == "done": + return "DONE" + return "BLOCKED" + + +def _todo_status_from_result(result: WorkerResult) -> str: + if result.status == "done": + return "DONE" + if result.repair_attempts: + return "DOING" + return "BLOCKED" + + +def _rebuild_table_row(cells: List[str]) -> str: + return "| " + " | ".join(cells) + " |" + + +def update_todo_after_merge( + project_root: Path, + result: WorkerResult, + applied_doc_paths: List[str], + sync_paths: List[str], +) -> Path: + todo_path = workflow_todo_path(project_root) + original_text = todo_path.read_text(encoding="utf-8") + lines = original_text.splitlines() + updated_lines: List[str] = [] + task_found = False + + unique_applied_doc_paths = list(dict.fromkeys(applied_doc_paths)) + unique_sync_paths = list(dict.fromkeys(sync_paths)) + for raw_line in lines: + if raw_line.startswith(f"| {result.task_id} "): + cells = [cell.strip() for cell in raw_line.strip().strip("|").split("|")] + if len(cells) > 8: + cells = cells[:7] + ["; ".join(cell for cell in cells[7:] if cell)] + if len(cells) >= 8: + cells[1] = _todo_status_from_result(result) + validation_cell = _validation_summary(result.validations) + if result.evidence_paths: + validation_cell += f"; evidence={len(result.evidence_paths)}" + if result.blockers: + validation_cell += "; blockers=" + ", ".join(result.blockers) + if result.xdb_sessions: + validation_cell += f"; xdb={len(result.xdb_sessions)}" + if result.debug_sessions: + validation_cell += f"; debug={len(result.debug_sessions)}" + if result.repair_attempts: + validation_cell += f"; repair={len(result.repair_attempts)}" + cells[6] = validation_cell + if unique_applied_doc_paths: + cells[7] = "Merged by engine: " + ", ".join( + Path(path).name for path in unique_applied_doc_paths + ) + updated_lines.append(_rebuild_table_row(cells)) + task_found = True + continue + updated_lines.append(raw_line) + + if not task_found: + raise ValueError(f"task row not found in todo.md for {result.task_id}") + + log_lines = [ + f"- `{now_iso()}` task `{result.task_id}` merged with status `{result.status}`", + f" Summary: {result.summary}", + f" Files Changed: {', '.join(f'`{item}`' for item in result.files_changed) or '`none`'}", + f" Validations: {_validation_summary(result.validations)}", + f" Evidence: {', '.join(f'`{item}`' for item in result.evidence_paths) or '`none`'}", + f" XDB Sessions: {_xdb_summary(result)}", + f" Debug Sessions: {_debug_summary(result)}", + f" Repair Attempts: {_repair_summary(result)}", + f" Risks: {', '.join(result.risks) or 'none'}", + f" Blockers: {', '.join(result.blockers) or 'none'}", + f" Applied Doc Paths: {', '.join(f'`{item}`' for item in unique_applied_doc_paths) or '`none`'}", + f" Engine Sync Paths: {', '.join(f'`{item}`' for item in unique_sync_paths) or '`none`'}", + ] + existing_log = _extract_marker_block(original_text, "TODO-RUN-LOG") + existing_body = "" + if existing_log: + existing_lines = existing_log.splitlines() + if existing_lines and existing_lines[0].strip() in {"# Air Engine Merge Log", "# AirEng Merge Log"}: + existing_body = "\n".join(existing_lines[1:]).strip() + else: + existing_body = existing_log.strip() + + merged_log_content = "# AirEng Merge Log\n\n" + "\n".join(log_lines) + if existing_body: + merged_log_content += "\n\n" + existing_body + + text = "\n".join(updated_lines).rstrip() + "\n" + text = _replace_marker_block( + text, + "TODO-RUN-LOG", + merged_log_content, + ) + todo_path.write_text(text, encoding="utf-8") + return todo_path + + +def mark_tasks_dispatched( + project_root: Path, + group_name: str, + dispatched_tasks: List[Dict[str, object]], + recommended_concurrency: int, +) -> List[str]: + updated_paths: List[str] = [] + current_todo_path = workflow_todo_path(project_root) + current_plan_path = workflow_plan_path(project_root) + task_ids = {str(item.get("taskId", "")).strip() for item in dispatched_tasks if str(item.get("taskId", "")).strip()} + + if current_todo_path.exists() and task_ids: + lines = current_todo_path.read_text(encoding="utf-8").splitlines() + rewritten: List[str] = [] + for raw_line in lines: + if raw_line.startswith("| "): + cells = [cell.strip() for cell in raw_line.strip().strip("|").split("|")] + if cells and cells[0] in task_ids and len(cells) >= 2: + cells[1] = "DOING" + raw_line = _rebuild_table_row(cells) + rewritten.append(raw_line) + current_todo_path.write_text("\n".join(rewritten).rstrip() + "\n", encoding="utf-8") + updated_paths.append(str(current_todo_path)) + + if current_plan_path.exists(): + plan_text = current_plan_path.read_text(encoding="utf-8") + lines = [ + "## AirEng Active Dispatch", + "", + f"- Updated At: `{now_iso()}`", + f"- Group: `{group_name or 'default'}`", + f"- Recommended Concurrency: `{recommended_concurrency}`", + "- Tasks:", + ] + if dispatched_tasks: + for item in dispatched_tasks: + lines.append( + f" - `{item.get('taskId', '')}` handoff=`{item.get('handoffPath', '')}` brief=`{item.get('briefPath', '')}`" + ) + else: + lines.append(" - none") + plan_text = _replace_marker_block(plan_text, "DISPATCH-STATUS", "\n".join(lines)) + current_plan_path.write_text(plan_text, encoding="utf-8") + updated_paths.append(str(current_plan_path)) + + return updated_paths + + +def _sync_block_content(result: WorkerResult, applied_doc_paths: List[str], title: str) -> str: + unique_applied_doc_paths = list(dict.fromkeys(applied_doc_paths)) + lines = [ + title, + "", + f"- Last Synced At: `{now_iso()}`", + f"- Task: `{result.task_id}`", + f"- Status: `{result.status}`", + f"- Summary: {result.summary}", + f"- Files Changed: {', '.join(f'`{item}`' for item in result.files_changed) or '`none`'}", + f"- Validations: {_validation_summary(result.validations)}", + f"- XDB Sessions: {_xdb_summary(result)}", + f"- Debug Sessions: {_debug_summary(result)}", + f"- Repair Attempts: {_repair_summary(result)}", + f"- Applied Doc Paths: {', '.join(f'`{item}`' for item in unique_applied_doc_paths) or '`none`'}", + f"- Risks: {', '.join(result.risks) or 'none'}", + f"- Blockers: {', '.join(result.blockers) or 'none'}", + ] + return "\n".join(lines) + + +def sync_engine_managed_docs(project_root: Path, result: WorkerResult, applied_doc_paths: List[str]) -> List[str]: + updated_paths: List[str] = [] + current_agents_path = agents_path(project_root) + if current_agents_path.exists(): + agents_text = current_agents_path.read_text(encoding="utf-8") + agents_text = _replace_marker_block( + agents_text, + "AGENTS-SYNC", + _sync_block_content(result, applied_doc_paths, "## AirEng Sync"), + ) + current_agents_path.write_text(agents_text, encoding="utf-8") + updated_paths.append(str(current_agents_path)) + + c4_path = architecture_c4_module_path(project_root) + if c4_path.exists(): + c4_text = c4_path.read_text(encoding="utf-8") + c4_text = _replace_marker_block( + c4_text, + "C4-SYNC", + _sync_block_content(result, applied_doc_paths, "## AirEng Sync"), + ) + c4_path.write_text(c4_text, encoding="utf-8") + updated_paths.append(str(c4_path)) + + return updated_paths + + +def enforce_doc_sync_requirements(project_root: Path, result: WorkerResult) -> None: + task = None + todo_path = workflow_todo_path(project_root) + if todo_path.exists(): + task = next((item for item in parse_tasks(todo_path) if item.task_id == result.task_id), None) + + required_doc_paths = set(result.global_doc_paths) + if task is not None: + required_doc_paths.update(task.global_doc_paths) + + if result.status != "done": + return + + if not required_doc_paths: + return + + covered_paths = set() + for update in result.document_updates: + covered_paths.add(update.path) + + missing_paths = [] + for required in sorted(required_doc_paths): + if required == "AirPlan/docs/architecture/adr/": + if not any("AirPlan/docs/architecture/adr/" in path.replace("\\", "/") for path in covered_paths): + missing_paths.append(required) + continue + if required not in covered_paths: + missing_paths.append(required) + + if missing_paths: + raise ValueError( + "document sync is incomplete for task " + + result.task_id + + ": missing updates for " + + ", ".join(missing_paths) + ) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/engine.py b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/engine.py new file mode 100755 index 0000000..3c18287 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/engine.py @@ -0,0 +1,1082 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Tuple + +from .airxdb_runtime import ( + ensure_xdb_sessions_for_result, + merge_xdb_sessions_into_state, + normalize_xdb_policy, +) +from .contracts import ( + DEFAULT_DEBUG_POLICY, + DEFAULT_XDB_POLICY, + ParallelReview, + REQUIRED_PROJECT_ARTIFACTS, + WorkerResult, + now_iso, +) +from .debug_runtime import ( + ensure_debug_sessions_for_result, + merge_debug_sessions_into_state, + normalize_debug_policy, +) +from .doc_sync import ( + apply_document_updates, + enforce_doc_sync_requirements, + mark_tasks_dispatched, + sync_engine_managed_docs, + update_todo_after_merge, +) +from .paths import airarc_root, aireng_root, todo_path as workflow_todo_path +from .repair_runtime import ( + ensure_repair_attempts_for_result, + load_active_repair_attempt, + mark_repair_attempts_active, + merge_repair_attempts_into_state, + normalize_repair_policy, + write_repair_queue, +) +from .review import build_parallel_review, render_review_markdown +from .todo_parser import parse_tasks +from .worker import enter_worker + + +def _json_dump(path: Path, payload: Dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def _json_load(path: Path) -> Dict[str, object]: + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8-sig")) + + +def _default_autonomy_policy() -> Dict[str, object]: + return { + "mayRunUnattended": True, + "mayEditFiles": True, + "mayRunCommands": True, + "defaultNoCodeExecution": True, + "subagentFirst": True, + "interventionScope": [ + "dispatch-state-repair", + "worker-redispatch", + "repair-brief-refresh", + "global-doc-convergence", + "glue-layer-unblock", + ], + } + + +def _default_monitoring_policy() -> Dict[str, object]: + return { + "checkIntervalSeconds": 300, + "stallAfterSeconds": 1800, + "maxInterventionAttemptsPerTask": 2, + } + + +def _default_active_worker() -> Dict[str, object]: + return { + "taskId": "", + "dispatchGroup": "", + "waveId": "", + "briefPath": "", + "handoffPath": "", + "workerStatePath": "", + "resultPath": "", + "spawnedAt": "", + "lastObservedAt": "", + "lastHeartbeatAt": "", + "status": "queued", + "lastResultPath": "", + "stallCount": 0, + "interventionCount": 0, + "repairDispatchPath": "", + "notes": [], + } + + +def _normalize_active_worker(payload: Dict[str, object]) -> Dict[str, object]: + worker = _default_active_worker() + worker.update(payload) + worker["notes"] = [str(item) for item in list(worker.get("notes", []))] + worker["stallCount"] = int(worker.get("stallCount", 0) or 0) + worker["interventionCount"] = int(worker.get("interventionCount", 0) or 0) + return worker + + +def _default_state(project_root: Path) -> Dict[str, object]: + return { + "enabled": False, + "updatedAt": "", + "projectRoot": str(project_root), + "artifactHealth": artifact_health(project_root), + "mergedResults": [], + "pendingGlobalUpdates": [], + "planningSource": "", + "reviewSourcePath": "", + "debugPolicy": normalize_debug_policy(DEFAULT_DEBUG_POLICY), + "debugSessions": [], + "taskDebugCounts": {}, + "xdbPolicy": normalize_xdb_policy(DEFAULT_XDB_POLICY), + "xdbSessions": [], + "taskXdbCounts": {}, + "repairPolicy": normalize_repair_policy(), + "repairAttempts": [], + "activeRepairCount": 0, + "engineMode": "idle", + "autonomyPolicy": _default_autonomy_policy(), + "monitoringPolicy": _default_monitoring_policy(), + "activeWaveId": "", + "activeDispatchPath": "", + "activeWorkers": [], + "dispatchHistory": [], + "dispatchedGroups": [], + "nextAction": "plan-or-dispatch", + "nextActionDetails": {"type": "plan-or-dispatch"}, + "lastLoopAt": "", + "lastInterventionAt": "", + "interventionHistory": [], + } + + +def _ensure_state_defaults(project_root: Path, state: Dict[str, object]) -> Dict[str, object]: + merged = _default_state(project_root) + merged.update(state) + merged["artifactHealth"] = artifact_health(project_root) + merged["debugPolicy"] = normalize_debug_policy(dict(merged.get("debugPolicy", {}))) + merged["xdbPolicy"] = normalize_xdb_policy(dict(merged.get("xdbPolicy", {}))) + merged["repairPolicy"] = normalize_repair_policy(dict(merged.get("repairPolicy", {}))) + merged["autonomyPolicy"] = { + **_default_autonomy_policy(), + **dict(merged.get("autonomyPolicy", {})), + } + merged["monitoringPolicy"] = { + **_default_monitoring_policy(), + **dict(merged.get("monitoringPolicy", {})), + } + merged.setdefault("debugSessions", []) + merged.setdefault("taskDebugCounts", {}) + merged.setdefault("xdbSessions", []) + merged.setdefault("taskXdbCounts", {}) + merged.setdefault("mergedResults", []) + merged.setdefault("pendingGlobalUpdates", []) + merged.setdefault("repairAttempts", []) + merged.setdefault("activeRepairCount", 0) + merged.setdefault("dispatchHistory", []) + merged.setdefault("dispatchedGroups", []) + merged.setdefault("interventionHistory", []) + merged["activeWorkers"] = [ + _normalize_active_worker(item) + for item in list(merged.get("activeWorkers", [])) + if isinstance(item, dict) + ] + merged.setdefault("nextAction", "plan-or-dispatch") + merged.setdefault("nextActionDetails", {"type": str(merged.get("nextAction", "plan-or-dispatch"))}) + return merged + + +def artifact_health(project_root: Path) -> Dict[str, bool]: + health: Dict[str, bool] = {} + for relative in REQUIRED_PROJECT_ARTIFACTS: + health[relative] = (project_root / relative).exists() + return health + + +def _engine_paths(project_root: Path) -> Dict[str, Path]: + engine_root = aireng_root(project_root) + return { + "root": engine_root, + "state": engine_root / "state.json", + "results": engine_root / "results", + "reviews": engine_root / "reviews", + "checkpoints": engine_root / "checkpoints", + "dispatch": engine_root / "dispatch", + "plan": engine_root / "plan.json", + "plan_md": engine_root / "plan.md", + "doc_queue_md": engine_root / "doc-update-queue.md", + } + + +def _ensure_layout(project_root: Path) -> Dict[str, Path]: + paths = _engine_paths(project_root) + paths["results"].mkdir(parents=True, exist_ok=True) + paths["reviews"].mkdir(parents=True, exist_ok=True) + paths["checkpoints"].mkdir(parents=True, exist_ok=True) + paths["dispatch"].mkdir(parents=True, exist_ok=True) + return paths + + +def _set_next_action(state: Dict[str, object], action: str, **details: object) -> Dict[str, object]: + state["nextAction"] = action + state["nextActionDetails"] = {"type": action, **details} + return state + + +def _mtime_iso(path: Path) -> str: + if not path.exists(): + return "" + return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat() + + +def _seconds_since_mtime(path: Path) -> float | None: + if not path.exists(): + return None + return max(datetime.now(timezone.utc).timestamp() - path.stat().st_mtime, 0.0) + + +def _sanitize_stamp(value: str) -> str: + return value.replace(":", "-").replace("+", "_") + + +def _next_dispatch_group_name(state: Dict[str, object], plan_payload: Dict[str, object]) -> str: + dispatched_groups = {str(item).strip() for item in list(state.get("dispatchedGroups", [])) if str(item).strip()} + for group in list(plan_payload.get("parallelGroups", [])): + if not isinstance(group, dict): + continue + group_name = str(group.get("name", "")).strip() + if group_name and group_name not in dispatched_groups: + return group_name + return "" + + +def _refresh_engine_progress(project_root: Path, state: Dict[str, object]) -> Dict[str, object]: + plan_payload = _json_load(_engine_paths(project_root)["plan"]) + active_workers = [ + _normalize_active_worker(item) + for item in list(state.get("activeWorkers", [])) + if isinstance(item, dict) + ] + state["activeWorkers"] = active_workers + + blocked_workers = [item for item in active_workers if str(item.get("status", "")) == "blocked"] + if blocked_workers: + state["engineMode"] = "blocked" + return _set_next_action( + state, + "user-decision-required", + blockedTaskIds=[item.get("taskId", "") for item in blocked_workers], + ) + + if active_workers: + state["engineMode"] = "monitoring" + return _set_next_action( + state, + "monitor-workers", + activeWorkerCount=len(active_workers), + checkIntervalSeconds=int(state.get("monitoringPolicy", {}).get("checkIntervalSeconds", 300) or 300), + ) + + next_group_name = _next_dispatch_group_name(state, plan_payload) + if next_group_name: + state["engineMode"] = "idle" + return _set_next_action(state, "dispatch-next-wave", groupName=next_group_name) + + state["engineMode"] = "completed" + state["activeWaveId"] = "" + state["activeDispatchPath"] = "" + return _set_next_action(state, "completed", mergedResultCount=len(list(state.get("mergedResults", [])))) + + +def enter_engine(project_root: Path) -> Tuple[Path, Dict[str, bool]]: + paths = _ensure_layout(project_root) + state = _ensure_state_defaults(project_root, {}) + state["enabled"] = True + state["updatedAt"] = now_iso() + _set_next_action(state, "plan-or-dispatch") + _json_dump(paths["state"], state) + _write_doc_queue(paths["doc_queue_md"], []) + return paths["state"], state["artifactHealth"] + + +def status_engine(project_root: Path) -> Dict[str, object]: + paths = _ensure_layout(project_root) + state = _json_load(paths["state"]) + return _ensure_state_defaults(project_root, state) + + +def _arc_review_paths(project_root: Path) -> Dict[str, Path]: + arc_root = airarc_root(project_root) / "reviews" + return { + "review_json": arc_root / "parallel-review.json", + "execution_plan_json": arc_root / "execution-plan.json", + } + + +def _load_parallel_review(path: Path) -> ParallelReview: + payload = json.loads(path.read_text(encoding="utf-8-sig")) + if "parallelReview" in payload: + payload = payload["parallelReview"] + return ParallelReview.from_dict(payload) + + +def _resolve_review_source(project_root: Path, todo_path: Path) -> Tuple[ParallelReview, str, str]: + arc_paths = _arc_review_paths(project_root) + + if arc_paths["execution_plan_json"].exists(): + review = _load_parallel_review(arc_paths["execution_plan_json"]) + return review, "airarc-execution-plan", str(arc_paths["execution_plan_json"]) + + if arc_paths["review_json"].exists(): + review = _load_parallel_review(arc_paths["review_json"]) + return review, "airarc-review", str(arc_paths["review_json"]) + + review = build_parallel_review(todo_path) + return review, "engine-fallback-analysis", "" + + +def _render_engine_plan_markdown(plan_payload: Dict[str, object]) -> str: + lines = [ + "# AirEng Execution Plan", + "", + f"- Generated At: `{plan_payload['generatedAt']}`", + f"- Project Root: `{plan_payload['projectRoot']}`", + f"- Todo Path: `{plan_payload['todoPath']}`", + f"- Planning Source: `{plan_payload['planningSource']}`", + f"- Review Source Path: `{plan_payload['reviewSourcePath'] or '(generated by engine fallback)'}`", + "", + "## Selected Tasks", + ] + + selected_tasks = plan_payload.get("selectedTasks", []) + if selected_tasks: + for task_id in selected_tasks: + lines.append(f"- `{task_id}`") + else: + lines.append("- No selected tasks.") + + lines.extend(["", "## Parallel Groups"]) + parallel_groups = plan_payload.get("parallelGroups", []) + if parallel_groups: + for group in parallel_groups: + lines.append(f"- `{group['name']}`: {', '.join(group['taskIds'])}") + lines.append(f" Reason: {group['reason']}") + else: + lines.append("- No parallel groups.") + + lines.extend(["", "## Serialization Points"]) + serialization_points = plan_payload.get("serializationPoints", []) + if serialization_points: + for item in serialization_points: + lines.append(f"- `{item['taskId']}`: {'; '.join(item['reasons'])}") + else: + lines.append("- No serialization points.") + + return "\n".join(lines) + "\n" + + +def _write_doc_queue(path: Path, pending_updates: list[Dict[str, object]]) -> None: + lines = [ + "# AirEng Doc Update Queue", + "", + "This file tracks global document updates that workers recommended but did not apply directly.", + "", + ] + if pending_updates: + for item in pending_updates: + lines.append(f"- Task: `{item['taskId']}`") + recommended = item.get("recommendedUpdates", []) + global_paths = item.get("globalDocPaths", []) + result_path = item.get("resultPath", "") + lines.append( + " Recommended Updates: " + + (", ".join(f"`{value}`" for value in recommended) if recommended else "`none`") + ) + lines.append( + " Global Doc Paths: " + + (", ".join(f"`{value}`" for value in global_paths) if global_paths else "`none`") + ) + lines.append(f" Result Path: `{result_path}`") + else: + lines.append("- No pending global document updates.") + + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def build_engine_plan(project_root: Path, todo_path: Path) -> Dict[str, object]: + paths = _ensure_layout(project_root) + review, planning_source, review_source_path = _resolve_review_source(project_root, todo_path) + + review_json_path = paths["reviews"] / "parallel-review.json" + review_md_path = paths["reviews"] / "parallel-review.md" + _json_dump(review_json_path, review.to_dict()) + review_md_path.write_text(render_review_markdown(review), encoding="utf-8") + + selected_tasks = review.parallel_groups[0].task_ids if review.parallel_groups else [] + plan_payload = { + "generatedAt": now_iso(), + "projectRoot": str(project_root), + "todoPath": str(todo_path), + "planningSource": planning_source, + "reviewSourcePath": review_source_path, + "selectedTasks": selected_tasks, + "parallelGroups": [group.to_dict() for group in review.parallel_groups], + "conflicts": [conflict.to_dict() for conflict in review.conflicts], + "serializationPoints": review.serialization_points, + "reviewJsonPath": str(review_json_path), + "reviewMarkdownPath": str(review_md_path), + } + _json_dump(paths["plan"], plan_payload) + paths["plan_md"].write_text(_render_engine_plan_markdown(plan_payload), encoding="utf-8") + + state = status_engine(project_root) + state["enabled"] = True + state["updatedAt"] = now_iso() + state["engineMode"] = "planning" + state["lastPlanPath"] = str(paths["plan"]) + state["lastPlanMarkdownPath"] = str(paths["plan_md"]) + state["lastReviewJsonPath"] = str(review_json_path) + state["lastReviewMarkdownPath"] = str(review_md_path) + state["planningSource"] = planning_source + state["reviewSourcePath"] = review_source_path + _refresh_engine_progress(project_root, state) + if not list(state.get("activeWorkers", [])): + next_group_name = _next_dispatch_group_name(state, plan_payload) + if next_group_name: + state["engineMode"] = "planning" + _set_next_action(state, "dispatch-next-wave", groupName=next_group_name) + _json_dump(paths["state"], state) + _write_doc_queue(paths["doc_queue_md"], list(state.get("pendingGlobalUpdates", []))) + + return { + "planPath": str(paths["plan"]), + "planMarkdownPath": str(paths["plan_md"]), + "reviewJsonPath": str(review_json_path), + "reviewMarkdownPath": str(review_md_path), + "planningSource": planning_source, + "reviewSourcePath": review_source_path, + "selectedTasks": selected_tasks, + "parallelGroupCount": len(review.parallel_groups), + "conflictCount": len(review.conflicts), + } + + +def _build_active_worker_records( + dispatched_tasks: List[Dict[str, object]], group_name: str, wave_id: str +) -> List[Dict[str, object]]: + records: List[Dict[str, object]] = [] + for task in dispatched_tasks: + record = _default_active_worker() + record.update( + { + "taskId": str(task.get("taskId", "")).strip(), + "dispatchGroup": group_name, + "waveId": wave_id, + "briefPath": str(task.get("briefPath", "")).strip(), + "handoffPath": str(task.get("handoffPath", "")).strip(), + "workerStatePath": str(task.get("workerStatePath", "")).strip(), + "resultPath": str(task.get("resultPath", "")).strip(), + "spawnedAt": now_iso(), + "lastObservedAt": "", + "lastHeartbeatAt": _mtime_iso(Path(str(task.get("workerStatePath", "")).strip())) + if str(task.get("workerStatePath", "")).strip() + else "", + "status": "dispatched", + "lastResultPath": "", + "stallCount": 0, + "interventionCount": 0, + "repairDispatchPath": "", + "notes": [], + } + ) + records.append(record) + return records + + +def dispatch_worker_group(project_root: Path, group_name: str = "") -> Dict[str, object]: + paths = _ensure_layout(project_root) + plan_payload = _json_load(paths["plan"]) + if not plan_payload: + raise ValueError("engine plan is missing; run plan mode before dispatch") + + parallel_groups = list(plan_payload.get("parallelGroups", [])) + if not parallel_groups: + raise ValueError("no parallel groups available for dispatch") + + selected_group = None + if group_name: + for group in parallel_groups: + if group.get("name") == group_name: + selected_group = group + break + if selected_group is None: + raise ValueError(f"parallel group not found: {group_name}") + else: + next_group_name = _next_dispatch_group_name(status_engine(project_root), plan_payload) + for group in parallel_groups: + if group.get("name") == next_group_name: + selected_group = group + break + if selected_group is None: + selected_group = parallel_groups[0] + + current_todo_path = workflow_todo_path(project_root) + task_map = {} + if current_todo_path.exists(): + try: + task_map = {task.task_id: task for task in parse_tasks(current_todo_path)} + except ValueError: + task_map = {} + + task_ids = [str(task_id) for task_id in selected_group.get("taskIds", [])] + recommended_concurrency = min(max(len(task_ids), 1), 3) + dispatched_tasks = [] + for task_id in task_ids: + prepared = enter_worker(project_root, task_id) + task_record = task_map.get(task_id) + dispatched_tasks.append( + { + "taskId": task_id, + "module": task_record.module if task_record else "", + "task": task_record.task if task_record else "", + "writePaths": list(task_record.write_paths) if task_record else [], + "globalDocPaths": list(task_record.global_doc_paths) if task_record else [], + "dependencies": list(task_record.dependencies) if task_record else [], + "briefPath": prepared["briefPath"], + "handoffPath": prepared["handoffPath"], + "resultPath": prepared["resultPath"], + "workerStatePath": prepared["workerStatePath"], + "subagentCommand": "/airdo", + "requiresIsolatedContext": True, + "recommendedAgentType": "worker", + } + ) + + timestamp = now_iso() + selected_group_name = str(selected_group.get("name", "")).strip() or "group" + wave_id = f"{selected_group_name}-{_sanitize_stamp(timestamp)}" + manifest = { + "generatedAt": timestamp, + "projectRoot": str(project_root), + "groupName": selected_group_name, + "waveId": wave_id, + "taskIds": task_ids, + "planningSource": plan_payload.get("planningSource", ""), + "reviewSourcePath": plan_payload.get("reviewSourcePath", ""), + "dispatchMode": "isolated-airdo-subagents", + "workerCommand": "/airdo", + "requiresIsolatedContext": True, + "recommendedConcurrency": recommended_concurrency, + "dispatchedTasks": dispatched_tasks, + } + manifest_path = paths["dispatch"] / f"{selected_group_name}.json" + _json_dump(manifest_path, manifest) + dispatch_doc_paths = mark_tasks_dispatched( + project_root, + selected_group_name, + dispatched_tasks, + recommended_concurrency, + ) + + state = status_engine(project_root) + state["enabled"] = True + state["updatedAt"] = now_iso() + state["lastLoopAt"] = state["updatedAt"] + state["engineMode"] = "dispatching" + state["activeWaveId"] = wave_id + state["activeDispatchPath"] = str(manifest_path) + state["activeWorkers"] = _build_active_worker_records(dispatched_tasks, selected_group_name, wave_id) + state["lastDispatchPath"] = str(manifest_path) + state["lastDispatchGroup"] = selected_group_name + state["lastDispatchDocPaths"] = dispatch_doc_paths + dispatched_groups = [str(item) for item in list(state.get("dispatchedGroups", [])) if str(item).strip()] + if selected_group_name not in dispatched_groups: + dispatched_groups.append(selected_group_name) + state["dispatchedGroups"] = dispatched_groups + history = [item for item in list(state.get("dispatchHistory", [])) if isinstance(item, dict)] + history.append( + { + "waveId": wave_id, + "groupName": selected_group_name, + "dispatchPath": str(manifest_path), + "taskIds": task_ids, + "dispatchedAt": state["updatedAt"], + } + ) + state["dispatchHistory"] = history[-20:] + _set_next_action( + state, + "monitor-workers", + activeWorkerCount=len(state["activeWorkers"]), + checkIntervalSeconds=int(state.get("monitoringPolicy", {}).get("checkIntervalSeconds", 300) or 300), + dispatchPath=str(manifest_path), + waveId=wave_id, + ) + _json_dump(paths["state"], state) + + return { + "dispatchPath": str(manifest_path), + "groupName": selected_group_name, + "waveId": wave_id, + "taskIds": task_ids, + "recommendedConcurrency": recommended_concurrency, + } + + +def _prepare_worker_for_repair(project_root: Path, task_id: str) -> Dict[str, object]: + prepared = enter_worker(project_root, str(task_id)) + return { + "taskId": str(task_id), + "prepared": prepared, + "stdout": json.dumps(prepared, ensure_ascii=False), + } + + +def _write_repair_dispatch_manifest( + paths: Dict[str, Path], project_root: Path, task_id: str, prepared_stdout: str +) -> Path: + active_attempt = load_active_repair_attempt(project_root, task_id) + manifest_path = paths["dispatch"] / f"repair-{task_id}.json" + manifest = { + "generatedAt": now_iso(), + "projectRoot": str(project_root), + "taskId": task_id, + "repairAttemptId": active_attempt.repair_id if active_attempt else "", + "repairBriefPath": active_attempt.repair_brief_path if active_attempt else "", + "workerBriefPath": active_attempt.worker_brief_path if active_attempt else "", + "command": f"/airdo handoff {task_id}", + "prepareStdout": prepared_stdout, + "nextAction": "continue-repair", + } + _json_dump(manifest_path, manifest) + return manifest_path + + +def _inspect_worker_record( + project_root: Path, worker: Dict[str, object], monitoring_policy: Dict[str, object] +) -> Dict[str, object]: + record = _normalize_active_worker(worker) + record["lastObservedAt"] = now_iso() + task_id = str(record.get("taskId", "")).strip() + worker_state_path = Path(str(record.get("workerStatePath", "")).strip()) if str(record.get("workerStatePath", "")).strip() else None + result_path = Path(str(record.get("resultPath", "")).strip()) if str(record.get("resultPath", "")).strip() else None + + if worker_state_path and worker_state_path.exists(): + record["lastHeartbeatAt"] = _mtime_iso(worker_state_path) + payload = _json_load(worker_state_path) + payload_status = str(payload.get("status", "")).strip() + payload_result_path = str(payload.get("resultPath", "")).strip() + if payload_result_path: + record["resultPath"] = payload_result_path + result_path = Path(payload_result_path) + if payload_status: + record["status"] = payload_status + if payload_status == "completed" and result_path and result_path.exists(): + record["status"] = "ready_to_merge" + record["lastResultPath"] = str(result_path) + return { + "taskId": task_id, + "classification": "ready_to_merge", + "worker": record, + "resultPath": str(result_path), + } + + active_attempt = load_active_repair_attempt(project_root, task_id) if task_id else None + if active_attempt is not None: + record["status"] = f"repair-{active_attempt.status}" + return { + "taskId": task_id, + "classification": "repairing", + "worker": record, + "repairAttemptId": active_attempt.repair_id, + } + + if worker_state_path and worker_state_path.exists(): + stall_after_seconds = int(monitoring_policy.get("stallAfterSeconds", 1800) or 1800) + age_seconds = _seconds_since_mtime(worker_state_path) + if age_seconds is not None and age_seconds >= stall_after_seconds: + record["status"] = "stalled" + record["stallCount"] = int(record.get("stallCount", 0) or 0) + 1 + return { + "taskId": task_id, + "classification": "stalled", + "worker": record, + "ageSeconds": int(age_seconds), + } + + if worker_state_path and not worker_state_path.exists(): + record["status"] = "stalled" + record["stallCount"] = int(record.get("stallCount", 0) or 0) + 1 + return { + "taskId": task_id, + "classification": "stalled", + "worker": record, + "ageSeconds": None, + } + + record["status"] = str(record.get("status", "active") or "active") + return { + "taskId": task_id, + "classification": "active", + "worker": record, + } + + +def _record_intervention(task_id: str, reason: str, action: str, outcome: str) -> Dict[str, object]: + return { + "taskId": task_id, + "reason": reason, + "action": action, + "outcome": outcome, + "at": now_iso(), + } + + +def _handle_stalled_workers( + project_root: Path, + state: Dict[str, object], + stalled_workers: List[Dict[str, object]], +) -> Tuple[Dict[str, object], List[Dict[str, object]], List[str]]: + if not stalled_workers: + return state, [], [] + + monitoring_policy = dict(state.get("monitoringPolicy", {})) + max_interventions = int(monitoring_policy.get("maxInterventionAttemptsPerTask", 2) or 2) + stalled_by_task = {str(item.get("taskId", "")): item for item in stalled_workers} + intervention_history = [item for item in list(state.get("interventionHistory", [])) if isinstance(item, dict)] + interventions: List[Dict[str, object]] = [] + blocked_tasks: List[str] = [] + updated_workers: List[Dict[str, object]] = [] + + for worker in [ + _normalize_active_worker(item) + for item in list(state.get("activeWorkers", [])) + if isinstance(item, dict) + ]: + task_id = str(worker.get("taskId", "")).strip() + stalled = stalled_by_task.get(task_id) + if stalled is None: + updated_workers.append(worker) + continue + + intervention_count = int(worker.get("interventionCount", 0) or 0) + if intervention_count >= max_interventions: + worker["status"] = "blocked" + blocked_tasks.append(task_id) + interventions.append( + _record_intervention( + task_id, + "worker stalled beyond intervention budget", + "escalate-to-user", + "blocked", + ) + ) + updated_workers.append(worker) + continue + + prepared = enter_worker(project_root, task_id) + worker.update( + { + "briefPath": prepared["briefPath"], + "handoffPath": prepared["handoffPath"], + "workerStatePath": prepared["workerStatePath"], + "resultPath": prepared["resultPath"], + "status": "redispatched", + "stallCount": 0, + "interventionCount": intervention_count + 1, + "lastObservedAt": now_iso(), + "lastHeartbeatAt": _mtime_iso(Path(prepared["workerStatePath"])), + "notes": list(worker.get("notes", [])) + ["redispatched after stall detection"], + } + ) + interventions.append( + _record_intervention( + task_id, + "worker stalled or stopped updating state", + "redispatch-worker", + "monitor-again", + ) + ) + updated_workers.append(worker) + + if interventions: + intervention_history.extend(interventions) + state["interventionHistory"] = intervention_history[-50:] + state["lastInterventionAt"] = interventions[-1]["at"] + state["activeWorkers"] = updated_workers + return state, interventions, blocked_tasks + + +def merge_worker_result(project_root: Path, result_path: Path) -> Dict[str, object]: + paths = _ensure_layout(project_root) + result = WorkerResult.from_dict(json.loads(result_path.read_text(encoding="utf-8-sig"))) + result.validate() + ensure_xdb_sessions_for_result(project_root, result, "engine-merge-fallback") + ensure_debug_sessions_for_result(project_root, result, "engine-merge-fallback") + ensure_repair_attempts_for_result(project_root, result, str(result_path)) + result.validate() + enforce_doc_sync_requirements(project_root, result) + + timestamp = now_iso().replace(":", "-") + archived_path = paths["results"] / f"{result.task_id}-{timestamp}.json" + if not result.finalized_at: + result.finalized_at = now_iso() + _json_dump(archived_path, result.to_dict()) + + state = status_engine(project_root) + state["enabled"] = True + state["updatedAt"] = now_iso() + state["lastLoopAt"] = state["updatedAt"] + state["engineMode"] = "merging" + merged_results = list(state.get("mergedResults", [])) + merged_results.append( + { + "taskId": result.task_id, + "status": result.status, + "summary": result.summary, + "archivedResultPath": str(archived_path), + } + ) + state["mergedResults"] = merged_results + merge_xdb_sessions_into_state(state, result.xdb_sessions) + merge_debug_sessions_into_state(state, result.debug_sessions) + merge_repair_attempts_into_state(state, result.repair_attempts) + + repair_prepared = False + repair_prepare_stdout = "" + repair_dispatch_path = "" + repair_worker_payload: Dict[str, str] = {} + if result.status != "done" and result.repair_attempts: + repair_policy = normalize_repair_policy(dict(state.get("repairPolicy", {}))) + if bool(repair_policy.get("autoPrepareWorker", True)): + prepared = _prepare_worker_for_repair(project_root, result.task_id) + repair_prepared = True + repair_prepare_stdout = str(prepared["stdout"]) + repair_worker_payload = dict(prepared.get("prepared", {})) + result.repair_attempts = ( + mark_repair_attempts_active(project_root, result.task_id) or result.repair_attempts + ) + merge_repair_attempts_into_state(state, result.repair_attempts) + repair_dispatch = _write_repair_dispatch_manifest( + paths, project_root, result.task_id, repair_prepare_stdout + ) + repair_dispatch_path = str(repair_dispatch) + state["lastRepairDispatchPath"] = repair_dispatch_path + + pending_updates = list(state.get("pendingGlobalUpdates", [])) + applied_doc_paths = apply_document_updates(project_root, result) + sync_paths = sync_engine_managed_docs(project_root, result, applied_doc_paths) + merged_todo_path = update_todo_after_merge(project_root, result, applied_doc_paths, sync_paths) + + if result.recommend_global_doc_updates and not result.document_updates: + pending_updates.append( + { + "taskId": result.task_id, + "recommendedUpdates": result.recommend_global_doc_updates, + "globalDocPaths": result.global_doc_paths, + "resultPath": str(archived_path), + } + ) + state["pendingGlobalUpdates"] = pending_updates + + remaining_workers: List[Dict[str, object]] = [] + for item in [ + _normalize_active_worker(worker) + for worker in list(state.get("activeWorkers", [])) + if isinstance(worker, dict) + ]: + if str(item.get("taskId", "")).strip() != result.task_id: + remaining_workers.append(item) + continue + if repair_prepared: + item.update( + { + "briefPath": repair_worker_payload.get("briefPath", item.get("briefPath", "")), + "handoffPath": repair_worker_payload.get("handoffPath", item.get("handoffPath", "")), + "workerStatePath": repair_worker_payload.get("workerStatePath", item.get("workerStatePath", "")), + "resultPath": repair_worker_payload.get("resultPath", item.get("resultPath", "")), + "status": "repair-dispatched", + "lastResultPath": str(archived_path), + "repairDispatchPath": repair_dispatch_path, + "stallCount": 0, + "lastObservedAt": now_iso(), + "lastHeartbeatAt": _mtime_iso(Path(repair_worker_payload.get("workerStatePath", ""))) + if repair_worker_payload.get("workerStatePath") + else item.get("lastHeartbeatAt", ""), + } + ) + remaining_workers.append(item) + state["activeWorkers"] = remaining_workers + _refresh_engine_progress(project_root, state) + if repair_prepared: + state["engineMode"] = "monitoring" + _set_next_action( + state, + "continue-repair", + taskId=result.task_id, + repairDispatchPath=repair_dispatch_path, + ) + + _json_dump(paths["state"], state) + _write_doc_queue(paths["doc_queue_md"], pending_updates) + repair_queue_path = write_repair_queue(project_root, state) + + return { + "taskId": result.task_id, + "status": result.status, + "archivedResultPath": str(archived_path), + "pendingGlobalUpdateCount": len(pending_updates), + "docQueuePath": str(paths["doc_queue_md"]), + "repairQueuePath": str(repair_queue_path), + "todoPath": str(merged_todo_path), + "appliedDocPathCount": len(applied_doc_paths) + len(sync_paths), + "xdbSessionCount": len(result.xdb_sessions), + "debugSessionCount": len(result.debug_sessions), + "repairAttemptCount": len(result.repair_attempts), + "repairPrepared": repair_prepared, + "repairPrepareStdout": repair_prepare_stdout, + "repairDispatchPath": repair_dispatch_path, + "repairWorkerStatePath": repair_worker_payload.get("workerStatePath", ""), + "repairHandoffPath": repair_worker_payload.get("handoffPath", ""), + "repairResultPath": repair_worker_payload.get("resultPath", ""), + "nextAction": str(state.get("nextAction", "")), + } + + +def monitor_engine(project_root: Path) -> Dict[str, object]: + paths = _ensure_layout(project_root) + state = status_engine(project_root) + state["enabled"] = True + state["updatedAt"] = now_iso() + state["lastLoopAt"] = state["updatedAt"] + state["engineMode"] = "monitoring" + monitoring_policy = dict(state.get("monitoringPolicy", {})) + + inspected = [ + _inspect_worker_record(project_root, worker, monitoring_policy) + for worker in list(state.get("activeWorkers", [])) + if isinstance(worker, dict) + ] + state["activeWorkers"] = [item["worker"] for item in inspected] + _json_dump(paths["state"], state) + + ready_to_merge = [item for item in inspected if item.get("classification") == "ready_to_merge"] + merged: List[Dict[str, object]] = [] + for item in ready_to_merge: + result_path = Path(str(item.get("resultPath", "")).strip()) + if result_path.exists(): + merged.append(merge_worker_result(project_root, result_path)) + + state = status_engine(project_root) + monitoring_policy = dict(state.get("monitoringPolicy", {})) + reinspected = [ + _inspect_worker_record(project_root, worker, monitoring_policy) + for worker in list(state.get("activeWorkers", [])) + if isinstance(worker, dict) + ] + state["activeWorkers"] = [item["worker"] for item in reinspected] + stalled_workers = [item for item in reinspected if item.get("classification") == "stalled"] + state, interventions, blocked_tasks = _handle_stalled_workers(project_root, state, stalled_workers) + state["updatedAt"] = now_iso() + state["lastLoopAt"] = state["updatedAt"] + _refresh_engine_progress(project_root, state) + if blocked_tasks: + state["engineMode"] = "blocked" + _set_next_action(state, "user-decision-required", blockedTaskIds=blocked_tasks) + _json_dump(paths["state"], state) + _write_doc_queue(paths["doc_queue_md"], list(state.get("pendingGlobalUpdates", []))) + repair_queue_path = write_repair_queue(project_root, state) + + return { + "engineMode": str(state.get("engineMode", "")), + "activeWorkerCount": len(list(state.get("activeWorkers", []))), + "readyToMergeCount": len(ready_to_merge), + "mergedCount": len(merged), + "stalledCount": len(stalled_workers), + "interventionCount": len(interventions), + "blockedTaskCount": len(blocked_tasks), + "repairQueuePath": str(repair_queue_path), + "nextAction": str(state.get("nextAction", "")), + } + + +def intervene_engine(project_root: Path) -> Dict[str, object]: + paths = _ensure_layout(project_root) + state = status_engine(project_root) + monitoring_policy = dict(state.get("monitoringPolicy", {})) + inspected = [ + _inspect_worker_record(project_root, worker, monitoring_policy) + for worker in list(state.get("activeWorkers", [])) + if isinstance(worker, dict) + ] + state["activeWorkers"] = [item["worker"] for item in inspected] + stalled_workers = [item for item in inspected if item.get("classification") == "stalled"] + state["engineMode"] = "intervening" + state["updatedAt"] = now_iso() + state["lastLoopAt"] = state["updatedAt"] + state, interventions, blocked_tasks = _handle_stalled_workers(project_root, state, stalled_workers) + _refresh_engine_progress(project_root, state) + if blocked_tasks: + state["engineMode"] = "blocked" + _set_next_action(state, "user-decision-required", blockedTaskIds=blocked_tasks) + _json_dump(paths["state"], state) + return { + "engineMode": str(state.get("engineMode", "")), + "stalledCount": len(stalled_workers), + "interventionCount": len(interventions), + "blockedTaskCount": len(blocked_tasks), + "nextAction": str(state.get("nextAction", "")), + } + + +def run_engine_once(project_root: Path, todo_path: Path | None = None) -> Dict[str, object]: + paths = _ensure_layout(project_root) + steps: List[str] = [] + plan_payload = _json_load(paths["plan"]) + if not plan_payload: + plan_result = build_engine_plan(project_root, todo_path or workflow_todo_path(project_root)) + steps.append("plan") + plan_payload = _json_load(paths["plan"]) + else: + plan_result = { + "planPath": str(paths["plan"]), + "planMarkdownPath": str(paths["plan_md"]), + } + + state = status_engine(project_root) + if list(state.get("activeWorkers", [])): + monitor_result = monitor_engine(project_root) + steps.append("monitor") + return { + "action": "monitor", + "steps": steps, + "planPath": str(plan_result.get("planPath", paths["plan"])), + "activeWorkerCount": monitor_result["activeWorkerCount"], + "nextAction": monitor_result["nextAction"], + "engineMode": monitor_result["engineMode"], + } + + next_group_name = _next_dispatch_group_name(state, plan_payload) + if next_group_name: + dispatch_result = dispatch_worker_group(project_root, next_group_name) + steps.append("dispatch") + refreshed_state = status_engine(project_root) + return { + "action": "dispatch", + "steps": steps, + "planPath": str(plan_result.get("planPath", paths["plan"])), + "dispatchPath": dispatch_result["dispatchPath"], + "waveId": dispatch_result["waveId"], + "taskIds": dispatch_result["taskIds"], + "nextAction": str(refreshed_state.get("nextAction", "")), + "engineMode": str(refreshed_state.get("engineMode", "")), + } + + state["enabled"] = True + state["updatedAt"] = now_iso() + state["lastLoopAt"] = state["updatedAt"] + _refresh_engine_progress(project_root, state) + _json_dump(paths["state"], state) + steps.append("complete") + return { + "action": "complete", + "steps": steps, + "planPath": str(plan_result.get("planPath", paths["plan"])), + "nextAction": str(state.get("nextAction", "")), + "engineMode": str(state.get("engineMode", "")), + } diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/paths.py b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/paths.py new file mode 100755 index 0000000..8dd971c --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/paths.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from pathlib import Path + +AIRPLAN_DIRNAME = "AirPlan" + + +def airplan_root(project_root: Path) -> Path: + return project_root / AIRPLAN_DIRNAME + + +def docs_root(project_root: Path) -> Path: + return airplan_root(project_root) / "docs" + + +def state_root(project_root: Path) -> Path: + return airplan_root(project_root) / "state" + + +def agents_path(project_root: Path) -> Path: + return airplan_root(project_root) / "AGENTS.md" + + +def root_agents_bootstrap_path(project_root: Path) -> Path: + return project_root / "AGENTS.md" + + +def plan_path(project_root: Path) -> Path: + return airplan_root(project_root) / "plan.md" + + +def todo_path(project_root: Path) -> Path: + return airplan_root(project_root) / "todo.md" + + +def analysis_requirements_path(project_root: Path) -> Path: + return docs_root(project_root) / "analysis" / "requirements.md" + + +def architecture_root(project_root: Path) -> Path: + return docs_root(project_root) / "architecture" + + +def architecture_solution_path(project_root: Path) -> Path: + return architecture_root(project_root) / "solution-architecture.md" + + +def architecture_adr_dir(project_root: Path) -> Path: + return architecture_root(project_root) / "adr" + + +def architecture_c4_module_path(project_root: Path) -> Path: + return architecture_root(project_root) / "c4" / "module.md" + + +def debug_root(project_root: Path) -> Path: + return docs_root(project_root) / "debug" + + +def debug_log_path(project_root: Path) -> Path: + return debug_root(project_root) / "debug-log.md" + + +def gui_debug_log_path(project_root: Path) -> Path: + return debug_root(project_root) / "gui-debug-log.md" + + +def airxdb_artifacts_dir(project_root: Path) -> Path: + return debug_root(project_root) / "airxdb-artifacts" + + +def network_root(project_root: Path) -> Path: + return docs_root(project_root) / "network" + + +def airndb_log_path(project_root: Path) -> Path: + return network_root(project_root) / "airndb-log.md" + + +def airndb_captures_dir(project_root: Path) -> Path: + return network_root(project_root) / "airndb-captures" + + +def validation_root(project_root: Path) -> Path: + return docs_root(project_root) / "validation" + + +def staticanalysis_path(project_root: Path) -> Path: + return docs_root(project_root) / "staticanalysis.md" + + +def plugin_state_root(project_root: Path, plugin_name: str) -> Path: + return state_root(project_root) / plugin_name + + +def airarc_root(project_root: Path) -> Path: + return plugin_state_root(project_root, "airarc") + + +def aireng_root(project_root: Path) -> Path: + return plugin_state_root(project_root, "aireng") + + +def airdo_root(project_root: Path) -> Path: + return plugin_state_root(project_root, "airdo") + + +def airdbg_root(project_root: Path) -> Path: + return plugin_state_root(project_root, "airdbg") + + +def airndb_root(project_root: Path) -> Path: + return plugin_state_root(project_root, "airndb") + + +def airsdb_root(project_root: Path) -> Path: + return plugin_state_root(project_root, "airsdb") + + +def airxdb_root(project_root: Path) -> Path: + return plugin_state_root(project_root, "airxdb") + + +def required_project_artifacts() -> tuple[str, ...]: + return ( + "AirPlan/AGENTS.md", + "AirPlan/docs/architecture/adr", + "AirPlan/docs/architecture/c4/module.md", + "AirPlan/plan.md", + "AirPlan/todo.md", + ) diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/project_bootstrap.py b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/project_bootstrap.py new file mode 100755 index 0000000..0e01545 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/project_bootstrap.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Dict, Tuple + +from .paths import ( + agents_path, + airplan_root, + analysis_requirements_path, + architecture_adr_dir, + architecture_c4_module_path, + architecture_solution_path, + debug_log_path, + gui_debug_log_path, + root_agents_bootstrap_path, + state_root, + staticanalysis_path, + validation_root, +) + +AIRARC_BEGIN = "" +AIRARC_END = "" +AIRENG_BEGIN = "" +AIRENG_END = "" +AIRDO_BEGIN = "" +AIRDO_END = "" + +ROOT_AGENTS_TEMPLATE = """# AGENTS.md + +- Canonical workflow context for this repository lives in `AirPlan/AGENTS.md`. +- Always load `AirPlan/AGENTS.md` first for project instructions, workflow rules, plan/todo state, ADR/C4 context, and current Air sync blocks. +- Treat `AirPlan/plan.md`, `AirPlan/todo.md`, and `AirPlan/docs/` as the authoritative workflow documents. +- Treat `AirPlan/state/` as the authoritative plugin and runtime state root. +- This root file is only a bootstrap shim; keep real workflow context maintained inside `AirPlan/AGENTS.md`. +""" + +PROJECT_AGENTS_TEMPLATE = f"""# AGENTS.md + +## Workflow Root + +- This project uses `AirPlan/` as the workflow root. +- Keep planning, execution state, ADR, C4, validation, debug, and plugin runtime data under `AirPlan/`. +- The repo-root `AGENTS.md` only bootstraps into this file. + +{AIRARC_BEGIN} +## AirArc Workflow + +1. Use `AirPlan/AGENTS.md` as the canonical project context entry point. +2. Load and maintain: + - `AirPlan/docs/analysis/requirements.md` + - `AirPlan/docs/architecture/solution-architecture.md` + - `AirPlan/docs/architecture/c4/module.md` + - `AirPlan/docs/architecture/adr/` +3. Produce or refine `AirPlan/plan.md` and `AirPlan/todo.md`. +4. Keep plans optimized for lower-cost follow-up sessions, including scope, validation, file targets, and parallelization boundaries. +5. AirArc is architecture-only: it may plan tasks and edit planning or architecture documents, but it must not write code or implement tasks directly. +{AIRARC_END} + +{AIRENG_BEGIN} +## AirEng Workflow + +1. Use `/aireng` as the sole scheduler for confirmed execution. +2. Prefer `AirPlan/state/airarc/reviews/execution-plan.json`, then `AirPlan/state/airarc/reviews/parallel-review.json`, before falling back to local `AirPlan/todo.md`. +3. Dispatch isolated `/airdo` subagents with bounded concurrency instead of defaulting to parent-thread coding. +4. Monitor active workers on a 5-minute cadence, merge ready results, and continue later waves automatically when work remains. +5. Keep `AirPlan/todo.md`, `AirPlan/plan.md`, `AirPlan/AGENTS.md`, ADR, and C4 docs synchronized during dispatch, monitoring, repair, and merge. +6. AirEng owns global debug, XDB, repair, intervention, and document convergence, but it should only intervene directly for hard blockers and must return to scheduler mode immediately afterward. +{AIRENG_END} + +{AIRDO_BEGIN} +## AirDo Workflow + +1. Use `/airdo` for one narrow task slice from `AirPlan/todo.md`. +2. Before editing, load: + - `AirPlan/AGENTS.md` + - `AirPlan/docs/architecture/adr/` + - `AirPlan/docs/architecture/c4/module.md` + - `AirPlan/plan.md` + - `AirPlan/todo.md` +3. Keep task-local progress resumable in `AirPlan/state/airdo/`. +4. When AirEng owns orchestration, return shared document changes through `documentUpdates`. +5. Route GUI work through AirXDB, debugging through AirDbg, network evidence through AirNDB, and static analysis through AirSDB when needed. +{AIRDO_END} +""" + +PLAN_TEMPLATE = """# Implementation Plan + +## Read First +1. `AirPlan/AGENTS.md` +2. `AirPlan/docs/analysis/requirements.md` +3. `AirPlan/docs/architecture/solution-architecture.md` +4. `AirPlan/docs/architecture/c4/module.md` +5. `AirPlan/docs/architecture/adr/` +6. `AirPlan/todo.md` + +## Goal +- Replace this section with the concrete product or project goal. + +## Constraints +- Record technical, organizational, legal, hardware, or platform constraints here. + +## Phases +- Add implementation phases once AirArc planning is complete. + +## Validation Strategy +- Record build, test, debug, GUI, network, and static-analysis validation commands here. +""" + +TODO_TEMPLATE = """# TODO + +Status values: TODO / DOING / DONE / BLOCKED + +| ID | Status | Module | Task | Files/Dirs | Done When | Validation | Static Analysis | ADR/C4 Update | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| T-001 | TODO | Planning | Replace with the first confirmed execution task | `AirPlan/plan.md`, `AirPlan/docs/` | Acceptance criteria are explicit and testable | Record the exact validation command | Record the static-analysis plan or why it is not applicable | Record required ADR or C4 updates | + +## Quality Gates +- Run the validation command listed in `AirPlan/plan.md` before marking a task `DONE`. +- Keep ADR and C4 docs synchronized whenever architecture, module boundaries, dependencies, or ownership change. +- Record skipped validation, residual risk, and follow-up work explicitly. +""" + +REQUIREMENTS_TEMPLATE = """# Requirements + +## Product Intent +- Replace with the user-visible outcome this repository should deliver. + +## Functional Requirements +- Replace with numbered or grouped functional requirements. + +## Constraints +- Replace with non-functional constraints, environmental limits, or safety rules. + +## Acceptance Notes +- Replace with the most important acceptance criteria and evidence rules. +""" + +SOLUTION_ARCHITECTURE_TEMPLATE = """# Solution Architecture + +## Overview +- Replace with the top-level architecture summary. + +## Major Components +- Replace with the main containers and their responsibilities. + +## Data And Control Flow +- Replace with the major interaction paths between components. + +## Key Risks +- Replace with the architecture risks, unknowns, and open decisions. +""" + +C4_MODULE_TEMPLATE = """# C4 Module + +## System Context +- Replace with the project purpose and external actors or systems. + +## Containers +- Replace with the main runtime or repository containers. + +## Modules + +| Module | Responsibility | Public Interfaces | Dependencies | Data Ownership | Quality Notes | +| --- | --- | --- | --- | --- | --- | +| `replace_me` | Replace with the first real module | Replace with interfaces | Replace with dependencies | Replace with owned data | Replace with testing or quality notes | +""" + +ADR_TEMPLATE = """# ADR-0001: Use AirPlan As The Workflow Root + +- Status: Accepted +- Date: YYYY-MM-DD + +## Context +This project needs a durable workflow root for planning, execution state, architecture context, validation evidence, and resumable AI sessions. + +## Decision +Store project workflow artifacts under `AirPlan/`, use the repo-root `AGENTS.md` only as a bootstrap shim, and let `aireng` plus `airdo` maintain plan, todo, ADR, and C4 context there. + +## Consequences +- Planning and execution context stay resumable across sessions. +- Global workflow docs live in one predictable location. +- Plugin runtime state does not clutter the main project tree. +""" + +DEBUG_LOG_TEMPLATE = """# Debug Log + +- Add reproducible bug investigations, root-cause notes, and validation outcomes here. +""" + +GUI_DEBUG_LOG_TEMPLATE = """# GUI Debug Log + +- Add screenshots, GUI observations, Midscene evidence, and visual acceptance notes here. +""" + +NETWORK_LOG_TEMPLATE = """# AirNDB Log + +- Add packet-capture commands, pcap paths, network observations, and conclusions here. +""" + +STATIC_ANALYSIS_TEMPLATE = """# Static Analysis + +- Add cppcheck or other static-analysis summaries, report paths, and residual risks here. +""" + +VALIDATION_README_TEMPLATE = """# Validation Artifacts + +- Save build logs, flash logs, test logs, screenshots, and validation summaries under this directory. +""" + +ARTIFACTS_README_TEMPLATE = """# Artifact Output + +- Save generated evidence files in this directory. +""" + + +def _write_if_missing(path: Path, content: str) -> str: + if path.exists(): + return "exists" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.rstrip() + "\n", encoding="utf-8", newline="\n") + return "created" + + +def _upsert_marked_block(existing: str, begin: str, end: str, block: str) -> Tuple[str, bool]: + begin_index = existing.find(begin) + end_index = existing.find(end) + normalized_block = block.rstrip() + "\n" + if begin_index >= 0 and end_index > begin_index: + end_index += len(end) + updated = existing[:begin_index].rstrip() + "\n\n" + normalized_block + existing[end_index:].lstrip() + return updated, updated != existing + updated = existing.rstrip() + "\n\n" + normalized_block + return updated, True + + +def _ensure_project_agents(path: Path) -> str: + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(PROJECT_AGENTS_TEMPLATE.rstrip() + "\n", encoding="utf-8", newline="\n") + return "created" + + original = path.read_text(encoding="utf-8-sig") + updated = original + changed = False + for begin, end, block in [ + ( + AIRARC_BEGIN, + AIRARC_END, + PROJECT_AGENTS_TEMPLATE[ + PROJECT_AGENTS_TEMPLATE.index(AIRARC_BEGIN) : PROJECT_AGENTS_TEMPLATE.index(AIRARC_END) + len(AIRARC_END) + ], + ), + ( + AIRENG_BEGIN, + AIRENG_END, + PROJECT_AGENTS_TEMPLATE[ + PROJECT_AGENTS_TEMPLATE.index(AIRENG_BEGIN) : PROJECT_AGENTS_TEMPLATE.index(AIRENG_END) + len(AIRENG_END) + ], + ), + ( + AIRDO_BEGIN, + AIRDO_END, + PROJECT_AGENTS_TEMPLATE[ + PROJECT_AGENTS_TEMPLATE.index(AIRDO_BEGIN) : PROJECT_AGENTS_TEMPLATE.index(AIRDO_END) + len(AIRDO_END) + ], + ), + ]: + updated, block_changed = _upsert_marked_block(updated, begin, end, block) + changed = changed or block_changed + + if not changed: + return "exists" + + path.write_text(updated.rstrip() + "\n", encoding="utf-8", newline="\n") + return "updated" + + +def ensure_project_bootstrap(project_root: Path) -> Dict[str, str]: + airplan_root(project_root).mkdir(parents=True, exist_ok=True) + architecture_adr_dir(project_root).mkdir(parents=True, exist_ok=True) + architecture_c4_module_path(project_root).parent.mkdir(parents=True, exist_ok=True) + analysis_requirements_path(project_root).parent.mkdir(parents=True, exist_ok=True) + debug_log_path(project_root).parent.mkdir(parents=True, exist_ok=True) + gui_debug_log_path(project_root).parent.mkdir(parents=True, exist_ok=True) + (debug_log_path(project_root).parent / "airxdb-artifacts").mkdir(parents=True, exist_ok=True) + (airplan_root(project_root) / "docs" / "network" / "airndb-captures").mkdir(parents=True, exist_ok=True) + (validation_root(project_root) / "logs").mkdir(parents=True, exist_ok=True) + state_root(project_root).mkdir(parents=True, exist_ok=True) + + results = { + str(root_agents_bootstrap_path(project_root)): _write_if_missing( + root_agents_bootstrap_path(project_root), ROOT_AGENTS_TEMPLATE + ), + str(agents_path(project_root)): _ensure_project_agents(agents_path(project_root)), + str(airplan_root(project_root) / "plan.md"): _write_if_missing( + airplan_root(project_root) / "plan.md", PLAN_TEMPLATE + ), + str(airplan_root(project_root) / "todo.md"): _write_if_missing( + airplan_root(project_root) / "todo.md", TODO_TEMPLATE + ), + str(analysis_requirements_path(project_root)): _write_if_missing( + analysis_requirements_path(project_root), REQUIREMENTS_TEMPLATE + ), + str(architecture_solution_path(project_root)): _write_if_missing( + architecture_solution_path(project_root), SOLUTION_ARCHITECTURE_TEMPLATE + ), + str(architecture_c4_module_path(project_root)): _write_if_missing( + architecture_c4_module_path(project_root), C4_MODULE_TEMPLATE + ), + str(architecture_adr_dir(project_root) / "ADR-0001-use-airplan-as-the-workflow-root.md"): _write_if_missing( + architecture_adr_dir(project_root) / "ADR-0001-use-airplan-as-the-workflow-root.md", + ADR_TEMPLATE, + ), + str(debug_log_path(project_root)): _write_if_missing( + debug_log_path(project_root), DEBUG_LOG_TEMPLATE + ), + str(gui_debug_log_path(project_root)): _write_if_missing( + gui_debug_log_path(project_root), GUI_DEBUG_LOG_TEMPLATE + ), + str(debug_log_path(project_root).parent / "airxdb-artifacts" / "README.md"): _write_if_missing( + debug_log_path(project_root).parent / "airxdb-artifacts" / "README.md", + ARTIFACTS_README_TEMPLATE, + ), + str(airplan_root(project_root) / "docs" / "network" / "airndb-log.md"): _write_if_missing( + airplan_root(project_root) / "docs" / "network" / "airndb-log.md", + NETWORK_LOG_TEMPLATE, + ), + str(airplan_root(project_root) / "docs" / "network" / "airndb-captures" / "README.md"): _write_if_missing( + airplan_root(project_root) / "docs" / "network" / "airndb-captures" / "README.md", + ARTIFACTS_README_TEMPLATE, + ), + str(validation_root(project_root) / "README.md"): _write_if_missing( + validation_root(project_root) / "README.md", VALIDATION_README_TEMPLATE + ), + str(validation_root(project_root) / "logs" / "README.md"): _write_if_missing( + validation_root(project_root) / "logs" / "README.md", ARTIFACTS_README_TEMPLATE + ), + str(staticanalysis_path(project_root)): _write_if_missing( + staticanalysis_path(project_root), STATIC_ANALYSIS_TEMPLATE + ), + } + return results diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/repair_runtime.py b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/repair_runtime.py new file mode 100755 index 0000000..807082a --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/repair_runtime.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Dict, Iterable, List + +from .contracts import DEFAULT_REPAIR_POLICY, RepairAttempt, WorkerResult, now_iso +from .paths import ( + airdo_root, + aireng_root, + debug_log_path, + gui_debug_log_path, + plan_path, + todo_path, +) + + +def _json_load(path: Path) -> Dict[str, object]: + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8-sig")) + + +def _json_dump(path: Path, payload: Dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def _ordered_unique(items: Iterable[str]) -> List[str]: + seen = set() + ordered: List[str] = [] + for item in items: + cleaned = str(item).strip() + if not cleaned or cleaned in seen: + continue + seen.add(cleaned) + ordered.append(cleaned) + return ordered + + +def normalize_repair_policy(policy: Dict[str, object] | None = None) -> Dict[str, object]: + merged = dict(DEFAULT_REPAIR_POLICY) + if policy: + merged.update(policy) + + merged["enabled"] = bool(merged.get("enabled", True)) + merged["triggerOnBlocked"] = bool(merged.get("triggerOnBlocked", True)) + merged["autoPrepareWorker"] = bool(merged.get("autoPrepareWorker", True)) + merged["triggerValidationStatuses"] = _ordered_unique( + str(item).lower() for item in list(merged.get("triggerValidationStatuses", [])) + ) + try: + max_attempts = int(merged.get("maxAttemptsPerTask", 2) or 2) + except (TypeError, ValueError): + max_attempts = 2 + merged["maxAttemptsPerTask"] = max(1, max_attempts) + + requeue_status = str(merged.get("requeueTodoStatus", "DOING")).strip().upper() + if requeue_status not in {"TODO", "DOING"}: + requeue_status = "DOING" + merged["requeueTodoStatus"] = requeue_status + return merged + + +def load_repair_policy(project_root: Path) -> Dict[str, object]: + state_path = aireng_root(project_root) / "state.json" + state = _json_load(state_path) + return normalize_repair_policy(state.get("repairPolicy", {})) + + +def _repair_root(project_root: Path) -> Path: + return aireng_root(project_root) / "repairs" + + +def _repair_queue_path(project_root: Path) -> Path: + return aireng_root(project_root) / "repair-queue.md" + + +def _repair_attempt_path(project_root: Path, task_id: str, repair_id: str) -> Path: + return _repair_root(project_root) / task_id / repair_id / "attempt.json" + + +def _repair_brief_path(project_root: Path, task_id: str, repair_id: str) -> Path: + return _repair_root(project_root) / task_id / repair_id / "repair-brief.md" + + +def _worker_repair_brief_path(project_root: Path, task_id: str) -> Path: + return airdo_root(project_root) / "tasks" / task_id / "repair-brief.md" + + +def _repair_reasons(result: WorkerResult, policy: Dict[str, object]) -> List[str]: + reasons: List[str] = [] + if result.status == "blocked" and bool(policy.get("triggerOnBlocked", True)): + reasons.append("blocked worker result requires automatic repair") + tracked_statuses = { + str(item).lower() for item in list(policy.get("triggerValidationStatuses", [])) + } + failed_validations = [ + item for item in result.validations if item.status.lower() in tracked_statuses + ] + if failed_validations: + reasons.append( + "validation failures: " + + ", ".join(f"{item.kind}:{item.status}" for item in failed_validations) + ) + return reasons + + +def _build_repair_brief(project_root: Path, result: WorkerResult, attempt: RepairAttempt) -> str: + debug_session_ids = ", ".join(f"`{item}`" for item in attempt.debug_session_ids) or "`none`" + xdb_session_ids = ", ".join(f"`{item.session_id}`" for item in result.xdb_sessions) or "`none`" + validations = ( + "; ".join( + f"{item.kind}:{item.status}" + (f" ({item.command})" if item.command else "") + for item in result.validations + ) + or "none" + ) + evidence = ", ".join(f"`{item}`" for item in result.evidence_paths) or "`none`" + blockers = ", ".join(result.blockers) or "none" + risks = ", ".join(result.risks) or "none" + return "\n".join( + [ + f"# Auto Repair Brief: {attempt.repair_id}", + "", + f"- Task ID: `{result.task_id}`", + f"- Attempt Index: `{attempt.attempt_index}`", + f"- Source Status: `{result.status}`", + f"- Reason: {attempt.reason}", + f"- Debug Sessions: {debug_session_ids}", + f"- Source Result Path: `{attempt.source_result_path}`", + "", + "## Required Context", + "", + f"- Load `{project_root / 'AirPlan' / 'AGENTS.md'}`, `{project_root / 'AirPlan' / 'docs' / 'architecture' / 'adr'}`, `{project_root / 'AirPlan' / 'docs' / 'architecture' / 'c4' / 'module.md'}`, `{plan_path(project_root)}`, `{todo_path(project_root)}`, `{debug_log_path(project_root)}`, and `{gui_debug_log_path(project_root)}` when present.", + "- Read the referenced AirDbg request/session artifacts before changing code.", + "- If this task has AirXDB evidence, read the GUI screenshots and report before changing code.", + "- Continue repairing automatically. Do not stop at the existence of a debug request.", + "- Only return `blocked` again when the repair budget is exhausted or a real user decision is required.", + "", + "## Failure Snapshot", + "", + f"- Summary: {result.summary}", + f"- Validations: {validations}", + f"- Blockers: {blockers}", + f"- Evidence: {evidence}", + f"- XDB Sessions: {xdb_session_ids}", + f"- Risks: {risks}", + "", + "## Repair Goal", + "", + "- Modify the scoped code until the original validation path passes again.", + "- Keep the same task id and reuse the existing debug session id in the final repaired result.", + "- Update document sync content if the final fix changes architecture or execution rules.", + "", + f"- Project Root: `{project_root}`", + ] + ) + "\n" + + +def _write_repair_attempt_artifacts( + project_root: Path, result: WorkerResult, attempt: RepairAttempt +) -> RepairAttempt: + attempt_path = Path(attempt.state_path) + brief_path = Path(attempt.repair_brief_path) + worker_brief_path = Path(attempt.worker_brief_path) + + brief_content = _build_repair_brief(project_root, result, attempt) + brief_path.parent.mkdir(parents=True, exist_ok=True) + brief_path.write_text(brief_content, encoding="utf-8") + + worker_brief_path.parent.mkdir(parents=True, exist_ok=True) + worker_brief_path.write_text(brief_content, encoding="utf-8") + + _json_dump( + attempt_path, + { + "repairAttempt": attempt.to_dict(), + "sourceSummary": result.summary, + "sourceBlockers": result.blockers, + "sourceValidations": [item.to_dict() for item in result.validations], + "sourceEvidencePaths": result.evidence_paths, + }, + ) + return attempt + + +def list_repair_attempts(project_root: Path, task_id: str = "") -> List[RepairAttempt]: + repair_root = _repair_root(project_root) + if not repair_root.exists(): + return [] + + attempts: List[RepairAttempt] = [] + search_root = repair_root / task_id if task_id else repair_root + if not search_root.exists(): + return [] + + for path in sorted(search_root.rglob("attempt.json")): + try: + payload = json.loads(path.read_text(encoding="utf-8-sig")) + attempt_payload = payload.get("repairAttempt", payload) + attempt = RepairAttempt.from_dict(attempt_payload) + attempt.validate() + except (json.JSONDecodeError, TypeError, ValueError): + continue + if task_id and attempt.task_id != task_id: + continue + attempts.append(attempt) + + attempts.sort(key=lambda item: (item.created_at, item.repair_id)) + return attempts + + +def load_active_repair_attempt(project_root: Path, task_id: str) -> RepairAttempt | None: + for attempt in reversed(list_repair_attempts(project_root, task_id)): + if attempt.status in {"queued", "active"}: + return attempt + return None + + +def _set_attempt_status(attempt: RepairAttempt, status: str) -> RepairAttempt: + attempt.status = status + attempt_path = Path(attempt.state_path) + if attempt_path.exists(): + payload = json.loads(attempt_path.read_text(encoding="utf-8-sig")) + payload["repairAttempt"] = attempt.to_dict() + _json_dump(attempt_path, payload) + return attempt + + +def ensure_repair_attempts_for_result( + project_root: Path, result: WorkerResult, source_result_path: str = "" +) -> List[RepairAttempt]: + policy = load_repair_policy(project_root) + if not bool(policy.get("enabled", True)): + return result.repair_attempts + + if result.repair_attempts: + return result.repair_attempts + + existing_attempts = list_repair_attempts(project_root, result.task_id) + active_attempts = [ + item for item in existing_attempts if item.status in {"queued", "active"} + ] + + if result.status == "done": + if active_attempts: + resolved_attempts = [] + for attempt in active_attempts: + resolved_attempts.append(_set_attempt_status(attempt, "resolved")) + result.repair_attempts = resolved_attempts + return result.repair_attempts + + reasons = _repair_reasons(result, policy) + if not reasons: + return result.repair_attempts + + if active_attempts: + result.repair_attempts = active_attempts + return result.repair_attempts + + if len(existing_attempts) >= int(policy.get("maxAttemptsPerTask", 2) or 2): + result.notes.append( + f"Automatic repair budget exhausted for {result.task_id}; user decision may be required." + ) + return result.repair_attempts + + attempt_index = len(existing_attempts) + 1 + repair_id = f"{result.task_id}-repair-{attempt_index:03d}" + created_at = now_iso() + attempt = RepairAttempt( + repair_id=repair_id, + task_id=result.task_id, + attempt_index=attempt_index, + source_status=result.status, + source_result_path=source_result_path or "", + reason="; ".join(reasons), + repair_brief_path=str(_repair_brief_path(project_root, result.task_id, repair_id)), + state_path=str(_repair_attempt_path(project_root, result.task_id, repair_id)), + worker_brief_path=str(_worker_repair_brief_path(project_root, result.task_id)), + debug_session_ids=_ordered_unique( + item.session_id for item in result.debug_sessions if item.session_id + ), + status="queued", + created_at=created_at, + ) + _write_repair_attempt_artifacts(project_root, result, attempt) + result.repair_attempts = [attempt] + result.notes.append( + f"Automatic repair attempt queued for {result.task_id}: {attempt.repair_id}" + ) + return result.repair_attempts + + +def mark_repair_attempts_active(project_root: Path, task_id: str) -> List[RepairAttempt]: + updated: List[RepairAttempt] = [] + for attempt in list_repair_attempts(project_root, task_id): + if attempt.status == "queued": + updated.append(_set_attempt_status(attempt, "active")) + return updated + + +def merge_repair_attempts_into_state( + state: Dict[str, object], repair_attempts: List[RepairAttempt] +) -> Dict[str, object]: + state["repairPolicy"] = normalize_repair_policy(dict(state.get("repairPolicy", {}))) + + existing_items = list(state.get("repairAttempts", [])) + items_by_id = {} + for item in existing_items: + if not isinstance(item, dict): + continue + repair_id = str(item.get("repairId", "")).strip() + if repair_id: + items_by_id[repair_id] = item + + for attempt in repair_attempts: + items_by_id[attempt.repair_id] = attempt.to_dict() + + merged_items = [items_by_id[key] for key in sorted(items_by_id)] + state["repairAttempts"] = merged_items + state["activeRepairCount"] = sum( + 1 for item in merged_items if str(item.get("status", "")).strip() in {"queued", "active"} + ) + if repair_attempts: + latest = repair_attempts[-1] + state["lastRepairAttemptId"] = latest.repair_id + state["lastRepairTaskId"] = latest.task_id + state["lastRepairAt"] = latest.created_at + return state + + +def summarize_active_repairs(state: Dict[str, object]) -> List[Dict[str, str]]: + summary: List[Dict[str, str]] = [] + for item in list(state.get("repairAttempts", [])): + if not isinstance(item, dict): + continue + status = str(item.get("status", "")).strip() + if status not in {"queued", "active"}: + continue + summary.append( + { + "repairId": str(item.get("repairId", "")).strip(), + "taskId": str(item.get("taskId", "")).strip(), + "status": status, + "reason": str(item.get("reason", "")).strip(), + "repairBriefPath": str(item.get("repairBriefPath", "")).strip(), + } + ) + return summary + + +def write_repair_queue(project_root: Path, state: Dict[str, object]) -> Path: + queue_path = _repair_queue_path(project_root) + active_items = summarize_active_repairs(state) + lines = [ + "# Air Engine Auto Repair Queue", + "", + "This file lists repair attempts that were automatically queued from blocked or failed worker results.", + "", + ] + if active_items: + for item in active_items: + lines.append(f"- Repair: `{item['repairId']}`") + lines.append(f" Task: `{item['taskId']}`") + lines.append(f" Status: `{item['status']}`") + lines.append(f" Reason: {item['reason'] or 'n/a'}") + lines.append(f" Brief: `{item['repairBriefPath']}`") + else: + lines.append("- No active auto repair attempts.") + + queue_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return queue_path diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/review.py b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/review.py new file mode 100755 index 0000000..4a51371 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/review.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from itertools import combinations +from pathlib import Path +from typing import Dict, Iterable, List, Set + +from .contracts import ( + ACTIVE_TASK_STATUSES, + ParallelGroup, + ParallelReview, + ReviewConflict, + TaskRecord, + now_iso, +) +from .todo_parser import parse_tasks + + +def _overlap_paths(left: Iterable[str], right: Iterable[str]) -> List[str]: + overlap: Set[str] = set() + left_items = list(left) + right_items = list(right) + + for left_path in left_items: + for right_path in right_items: + left_clean = left_path.rstrip("/\\") + right_clean = right_path.rstrip("/\\") + if left_clean == right_clean: + overlap.add(left_path) + continue + if left_clean.startswith(right_clean): + overlap.add(right_path) + elif right_clean.startswith(left_clean): + overlap.add(left_path) + + return sorted(overlap) + + +def _task_conflict(left: TaskRecord, right: TaskRecord) -> ReviewConflict | None: + overlap = _overlap_paths(left.normalized_write_set(), right.normalized_write_set()) + if overlap: + return ReviewConflict( + task_ids=[left.task_id, right.task_id], + reason="shared write set overlap", + overlap_paths=overlap, + ) + + if left.touches_global_docs() and right.touches_global_docs(): + return ReviewConflict( + task_ids=[left.task_id, right.task_id], + reason="both tasks touch global planning or architecture documents", + overlap_paths=sorted(set(left.global_doc_paths + right.global_doc_paths)), + ) + + return None + + +def _greedy_parallel_groups(tasks: List[TaskRecord]) -> List[ParallelGroup]: + groups: List[List[TaskRecord]] = [] + for task in tasks: + placed = False + for group in groups: + if all(_task_conflict(task, existing) is None for existing in group): + group.append(task) + placed = True + break + if not placed: + groups.append([task]) + + output: List[ParallelGroup] = [] + for index, group in enumerate(groups, start=1): + output.append( + ParallelGroup( + name=f"group-{index}", + task_ids=[task.task_id for task in group], + reason="no detected write-set conflict inside this group", + ) + ) + return output + + +def _build_dependency_edges(tasks: List[TaskRecord]) -> List[Dict[str, str]]: + task_ids = {task.task_id for task in tasks} + edges: List[Dict[str, str]] = [] + for task in tasks: + for dependency in task.dependencies: + if dependency in task_ids: + edges.append({"from": dependency, "to": task.task_id}) + return edges + + +def build_parallel_review(todo_path: Path) -> ParallelReview: + all_tasks = parse_tasks(todo_path) + active_tasks = [task for task in all_tasks if task.status in ACTIVE_TASK_STATUSES] + active_task_ids = {task.task_id for task in active_tasks} + task_map = {task.task_id: task for task in active_tasks} + edges = _build_dependency_edges(active_tasks) + + in_degree = {task.task_id: 0 for task in active_tasks} + children: Dict[str, List[str]] = {task.task_id: [] for task in active_tasks} + for edge in edges: + parent = edge["from"] + child = edge["to"] + if parent not in active_task_ids or child not in active_task_ids: + continue + in_degree[child] += 1 + children[parent].append(child) + + ready = sorted( + [task.task_id for task in active_tasks if in_degree[task.task_id] == 0], + key=lambda task_id: task_map[task_id].line_number, + ) + scheduled = set() + parallel_groups: List[ParallelGroup] = [] + wave_index = 1 + + while ready: + current_wave_ids = ready + ready = [] + current_tasks = [task_map[task_id] for task_id in current_wave_ids] + wave_groups = _greedy_parallel_groups(current_tasks) + for group in wave_groups: + group.name = f"wave-{wave_index}-{group.name}" + parallel_groups.extend(wave_groups) + wave_index += 1 + + for task_id in current_wave_ids: + scheduled.add(task_id) + for child in children.get(task_id, []): + in_degree[child] -= 1 + if in_degree[child] == 0: + ready.append(child) + ready.sort(key=lambda task_id: task_map[task_id].line_number) + + notes: List[str] = [] + unscheduled = sorted(active_task_ids - scheduled) + if unscheduled: + notes.append( + "Some active tasks could not be layered. Check for cyclic or missing dependencies: " + + ", ".join(unscheduled) + ) + + if all(not task.dependencies for task in active_tasks) and len(active_tasks) > 1: + notes.append( + "No explicit dependency hints were found. Parallel grouping relies on write-set isolation and global-doc serialization rules." + ) + + blocked_tasks = [task.task_id for task in all_tasks if task.status == "BLOCKED"] + if blocked_tasks: + notes.append("Blocked tasks were excluded from ready groups: " + ", ".join(blocked_tasks)) + + conflicts: List[ReviewConflict] = [] + for left, right in combinations(active_tasks, 2): + conflict = _task_conflict(left, right) + if conflict is not None: + conflicts.append(conflict) + + serialization_points: List[Dict[str, object]] = [] + for task in active_tasks: + reasons: List[str] = [] + if task.touches_global_docs(): + reasons.append("touches global planning or architecture documents") + if any(task.task_id in conflict.task_ids for conflict in conflicts): + reasons.append("has shared write-set conflicts that need engine-level scheduling") + if reasons: + serialization_points.append( + { + "taskId": task.task_id, + "reasons": reasons, + "paths": task.global_doc_paths or task.normalized_write_set(), + } + ) + + return ParallelReview( + source_todo=str(todo_path), + generated_at=now_iso(), + tasks_considered=active_tasks, + dependency_edges=edges, + parallel_groups=parallel_groups, + conflicts=conflicts, + serialization_points=serialization_points, + notes=notes, + ) + + +def render_review_markdown(review: ParallelReview) -> str: + lines = [ + "# AirArc Parallel Review", + "", + f"- Source TODO: `{review.source_todo}`", + f"- Generated At: `{review.generated_at}`", + f"- Active Tasks: `{', '.join(task.task_id for task in review.tasks_considered) or 'none'}`", + "", + "## Parallel Groups", + ] + + if review.parallel_groups: + for group in review.parallel_groups: + lines.append(f"- `{group.name}`: {', '.join(group.task_ids)}") + lines.append(f" Reason: {group.reason}") + else: + lines.append("- No ready parallel groups were detected.") + + lines.extend(["", "## Dependency Edges"]) + if review.dependency_edges: + for edge in review.dependency_edges: + lines.append(f"- `{edge['from']}` -> `{edge['to']}`") + else: + lines.append("- No explicit dependency edges were detected.") + + lines.extend(["", "## Shared-Write Conflicts"]) + if review.conflicts: + for conflict in review.conflicts: + lines.append( + f"- `{conflict.task_ids[0]}` <-> `{conflict.task_ids[1]}`: {conflict.reason}" + ) + if conflict.overlap_paths: + lines.append(" Paths: " + ", ".join(f"`{path}`" for path in conflict.overlap_paths)) + else: + lines.append("- No shared-write conflicts were detected.") + + lines.extend(["", "## Serialization Points"]) + if review.serialization_points: + for point in review.serialization_points: + lines.append(f"- `{point['taskId']}`: {'; '.join(point['reasons'])}") + paths = point.get("paths", []) + if paths: + lines.append(" Paths: " + ", ".join(f"`{path}`" for path in paths)) + else: + lines.append("- No serialization points were detected.") + + lines.extend(["", "## Notes"]) + if review.notes: + for note in review.notes: + lines.append(f"- {note}") + else: + lines.append("- No extra notes.") + + return "\n".join(lines) + "\n" diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/todo_parser.py b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/todo_parser.py new file mode 100755 index 0000000..4f14748 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/todo_parser.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Dict, List, Optional + +from .contracts import TaskRecord + +TASK_ID_RE = re.compile(r"T-\d+") +HEADER_RE = re.compile(r"^\|\s*ID\s*\|\s*Status\s*\|", re.IGNORECASE) +SEPARATOR_RE = re.compile(r"^\|\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?$") +CODE_SPAN_RE = re.compile(r"`([^`]+)`") +DEPENDENCY_HINT_RE = re.compile(r"\[(?:deps?|depends)\s*:\s*([^\]]+)\]", re.IGNORECASE) + + +def _split_row(line: str) -> List[str]: + return [cell.strip() for cell in line.strip().strip("|").split("|")] + + +def _normalize_paths(items: List[str]) -> List[str]: + seen = set() + ordered: List[str] = [] + for item in items: + cleaned = item.strip().strip("`") + if not cleaned or cleaned.lower() == "planned": + continue + if cleaned in seen: + continue + seen.add(cleaned) + ordered.append(cleaned) + return ordered + + +def extract_paths(cell_text: str) -> List[str]: + code_paths = CODE_SPAN_RE.findall(cell_text) + if code_paths: + return _normalize_paths(code_paths) + + candidates = re.split(r"[,;+]", cell_text) + paths = [ + token.strip() + for token in candidates + if "/" in token or "\\" in token or token.endswith((".md", ".py", ".json")) + ] + return _normalize_paths(paths) + + +def extract_dependencies(*cells: str) -> List[str]: + found: List[str] = [] + for cell in cells: + for match in DEPENDENCY_HINT_RE.finditer(cell): + found.extend(TASK_ID_RE.findall(match.group(1))) + return _normalize_paths(found) + + +def extract_global_doc_paths(*cells: str) -> List[str]: + text = " ".join(cells) + candidates: List[str] = [] + lowered = text.lower() + + if "agents.md" in lowered or "agents" in text: + candidates.append("AirPlan/AGENTS.md") + if "airplan/docs/architecture/adr" in lowered or "docs/architecture/adr" in lowered or "adr" in text: + candidates.append("AirPlan/docs/architecture/adr/") + if "airplan/docs/architecture/c4/module.md" in lowered or "docs/architecture/c4/module.md" in lowered or "c4" in text: + candidates.append("AirPlan/docs/architecture/c4/module.md") + if "plan.md" in lowered: + candidates.append("AirPlan/plan.md") + if "todo.md" in lowered or "todo" in text: + candidates.append("AirPlan/todo.md") + if "staticanalysis.md" in lowered: + candidates.append("AirPlan/docs/staticanalysis.md") + + for path in extract_paths(text): + normalized = path.replace("\\", "/") + if normalized in {"AGENTS.md", "AirPlan/AGENTS.md"}: + candidates.append("AirPlan/AGENTS.md") + elif "AirPlan/docs/architecture/adr" in normalized or "docs/architecture/adr" in normalized: + candidates.append("AirPlan/docs/architecture/adr/") + elif "AirPlan/docs/architecture/c4/module.md" in normalized or "docs/architecture/c4/module.md" in normalized: + candidates.append("AirPlan/docs/architecture/c4/module.md") + elif normalized in {"plan.md", "AirPlan/plan.md"}: + candidates.append("AirPlan/plan.md") + elif normalized in {"todo.md", "AirPlan/todo.md"}: + candidates.append("AirPlan/todo.md") + elif normalized in {"staticanalysis.md", "AirPlan/docs/staticanalysis.md"}: + candidates.append("AirPlan/docs/staticanalysis.md") + + return _normalize_paths(candidates) + + +def parse_tasks(todo_path: Path) -> List[TaskRecord]: + lines = todo_path.read_text(encoding="utf-8").splitlines() + header_index: Optional[int] = None + + for idx, line in enumerate(lines): + if HEADER_RE.search(line): + header_index = idx + break + + if header_index is None: + raise ValueError(f"no TODO task table found in {todo_path}") + + header_cells = _split_row(lines[header_index]) + tasks: List[TaskRecord] = [] + + for line_number in range(header_index + 1, len(lines)): + raw_line = lines[line_number] + if not raw_line.strip(): + if tasks: + break + continue + if not raw_line.strip().startswith("|"): + if tasks: + break + continue + if SEPARATOR_RE.match(raw_line): + continue + + row_cells = _split_row(raw_line) + if len(row_cells) < len(header_cells): + row_cells += [""] * (len(header_cells) - len(row_cells)) + + row: Dict[str, str] = dict(zip(header_cells, row_cells)) + task_id = row.get("ID", "").strip() + if not task_id: + continue + + files_dirs = row.get("Files/Dirs", "").strip() + adr_c4_update = row.get("ADR/C4 Update", "").strip() + task_text = row.get("Task", "").strip() + validation = row.get("Validation", "").strip() + + tasks.append( + TaskRecord( + task_id=task_id, + status=row.get("Status", "").strip().upper(), + module=row.get("Module", "").strip(), + task=task_text, + files_dirs=files_dirs, + done_when=row.get("Done When", "").strip(), + validation=validation, + adr_c4_update=adr_c4_update, + line_number=line_number + 1, + dependencies=extract_dependencies(task_text, files_dirs, validation, adr_c4_update), + write_paths=extract_paths(files_dirs), + global_doc_paths=extract_global_doc_paths( + files_dirs, adr_c4_update, task_text + ), + ) + ) + + return tasks + + +def find_task(tasks: List[TaskRecord], task_id: str) -> Optional[TaskRecord]: + for task in tasks: + if task.task_id == task_id: + return task + return None diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/worker.py b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/worker.py new file mode 100755 index 0000000..042fe7e --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/worker.py @@ -0,0 +1,372 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Dict, List + +from .airxdb_runtime import ensure_xdb_sessions_for_result, task_requires_xdb +from .contracts import ( + DocumentUpdate, + WorkerResult, + default_worker_result, + now_iso, + required_project_artifacts, +) +from .debug_runtime import ensure_debug_sessions_for_result +from .doc_sync import enforce_doc_sync_requirements +from .paths import ( + agents_path, + airdo_root, + architecture_adr_dir, + architecture_c4_module_path, + plan_path, + todo_path, +) +from .repair_runtime import load_active_repair_attempt +from .todo_parser import find_task, parse_tasks + + +def _json_dump(path: Path, payload: Dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def _artifact_health(project_root: Path) -> Dict[str, bool]: + return { + relative: (project_root / relative).exists() + for relative in required_project_artifacts() + } + + +def _paths(project_root: Path) -> Dict[str, Path]: + worker_root = airdo_root(project_root) + return { + "root": worker_root, + "state": worker_root / "state.json", + "tasks": worker_root / "tasks", + "results": worker_root / "results", + } + + +def _ensure_layout(project_root: Path) -> Dict[str, Path]: + paths = _paths(project_root) + paths["tasks"].mkdir(parents=True, exist_ok=True) + paths["results"].mkdir(parents=True, exist_ok=True) + return paths + + +def _task_dir(project_root: Path, task_id: str) -> Path: + return _paths(project_root)["tasks"] / task_id + + +def worker_state_path(project_root: Path, task_id: str) -> Path: + return _task_dir(project_root, task_id) / "worker-state.json" + + +def _load_task_record(project_root: Path, task_id: str): + current_todo = todo_path(project_root) + if not current_todo.exists(): + return None + try: + return find_task(parse_tasks(current_todo), task_id) + except ValueError: + return None + + +def _worker_rules_block() -> List[str]: + return [ + "## Worker Rules", + "", + "- Load `AirPlan/AGENTS.md`, `AirPlan/docs/architecture/adr/`, `AirPlan/docs/architecture/c4/module.md`, `AirPlan/plan.md`, and `AirPlan/todo.md` before doing work.", + "- Keep the task scoped to this brief.", + "- Preserve validation evidence on disk.", + "- Do not directly take ownership of global `AirPlan/todo.md`, `AirPlan/AGENTS.md`, ADR, or C4 updates unless explicitly delegated.", + "- If this task touches global docs, `result.json` must include executable `documentUpdates`, not just recommendations.", + "- If this task is GUI or visual acceptance related, `result.json` must end with successful AirXDB evidence before it can close as done.", + "- If the result is blocked, or a validation ends in failed/error, the worker auto-requests AirDbg before finalize.", + "- If the task is GUI-related, the worker auto-captures AirXDB evidence before finalize.", + "- If an active auto-repair attempt exists, continue repairing immediately instead of stopping at the debug request.", + "- Do not stop at an 'about to implement', 'implementation plan ready', or similar midpoint status. Continue editing, validating, and finalizing unless a real blocker or user decision is required.", + "- Do not return only a progress update when the task is actionable. Return only after finalize, or after recording a concrete blocked reason that needs intervention.", + "- Fill `result.json`, then finalize it for engine merge.", + "", + ] + + +def _placeholder_result(project_root: Path, task_id: str) -> WorkerResult: + task_summary = "" + task = _load_task_record(project_root, task_id) + if task: + task_summary = task.task + + result = default_worker_result(task_id, task_summary) + if task and task.global_doc_paths: + result.global_doc_paths = list(task.global_doc_paths) + result.recommend_global_doc_updates = list(task.global_doc_paths) + result.document_updates = [] + for doc_path in task.global_doc_paths: + if doc_path == "AirPlan/docs/architecture/adr/": + result.document_updates.append( + DocumentUpdate( + path="AirPlan/docs/architecture/adr/ADR-XXXX-task-sync.md", + action="create_file", + content=( + "# ADR-XXXX: task-sync\n\n" + "- Status: Accepted\n" + "- Date: YYYY-MM-DD\n\n" + "## Context\n" + "Replace with the concrete context introduced by this task.\n\n" + "## Decision\n" + "Replace with the concrete decision introduced by this task.\n\n" + "## Consequences\n" + "- Replace with the concrete consequences introduced by this task.\n" + ), + ) + ) + else: + result.document_updates.append( + DocumentUpdate( + path=doc_path, + action="replace_block", + marker=f"{task_id}-{Path(doc_path).name}".replace(".", "-").upper(), + content=( + f"## Sync For {task_id}\n\n" + "- Summary: Replace with a concrete summary.\n" + "- Code Reality: Replace with the actual code outcome.\n" + "- Validation: Replace with the actual validation result.\n" + ), + ) + ) + return result + + +def _is_untouched_placeholder_result(project_root: Path, result: WorkerResult) -> bool: + placeholder = _placeholder_result(project_root, result.task_id) + return result.to_dict() == placeholder.to_dict() + + +def resolve_worker_result_path(project_root: Path, task_id: str) -> Path: + state_path = worker_state_path(project_root, task_id) + if state_path.exists(): + payload = json.loads(state_path.read_text(encoding="utf-8-sig")) + raw_path = str(payload.get("resultPath", "")).strip() + if raw_path: + candidate = Path(raw_path).expanduser() + if not candidate.is_absolute(): + candidate = (project_root / candidate).resolve() + return candidate + return _task_dir(project_root, task_id) / "result.json" + + +def _write_brief(project_root: Path, task_id: str) -> Path: + task_folder = _task_dir(project_root, task_id) + task_folder.mkdir(parents=True, exist_ok=True) + task = _load_task_record(project_root, task_id) + active_repair = load_active_repair_attempt(project_root, task_id) + + lines: List[str] = [f"# Worker Brief: {task_id}", ""] + if task: + requires_xdb = task_requires_xdb(task, None) + lines.extend( + [ + f"- Module: `{task.module}`", + f"- Status In TODO: `{task.status}`", + f"- Task: {task.task}", + f"- Write Scope: {', '.join(f'`{path}`' for path in task.write_paths) or '`(not declared)`'}", + f"- Global Doc Paths: {', '.join(f'`{path}`' for path in task.global_doc_paths) or '`none`'}", + f"- Validation: {task.validation or '(not declared)'}", + f"- Document Sync Required: {'yes' if task.global_doc_paths else 'no'}", + f"- AirXDB Required: {'yes' if requires_xdb else 'no'}", + "", + ] + ) + + if active_repair: + lines.extend( + [ + "## Active Auto Repair", + "", + f"- Repair ID: `{active_repair.repair_id}`", + f"- Attempt Index: `{active_repair.attempt_index}`", + f"- Status: `{active_repair.status}`", + f"- Reason: {active_repair.reason}", + f"- Repair Brief: `{active_repair.repair_brief_path}`", + f"- Debug Sessions: {', '.join(f'`{item}`' for item in active_repair.debug_session_ids) or '`none`'}", + "", + ] + ) + + lines.extend(_worker_rules_block()) + brief_path = task_folder / "brief.md" + brief_path.write_text("\n".join(lines), encoding="utf-8") + return brief_path + + +def _write_handoff(project_root: Path, task_id: str, brief_path: Path, result_path: Path) -> Path: + task = _load_task_record(project_root, task_id) + current_worker_state_path = worker_state_path(project_root, task_id) + handoff_path = _task_dir(project_root, task_id) / "subagent-handoff.md" + lines = [ + f"# AirDo Subagent Handoff: {task_id}", + "", + "You are an isolated AirDo execution subagent launched by AirEng.", + "", + f"- Task ID: `{task_id}`", + f"- Project Root: `{project_root}`", + f"- Brief Path: `{brief_path}`", + f"- Worker State Path: `{current_worker_state_path}`", + f"- Template Result Path: `{result_path}`", + f"- Task Summary: {task.task if task else '(load from brief)'}", + f"- Write Scope: {', '.join(f'`{path}`' for path in (task.write_paths if task else [])) or '`(load from brief)`'}", + "", + "## Required Reads", + "", + f"- `{agents_path(project_root)}`", + f"- `{architecture_adr_dir(project_root)}`", + f"- `{architecture_c4_module_path(project_root)}`", + f"- `{plan_path(project_root)}`", + f"- `{todo_path(project_root)}`", + f"- `{brief_path}`", + "", + "## Execution Contract", + "", + "- Stay inside this task scope and its declared write set.", + "- Do not assume the parent thread history is available; build context from the files above.", + "- Execute the task, gather validation evidence, and update `result.json` with honest status, files changed, risks, blockers, and document updates when required.", + "- Run AirDbg or AirXDB automatically when the runtime rules require them.", + "- Do not pause for intermediate implementation-status replies. Keep going until the task is finalized unless you hit a real blocker that requires external input.", + "- Finalize the result before returning control to AirEng.", + "", + "## Finalize Command", + "", + f"`python \"$HOME/plugins/airdo/scripts/airdo_mode.py\" --mode finish --project . --task-id {task_id}`", + "", + "## Return Contract", + "", + "- Report the final status and the finalized result path.", + "- After `finish`, treat `worker-state.json` `resultPath` as the canonical result location instead of re-reading the template `result.json`.", + "- Do not merge global docs yourself unless the task explicitly delegated document updates through `result.json`.", + "", + ] + handoff_path.write_text("\n".join(lines), encoding="utf-8") + return handoff_path + + +def enter_worker(project_root: Path, task_id: str) -> Dict[str, object]: + paths = _ensure_layout(project_root) + task_folder = _task_dir(project_root, task_id) + task_folder.mkdir(parents=True, exist_ok=True) + + result_path = task_folder / "result.json" + if not result_path.exists(): + result = _placeholder_result(project_root, task_id) + _json_dump(result_path, result.to_dict()) + + brief_path = _write_brief(project_root, task_id) + handoff_path = _write_handoff(project_root, task_id, brief_path, result_path) + state_path = worker_state_path(project_root, task_id) + state = { + "enabled": True, + "updatedAt": now_iso(), + "projectRoot": str(project_root), + "artifactHealth": _artifact_health(project_root), + "activeTaskId": task_id, + } + _json_dump(paths["state"], state) + _json_dump( + state_path, + { + "taskId": task_id, + "status": "entered", + "enteredAt": now_iso(), + "templateResultPath": str(result_path), + "resultPath": str(result_path), + "briefPath": str(brief_path), + "handoffPath": str(handoff_path), + }, + ) + return { + "taskId": task_id, + "briefPath": str(brief_path), + "handoffPath": str(handoff_path), + "resultPath": str(result_path), + "workerStatePath": str(state_path), + } + + +def handoff_worker(project_root: Path, task_id: str) -> Dict[str, object]: + entered = enter_worker(project_root, task_id) + return { + "taskId": task_id, + "briefPath": entered["briefPath"], + "handoffPath": entered["handoffPath"], + "resultPath": entered["resultPath"], + "workerStatePath": entered["workerStatePath"], + } + + +def finish_worker(project_root: Path, task_id: str, result_path: Path | None) -> Dict[str, object]: + _ensure_layout(project_root) + task_folder = _task_dir(project_root, task_id) + if result_path is None: + result_path = task_folder / "result.json" + + result = WorkerResult.from_dict(json.loads(result_path.read_text(encoding="utf-8-sig"))) + if _is_untouched_placeholder_result(project_root, result): + raise ValueError( + "worker result is still the untouched default template; update result.json before finalize" + ) + result.validate_for_finalize() + ensure_xdb_sessions_for_result(project_root, result, "worker-finish") + ensure_debug_sessions_for_result(project_root, result, "worker-finish") + result.validate_for_finalize() + enforce_doc_sync_requirements(project_root, result) + if result.task_id != task_id: + raise ValueError("result taskId does not match --task-id") + if not result.finalized_at: + result.finalized_at = now_iso() + + finalized_path = _paths(project_root)["results"] / f"{task_id}.json" + _json_dump(finalized_path, result.to_dict()) + previous_state: Dict[str, object] = {} + current_state_path = worker_state_path(project_root, task_id) + if current_state_path.exists(): + previous_state = json.loads(current_state_path.read_text(encoding="utf-8-sig")) + _json_dump( + current_state_path, + { + "taskId": task_id, + "status": "completed", + "enteredAt": previous_state.get("enteredAt", ""), + "briefPath": previous_state.get("briefPath", ""), + "handoffPath": previous_state.get("handoffPath", ""), + "templateResultPath": previous_state.get("templateResultPath", str(task_folder / "result.json")), + "finalizedAt": result.finalized_at, + "resultPath": str(finalized_path), + }, + ) + return { + "taskId": task_id, + "status": result.status, + "finalizedResultPath": str(finalized_path), + "workerStatePath": str(current_state_path), + } + + +def status_worker(project_root: Path) -> Dict[str, object]: + paths = _ensure_layout(project_root) + state_path = paths["state"] + enabled = state_path.exists() + active_task_id = "" + if enabled: + payload = json.loads(state_path.read_text(encoding="utf-8-sig")) + active_task_id = str(payload.get("activeTaskId", "")) + + task_ids = sorted(path.name for path in paths["tasks"].iterdir() if path.is_dir()) + return { + "enabled": enabled, + "projectRoot": str(project_root), + "artifactHealth": _artifact_health(project_root), + "activeTaskId": active_task_id, + "taskIds": task_ids, + } diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/.codex-plugin/plugin.json b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/.codex-plugin/plugin.json new file mode 100755 index 0000000..40727bc --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/.codex-plugin/plugin.json @@ -0,0 +1,42 @@ +{ + "name": "airarc", + "version": "0.3.1", + "description": "Experimental upgraded AirArc prototype with built-in post-plan parallelization review support.", + "author": { + "name": "14816", + "email": "noreply@example.com", + "url": "https://airlongdian.fun" + }, + "homepage": "https://airlongdian.fun/plugins/airarc", + "repository": "https://airlongdian.fun/plugins/airarc", + "license": "MIT", + "keywords": [ + "airarc", + "architecture", + "planning", + "parallel", + "review" + ], + "skills": "./skills/", + "interface": { + "displayName": "AirArc", + "shortDescription": "Architecture-first planning with built-in parallel review", + "longDescription": "This AirArc prototype keeps architecture-first planning and adds a built-in post-plan review step that emits dependency edges, parallel-safe groups, shared write-set conflicts, serialization points, and an execution plan for Air Engine.", + "developerName": "14816", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Write" + ], + "websiteURL": "https://airlongdian.fun/plugins/airarc", + "privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/", + "termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/", + "defaultPrompt": [ + "Use AirArc to initialize or inspect project planning context.", + "Use AirArc to produce a post-plan parallelization review from todo.md.", + "Use AirArc to identify dependency edges, shared-write conflicts, and serialization points before workers run." + ], + "brandColor": "#0D9488", + "screenshots": [] + } +} diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/commands/airarc.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/commands/airarc.md new file mode 100755 index 0000000..9600809 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/commands/airarc.md @@ -0,0 +1,24 @@ +--- +description: Enter, inspect, or run the upgraded AirArc planning flow with built-in parallel review output. AirArc is architecture-only and must not write code. +argument-hint: [enter|status|parallel-review] +allowed-tools: [Read, Glob, Grep, Bash, Write, Edit] +--- + +# /airarc + +Use the upgraded AirArc plugin for architecture-first planning and post-plan parallel review. AirArc only does architecture planning, task decomposition, and document updates; it does not implement code changes. + +## Steps + +1. Parse `$ARGUMENTS`; default to `enter` when empty. +2. Run the matching mode from the current project root: + +```bash +python "$HOME/plugins/airarc/scripts/airarc_mode.py" --mode --project . +``` + +3. The runtime auto-bootstraps missing `AirPlan/` files on first startup and does not overwrite existing project artifacts. +4. If the request is a planning or architecture-change task, keep `AirPlan/AGENTS.md`, ADRs, C4 module docs, `AirPlan/plan.md`, and `AirPlan/todo.md` aligned with the new AirArc output. +5. Do not write code, apply patches, or implement tasks directly from AirArc; hand confirmed execution work off to AirEng or other execution workflows. +6. When planning needs UI or visual evidence, hand off screenshots or minimal GUI exploration to AirXDB before finalizing the plan. +7. When `parallel-review` runs, record the emitted dependency edges, shared-write conflicts, serialization points, and execution-plan artifacts. diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/scripts/airarc_mode.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/scripts/airarc_mode.py new file mode 100755 index 0000000..54d1ef3 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/scripts/airarc_mode.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +def _add_lib_path() -> None: + root = Path(__file__).resolve().parents[3] + 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.contracts import now_iso +from air_runtime.paths import airarc_root, required_project_artifacts, todo_path as workflow_todo_path +from air_runtime.project_bootstrap import ensure_project_bootstrap +from air_runtime.review import build_parallel_review, render_review_markdown + +REQUIRED_ARTIFACTS = required_project_artifacts() + + +def _paths(project_root: Path) -> dict[str, Path]: + root = airarc_root(project_root) + return { + "root": root, + "state": root / "state.json", + "reviews": root / "reviews", + "execution_plan_json": root / "reviews" / "execution-plan.json", + "execution_plan_md": root / "reviews" / "execution-plan.md", + } + + +def _ensure_layout(project_root: Path) -> dict[str, Path]: + paths = _paths(project_root) + paths["reviews"].mkdir(parents=True, exist_ok=True) + return paths + + +def _artifact_health(project_root: Path) -> dict[str, bool]: + return { + relative: (project_root / relative).exists() + for relative in REQUIRED_ARTIFACTS + } + + +def _write_state(project_root: Path, enabled: bool) -> Path: + paths = _ensure_layout(project_root) + payload = { + "enabled": enabled, + "updatedAt": now_iso(), + "projectRoot": str(project_root), + "artifactHealth": _artifact_health(project_root), + } + paths["state"].write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + return paths["state"] + + +def enter_mode(project_root: Path) -> dict[str, object]: + state_path = _write_state(project_root, True) + return { + "statePath": str(state_path), + "artifactHealth": _artifact_health(project_root), + } + + +def status_mode(project_root: Path) -> dict[str, object]: + paths = _ensure_layout(project_root) + if paths["state"].exists(): + payload = json.loads(paths["state"].read_text(encoding="utf-8-sig")) + else: + payload = { + "enabled": False, + "projectRoot": str(project_root), + "artifactHealth": _artifact_health(project_root), + } + payload["artifactHealth"] = _artifact_health(project_root) + return payload + + +def parallel_review_mode(project_root: Path, todo_path: Path) -> dict[str, object]: + paths = _ensure_layout(project_root) + review = build_parallel_review(todo_path) + json_path = paths["reviews"] / "parallel-review.json" + markdown_path = paths["reviews"] / "parallel-review.md" + json_path.write_text(json.dumps(review.to_dict(), indent=2) + "\n", encoding="utf-8") + markdown_path.write_text(render_review_markdown(review), encoding="utf-8") + selected_tasks = review.parallel_groups[0].task_ids if review.parallel_groups else [] + execution_plan_payload = { + "generatedAt": now_iso(), + "projectRoot": str(project_root), + "todoPath": str(todo_path), + "planSource": "airarc-post-plan-review", + "parallelReview": review.to_dict(), + "selectedTasks": selected_tasks, + "parallelGroups": [group.to_dict() for group in review.parallel_groups], + "conflicts": [conflict.to_dict() for conflict in review.conflicts], + "serializationPoints": review.serialization_points, + } + paths["execution_plan_json"].write_text( + json.dumps(execution_plan_payload, indent=2) + "\n", + encoding="utf-8", + ) + execution_lines = [ + "# AirArc Execution Plan", + "", + f"- Generated At: `{execution_plan_payload['generatedAt']}`", + f"- Todo Path: `{todo_path}`", + f"- Plan Source: `{execution_plan_payload['planSource']}`", + "", + "## Selected Tasks", + ] + if selected_tasks: + for task_id in selected_tasks: + execution_lines.append(f"- `{task_id}`") + else: + execution_lines.append("- No selected tasks.") + execution_lines.extend(["", "## Parallel Groups"]) + if review.parallel_groups: + for group in review.parallel_groups: + execution_lines.append(f"- `{group.name}`: {', '.join(group.task_ids)}") + execution_lines.append(f" Reason: {group.reason}") + else: + execution_lines.append("- No parallel groups.") + execution_lines.extend(["", "## Serialization Points"]) + if review.serialization_points: + for item in review.serialization_points: + execution_lines.append(f"- `{item['taskId']}`: {'; '.join(item['reasons'])}") + else: + execution_lines.append("- No serialization points.") + paths["execution_plan_md"].write_text("\n".join(execution_lines) + "\n", encoding="utf-8") + _write_state(project_root, True) + return { + "jsonPath": str(json_path), + "markdownPath": str(markdown_path), + "executionPlanJsonPath": str(paths["execution_plan_json"]), + "executionPlanMarkdownPath": str(paths["execution_plan_md"]), + "parallelGroupCount": len(review.parallel_groups), + "conflictCount": len(review.conflicts), + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Upgraded AirArc prototype runtime") + parser.add_argument("--mode", choices=["enter", "status", "parallel-review"], default="status") + parser.add_argument("--project", default=".") + parser.add_argument("--todo", default="") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + project_root = Path(args.project).expanduser().resolve() + ensure_project_bootstrap(project_root) + + if args.mode == "enter": + result = enter_mode(project_root) + print("airarc_mode=enabled") + print(f"project_root={project_root}") + print(f"state_path={result['statePath']}") + for key, value in result["artifactHealth"].items(): + print(f"{key}={'ok' if value else 'missing'}") + return + + if args.mode == "status": + result = status_mode(project_root) + print(f"airarc_mode={'enabled' if result.get('enabled') else 'disabled'}") + print(f"project_root={project_root}") + for key, value in result["artifactHealth"].items(): + print(f"{key}={'ok' if value else 'missing'}") + return + + current_todo_path = Path(args.todo).expanduser().resolve() if args.todo else workflow_todo_path(project_root) + result = parallel_review_mode(project_root, current_todo_path) + print("airarc_mode=parallel-reviewed") + print(f"project_root={project_root}") + print(f"todo_path={current_todo_path}") + print(f"json_path={result['jsonPath']}") + print(f"markdown_path={result['markdownPath']}") + print(f"execution_plan_json_path={result['executionPlanJsonPath']}") + print(f"execution_plan_markdown_path={result['executionPlanMarkdownPath']}") + print(f"parallel_group_count={result['parallelGroupCount']}") + print(f"conflict_count={result['conflictCount']}") + + +if __name__ == "__main__": + main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/skills/airarc/SKILL.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/skills/airarc/SKILL.md new file mode 100755 index 0000000..6df0492 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/skills/airarc/SKILL.md @@ -0,0 +1,37 @@ +--- +name: airarc +description: Architecture-first workflow with built-in post-plan parallelization review. Use when planning should emit dependency edges, parallel groups, write-set conflicts, and serialization points for AirEng. AirArc plans and edits planning docs only; it does not write code. +--- + +# AirArc + +## Upgrade Notes + +- Keep the original architecture-first planning role. +- AirArc is an architect-only workflow: it may plan tasks and edit architecture or planning documents, but it must not implement code changes. +- Add built-in post-plan review instead of a separate review-only plugin. +- Emit engine-consumable execution artifacts after planning. + +## Outputs + +- `AirPlan/state/airarc/state.json` +- `AirPlan/state/airarc/reviews/parallel-review.json` +- `AirPlan/state/airarc/reviews/parallel-review.md` +- `AirPlan/state/airarc/reviews/execution-plan.json` +- `AirPlan/state/airarc/reviews/execution-plan.md` + +## Review Responsibilities + +- Compute dependency edges. +- Compute parallel-safe groups. +- Detect shared write-set conflicts. +- Mark serialization points for global docs and merge boundaries. +- Produce an execution plan that AirEng can prefer directly. + +## Commands + +```bash +python ../../scripts/airarc_mode.py --mode enter --project +python ../../scripts/airarc_mode.py --mode status --project +python ../../scripts/airarc_mode.py --mode parallel-review --project --todo +``` diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/skills/airarc/agents/openai.yaml b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/skills/airarc/agents/openai.yaml new file mode 100755 index 0000000..60d5ad0 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airarc/skills/airarc/agents/openai.yaml @@ -0,0 +1,3 @@ +name: airarc +short_description: Architecture-first planning with built-in parallel review and Air Engine handoff +default_prompt: "Use AirArc to build or refresh architecture context, then emit a post-plan parallel review and execution plan for Air Engine." diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/.codex-plugin/plugin.json b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/.codex-plugin/plugin.json new file mode 100755 index 0000000..e3e9d09 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/.codex-plugin/plugin.json @@ -0,0 +1,52 @@ +{ + "name": "airdbg", + "version": "0.1.4", + "description": "Debug-first repair workflow for reproducing defects, requiring GUI evidence for local or remote GUI validation, using local or remote AirNDB for packet-capture evidence, and using local or remote AirSDB for static-analysis evidence when needed, while finding root causes, applying focused fixes, verifying behavior, and maintaining AGENTS.md, ADR, and C4 module context.", + "author": { + "name": "14816", + "email": "noreply@example.com", + "url": "https://airlongdian.fun/plugins/airdbg" + }, + "homepage": "https://airlongdian.fun/plugins/airdbg", + "repository": "https://airlongdian.fun/plugins/airdbg", + "license": "MIT", + "keywords": [ + "airdbg", + "debug", + "bugfix", + "root-cause", + "adr", + "c4", + "airxdb", + "airndb", + "gui-debug", + "screenshot", + "packet-capture", + "pcap", + "airsdb", + "cppcheck", + "static-analysis" + ], + "skills": "./skills/", + "interface": { + "displayName": "AirDbg", + "shortDescription": "Debug-first repair with graphical, packet, and static-analysis evidence", + "longDescription": "AirDbg guides Codex through reproducible debugging: load project context, initialize missing AGENTS.md/ADR/C4 docs, discuss symptoms with the user, require AirXDB or equivalent GUI or screen evidence for every GUI validation instead of trusting process liveness, call local or remote AirNDB for tcpdump or WinDump packet capture, remote tcpdump or dumpcap capture, pcap summaries, BPF filters, DNS, TCP, UDP, TLS, HTTP connectivity and network evidence when needed, call local or remote AirSDB for cppcheck static analysis, staticanalysis.md summaries, and static-analysis documentation when needed, isolate root cause, apply focused fixes, verify behavior, and keep architecture context current.", + "developerName": "14816", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Write" + ], + "websiteURL": "https://airlongdian.fun/plugins/airdbg", + "privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/", + "termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/", + "defaultPrompt": [ + "Use AirDbg to reproduce the current bug, isolate the root cause, and apply the smallest safe fix.", + "Use AirDbg to repair a failing test and keep AGENTS.md, ADR, and C4 context in sync.", + "Use AirDbg to investigate a GUI, network, or C/C++ quality issue and collect the right evidence before changing code." + ], + "brandColor": "#D97706", + "screenshots": [] + } +} diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/commands/airdbg.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/commands/airdbg.md new file mode 100755 index 0000000..572f211 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/commands/airdbg.md @@ -0,0 +1,100 @@ +--- +description: Enter, exit, or inspect AirDbg mode for debugging and repair, with mandatory GUI evidence plus AirNDB and AirSDB evidence handoff +argument-hint: [enter|exit|status] +allowed-tools: [Read, Glob, Grep, Bash, Write, Edit] +--- + +# /airdbg + +控制当前工作区的 AirDbg 调试修复模式。 + +用户传入参数:`$ARGUMENTS` + +- `enter` 或空参数:进入 AirDbg 模式并初始化调试上下文。 +- `status`:检查 AirDbg 状态和关键文件是否存在。 +- `exit`:退出 AirDbg 模式。 + +## 执行步骤 + +1. 解析 `$ARGUMENTS`,默认动作为 `enter`。 +2. 在当前项目根目录运行: + +```bash +python "$HOME/plugins/airdbg/scripts/airdbg_mode.py" --mode --project . +``` + +如果 `python` 不存在,尝试 `py` 或 `python3`。 + +3. `enter` 后开始调试对话: + - 确认错误症状、期望行为、实际行为。 + - 确认复现步骤、环境、最近变更和修复限制。 + - 读取或初始化 `AGENTS.md`、ADR 和 C4 module。 + - 复现、定位根因、最小修复、验证。 + +4. 调试中遇到图形相关需求时,必须调用 `airxdb` 或补充等效 GUI/屏幕证据: + - 图形对比、截图取证、视觉回归、布局错位、弹窗、焦点、Canvas、浏览器/桌面 GUI:用 AirXDB 获取截图或报告。 + - 如果目标 GUI 在远程设备、测试机、服务器、VM 或 SSH 主机上,使用 AirXDB 远程设备路径: + +```bash +python "$HOME/plugins/airxdb/scripts/airxdb_remote_device.py" --project . --action setup +python "$HOME/plugins/airxdb/scripts/airxdb_remote_device.py" --project . --action screenshot +``` + + - 远程 helper 缺少 `AIRXDB_REMOTE_SSH_TARGET` 时,先让用户提供 SSH 目标;有目标后它会自动探测远端截图工具,缺失时自动尝试配置。 + - 需要确认用户界面操作是否可用:用 AirXDB 执行最小 GUI 操作验证。 + - 本地或远程 GUI 测试/验证不得只以进程存在、窗口拉起、命令退出成功或日志无异常视为通过。 + - 如果是嵌入式屏幕、显示链路等截图无诊断价值的场景,可不强制截图,但必须补充等效的 GUI/屏幕状态证据和操作验证,并在 debug log 记录原因。 + - AirDbg 继续负责根因分析、修复和最终验证,并把 AirXDB 证据写入 debug log。 + +5. 调试中遇到抓包或网络层证据需求时,可调用 `airndb`: + - DNS、TCP、UDP、TLS、HTTP 连接、端口、代理、防火墙、丢包、重传、RST、延迟:用 AirNDB 设计 BPF 并抓取/读取 pcap。 + - 需要判断请求是否发出、响应是否回来或失败发生在哪个网络阶段:用 AirNDB 生成 pcap、summary 和 JSON 报告。 + - 如果目标流量在远程设备、测试机、服务器、VM、容器宿主机或 SSH 主机上,使用 AirNDB 远程设备路径: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_remote_device.py" --project . --action setup +python "$HOME/plugins/airndb/scripts/airndb_remote_device.py" --project . --action interfaces +python "$HOME/plugins/airndb/scripts/airndb_remote_device.py" --project . --action capture --iface --filter "" --count 200 --timeout 30 +``` + + - 远程 helper 缺少 `AIRNDB_REMOTE_SSH_TARGET` 时,先让用户提供 SSH 目标;有目标后它会自动探测远端 `tcpdump` / `dumpcap`,缺失时自动尝试配置。 + - AirDbg 继续负责代码层根因分析、修复和最终验证,并把 AirNDB 证据写入 debug log。 + +6. 调试中遇到 C/C++ 静态分析或 cppcheck 证据需求时,可调用 `airsdb`: +- 未初始化变量、空指针、越界、资源释放、危险转换、CWE、代码质量或安全性初筛:用 AirSDB 运行 cppcheck 并维护 `AirPlan/docs/staticanalysis.md`。 +- 当检验、测试或调试判断需要静态分析能力时,用 AirSDB 获取 `AirPlan/docs/staticanalysis.md`、XML/JSON 报告等静态分析信息和文档辅助调试。 + - 如果目标代码在远程设备、测试机、服务器、VM、容器宿主机或 SSH 主机上,使用 AirSDB 远程设备路径: + +```bash +python "$HOME/plugins/airsdb/scripts/airsdb_remote_device.py" --project . --action setup +python "$HOME/plugins/airsdb/scripts/airsdb_remote_device.py" --project . --action scan +``` + + - AirDbg 继续负责代码层根因分析、修复和最终验证,并把 AirSDB 证据写入 debug log。 + +7. 修复过程中必须维护: + - `AGENTS.md` + - `docs/architecture/adr/` + - `docs/architecture/c4/module.md` + - `docs/debug/debug-log.md` + +## 输出文案 + +进入模式: + +```text +AirDbg 模式已开启:已初始化或检查 AGENTS.md、ADR、C4 module 和 debug log。请描述错误症状、复现步骤、期望行为和实际行为;如果涉及本机或远程 GUI 测试/验证,必须使用 AirXDB 或等效 GUI/屏幕证据做复验,不能只看进程是否存活;如果涉及抓包或网络层分析,可调用 AirNDB 获取本机或远程 pcap/summary 证据;如果涉及 C/C++ 静态分析或检验需要静态分析能力,可调用 AirSDB 获取本机或远程 cppcheck/staticanalysis 证据。 +``` + +退出模式: + +```text +AirDbg 模式已退出:已返回标准 Codex 流程。 +``` + +状态检查: + +```text +AirDbg 状态: +关键文件:逐项列出 ok/missing。 +``` diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/scripts/airdbg_mode.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/scripts/airdbg_mode.py new file mode 100755 index 0000000..63612df --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/scripts/airdbg_mode.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Bootstrap AirDbg debugging context files.""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, Tuple + +MARKER_BEGIN = "" +MARKER_END = "" + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def airdbg_agents_block() -> str: + return f"""{MARKER_BEGIN} +## AirDbg Debug Workflow + +1. Use AirDbg for `/airdbg` debugging and repair sessions. +2. Before fixing, load project context: + - `AirPlan/AGENTS.md` + - `AirPlan/docs/architecture/adr/` + - `AirPlan/docs/architecture/c4/module.md` + - `AirPlan/docs/debug/debug-log.md` +3. Reproduce the issue before changing code whenever feasible. +4. Identify root cause, then apply the smallest verifiable fix. +5. Record reproduction, root cause, fix, validation, and residual risk in `AirPlan/docs/debug/debug-log.md`. +6. When debugging needs graphical comparison, screenshots, GUI operation, browser/desktop UI, canvas, layout, focus, popup, or visual evidence, call AirXDB for screenshots, GUI exploration, or operation validation. +7. For every local or remote GUI test or validation, do not treat process liveness, window launch, command success, or clean logs as a pass; require image/GUI evidence plus at least one GUI operation or state check. If screenshots are not diagnostically useful, such as many embedded-screen or display-pipeline scenarios, record equivalent screen evidence instead. +8. If the GUI target is a remote device, test machine, VM, server, or SSH host, call the AirXDB remote device helper before local Computer MCP; it should probe SSH, auto-configure missing remote screenshot tools when possible, and save evidence under `AirPlan/docs/debug/airxdb-artifacts/`. +9. When debugging needs packet capture, pcap reading, BPF filters, DNS/TCP/UDP/TLS/HTTP connectivity, ports, proxy, firewall, packet loss, retransmits, resets, or latency evidence, call AirNDB. +10. If the network target is a remote device, test machine, VM, container host, server, or SSH host, call the AirNDB remote device helper before local capture; it should probe SSH, auto-configure missing remote tcpdump/dumpcap when possible, and save evidence under `AirPlan/docs/network/airndb-captures/`. +11. When debugging needs C/C++ static analysis, cppcheck, code quality, security-relevant findings, CWE, null pointer, bounds, resource, conversion, uninitialized-variable evidence, or static-analysis capability to support validation, call AirSDB and read `AirPlan/docs/staticanalysis.md` plus XML/JSON artifacts when helpful. +12. If the static-analysis target is a remote device, test machine, VM, container host, server, or SSH host, call the AirSDB remote device helper before local cppcheck; it should probe SSH, auto-configure missing remote cppcheck when possible, and maintain `staticanalysis.md`. +13. Record AirXDB, AirNDB, and AirSDB commands, remote target when applicable, screenshot/pcap/staticanalysis/report paths, observations, and how they relate to root cause in `AirPlan/docs/debug/debug-log.md`. +14. Update ADR records when a fix changes long-term behavior, contracts, dependencies, data ownership, error handling, GUI automation boundaries, remote-device boundaries, network boundaries, static-analysis boundaries, or architecture decisions. +15. Update C4 module docs when module boundaries, dependencies, public interfaces, data ownership, GUI automation, local/remote screenshot evidence, local/remote packet capture, static-analysis evidence, network observability, or visual validation boundaries change. +16. Keep ADRs concise because they are AI context records. +{MARKER_END} +""" + + +def c4_module_template() -> str: + return """# C4 Module + +## System Context +- TODO: Describe the system, users, and important external systems. + +## Containers +- TODO: Describe deployable/runtime units. + +## Modules + +| Module | Responsibility | Public Interfaces | Dependencies | Data Ownership | Debug-Relevant Notes | +| --- | --- | --- | --- | --- | --- | +| TODO | TODO | TODO | TODO | TODO | TODO | + +## Error and Observability Flow +- TODO: Describe where errors are raised, logged, retried, surfaced, or recovered. + +## GUI / Visual Debug Boundaries +- AirXDB screenshot evidence, GUI operation checks, graphical comparison, or visual validation used in debugging: TODO +- AirXDB remote device evidence, SSH target, remote screenshot tool, or remote display constraints used in debugging: TODO +- Browser bridge, desktop control, canvas, focus, popup, layout, or multi-display constraints relevant to defects: TODO + +## Network / Packet Debug Boundaries +- AirNDB pcap evidence, tcpdump/WinDump commands, BPF filters, packet summaries, or network validation used in debugging: TODO +- AirNDB remote device evidence, SSH target, remote tcpdump/dumpcap tool, or remote capture permissions used in debugging: TODO +- DNS, TCP, UDP, TLS, HTTP, proxy, firewall, port, NAT, WSL/container/VM/host network constraints relevant to defects: TODO + +## Static Analysis Boundaries +- AirSDB cppcheck evidence, staticanalysis.md entries, XML/JSON reports, suppressions, or quality gates used in debugging: TODO +- AirSDB remote device evidence, SSH target, remote cppcheck tool, or remote static-analysis permissions used in debugging: TODO + +## Change Log +- TODO: Record module-boundary changes caused by fixes. +""" + + +def adr_template() -> str: + return """# ADR-0001: AirDbg Debug Context Governance + +- Status: Accepted +- Date: TODO + +## Context +Debugging sessions need durable AI-readable context so future fixes can understand prior decisions. + +## Decision +Use AirDbg to maintain `AirPlan/AGENTS.md`, `AirPlan/docs/architecture/c4/module.md`, `AirPlan/docs/architecture/adr/`, and `AirPlan/docs/debug/debug-log.md` during repair work. +When debugging requires graphical comparison, screenshots, GUI operations, or visual evidence, use AirXDB for evidence gathering and operation validation, then return to AirDbg for root-cause analysis and focused repair. If the GUI target is remote, use AirXDB's remote device helper before local Computer MCP so SSH and remote screenshot tooling are checked or auto-configured. +Do not treat process liveness, window launch, command success, or clean logs as sufficient proof for a GUI pass. Require image/GUI evidence and a GUI operation or state check for every local or remote GUI validation. If screenshots are not diagnostically useful, such as many embedded-screen or display-pipeline scenarios, record equivalent screen evidence and why screenshots were skipped. +When debugging requires packet capture, pcap analysis, DNS/TCP/UDP/TLS/HTTP connectivity evidence, ports, proxy, firewall, retransmits, resets, or latency analysis, use AirNDB for bounded network evidence, then return to AirDbg for root-cause analysis and focused repair. If the network target is remote, use AirNDB's remote device helper before local capture so SSH and remote tcpdump/dumpcap are checked or auto-configured. +When debugging requires C/C++ static analysis, cppcheck, code quality, security-relevant findings, CWE evidence, or static-analysis capability to support validation, use AirSDB for local or remote static-analysis evidence and `staticanalysis.md` context, then return to AirDbg for root-cause analysis and focused repair. + +## Consequences +- Fixes carry reproducible context across sessions. +- Architecture-impacting repairs must update ADR and C4 module docs. +- ADRs stay concise and focused on decisions. +- GUI evidence and graphical operation results are linked to root cause and validation records. +- Embedded-screen or display-pipeline cases that skip screenshots still record equivalent screen evidence and the reason screenshots were not useful. +- Network packet evidence and pcap summaries are linked to root cause and validation records. +- Static-analysis evidence and staticanalysis.md summaries are linked to root cause and validation records. +- Remote-device SSH targets, auto-configuration outcomes, and permission limits are recorded when they affect debugging. + +## Alternatives +- Chat-only debugging notes: rejected because context is easy to lose. +""" + + +def debug_log_template() -> str: + return """# Debug Log + +Append entries for AirDbg sessions. + +## Entry Template + +### YYYY-MM-DD: short problem title + +- Symptom: TODO +- Expected: TODO +- Actual: TODO +- Reproduction: TODO +- Root cause: TODO +- AirXDB local/remote evidence: TODO +- AirNDB local/remote evidence: TODO +- AirSDB local/remote static-analysis evidence: TODO +- Fix: TODO +- Validation: TODO +- ADR/C4 updates: TODO +- Residual risk: TODO +""" + + +def write_if_missing(path: Path, content: str) -> bool: + if path.exists(): + return False + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8", newline="\n") + return True + + +def upsert_agents_md(path: Path) -> str: + block = airdbg_agents_block().rstrip() + "\n" + + if path.exists(): + original = path.read_text(encoding="utf-8") + existed = True + else: + original = "# AGENTS.md\n\n" + existed = False + + begin = original.find(MARKER_BEGIN) + end = original.find(MARKER_END) + + if begin >= 0 and end > begin: + end += len(MARKER_END) + updated = original[:begin].rstrip() + "\n\n" + block + original[end:].lstrip() + status = "updated" + else: + updated = original.rstrip() + "\n\n" + block + status = "updated" if existed else "created" + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(updated, encoding="utf-8", newline="\n") + return status + + +def artifact_map(project_root: Path) -> Dict[str, Path]: + return { + "AGENTS.md": project_root / "AirPlan" / "AGENTS.md", + "c4_module": project_root / "AirPlan" / "docs" / "architecture" / "c4" / "module.md", + "adr_dir": project_root / "AirPlan" / "docs" / "architecture" / "adr", + "adr_0001": project_root / "AirPlan" / "docs" / "architecture" / "adr" / "ADR-0001-airdbg-debug-context-governance.md", + "debug_log": project_root / "AirPlan" / "docs" / "debug" / "debug-log.md", + "state": project_root / "AirPlan" / "state" / "airdbg" / "state.json", + } + + +def write_state(path: Path, enabled: bool, project_root: Path) -> None: + artifacts = artifact_map(project_root) + health = { + name: artifacts[name].exists() + for name in [ + "AGENTS.md", + "c4_module", + "adr_dir", + "adr_0001", + "debug_log", + ] + } + + payload = { + "enabled": enabled, + "updatedAt": now_iso(), + "projectRoot": str(project_root), + "artifactHealth": health, + } + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def enter_mode(project_root: Path) -> Tuple[str, Dict[str, str]]: + artifacts = artifact_map(project_root) + results: Dict[str, str] = {} + + results["AGENTS.md"] = upsert_agents_md(artifacts["AGENTS.md"]) + results["c4_module"] = "created" if write_if_missing(artifacts["c4_module"], c4_module_template()) else "exists" + artifacts["adr_dir"].mkdir(parents=True, exist_ok=True) + results["adr_dir"] = "exists" + results["adr_0001"] = "created" if write_if_missing(artifacts["adr_0001"], adr_template()) else "exists" + results["debug_log"] = "created" if write_if_missing(artifacts["debug_log"], debug_log_template()) else "exists" + + write_state(artifacts["state"], True, project_root) + return "enabled", results + + +def exit_mode(project_root: Path) -> Tuple[str, Dict[str, str]]: + artifacts = artifact_map(project_root) + write_state(artifacts["state"], False, project_root) + return "disabled", {} + + +def status_mode(project_root: Path) -> Tuple[str, Dict[str, str]]: + artifacts = artifact_map(project_root) + state_file = artifacts["state"] + + enabled = False + if state_file.exists(): + try: + payload = json.loads(state_file.read_text(encoding="utf-8")) + enabled = bool(payload.get("enabled")) + except json.JSONDecodeError: + enabled = False + + results = { + name: ("ok" if path.exists() else "missing") + for name, path in artifacts.items() + if name != "state" + } + return ("enabled" if enabled else "disabled"), results + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Manage AirDbg project artifacts.") + parser.add_argument("--mode", choices=["enter", "exit", "status"], default="enter") + parser.add_argument("--project", default=".") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + project_root = Path(args.project).expanduser().resolve() + + if args.mode == "enter": + mode_state, result = enter_mode(project_root) + elif args.mode == "exit": + mode_state, result = exit_mode(project_root) + else: + mode_state, result = status_mode(project_root) + + print(f"airdbg_mode={mode_state}") + print(f"project_root={project_root}") + for key, value in result.items(): + print(f"{key}={value}") + + +if __name__ == "__main__": + main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/scripts/install_airdbg_plugin.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/scripts/install_airdbg_plugin.py new file mode 100755 index 0000000..c0e5d41 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/scripts/install_airdbg_plugin.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Install the AirDbg plugin into the current user's home-local plugin directory.""" + +from __future__ import annotations + +import argparse +import json +import shutil +from pathlib import Path +from typing import Any, Dict + +PLUGIN_NAME = "airdbg" + + +def copy_plugin(source: Path, target: Path) -> None: + if source.resolve() == target.resolve(): + return + if target.exists(): + shutil.rmtree(target) + ignore = shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store", ".git") + shutil.copytree(source, target, ignore=ignore) + + +def marketplace_payload() -> Dict[str, Any]: + return { + "name": "local-airdbg", + "interface": {"displayName": "Local AirDbg Plugins"}, + "plugins": [], + } + + +def update_marketplace(path: Path) -> None: + if path.exists(): + payload = json.loads(path.read_text(encoding="utf-8")) + else: + payload = marketplace_payload() + + payload.setdefault("name", "local-airdbg") + payload.setdefault("interface", {}).setdefault("displayName", "Local AirDbg Plugins") + plugins = payload.setdefault("plugins", []) + + entry = { + "name": PLUGIN_NAME, + "source": { + "source": "local", + "path": f"./plugins/{PLUGIN_NAME}", + }, + "policy": { + "installation": "INSTALLED_BY_DEFAULT", + "authentication": "ON_INSTALL", + }, + "category": "Productivity", + } + + for index, existing in enumerate(plugins): + if existing.get("name") == PLUGIN_NAME: + plugins[index] = entry + break + else: + plugins.append(entry) + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Install AirDbg as a home-local Codex plugin.") + parser.add_argument("--source", default=str(Path(__file__).resolve().parents[1])) + parser.add_argument("--home", default=str(Path.home())) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + source = Path(args.source).expanduser().resolve() + home = Path(args.home).expanduser().resolve() + target = home / "plugins" / PLUGIN_NAME + marketplace = home / ".agents" / "plugins" / "marketplace.json" + + copy_plugin(source, target) + update_marketplace(marketplace) + + print(f"installed_plugin={target}") + print(f"marketplace={marketplace}") + + +if __name__ == "__main__": + main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/skills/airdbg/SKILL.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/skills/airdbg/SKILL.md new file mode 100755 index 0000000..5d9dc50 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/skills/airdbg/SKILL.md @@ -0,0 +1,245 @@ +--- +name: airdbg +description: Debug-first repair workflow. Use when the user invokes /airdbg or explicitly asks for AirDbg mode to debug, reproduce, diagnose, or fix software errors, including GUI, visual, screenshot, browser UI, desktop UI, remote GUI/device debugging, canvas, layout, focus, popup, graphical operation, network, packet capture, remote packet capture, pcap, DNS, TCP, UDP, TLS, HTTP connectivity, proxy, firewall, port, retransmit, reset, latency, cppcheck, static analysis, code quality, or security-relevant C/C++ defects. Load or initialize AirPlan/AGENTS.md, AirPlan/docs/architecture/adr/ decision records, and AirPlan/docs/architecture/c4/module.md; discuss symptoms and constraints with the user; reproduce the issue; require AirXDB local or remote device helpers or equivalent GUI/screen evidence for every local or remote GUI validation instead of treating process liveness as success; call AirNDB local or remote device helpers when tcpdump/WinDump packet capture, pcap analysis, BPF filters, or network-layer evidence is needed; call AirSDB local or remote device helpers when static-analysis capability, cppcheck evidence, or AirPlan/docs/staticanalysis.md documentation is needed; identify root cause; apply a focused fix; verify with tests or equivalent checks; and update AirPlan/AGENTS.md, ADR, and C4 module docs when project behavior, module boundaries, dependencies, GUI automation boundaries, network boundaries, static-analysis boundaries, remote-device boundaries, or architecture decisions change. +--- + +# AirDbg + +## 核心约束 + +- 全程使用中文与用户交流,代码、命令、日志、路径、异常名保持原文。 +- `/airdbg` 是主要触发入口。用户进入 AirDbg 后,围绕调试和修复错误推进。 +- 先加载项目上下文,再修复:`AirPlan/AGENTS.md`、`AirPlan/docs/architecture/adr/`、`AirPlan/docs/architecture/c4/module.md`。 +- 如果这些文件不存在,先分析当前项目并初始化它们;C4 module 要记录真实模块边界,不只放空模板。 +- 与用户交流症状、复现步骤、期望行为、实际行为、影响范围和修复约束。 +- 默认做最小可验证修复,避免顺手重构。 +- 每个修复都要验证。优先自动化测试,其次是可重复命令或明确的手工验证步骤。 +- 只要验证或复现涉及本地或远程 GUI,就不能只以进程存在、窗口拉起、命令退出成功、端口监听或日志无异常判定通过;必须辅以图像/GUI 检验和测试。 +- 调试中遇到图形对比、截图取证、GUI 操作、浏览器/桌面界面、Canvas、弹窗、焦点、布局、视觉回归或其他图形功能时,必须调用 `airxdb` 获取截图、探索界面、执行操作验证或收集视觉证据;如果是嵌入式屏幕、显示链路等截图无诊断价值的场景,可不强制截图,但必须补充等效的 GUI/屏幕状态证据和操作验证,并记录原因。 +- 如果 GUI 问题发生在远程设备、测试机、VM、服务器或 SSH 主机上,调用 AirXDB remote device helper,而不是默认使用本机 Computer MCP。 +- 调试中遇到抓包分析、pcap、tcpdump/WinDump、BPF、DNS、TCP、UDP、TLS、HTTP 连接、端口、代理、防火墙、丢包、重传、RST 或延迟问题时,可以调用 `airndb` 获取网络层调试证据。 +- 如果网络问题发生在远程设备、测试机、VM、容器宿主机、服务器或 SSH 主机上,调用 AirNDB remote device helper,而不是默认使用本机抓包工具。 +- 调试中遇到需要静态分析能力的检验、测试或定位场景,以及 C/C++ 静态分析、cppcheck、代码质量、安全性初筛、未初始化变量、空指针、越界、资源释放、危险转换或 CWE 线索需求时,可以调用 `airsdb` 获取 `AirPlan/docs/staticanalysis.md`、XML/JSON 报告等静态分析证据和辅助调试文档。 +- 如果静态分析目标在远程设备、测试机、VM、容器宿主机、服务器或 SSH 主机上,调用 AirSDB remote device helper,而不是默认使用本机 cppcheck。 +- 一定要根据项目变化维护 `AirPlan/AGENTS.md`、ADR 和 C4 module。 +- ADR 是给 AI 作为上下文的决策记录,短、准、可检索即可,不写冗长修饰。 + +## 启动与初始化 + +进入 `/airdbg` 时运行: + +```bash +python ../../scripts/airdbg_mode.py --mode enter --project . +``` + +如果当前环境没有 `python`,尝试 `py` 或 `python3`。脚本不可用时,手动确保以下结构存在: + +- `AirPlan/AGENTS.md` +- `AirPlan/docs/architecture/adr/` +- `AirPlan/docs/architecture/c4/module.md` +- `AirPlan/docs/debug/debug-log.md` +- `AirPlan/state/airdbg/state.json` + +初始化后读取已有内容作为上下文。不要覆盖用户已有正文;只补齐缺失结构或更新 AirDbg 标记块。 + +## 图形调试与 AirXDB 协作 + +AirDbg 负责根因分析、代码层修复和验证收尾;AirXDB 负责图形界面的取证和操作层复现。遇到以下情况时,必须调用 AirXDB 或补充等效 GUI/屏幕证据: + +- 需要截图或图形对比来理解错误现场、视觉回归、布局错位、颜色/尺寸/遮挡差异。 +- 需要操作浏览器 UI、桌面 UI、Electron/Qt/WPF 等应用、Canvas、菜单、弹窗、托盘、任务栏或多显示器界面。 +- 需要 `/airxdb screenshot` 保存错误现场,再把截图交给 AirDbg 做代码层诊断。 +- 目标 GUI 在远程设备、测试机、VM、服务器或 SSH 主机上,需要 `/airxdb remote-screenshot` 或 `airxdb_remote_device.py` 保存远程错误现场。 +- 需要用 AirXDB 执行最小 GUI 操作,确认按钮、表单、导航、窗口切换、焦点或图形流程是否真的失败。 +- 需要把 GUI 证据沉淀到 `AirPlan/docs/debug/gui-debug-log.md`、`AirPlan/docs/debug/airxdb-artifacts/` 或 AirDbg 的 `AirPlan/docs/debug/debug-log.md`。 + +协作规则: + +- 先用 AirXDB 收集最小必要证据,再回到 AirDbg 分析代码根因;不要把视觉症状直接当作根因。 +- 任何本地或远程 GUI 测试/验证都不能仅以进程存活、窗口创建成功、命令返回成功或日志无异常视为通过;默认至少保留 1 份截图/图像证据,并完成 1 次关键 GUI 操作或状态检查。 +- 如果是嵌入式屏幕、显示控制器、外接面板链路等截图无诊断价值的场景,可改用外部采集视频、framebuffer dump、串口/日志配合按键或触控操作记录、状态灯/OSD 观察记录等等效证据,但必须在 `debug-log.md` 记录为什么不截图以及替代证据是什么。 +- 截图模式可在没有 Midscene 语义模型配置时使用;语义视觉动作按 AirXDB 规则先检查模型配置。 +- 本机 GUI 证据使用 `/airxdb screenshot` 或 `airxdb_computer_mcp_smoke.py`;远程 GUI 证据使用 `airxdb_remote_device.py --action setup|screenshot`,由它探测 SSH、远端截图工具并在缺失时自动尝试配置。 +- 远程 helper 缺少 `AIRXDB_REMOTE_SSH_TARGET` 时,先让用户提供 SSH 目标;需要交互式 sudo、管理员确认或无支持包管理器时停止并说明。 +- AirDbg 的 `debug-log.md` 必须记录 AirXDB 命令、截图/报告路径、关键观察、与根因的关系、复验结果和剩余风险。 +- 如果 GUI 自动化、截图取证、视觉验收、浏览器桥接或桌面控制成为长期调试/测试边界,更新 C4 module 并创建或修订 ADR。 +- 如果发现稳定可复用的 GUI 调试命令、截图方式、远程设备配置或视觉验收步骤,更新 `AGENTS.md`。 +- 截图可能包含账号、密钥、客户数据或聊天内容时,先提醒用户脱敏,再外部分享或长期保留。 + +## 抓包调试与 AirNDB 协作 + +AirDbg 负责把网络证据和代码行为联系起来,定位根因并修复;AirNDB 负责 tcpdump/WinDump 抓包、pcap 摘要、BPF 过滤器和网络层证据。遇到以下情况时,调用 AirNDB: + +- 需要抓包判断请求是否发出、响应是否回来、连接是否被 RST/ICMP/防火墙/代理中断。 +- 需要分析 DNS 查询、TCP 三次握手、TLS 握手、HTTP 连接、UDP 流量、端口可达性、重传、丢包或延迟。 +- 需要读取已有 `.pcap` 或生成新的短时有界 pcap 给调试使用。 +- 需要确定问题在应用代码、系统网络栈、容器/WSL/VM/宿主机边界、代理、防火墙还是远端服务。 +- 目标流量发生在远程设备、测试机、VM、容器宿主机、服务器或 SSH 主机上,需要 `/airndb remote-interfaces`、`/airndb remote-capture` 或 `airndb_remote_device.py` 获取远程网络证据。 + +协作规则: + +- 先让 AirNDB 明确授权范围、接口、BPF 过滤器、抓包窗口和 pcap 输出路径;不要进行无界抓包。 +- 本机网络证据使用 `airndb_capture.py`;远程网络证据使用 `airndb_remote_device.py --action setup|interfaces|command|capture`,由它探测 SSH、远端 `tcpdump` / `dumpcap` 并在缺失时自动尝试配置。 +- 远程 helper 缺少 `AIRNDB_REMOTE_SSH_TARGET` 时,先让用户提供 SSH 目标;需要交互式 sudo、管理员确认或无支持包管理器时停止并说明。 +- AirDbg 的 `debug-log.md` 必须记录 AirNDB 命令、pcap/summary/report 路径、关键包或时间线观察、与根因的关系、复验结果和剩余风险。 +- 如果抓包发现新的长期网络边界、端口、协议、DNS、代理、TLS、容器/WSL/VM/宿主机约束或观测方式,更新 C4 module 并创建或修订 ADR。 +- 如果发现稳定可复用的抓包命令、接口选择规则、BPF、远程设备配置或 pcap 读取方式,更新 `AGENTS.md`。 +- pcap 可能包含 token、cookie、payload、内网地址、主机名或个人信息;对外分享前必须提醒用户脱敏。 + +## 静态分析与 AirSDB 协作 + +AirDbg 负责把静态分析线索和代码根因联系起来;AirSDB 负责 cppcheck 检测/安装、本机或远程扫描、XML/JSON 产物和 `AirPlan/docs/staticanalysis.md` 简短报告。遇到以下情况时,可以调用 AirSDB: + +- 需要用 cppcheck 辅助定位 C/C++ bug、内存/资源/越界/空指针/未初始化变量/危险转换/CWE 线索。 +- 需要在修复前后比较静态分析结果。 +- 需要给 AirDbg 的根因分析提供短报告而不是长 XML。 +- 检验、测试或调试判断需要静态分析能力、质量门信息或可引用文档时,需要读取 `AirPlan/docs/staticanalysis.md` 或 AirSDB XML/JSON 报告辅助分析。 +- 目标代码在远程设备、测试机、VM、容器宿主机、服务器或 SSH 主机上,需要 `/airsdb remote-scan` 或 `airsdb_remote_device.py` 获取远端静态分析证据。 + +协作规则: + +- 本机静态分析使用 `airsdb_cppcheck.py --action scan`;远程静态分析使用 `airsdb_remote_device.py --action setup|scan`,由它探测 SSH、远端 cppcheck 并在缺失时自动尝试配置。 +- AirDbg 的 `AirPlan/docs/debug/debug-log.md` 必须记录 AirSDB 命令、`AirPlan/docs/staticanalysis.md`、XML/JSON 报告路径、关键 findings、与根因的关系、复验结果和剩余风险。 +- 如果静态分析发现新的长期质量门槛、suppressions、远程设备配置或 cppcheck 命令,更新 `AGENTS.md`。 +- 如果静态分析成为长期测试/调试边界,更新 C4 module 并创建或修订 ADR。 + +## 调试流程 + +1. 确认问题边界: + - 用户看到的错误是什么。 + - 期望行为和实际行为是什么。 + - 复现步骤、输入数据、环境、版本、最近变更是什么。 + - 有哪些不能破坏的兼容性或性能要求。 +2. 加载上下文: + - 读取 `AGENTS.md`。 + - 读取 ADR 列表和相关 ADR。 + - 读取 `docs/architecture/c4/module.md`。 + - 查看测试、入口、依赖、配置和最近相关文件。 +3. 复现问题: + - 优先运行已有失败测试或用户给出的命令。 + - 没有复现命令时,先构造最小复现或定位性测试。 + - 如果复现依赖 GUI、截图或图形操作,必须调用 AirXDB 获取截图、执行最小界面操作或保存 GUI 报告;远程目标走 AirXDB remote device helper;嵌入式截图无效时改用等效 GUI/屏幕证据并记录原因。 + - 如果复现依赖网络路径或抓包证据,调用 AirNDB 获取短时 pcap、摘要或网络层时间线;远程目标走 AirNDB remote device helper。 +- 如果复现或定位需要 C/C++ 静态分析,或当前检验需要静态分析能力辅助判断,调用 AirSDB 运行本机或远程 cppcheck,并读取 `AirPlan/docs/staticanalysis.md`。 + - 记录复现命令和关键输出到 `docs/debug/debug-log.md`。 +4. 定位根因: + - 从错误栈、日志、测试断言、数据流和模块边界推断。 + - 对 GUI 问题,结合 AirXDB 本机或远程截图/报告判断视觉症状、交互失败和代码根因之间的关系。 + - 对网络问题,结合 AirNDB 本机或远程 pcap/摘要判断请求是否出站、响应是否入站、失败发生在 DNS/TCP/TLS/应用层哪一段。 + - 对静态分析问题,结合 AirSDB findings 判断哪些是当前 bug 线索、哪些是既有质量债或误报。 + - 必要时加临时日志或小范围探针,完成后清理。 + - 区分根因、诱因和表面症状。 +5. 修复: + - 优先选择影响面小、能解释根因的修复。 + - 不做无关格式化、批量重构或架构迁移。 + - 如果修复会改变模块边界、依赖、接口、数据所有权或关键行为,先更新 C4/ADR。 +6. 验证: + - 运行失败用例、相关单元测试、集成测试、lint/typecheck。 + - 如果修复涉及 GUI 或视觉行为,必须调用 AirXDB 截图、图形对比或操作验证关键路径;远程目标用远程 helper 复验;嵌入式截图无效时改用等效 GUI/屏幕证据并记录原因。 + - 如果修复涉及网络行为,调用 AirNDB 复验关键网络路径或读取 pcap 摘要;远程目标用远程 helper 复验。 +- 如果修复涉及 C/C++ 风险、静态分析 findings,或验证需要静态分析能力辅助判断,调用 AirSDB 复跑 cppcheck 并更新 `AirPlan/docs/staticanalysis.md`。 + - 如果不能运行,说明原因,并给出可复验的替代验证。 + - 记录验证证据到 debug log。 +7. 收尾: + - 更新 `AGENTS.md` 中与调试、测试、运行方式相关的项目上下文。 + - 更新或新增 ADR。 + - 更新 C4 module。 + - 向用户汇报根因、改动、验证结果、剩余风险。 + +## AGENTS.md 维护 + +在以下情况更新 `AGENTS.md`: + +- 发现新的运行、测试、构建、调试命令。 +- 发现新的 AirXDB 截图、GUI 操作验证、图形对比、远程设备配置或视觉验收命令。 +- 发现新的 AirNDB 抓包命令、BPF 过滤器、接口选择规则、远程设备配置、pcap 读取方式或网络复验步骤。 +- 发现新的 AirSDB cppcheck 命令、suppressions、质量门槛、远程设备配置或静态分析复验步骤。 +- 发现影响后续 AI 会话的重要项目约束。 +- 修复改变了模块职责、关键流程或错误处理策略。 +- 发现常见坑、环境要求或验证方式。 + +保持内容可执行、可复用,不写调试过程流水账。 + +## ADR 维护 + +目录:`docs/architecture/adr/`。 + +需要 ADR 的情况: + +- 修复选择了一个会影响长期架构或行为兼容性的方案。 +- 改变错误处理、重试、事务、缓存、一致性、安全边界。 +- 改变模块依赖、数据所有权、接口契约。 +- 将 GUI 自动化、截图取证、远程设备 GUI 取证、视觉验收或图形调试流程纳入长期测试/调试边界。 +- 将抓包、远程设备抓包、pcap 分析、网络观测、端口、协议、DNS、代理、TLS 或网络拓扑纳入长期调试/测试边界。 +- 将 cppcheck、staticanalysis.md、静态分析质量门槛或远程静态分析纳入长期调试/测试边界。 +- 拒绝了明显可选方案,需要给后续 AI 留下原因。 + +ADR 模板: + +```markdown +# ADR-000X: short-title + +- Status: Accepted +- Date: YYYY-MM-DD + +## Context +简述错误、约束和为什么需要决策。 + +## Decision +简述采用的修复或架构选择。 + +## Consequences +- 正面影响 +- 代价或风险 + +## Alternatives +- 方案 A:放弃原因 +``` + +## C4 Module 维护 + +文件:`docs/architecture/c4/module.md`。 + +必须记录: + +- 模块名。 +- 职责。 +- 对外接口。 +- 依赖。 +- 数据所有权。 +- 与本次错误或修复相关的质量属性。 + +新增模块、拆分模块、改变依赖、改变接口、改变数据边界、改变错误处理流时必须更新。 + +引入或改变 GUI 自动化、浏览器桥接、桌面控制、截图取证、远程设备 GUI 取证、视觉验收或图形调试基础设施时,也必须更新。 + +引入或改变 tcpdump/WinDump 抓包、远程设备抓包、pcap 分析、网络观测、端口、协议、DNS、代理、TLS、容器/WSL/VM/宿主机网络边界时,也必须更新。 + +引入或改变 cppcheck、staticanalysis.md、静态分析质量门槛、suppressions 或远程静态分析边界时,也必须更新。 + +## debug-log 维护 + +文件:`docs/debug/debug-log.md`。 + +每次 AirDbg 修复至少追加: + +- 问题摘要。 +- 复现命令或复现步骤。 +- 根因。 +- 修复摘要。 +- 验证命令和结果。 +- AirXDB 本机或远程截图/报告/操作验证证据及其结论(如适用)。 +- AirNDB 本机或远程 pcap/summary/report/抓包分析证据及其结论(如适用)。 +- AirSDB 本机或远程 staticanalysis.md/XML/JSON 静态分析证据及其结论(如适用)。 +- 相关 ADR/C4 更新。 +- 剩余风险。 + +## 输出格式 + +调试完成后用中文简洁汇报: + +- 根因。 +- 修复了什么。 +- 更新了哪些 `AGENTS.md` / ADR / C4 / debug log 上下文。 +- 运行了哪些验证;是否调用 AirXDB/AirNDB/AirSDB,截图、pcap、staticanalysis、报告或操作证据在哪里。 +- 仍然存在的风险或未验证项。 diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/skills/airdbg/agents/openai.yaml b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/skills/airdbg/agents/openai.yaml new file mode 100755 index 0000000..95da337 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdbg/skills/airdbg/agents/openai.yaml @@ -0,0 +1,3 @@ +name: airdbg +short_description: Debug-first repair with local/remote AirXDB, AirNDB, and AirSDB evidence +default_prompt: "使用 AirDbg 调试并修复当前项目错误;GUI 测试或验证必须保留 GUI 复验证据,嵌入式截图无效时改用等效屏幕证据;网络证据调用 AirNDB,需要静态分析能力时调用 AirSDB。" diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/.codex-plugin/plugin.json b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/.codex-plugin/plugin.json new file mode 100755 index 0000000..49acbe3 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/.codex-plugin/plugin.json @@ -0,0 +1,42 @@ +{ + "name": "airdo", + "version": "0.5.0", + "description": "AirDo is the public execution plugin for one scoped task slice. It is designed to run standalone or as an isolated AirEng subagent and to finalize structured results into AirPlan.", + "author": { + "name": "14816", + "email": "noreply@example.com", + "url": "https://airlongdian.fun" + }, + "homepage": "https://airlongdian.fun/plugins/airdo", + "repository": "https://airlongdian.fun/plugins/airdo", + "license": "MIT", + "keywords": [ + "airdo", + "executor", + "subagent", + "todo", + "validation" + ], + "skills": "./skills/", + "interface": { + "displayName": "AirDo", + "shortDescription": "Execute one scoped task and finalize a structured result", + "longDescription": "AirDo is the public execution plugin for one narrow task slice. It can be run directly or launched by AirEng as a fully isolated subagent, auto-routes GUI work into AirXDB, auto-routes blockers into AirDbg, and finalizes one structured result in AirPlan for AirEng to merge.", + "developerName": "14816", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Write" + ], + "websiteURL": "https://airlongdian.fun/plugins/airdo", + "privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/", + "termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/", + "defaultPrompt": [ + "Use AirDo to start one scoped task from AirPlan/todo.md and create a structured result template.", + "Use AirDo to execute a handoff in an isolated subagent context and finalize the AirPlan result.", + "Use AirDo to continue from a repair brief until fixed or a real blocker remains." + ], + "brandColor": "#2563EB", + "screenshots": [] + } +} diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/commands/airdo.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/commands/airdo.md new file mode 100755 index 0000000..ec912e7 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/commands/airdo.md @@ -0,0 +1,38 @@ +--- +description: Start, inspect, hand off, or finish one scoped AirDo task slice with automatic AirXDB/AirDbg routing and AirPlan result finalization +argument-hint: [enter|status|handoff|finish] +allowed-tools: [Read, Glob, Grep, Bash, Write, Edit] +--- + +# /airdo + +Use AirDo for one narrow execution slice. It can run directly in the parent thread, but it is primarily designed to be launched as an isolated AirEng subagent. + +## Steps + +1. Parse `$ARGUMENTS`; default to `status` when empty. +2. Always operate from the current project root and store worker state under `AirPlan/state/airdo/`. +3. Run the matching mode: + +```bash +python "$HOME/plugins/airdo/scripts/airdo_mode.py" --mode --project . --task-id +``` + +4. For `enter` or `handoff`, load: + - `AirPlan/AGENTS.md` + - `AirPlan/docs/architecture/adr/` + - `AirPlan/docs/architecture/c4/module.md` + - `AirPlan/plan.md` + - `AirPlan/todo.md` + - `AirPlan/state/airdo/tasks//brief.md` + +5. When AirDo is invoked as an AirEng child: + - Treat the session as isolated and rebuild context only from the files above. + - Stay inside the task write scope from the brief/handoff. + - Finalize exactly one structured result under `AirPlan/state/airdo/results/.json`. + - Treat `AirPlan/state/airdo/tasks//worker-state.json` `resultPath` as the canonical pointer to the latest result artifact. Do not assume the task-local template `result.json` is the finalized output. + - Do not stop at "准备实施", implementation-plan-only, or generic progress replies when the task is actionable. Continue editing, validating, and finalizing unless a real blocker or user decision is required. + - When the task changes planning or architecture reality, include concrete `documentUpdates` for `AirPlan/plan.md`, `AirPlan/docs/architecture/adr/`, and `AirPlan/docs/architecture/c4/module.md` instead of leaving those docs stale. + +6. On GUI-like work, route acceptance through AirXDB before finishing. +7. On blockers or failed validation, route through AirDbg, continue through active repair briefs when present, and return one structured result for AirEng to merge. diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/scripts/airdo_mode.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/scripts/airdo_mode.py new file mode 100755 index 0000000..0f3d195 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/scripts/airdo_mode.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +def _add_lib_path() -> None: + root = Path(__file__).resolve().parents[3] + 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.worker import enter_worker, finish_worker, handoff_worker, status_worker + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="AirDo public worker runtime") + parser.add_argument("--mode", choices=["enter", "status", "handoff", "finish"], default="status") + parser.add_argument("--project", default=".") + parser.add_argument("--task-id", default="") + parser.add_argument("--result", default="") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + project_root = Path(args.project).expanduser().resolve() + + if args.mode == "status": + status = status_worker(project_root) + print(f"airdo_mode={'enabled' if status['enabled'] else 'disabled'}") + print(f"project_root={project_root}") + print(f"active_task_id={status['activeTaskId']}") + print(f"known_tasks={','.join(status['taskIds'])}") + for key, value in status["artifactHealth"].items(): + print(f"{key}={'ok' if value else 'missing'}") + return + + if not args.task_id: + raise SystemExit("--task-id is required for enter, handoff, and finish modes") + + if args.mode == "enter": + result = enter_worker(project_root, args.task_id) + print("airdo_mode=entered") + print(f"project_root={project_root}") + print(f"task_id={result['taskId']}") + print(f"brief_path={result['briefPath']}") + print(f"handoff_path={result['handoffPath']}") + print(f"result_path={result['resultPath']}") + print(f"worker_state_path={result['workerStatePath']}") + return + + if args.mode == "handoff": + result = handoff_worker(project_root, args.task_id) + print("airdo_mode=handoff-ready") + print(f"project_root={project_root}") + print(f"task_id={result['taskId']}") + print(f"brief_path={result['briefPath']}") + print(f"handoff_path={result['handoffPath']}") + print(f"result_path={result['resultPath']}") + print(f"worker_state_path={result['workerStatePath']}") + return + + result_path = Path(args.result).expanduser().resolve() if args.result else None + finalized = finish_worker(project_root, args.task_id, result_path) + print("airdo_mode=finished") + print(f"project_root={project_root}") + print(f"task_id={finalized['taskId']}") + print(f"task_status={finalized['status']}") + print(f"finalized_result_path={finalized['finalizedResultPath']}") + print(f"worker_state_path={finalized['workerStatePath']}") + + +if __name__ == "__main__": + main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/skills/airdo/SKILL.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/skills/airdo/SKILL.md new file mode 100755 index 0000000..2ba2a92 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/skills/airdo/SKILL.md @@ -0,0 +1,47 @@ +--- +name: airdo +description: Public Air executor for one scoped todo slice. Use it standalone or as an isolated AirEng subagent and finalize the result into AirPlan. +--- + +# AirDo + +## Role + +- Execute one task or one very small implementation slice. +- Keep AirDo execution guarantees for context loading, evidence, and validation. +- Finalize one structured result package in `AirPlan/state/airdo/results/`. +- Act as the standard AirEng child executor when work is delegated into isolated subagents. + +## Inputs + +- `AirPlan/AGENTS.md` +- `AirPlan/docs/architecture/adr/` +- `AirPlan/docs/architecture/c4/module.md` +- `AirPlan/plan.md` +- `AirPlan/todo.md` +- `AirPlan/state/airdo/tasks//brief.md` +- `AirPlan/state/airdo/tasks//subagent-handoff.md` when launched by AirEng + +## Result Rules + +- Finalize one `result.json` per task. +- Treat `AirPlan/state/airdo/tasks//worker-state.json` `resultPath` as the canonical pointer to the latest result artifact. The task-local `result.json` is only the editable template before finalize. +- Include validations, evidence, risks, blockers, and document updates when needed. +- Do not edit global `AirPlan/todo.md`, `AirPlan/AGENTS.md`, ADR, or C4 files directly unless explicitly delegated through `documentUpdates`. +- If the task changes execution planning or architecture reality, include concrete `documentUpdates` so AirEng can keep `todo`, `plan`, ADR, and C4 synchronized during merge. + +## Automatic Routing + +- On `blocked` results, auto-request AirDbg before finalize. +- On GUI or visual work, auto-request AirXDB before finalize. +- If AirEng queued an active repair attempt, continue repairing automatically instead of stopping at the first blocker. +- Do not stop at implementation-prep or progress-only updates when the task is actionable. Continue until finalize unless a real blocker or explicit user decision is required. + +## Commands + +```bash +python ../../scripts/airdo_mode.py --mode enter --project --task-id T-001 +python ../../scripts/airdo_mode.py --mode status --project +python ../../scripts/airdo_mode.py --mode handoff --project --task-id T-001 +python ../../scripts/airdo_mode.py --mode finish --project --task-id T-001 +``` diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/skills/airdo/agents/openai.yaml b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/skills/airdo/agents/openai.yaml new file mode 100755 index 0000000..8c76a64 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airdo/skills/airdo/agents/openai.yaml @@ -0,0 +1,3 @@ +name: airdo +short_description: Public Air executor for one scoped task, validation evidence, and AirPlan result finalization +default_prompt: "Use AirDo to execute one task, gather validation evidence, auto-route GUI or debug work, and finalize a structured result in AirPlan for AirEng to merge. Do not stop at implementation-prep or progress-only updates when the task is actionable; continue until finalize unless a real blocker or explicit user decision is required." diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/.codex-plugin/plugin.json b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/.codex-plugin/plugin.json new file mode 100755 index 0000000..7547fb2 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/.codex-plugin/plugin.json @@ -0,0 +1,42 @@ +{ + "name": "aireng", + "version": "0.6.0", + "description": "AirEng is the sole public Air scheduler plugin. It reads AirArc planning artifacts, dispatches isolated AirDo subagents, monitors them on a 5-minute cadence, and merges structured results back into AirPlan without defaulting to parent-thread coding.", + "author": { + "name": "14816", + "email": "noreply@example.com", + "url": "https://airlongdian.fun" + }, + "homepage": "https://airlongdian.fun/plugins/aireng", + "repository": "https://airlongdian.fun/plugins/aireng", + "license": "MIT", + "keywords": [ + "air", + "aireng", + "scheduler", + "subagent", + "parallel" + ], + "skills": "./skills/", + "interface": { + "displayName": "AirEng", + "shortDescription": "Schedule and monitor isolated AirDo subagents from AirArc plans", + "longDescription": "AirEng is the sole public scheduler for the Air workflow. It reads AirArc execution artifacts from AirPlan, prepares isolated task handoffs, dispatches multiple AirDo worker subagents with bounded concurrency, monitors them every 5 minutes, repairs stalled flow when possible, and merges structured worker results without replaying long parent-thread context or defaulting to parent-thread coding.", + "developerName": "14816", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Write" + ], + "websiteURL": "https://airlongdian.fun/plugins/aireng", + "privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/", + "termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/", + "defaultPrompt": [ + "Use AirEng to load AirArc execution artifacts from AirPlan and plan a bounded dispatch wave.", + "Use AirEng to launch isolated AirDo subagents in parallel, keep concurrency within the dispatch manifest, and re-check active workers every 300 seconds.", + "Use AirEng to merge finalized AirDo results, repair blocked or stalled tasks, and keep AirPlan documents synchronized without defaulting to parent-thread implementation." + ], + "brandColor": "#0F766E", + "screenshots": [] + } +} diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/commands/aireng.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/commands/aireng.md new file mode 100755 index 0000000..b9b2006 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/commands/aireng.md @@ -0,0 +1,80 @@ +--- +description: Run AirEng as the sole public Air scheduler that reads AirArc artifacts, dispatches isolated AirDo subagents, monitors them every 5 minutes, and merges structured results into AirPlan without defaulting to parent-thread coding +argument-hint: [run|status|plan|dispatch|monitor|merge|intervene] +allowed-tools: [Read, Glob, Grep, Bash, Write, Edit] +--- + +# /aireng + +Use AirEng as the only public execution scheduler when the workflow should stay thin in the parent thread and execute real work through isolated subagents. + +## Steps + +1. Parse `$ARGUMENTS`; default to `run` when empty. +2. Always operate from the current project root and store workflow artifacts under `AirPlan/`. +3. The runtime auto-bootstraps missing `AirPlan/` files on first startup and does not overwrite existing project artifacts. +4. AirEng is a scheduler and convergence engine, not a default coding worker: + - Prefer isolated `/airdo` execution for normal task implementation. + - Keep parent-thread work focused on planning-source selection, dispatch, monitoring, merge, repair, and document convergence. + - Treat direct parent-thread editing as a temporary unblock action only when a worker is hard-blocked and cannot self-recover. + - After any temporary intervention, return immediately to scheduler mode. +5. AirEng is authorized to autonomously decide commands and file edits when needed to keep development moving unattended, but that autonomy serves orchestration, repair, doc sync, and unblock actions first rather than long-running parent-thread implementation. +6. For `status`, run: + +```bash +python "$HOME/plugins/aireng/scripts/aireng_mode.py" --mode status --project . +``` + +7. For `plan`, run: + +```bash +python "$HOME/plugins/aireng/scripts/aireng_mode.py" --mode plan --project . +``` + +8. For `dispatch`, run: + +```bash +python "$HOME/plugins/aireng/scripts/aireng_mode.py" --mode dispatch --project . +``` + +Then read the generated dispatch manifest under `AirPlan/state/aireng/dispatch/`, open each `handoffPath`, and prepare one isolated AirDo worker subagent per task. After a worker finishes, treat its `workerStatePath` `resultPath` as canonical instead of re-reading the task-local template `result.json`. + +9. For `monitor`, run: + +```bash +python "$HOME/plugins/aireng/scripts/aireng_mode.py" --mode monitor --project . +``` + +Use this to perform one non-blocking scheduler inspection pass: merge ready results, detect stalled workers, prepare repair continuation, and update `nextAction` in `AirPlan/state/aireng/state.json`. + +10. For `intervene`, run: + +```bash +python "$HOME/plugins/aireng/scripts/aireng_mode.py" --mode intervene --project . +``` + +Use this only for hard blockers that AirEng cannot clear through ordinary monitoring, repair, or re-dispatch decisions. + +11. For `run`, do one full scheduler step: + - Ensure AirEng state exists with `enter` if needed. + - Prefer `AirPlan/state/airarc/reviews/execution-plan.json`; if stale or missing, refresh via `plan`. + - If no active wave exists, dispatch the next available parallel-safe wave. + - If active workers already exist, monitor them instead of re-dispatching blindly. + - Spawn one `worker` subagent per dispatched task with `fork_context=false` so contexts stay isolated. + - Pass only the project path plus the task handoff file content; do not fork the full parent conversation. + - Keep at most `recommendedConcurrency` workers active at once. + - Do not execute ordinary child-task implementation in the parent thread. + - Do not interrupt actionable workers for midpoint status checks. + - Refresh `AirPlan/todo.md` and the active dispatch block in `AirPlan/plan.md` when a wave starts so progress is visible during execution. + - When ready results appear, merge them through: + +```bash +python "$HOME/plugins/aireng/scripts/aireng_mode.py" --mode merge --project . --result +``` + +12. In unattended runs, keep the scheduler on a 5-minute monitoring cadence: + - Re-check active workers every 300 seconds. + - Continue dispatching later waves automatically when the current wave converges. + - Stop only when state reaches `completed` or a true user-decision blocker remains. + +13. Throughout execution, keep `AirPlan/todo.md`, `AirPlan/plan.md`, and any required ADR/C4 updates synchronized. AirDo proposes `documentUpdates`; AirEng owns applying them. diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/scripts/aireng_mode.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/scripts/aireng_mode.py new file mode 100755 index 0000000..ef08ea2 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/scripts/aireng_mode.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +def _add_lib_path() -> None: + root = Path(__file__).resolve().parents[3] + 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.engine import ( + build_engine_plan, + dispatch_worker_group, + enter_engine, + intervene_engine, + merge_worker_result, + monitor_engine, + run_engine_once, + status_engine, +) +from air_runtime.paths import todo_path as workflow_todo_path +from air_runtime.project_bootstrap import ensure_project_bootstrap + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="AirEng public scheduler runtime") + parser.add_argument( + "--mode", + choices=["enter", "status", "plan", "dispatch", "merge", "monitor", "run", "intervene"], + default="status", + ) + parser.add_argument("--project", default=".") + parser.add_argument("--todo", default="") + parser.add_argument("--result", default="") + parser.add_argument("--dispatch-group", default="") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + project_root = Path(args.project).expanduser().resolve() + ensure_project_bootstrap(project_root) + + if args.mode == "enter": + state_path, health = enter_engine(project_root) + print("aireng_mode=enabled") + print(f"project_root={project_root}") + print(f"state_path={state_path}") + for key, value in health.items(): + print(f"{key}={'ok' if value else 'missing'}") + return + + if args.mode == "status": + state = status_engine(project_root) + print(f"aireng_mode={'enabled' if state.get('enabled') else 'disabled'}") + print(f"project_root={project_root}") + print(f"engine_mode={state.get('engineMode', '')}") + print(f"active_wave_id={state.get('activeWaveId', '')}") + print(f"active_dispatch_path={state.get('activeDispatchPath', '')}") + print(f"active_worker_count={len(state.get('activeWorkers', []))}") + print(f"merged_results={len(state.get('mergedResults', []))}") + print(f"pending_global_updates={len(state.get('pendingGlobalUpdates', []))}") + print(f"planning_source={state.get('planningSource', '')}") + print(f"xdb_sessions={len(state.get('xdbSessions', []))}") + print(f"xdb_policy_enabled={state.get('xdbPolicy', {}).get('enabled')}") + print(f"debug_sessions={len(state.get('debugSessions', []))}") + print(f"debug_policy_enabled={state.get('debugPolicy', {}).get('enabled')}") + print(f"repair_attempts={len(state.get('repairAttempts', []))}") + print(f"active_repairs={state.get('activeRepairCount', 0)}") + print(f"repair_policy_enabled={state.get('repairPolicy', {}).get('enabled')}") + print(f"monitor_interval_seconds={state.get('monitoringPolicy', {}).get('checkIntervalSeconds', 0)}") + print(f"last_loop_at={state.get('lastLoopAt', '')}") + print(f"last_intervention_at={state.get('lastInterventionAt', '')}") + print(f"next_action={state.get('nextAction', '')}") + for key, value in state.get("artifactHealth", {}).items(): + print(f"{key}={'ok' if value else 'missing'}") + return + + if args.mode == "plan": + current_todo_path = Path(args.todo).expanduser().resolve() if args.todo else workflow_todo_path(project_root) + result = build_engine_plan(project_root, current_todo_path) + print("aireng_mode=planned") + print(f"project_root={project_root}") + print(f"todo_path={current_todo_path}") + print(f"plan_path={result['planPath']}") + print(f"plan_markdown_path={result['planMarkdownPath']}") + print(f"review_json_path={result['reviewJsonPath']}") + print(f"review_markdown_path={result['reviewMarkdownPath']}") + print(f"planning_source={result['planningSource']}") + print(f"review_source_path={result['reviewSourcePath']}") + print(f"selected_tasks={','.join(result['selectedTasks'])}") + print(f"parallel_group_count={result['parallelGroupCount']}") + print(f"conflict_count={result['conflictCount']}") + return + + if args.mode == "dispatch": + result = dispatch_worker_group(project_root, args.dispatch_group) + print("aireng_mode=dispatched") + print(f"project_root={project_root}") + print(f"dispatch_path={result['dispatchPath']}") + print(f"group_name={result['groupName']}") + print(f"wave_id={result['waveId']}") + print(f"task_ids={','.join(result['taskIds'])}") + print(f"recommended_concurrency={result['recommendedConcurrency']}") + return + + if args.mode == "monitor": + result = monitor_engine(project_root) + print("aireng_mode=monitored") + print(f"project_root={project_root}") + print(f"engine_mode={result['engineMode']}") + print(f"active_worker_count={result['activeWorkerCount']}") + print(f"ready_to_merge_count={result['readyToMergeCount']}") + print(f"merged_count={result['mergedCount']}") + print(f"stalled_count={result['stalledCount']}") + print(f"intervention_count={result['interventionCount']}") + print(f"blocked_task_count={result['blockedTaskCount']}") + print(f"repair_queue_path={result['repairQueuePath']}") + print(f"next_action={result['nextAction']}") + return + + if args.mode == "intervene": + result = intervene_engine(project_root) + print("aireng_mode=intervened") + print(f"project_root={project_root}") + print(f"engine_mode={result['engineMode']}") + print(f"stalled_count={result['stalledCount']}") + print(f"intervention_count={result['interventionCount']}") + print(f"blocked_task_count={result['blockedTaskCount']}") + print(f"next_action={result['nextAction']}") + return + + if args.mode == "run": + current_todo_path = Path(args.todo).expanduser().resolve() if args.todo else workflow_todo_path(project_root) + result = run_engine_once(project_root, current_todo_path) + print("aireng_mode=ran") + print(f"project_root={project_root}") + print(f"action={result['action']}") + print(f"steps={','.join(result['steps'])}") + print(f"plan_path={result['planPath']}") + print(f"engine_mode={result['engineMode']}") + print(f"next_action={result['nextAction']}") + if 'dispatchPath' in result: + print(f"dispatch_path={result['dispatchPath']}") + if 'waveId' in result: + print(f"wave_id={result['waveId']}") + if 'taskIds' in result: + print(f"task_ids={','.join(result['taskIds'])}") + if 'activeWorkerCount' in result: + print(f"active_worker_count={result['activeWorkerCount']}") + return + + if not args.result: + raise SystemExit("--result is required for merge mode") + + result_path = Path(args.result).expanduser().resolve() + merged = merge_worker_result(project_root, result_path) + print("aireng_mode=merged") + print(f"project_root={project_root}") + print(f"task_id={merged['taskId']}") + print(f"task_status={merged['status']}") + print(f"archived_result_path={merged['archivedResultPath']}") + print(f"pending_global_update_count={merged['pendingGlobalUpdateCount']}") + print(f"doc_queue_path={merged['docQueuePath']}") + print(f"repair_queue_path={merged['repairQueuePath']}") + print(f"todo_path={merged['todoPath']}") + print(f"applied_doc_path_count={merged['appliedDocPathCount']}") + print(f"xdb_session_count={merged['xdbSessionCount']}") + print(f"debug_session_count={merged['debugSessionCount']}") + print(f"repair_attempt_count={merged['repairAttemptCount']}") + print(f"repair_prepared={merged['repairPrepared']}") + print(f"repair_dispatch_path={merged['repairDispatchPath']}") + print(f"next_action={merged['nextAction']}") + + +if __name__ == "__main__": + main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/skills/aireng/SKILL.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/skills/aireng/SKILL.md new file mode 100755 index 0000000..3b9c87f --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/skills/aireng/SKILL.md @@ -0,0 +1,65 @@ +--- +name: aireng +description: Sole public Air scheduler that reads AirArc execution artifacts, dispatches isolated AirDo subagents with bounded concurrency, monitors them on a 5-minute cadence, and merges structured results into AirPlan. +--- + +# AirEng + +## Role + +- Own the global execution contract in `AirPlan/`. +- Read AirArc review output before falling back to local todo analysis. +- Dispatch isolated AirDo subagents with `fork_context=false`. +- Monitor active workers, merge structured worker results, and keep the scheduler moving unattended. +- Own debug policy, XDB policy, repair policy, intervention policy, and global document convergence. +- Default to no parent-thread coding; use parent-thread edits only for short unblock actions that restore the scheduler. + +## Planning Source Order + +1. `AirPlan/state/airarc/reviews/execution-plan.json` +2. `AirPlan/state/airarc/reviews/parallel-review.json` +3. Engine fallback analysis of `AirPlan/todo.md` + +## Dispatch Contract + +- Generate the dispatch manifest under `AirPlan/state/aireng/dispatch/`. +- Respect `recommendedConcurrency`; do not flood the workspace with overlapping workers. +- Spawn one isolated AirDo subagent per task handoff. +- After a worker finishes, read its `workerStatePath` and use the `resultPath` recorded there as the canonical finalized result location. +- Pass only the task handoff and project path to each worker. Do not fork the full parent thread history. +- Do not interrupt actionable workers for midpoint status updates; let them continue through implementation and finalize unless they surface a real blocker. +- Keep parent-thread work limited to orchestration, monitoring, merge, repair, document convergence, and minimal unblock actions. +- Refresh `AirPlan/todo.md` and the active dispatch block in `AirPlan/plan.md` when a wave starts so execution progress is visible during the run. + +## Monitoring Contract + +- Store scheduler state in `AirPlan/state/aireng/state.json`. +- Track `engineMode`, `activeWaveId`, `activeDispatchPath`, `activeWorkers`, `monitoringPolicy`, `nextAction`, and `interventionHistory`. +- Use `monitoringPolicy.checkIntervalSeconds = 300` as the default cadence for unattended monitoring. +- Prefer re-dispatch, repair, debug, or other isolated recovery flows before direct intervention. +- Escalate to user decision only when a worker remains hard-blocked after the allowed intervention budget. + +## Merge Guarantees + +- Update task status and merge log in `AirPlan/todo.md`. +- Apply worker `documentUpdates`. +- Refresh engine-managed sync blocks in `AirPlan/AGENTS.md` and `AirPlan/docs/architecture/c4/module.md`. +- Track AirXDB sessions in `AirPlan/state/aireng/state.json`. +- Track debug sessions in `AirPlan/state/aireng/state.json`. +- Track repair attempts in `AirPlan/state/aireng/state.json`. +- Refuse a `done` merge when required global document updates are missing. +- Refuse a GUI-like `done` merge when successful AirXDB evidence is missing. +- Apply worker `documentUpdates` promptly so plan, ADR, and C4 changes do not lag behind completed slices. + +## Commands + +```bash +python ../../scripts/aireng_mode.py --mode enter --project +python ../../scripts/aireng_mode.py --mode status --project +python ../../scripts/aireng_mode.py --mode plan --project --todo +python ../../scripts/aireng_mode.py --mode dispatch --project [--dispatch-group ] +python ../../scripts/aireng_mode.py --mode monitor --project +python ../../scripts/aireng_mode.py --mode run --project [--todo ] +python ../../scripts/aireng_mode.py --mode intervene --project +python ../../scripts/aireng_mode.py --mode merge --project --result +``` diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/skills/aireng/agents/openai.yaml b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/skills/aireng/agents/openai.yaml new file mode 100755 index 0000000..83079c6 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/aireng/skills/aireng/agents/openai.yaml @@ -0,0 +1,3 @@ +name: aireng +short_description: Public Air scheduler for isolated AirDo subagent dispatch and AirPlan merges +default_prompt: "Use AirEng to read AirArc execution artifacts from AirPlan, dispatch isolated AirDo subagents with bounded concurrency, and merge structured worker results back into AirPlan. When dispatching workers, instruct them to continue through implementation and finalize without stopping for midpoint progress updates unless a real blocker or explicit user decision is required." diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/.codex-plugin/plugin.json b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/.codex-plugin/plugin.json new file mode 100755 index 0000000..e6fc343 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/.codex-plugin/plugin.json @@ -0,0 +1,44 @@ +{ + "name": "airndb", + "version": "0.1.2", + "description": "Network-debug workflow for bounded local or remote tcpdump or WinDump packet capture, first-run tcpdump or WinDump detection, official WinDump auto-download on Windows, remote tcpdump auto-configuration over SSH, pcap analysis, BPF filters, and AI-readable network debugging evidence.", + "author": { + "name": "14816", + "email": "noreply@example.com", + "url": "https://airlongdian.fun/plugins/airndb" + }, + "homepage": "https://airlongdian.fun/plugins/airndb", + "repository": "https://airlongdian.fun/plugins/airndb", + "license": "MIT", + "keywords": [ + "airndb", + "network-debug", + "tcpdump", + "windump", + "pcap", + "bpf", + "packet-capture" + ], + "skills": "./skills/", + "interface": { + "displayName": "AirNDB", + "shortDescription": "Local and remote packet capture workflow with WinDump setup", + "longDescription": "AirNDB helps Codex detect tcpdump or WinDump on first startup, automatically download official WinDump.exe on Windows when no capture tool is available, configure project-local tool.env, use a remote device helper over SSH for remote packet capture and auto-configure missing remote tcpdump when possible, collect bounded packet captures, choose safe BPF filters, save timestamped pcap artifacts, read packet summaries, and record network debugging evidence in AGENTS.md, ADR, C4 module, and network debug logs.", + "developerName": "14816", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Write" + ], + "websiteURL": "https://airlongdian.fun/plugins/airndb", + "privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/", + "termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/", + "defaultPrompt": [ + "Use AirNDB to investigate a network issue and collect bounded packet capture evidence.", + "Use AirNDB to detect or configure tcpdump or WinDump before starting a local or remote capture.", + "Use AirNDB to provide packet-level evidence back to AirDbg or AirDo for debugging or validation." + ], + "brandColor": "#0891B2", + "screenshots": [] + } +} diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/commands/airndb.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/commands/airndb.md new file mode 100755 index 0000000..2c89240 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/commands/airndb.md @@ -0,0 +1,109 @@ +--- +description: Enter, exit, inspect, capture, remote-capture, or read AirNDB tcpdump/WinDump network debug mode +argument-hint: [enter|setup|exit|status|interfaces|command|capture|read|remote-setup|remote-status|remote-interfaces|remote-command|remote-capture] +allowed-tools: [Read, Glob, Grep, Bash, Write, Edit] +--- + +# /airndb + +控制当前工作区的 AirNDB 网络抓包调试模式。 + +用户传入参数:`$ARGUMENTS` + +- `enter` 或空参数:进入 AirNDB 模式并初始化网络调试上下文。 +- `status`:检查 AirNDB 状态和关键文件是否存在。 +- `setup`:执行首启工具自检;Windows 下缺少 tcpdump/WinDump 时自动下载官方 `WinDump.exe` 并写入 `AirPlan/state/airndb/tool.env`。 +- `interfaces`:调用 tcpdump/WinDump 列出可抓包接口。 +- `command`:生成安全、有界的 tcpdump/WinDump 抓包命令,不实际执行。 +- `capture`:执行短时有界抓包,写入 timestamped `.pcap` 和 JSON 报告。 +- `read`:读取已有 `.pcap`,生成文本摘要和 JSON 报告。 +- `remote-setup`:检查远程设备 SSH 和抓包工具;缺失时自动尝试配置远程 `tcpdump`。 +- `remote-status`:只检查远程设备配置和工具状态,不自动安装。 +- `remote-interfaces`:通过 SSH 调用远端抓包工具列出接口。 +- `remote-command`:生成安全、有界的远程抓包命令,不实际执行。 +- `remote-capture`:通过 SSH 执行短时有界远程抓包,并把 pcap/JSON 报告拉回当前项目。 +- `exit`:退出 AirNDB 模式。 + +## 执行步骤 + +1. 解析 `$ARGUMENTS`,默认动作为 `enter`。 +2. 在当前项目根目录运行;`enter` / `setup` 会检测 `tcpdump` / `WinDump.exe`,Windows 缺失时自动从 WinDump 官方下载页获取 `WinDump.exe`,校验 SHA1 后配置 `AirPlan/state/airndb/tool.env`: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_mode.py" --mode --project . +``` + +如果 `python` 不存在,尝试 `py`、`python3` 或用户提供的 Python 绝对路径。 + +3. 如果 `$ARGUMENTS` 包含 `remote`、`ssh`、`远程`,或用户已经说明目标流量在远程设备、测试机、服务器、VM、容器宿主机、SSH 主机上,优先运行远程设备 helper: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_remote_device.py" --project . --action +``` + +首次缺少远程配置时,helper 会生成 `AirPlan/state/airndb/remote-device.env.example` 并提示设置 `AIRNDB_REMOTE_SSH_TARGET`。有 SSH 目标后,helper 会探测远端 `tcpdump` / `dumpcap`;缺失时自动尝试安装 `tcpdump`,只使用非交互式 `sudo -n`,无法自动配置时停止并提示用户。 + +4. `interfaces` 且目标是本机时运行: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_capture.py" --project . --action interfaces +``` + +5. `command` 且目标是本机时先收集接口、host/port/protocol/filter、包数或超时,再运行: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_capture.py" --project . --action command --iface --filter "" --count 200 +``` + +6. `capture` 且目标是本机时必须使用有界抓包: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_capture.py" --project . --action capture --iface --filter "" --count 200 --timeout 30 +``` + +7. `read` 时读取已有 pcap: + +```bash +python "$HOME/plugins/airndb/scripts/airndb_capture.py" --project . --action read --read-file docs/network/airndb-captures/.pcap --filter "" +``` + +8. 每次抓包或读取后维护: +- `AirPlan/docs/network/airndb-log.md` + - `docs/architecture/adr/` + - `docs/architecture/c4/module.md` + - `AGENTS.md` + +## 安全边界 + +- 只抓取用户授权的本机、项目、测试环境或明确允许的网络流量。 +- 默认禁止无界抓包;必须使用 `--count`、`--timeout` 或轮转策略。 +- 默认使用 `-nn` 和 `-s 0`,避免 DNS/service-name 解析并保留完整包。 +- 自动获取仅下载 `WinDump.exe` 并配置工具路径,不静默安装 WinPcap/Npcap 抓包驱动;接口列举失败时提示用户安装驱动或以管理员权限重试。 +- pcap 可能包含凭据、cookie、token、内网地址或个人信息;对外分享前必须提醒脱敏。 + +## 输出文案 + +进入模式: + +```text +AirNDB 模式已开启:已初始化或检查 AGENTS.md、ADR、C4 module 和 network debug log,并完成 tcpdump/WinDump 工具自检。请说明网络问题、目标主机/端口/协议、抓包接口和允许的抓包窗口。 +``` + +抓包完成: + +```text +AirNDB 抓包完成:pcap 和 JSON 报告已写入 docs/network/airndb-captures/,请结合 airndb-log.md 继续分析。 +``` + +remote-capture 完成: + +```text +AirNDB 远程抓包完成:已通过 SSH 执行有界抓包,pcap 和 JSON 报告已写入 docs/network/airndb-captures/,请结合 airndb-log.md 继续分析。 +``` + +状态检查: + +```text +AirNDB 状态: +关键文件:逐项列出 ok/missing。 +``` diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/scripts/airndb_capture.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/scripts/airndb_capture.py new file mode 100755 index 0000000..1441b9d --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/scripts/airndb_capture.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +"""Run safe bounded tcpdump/WinDump capture helpers for AirNDB.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import shlex +import shutil +import subprocess +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + + +DEFAULT_COUNT = 200 +DEFAULT_TIMEOUT = 30 +MAX_COUNT = 500000 +WINDUMP_DOWNLOAD_PAGE = "https://www.winpcap.org/windump/install/" +WINDUMP_DOWNLOAD_URL = "https://www.winpcap.org/windump/install/bin/windump_3_9_5/WinDump.exe" +WINDUMP_SHA1 = "d59bc54721951dec855cbb4bbc000f9a71ea4d95" + + +def now_stamp() -> str: + return datetime.now().strftime("%Y%m%d-%H%M%S") + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def is_windows() -> bool: + return platform.system().lower().startswith("win") + + +def parse_env_file(path: Path) -> Dict[str, str]: + if not path.exists(): + return {} + values: Dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"') + return values + + +def project_tool_env(project_root: Optional[Path]) -> Dict[str, str]: + if not project_root: + return {} + return parse_env_file(project_root / "AirPlan" / "state" / "airndb" / "tool.env") + + +def tool_dir(project_root: Optional[Path] = None) -> Path: + configured = os.environ.get("AIRNDB_TOOL_DIR", "").strip() + if configured: + return Path(configured).expanduser().resolve() + if project_root: + return (project_root / "AirPlan" / "state" / "airndb" / "tools").resolve() + return Path.home() / ".airndb" / "tools" + + +def detect_tool(explicit: str = "", project_root: Optional[Path] = None) -> Optional[str]: + candidates: List[str] = [] + if explicit: + candidates.append(explicit) + project_env = project_tool_env(project_root) + project_tool = project_env.get("AIRNDB_TCPDUMP", "") + if project_tool: + candidates.append(project_tool) + env_tool = os.environ.get("AIRNDB_TCPDUMP", "") + if env_tool: + candidates.append(env_tool) + bundled = tool_dir(project_root) / "WinDump.exe" + if bundled.exists(): + candidates.append(str(bundled)) + candidates.extend(["windump", "WinDump.exe", "tcpdump"] if is_windows() else ["tcpdump", "windump", "WinDump.exe"]) + for candidate in candidates: + if Path(candidate).exists(): + return str(Path(candidate).resolve()) + found = shutil.which(candidate) + if found: + return found + return None + + +def sha1_file(path: Path) -> str: + digest = hashlib.sha1() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def write_project_tool_env(project_root: Path, tool_path: Path) -> Path: + airndb_dir = project_root / "AirPlan" / "state" / "airndb" + airndb_dir.mkdir(parents=True, exist_ok=True) + env_path = airndb_dir / "tool.env" + env_path.write_text(f"AIRNDB_TCPDUMP={tool_path}\n", encoding="utf-8", newline="\n") + gitignore = airndb_dir / ".gitignore" + existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else "" + if "tool.env" not in existing.splitlines(): + gitignore.write_text((existing.rstrip() + "\ntool.env\n").lstrip(), encoding="utf-8", newline="\n") + return env_path + + +def download_windump(target: Path, timeout: int = 60) -> Dict[str, Any]: + target.parent.mkdir(parents=True, exist_ok=True) + temp = target.with_suffix(".download") + try: + with urllib.request.urlopen(WINDUMP_DOWNLOAD_URL, timeout=timeout) as response: + data = response.read() + temp.write_bytes(data) + actual_sha1 = sha1_file(temp) + if actual_sha1.lower() != WINDUMP_SHA1.lower(): + temp.unlink(missing_ok=True) + return { + "status": "failed", + "reason": "sha1_mismatch", + "expectedSha1": WINDUMP_SHA1, + "actualSha1": actual_sha1, + "downloadPage": WINDUMP_DOWNLOAD_PAGE, + "downloadUrl": WINDUMP_DOWNLOAD_URL, + } + if target.exists(): + target.unlink() + temp.replace(target) + return { + "status": "downloaded", + "path": str(target), + "sha1": actual_sha1, + "downloadPage": WINDUMP_DOWNLOAD_PAGE, + "downloadUrl": WINDUMP_DOWNLOAD_URL, + } + except (urllib.error.URLError, TimeoutError, OSError) as exc: + temp.unlink(missing_ok=True) + return { + "status": "failed", + "reason": type(exc).__name__, + "message": str(exc), + "downloadPage": WINDUMP_DOWNLOAD_PAGE, + "downloadUrl": WINDUMP_DOWNLOAD_URL, + } + + +def ensure_capture_tool(project_root: Path, explicit: str = "", auto_install: bool = False) -> Dict[str, Any]: + existing = detect_tool(explicit, project_root) + if existing: + env_path = write_project_tool_env(project_root, Path(existing)) if Path(existing).exists() else None + return { + "status": "ok", + "tool": existing, + "source": "existing", + "projectEnv": str(env_path) if env_path else "", + } + if not is_windows(): + return { + "status": "missing", + "tool": "", + "reason": "tcpdump_or_windump_not_found", + "hint": "Install tcpdump or pass --tool .", + } + if not auto_install: + return { + "status": "missing", + "tool": "", + "reason": "tcpdump_or_windump_not_found", + "hint": "Run /airndb enter or pass --tool .", + "downloadPage": WINDUMP_DOWNLOAD_PAGE, + } + target = tool_dir(project_root) / "WinDump.exe" + download = download_windump(target) + if download.get("status") != "downloaded": + return { + "status": "missing", + "tool": "", + "reason": "windump_download_failed", + "download": download, + "hint": "Install tcpdump/WinDump manually or set AIRNDB_TCPDUMP.", + } + env_path = write_project_tool_env(project_root, target) + return { + "status": "ok", + "tool": str(target), + "source": "downloaded", + "projectEnv": str(env_path), + "download": download, + "driverHint": "WinDump still requires a packet capture driver such as Npcap or WinPcap; install one manually if interface listing fails.", + } + + +def split_filter(filter_expr: str) -> List[str]: + if not filter_expr.strip(): + return [] + try: + return shlex.split(filter_expr, posix=not is_windows()) + except ValueError: + return filter_expr.split() + + +def display_command(command: Sequence[str]) -> str: + if is_windows(): + return subprocess.list2cmdline(list(command)) + return shlex.join(list(command)) + + +def tail(text: str, limit: int = 8000) -> str: + if len(text) <= limit: + return text + return text[-limit:] + + +def bounded_count(value: int) -> int: + if value < 1: + raise SystemExit("--count must be >= 1") + if value > MAX_COUNT: + raise SystemExit(f"--count must be <= {MAX_COUNT}") + return value + + +def capture_dir(project_root: Path, output_dir: str = "") -> Path: + path = Path(output_dir).expanduser().resolve() if output_dir else project_root / "AirPlan" / "docs" / "network" / "airndb-captures" + path.mkdir(parents=True, exist_ok=True) + return path + + +def base_capture_command(tool: str, iface: str, output: Path, count: int, filter_expr: str) -> List[str]: + if not iface: + raise SystemExit("--iface is required for capture or command") + command = [tool, "-i", iface, "-nn", "-s", "0", "-w", str(output), "-c", str(bounded_count(count))] + command.extend(split_filter(filter_expr)) + return command + + +def read_command(tool: str, read_file: Path, filter_expr: str) -> List[str]: + if not read_file.exists(): + raise SystemExit(f"read file not found: {read_file}") + command = [tool, "-nn", "-tttt", "-r", str(read_file)] + command.extend(split_filter(filter_expr)) + return command + + +def run_command(command: Sequence[str], timeout: int) -> Dict[str, Any]: + started = now_iso() + try: + completed = subprocess.run( + list(command), + check=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + return { + "startedAt": started, + "finishedAt": now_iso(), + "timedOut": False, + "returnCode": completed.returncode, + "stdout": tail(completed.stdout), + "stderr": tail(completed.stderr), + } + except subprocess.TimeoutExpired as exc: + return { + "startedAt": started, + "finishedAt": now_iso(), + "timedOut": True, + "returnCode": None, + "stdout": tail((exc.stdout or "") if isinstance(exc.stdout, str) else ""), + "stderr": tail((exc.stderr or "") if isinstance(exc.stderr, str) else ""), + } + + +def write_report(output_dir: Path, action: str, payload: Dict[str, Any]) -> Path: + report_path = output_dir / f"{now_stamp()}-airndb-{action}.json" + report_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return report_path + + +def append_log(project_root: Path, payload: Dict[str, Any]) -> None: + log_path = project_root / "AirPlan" / "docs" / "network" / "airndb-log.md" + log_path.parent.mkdir(parents=True, exist_ok=True) + lines = [ + "", + f"## {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: AirNDB {payload['action']}", + "", + f"- Tool: `{payload.get('tool', 'missing')}`", + f"- Interface: `{payload.get('interface', '')}`", + f"- Filter: `{payload.get('filter', '')}`", + f"- Command: `{payload.get('command', '')}`", + f"- Artifacts: `{payload.get('artifact', '')}`", + f"- Report: `{payload.get('report', '')}`", + f"- Result: `{payload.get('result', '')}`", + "- AirDbg handoff: TODO", + "- Residual risk: pcap files may contain sensitive packet contents; review before sharing.", + ] + with log_path.open("a", encoding="utf-8", newline="\n") as handle: + handle.write("\n".join(lines) + "\n") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="AirNDB tcpdump/WinDump helper.") + parser.add_argument("--project", default=".") + parser.add_argument("--action", choices=["interfaces", "command", "capture", "read"], default="interfaces") + parser.add_argument("--tool", default="", help="Explicit tcpdump/WinDump path.") + parser.add_argument("--iface", default="", help="Interface name or WinDump interface index.") + parser.add_argument("--filter", default="", help="BPF filter expression.") + parser.add_argument("--count", type=int, default=DEFAULT_COUNT) + parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT) + parser.add_argument("--output-dir", default="") + parser.add_argument("--output", default="") + parser.add_argument("--read-file", default="") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + project_root = Path(args.project).expanduser().resolve() + output_dir = capture_dir(project_root, args.output_dir) + tool = detect_tool(args.tool, project_root) + if not tool: + print("airndb_status=blocked") + print("reason=tcpdump_or_windump_not_found") + print("hint=Install tcpdump on Unix-like systems or WinDump/Npcap on Windows, or pass --tool .") + raise SystemExit(2) + + if args.action == "interfaces": + command = [tool, "-D"] + result = run_command(command, max(5, args.timeout)) + payload = { + "action": "interfaces", + "tool": tool, + "command": display_command(command), + "result": result, + } + report = write_report(output_dir, "interfaces", payload) + print("airndb_status=ok" if result["returnCode"] == 0 else "airndb_status=failed") + print(f"tool={tool}") + print(f"command={display_command(command)}") + print(f"report={report}") + print(result.get("stdout", "")) + print(result.get("stderr", "")) + append_log(project_root, { + "action": "interfaces", + "tool": tool, + "interface": "", + "filter": "", + "command": display_command(command), + "artifact": "", + "report": str(report), + "result": "ok" if result["returnCode"] == 0 else "failed", + }) + return + + if args.action == "command": + output = Path(args.output).expanduser().resolve() if args.output else output_dir / f"{now_stamp()}-airndb.pcap" + command = base_capture_command(tool, args.iface, output, args.count, args.filter) + print("airndb_status=ok") + print(f"capture_command={display_command(command)}") + print(f"output={output}") + return + + if args.action == "capture": + output = Path(args.output).expanduser().resolve() if args.output else output_dir / f"{now_stamp()}-airndb.pcap" + command = base_capture_command(tool, args.iface, output, args.count, args.filter) + result = run_command(command, max(1, args.timeout)) + payload = { + "action": "capture", + "tool": tool, + "interface": args.iface, + "filter": args.filter, + "count": bounded_count(args.count), + "timeoutSeconds": args.timeout, + "pcap": str(output), + "command": display_command(command), + "result": result, + } + report = write_report(output_dir, "capture", payload) + status = "timeout" if result["timedOut"] else ("ok" if result["returnCode"] == 0 else "failed") + print(f"airndb_status={status}") + print(f"pcap={output}") + print(f"report={report}") + print(f"command={display_command(command)}") + append_log(project_root, { + "action": "capture", + "tool": tool, + "interface": args.iface, + "filter": args.filter, + "command": display_command(command), + "artifact": str(output), + "report": str(report), + "result": status, + }) + return + + if args.action == "read": + if not args.read_file: + raise SystemExit("--read-file is required for read") + read_file = Path(args.read_file).expanduser() + if not read_file.is_absolute(): + read_file = project_root / read_file + read_file = read_file.resolve() + command = read_command(tool, read_file, args.filter) + result = run_command(command, max(1, args.timeout)) + summary_path = output_dir / f"{now_stamp()}-airndb-read.txt" + summary_path.write_text((result.get("stdout") or "") + (result.get("stderr") or ""), encoding="utf-8", newline="\n") + payload = { + "action": "read", + "tool": tool, + "readFile": str(read_file), + "filter": args.filter, + "summary": str(summary_path), + "command": display_command(command), + "result": result, + } + report = write_report(output_dir, "read", payload) + status = "ok" if result["returnCode"] == 0 else "failed" + print(f"airndb_status={status}") + print(f"summary={summary_path}") + print(f"report={report}") + print(f"command={display_command(command)}") + append_log(project_root, { + "action": "read", + "tool": tool, + "interface": "", + "filter": args.filter, + "command": display_command(command), + "artifact": str(summary_path), + "report": str(report), + "result": status, + }) + + +if __name__ == "__main__": + main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/scripts/airndb_mode.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/scripts/airndb_mode.py new file mode 100755 index 0000000..59e8437 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/scripts/airndb_mode.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Bootstrap AirNDB network debugging context files.""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, Tuple + +from airndb_capture import ensure_capture_tool + +MARKER_BEGIN = "" +MARKER_END = "" + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def airndb_agents_block() -> str: + return f"""{MARKER_BEGIN} +## AirNDB Network Debug Workflow + +1. Use AirNDB for `/airndb` sessions that need tcpdump/WinDump packet capture, pcap reading, BPF filters, or network evidence. +2. On first entry, detect tcpdump/WinDump; on Windows, if none is available, download official `WinDump.exe` from https://www.winpcap.org/windump/install/, verify SHA1, and write `AirPlan/state/airndb/tool.env`. +3. Only capture authorized traffic; prefer short bounded captures with `-nn`, `-s 0`, `-c `, and narrow BPF filters. +4. Before capture, load: + - `AirPlan/AGENTS.md` + - `AirPlan/docs/architecture/adr/` + - `AirPlan/docs/architecture/c4/module.md` + - `AirPlan/docs/network/airndb-log.md` +5. Store pcap, text summaries, and JSON reports under `AirPlan/docs/network/airndb-captures/`. +6. Record exact command, interface, filter, capture window, artifact paths, key observations, and residual risk in `AirPlan/docs/network/airndb-log.md`. +7. AirDbg may call AirNDB when debugging needs DNS/TCP/UDP/TLS/HTTP, ports, proxy, firewall, packet-loss, retransmit, reset, or pcap evidence. +8. Update ADR/C4 when network boundaries, capture tooling, observability, ports, protocols, DNS, proxy, TLS, or runtime topology become durable architecture context. +{MARKER_END} +""" + + +def c4_module_template() -> str: + return """# C4 Module + +## System Context +- TODO: Describe the system, users, and important external systems. + +## Containers +- TODO: Describe runtime/deployable units and network boundaries. + +## Modules + +| Module | Responsibility | Network Interfaces | Dependencies | Data Ownership | Network Debug Notes | +| --- | --- | --- | --- | --- | --- | +| TODO | TODO | TODO | TODO | TODO | TODO | + +## Network / Observability Boundaries +- tcpdump/WinDump capture points, interfaces, container/WSL/VM/host boundaries: TODO +- Ports, protocols, DNS, proxy, TLS, firewall, NAT, or gateway notes: TODO + +## Change Log +- TODO: Record network-boundary or observability changes discovered by AirNDB. +""" + + +def adr_template() -> str: + return """# ADR-0001: AirNDB Packet Capture Governance + +- Status: Accepted +- Date: TODO + +## Context +Network debugging needs durable, AI-readable packet capture context and bounded evidence collection. + +## Decision +Use AirNDB to build safe tcpdump/WinDump commands, capture or read pcap artifacts, and maintain `AirPlan/docs/network/airndb-log.md`, C4 module docs, and ADR records when network boundaries or diagnostics change. + +## Consequences +- Packet evidence can be reused by future AirDbg or AirNDB sessions. +- Captures must stay bounded and authorized. +- pcap artifacts may contain sensitive data and need careful handling. + +## Alternatives +- Chat-only packet notes: rejected because commands, filters, and pcap paths are easy to lose. +""" + + +def network_log_template() -> str: + return """# AirNDB Network Debug Log + +Append entries for AirNDB packet capture sessions. + +## Entry Template + +### YYYY-MM-DD: short network issue title + +- Scope / authorization: TODO +- Symptom: TODO +- Interface: TODO +- Filter: TODO +- Capture window: TODO +- Command: TODO +- Artifacts: TODO +- Key observations: TODO +- AirDbg handoff: TODO +- ADR/C4 updates: TODO +- Residual risk: TODO +""" + + +def captures_gitignore_template() -> str: + return """*.pcap +*.pcapng +*.cap +*.txt +*.json +!.gitignore +""" + + +def airndb_gitignore_template() -> str: + return """tool.env +""" + + +def write_if_missing(path: Path, content: str) -> bool: + if path.exists(): + return False + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8", newline="\n") + return True + + +def upsert_agents_md(path: Path) -> str: + block = airndb_agents_block().rstrip() + "\n" + if path.exists(): + original = path.read_text(encoding="utf-8") + existed = True + else: + original = "# AGENTS.md\n\n" + existed = False + + begin = original.find(MARKER_BEGIN) + end = original.find(MARKER_END) + if begin >= 0 and end > begin: + end += len(MARKER_END) + updated = original[:begin].rstrip() + "\n\n" + block + original[end:].lstrip() + status = "updated" + else: + updated = original.rstrip() + "\n\n" + block + status = "updated" if existed else "created" + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(updated, encoding="utf-8", newline="\n") + return status + + +def artifact_map(project_root: Path) -> Dict[str, Path]: + return { + "AGENTS.md": project_root / "AirPlan" / "AGENTS.md", + "c4_module": project_root / "AirPlan" / "docs" / "architecture" / "c4" / "module.md", + "adr_dir": project_root / "AirPlan" / "docs" / "architecture" / "adr", + "adr_0001": project_root / "AirPlan" / "docs" / "architecture" / "adr" / "ADR-0001-airndb-packet-capture-governance.md", + "network_log": project_root / "AirPlan" / "docs" / "network" / "airndb-log.md", + "captures_dir": project_root / "AirPlan" / "docs" / "network" / "airndb-captures", + "captures_gitignore": project_root / "AirPlan" / "docs" / "network" / "airndb-captures" / ".gitignore", + "tool_env": project_root / "AirPlan" / "state" / "airndb" / "tool.env", + "airndb_gitignore": project_root / "AirPlan" / "state" / "airndb" / ".gitignore", + "state": project_root / "AirPlan" / "state" / "airndb" / "state.json", + } + + +def write_state(path: Path, enabled: bool, project_root: Path) -> None: + artifacts = artifact_map(project_root) + health = { + name: artifacts[name].exists() + for name in [ + "AGENTS.md", + "c4_module", + "adr_dir", + "adr_0001", + "network_log", + "captures_dir", + "captures_gitignore", + "tool_env", + "airndb_gitignore", + ] + } + payload = { + "enabled": enabled, + "updatedAt": now_iso(), + "projectRoot": str(project_root), + "artifactHealth": health, + "captureTool": ensure_capture_tool(project_root, auto_install=False), + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def enter_mode(project_root: Path) -> Tuple[str, Dict[str, str]]: + artifacts = artifact_map(project_root) + results: Dict[str, str] = {} + results["AGENTS.md"] = upsert_agents_md(artifacts["AGENTS.md"]) + results["c4_module"] = "created" if write_if_missing(artifacts["c4_module"], c4_module_template()) else "exists" + artifacts["adr_dir"].mkdir(parents=True, exist_ok=True) + results["adr_dir"] = "exists" + results["adr_0001"] = "created" if write_if_missing(artifacts["adr_0001"], adr_template()) else "exists" + results["network_log"] = "created" if write_if_missing(artifacts["network_log"], network_log_template()) else "exists" + artifacts["captures_dir"].mkdir(parents=True, exist_ok=True) + results["captures_dir"] = "exists" + results["captures_gitignore"] = "created" if write_if_missing(artifacts["captures_gitignore"], captures_gitignore_template()) else "exists" + results["airndb_gitignore"] = "created" if write_if_missing(artifacts["airndb_gitignore"], airndb_gitignore_template()) else "exists" + tool_status = ensure_capture_tool(project_root, auto_install=True) + results["capture_tool"] = tool_status.get("status", "unknown") + results["capture_tool_path"] = tool_status.get("tool", "") + results["capture_tool_source"] = tool_status.get("source", "") + results["capture_tool_env"] = tool_status.get("projectEnv", "") + if tool_status.get("driverHint"): + results["capture_driver_hint"] = tool_status["driverHint"] + write_state(artifacts["state"], True, project_root) + return "enabled", results + + +def exit_mode(project_root: Path) -> Tuple[str, Dict[str, str]]: + artifacts = artifact_map(project_root) + write_state(artifacts["state"], False, project_root) + return "disabled", {} + + +def status_mode(project_root: Path) -> Tuple[str, Dict[str, str]]: + artifacts = artifact_map(project_root) + state_file = artifacts["state"] + enabled = False + if state_file.exists(): + try: + payload = json.loads(state_file.read_text(encoding="utf-8")) + enabled = bool(payload.get("enabled")) + except json.JSONDecodeError: + enabled = False + results = { + name: ("ok" if path.exists() else "missing") + for name, path in artifacts.items() + if name != "state" + } + return ("enabled" if enabled else "disabled"), results + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Manage AirNDB network debug artifacts.") + parser.add_argument("--mode", choices=["enter", "setup", "exit", "status"], default="enter") + parser.add_argument("--project", default=".") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + project_root = Path(args.project).expanduser().resolve() + if args.mode in {"enter", "setup"}: + mode_state, result = enter_mode(project_root) + elif args.mode == "exit": + mode_state, result = exit_mode(project_root) + else: + mode_state, result = status_mode(project_root) + print(f"airndb_mode={mode_state}") + print(f"project_root={project_root}") + for key, value in result.items(): + print(f"{key}={value}") + + +if __name__ == "__main__": + main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/scripts/airndb_remote_device.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/scripts/airndb_remote_device.py new file mode 100755 index 0000000..8ad7875 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/scripts/airndb_remote_device.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""AirNDB remote packet capture helper over SSH.""" +from __future__ import annotations +import argparse, base64, json, os, shlex, shutil, subprocess +from datetime import datetime, timezone +from pathlib import Path +MAX_COUNT = 500000 + +def stamp(): return datetime.now().strftime('%Y%m%d-%H%M%S') +def iso(): return datetime.now(timezone.utc).isoformat() +def q(s): return shlex.quote(str(s)) +def split(s): return shlex.split(s, posix=True) if s.strip() else [] + +def read_env(path): + vals = {} + if path.exists(): + for raw in path.read_text(encoding='utf-8-sig').splitlines(): + line = raw.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1); vals[k.strip()] = v.strip().strip('"').strip("'") + for k in ['AIRNDB_REMOTE_SSH_TARGET','AIRNDB_REMOTE_SSH_PORT','AIRNDB_REMOTE_SSH_OPTIONS','AIRNDB_REMOTE_WORKDIR','AIRNDB_REMOTE_TCPDUMP','AIRNDB_REMOTE_CAPTURE_PREFIX']: + if os.environ.get(k): vals[k] = os.environ[k] + vals.setdefault('AIRNDB_REMOTE_SSH_PORT','22'); vals.setdefault('AIRNDB_REMOTE_TCPDUMP','auto'); vals.setdefault('AIRNDB_REMOTE_CAPTURE_PREFIX','sudo -n') + return vals + +def write_env(path, updates): + path.parent.mkdir(parents=True, exist_ok=True); old = path.read_text(encoding='utf-8') if path.exists() else ''; keys = set(updates); lines = [] + for raw in old.splitlines(): + key = raw.split('=',1)[0].strip() if '=' in raw and not raw.strip().startswith('#') else None + if key not in keys: lines.append(raw) + if lines and lines[-1].strip(): lines.append('') + for k, v in updates.items(): + if v: lines.append(f'{k}={v}') + path.write_text('\n'.join(lines).rstrip()+'\n', encoding='utf-8', newline='\n') + +def ensure_files(project): + d = project/'AirPlan'/'state'/'airndb'; d.mkdir(parents=True, exist_ok=True); ex = d/'remote-device.env.example' + if not ex.exists(): + ex.write_text('# AirNDB remote packet-capture device configuration\nAIRNDB_REMOTE_SSH_TARGET=user@host\nAIRNDB_REMOTE_SSH_PORT=22\nAIRNDB_REMOTE_SSH_OPTIONS=\nAIRNDB_REMOTE_WORKDIR=\nAIRNDB_REMOTE_TCPDUMP=auto\nAIRNDB_REMOTE_CAPTURE_PREFIX=sudo -n\n', encoding='utf-8', newline='\n') + gi = d/'.gitignore'; old = gi.read_text(encoding='utf-8') if gi.exists() else '' + for item in ['remote-device.env','tool.env']: + if item not in old.splitlines(): old = (old.rstrip()+f'\n{item}\n').lstrip() + gi.write_text(old, encoding='utf-8', newline='\n') + +def ssh_args(env, cmd): + target = env.get('AIRNDB_REMOTE_SSH_TARGET','') + if not target: raise SystemExit('AIRNDB_REMOTE_SSH_TARGET is required') + args = [shutil.which('ssh') or 'ssh']; port = env.get('AIRNDB_REMOTE_SSH_PORT','22') + if port: args += ['-p', port] + args += split(env.get('AIRNDB_REMOTE_SSH_OPTIONS','')); return args + [target, cmd] + +def run(env, cmd, timeout=60): + return subprocess.run(ssh_args(env, cmd), capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout) + +def remote_home(env): + r = run(env, 'printf %s "$HOME"', 15); return r.stdout.strip() if r.returncode == 0 else '' + +def workdir(env): + if env.get('AIRNDB_REMOTE_WORKDIR'): return env['AIRNDB_REMOTE_WORKDIR'] + home = remote_home(env); return (home.rstrip('/')+'/.airndb') if home else '.airndb' + +def detect_tool(env): + tool = env.get('AIRNDB_REMOTE_TCPDUMP','auto').strip() + if tool and tool != 'auto': return tool + r = run(env, 'for t in tcpdump dumpcap windump WinDump.exe; do command -v "$t" >/dev/null 2>&1 && { command -v "$t"; exit 0; }; done', 15) + return r.stdout.strip().splitlines()[-1] if r.returncode == 0 and r.stdout.strip() else '' + +def install_tool(env): + script = """set -e +if command -v tcpdump >/dev/null 2>&1; then exit 0; fi +if [ "$(id -u 2>/dev/null || echo 1)" = "0" ]; then SUDO=""; else SUDO="sudo -n"; fi +if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update && $SUDO apt-get install -y tcpdump; +elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y tcpdump; +elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y tcpdump; +elif command -v apk >/dev/null 2>&1; then $SUDO apk add tcpdump; +elif command -v pacman >/dev/null 2>&1; then $SUDO pacman -Sy --noconfirm tcpdump; +else exit 42; fi""" + return run(env, script, 300) + +def setup(project, env, auto=True): + if not env.get('AIRNDB_REMOTE_SSH_TARGET'): + return {'status':'blocked','reason':'missing_remote_target','example':str(project/'AirPlan'/'state'/'airndb'/'remote-device.env.example')} + if not shutil.which('ssh'): return {'status':'blocked','reason':'ssh_not_found'} + probe = run(env, 'printf ok', 20) + if probe.returncode: return {'status':'blocked','reason':'ssh_probe_failed','stderr':probe.stderr.strip()} + wd = workdir(env); mk = run(env, f'mkdir -p {q(wd)}', 20) + if mk.returncode: return {'status':'blocked','reason':'remote_workdir_failed','stderr':mk.stderr.strip()} + tool = detect_tool(env); install = 'skipped' + if not tool and auto: + inst = install_tool(env); install = 'ok' if inst.returncode == 0 else f'failed:{inst.returncode}'; tool = detect_tool(env) + upd = {'AIRNDB_REMOTE_WORKDIR':wd} + if tool: upd['AIRNDB_REMOTE_TCPDUMP'] = tool + write_env(project/'AirPlan'/'state'/'airndb'/'remote-device.env', upd) + return {'status':'ok' if tool else 'blocked','target':env.get('AIRNDB_REMOTE_SSH_TARGET',''),'workdir':wd,'tool':tool,'capturePrefix':env.get('AIRNDB_REMOTE_CAPTURE_PREFIX',''),'autoConfigure':install,'hint':'' if tool else 'Install tcpdump/dumpcap or set AIRNDB_REMOTE_TCPDUMP.'} + +def bounded(n): + if n < 1 or n > MAX_COUNT: raise SystemExit(f'--count must be 1..{MAX_COUNT}') + return n + +def filt(expr): + if not expr.strip(): return [] + try: return split(expr) + except ValueError: return expr.split() + +def cap_cmd(env, tool, iface, out, count, filter_expr): + if not iface: raise SystemExit('--iface is required') + parts = split(env.get('AIRNDB_REMOTE_CAPTURE_PREFIX','')) + [tool,'-i',iface,'-nn','-s','0','-w',out,'-c',str(bounded(count))] + filt(filter_expr) + return shlex.join(parts) + +def outdir(project, configured): + p = Path(configured).expanduser().resolve() if configured else project/'AirPlan'/'docs'/'network'/'airndb-captures'; p.mkdir(parents=True, exist_ok=True); return p + +def emit(prefix, data): + print(f'{prefix}_status={data.get("status","unknown")}') + for k, v in data.items(): + if k != 'status': print(f'{k}={json.dumps(v, ensure_ascii=False) if isinstance(v,(dict,list)) else v}') + +def log(project, data): + lp = project/'AirPlan'/'docs'/'network'/'airndb-log.md'; lp.parent.mkdir(parents=True, exist_ok=True) + lp.open('a', encoding='utf-8', newline='\n').write(f"\n## {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: AirNDB remote {data.get('action','capture')}\n\n- Remote target: `{data.get('target','')}`\n- Tool: `{data.get('tool','')}`\n- Interface: `{data.get('interface','')}`\n- Filter: `{data.get('filter','')}`\n- Command: `{data.get('command','')}`\n- Artifacts: `{data.get('artifact','')}`\n- Report: `{data.get('report','')}`\n- Result: `{data.get('status','')}`\n- AirDbg handoff: TODO\n- Residual risk: remote pcap files may contain sensitive packet contents.\n") + +def main(): + ap = argparse.ArgumentParser(); ap.add_argument('--project', default='.'); ap.add_argument('--action', choices=['setup','status','interfaces','command','capture'], default='setup'); ap.add_argument('--iface', default=''); ap.add_argument('--filter', default=''); ap.add_argument('--count', type=int, default=200); ap.add_argument('--timeout', type=int, default=30); ap.add_argument('--output-dir', default=''); ap.add_argument('--output', default=''); ap.add_argument('--no-auto-configure', action='store_true'); a = ap.parse_args() + project = Path(a.project).expanduser().resolve(); ensure_files(project); env = read_env(project/'AirPlan'/'state'/'airndb'/'remote-device.env') + info = setup(project, env, auto=(a.action!='status' and not a.no_auto_configure)) + if a.action in ['setup','status'] or info.get('status') != 'ok': emit('airndb_remote', info); raise SystemExit(0 if info.get('status')=='ok' else 2) + od = outdir(project, a.output_dir); tool = info['tool'] + if a.action == 'interfaces': + icmd = shlex.join(split(env.get('AIRNDB_REMOTE_CAPTURE_PREFIX','')) + [tool,'-D']); r = run(env, icmd, max(5,a.timeout)); data = {'action':'interfaces','status':'ok' if r.returncode==0 else 'failed','target':info['target'],'tool':tool,'command':shlex.join(ssh_args(env, icmd)),'stdout':r.stdout.strip(),'stderr':r.stderr.strip()}; report = od/(stamp()+'-airndb-remote-interfaces.json'); report.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8'); data['report']=str(report); log(project,data); emit('airndb_remote',data); print(r.stdout); print(r.stderr); raise SystemExit(0 if data['status']=='ok' else 1) + remote = a.output or info['workdir'].rstrip('/') + '/' + stamp() + '-airndb-remote.pcap'; cmd = cap_cmd(env, tool, a.iface, remote, a.count, a.filter) + if a.action == 'command': emit('airndb_remote', {'status':'ok','target':info['target'],'remotePcap':remote,'command':shlex.join(ssh_args(env, cmd))}); return + r = run(env, cmd, max(1,a.timeout)); data = {'action':'capture','status':'ok' if r.returncode==0 else 'failed','target':info['target'],'tool':tool,'interface':a.iface,'filter':a.filter,'count':bounded(a.count),'timeoutSeconds':a.timeout,'remotePcap':remote,'command':shlex.join(ssh_args(env, cmd)),'stdout':r.stdout.strip(),'stderr':r.stderr.strip(),'capturedAt':iso()} + if r.returncode == 0: + b64 = run(env, f'base64 < {q(remote)}', a.timeout); local = od/(stamp()+'-airndb-remote.pcap'); local.write_bytes(base64.b64decode(''.join(b64.stdout.split()))); data['artifact'] = str(local) + report = od/(stamp()+'-airndb-remote-capture.json'); report.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8'); data['report'] = str(report); log(project, data); emit('airndb_remote', data); raise SystemExit(0 if data['status']=='ok' else 1) +if __name__ == '__main__': main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/scripts/install_airndb_plugin.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/scripts/install_airndb_plugin.py new file mode 100755 index 0000000..4fc7b4f --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/scripts/install_airndb_plugin.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Install the AirNDB plugin into the current user's home-local plugin directory.""" + +from __future__ import annotations + +import argparse +import json +import shutil +from pathlib import Path +from typing import Any, Dict + +PLUGIN_NAME = "airndb" + + +def copy_plugin(source: Path, target: Path) -> None: + if source.resolve() == target.resolve(): + return + if target.exists(): + shutil.rmtree(target) + ignore = shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store", ".git") + shutil.copytree(source, target, ignore=ignore) + + +def marketplace_payload() -> Dict[str, Any]: + return { + "name": "local-airarc", + "interface": {"displayName": "Local AirArc Plugins"}, + "plugins": [], + } + + +def update_marketplace(path: Path) -> None: + if path.exists(): + payload = json.loads(path.read_text(encoding="utf-8")) + else: + payload = marketplace_payload() + payload.setdefault("name", "local-airarc") + payload.setdefault("interface", {}).setdefault("displayName", "Local AirArc Plugins") + plugins = payload.setdefault("plugins", []) + entry = { + "name": PLUGIN_NAME, + "source": { + "source": "local", + "path": f"./plugins/{PLUGIN_NAME}", + }, + "policy": { + "installation": "INSTALLED_BY_DEFAULT", + "authentication": "ON_INSTALL", + }, + "category": "Productivity", + } + for index, existing in enumerate(plugins): + if existing.get("name") == PLUGIN_NAME: + plugins[index] = entry + break + else: + plugins.append(entry) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Install AirNDB as a home-local Codex plugin.") + parser.add_argument("--source", default=str(Path(__file__).resolve().parents[1])) + parser.add_argument("--home", default=str(Path.home())) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + source = Path(args.source).expanduser().resolve() + home = Path(args.home).expanduser().resolve() + target = home / "plugins" / PLUGIN_NAME + marketplace = home / ".agents" / "plugins" / "marketplace.json" + copy_plugin(source, target) + update_marketplace(marketplace) + print(f"installed_plugin={target}") + print(f"marketplace={marketplace}") + + +if __name__ == "__main__": + main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/skills/airndb/SKILL.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/skills/airndb/SKILL.md new file mode 100755 index 0000000..022edf6 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/skills/airndb/SKILL.md @@ -0,0 +1,214 @@ +--- +name: airndb +description: Network-debug packet capture workflow. Use when the user invokes /airndb or asks to debug networking, packet loss, DNS, TCP, UDP, TLS handshakes, HTTP connectivity, ports, retransmits, resets, latency, firewall, proxy, service reachability, pcap files, tcpdump, WinDump, remote packet capture over SSH, or BPF filters. Load or initialize AirPlan/AGENTS.md, AirPlan/docs/architecture/adr/ decision records, AirPlan/docs/architecture/c4/module.md, AirPlan/docs/network/airndb-log.md, and AirPlan/docs/network/airndb-captures/; on first startup detect tcpdump/WinDump and on Windows auto-download official WinDump.exe when no capture tool is available; when remote debugging, call the AirNDB remote device helper and auto-configure remote tcpdump/dumpcap when missing; build safe bounded tcpdump/WinDump commands; capture or read pcap artifacts; summarize packet evidence; and maintain AirPlan/AGENTS.md, ADR, C4 module docs, and network debug logs when capture tooling, network boundaries, or debugging decisions change. +--- + +# AirNDB + +## 核心约束 + +- 全程使用中文与用户交流,命令、接口名、BPF、日志、路径和协议名保持原文。 +- `/airndb` 专用于网络抓包、pcap 分析和网络层调试证据收集。 +- 只抓取用户授权的本机、项目、测试环境或明确允许的网络流量。 +- 默认不做无界抓包;必须使用包数、超时、时长或轮转上限。 +- 默认先列接口,再确认接口、目标 host/port/protocol/filter、抓包窗口和输出路径。 +- 初次启动必须检测 `tcpdump` / `windump` / `WinDump.exe` 是否可用;Windows 下如果不可用,自动从 WinDump 官方下载页获取 `WinDump.exe`,校验 SHA1 后写入 `AirPlan/state/airndb/tool.env`。 +- 远程设备、测试机、VM 或 SSH 主机上的网络调试,先调用 `../../scripts/airndb_remote_device.py`;缺少远程 `tcpdump` / `dumpcap` 时允许脚本自动配置,无法无密码 `sudo` 或无包管理器时停止并提示用户。 +- 自动获取只下载 WinDump 用户态程序,不静默安装 WinPcap/Npcap 抓包驱动;如果接口列举失败,提示用户安装 Npcap 或 WinPcap 并用管理员权限重试。 +- 默认使用 `-nn` 避免 DNS/service-name 解析,使用 `-s 0` 写入完整 pcap。 +- pcap 可能包含凭据、cookie、token、payload、内网地址、主机名或个人信息;对外分享前必须提醒脱敏。 +- 网络证据要写入 `AirPlan/docs/network/airndb-log.md`,pcap/摘要/JSON 报告写入 `AirPlan/docs/network/airndb-captures/`。 +- 根据项目变化维护 `AirPlan/AGENTS.md`、ADR 和 C4 module。 +- 如果需要 WinDump/tcpdump 选项和 BPF 简表,读取 [references/windump-tcpdump-notes.md](references/windump-tcpdump-notes.md)。 + +## 启动与初始化 + +进入 `/airndb` 时运行: + +```bash +python ../../scripts/airndb_mode.py --mode enter --project . +``` + +如果当前环境没有 `python`,尝试 `py`、`python3` 或用户提供的 Python 绝对路径。脚本不可用时,手动确保以下结构存在: + +- `AirPlan/AGENTS.md` +- `AirPlan/docs/architecture/adr/` +- `AirPlan/docs/architecture/c4/module.md` +- `AirPlan/docs/network/airndb-log.md` +- `AirPlan/docs/network/airndb-captures/` +- `AirPlan/state/airndb/state.json` + +初始化后读取已有内容作为上下文。不要覆盖用户已有正文;只补齐缺失结构或更新 AirNDB 标记块。 + +## 首次工具配置 + +`airndb_mode.py --mode enter` 会执行工具自检: + +1. 查找显式配置、项目 `AirPlan/state/airndb/tool.env`、环境变量 `AIRNDB_TCPDUMP`、项目 `AirPlan/state/airndb/tools/WinDump.exe`、PATH 中的 `windump` / `WinDump.exe` / `tcpdump`。 +2. 如果找到可用工具,写入或刷新 `AirPlan/state/airndb/tool.env`,后续 `airndb_capture.py` 自动读取。 +3. 如果 Windows 上找不到工具,自动从 WinDump 官方下载地址获取 `WinDump.exe`,校验 SHA1 `d59bc54721951dec855cbb4bbc000f9a71ea4d95`,保存到 `AirPlan/state/airndb/tools/WinDump.exe`,然后写入 `AirPlan/state/airndb/tool.env`。 +4. 如果下载失败或校验失败,停止并提示用户手动安装 `tcpdump` / `WinDump.exe` 或设置 `AIRNDB_TCPDUMP`。 + +`AirPlan/state/airndb/tool.env` 是本机路径配置,由 `AirPlan/state/airndb/.gitignore` 忽略,不应提交。 + +注意:WinDump 仍需要抓包驱动。官方 WinDump 安装页要求先安装 WinPcap 3.1 或更新版本;WinPcap 主页提示项目已停止维护并建议 Windows 10 用户使用 Npcap。AirNDB 不静默安装驱动,只负责检测、下载 WinDump.exe 和配置本机路径。 + +## 远程设备工具配置 + +当用户说明目标流量发生在远程设备、测试机、服务器、VM、容器宿主机、SSH 主机,或本机抓包看不到目标流量时,不要先使用本机 `airndb_capture.py`。先运行远程设备 helper: + +```bash +python ../../scripts/airndb_remote_device.py --project . --action setup +``` + +如果当前环境没有 `python`,尝试 `py`、`python3` 或用户提供的 Python 绝对路径。首次运行会生成 `AirPlan/state/airndb/remote-device.env.example`;把连接信息写入 `AirPlan/state/airndb/remote-device.env` 或当前环境变量: + +- `AIRNDB_REMOTE_SSH_TARGET=user@host` +- `AIRNDB_REMOTE_SSH_PORT=22` +- `AIRNDB_REMOTE_SSH_OPTIONS=` +- `AIRNDB_REMOTE_WORKDIR=` +- `AIRNDB_REMOTE_TCPDUMP=auto` +- `AIRNDB_REMOTE_CAPTURE_PREFIX=sudo -n` + +远程 helper 行为: + +- 检查本机 `ssh`、远程连通性和远程工作目录。 +- 探测 `tcpdump`、`dumpcap`、`windump`、`WinDump.exe`。 +- 工具缺失时自动尝试用远端包管理器安装 `tcpdump`,只使用非交互式 `sudo -n`;需要密码、管理员确认或无支持的包管理器时停止并提示用户。 +- 将可复用配置写入 `AirPlan/state/airndb/remote-device.env`,该文件由 `AirPlan/state/airndb/.gitignore` 忽略。 +- 抓包产物拉回 `AirPlan/docs/network/airndb-captures/`,并追加 `AirPlan/docs/network/airndb-log.md`。 + +常用远程命令: + +```bash +python ../../scripts/airndb_remote_device.py --project . --action interfaces +python ../../scripts/airndb_remote_device.py --project . --action command --iface --filter "" --count 200 +python ../../scripts/airndb_remote_device.py --project . --action capture --iface --filter "" --count 200 --timeout 30 +``` + +远程抓包仍必须有明确授权、接口、BPF、包数或超时上限。`AIRNDB_REMOTE_CAPTURE_PREFIX` 默认是 `sudo -n`;如果远端已配置免 sudo 的 capture capability,可改为空或指定更合适的前缀。 + +## 工作流 + +1. 明确网络问题: + - 现象:连不上、超时、重置、DNS 异常、TLS 握手失败、丢包、延迟、端口不可达、代理/防火墙疑似问题。 + - 目标:源/目的 host、端口、协议、服务名、容器/VM/WSL/宿主机边界。 + - 抓包窗口:包数、超时、复现步骤和是否允许保存 payload。 +2. 发现接口: + - 本机调试运行 `airndb_capture.py --action interfaces`。 + - 远程调试运行 `airndb_remote_device.py --action interfaces`。 + - Windows 优先使用 `windump -D` 或 `WinDump.exe -D`;Linux/macOS 优先 `tcpdump -D`。 +3. 设计过滤器: + - 使用最窄可行 BPF:`host`、`src host`、`dst host`、`port`、`tcp`、`udp`、`icmp`、`net`。 + - 不确定时先短时宽过滤,再根据结果收窄。 +4. 执行有界抓包: + - 使用 `airndb_capture.py --action capture --iface --filter "" --count --timeout `。 + - 产物写入 `AirPlan/docs/network/airndb-captures/`。 +5. 读取和分析: + - 使用 `airndb_capture.py --action read --read-file --filter ""` 生成文本摘要。 + - 结合时间线、TCP flags、重传、RST、DNS 响应、ICMP、TLS ClientHello/ServerHello 迹象判断网络层事实。 +6. 记录证据: + - exact command + - interface + - BPF filter + - packet count or timeout + - pcap path + - summary/report path + - 观察结论、限制和剩余风险 + +## 与 AirDbg 协作 + +- AirNDB 负责抓包、pcap 摘要、网络层证据和过滤器。 +- AirDbg 负责代码层根因分析、修复和验证收尾。 +- AirDbg 调试中遇到 DNS、TCP、UDP、TLS、HTTP 连接、端口、代理、防火墙、丢包、重传或 pcap 证据需求时,可以调用 AirNDB。 +- AirNDB 收集到的证据必须能被 AirDbg 直接引用:命令、pcap 路径、摘要、关键包、时间线和结论要写清楚。 + +## 常用命令 + +列接口: + +```bash +python ../../scripts/airndb_capture.py --project . --action interfaces +``` + +检查或初始化工具路径: + +```bash +python ../../scripts/airndb_mode.py --project . --mode enter +``` + +只生成命令: + +```bash +python ../../scripts/airndb_capture.py --project . --action command --iface 1 --filter "tcp and port 443" --count 200 +``` + +短时抓包: + +```bash +python ../../scripts/airndb_capture.py --project . --action capture --iface 1 --filter "tcp and port 443" --count 200 --timeout 30 +``` + +读取 pcap: + +```bash +python ../../scripts/airndb_capture.py --project . --action read --read-file AirPlan/docs/network/airndb-captures/example.pcap +``` + +## AGENTS.md 维护 + +在以下情况更新 `AGENTS.md`: + +- 发现稳定可复用的 tcpdump/WinDump 命令、接口选择规则、BPF 过滤器或 pcap 读取方式。 +- 发现影响后续 AI 调试的网络边界:容器、WSL、VM、代理、防火墙、VPN、DNS、TLS、NAT、端口映射。 +- 发现抓包权限、驱动、管理员权限或平台差异。 +- 发现本机 tcpdump/WinDump 路径或 `AirPlan/state/airndb/tool.env` 配置方式。 +- 发现远程设备 SSH 入口、远程抓包工具、`AIRNDB_REMOTE_*` 配置方式或远端抓包权限限制。 + +## ADR 维护 + +目录:`docs/architecture/adr/`。 + +需要 ADR 的情况: + +- 长期采用 tcpdump/WinDump 作为项目网络诊断方式。 +- 抓包流程改变了测试边界、网络观测边界、运行权限、数据留存或安全策略。 +- 发现需要保留的网络架构决策,例如代理、DNS、TLS、端口、服务发现或跨容器/宿主机边界。 + +ADR 保持简洁:Context、Decision、Consequences、Alternatives。 + +## C4 Module 维护 + +文件:`docs/architecture/c4/module.md`。 + +当网络调试发现或改变以下内容时,必须更新: + +- 模块间网络依赖。 +- 服务端口、协议、DNS、代理、TLS、队列、网关、容器/宿主机/WSL/VM 边界。 +- 抓包或观测基础设施成为长期模块或运行边界。 +- 网络错误处理、重试、超时、连接池或安全边界。 + +## network log 维护 + +文件:`AirPlan/docs/network/airndb-log.md`。 + +每次 AirNDB 会话至少追加: + +- 问题摘要。 +- 授权范围和目标流量。 +- 接口、BPF、抓包窗口。 +- 是否使用远程设备 helper 以及远程目标、抓包工具和权限限制。 +- pcap/summary/report 路径。 +- 关键包或时间线观察。 +- 结论、限制和给 AirDbg 的线索。 +- ADR/C4/AGENTS 更新。 + +## 完成输出 + +本轮网络调试结束时,用中文简洁汇报: + +- 使用了哪个接口和过滤器。 +- 抓包是否成功,证据在哪里。 +- 关键观察和网络层结论。 +- 更新了哪些 `AGENTS.md` / ADR / C4 / network log。 +- 是否需要切给 AirDbg 做代码层修复。 diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/skills/airndb/agents/openai.yaml b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/skills/airndb/agents/openai.yaml new file mode 100755 index 0000000..a14e827 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/skills/airndb/agents/openai.yaml @@ -0,0 +1,3 @@ +name: airndb +short_description: Local and remote packet capture workflow with tcpdump and WinDump +default_prompt: "使用 AirNDB 做安全有界抓包;远程设备优先调用 remote device helper 并自动配置 tcpdump。" diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/skills/airndb/references/windump-tcpdump-notes.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/skills/airndb/references/windump-tcpdump-notes.md new file mode 100755 index 0000000..ba8b51b --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airndb/skills/airndb/references/windump-tcpdump-notes.md @@ -0,0 +1,83 @@ +# WinDump / Tcpdump Notes + +Source: https://www.winpcap.org/windump/docs/manual.htm + +## AirNDB Summary + +- WinDump follows tcpdump-style packet capture usage on Windows. +- `-D` lists available capture interfaces. +- `-i ` selects the capture interface. On Windows this is often the interface number from `-D`. +- `-c ` stops after a bounded number of packets. +- `-w ` writes raw packets to a pcap file. +- `-r ` reads packets back from a pcap file. +- `-n` avoids host name resolution; `-nn` also avoids service name resolution. +- `-s ` controls packet snapshot length. AirNDB uses `-s 0` for pcap captures so packets are not truncated. +- Filter expressions use BPF primitives such as `host`, `net`, `port`, `src`, `dst`, `tcp`, `udp`, `icmp`, `arp`, `and`, `or`, and `not`. + +## Windows Notes + +- Prefer `WinDump.exe` or `windump` when `tcpdump` is unavailable on Windows. +- WinDump normally requires a packet capture driver such as WinPcap/Npcap and may require an elevated terminal. +- Interface names can be long adapter paths; the numeric index from `windump -D` is usually easier to use. +- Store pcap artifacts in a project-local ignored directory such as `docs/network/airndb-captures/`. + +## AirNDB Auto Setup + +- On `/airndb enter`, AirNDB checks for `tcpdump`, `windump`, or `WinDump.exe`. +- If no capture tool is available on Windows, AirNDB downloads the official `WinDump.exe` linked from the WinDump install page: + +```text +https://www.winpcap.org/windump/install/bin/windump_3_9_5/WinDump.exe +``` + +- AirNDB verifies SHA1 before using the file: + +```text +d59bc54721951dec855cbb4bbc000f9a71ea4d95 +``` + +- AirNDB stores the binary at `AirPlan/state/airndb/tools/WinDump.exe` and writes `AirPlan/state/airndb/tool.env`: + +```text +AIRNDB_TCPDUMP= +``` + +- AirNDB does not silently install WinPcap/Npcap drivers. If `WinDump.exe -D` fails after download, tell the user to install Npcap or WinPcap and retry from an elevated terminal. + +## Safe Defaults + +- Start with interface discovery before capture: + +```bash +windump -D +tcpdump -D +``` + +- Prefer short, bounded capture: + +```bash +tcpdump -i -nn -s 0 -w .pcap -c 200 '' +``` + +- Read back a pcap summary: + +```bash +tcpdump -nn -r .pcap '' +``` + +## BPF Examples + +```text +host 192.0.2.10 +tcp and port 443 +udp and port 53 +src host 192.0.2.10 and dst port 443 +net 10.0.0.0/8 and not port 22 +icmp or icmp6 +``` + +## Evidence Rules + +- Record exact command, interface, filter, packet count, capture window, pcap path, and summary path. +- Keep pcap files private unless reviewed; they can contain tokens, cookies, payload, internal hostnames, and addresses. +- If application payload is encrypted, use packet timing, DNS, TCP/TLS handshakes, retransmissions, resets, or connection failures as evidence instead of expecting plaintext. diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/.codex-plugin/plugin.json b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/.codex-plugin/plugin.json new file mode 100755 index 0000000..2a99115 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/.codex-plugin/plugin.json @@ -0,0 +1,46 @@ +{ + "name": "airsdb", + "version": "0.1.1", + "description": "Cppcheck static-analysis workflow for local and remote C/C++ code quality, security-relevant findings, exhaustive branch-analysis detail, AirDbg or AirDo handoff reports, and staticanalysis.md maintenance.", + "author": { + "name": "14816", + "email": "noreply@example.com", + "url": "https://airlongdian.fun/plugins/airsdb" + }, + "homepage": "https://airlongdian.fun/plugins/airsdb", + "repository": "https://airlongdian.fun/plugins/airsdb", + "license": "MIT", + "keywords": [ + "airsdb", + "cppcheck", + "static-analysis", + "c", + "cpp", + "code-quality", + "security", + "airdbg", + "airdo" + ], + "skills": "./skills/", + "interface": { + "displayName": "AirSDB", + "shortDescription": "Cppcheck static analysis with exhaustive local and remote reports", + "longDescription": "AirSDB helps Codex run cppcheck static analysis for C or C++ projects, detect or auto-configure cppcheck on first startup, run local scans using compile_commands.json when available, default local and remote scans to --check-level=exhaustive for maximum branch-analysis detail, run remote scans over SSH with remote cppcheck auto-configuration when possible, write detailed XML or JSON artifacts under AirPlan/state/airsdb/reports, maintain concise AirPlan/docs/staticanalysis.md reports for AI context, and hand static-analysis evidence to AirDbg and AirDo.", + "developerName": "14816", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Write" + ], + "websiteURL": "https://airlongdian.fun/plugins/airsdb", + "privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/", + "termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/", + "defaultPrompt": [ + "Use AirSDB to run cppcheck and capture detailed static-analysis evidence for a local C or C++ codebase.", + "Use AirSDB to prepare or run a remote cppcheck scan over SSH and bring the findings back into the project context.", + "Use AirSDB to provide static-analysis evidence to AirDbg or AirDo before we change C or C++ code." + ], + "brandColor": "#0F766E", + "screenshots": [] + } +} diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/commands/airsdb.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/commands/airsdb.md new file mode 100755 index 0000000..e97b657 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/commands/airsdb.md @@ -0,0 +1,77 @@ +--- +description: Enter, inspect, run, or remote-run AirSDB cppcheck static analysis with exhaustive branch checking +argument-hint: [enter|setup|status|scan|command|remote-setup|remote-status|remote-command|remote-scan|exit] +allowed-tools: [Read, Glob, Grep, Bash, Write, Edit] +--- + +# /airsdb + +控制当前工作区的 AirSDB 静态分析模式。 + +用户传入参数:`$ARGUMENTS` + +- `enter` 或空参数:进入 AirSDB 模式,初始化 `AirPlan/docs/staticanalysis.md` 并检测 cppcheck。 +- `setup`:检测/自动配置本机 cppcheck。 +- `status`:检查 AirSDB 状态和本机 cppcheck,不自动安装。 +- `command`:生成本机 cppcheck 命令,不实际执行。 +- `scan`:运行本机 cppcheck,生成 XML/JSON 并更新 `AirPlan/docs/staticanalysis.md`。 +- `remote-setup`:检查远程 SSH 和远端 cppcheck;缺失时自动尝试配置。 +- `remote-status`:只检查远程配置和工具状态,不自动安装。 +- `remote-command`:生成远程 cppcheck 命令,不实际执行。 +- `remote-scan`:通过 SSH 在远端运行 cppcheck,拉回报告并更新 `AirPlan/docs/staticanalysis.md`。 +- `exit`:退出 AirSDB 模式。 + +## 执行步骤 + +1. 解析 `$ARGUMENTS`,默认动作为 `enter`。 +2. `enter` / `setup` / `status` / `exit` 时运行: + +```bash +python "$HOME/plugins/airsdb/scripts/airsdb_mode.py" --mode --project . +``` + +如果 `python` 不存在,尝试 `py`、`python3` 或用户提供的 Python 绝对路径。 + +3. 本机 `command` / `scan` 时运行: + +```bash +python "$HOME/plugins/airsdb/scripts/airsdb_cppcheck.py" --project . --action +``` + +本机 scan 会优先使用 `compile_commands.json`,否则扫描当前项目并排除常见噪声目录;默认带 `--check-level=exhaustive` 以提供尽可能详细的分支分析信息。结果写入 `AirPlan/state/airsdb/reports/`,摘要写入 `AirPlan/docs/staticanalysis.md`。 + +4. 如果 `$ARGUMENTS` 包含 `remote`、`ssh`、`远程`,或用户说明目标代码在远程设备、测试机、服务器、VM、容器宿主机或 SSH 主机上,优先运行远程 helper: + +```bash +python "$HOME/plugins/airsdb/scripts/airsdb_remote_device.py" --project . --action +``` + +首次缺少远程配置时,helper 会生成 `AirPlan/state/airsdb/remote-device.env.example` 并提示设置 `AIRSDB_REMOTE_SSH_TARGET` 和 `AIRSDB_REMOTE_PROJECT`。有 SSH 目标后,helper 会探测远端 cppcheck;缺失时自动尝试安装,只使用非交互式 `sudo -n`,无法自动配置时停止并提示用户。远程 scan 同样默认带 `--check-level=exhaustive`。 + +5. 每次 scan 后必须检查并维护: + - `AirPlan/docs/staticanalysis.md` +- `AirPlan/state/airsdb/reports/-cppcheck.xml` +- `AirPlan/state/airsdb/reports/-cppcheck.json` + - 必要时更新 `AGENTS.md`、ADR、C4 module + +## 输出文案 + +进入模式: + +```text +AirSDB 模式已开启:已初始化或检查 `AirPlan/docs/staticanalysis.md`,并检测 cppcheck。可运行本机 scan,也可在远程目标上使用 remote device helper 自动检测/配置 cppcheck;本机和远程 scan 默认使用 `--check-level=exhaustive`。 +``` + +扫描完成: + +```text +AirSDB 静态分析完成:XML/JSON 报告已写入 `AirPlan/state/airsdb/reports/`,简短 AI 上下文已追加到 `AirPlan/docs/staticanalysis.md`,可交给 AirDbg 或 AirDo 使用。 +``` + +状态检查: + +```text +AirSDB 状态: +cppcheck: +关键文件:逐项列出 ok/missing。 +``` diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/scripts/airsdb_cppcheck.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/scripts/airsdb_cppcheck.py new file mode 100755 index 0000000..e40a21a --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/scripts/airsdb_cppcheck.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""Run local cppcheck scans and maintain AirSDB reports.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import subprocess +import sys +import xml.etree.ElementTree as ET +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from airsdb_mode import ensure_files, read_env, setup_cppcheck, stamp + +DEFAULT_ENABLE = "warning,style,performance,portability,information" +DEFAULT_CHECK_LEVEL = "exhaustive" +DEFAULT_EXCLUDES = [ + ".git", + ".airsdb", + "node_modules", + "build", + "cmake-build-debug", + "cmake-build-release", + "dist", + "out", + "vendor", + "third_party", + "external", +] + + +def iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def rel(project: Path, path: str) -> str: + if not path: + return "" + try: + return str(Path(path).resolve().relative_to(project)) + except Exception: + return path + + +def find_project_file(project: Path, configured: str = "") -> Optional[Path]: + candidates = [] + if configured: + candidates.append(Path(configured)) + candidates.extend( + [ + project / "compile_commands.json", + project / "build" / "compile_commands.json", + project / "cmake-build-debug" / "compile_commands.json", + project / "cmake-build-release" / "compile_commands.json", + ] + ) + for candidate in candidates: + p = candidate if candidate.is_absolute() else project / candidate + if p.exists(): + return p.resolve() + return None + + +def build_args( + project: Path, + tool: str, + project_file: str = "", + target: str = "", + enable: str = DEFAULT_ENABLE, + check_level: str = DEFAULT_CHECK_LEVEL, + jobs: int = 1, + std: str = "", + extra: Optional[List[str]] = None, +) -> List[str]: + build_dir = project / "AirPlan" / "state" / "airsdb" / "cppcheck-build" + build_dir.mkdir(parents=True, exist_ok=True) + args = [ + tool, + f"--enable={enable}", + f"--check-level={check_level}", + "--inconclusive", + "--inline-suppr", + "--quiet", + "--xml", + "--xml-version=2", + f"--cppcheck-build-dir={build_dir}", + ] + if jobs > 1: + args.append(f"-j{jobs}") + if std: + args.append(f"--std={std}") + for item in extra or []: + if item: + args.extend(shlex.split(item, posix=(os.name != "nt"))) + + pf = find_project_file(project, project_file) + if pf: + args.append(f"--project={pf}") + else: + for ignored in DEFAULT_EXCLUDES: + args.append(f"-i{ignored}") + args.append(target or ".") + return args + + +def command_text(args: List[str]) -> str: + if os.name == "nt": + return subprocess.list2cmdline(args) + return shlex.join(args) + + +def extract_xml(text: str) -> str: + start = text.find("= 0: + return text[start:] + start = text.find("= 0: + return text[start:] + return text + + +def parse_cppcheck_xml(xml_text: str, project: Path) -> Dict[str, Any]: + xml_text = extract_xml(xml_text) + findings: List[Dict[str, Any]] = [] + counts: Counter[str] = Counter() + if not xml_text.strip(): + return {"counts": {}, "findings": [], "parseError": "empty_xml"} + try: + root = ET.fromstring(xml_text) + except ET.ParseError as exc: + return {"counts": {}, "findings": [], "parseError": str(exc)} + for error in root.findall(".//error"): + severity = error.attrib.get("severity", "unknown") + counts[severity] += 1 + locations = [] + for loc in error.findall("location"): + locations.append( + { + "file": rel(project, loc.attrib.get("file", "")), + "line": loc.attrib.get("line", ""), + "info": loc.attrib.get("info", ""), + } + ) + findings.append( + { + "severity": severity, + "id": error.attrib.get("id", ""), + "msg": error.attrib.get("msg", ""), + "cwe": error.attrib.get("cwe", ""), + "locations": locations, + } + ) + return {"counts": dict(counts), "findings": findings, "parseError": ""} + + +def top_findings(findings: List[Dict[str, Any]], limit: int = 12) -> List[str]: + rank = {"error": 0, "warning": 1, "performance": 2, "portability": 3, "style": 4, "information": 5} + + def key(item: Dict[str, Any]) -> tuple[int, str]: + return (rank.get(item.get("severity", ""), 9), item.get("id", "")) + + lines = [] + for item in sorted(findings, key=key)[:limit]: + loc = item.get("locations") or [{}] + first = loc[0] + where = first.get("file", "") + if first.get("line"): + where += f":{first['line']}" + cwe = f" CWE-{item['cwe']}" if item.get("cwe") else "" + lines.append(f"[{item.get('severity')}:{item.get('id')}{cwe}] {where} {item.get('msg')}".strip()) + return lines + + +def append_staticanalysis(project: Path, data: Dict[str, Any]) -> None: + ensure_files(project) + path = project / "AirPlan" / "docs" / "staticanalysis.md" + counts = data.get("counts") or {} + counts_text = ", ".join(f"{k}={v}" for k, v in sorted(counts.items())) or "none" + tops = data.get("topFindings") or [] + top_text = "\n".join(f" - {line}" for line in tops) if tops else " - none" + result = "failed" if data.get("returnCode", 0) not in [0, None] else ("findings" if data.get("findingCount", 0) else "ok") + entry = ( + f"\n### {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: AirSDB cppcheck\n\n" + f"- Target: {data.get('target', 'local')}\n" + f"- Tool: `{data.get('tool', '')}` {data.get('version', '')}\n" + f"- Command: `{data.get('command', '')}`\n" + f"- Result: {result}; returnCode={data.get('returnCode', '')}\n" + f"- Counts: {counts_text}\n" + f"- Reports: `{data.get('xmlReport', '')}`, `{data.get('jsonReport', '')}`\n" + f"- Top findings:\n{top_text}\n" + f"- AirDbg/AirDo handoff: {data.get('handoff', 'Review top findings before code-level repair or todo validation.')}\n" + f"- Residual risk: cppcheck is static analysis and may miss runtime, integration, configuration, or dependency issues.\n" + ) + path.open("a", encoding="utf-8", newline="\n").write(entry) + + +def write_reports(project: Path, data: Dict[str, Any], xml_text: str, prefix: str = "cppcheck") -> Dict[str, Any]: + reports = project / "AirPlan" / "state" / "airsdb" / "reports" + reports.mkdir(parents=True, exist_ok=True) + ts = stamp() + xml_path = reports / f"{ts}-{prefix}.xml" + json_path = reports / f"{ts}-{prefix}.json" + xml_path.write_text(xml_text, encoding="utf-8", newline="\n") + data["xmlReport"] = str(xml_path) + data["jsonReport"] = str(json_path) + json_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n") + return data + + +def run_scan(args: argparse.Namespace) -> int: + project = Path(args.project).expanduser().resolve() + ensure_files(project) + setup = setup_cppcheck(project, auto=not args.no_auto_configure) + if setup.get("status") != "ok": + print(f"airsdb_scan_status=blocked") + for key, value in setup.items(): + print(f"{key}={value}") + return 2 + tool = setup["tool"] + env = read_env(project / "AirPlan" / "state" / "airsdb" / "tool.env") + extra = list(args.extra or []) + if env.get("AIRSDB_CPPCHECK_OPTIONS"): + extra.append(env["AIRSDB_CPPCHECK_OPTIONS"]) + cmd = build_args(project, tool, args.project_file, args.target, args.enable, args.check_level, args.jobs, args.std, extra) + if args.action == "command": + print("airsdb_command_status=ok") + print(f"command={command_text(cmd)}") + return 0 + try: + run = subprocess.run(cmd, cwd=project, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=args.timeout) + except subprocess.TimeoutExpired as exc: + data = { + "target": "local", + "status": "timeout", + "tool": tool, + "version": setup.get("version", ""), + "checkLevel": args.check_level, + "command": command_text(cmd), + "returnCode": "timeout", + "stdout": exc.stdout or "", + "stderr": exc.stderr or "", + "capturedAt": iso(), + "counts": {}, + "findings": [], + "findingCount": 0, + "topFindings": [], + } + data = write_reports(project, data, str(exc.stderr or ""), "cppcheck-timeout") + append_staticanalysis(project, data) + print("airsdb_scan_status=timeout") + print(f"jsonReport={data['jsonReport']}") + return 1 + + xml_text = extract_xml(run.stderr or "") + parsed = parse_cppcheck_xml(xml_text, project) + findings = parsed.get("findings", []) + data = { + "target": "local", + "status": "ok" if run.returncode == 0 else "failed", + "tool": tool, + "version": setup.get("version", ""), + "checkLevel": args.check_level, + "command": command_text(cmd), + "returnCode": run.returncode, + "stdout": run.stdout.strip(), + "stderrPreview": (run.stderr or "")[:2000], + "capturedAt": iso(), + "counts": parsed.get("counts", {}), + "findings": findings, + "findingCount": len(findings), + "topFindings": top_findings(findings, args.limit), + "parseError": parsed.get("parseError", ""), + } + data = write_reports(project, data, xml_text, "cppcheck") + append_staticanalysis(project, data) + print(f"airsdb_scan_status={data['status']}") + print(f"findingCount={data['findingCount']}") + print(f"xmlReport={data['xmlReport']}") + print(f"jsonReport={data['jsonReport']}") + return 0 if run.returncode == 0 else 1 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run local AirSDB cppcheck analysis.") + parser.add_argument("--project", default=".") + parser.add_argument("--action", choices=["setup", "status", "command", "scan"], default="scan") + parser.add_argument("--project-file", default="") + parser.add_argument("--target", default="") + parser.add_argument("--enable", default=DEFAULT_ENABLE) + parser.add_argument("--check-level", default=DEFAULT_CHECK_LEVEL) + parser.add_argument("--std", default="") + parser.add_argument("--jobs", type=int, default=1) + parser.add_argument("--timeout", type=int, default=900) + parser.add_argument("--limit", type=int, default=12) + parser.add_argument("--extra", action="append", default=[]) + parser.add_argument("--no-auto-configure", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + project = Path(args.project).expanduser().resolve() + if args.action in ["setup", "status"]: + ensure_files(project) + setup = setup_cppcheck(project, auto=(args.action == "setup" and not args.no_auto_configure)) + print(f"airsdb_cppcheck_status={setup.get('status', 'unknown')}") + for key, value in setup.items(): + if key != "status": + print(f"{key}={value}") + raise SystemExit(0 if setup.get("status") == "ok" else 2) + raise SystemExit(run_scan(args)) + + +if __name__ == "__main__": + main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/scripts/airsdb_mode.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/scripts/airsdb_mode.py new file mode 100755 index 0000000..d5c4842 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/scripts/airsdb_mode.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Bootstrap AirSDB static-analysis context and cppcheck tooling.""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import shutil +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, Optional, Tuple + +MARKER_BEGIN = "" +MARKER_END = "" + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def stamp() -> str: + return datetime.now().strftime("%Y%m%d-%H%M%S") + + +def read_env(path: Path) -> Dict[str, str]: + vals: Dict[str, str] = {} + if path.exists(): + for raw in path.read_text(encoding="utf-8-sig").splitlines(): + line = raw.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + vals[key.strip()] = value.strip().strip('"').strip("'") + for key in ["AIRSDB_CPPCHECK", "AIRSDB_CPPCHECK_OPTIONS"]: + if os.environ.get(key): + vals[key] = os.environ[key] + return vals + + +def write_env(path: Path, updates: Dict[str, str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + old = path.read_text(encoding="utf-8") if path.exists() else "" + keys = set(updates) + lines = [] + for raw in old.splitlines(): + key = raw.split("=", 1)[0].strip() if "=" in raw and not raw.strip().startswith("#") else None + if key not in keys: + lines.append(raw) + if lines and lines[-1].strip(): + lines.append("") + for key, value in updates.items(): + if value: + lines.append(f"{key}={value}") + path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8", newline="\n") + + +def ensure_files(project: Path) -> None: + air = project / "AirPlan" / "state" / "airsdb" + air.mkdir(parents=True, exist_ok=True) + example = air / "tool.env.example" + if not example.exists(): + example.write_text( + "# AirSDB local cppcheck configuration\n" + "AIRSDB_CPPCHECK=cppcheck\n" + "AIRSDB_CPPCHECK_OPTIONS=\n" + "# AirSDB defaults to --check-level=exhaustive for maximum branch analysis detail.\n", + encoding="utf-8", + newline="\n", + ) + gitignore = air / ".gitignore" + old = gitignore.read_text(encoding="utf-8") if gitignore.exists() else "" + for item in ["tool.env", "remote-device.env", "reports/", "cppcheck-build/"]: + if item not in old.splitlines(): + old = (old.rstrip() + f"\n{item}\n").lstrip() + gitignore.write_text(old, encoding="utf-8", newline="\n") + + static = project / "AirPlan" / "docs" / "staticanalysis.md" + if not static.exists(): + static.write_text( + "# Static Analysis\n\n" + "Short AI-context reports for AirSDB cppcheck runs. Keep entries concise.\n\n" + "## Entry Template\n\n" + "### YYYY-MM-DD HH:MM:SS: AirSDB cppcheck\n\n" + "- Target: TODO\n" + "- Tool: TODO\n" + "- Command: TODO\n" + "- Result: TODO\n" + "- Counts: TODO\n" + "- Reports: TODO\n" + "- Top findings: TODO\n" + "- AirDbg/AirDo handoff: TODO\n" + "- Residual risk: TODO\n", + encoding="utf-8", + newline="\n", + ) + + +def airsdb_agents_block() -> str: + return f"""{MARKER_BEGIN} +## AirSDB Static Analysis Workflow + +1. Use AirSDB for `/airsdb` sessions that run cppcheck static analysis for C/C++ code quality, security-relevant defects, and debugging evidence. +2. On first entry, detect cppcheck; if missing, auto-configure it with the best available package manager or stop with install instructions. +3. Maintain `AirPlan/docs/staticanalysis.md` as the short AI-context report for AirDbg and AirDo. Keep detailed XML/JSON artifacts under `AirPlan/state/airsdb/reports/`. +4. Prefer `compile_commands.json` when available; otherwise scan the narrowest useful source tree with common generated/vendor directories excluded. +5. Default local and remote cppcheck scans to `--check-level=exhaustive` so AirSDB provides the most detailed branch-analysis evidence it can. +6. For local analysis, use `airsdb_cppcheck.py`; for remote targets over SSH, use `airsdb_remote_device.py` before local analysis. +7. When remote cppcheck is missing, the remote helper may auto-configure it with non-interactive package-manager commands; if sudo/password or unsupported OS blocks it, stop and record the blocker. +8. Record command, target, report paths, counts, top findings, AirDbg/AirDo handoff, and residual risk in `AirPlan/docs/staticanalysis.md`. +9. Update ADR/C4 only when static analysis tooling becomes a durable project boundary or changes implementation decisions. +{MARKER_END} +""" + + +def upsert_agents_md(path: Path) -> str: + block = airsdb_agents_block().rstrip() + "\n" + if path.exists(): + original = path.read_text(encoding="utf-8") + existed = True + else: + original = "# AGENTS.md\n\n" + existed = False + begin = original.find(MARKER_BEGIN) + end = original.find(MARKER_END) + if begin >= 0 and end > begin: + end += len(MARKER_END) + updated = original[:begin].rstrip() + "\n\n" + block + original[end:].lstrip() + status = "updated" + else: + updated = original.rstrip() + "\n\n" + block + status = "updated" if existed else "created" + path.write_text(updated, encoding="utf-8", newline="\n") + return status + + +def run_args(args: list[str], timeout: int = 600) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout) + + +def run_shell(cmd: str, timeout: int = 600) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, shell=True, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout) + + +def cppcheck_version(tool: str) -> str: + try: + result = run_args([tool, "--version"], timeout=20) + except Exception: + return "" + text = (result.stdout or result.stderr).strip() + return text.splitlines()[0] if text else "" + + +def detect_cppcheck(project: Path) -> Tuple[Optional[str], str]: + env = read_env(project / "AirPlan" / "state" / "airsdb" / "tool.env") + candidates = [] + configured = env.get("AIRSDB_CPPCHECK", "").strip() + if configured: + candidates.append(configured) + candidates.append("cppcheck") + if platform.system().lower() == "windows": + candidates.extend( + [ + r"C:\Program Files\Cppcheck\cppcheck.exe", + r"C:\Program Files (x86)\Cppcheck\cppcheck.exe", + ] + ) + for candidate in candidates: + path = shutil.which(candidate) or candidate + if Path(path).exists() or shutil.which(path): + version = cppcheck_version(path) + if version: + return path, version + return None, "" + + +def install_cppcheck(project: Path) -> Dict[str, str]: + system = platform.system().lower() + attempts = [] + if system == "windows": + package_commands = [ + ["winget", "install", "--id", "Cppcheck.Cppcheck", "-e", "--accept-source-agreements", "--accept-package-agreements", "--silent"], + ["choco", "install", "cppcheck", "-y"], + ["scoop", "install", "cppcheck"], + ] + for cmd in package_commands: + exe = shutil.which(cmd[0]) + if not exe: + continue + run = run_args([exe] + cmd[1:], timeout=1200) + attempts.append({"command": " ".join(cmd), "returncode": str(run.returncode)}) + tool, version = detect_cppcheck(project) + if tool: + write_env(project / "AirPlan" / "state" / "airsdb" / "tool.env", {"AIRSDB_CPPCHECK": tool}) + return {"status": "ok", "tool": tool, "version": version, "installer": cmd[0], "attempts": json.dumps(attempts)} + return { + "status": "blocked", + "reason": "cppcheck_not_found", + "hint": "Install Cppcheck from https://cppcheck.sourceforge.io/ or configure AIRSDB_CPPCHECK.", + "attempts": json.dumps(attempts), + } + + commands = [ + "if command -v apt-get >/dev/null 2>&1; then sudo -n apt-get update && sudo -n apt-get install -y cppcheck; " + "elif command -v dnf >/dev/null 2>&1; then sudo -n dnf install -y cppcheck; " + "elif command -v yum >/dev/null 2>&1; then sudo -n yum install -y cppcheck; " + "elif command -v apk >/dev/null 2>&1; then sudo -n apk add cppcheck; " + "elif command -v pacman >/dev/null 2>&1; then sudo -n pacman -Sy --noconfirm cppcheck; " + "elif command -v brew >/dev/null 2>&1; then brew install cppcheck; " + "elif command -v port >/dev/null 2>&1; then sudo -n port install cppcheck; " + "else exit 42; fi" + ] + run = run_shell(commands[0], timeout=1200) + attempts.append({"command": "system-package-manager", "returncode": str(run.returncode)}) + tool, version = detect_cppcheck(project) + if tool: + write_env(project / "AirPlan" / "state" / "airsdb" / "tool.env", {"AIRSDB_CPPCHECK": tool}) + return {"status": "ok", "tool": tool, "version": version, "installer": "system-package-manager", "attempts": json.dumps(attempts)} + return { + "status": "blocked", + "reason": "cppcheck_not_found", + "hint": "Install cppcheck with the OS package manager or configure AIRSDB_CPPCHECK.", + "attempts": json.dumps(attempts), + } + + +def setup_cppcheck(project: Path, auto: bool = True) -> Dict[str, str]: + ensure_files(project) + tool, version = detect_cppcheck(project) + if tool: + write_env(project / "AirPlan" / "state" / "airsdb" / "tool.env", {"AIRSDB_CPPCHECK": tool}) + return {"status": "ok", "tool": tool, "version": version, "autoConfigure": "skipped"} + if not auto: + return {"status": "blocked", "reason": "cppcheck_not_found", "example": str(project / "AirPlan" / "state" / "airsdb" / "tool.env.example")} + return install_cppcheck(project) + + +def artifact_map(project: Path) -> Dict[str, Path]: + return { + "AGENTS.md": project / "AirPlan" / "AGENTS.md", + "staticanalysis": project / "AirPlan" / "docs" / "staticanalysis.md", + "state": project / "AirPlan" / "state" / "airsdb" / "state.json", + "tool_env": project / "AirPlan" / "state" / "airsdb" / "tool.env", + } + + +def write_state(path: Path, enabled: bool, project: Path, tool_info: Dict[str, str]) -> None: + artifacts = artifact_map(project) + payload = { + "enabled": enabled, + "updatedAt": now_iso(), + "projectRoot": str(project), + "cppcheck": tool_info, + "artifactHealth": {name: p.exists() for name, p in artifacts.items() if name != "state"}, + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n") + + +def enter_mode(project: Path, auto: bool = True) -> Tuple[str, Dict[str, str]]: + ensure_files(project) + results = {"AGENTS.md": upsert_agents_md(project / "AirPlan" / "AGENTS.md"), "staticanalysis": "exists"} + tool_info = setup_cppcheck(project, auto=auto) + for key, value in tool_info.items(): + results[f"cppcheck_{key}"] = value + write_state(project / "AirPlan" / "state" / "airsdb" / "state.json", True, project, tool_info) + return "enabled", results + + +def exit_mode(project: Path) -> Tuple[str, Dict[str, str]]: + ensure_files(project) + tool_info = setup_cppcheck(project, auto=False) + write_state(project / "AirPlan" / "state" / "airsdb" / "state.json", False, project, tool_info) + return "disabled", {} + + +def status_mode(project: Path) -> Tuple[str, Dict[str, str]]: + ensure_files(project) + state = project / "AirPlan" / "state" / "airsdb" / "state.json" + enabled = False + if state.exists(): + try: + enabled = bool(json.loads(state.read_text(encoding="utf-8")).get("enabled")) + except json.JSONDecodeError: + enabled = False + tool_info = setup_cppcheck(project, auto=False) + results = {name: ("ok" if path.exists() else "missing") for name, path in artifact_map(project).items() if name != "state"} + for key, value in tool_info.items(): + results[f"cppcheck_{key}"] = value + return ("enabled" if enabled else "disabled"), results + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Manage AirSDB static-analysis artifacts.") + parser.add_argument("--mode", choices=["enter", "setup", "exit", "status"], default="enter") + parser.add_argument("--project", default=".") + parser.add_argument("--no-auto-configure", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + project = Path(args.project).expanduser().resolve() + if args.mode in ["enter", "setup"]: + mode_state, result = enter_mode(project, auto=not args.no_auto_configure) + elif args.mode == "exit": + mode_state, result = exit_mode(project) + else: + mode_state, result = status_mode(project) + print(f"airsdb_mode={mode_state}") + print(f"project_root={project}") + for key, value in result.items(): + print(f"{key}={value}") + + +if __name__ == "__main__": + main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/scripts/airsdb_remote_device.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/scripts/airsdb_remote_device.py new file mode 100755 index 0000000..dd22a63 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/scripts/airsdb_remote_device.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""Run AirSDB cppcheck on a remote SSH target.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import shlex +import shutil +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List + +from airsdb_cppcheck import append_staticanalysis, parse_cppcheck_xml, top_findings, write_reports +from airsdb_mode import ensure_files, stamp + +DEFAULT_ENABLE = "warning,style,performance,portability,information" +DEFAULT_CHECK_LEVEL = "exhaustive" + + +def iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def q(value: str) -> str: + return shlex.quote(str(value)) + + +def split(value: str) -> List[str]: + return shlex.split(value, posix=True) if value.strip() else [] + + +def read_env(path: Path) -> Dict[str, str]: + vals: Dict[str, str] = {} + if path.exists(): + for raw in path.read_text(encoding="utf-8-sig").splitlines(): + line = raw.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + vals[key.strip()] = value.strip().strip('"').strip("'") + for key in [ + "AIRSDB_REMOTE_SSH_TARGET", + "AIRSDB_REMOTE_SSH_PORT", + "AIRSDB_REMOTE_SSH_OPTIONS", + "AIRSDB_REMOTE_WORKDIR", + "AIRSDB_REMOTE_PROJECT", + "AIRSDB_REMOTE_CPPCHECK", + ]: + if os.environ.get(key): + vals[key] = os.environ[key] + vals.setdefault("AIRSDB_REMOTE_SSH_PORT", "22") + vals.setdefault("AIRSDB_REMOTE_CPPCHECK", "auto") + return vals + + +def write_env(path: Path, updates: Dict[str, str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + old = path.read_text(encoding="utf-8") if path.exists() else "" + keys = set(updates) + lines = [] + for raw in old.splitlines(): + key = raw.split("=", 1)[0].strip() if "=" in raw and not raw.strip().startswith("#") else None + if key not in keys: + lines.append(raw) + if lines and lines[-1].strip(): + lines.append("") + for key, value in updates.items(): + if value: + lines.append(f"{key}={value}") + path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8", newline="\n") + + +def ensure_remote_files(project: Path) -> None: + ensure_files(project) + air = project / "AirPlan" / "state" / "airsdb" + example = air / "remote-device.env.example" + if not example.exists(): + example.write_text( + "# AirSDB remote cppcheck configuration\n" + "AIRSDB_REMOTE_SSH_TARGET=user@host\n" + "AIRSDB_REMOTE_SSH_PORT=22\n" + "AIRSDB_REMOTE_SSH_OPTIONS=\n" + "AIRSDB_REMOTE_WORKDIR=\n" + "AIRSDB_REMOTE_PROJECT=/path/to/remote/project\n" + "AIRSDB_REMOTE_CPPCHECK=auto\n", + encoding="utf-8", + newline="\n", + ) + + +def ssh_args(env: Dict[str, str], cmd: str) -> List[str]: + target = env.get("AIRSDB_REMOTE_SSH_TARGET", "") + if not target: + raise SystemExit("AIRSDB_REMOTE_SSH_TARGET is required") + args = [shutil.which("ssh") or "ssh"] + port = env.get("AIRSDB_REMOTE_SSH_PORT", "22") + if port: + args += ["-p", port] + args += split(env.get("AIRSDB_REMOTE_SSH_OPTIONS", "")) + return args + [target, cmd] + + +def run(env: Dict[str, str], cmd: str, timeout: int = 60) -> subprocess.CompletedProcess[str]: + return subprocess.run(ssh_args(env, cmd), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout) + + +def remote_home(env: Dict[str, str]) -> str: + result = run(env, 'printf %s "$HOME"', 15) + return result.stdout.strip() if result.returncode == 0 else "" + + +def workdir(env: Dict[str, str]) -> str: + if env.get("AIRSDB_REMOTE_WORKDIR"): + return env["AIRSDB_REMOTE_WORKDIR"] + home = remote_home(env) + return (home.rstrip("/") + "/.airsdb") if home else ".airsdb" + + +def detect_tool(env: Dict[str, str]) -> str: + tool = env.get("AIRSDB_REMOTE_CPPCHECK", "auto").strip() + if tool and tool != "auto": + result = run(env, f"command -v {q(tool)} >/dev/null 2>&1 && command -v {q(tool)} || test -x {q(tool)} && printf %s {q(tool)}", 20) + return result.stdout.strip().splitlines()[-1] if result.returncode == 0 and result.stdout.strip() else "" + result = run(env, 'command -v cppcheck >/dev/null 2>&1 && command -v cppcheck', 20) + return result.stdout.strip().splitlines()[-1] if result.returncode == 0 and result.stdout.strip() else "" + + +def remote_version(env: Dict[str, str], tool: str) -> str: + result = run(env, f"{q(tool)} --version", 20) + text = (result.stdout or result.stderr).strip() + return text.splitlines()[0] if result.returncode == 0 and text else "" + + +def install_tool(env: Dict[str, str]) -> subprocess.CompletedProcess[str]: + script = """set -e +if command -v cppcheck >/dev/null 2>&1; then exit 0; fi +if [ "$(id -u 2>/dev/null || echo 1)" = "0" ]; then SUDO=""; else SUDO="sudo -n"; fi +if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update && $SUDO apt-get install -y cppcheck; +elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y cppcheck; +elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y cppcheck; +elif command -v apk >/dev/null 2>&1; then $SUDO apk add cppcheck; +elif command -v pacman >/dev/null 2>&1; then $SUDO pacman -Sy --noconfirm cppcheck; +elif command -v brew >/dev/null 2>&1; then brew install cppcheck; +elif command -v port >/dev/null 2>&1; then $SUDO port install cppcheck; +else exit 42; fi""" + return run(env, script, 1200) + + +def setup(project: Path, env: Dict[str, str], auto: bool = True) -> Dict[str, str]: + if not env.get("AIRSDB_REMOTE_SSH_TARGET"): + return {"status": "blocked", "reason": "missing_remote_target", "example": str(project / "AirPlan" / "state" / "airsdb" / "remote-device.env.example")} + if not shutil.which("ssh"): + return {"status": "blocked", "reason": "ssh_not_found"} + probe = run(env, "printf ok", 20) + if probe.returncode: + return {"status": "blocked", "reason": "ssh_probe_failed", "stderr": probe.stderr.strip()} + wd = workdir(env) + mk = run(env, f"mkdir -p {q(wd)} {q(wd.rstrip('/') + '/reports')} {q(wd.rstrip('/') + '/cppcheck-build')}", 30) + if mk.returncode: + return {"status": "blocked", "reason": "remote_workdir_failed", "stderr": mk.stderr.strip()} + tool = detect_tool(env) + install = "skipped" + if not tool and auto: + inst = install_tool(env) + install = "ok" if inst.returncode == 0 else f"failed:{inst.returncode}" + tool = detect_tool(env) + updates = {"AIRSDB_REMOTE_WORKDIR": wd} + if tool: + updates["AIRSDB_REMOTE_CPPCHECK"] = tool + write_env(project / "AirPlan" / "state" / "airsdb" / "remote-device.env", updates) + version = remote_version(env, tool) if tool else "" + return { + "status": "ok" if tool else "blocked", + "target": env.get("AIRSDB_REMOTE_SSH_TARGET", ""), + "workdir": wd, + "tool": tool, + "version": version, + "autoConfigure": install, + "hint": "" if tool else "Install cppcheck on the remote target or set AIRSDB_REMOTE_CPPCHECK.", + } + + +def scan_cmd( + env: Dict[str, str], + tool: str, + remote_project: str, + remote_xml: str, + enable: str, + check_level: str, + std: str, + extra: List[str], +) -> str: + build_dir = workdir(env).rstrip("/") + "/cppcheck-build" + report_dir = remote_xml.rsplit("/", 1)[0] if "/" in remote_xml else "." + options = [ + q(tool), + f"--enable={q(enable)}", + f"--check-level={q(check_level)}", + "--inconclusive", + "--inline-suppr", + "--quiet", + "--xml", + "--xml-version=2", + f"--cppcheck-build-dir={q(build_dir)}", + ] + if std: + options.append(f"--std={q(std)}") + for item in extra: + if item: + options.append(item) + return ( + f"cd {q(remote_project)} && mkdir -p {q(report_dir)} {q(build_dir)} && " + "if [ -f compile_commands.json ]; then AIRSDB_PROJECT_ARG='--project=compile_commands.json'; " + "elif [ -f build/compile_commands.json ]; then AIRSDB_PROJECT_ARG='--project=build/compile_commands.json'; " + "elif [ -f cmake-build-debug/compile_commands.json ]; then AIRSDB_PROJECT_ARG='--project=cmake-build-debug/compile_commands.json'; " + "elif [ -f cmake-build-release/compile_commands.json ]; then AIRSDB_PROJECT_ARG='--project=cmake-build-release/compile_commands.json'; " + "else AIRSDB_PROJECT_ARG='.'; fi; " + + " ".join(options) + + f" $AIRSDB_PROJECT_ARG 2> {q(remote_xml)}; AIRSDB_RC=$?; " + 'printf "airsdb_cppcheck_rc=%s\\n" "$AIRSDB_RC"; ' + 'printf "airsdb_project_arg=%s\\n" "$AIRSDB_PROJECT_ARG"; ' + "exit 0" + ) + + +def emit(prefix: str, data: Dict[str, object]) -> None: + print(f"{prefix}_status={data.get('status', 'unknown')}") + for key, value in data.items(): + if key != "status": + print(f"{key}={json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else value}") + + +def parse_stdout_value(stdout: str, key: str) -> str: + prefix = f"{key}=" + for line in stdout.splitlines(): + if line.startswith(prefix): + return line[len(prefix) :].strip() + return "" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run AirSDB cppcheck on a remote SSH target.") + parser.add_argument("--project", default=".") + parser.add_argument("--action", choices=["setup", "status", "command", "scan"], default="setup") + parser.add_argument("--remote-project", default="") + parser.add_argument("--enable", default=DEFAULT_ENABLE) + parser.add_argument("--check-level", default=DEFAULT_CHECK_LEVEL) + parser.add_argument("--std", default="") + parser.add_argument("--timeout", type=int, default=900) + parser.add_argument("--limit", type=int, default=12) + parser.add_argument("--extra", action="append", default=[]) + parser.add_argument("--no-auto-configure", action="store_true") + args = parser.parse_args() + + project = Path(args.project).expanduser().resolve() + ensure_remote_files(project) + env = read_env(project / "AirPlan" / "state" / "airsdb" / "remote-device.env") + info = setup(project, env, auto=(args.action != "status" and not args.no_auto_configure)) + if args.action in ["setup", "status"] or info.get("status") != "ok": + emit("airsdb_remote", info) + raise SystemExit(0 if info.get("status") == "ok" else 2) + + remote_project = args.remote_project or env.get("AIRSDB_REMOTE_PROJECT", "") + if not remote_project: + emit("airsdb_remote", {"status": "blocked", "reason": "missing_remote_project", "example": str(project / "AirPlan" / "state" / "airsdb" / "remote-device.env.example")}) + raise SystemExit(2) + + remote_xml = info["workdir"].rstrip("/") + "/reports/" + stamp() + "-airsdb-remote-cppcheck.xml" + cmd = scan_cmd(env, str(info["tool"]), remote_project, remote_xml, args.enable, args.check_level, args.std, args.extra) + full_command = shlex.join(ssh_args(env, cmd)) + if args.action == "command": + emit("airsdb_remote", {"status": "ok", "target": info["target"], "remoteProject": remote_project, "remoteXml": remote_xml, "command": full_command}) + return + + run_result = run(env, cmd, args.timeout) + rc_text = parse_stdout_value(run_result.stdout, "airsdb_cppcheck_rc") + cppcheck_rc = int(rc_text) if rc_text.isdigit() else run_result.returncode + b64 = run(env, f"base64 < {q(remote_xml)}", 120) + xml_text = "" + if b64.returncode == 0 and b64.stdout.strip(): + xml_text = base64.b64decode("".join(b64.stdout.split())).decode("utf-8", errors="replace") + parsed = parse_cppcheck_xml(xml_text, project) + findings = parsed.get("findings", []) + data = { + "target": f"remote:{info['target']}", + "remoteProject": remote_project, + "status": "ok" if run_result.returncode == 0 else "failed", + "tool": info.get("tool", ""), + "version": info.get("version", ""), + "checkLevel": args.check_level, + "command": full_command, + "returnCode": cppcheck_rc, + "sshReturnCode": run_result.returncode, + "stdout": run_result.stdout.strip(), + "stderr": run_result.stderr.strip(), + "remoteXml": remote_xml, + "capturedAt": iso(), + "counts": parsed.get("counts", {}), + "findings": findings, + "findingCount": len(findings), + "topFindings": top_findings(findings, args.limit), + "parseError": parsed.get("parseError", ""), + } + data = write_reports(project, data, xml_text, "remote-cppcheck") + append_staticanalysis(project, data) + emit("airsdb_remote", data) + raise SystemExit(0 if run_result.returncode == 0 else 1) + + +if __name__ == "__main__": + main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/skills/airsdb/SKILL.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/skills/airsdb/SKILL.md new file mode 100755 index 0000000..5169b2c --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/skills/airsdb/SKILL.md @@ -0,0 +1,166 @@ +--- +name: airsdb +description: Cppcheck static-analysis workflow for C/C++ projects. Use when the user invokes /airsdb, asks to run static analysis, evaluate code quality or security with cppcheck, generate a short AI-context static-analysis report for AirDbg or AirDo, diagnose issues that need static analysis, maintain AirPlan/docs/staticanalysis.md, or run local/remote cppcheck over SSH. On first startup detect cppcheck and auto-install or auto-configure it when missing; default local and remote scans to `--check-level=exhaustive` for maximum branch-analysis detail; and call the AirSDB remote device helper when remote cppcheck is needed. +--- + +# AirSDB + +## 核心约束 + +- 全程使用中文与用户交流,命令、路径、工具名、告警 id、CWE 保持原文。 +- `/airsdb` 专用于 C/C++ 静态分析、代码质量/安全性初筛、cppcheck 证据收集,以及给 AirDbg/AirDo 提供简短 AI 上下文报告。 +- 第一次进入必须检测 `cppcheck`。本机缺失时自动尝试用包管理器安装或配置;无法自动安装时停止并提示官方下载页或 `AIRSDB_CPPCHECK`。 +- 必须创建或维护 `AirPlan/docs/staticanalysis.md`。它只写简短摘要,详细 XML/JSON 产物放在 `AirPlan/state/airsdb/reports/`。 +- 本机分析使用 `../../scripts/airsdb_cppcheck.py`。 +- 远程设备、测试机、VM、容器宿主机、服务器或 SSH 主机上的静态分析,先使用 `../../scripts/airsdb_remote_device.py`;远端缺少 `cppcheck` 时允许脚本自动配置,无法无密码 `sudo` 或无包管理器时停止并提示用户。 +- 优先使用 `compile_commands.json`;没有时只扫描最窄可行目录,并排除 `.git`、`AirPlan/state/airsdb`、`build`、`node_modules`、`vendor`、`third_party` 等常见噪声目录。 +- 默认使用 `--check-level=exhaustive`,尽可能提供详细分支分析信息,避免出现 `normalCheckLevelMaxBranches` 这类因分支分析深度受限造成的信息缺口;只有用户明确要求降级时才改。 +- Cppcheck 是静态分析,不等同于编译、测试或安全审计;结论要写成“证据/线索”,不要夸大。 +- 如果需要 cppcheck 安装和命令细节,读取 [references/cppcheck-notes.md](references/cppcheck-notes.md)。 + +## 启动与环境检测 + +进入 `/airsdb` 时运行: + +```bash +python ../../scripts/airsdb_mode.py --mode enter --project . +``` + +如果当前环境没有 `python`,尝试 `py`、`python3` 或用户提供的 Python 绝对路径。 + +脚本会: + +- 初始化 `AirPlan/state/airsdb/`、`AirPlan/state/airsdb/tool.env.example`、`AirPlan/state/airsdb/.gitignore`。 +- 创建或维护 `AirPlan/docs/staticanalysis.md`。 +- 在 `AirPlan/AGENTS.md` 中维护 AirSDB 标记块。 +- 检测 `AIRSDB_CPPCHECK`、`AirPlan/state/airsdb/tool.env`、PATH 和常见 Windows 安装路径。 +- 找不到 `cppcheck` 时自动尝试安装: + - Windows:`winget`、`choco`、`scoop` + - Linux/macOS:`apt-get`、`dnf`、`yum`、`apk`、`pacman`、`brew`、`port` + +`AirPlan/state/airsdb/tool.env` 是本机路径配置,由 `AirPlan/state/airsdb/.gitignore` 忽略,不应提交。 + +## 本机分析 + +检查或安装 cppcheck: + +```bash +python ../../scripts/airsdb_cppcheck.py --project . --action setup +``` + +只生成命令: + +```bash +python ../../scripts/airsdb_cppcheck.py --project . --action command +``` + +执行扫描: + +```bash +python ../../scripts/airsdb_cppcheck.py --project . --action scan --timeout 900 +``` + +常用参数: + +- `--project-file build/compile_commands.json`:指定编译数据库。 +- `--target src`:没有编译数据库时限制扫描目录。 +- `--enable warning,style,performance,portability,information`:默认检查集合。 +- `--check-level exhaustive`:默认详细分支分析级别。 +- `--std c++17`:指定 C/C++ 标准。 +- `--extra "--suppress=missingIncludeSystem"`:追加 cppcheck 参数。 + +扫描后必须确认: + +- `AirPlan/state/airsdb/reports/-cppcheck.xml` +- `AirPlan/state/airsdb/reports/-cppcheck.json` +- `AirPlan/docs/staticanalysis.md` 已追加简短报告 + +## 远程设备分析 + +当目标代码或复现场景在远程设备上时,不要先跑本机 cppcheck。先运行: + +```bash +python ../../scripts/airsdb_remote_device.py --project . --action setup +``` + +首次运行会生成 `AirPlan/state/airsdb/remote-device.env.example`。将连接信息写入 `AirPlan/state/airsdb/remote-device.env` 或当前环境变量: + +- `AIRSDB_REMOTE_SSH_TARGET=user@host` +- `AIRSDB_REMOTE_SSH_PORT=22` +- `AIRSDB_REMOTE_SSH_OPTIONS=` +- `AIRSDB_REMOTE_WORKDIR=` +- `AIRSDB_REMOTE_PROJECT=/path/to/remote/project` +- `AIRSDB_REMOTE_CPPCHECK=auto` + +远程 helper 行为: + +- 检查本机 `ssh`、远程连通性、远程工作目录。 +- 探测远端 `cppcheck`。 +- 缺失时自动尝试用远端包管理器安装 `cppcheck`,只使用非交互式 `sudo -n`;需要密码、管理员确认或无支持包管理器时停止并提示用户。 +- 在远端项目目录运行 cppcheck,把 XML 拉回本机 `AirPlan/state/airsdb/reports/` 并更新本机 `AirPlan/docs/staticanalysis.md`。 + +远程命令: + +```bash +python ../../scripts/airsdb_remote_device.py --project . --action command +python ../../scripts/airsdb_remote_device.py --project . --action scan --timeout 900 +``` + +## 与 AirDbg 协作 + +AirDbg 调试中遇到以下情况时调用 AirSDB: + +- 需要用静态分析辅助定位崩溃、内存错误、未初始化变量、空指针、越界、危险转换、资源释放或 CWE 线索。 +- 需要在修复前后比较 cppcheck 结果。 +- 需要给根因分析提供短报告,而不是完整 XML 噪声。 + +AirSDB 给 AirDbg 的交接必须写入 `AirPlan/docs/staticanalysis.md`: + +- 命令和目标 +- XML/JSON 报告路径 +- severity/id/CWE 计数 +- Top findings +- 哪些 findings 与当前 bug 相关 +- 剩余风险 + +## 与 AirDo 协作 + +AirDo 执行 `AirPlan/todo.md` 时可以调用 AirSDB 做验收或排障: + +- todo 要求静态分析、质量检查、安全性初筛或 C/C++ 代码风险评估。 +- 验证失败但需要 cppcheck 辅助定位。 +- 远程设备上的实现需要远端 cppcheck 证据。 + +AirDo 仍然拥有 `AirPlan/todo.md` 进度。调用 AirSDB 后,把命令、报告路径、结论和剩余风险写回当前 todo 项。 + +## staticanalysis.md 维护 + +每次 AirSDB 扫描至少追加: + +- Target:local 或 remote target +- Tool:cppcheck 路径和版本 +- Command:实际命令 +- Result:ok / findings / failed +- Counts:各 severity 数量 +- Reports:XML/JSON 路径 +- Top findings:最多 12 条,含 severity、id、CWE、文件行号、摘要 +- AirDbg/AirDo handoff:当前任务如何使用这些结果 +- Residual risk:静态分析未覆盖的风险 + +不要把完整 XML、长日志或大段 cppcheck 输出塞进 `staticanalysis.md`。 + +## AGENTS / ADR / C4 + +- 发现稳定可复用的 AirSDB 命令、远程设备配置、过滤策略、suppressions 或质量门槛时,更新 `AGENTS.md`。 +- 如果静态分析成为长期测试/调试边界,或影响模块边界、质量策略、安全策略、CI 策略,更新 C4 module 并新增或修订 ADR。 +- 如果只是一次临时扫描,只维护 `staticanalysis.md` 即可。 + +## 完成输出 + +本轮结束时用中文简洁汇报: + +- 使用本机还是远程 cppcheck。 +- cppcheck 是否可用,是否发生自动配置。 +- 报告路径和 `staticanalysis.md` 是否更新。 +- 发现数量和最重要的 3-5 条线索。 +- 是否建议交给 AirDbg 修复,或交给 AirDo 写回 todo 验收。 diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/skills/airsdb/agents/openai.yaml b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/skills/airsdb/agents/openai.yaml new file mode 100755 index 0000000..7c462d3 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/skills/airsdb/agents/openai.yaml @@ -0,0 +1,3 @@ +name: airsdb +short_description: Cppcheck static analysis with exhaustive local/remote reports +default_prompt: "使用 AirSDB 运行本机或远程 cppcheck 静态分析,默认启用 --check-level=exhaustive,生成简短 staticanalysis.md 报告并交给 AirDbg/AirDo 使用。" diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/skills/airsdb/references/cppcheck-notes.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/skills/airsdb/references/cppcheck-notes.md new file mode 100755 index 0000000..dd9b00f --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airsdb/skills/airsdb/references/cppcheck-notes.md @@ -0,0 +1,27 @@ +# Cppcheck Notes + +Use this only when AirSDB needs cppcheck install or command details. + +## Sources + +- Official open-source download page: https://cppcheck.sourceforge.io/ +- Official repository package notes: https://github.com/danmar/cppcheck +- Official manual: https://cppcheck.sourceforge.io/manual.html +- User-provided Chinese guide: https://www.zeeklog.com/cppcheckzhong-ji-zhi-nan-cong-ling-kai-shi-zhang-wo-c-c-jing-tai-dai-ma-fen-xi +- User-provided download reference: http://cppcheck.net/#download + +## Install Notes + +- The official page lists current open-source releases and package-manager examples. +- Windows official installer is linked from the Cppcheck open-source page. +- Package managers can be convenient but may lag behind official releases. +- AirSDB auto-configures with package managers first because it must be non-interactive for agent workflows. + +## Command Notes + +- Prefer `cppcheck --project=compile_commands.json` when the project has a compilation database. +- Generate a CMake compilation database with `cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON .` when appropriate. +- Use `--xml --xml-version=2` for machine-readable reports. +- Use `--cppcheck-build-dir=` for incremental analysis and better whole-program analysis. +- Use `-i` to skip generated/vendor directories. +- Use suppressions instead of deleting warnings when a finding is a known false positive. diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/.codex-plugin/plugin.json b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/.codex-plugin/plugin.json new file mode 100755 index 0000000..32f7b35 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/.codex-plugin/plugin.json @@ -0,0 +1,43 @@ +{ + "name": "airxdb", + "version": "0.2.2", + "description": "Midscene-based GUI debugging workflow for browser, desktop, and remote SSH GUI issues, with screenshot evidence for AirDbg, model-family gates, Computer MCP smoke tests, remote device screenshot setup, Windows screenshot repair, and reports.", + "author": { + "name": "14816", + "email": "noreply@example.com", + "url": "https://airlongdian.fun/plugins/airxdb" + }, + "homepage": "https://airlongdian.fun/plugins/airxdb", + "repository": "https://airlongdian.fun/plugins/airxdb", + "license": "MIT", + "keywords": [ + "airxdb", + "midscene", + "gui", + "ui-debug", + "playwright", + "mcp" + ], + "skills": "./skills/", + "interface": { + "displayName": "AirXDB", + "shortDescription": "Midscene GUI debug workflow with local and remote screenshot evidence", + "longDescription": "AirXDB helps debug graphical interfaces with Midscene.js. It captures screenshot evidence for AirDbg even without model configuration, checks Midscene model and model-family configuration for semantic actions, smoke-tests Computer MCP, uses a remote device helper over SSH for remote GUI evidence and auto-configures missing remote screenshot tools when possible, repairs known Windows screenshot asset gaps, works with AirDbg to reproduce browser, desktop, and remote UI issues visually, generate reports, choose the right Midscene mode, and keep AGENTS.md, ADR, C4 module, and GUI debug logs current.", + "developerName": "14816", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Write" + ], + "websiteURL": "https://airlongdian.fun/plugins/airxdb", + "privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/", + "termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/", + "defaultPrompt": [ + "Use AirXDB with AirDbg to reproduce a browser, desktop, or remote GUI problem visually.", + "Use AirXDB to check the Midscene setup and choose the right GUI automation mode before acting.", + "Use AirXDB to capture screenshot evidence and a reproducible visual report for the current issue." + ], + "brandColor": "#0EA5A4", + "screenshots": [] + } +} diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/commands/airxdb.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/commands/airxdb.md new file mode 100755 index 0000000..b72fb88 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/commands/airxdb.md @@ -0,0 +1,109 @@ +--- +description: Enter, exit, inspect, screenshot, remote-screenshot, or smoke-test AirXDB GUI debug mode +argument-hint: [enter|exit|status|screenshot|remote-setup|remote-status|remote-screenshot|smoke] +allowed-tools: [Read, Glob, Grep, Bash, Write, Edit] +--- + +# /airxdb + +控制当前工作区的 AirXDB 图形界面调试模式。 + +用户传入参数:`$ARGUMENTS` + +- `enter` 或空参数:进入 AirXDB 模式并初始化 GUI 调试上下文。 +- `status`:检查 AirXDB 状态和关键文件是否存在。 +- `screenshot`:连接 Computer MCP 并截图,为 `airdbg` 提供 GUI 错误诊断证据;不要求 Midscene 模型配置。 +- `remote-setup`:检查远程设备 SSH 和截图工具;缺失时自动尝试配置远程截图工具。 +- `remote-status`:只检查远程设备配置和工具状态,不自动安装。 +- `remote-screenshot`:通过 SSH 在远程设备截图,并把截图/JSON 报告拉回当前项目。 +- `smoke`:启动 Computer MCP smoke test,默认视觉定位 Windows taskbar Start button 并移动鼠标。 +- `exit`:退出 AirXDB 模式。 + +## 执行步骤 + +1. 解析 `$ARGUMENTS`,默认动作为 `enter`。 +2. 在当前项目根目录运行: + +```bash +python "$HOME/plugins/airxdb/scripts/airxdb_mode.py" --mode --project . +``` + +如果 `python` 不存在,尝试 `py` 或 `python3`。 + +3. `enter` 后必须: +- 加载或初始化 `AirPlan/AGENTS.md`、ADR、C4 module、`AirPlan/docs/debug/gui-debug-log.md` + - 检查输出中的 `midscene_config` + - 如果出现 `midscene_config_required=true`、`midscene_config=missing:...` 或 `midscene_semantic_config=missing:...`,先向用户索取 Midscene 模型配置,再执行视觉语义动作 + - 判断是浏览器 GUI 还是桌面 GUI + - 判断是否已有 Playwright + - 选择 Midscene 模式:Playwright / Bridge / Computer / MCP + - 与用户确认是否要和 `airdbg` 配合推进 + +4. 如果 `$ARGUMENTS` 包含 `remote`、`ssh`、`远程`,或用户已经说明目标 GUI 在远程设备、测试机、服务器、VM、SSH 主机上,优先运行远程设备 helper: + +```bash +python "$HOME/plugins/airxdb/scripts/airxdb_remote_device.py" --project . --action +``` + +首次缺少远程配置时,helper 会生成 `AirPlan/state/airxdb/remote-device.env.example` 并提示设置 `AIRXDB_REMOTE_SSH_TARGET`。有 SSH 目标后,helper 会探测远端截图工具;缺失时自动尝试安装 `scrot`,只使用非交互式 `sudo -n`,无法自动配置时停止并提示用户。 + +5. `screenshot` 且目标是本机桌面时运行: + +```bash +python "$HOME/plugins/airxdb/scripts/airxdb_computer_mcp_smoke.py" --project . --action screenshot +``` + +截图结果写入 `AirPlan/docs/debug/airxdb-artifacts/`,并应追加到 `AirPlan/docs/debug/gui-debug-log.md`,作为给 `airdbg` 的诊断证据。 + +6. `smoke` 时运行: + +```bash +python "$HOME/plugins/airxdb/scripts/airxdb_computer_mcp_smoke.py" --project . --action mousemove --prompt "Windows taskbar Start button" +``` + +如果 `python` 不存在,尝试 `py`、`python3` 或用户提供的 Python 绝对路径。`smoke` 会加载 `AirPlan/state/airxdb/midscene.local.env`,启动 `@midscene/computer-mcp`,必要时修复 Windows 截图脚本缺失,并把截图/JSON 报告写入 `AirPlan/docs/debug/airxdb-artifacts/`。 + +7. GUI 调试过程中必须维护: + - `AGENTS.md` + - `docs/architecture/adr/` + - `docs/architecture/c4/module.md` +- `AirPlan/docs/debug/gui-debug-log.md` + +## 输出文案 + +进入模式: + +```text +AirXDB 模式已开启:已初始化或检查 AGENTS.md、ADR、C4 module 和 GUI debug log。若 Midscene 模型配置缺失,请先提供 MIDSCENE_MODEL_NAME、MIDSCENE_MODEL_BASE_URL、MIDSCENE_MODEL_API_KEY、MIDSCENE_MODEL_FAMILY;之后请说明目标界面、复现步骤,以及你希望使用 Playwright、Bridge、Desktop 还是 MCP 路线。 +``` + +smoke 成功: + +```text +AirXDB smoke test 通过:Computer MCP 已连接桌面,语义视觉动作完成,截图和 JSON 报告已写入 docs/debug/airxdb-artifacts/。 +``` + +screenshot 成功: + +```text +AirXDB 截图取证完成:Computer MCP 已连接桌面并保存截图/JSON 报告,可交给 airdbg 做错误诊断。 +``` + +remote-screenshot 成功: + +```text +AirXDB 远程截图取证完成:已通过 SSH 获取远程设备截图/JSON 报告,并写入 docs/debug/airxdb-artifacts/,可交给 airdbg 做错误诊断。 +``` + +退出模式: + +```text +AirXDB 模式已退出:已返回标准 Codex 流程。 +``` + +状态检查: + +```text +AirXDB 状态: +关键文件:逐项列出 ok/missing。 +``` diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/airxdb_computer_mcp_smoke.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/airxdb_computer_mcp_smoke.py new file mode 100755 index 0000000..2a6c72e --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/airxdb_computer_mcp_smoke.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +"""Run a small AirXDB Computer MCP smoke test with screenshot artifacts.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import shutil +import socket +import subprocess +import tarfile +import tempfile +import threading +import time +import urllib.error +import urllib.request +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple + +REQUIRED_BASE_ENV = [ + "MIDSCENE_MODEL_NAME", + "MIDSCENE_MODEL_BASE_URL", + "MIDSCENE_MODEL_API_KEY", +] +REQUIRED_SEMANTIC_ENV = [ + *REQUIRED_BASE_ENV, + "MIDSCENE_MODEL_FAMILY", +] +VALID_MODEL_FAMILIES = [ + "doubao-vision", + "doubao-seed", + "gemini", + "qwen2.5-vl", + "qwen3-vl", + "qwen3.5", + "qwen3.6", + "vlm-ui-tars", + "vlm-ui-tars-doubao", + "vlm-ui-tars-doubao-1.5", + "glm-v", + "auto-glm", + "auto-glm-multilingual", + "gpt-5", +] + + +def now_stamp() -> str: + return datetime.now().strftime("%Y%m%d-%H%M%S") + + +def load_env(project_root: Path) -> Dict[str, str]: + env = os.environ.copy() + env_file = project_root / "AirPlan" / "state" / "airxdb" / "midscene.local.env" + if not env_file.exists(): + return env + + for raw_line in env_file.read_text(encoding="utf-8-sig").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and value and key not in env: + env[key] = value + return env + + +def infer_model_family(model_name: str) -> str: + normalized = model_name.strip().lower() + if normalized.startswith("gpt-5"): + return "gpt-5" + if "qwen3" in normalized: + return "qwen3-vl" + if "qwen2.5" in normalized or "qwen-2.5" in normalized: + return "qwen2.5-vl" + if "gemini" in normalized: + return "gemini" + if "doubao" in normalized: + return "doubao-seed" + if "glm" in normalized: + return "glm-v" + return "" + + +def validate_env(env: Dict[str, str], semantic: bool) -> List[str]: + if not semantic: + return [] + required = REQUIRED_SEMANTIC_ENV if semantic else REQUIRED_BASE_ENV + missing = [name for name in required if not env.get(name)] + family = env.get("MIDSCENE_MODEL_FAMILY", "") + if semantic and family and family not in VALID_MODEL_FAMILIES: + missing.append("MIDSCENE_MODEL_FAMILY(valid value)") + return missing + + +def redact(text: str, env: Dict[str, str]) -> str: + secret = env.get("MIDSCENE_MODEL_API_KEY") + if secret: + text = text.replace(secret, "") + return text + + +def command_name(name: str) -> str: + if os.name == "nt": + candidate = shutil.which(f"{name}.cmd") + if candidate: + return candidate + return shutil.which(name) or name + + +def free_port() -> int: + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return int(port) + + +def npm_cache_root(env: Dict[str, str]) -> Optional[Path]: + local_app_data = env.get("LOCALAPPDATA") + if local_app_data: + return Path(local_app_data) / "npm-cache" + home = env.get("HOME") or env.get("USERPROFILE") + return Path(home) / ".npm" if home else None + + +def midscene_dist_dirs(project_root: Path, env: Dict[str, str]) -> List[Path]: + candidates: List[Path] = [] + local = project_root / "node_modules" / "@midscene" / "computer-mcp" / "dist" + if local.exists(): + candidates.append(local) + + cache = npm_cache_root(env) + if cache: + candidates.extend(cache.glob("_npx/*/node_modules/@midscene/computer-mcp/dist")) + + unique: List[Path] = [] + seen = set() + for path in candidates: + resolved = str(path.resolve()) + if resolved not in seen: + seen.add(resolved) + unique.append(path) + return unique + + +def ensure_windows_screenshot_assets(project_root: Path, env: Dict[str, str]) -> Tuple[str, List[str]]: + if os.name != "nt": + return "skipped-non-windows", [] + + dist_dirs = midscene_dist_dirs(project_root, env) + missing_dirs = [ + path for path in dist_dirs + if not (path / "screenCapture_1.3.2.bat").exists() or not (path / "app.manifest").exists() + ] + if not missing_dirs: + return "ok", [str(path) for path in dist_dirs] + if not dist_dirs: + return "missing-midscene-package", [] + + npm = command_name("npm") + with tempfile.TemporaryDirectory(prefix="airxdb-screenshot-desktop-") as tmp: + tmp_path = Path(tmp) + subprocess.run( + [npm, "pack", "screenshot-desktop@1.15.3", "--pack-destination", str(tmp_path)], + cwd=str(project_root), + env=env, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + tgz = next(tmp_path.glob("screenshot-desktop-1.15.3.tgz")) + with tarfile.open(tgz, "r:gz") as archive: + archive.extract("package/lib/win32/screenCapture_1.3.2.bat", path=tmp_path) + archive.extract("package/lib/win32/app.manifest", path=tmp_path) + bat = tmp_path / "package" / "lib" / "win32" / "screenCapture_1.3.2.bat" + manifest = tmp_path / "package" / "lib" / "win32" / "app.manifest" + for dist in missing_dirs: + shutil.copy2(bat, dist / "screenCapture_1.3.2.bat") + shutil.copy2(manifest, dist / "app.manifest") + return "repaired", [str(path) for path in missing_dirs] + + +def decode_response(raw: str, content_type: str) -> Optional[Dict[str, Any]]: + if "text/event-stream" in content_type or raw.startswith("event:") or raw.startswith("data:"): + data_lines = [line[5:].strip() for line in raw.splitlines() if line.startswith("data:")] + raw = "\n".join(data_lines).strip() + return json.loads(raw) if raw.strip() else None + + +class MCPClient: + def __init__(self, url: str, env: Dict[str, str], timeout: int) -> None: + self.url = url + self.env = env + self.timeout = timeout + self.session_id: Optional[str] = None + self.seq = 1 + + def post(self, method: str, params: Optional[Dict[str, Any]] = None, expect_id: bool = True) -> Optional[Dict[str, Any]]: + payload: Dict[str, Any] = {"jsonrpc": "2.0", "method": method, "params": params or {}} + if expect_id: + payload["id"] = self.seq + self.seq += 1 + + headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + } + if self.session_id: + headers["mcp-session-id"] = self.session_id + + request = urllib.request.Request( + self.url, + data=json.dumps(payload).encode("utf-8"), + headers=headers, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + raw = response.read().decode("utf-8", "replace") + raw = redact(raw, self.env) + self.session_id = response.headers.get("mcp-session-id") or self.session_id + return decode_response(raw, response.headers.get("content-type", "")) + except urllib.error.HTTPError as error: + raw = error.read().decode("utf-8", "replace") + raise RuntimeError(f"HTTP {error.code} for {method}: {redact(raw, self.env)}") from error + + def call_tool(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: + return self.post("tools/call", {"name": name, "arguments": arguments or {}}) + + +def content_texts(result: Optional[Dict[str, Any]]) -> List[str]: + content = (result or {}).get("result", {}).get("content", []) + return [item.get("text", "") for item in content if item.get("type") == "text"] + + +def save_images(result: Optional[Dict[str, Any]], output_dir: Path, stem: str) -> List[str]: + saved: List[str] = [] + content = (result or {}).get("result", {}).get("content", []) + for index, item in enumerate(content): + if item.get("type") != "image" or not item.get("data"): + continue + mime = item.get("mimeType", "image/png") + ext = ".jpg" if "jpeg" in mime or "jpg" in mime else ".png" + path = output_dir / f"{stem}-{index}{ext}" + path.write_bytes(base64.b64decode(item["data"])) + saved.append(str(path)) + return saved + + +def wait_for_port(port: int, proc: subprocess.Popen[str], logs: List[str], timeout: int) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if proc.poll() is not None: + raise RuntimeError(f"computer-mcp exited {proc.returncode}\n" + "\n".join(logs[-80:])) + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return + except OSError: + time.sleep(0.25) + raise TimeoutError("computer-mcp HTTP server did not open a port\n" + "\n".join(logs[-80:])) + + +def start_log_pump(stream: Any, prefix: str, logs: List[str], env: Dict[str, str]) -> None: + def pump() -> None: + for line in iter(stream.readline, ""): + logs.append(prefix + redact(line.rstrip(), env)) + + threading.Thread(target=pump, daemon=True).start() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run AirXDB Computer MCP smoke test.") + parser.add_argument("--project", default=".") + parser.add_argument("--prompt", default="Windows taskbar Start button") + parser.add_argument("--action", choices=["connect", "screenshot", "mousemove", "act"], default="mousemove") + parser.add_argument("--output-dir", default="") + parser.add_argument("--timeout", type=int, default=240) + parser.add_argument("--no-repair-screenshot-assets", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + project_root = Path(args.project).expanduser().resolve() + output_dir = Path(args.output_dir).expanduser().resolve() if args.output_dir else project_root / "AirPlan" / "docs" / "debug" / "airxdb-artifacts" + output_dir.mkdir(parents=True, exist_ok=True) + env = load_env(project_root) + semantic = args.action in {"mousemove", "act"} + missing = validate_env(env, semantic) + if missing: + print("airxdb_smoke=blocked") + print("missing_midscene_env=" + ",".join(missing)) + suggestion = infer_model_family(env.get("MIDSCENE_MODEL_NAME", "")) + if suggestion: + print(f"suggested_midscene_model_family={suggestion}") + print("valid_midscene_model_families=" + ",".join(VALID_MODEL_FAMILIES)) + raise SystemExit(2) + + port = free_port() + logs: List[str] = [] + npx = command_name("npx") + proc = subprocess.Popen( + [npx, "-y", "@midscene/computer-mcp", "--mode", "http", "--host", "127.0.0.1", "--port", str(port)], + cwd=str(project_root), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + assert proc.stdout is not None + assert proc.stderr is not None + start_log_pump(proc.stdout, "OUT ", logs, env) + start_log_pump(proc.stderr, "ERR ", logs, env) + + steps: List[Dict[str, Any]] = [] + asset_status = "skipped" + asset_paths: List[str] = [] + try: + wait_for_port(port, proc, logs, min(args.timeout, 60)) + if not args.no_repair_screenshot_assets: + asset_status, asset_paths = ensure_windows_screenshot_assets(project_root, env) + + client = MCPClient(f"http://127.0.0.1:{port}/mcp", env, args.timeout) + client.post( + "initialize", + { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "airxdb-computer-mcp-smoke", "version": "0.2.1"}, + }, + ) + client.post("notifications/initialized", {}, expect_id=False) + + actions: List[Tuple[str, Dict[str, Any], str]] = [ + ("computer_connect", {}, "connect"), + ] + if args.action == "mousemove": + actions.append(("MouseMove", {"locate": {"prompt": args.prompt}}, "mousemove")) + elif args.action == "act": + actions.append(("act", {"prompt": args.prompt}, "act")) + if args.action in {"screenshot", "mousemove", "act"}: + actions.append(("take_screenshot", {}, "final")) + + for tool, arguments, stem in actions: + result = client.call_tool(tool, arguments) + steps.append( + { + "tool": tool, + "arguments": arguments, + "texts": content_texts(result), + "images": save_images(result, output_dir, f"{now_stamp()}-{stem}"), + } + ) + time.sleep(0.3) + + try: + result = client.call_tool("computer_disconnect", {}) + steps.append({"tool": "computer_disconnect", "arguments": {}, "texts": content_texts(result), "images": []}) + except Exception as error: + steps.append({"tool": "computer_disconnect", "arguments": {}, "texts": [str(error)], "images": []}) + + failed = [ + step for step in steps + if any(text.lower().startswith(("warning:", "failed", "error")) for text in step["texts"]) + ] + status = "failed" if failed else "ok" + report = { + "status": status, + "action": args.action, + "prompt": args.prompt, + "modelName": env.get("MIDSCENE_MODEL_NAME", ""), + "modelFamily": env.get("MIDSCENE_MODEL_FAMILY", ""), + "assetRepair": {"status": asset_status, "paths": asset_paths}, + "steps": steps, + "logsTail": logs[-80:], + } + report_path = output_dir / f"{now_stamp()}-airxdb-smoke.json" + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + print(f"airxdb_smoke={status}") + print(f"capture_mode={'semantic' if semantic else 'screenshot'}") + print(f"report={report_path}") + print(f"asset_repair={asset_status}") + for step in steps: + print(f"step={step['tool']}") + for text in step["texts"]: + print("text=" + text.replace("\n", " | ")[:1600]) + for image in step["images"]: + print(f"image={image}") + raise SystemExit(1 if failed else 0) + finally: + proc.terminate() + try: + proc.wait(timeout=8) + except subprocess.TimeoutExpired: + proc.kill() + + +if __name__ == "__main__": + main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/airxdb_mode.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/airxdb_mode.py new file mode 100755 index 0000000..346ac09 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/airxdb_mode.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +"""Bootstrap AirXDB GUI debug context files.""" + +from __future__ import annotations + +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, Tuple + +MARKER_BEGIN = "" +MARKER_END = "" +REQUIRED_MIDSCENE_ENV = [ + "MIDSCENE_MODEL_NAME", + "MIDSCENE_MODEL_BASE_URL", + "MIDSCENE_MODEL_API_KEY", +] +SEMANTIC_MIDSCENE_ENV = [ + *REQUIRED_MIDSCENE_ENV, + "MIDSCENE_MODEL_FAMILY", +] +OPTIONAL_MIDSCENE_ENV = [ + "MCP_SERVER_REQUEST_TIMEOUT", +] +MODEL_FAMILY_VALUES = [ + "doubao-vision", + "doubao-seed", + "gemini", + "qwen2.5-vl", + "qwen3-vl", + "qwen3.5", + "qwen3.6", + "vlm-ui-tars", + "vlm-ui-tars-doubao", + "vlm-ui-tars-doubao-1.5", + "glm-v", + "auto-glm", + "auto-glm-multilingual", + "gpt-5", +] + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def airxdb_agents_block() -> str: + return f"""{MARKER_BEGIN} +## AirXDB GUI Debug Workflow + +1. Use AirXDB for `/airxdb` sessions that debug browser or desktop GUI issues with Midscene.js. +2. Before working, load: + - `AirPlan/AGENTS.md` + - `AirPlan/docs/architecture/adr/` + - `AirPlan/docs/architecture/c4/module.md` + - `AirPlan/docs/debug/gui-debug-log.md` +3. Classify the target first: + - Web + Playwright + - Web + Chrome Bridge + - Desktop Computer / Playground + - MCP +4. Before semantic visual actions, require Midscene model config: + - `MIDSCENE_MODEL_NAME` + - `MIDSCENE_MODEL_BASE_URL` + - `MIDSCENE_MODEL_API_KEY` + - `MIDSCENE_MODEL_FAMILY` +5. Screenshot capture is allowed without semantic model config and should be used as AirDbg diagnostic evidence. +6. Reproduce visually first and keep Midscene report paths, screenshots, and commands. +7. Use AirXDB with AirDbg when GUI reproduction and code-level fixing are both needed. +8. Update ADR when long-term GUI automation or bridge/MCP choices become architecture context. +9. Update C4 module when UI automation boundaries, browser bridge layers, or desktop control boundaries change. +10. Keep GUI debug logs resumable and concise. +{MARKER_END} +""" + + +def c4_module_template() -> str: + return """# C4 Module + +## System Context +- TODO: Describe the product, users, and UI surfaces involved in the GUI issue. + +## Containers +- TODO: Describe browser, frontend app, desktop app, automation runner, and external services. + +## Modules + +| Module | Responsibility | Public Interfaces | Dependencies | Data Ownership | GUI Debug Notes | +| --- | --- | --- | --- | --- | --- | +| TODO | TODO | TODO | TODO | TODO | TODO | + +## Automation / Observability Boundaries +- TODO: Record Playwright, Bridge, Desktop, MCP, report generation, and screenshot boundaries. + +## Change Log +- TODO: Record GUI-debug-related boundary changes. +""" + + +def adr_template() -> str: + return """# ADR-0001: AirXDB GUI Debug Governance + +- Status: Accepted +- Date: TODO + +## Context +GUI debugging needs stable visual reproduction, report evidence, and durable project context across sessions. + +## Decision +Use AirXDB to maintain `AirPlan/AGENTS.md`, C4 module docs, ADR records, and `AirPlan/docs/debug/gui-debug-log.md` during Midscene-based GUI debugging. + +## Consequences +- GUI issues can be reproduced with report evidence. +- Long-term GUI automation choices become traceable. +- AirDbg can consume AirXDB evidence for code-level fixes. + +## Alternatives +- Screenshot-only chat debugging: rejected because it is hard to resume and verify. +""" + + +def gui_debug_log_template() -> str: + return """# GUI Debug Log + +Append entries for AirXDB sessions. + +## Entry Template + +### YYYY-MM-DD: short GUI issue title + +- Target surface: TODO +- Midscene mode: Playwright / Bridge / Computer / MCP +- Symptom: TODO +- Expected: TODO +- Actual: TODO +- Reproduction: TODO +- Report path: TODO +- Key observations: TODO +- Hand-off to AirDbg: TODO +- Validation after fix: TODO +- ADR/C4 updates: TODO +- Residual risk: TODO +""" + + +def midscene_env_example() -> str: + return """# AirXDB Midscene model configuration +# +# Copy this file to `.airxdb/midscene.local.env` and fill values locally, +# or export the same variables in your shell before running AirXDB. +# Never commit real API keys. + +MIDSCENE_MODEL_NAME= +MIDSCENE_MODEL_BASE_URL= +MIDSCENE_MODEL_API_KEY= + +# Required for semantic visual actions such as act, Tap, Input, +# KeyboardPress, MouseMove with locate prompts, and aiLocate. +# Common values: +# - gpt-5 for GPT-5.x visual models, including gpt-5.4 +# - qwen2.5-vl +# - qwen3-vl +# - gemini +# - doubao-seed +MIDSCENE_MODEL_FAMILY= + +# Optional: +MCP_SERVER_REQUEST_TIMEOUT=120000 +""" + + +def airxdb_gitignore_template() -> str: + return """midscene.local.env +""" + + +def write_if_missing(path: Path, content: str) -> bool: + if path.exists(): + return False + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8", newline="\n") + return True + + +def upsert_agents_md(path: Path) -> str: + block = airxdb_agents_block().rstrip() + "\n" + + if path.exists(): + original = path.read_text(encoding="utf-8") + existed = True + else: + original = "# AGENTS.md\n\n" + existed = False + + begin = original.find(MARKER_BEGIN) + end = original.find(MARKER_END) + + if begin >= 0 and end > begin: + end += len(MARKER_END) + updated = original[:begin].rstrip() + "\n\n" + block + original[end:].lstrip() + status = "updated" + else: + updated = original.rstrip() + "\n\n" + block + status = "updated" if existed else "created" + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(updated, encoding="utf-8", newline="\n") + return status + + +def artifact_map(project_root: Path) -> Dict[str, Path]: + return { + "AGENTS.md": project_root / "AirPlan" / "AGENTS.md", + "c4_module": project_root / "AirPlan" / "docs" / "architecture" / "c4" / "module.md", + "adr_dir": project_root / "AirPlan" / "docs" / "architecture" / "adr", + "adr_0001": project_root / "AirPlan" / "docs" / "architecture" / "adr" / "ADR-0001-airxdb-gui-debug-governance.md", + "gui_debug_log": project_root / "AirPlan" / "docs" / "debug" / "gui-debug-log.md", + "model_env_example": project_root / "AirPlan" / "state" / "airxdb" / "midscene.env.example", + "airxdb_gitignore": project_root / "AirPlan" / "state" / "airxdb" / ".gitignore", + "model_env_local": project_root / "AirPlan" / "state" / "airxdb" / "midscene.local.env", + "state": project_root / "AirPlan" / "state" / "airxdb" / "state.json", + } + + +def load_local_midscene_env(project_root: Path) -> str: + env_file = artifact_map(project_root)["model_env_local"] + if not env_file.exists(): + return "missing" + + loaded = 0 + for raw_line in env_file.read_text(encoding="utf-8-sig").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ and value: + os.environ[key] = value + loaded += 1 + return f"loaded:{loaded}" + + +def infer_model_family(model_name: str) -> str: + normalized = model_name.strip().lower() + if not normalized: + return "" + if normalized.startswith("gpt-5"): + return "gpt-5" + if "qwen3" in normalized: + return "qwen3-vl" + if "qwen2.5" in normalized or "qwen-2.5" in normalized: + return "qwen2.5-vl" + if "gemini" in normalized: + return "gemini" + if "doubao" in normalized: + return "doubao-seed" + if "glm" in normalized: + return "glm-v" + return "" + + +def status_from_missing(missing: list[str], invalid: bool = False) -> str: + if invalid: + return "invalid:MIDSCENE_MODEL_FAMILY" + return "ok" if not missing else "missing:" + ",".join(missing) + + +def midscene_config_health() -> Dict[str, object]: + checked_names = SEMANTIC_MIDSCENE_ENV + OPTIONAL_MIDSCENE_ENV + present = {name: bool(os.environ.get(name)) for name in checked_names} + missing_basic = [name for name in REQUIRED_MIDSCENE_ENV if not present[name]] + missing_semantic = [name for name in SEMANTIC_MIDSCENE_ENV if not present[name]] + model_name = os.environ.get("MIDSCENE_MODEL_NAME", "").strip() + model_family = os.environ.get("MIDSCENE_MODEL_FAMILY", "").strip() + valid_model_family = (not model_family) or model_family in MODEL_FAMILY_VALUES + suggested_model_family = infer_model_family(model_name) + basic_ready = not missing_basic + semantic_ready = basic_ready and not missing_semantic and valid_model_family + + return { + "ready": semantic_ready, + "basicReady": basic_ready, + "semanticReady": semantic_ready, + "required": REQUIRED_MIDSCENE_ENV, + "requiredForSemanticActions": SEMANTIC_MIDSCENE_ENV, + "optional": OPTIONAL_MIDSCENE_ENV, + "present": present, + "missing": missing_basic, + "missingForSemanticActions": missing_semantic, + "validModelFamily": valid_model_family, + "modelFamily": model_family, + "suggestedModelFamily": suggested_model_family, + "validModelFamilies": MODEL_FAMILY_VALUES, + } + + +def write_state(path: Path, enabled: bool, project_root: Path) -> None: + artifacts = artifact_map(project_root) + health = { + name: artifacts[name].exists() + for name in [ + "AGENTS.md", + "c4_module", + "adr_dir", + "adr_0001", + "gui_debug_log", + "model_env_example", + ] + } + config_health = midscene_config_health() + + payload = { + "enabled": enabled, + "updatedAt": now_iso(), + "projectRoot": str(project_root), + "artifactHealth": health, + "midsceneConfig": config_health, + "screenshotCapture": { + "requiresModelConfig": False, + "recommendedScript": "airxdb_computer_mcp_smoke.py --action screenshot", + }, + } + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def enter_mode(project_root: Path) -> Tuple[str, Dict[str, str]]: + artifacts = artifact_map(project_root) + results: Dict[str, str] = {} + + results["midscene_local_env"] = load_local_midscene_env(project_root) + results["AGENTS.md"] = upsert_agents_md(artifacts["AGENTS.md"]) + results["c4_module"] = "created" if write_if_missing(artifacts["c4_module"], c4_module_template()) else "exists" + artifacts["adr_dir"].mkdir(parents=True, exist_ok=True) + results["adr_dir"] = "exists" + results["adr_0001"] = "created" if write_if_missing(artifacts["adr_0001"], adr_template()) else "exists" + results["gui_debug_log"] = "created" if write_if_missing(artifacts["gui_debug_log"], gui_debug_log_template()) else "exists" + results["model_env_example"] = "created" if write_if_missing(artifacts["model_env_example"], midscene_env_example()) else "exists" + results["airxdb_gitignore"] = "created" if write_if_missing(artifacts["airxdb_gitignore"], airxdb_gitignore_template()) else "exists" + + config_health = midscene_config_health() + results["midscene_basic_config"] = status_from_missing(config_health["missing"]) # type: ignore[arg-type] + results["midscene_semantic_config"] = status_from_missing( + config_health["missingForSemanticActions"], # type: ignore[arg-type] + invalid=not bool(config_health["validModelFamily"]), + ) + results["midscene_config"] = results["midscene_semantic_config"] + + write_state(artifacts["state"], True, project_root) + return "enabled", results + + +def exit_mode(project_root: Path) -> Tuple[str, Dict[str, str]]: + artifacts = artifact_map(project_root) + write_state(artifacts["state"], False, project_root) + return "disabled", {} + + +def status_mode(project_root: Path) -> Tuple[str, Dict[str, str]]: + artifacts = artifact_map(project_root) + local_env_status = load_local_midscene_env(project_root) + state_file = artifacts["state"] + + enabled = False + if state_file.exists(): + try: + payload = json.loads(state_file.read_text(encoding="utf-8")) + enabled = bool(payload.get("enabled")) + except json.JSONDecodeError: + enabled = False + + results = { + name: ("ok" if path.exists() else "missing") + for name, path in artifacts.items() + if name not in ["state", "model_env_local"] + } + results["midscene_local_env"] = local_env_status + config_health = midscene_config_health() + results["midscene_basic_config"] = status_from_missing(config_health["missing"]) # type: ignore[arg-type] + results["midscene_semantic_config"] = status_from_missing( + config_health["missingForSemanticActions"], # type: ignore[arg-type] + invalid=not bool(config_health["validModelFamily"]), + ) + results["midscene_config"] = results["midscene_semantic_config"] + return ("enabled" if enabled else "disabled"), results + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Manage AirXDB GUI debug artifacts.") + parser.add_argument("--mode", choices=["enter", "exit", "status"], default="enter") + parser.add_argument("--project", default=".") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + project_root = Path(args.project).expanduser().resolve() + + if args.mode == "enter": + mode_state, result = enter_mode(project_root) + elif args.mode == "exit": + mode_state, result = exit_mode(project_root) + else: + mode_state, result = status_mode(project_root) + + print(f"airxdb_mode={mode_state}") + print(f"project_root={project_root}") + for key, value in result.items(): + print(f"{key}={value}") + + config_health = midscene_config_health() + if mode_state == "enabled" and not config_health["semanticReady"]: + missing = ",".join(config_health["missingForSemanticActions"]) # type: ignore[arg-type] + print("midscene_config_required=true") + print(f"missing_midscene_env={missing}") + if not config_health["validModelFamily"]: + print(f"invalid_midscene_model_family={config_health['modelFamily']}") + if config_health["suggestedModelFamily"]: + print(f"suggested_midscene_model_family={config_health['suggestedModelFamily']}") + print("valid_midscene_model_families=" + ",".join(MODEL_FAMILY_VALUES)) + print("midscene_config_prompt=请先提供 Midscene 模型配置:MIDSCENE_MODEL_NAME、MIDSCENE_MODEL_BASE_URL、MIDSCENE_MODEL_API_KEY、MIDSCENE_MODEL_FAMILY;可选 MCP_SERVER_REQUEST_TIMEOUT。不要把真实 API key 写入 ADR/C4/debug log。") + + +if __name__ == "__main__": + main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/airxdb_remote_device.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/airxdb_remote_device.py new file mode 100755 index 0000000..2329ecc --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/airxdb_remote_device.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""AirXDB remote GUI device helper over SSH.""" +from __future__ import annotations +import argparse, base64, json, os, shlex, shutil, subprocess +from datetime import datetime, timezone +from pathlib import Path + +def stamp(): return datetime.now().strftime('%Y%m%d-%H%M%S') +def iso(): return datetime.now(timezone.utc).isoformat() +def q(s): return shlex.quote(str(s)) + +def read_env(path): + vals = {} + if path.exists(): + for raw in path.read_text(encoding='utf-8-sig').splitlines(): + line = raw.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1); vals[k.strip()] = v.strip().strip('"').strip("'") + for k in ['AIRXDB_REMOTE_SSH_TARGET','AIRXDB_REMOTE_SSH_PORT','AIRXDB_REMOTE_SSH_OPTIONS','AIRXDB_REMOTE_WORKDIR','AIRXDB_REMOTE_SCREENSHOT_TOOL','AIRXDB_REMOTE_DISPLAY']: + if os.environ.get(k): vals[k] = os.environ[k] + return vals + +def write_env(path, updates): + path.parent.mkdir(parents=True, exist_ok=True) + old = path.read_text(encoding='utf-8') if path.exists() else '' + keys = set(updates); lines = [] + for raw in old.splitlines(): + key = raw.split('=',1)[0].strip() if '=' in raw and not raw.strip().startswith('#') else None + if key not in keys: lines.append(raw) + if lines and lines[-1].strip(): lines.append('') + for k, v in updates.items(): + if v: lines.append(f'{k}={v}') + path.write_text('\n'.join(lines).rstrip()+'\n', encoding='utf-8', newline='\n') + +def ensure_files(project): + d = project/'AirPlan'/'state'/'airxdb'; d.mkdir(parents=True, exist_ok=True) + ex = d/'remote-device.env.example' + if not ex.exists(): + ex.write_text('# AirXDB remote GUI device configuration\nAIRXDB_REMOTE_SSH_TARGET=user@host\nAIRXDB_REMOTE_SSH_PORT=22\nAIRXDB_REMOTE_SSH_OPTIONS=\nAIRXDB_REMOTE_WORKDIR=\nAIRXDB_REMOTE_SCREENSHOT_TOOL=auto\nAIRXDB_REMOTE_DISPLAY=\n', encoding='utf-8', newline='\n') + gi = d/'.gitignore'; old = gi.read_text(encoding='utf-8') if gi.exists() else '' + for item in ['remote-device.env','midscene.local.env']: + if item not in old.splitlines(): old = (old.rstrip()+f'\n{item}\n').lstrip() + gi.write_text(old, encoding='utf-8', newline='\n') + +def ssh_args(env, cmd): + target = env.get('AIRXDB_REMOTE_SSH_TARGET','') + if not target: raise SystemExit('AIRXDB_REMOTE_SSH_TARGET is required') + args = [shutil.which('ssh') or 'ssh'] + port = env.get('AIRXDB_REMOTE_SSH_PORT','22') + if port: args += ['-p', port] + opts = env.get('AIRXDB_REMOTE_SSH_OPTIONS','').strip() + if opts: args += shlex.split(opts, posix=True) + return args + [target, cmd] + +def run(env, cmd, timeout=60): + return subprocess.run(ssh_args(env, cmd), capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout) + +def remote_home(env): + r = run(env, 'printf %s "$HOME"', 15) + return r.stdout.strip() if r.returncode == 0 else '' + +def workdir(env): + if env.get('AIRXDB_REMOTE_WORKDIR'): return env['AIRXDB_REMOTE_WORKDIR'] + home = remote_home(env); return (home.rstrip('/')+'/.airxdb') if home else '.airxdb' + +def detect_tool(env): + tool = env.get('AIRXDB_REMOTE_SCREENSHOT_TOOL','auto').strip() + if tool and tool != 'auto': + r = run(env, f'command -v {q(tool)}', 15); return tool if r.returncode == 0 else '' + script = 'for t in gnome-screenshot spectacle scrot grim import screencapture; do command -v "$t" >/dev/null 2>&1 && { printf %s "$t"; exit 0; }; done' + r = run(env, script, 15) + return r.stdout.strip().splitlines()[-1] if r.returncode == 0 and r.stdout.strip() else '' + +def install_tool(env): + script = """set -e +if command -v scrot >/dev/null 2>&1; then exit 0; fi +if [ "$(id -u 2>/dev/null || echo 1)" = "0" ]; then SUDO=""; else SUDO="sudo -n"; fi +if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update && $SUDO apt-get install -y scrot; +elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y scrot; +elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y scrot; +elif command -v apk >/dev/null 2>&1; then $SUDO apk add scrot; +elif command -v pacman >/dev/null 2>&1; then $SUDO pacman -Sy --noconfirm scrot; +else exit 42; fi""" + return run(env, script, 300) + +def setup(project, env, auto=True): + if not env.get('AIRXDB_REMOTE_SSH_TARGET'): + return {'status':'blocked','reason':'missing_remote_target','example':str(project/'AirPlan'/'state'/'airxdb'/'remote-device.env.example')} + if not shutil.which('ssh'): return {'status':'blocked','reason':'ssh_not_found'} + probe = run(env, 'printf ok', 20) + if probe.returncode: return {'status':'blocked','reason':'ssh_probe_failed','stderr':probe.stderr.strip()} + wd = workdir(env); mk = run(env, f'mkdir -p {q(wd)}', 20) + if mk.returncode: return {'status':'blocked','reason':'remote_workdir_failed','stderr':mk.stderr.strip()} + tool = detect_tool(env); install = 'skipped' + if not tool and auto: + inst = install_tool(env); install = 'ok' if inst.returncode == 0 else f'failed:{inst.returncode}'; tool = detect_tool(env) + upd = {'AIRXDB_REMOTE_WORKDIR': wd} + if tool: upd['AIRXDB_REMOTE_SCREENSHOT_TOOL'] = tool + write_env(project/'AirPlan'/'state'/'airxdb'/'remote-device.env', upd) + return {'status':'ok' if tool else 'blocked','target':env.get('AIRXDB_REMOTE_SSH_TARGET',''),'workdir':wd,'screenshotTool':tool,'autoConfigure':install,'hint':'' if tool else 'Install scrot/gnome-screenshot/grim/spectacle/import/screencapture or set AIRXDB_REMOTE_SCREENSHOT_TOOL.'} + +def shot_cmd(tool, out, display): + pre = f'export DISPLAY={q(display)}; ' if display else '' + table = {'gnome-screenshot':f'gnome-screenshot -f {q(out)}','spectacle':f'spectacle -b -n -o {q(out)}','scrot':f'scrot {q(out)}','grim':f'grim {q(out)}','import':f'import -window root {q(out)}','screencapture':f'screencapture -x {q(out)}'} + if tool not in table: raise SystemExit(f'unsupported screenshot tool: {tool}') + return (pre if tool != 'screencapture' else '') + table[tool] + +def emit(prefix, data): + print(f'{prefix}_status={data.get("status","unknown")}') + for k, v in data.items(): + if k != 'status': print(f'{k}={json.dumps(v, ensure_ascii=False) if isinstance(v,(dict,list)) else v}') + +def main(): + ap = argparse.ArgumentParser(); ap.add_argument('--project', default='.'); ap.add_argument('--action', choices=['setup','status','screenshot'], default='setup'); ap.add_argument('--output-dir', default=''); ap.add_argument('--timeout', type=int, default=60); ap.add_argument('--no-auto-configure', action='store_true'); a = ap.parse_args() + project = Path(a.project).expanduser().resolve(); ensure_files(project); env = read_env(project/'AirPlan'/'state'/'airxdb'/'remote-device.env') + info = setup(project, env, auto=(a.action!='status' and not a.no_auto_configure)) + if a.action in ['setup','status'] or info.get('status') != 'ok': emit('airxdb_remote', info); raise SystemExit(0 if info.get('status')=='ok' else 2) + outdir = Path(a.output_dir).expanduser().resolve() if a.output_dir else project/'AirPlan'/'docs'/'debug'/'airxdb-artifacts'; outdir.mkdir(parents=True, exist_ok=True) + remote = info['workdir'].rstrip('/') + '/' + stamp() + '-airxdb-remote.png'; cap = run(env, shot_cmd(info['screenshotTool'], remote, env.get('AIRXDB_REMOTE_DISPLAY','')), a.timeout) + data = {'status':'ok' if cap.returncode == 0 else 'failed','target':info['target'],'tool':info['screenshotTool'],'remoteFile':remote,'stdout':cap.stdout.strip(),'stderr':cap.stderr.strip(),'capturedAt':iso()} + if cap.returncode == 0: + b64 = run(env, f'base64 < {q(remote)}', a.timeout) + local = outdir/(stamp()+'-airxdb-remote.png'); local.write_bytes(base64.b64decode(''.join(b64.stdout.split()))); data['screenshot'] = str(local) + report = outdir/(stamp()+'-airxdb-remote-device.json'); report.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8'); data['report'] = str(report) + if data['status'] == 'ok': + log = project/'AirPlan'/'docs'/'debug'/'gui-debug-log.md'; log.parent.mkdir(parents=True, exist_ok=True); log.open('a', encoding='utf-8', newline='\n').write(f"\n## {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: AirXDB remote screenshot\n\n- Remote target: `{data['target']}`\n- Screenshot: `{data.get('screenshot','')}`\n- Report: `{data['report']}`\n- AirDbg handoff: TODO\n- Residual risk: remote screenshots may contain sensitive data.\n") + emit('airxdb_remote', data); raise SystemExit(0 if data['status']=='ok' else 1) +if __name__ == '__main__': main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/install_airxdb_plugin.py b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/install_airxdb_plugin.py new file mode 100755 index 0000000..f863781 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/install_airxdb_plugin.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Install the AirXDB plugin into the current user's home-local plugin directory.""" + +from __future__ import annotations + +import argparse +import json +import shutil +from pathlib import Path +from typing import Any, Dict + +PLUGIN_NAME = "airxdb" + + +def copy_plugin(source: Path, target: Path) -> None: + if source.resolve() == target.resolve(): + return + if target.exists(): + shutil.rmtree(target) + ignore = shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store", ".git") + shutil.copytree(source, target, ignore=ignore) + + +def marketplace_payload() -> Dict[str, Any]: + return { + "name": "local-airxdb", + "interface": {"displayName": "Local AirXDB Plugins"}, + "plugins": [], + } + + +def update_marketplace(path: Path) -> None: + if path.exists(): + payload = json.loads(path.read_text(encoding="utf-8")) + else: + payload = marketplace_payload() + + payload.setdefault("name", "local-airxdb") + payload.setdefault("interface", {}).setdefault("displayName", "Local AirXDB Plugins") + plugins = payload.setdefault("plugins", []) + + entry = { + "name": PLUGIN_NAME, + "source": { + "source": "local", + "path": f"./plugins/{PLUGIN_NAME}", + }, + "policy": { + "installation": "INSTALLED_BY_DEFAULT", + "authentication": "ON_INSTALL", + }, + "category": "Productivity", + } + + for index, existing in enumerate(plugins): + if existing.get("name") == PLUGIN_NAME: + plugins[index] = entry + break + else: + plugins.append(entry) + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Install AirXDB as a home-local Codex plugin.") + parser.add_argument("--source", default=str(Path(__file__).resolve().parents[1])) + parser.add_argument("--home", default=str(Path.home())) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + source = Path(args.source).expanduser().resolve() + home = Path(args.home).expanduser().resolve() + target = home / "plugins" / PLUGIN_NAME + marketplace = home / ".agents" / "plugins" / "marketplace.json" + + copy_plugin(source, target) + update_marketplace(marketplace) + + print(f"installed_plugin={target}") + print(f"marketplace={marketplace}") + + +if __name__ == "__main__": + main() diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/skills/airxdb/SKILL.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/skills/airxdb/SKILL.md new file mode 100755 index 0000000..67269e0 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/skills/airxdb/SKILL.md @@ -0,0 +1,263 @@ +--- +name: airxdb +description: Midscene-based GUI debugging workflow. Use when the user invokes /airxdb, asks to debug browser UI, desktop UI, canvas UI, visual regressions, flaky interface interactions, remote GUI/device debugging over SSH, or reproduce graphical issues with Midscene.js. Load or initialize AirPlan/AGENTS.md, AirPlan/docs/architecture/adr/ decision records, and AirPlan/docs/architecture/c4/module.md; collaborate with AirDbg for GUI issues; choose the right Midscene mode for Playwright, Chrome bridge mode, desktop computer automation, MCP, or the AirXDB remote device helper; auto-configure remote screenshot tooling when missing; generate visual reproduction steps and reports; and maintain AirPlan/AGENTS.md, ADR, C4 module, and GUI debug logs whenever GUI-debug tooling or interface behavior changes. +--- + +# AirXDB + +## 核心约束 + +- 全程使用中文与用户交流,代码、命令、日志、路径、包名保持原文。 +- `/airxdb` 专用于图形界面和视觉交互层面的调试,不替代 `airdbg` 的通用根因分析职责。 +- 优先与 `airdbg` 配合: + - `airxdb` 负责界面复现、视觉定位、交互自动化、Midscene 报告与截图证据。 + - `airdbg` 负责代码层根因、最小修复、测试验证和通用调试收尾。 +- 先加载或初始化上下文:`AirPlan/AGENTS.md`、`AirPlan/docs/architecture/adr/`、`AirPlan/docs/architecture/c4/module.md`、`AirPlan/docs/debug/gui-debug-log.md`。 +- 如果这些文件不存在,先分析当前项目并初始化它们;C4 module 要反映真实模块边界,尤其是前端、UI、桌面桥接、自动化测试相关边界。 +- Midscene 路线优先使用视觉复现和 HTML 报告收集证据,不把 GUI 调试退化成纯日志猜测。 +- 截图取证是 AirXDB 的一等能力:即使没有语义模型配置,也可以连接 Computer MCP 截图,为 `airdbg` 提供错误现场、布局状态、弹窗、焦点和多显示器信息。 +- 远程设备、测试机、VM 或 SSH 主机上的 GUI 调试,先调用 `../../scripts/airxdb_remote_device.py`;缺少远程截图工具时允许脚本自动配置,无法无密码 `sudo` 或无包管理器时停止并提示用户。 +- 优先做最小可验证复现和最小必要修复,不顺手大改界面架构。 +- GUI 调试过程中,一定要维护 `AirPlan/AGENTS.md`、ADR、C4 module 和 `AirPlan/docs/debug/gui-debug-log.md`。 +- 如果需要 Midscene 包名、桥接/MCP 配置、常用命令,读取 [references/midscene-official-notes.md](references/midscene-official-notes.md)。 + +## 启动与初始化 + +进入 `/airxdb` 时运行: + +```bash +python ../../scripts/airxdb_mode.py --mode enter --project . +``` + +如果当前环境没有 `python`,尝试 `py` 或 `python3`。脚本不可用时,手动确保以下结构存在: + +- `AirPlan/AGENTS.md` +- `AirPlan/docs/architecture/adr/` +- `AirPlan/docs/architecture/c4/module.md` +- `AirPlan/docs/debug/gui-debug-log.md` +- `AirPlan/state/airxdb/state.json` + +初始化后读取已有内容作为上下文。不要覆盖用户已有正文;只补齐缺失结构或更新 AirXDB 标记块。 + +## 首次模型配置 + +AirXDB 第一次进入项目时必须检查 Midscene 模型配置。`airxdb_mode.py` 会输出 `midscene_config`,如果出现 `midscene_config_required=true` 或 `midscene_config=missing:...`: + +- 暂停执行 `act`、`Tap`、`Input`、`KeyboardPress`、视觉定位等语义动作。 +- 用中文向用户索取配置: + - `MIDSCENE_MODEL_NAME` + - `MIDSCENE_MODEL_BASE_URL` + - `MIDSCENE_MODEL_API_KEY` + - `MIDSCENE_MODEL_FAMILY` + - 可选:`MCP_SERVER_REQUEST_TIMEOUT` +- 告知用户可以只在当前会话设置环境变量,或写入 `AirPlan/state/airxdb/midscene.local.env` 方便后续复用。 +- `AirPlan/state/airxdb/midscene.local.env` 只保存本机密钥,默认由 `AirPlan/state/airxdb/.gitignore` 忽略;不要把真实 API key 写入 `AirPlan/AGENTS.md`、ADR、C4 或 debug log。 +- 如果用户暂时不提供模型配置,仍然可以做截图、MCP 连接、显示器枚举、环境探测等非语义取证操作;不能声称完成了 Midscene 视觉语义操作。 +- `MIDSCENE_MODEL_FAMILY` 是语义视觉动作必填项。`gpt-5.4` 这类 GPT-5.x 视觉模型使用 `gpt-5`。 +- 常见合法 family:`gpt-5`、`qwen2.5-vl`、`qwen3-vl`、`gemini`、`doubao-seed`、`vlm-ui-tars`。 + +## 截图取证模式 + +当用户需要给 `airdbg` 提供错误诊断信息、GUI 现场、弹窗、焦点状态、布局错位或多显示器证据时,优先使用截图取证模式: + +```bash +python ../../scripts/airxdb_computer_mcp_smoke.py --project . --action screenshot +``` + +截图取证模式不要求 `MIDSCENE_MODEL_NAME`、`MIDSCENE_MODEL_BASE_URL`、`MIDSCENE_MODEL_API_KEY` 或 `MIDSCENE_MODEL_FAMILY`。它只验证 Computer MCP 桌面连接和截图能力,并将截图/JSON 报告写入 `AirPlan/docs/debug/airxdb-artifacts/`。 + +截图后必须在 `AirPlan/docs/debug/gui-debug-log.md` 追加: + +- 截图目标和平台 +- 截图文件路径 +- 当前界面关键观察 +- 给 `airdbg` 的诊断线索 +- 是否还需要语义视觉动作或代码层修复 + +如果截图可能包含密钥、聊天内容、账号、客户数据或隐私信息,在对外分享前提醒用户脱敏。 + +## 远程设备截图模式 + +当用户说明目标在远程设备、测试机、服务器、VM、SSH 主机,或当前桌面不是目标 GUI 所在机器时,不要先使用本机 Computer MCP。先运行远程设备 helper: + +```bash +python ../../scripts/airxdb_remote_device.py --project . --action setup +``` + +如果当前环境没有 `python`,尝试 `py`、`python3` 或用户提供的 Python 绝对路径。首次运行会生成 `AirPlan/state/airxdb/remote-device.env.example`;把连接信息写入 `AirPlan/state/airxdb/remote-device.env` 或当前环境变量: + +- `AIRXDB_REMOTE_SSH_TARGET=user@host` +- `AIRXDB_REMOTE_SSH_PORT=22` +- `AIRXDB_REMOTE_SSH_OPTIONS=` +- `AIRXDB_REMOTE_WORKDIR=` +- `AIRXDB_REMOTE_SCREENSHOT_TOOL=auto` +- `AIRXDB_REMOTE_DISPLAY=` + +远程 helper 行为: + +- 检查本机 `ssh`、远程连通性和远程工作目录。 +- 探测 `gnome-screenshot`、`spectacle`、`scrot`、`grim`、`import`、`screencapture`。 +- 工具缺失时自动尝试用远端包管理器安装 `scrot`,只使用非交互式 `sudo -n`;需要密码、管理员确认或无支持的包管理器时停止并提示用户。 +- 将可复用配置写入 `AirPlan/state/airxdb/remote-device.env`,该文件由 `AirPlan/state/airxdb/.gitignore` 忽略。 + +远程截图: + +```bash +python ../../scripts/airxdb_remote_device.py --project . --action screenshot +``` + +远程截图和 JSON 报告写入 `AirPlan/docs/debug/airxdb-artifacts/`,并追加 `AirPlan/docs/debug/gui-debug-log.md`。远程截图只能证明远端截图链路和当前 GUI 现场;如果需要 Midscene 语义视觉动作,仍需单独确认远端或本机可用的 Midscene 接入方式。 + +## Computer MCP 快速验证 + +验证桌面 GUI 能力时优先运行 smoke test 脚本,而不是临时拼 MCP 客户端: + +```bash +python ../../scripts/airxdb_computer_mcp_smoke.py --project . --action mousemove --prompt "Windows taskbar Start button" +``` + +如果当前环境没有 `python`,尝试 `py`、`python3` 或用户提供的 Python 绝对路径。 + +脚本行为: + +- 加载 `AirPlan/state/airxdb/midscene.local.env`,但输出和 JSON 报告会屏蔽 API key;`--action screenshot` 不要求模型配置。 +- 启动 `@midscene/computer-mcp` HTTP 服务。 +- Windows 下自动检查并修复 `screenCapture_1.3.2.bat` / `app.manifest` 缺失问题;修复来源是 `screenshot-desktop@1.15.3` 官方 npm 包。 +- 执行 `computer_connect`、可选语义动作、`take_screenshot`、`computer_disconnect`。 +- 证据写入 `docs/debug/airxdb-artifacts/`,包括截图和 `airxdb-smoke.json` 报告。 + +结果判断: + +- `airxdb_smoke=ok` 才能说明 Computer MCP 视觉链路通过。 +- 如果只完成截图,没有完成 `MouseMove` / `act` / `Tap` 等语义动作,只能说“截图/连接可用”,不能说“视觉语义操作已通过”。 +- 如果报 `MIDSCENE_MODEL_FAMILY is not set to a visual language model`,先补 family;`gpt-5.4` 用 `gpt-5`。 +- Windows 开始菜单 + 中文输入法场景下,输入查询词后可能需要双回车:第一次提交输入法,第二次执行启动。 + +## Midscene 模式选择 + +先判断目标界面和现有技术栈,再选 Midscene 模式: + +1. Web + 已有 Playwright: + - 优先使用 Midscene 的 Playwright 集成。 + - 适合已有 E2E、页面复现、交互不稳定、视觉断言场景。 +2. Web + 需要复用本地 Chrome 状态: + - 使用 Chrome Bridge Mode。 + - 适合需要复用 cookies、扩展、已登录会话、人工介入浏览器态的场景。 +3. 桌面应用 GUI: + - 使用 Midscene Computer / Playground。 + - 适合 Electron、Qt、WPF、原生应用和跨应用流程。 +4. 远程设备 GUI: + - 先使用 AirXDB remote device helper 取证和自动配置远端截图工具。 + - 适合 SSH 可达的测试机、VM、服务器桌面或远程 Linux/macOS GUI。 +5. 需要把 GUI 操作暴露给上层 Agent 或工具链: + - 使用 Midscene MCP。 + - 浏览器优先 Web Bridge MCP,桌面优先 Computer MCP。 + +不确定时,一次只问一个关键问题: + +- 这是浏览器页面还是桌面应用? +- 项目里是否已有 Playwright? +- 是否必须复用本机浏览器登录态? +- 是要快速复现,还是要沉淀成长期自动化脚本? + +## GUI 调试流程 + +1. 明确问题边界: + - 哪个界面、哪条交互链路、什么平台。 + - 期望行为和实际行为。 + - 是否涉及视觉错位、点击不到、浮层遮挡、Canvas、焦点问题、窗口切换、多显示器等。 +2. 加载上下文: + - 读取 `AGENTS.md`。 + - 读取相关 ADR。 + - 读取 `docs/architecture/c4/module.md`。 + - 查看前端/桌面自动化相关代码、测试、构建配置。 +3. 选择 Midscene 模式并做最小复现: + - Playwright + - Chrome Bridge + - Computer / Playground + - MCP +4. 生成可回放证据: + - Midscene HTML 报告 + - 关键截图 + - 复现命令 + - 相关日志和报错 +5. 将证据和观察写入 `AirPlan/docs/debug/gui-debug-log.md`。 +6. 如果问题只是界面复现层,继续用 `airxdb` 深挖。 +7. 如果已定位到代码层根因,转入或并行配合 `airdbg` 做修复。 +8. 修复后再次用 Midscene 复跑关键 GUI 路径,确认问题关闭。 + +## 与 AirDbg 的协作 + +- `airxdb` 先做: + - GUI 复现 + - 视觉定位 + - 界面交互脚本/桥接/MCP 配置 + - 报告与截图证据 +- `airdbg` 再做: + - 根因代码分析 + - 修复实现 + - 测试验证 + - 风险收尾 + +如果当前问题同时包含“界面复现难”和“代码根因不明”,先用 `airxdb` 稳定复现,再把复现结论和报告交给 `airdbg`。 + +## AGENTS.md 维护 + +在以下情况更新 `AGENTS.md`: + +- 发现新的 GUI 调试命令、Playwright 命令、Midscene 运行方式。 +- 发现系统权限要求,例如桌面自动化权限、屏幕录制权限、多显示器限制。 +- 发现影响后续 GUI 调试的重要约束,如浏览器桥接、登录态、测试环境、显示缩放。 +- 发现远程设备 SSH 入口、远程截图工具、`AIRXDB_REMOTE_*` 配置方式或远端显示环境限制。 +- 引入了新的 GUI 自动化脚本、报告目录或运行前置条件。 + +内容保持可执行、可复用,不写流水账。 + +## ADR 维护 + +目录:`docs/architecture/adr/`。 + +需要 ADR 的情况: + +- 决定长期采用某种 Midscene 接入方式,例如 Playwright 集成、Bridge Mode、Computer、MCP。 +- GUI 调试方案改变了测试边界、前端交互契约、浏览器控制方式、桌面自动化权限模型。 +- 为了稳定复现而新增长期保留的自动化脚本、报告流程或辅助基础设施。 + +ADR 保持简洁:Context、Decision、Consequences、Alternatives。 + +## C4 Module 维护 + +文件:`docs/architecture/c4/module.md`。 + +当 GUI 调试或修复改变以下内容时,必须更新: + +- 前端模块边界 +- 自动化测试边界 +- 浏览器桥接/桌面控制边界 +- 报告和调试基础设施 +- UI 层和服务层之间的数据所有权或依赖 + +## GUI Debug Log 维护 + +文件:`AirPlan/docs/debug/gui-debug-log.md`。 + +每次 AirXDB 会话至少追加: + +- 问题摘要 +- 目标平台和界面 +- Midscene 模式(Playwright / Bridge / Computer / MCP) +- 是否使用远程设备 helper 以及远程目标、截图工具和限制 +- 复现步骤或命令 +- 报告文件路径 +- 截图或关键观察 +- 转交给 `airdbg` 的结论,或已完成的修复验证 +- 剩余风险 + +## 输出格式 + +本轮 GUI 调试结束时,用中文简洁汇报: + +- 选择了哪种 Midscene 模式,为什么。 +- 复现是否成功,证据在哪里。 +- 更新了哪些 `AGENTS.md` / ADR / C4 / GUI debug log。 +- 是否需要切给 `airdbg` 继续做代码层修复。 diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/skills/airxdb/agents/openai.yaml b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/skills/airxdb/agents/openai.yaml new file mode 100755 index 0000000..78f95a4 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/skills/airxdb/agents/openai.yaml @@ -0,0 +1,3 @@ +name: airxdb +short_description: Midscene GUI debug workflow with local and remote screenshot evidence +default_prompt: "使用 AirXDB 配合 AirDbg 做图形界面调试;远程设备优先调用 remote device helper 并自动配置截图工具。" diff --git a/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/skills/airxdb/references/midscene-official-notes.md b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/skills/airxdb/references/midscene-official-notes.md new file mode 100755 index 0000000..ef1ac04 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/skills/airxdb/references/midscene-official-notes.md @@ -0,0 +1,159 @@ +# Midscene Official Notes + +用于 `airxdb` 的轻量参考,不替代官方文档。 + +## 选择模式 + +- Web + 现有 Playwright 项目: + - 优先 Midscene Playwright 集成 + - 适合浏览器页面复现、E2E、界面交互不稳定问题 +- Web + 需要复用本机 Chrome 的 cookies / 已登录状态 / 扩展: + - 使用 Chrome Bridge Mode +- 桌面应用 GUI: + - 使用 Midscene Computer 或 Playground +- 需要给上层 Agent / MCP 客户端暴露 GUI 操作: + - 浏览器用 Web Bridge MCP + - 桌面用 Computer MCP + +## 关键能力 + +- Midscene 的 UI 操作以纯视觉为主,可用于 Web、移动端、桌面端和 Canvas。 +- Midscene 支持生成 HTML 报告,适合作为 GUI debug 证据。 +- Midscene 可通过 MCP 暴露截图和动作空间操作。 + +## 首次模型配置 + +AirXDB 第一次进入项目时先检查这些变量: + +```bash +MIDSCENE_MODEL_NAME +MIDSCENE_MODEL_BASE_URL +MIDSCENE_MODEL_API_KEY +MIDSCENE_MODEL_FAMILY +MCP_SERVER_REQUEST_TIMEOUT +``` + +前三个是连接模型服务的基本配置。`MIDSCENE_MODEL_FAMILY` 是视觉语义动作必填项,`MCP_SERVER_REQUEST_TIMEOUT` 按模型服务情况补充。 + +常见 `MIDSCENE_MODEL_FAMILY`: + +- `gpt-5`:GPT-5.x 视觉模型,例如 `gpt-5.4` +- `qwen2.5-vl` +- `qwen3-vl` +- `gemini` +- `doubao-seed` +- `vlm-ui-tars` + +不要把真实 API key 写入 ADR、C4、debug log 或可提交文档。需要本机持久化时优先使用 `AirPlan/state/airxdb/midscene.local.env`。 + +## Chrome Bridge Mode + +- 官方说明: + - 需要 Midscene Chrome 插件 + - 终端侧配置模型环境变量 + - 适合复用本地浏览器登录态和页面状态 +- 常用依赖: + +```bash +npm install @midscene/web tsx --save-dev +``` + +- 常见入口: + +```ts +import { AgentOverChromeBridge } from "@midscene/web/bridge-mode"; +``` + +- 常见运行方式: + +```bash +tsx demo-new-tab.ts +``` + +- 常见模型环境变量: + +```bash +MIDSCENE_MODEL_BASE_URL +MIDSCENE_MODEL_API_KEY +MIDSCENE_MODEL_NAME +MIDSCENE_MODEL_FAMILY +``` + +## MCP + +- 浏览器桥接 MCP: + +```text +@midscene/web-bridge-mcp +``` + +- 桌面 MCP: + +```text +@midscene/computer-mcp +``` + +- Computer MCP 常见配置核心: + +```json +{ + "command": "npx", + "args": ["-y", "@midscene/computer-mcp"] +} +``` + +- 常见 MCP 模型环境变量: + +```bash +MIDSCENE_MODEL_BASE_URL +MIDSCENE_MODEL_API_KEY +MIDSCENE_MODEL_NAME +MIDSCENE_MODEL_FAMILY +MCP_SERVER_REQUEST_TIMEOUT +``` + +## 桌面自动化 + +- Midscene 支持 Windows、macOS、Linux 桌面自动化。 +- 桌面控制包括鼠标、键盘、截图、多显示器。 +- Linux 可在 Xvfb 下做无头执行。 +- Windows 下 `@midscene/computer-mcp` 的 npx 缓存包可能缺 `dist/screenCapture_1.3.2.bat` 和 `dist/app.manifest`。AirXDB smoke test 会从 `screenshot-desktop@1.15.3` npm 包自动补齐。 +- 桌面 GUI 调试可优先考虑: + - 快速试用:Playground + - 持续脚本化:Computer SDK / MCP + +## AirXDB Smoke Test + +```bash +python "$HOME/plugins/airxdb/scripts/airxdb_computer_mcp_smoke.py" --project . --action mousemove --prompt "Windows taskbar Start button" +``` + +输出: + +- `airxdb_smoke=ok`:Computer MCP 连接、截图、语义动作完成。 +- `airxdb_smoke=blocked`:缺模型配置或 family 不合法。 +- `asset_repair=repaired`:已补齐 Windows 截图脚本。 +- `report=`:JSON 报告,API key 已脱敏。 + +## AirXDB Screenshot Evidence + +截图取证不需要模型配置: + +```bash +python "$HOME/plugins/airxdb/scripts/airxdb_computer_mcp_smoke.py" --project . --action screenshot +``` + +用于: + +- 给 `airdbg` 提供 GUI 错误现场 +- 捕获弹窗、遮挡、焦点、布局错位、任务栏/托盘状态 +- 记录多显示器和当前桌面状态 + +截图可能包含敏感信息。写入 ADR/C4/debug log 时记录路径和观察,不复制密钥或隐私内容。 + +## GUI Debug 推荐策略 + +1. 先选模式,不要一上来混用多种接入。 +2. 先做最小复现,再考虑长期自动化。 +3. 保留 HTML 报告、截图和复现命令。 +4. 若问题已定位到代码层,把证据交给 `airdbg` 做修复。 diff --git a/AirPlan/docs/spec/AirPlan-Para_V1.0.0_CTO版_参考.md b/AirPlan/docs/spec/AirPlan-Para_V1.0.0_CTO版_参考.md new file mode 100755 index 0000000..ea11f95 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-Para_V1.0.0_CTO版_参考.md @@ -0,0 +1,215 @@ +# AirPlan-Para V1.0.0:面向 CTO 与架构负责人的 AI 工程治理参考稿 + +## 一句话定位 + +AirPlan-Para V1.0.0 是一套面向 Claude Code / Codex 本地工作流的 AI 工程治理框架。它已经把规划、调度、执行、调试、GUI 证据、网络证据、静态分析和长会话恢复固化为本地插件体系,用来解决 AI 辅助开发在真实项目中的稳定性和可追溯性问题。其中 aireng 作为公共调度层,多数时候处于非占用状态,开发者可以随时介入交流、补充约束或更正方向;同时支持并行派发多个隔离 worker,将串行等待变为并发推进,大幅压缩交付周期。 + +## 它解决的不是“能不能写代码”,而是“能不能稳定交付” + +多数 coding agent 的问题不在于完全不会改代码,而在于工程行为不稳定: + +- 新会话无法稳定继承历史上下文 +- 任务状态只存在聊天记录中,无法断点续作 +- 规划、执行、调试之间缺少统一的工作流契约 +- GUI 验证容易把进程存活误判为通过 +- 网络问题缺少 packet-level evidence +- C/C++ 项目缺少静态分析质量门 +- 长会话 compact 后上下文语义容易漂移 +- 多个子任务并行时容易出现上下文串扰或结果文件误读 +- 调度过程像一个黑盒,开发者难以在任务执行中即时介入调整方向 +- 串行执行模式下,独立子任务无法并发推进,整体交付周期被线性拉长 + +AirPlan-Para 当前版本的价值,在于把这些问题分别落到明确的插件职责、项目文件和运行时状态里。 + +## 当前版本的真实套件边界 + +AirPlan-Para 当前是 **8 插件** 体系: + +- `airarc 0.3.1` +- `aireng 0.6.0` +- `airdo 0.5.0` +- `airdbg 0.1.4` +- `airxdb 0.2.2` +- `airndb 0.1.2` +- `airsdb 0.1.1` +- `aircontext 0.1.0` + +其中: + +- 前 7 个插件构成围绕 `AirPlan/` 工作流根的规划-调度-执行-调试-证据链 +- `aircontext` 负责同一套件中的上下文压缩与自动恢复层 + +## 当前版本已经实现的核心能力 + +### 1. 结构化项目上下文 + +系统围绕 `AirPlan/` 工作流根运行,并维护以下关键资产: + +- `AirPlan/AGENTS.md` +- `AirPlan/plan.md` +- `AirPlan/todo.md` +- `AirPlan/docs/architecture/adr/` +- `AirPlan/docs/architecture/c4/module.md` +- `AirPlan/docs/debug/debug-log.md` +- `AirPlan/docs/debug/gui-debug-log.md` +- `AirPlan/docs/network/airndb-log.md` +- `AirPlan/docs/staticanalysis.md` +- `AirPlan/state/` +- `AirPlan/AirContext/` + +其中 `airarc` 与 `aireng` 已经支持首次启动时自动补齐缺失的 `AirPlan/` bootstrap 工件;`aircontext` 则在需要时维护 `AirPlan/AirContext/` 中的压缩规则、状态与 snapshots。 + +### 2. 规划与并发评审 + +`airarc` 当前已经实现: + +- architecture-first planning +- `plan.md` / `todo.md` 驱动的执行协议输出 +- post-plan parallel review +- dependency edges、parallel groups、shared write-set conflicts、serialization points 输出 +- 供调度层优先读取的 execution plan 工件 + +### 3. 受控调度与结果合并 + +`aireng` 是整个套件的公共调度层,其设计有两个关键优势,直接区别于常见的单线程 agent 执行模式: + +**随时可介入的调度层。** `aireng` 在派发 worker 后多数时候处于非占用状态——它不需要像 worker 一样持续消耗上下文窗口去执行具体任务。这意味着开发者在任务执行期间可以随时与 aireng 交流:补充遗漏信息、调整执行约束、重新排定优先级,或是在发现方向偏差时立即介入更正。调度不是一个黑盒,而是一个开放的协作节点。 + +**并行推进替代串行等待。** `aireng` 按 bounded concurrency 策略将独立子任务并行派发给多个隔离的 `airdo` worker。不再是“做完一个再看下一个”,而是同一波次内多个 worker 并发执行,互不阻塞。对于有多个独立模块、多组件并行开发的项目,这种模式大幅压缩了端到端交付时间。 + +在此基础上,`aireng` 当前已经实现: + +- 读取 `airarc` execution artifacts +- 生成 dispatch manifest +- 按 bounded concurrency 调度多个隔离 `airdo` worker +- 使用任务级 handoff 保持上下文隔离 +- 合并结构化 worker result +- 自动准备 repair dispatch,并维护 repair queue +- 在 merge 过程中同步 `plan.md`、`todo.md`、ADR 和 C4 文档 + +同时,`aireng` 已经明确使用 `worker-state.json` 中的 `resultPath` 作为 canonical finalized result locator,避免把任务目录里的模板 `result.json` 误当成最终结果。 + +### 4. 单任务执行与 finalize 完整性保护 + +`airdo` 当前已经实现: + +- 单任务 scoped execution +- task-local brief 与 subagent handoff +- 结构化 `result.json` +- finalize 后写入 `AirPlan/state/airdo/results/.json` +- `worker-state.json` 记录 canonical result path +- GUI 任务自动衔接 `airxdb` +- blocked 或 failed validation 自动衔接 `airdbg` +- active repair brief 下继续执行而不是停在中间状态 + +当前运行时还带有 finalize guard: + +- untouched 默认模板不能直接 finalize +- `done` 结果不能是逻辑空载荷 +- `done` 结果至少要带上变更、验证、证据或文档更新之一 +- 无真实 blocker 时,worker 默认继续执行到 finalize,而不是停在“准备实施”或泛化进度汇报 + +### 5. Debug-first 维修闭环 + +`airdbg` 当前已经实现 debug-first repair workflow: + +- reproduce -> evidence -> RCA -> minimal fix -> verification +- GUI 相关问题必须引入 `airxdb` 或等效 GUI/屏幕证据 +- 网络问题可调用 `airndb` +- C/C++ 静态分析问题可调用 `airsdb` +- 持续维护 `AGENTS.md`、ADR、C4 module 和 `debug-log.md` + +### 6. GUI / 网络 / 静态分析证据层 + +AirPlan-Para 当前已交付 3 个专门的证据插件: + +- `airxdb` + - Midscene-based GUI 调试 + - 截图证据 + - Computer MCP smoke test + - model-family gating + - 远程设备截图 helper + - Windows 截图资产修复 + +- `airndb` + - bounded local / remote tcpdump or WinDump capture + - Windows 首次启动自动下载官方 WinDump.exe + - BPF filters + - pcap 读取与摘要 + - 远程 SSH 抓包辅助 + +- `airsdb` + - 本机与远程 cppcheck + - `--check-level=exhaustive` + - XML / JSON 报告 + - `staticanalysis.md` AI-readable 摘要 + +### 7. 长会话压缩与自动恢复层 + +`aircontext` 当前实现的是同一套件里的 session continuity layer: + +- 通过 `aircontext` wrapper 启动 Claude Code +- 在 `SessionStart`、`UserPromptSubmit`、`PostToolUse`、`PreCompact` 上接入 hook +- 基于阈值和冷却时间决定何时 compact +- 调用外部 OpenAI-compatible LLM 按规则压缩当前活跃链 +- 把摘要与 continuation prompt 写回 session JSONL +- 自动执行 `claude --resume ` 继续长任务 +- 在项目内维护 `AirPlan/AirContext/` + +## 管理层视角的价值 + +从 CTO 或架构负责人角度看,AirPlan-Para 的价值不在“多一个插件”,而在于它已经把 AI 工程中的关键风险做了制度化约束: + +- 用项目文件代替临时聊天上下文 +- 用规划工件约束后续调度 +- 用隔离 worker 替代大上下文单线程执行 +- 用结构化 result 和 worker-state 约束交付结果 +- 用 GUI / 网络 / 静态分析证据降低误判 +- 用 session compaction policy 降低长会话语义漂移 +- 用 repair queue 和可恢复状态提升中断后的连续性 +- 用非占用式调度层保持开发者对执行过程的可见性与即时应变能力 +- 用隔离并行执行替代串行等待,以并发换交付速度 + +## 当前交付清单 + +### 插件清单(8 个) + +- `airarc 0.3.1` +- `aireng 0.6.0` +- `airdo 0.5.0` +- `airdbg 0.1.4` +- `airxdb 0.2.2` +- `airndb 0.1.2` +- `airsdb 0.1.1` +- `aircontext 0.1.0` + +### 部署入口 + +- `AirPlan-Para.zip` +- `install_to_home.ps1` +- `install_to_home.cmd` +- `init_project_airplan.ps1` + +## 边界说明 + +当前插件体系已经明确支持: + +- 多会话上下文恢复 +- 任务级 SubAgent 隔离执行 +- 受控并发调度 +- GUI / 网络 / C/C++ 证据驱动验证 +- 调试与修复闭环 +- 规则驱动 compact 与自动 resume + +当前插件体系不应被宣传为: + +- 容器级 runtime sandbox +- 完整的企业 SaaS 平台 +- 面向多个账号的 broker / relay 调度系统 +- 已实现的 file lock / document lock runtime + +## 结论 + +AirPlan-Para V1.0.0 已经不是“若干 AI 插件的集合”,而是一套具备规划、调度、执行、调试、证据和恢复能力的本地 AI 工程治理框架。其中 aireng 的独特价值在于:既通过并行调度把交付效率从串行约束中释放出来,又以非占用式的设计让开发者始终可以介入协作——调度不是黑盒,效率不以失控为代价。 + +它解决的核心问题,不是让 AI 开始写代码,而是让 AI 在真实项目里更稳定、更快速、更可协作地持续交付。 diff --git a/AirPlan/docs/spec/AirPlan-Para_V1.0.0_官网发布页版_参考.md b/AirPlan/docs/spec/AirPlan-Para_V1.0.0_官网发布页版_参考.md new file mode 100755 index 0000000..7aa9d06 --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-Para_V1.0.0_官网发布页版_参考.md @@ -0,0 +1,142 @@ +# AirPlan-Para V1.0.0 +## 让 AI 开发从“能写代码”升级到“能稳定交付” + +AirPlan-Para 是一套已经落地的 AI 工程插件套件。它面向 Claude Code / Codex 本地工作流,把规划、调度、执行、调试、GUI 证据、网络抓包、静态分析和长会话恢复整合成一个可恢复、可追溯、可验证的工程系统。其调度层 aireng 采用非占用式设计,开发者可随时介入调整约束与方向;同时以隔离并行执行替代串行等待,显著加快多任务交付速度。 + +## 当前版本已经实现的核心能力 + +### AirArc:规划 + 并行评审 + +- architecture-first planning +- `plan.md` / `todo.md` 驱动 +- dependency edges +- parallel groups +- shared write-set conflicts +- serialization points +- execution plan 输出 + +### AirEng:调度 + 合并 + +作为公共调度层,aireng 有两个核心设计优势:一是派发 worker 后多数时候处于非占用状态,开发者可随时交流补充信息、调整约束或介入更正,调度不是黑盒;二是以 bounded concurrency 策略并行派发多个隔离 worker,将串行等待变为并发推进,大幅压缩交付周期。 + +- 读取 AirArc execution artifacts +- 生成 dispatch manifest +- bounded concurrency 调度多个隔离 AirDo worker +- 合并结构化结果 +- 准备 repair dispatch 与 repair queue +- 同步 `plan.md`、`todo.md`、ADR、C4 + +### AirDo:单任务执行 + finalize + +- task-local brief / handoff +- 结构化 `result.json` +- finalize 到 `AirPlan/state/airdo/results/` +- `worker-state.json` 记录 canonical result path +- GUI 任务自动接 AirXDB +- blocked / failed validation 自动接 AirDbg + +### AirDbg:Debug-first repair + +- reproduce +- root cause analysis +- minimal fix +- regression verification +- 联动 AirXDB / AirNDB / AirSDB +- 维护 AGENTS、ADR、C4、debug log + +### AirXDB:GUI 证据 + +- Midscene GUI 调试 +- 本地与远程截图 +- Computer MCP smoke +- model-family 检查 +- Windows 截图资产修复 + +### AirNDB:网络证据 + +- bounded local / remote tcpdump or WinDump +- Windows 自动下载官方 WinDump.exe +- BPF filter +- pcap 读取与摘要 +- 远程 SSH 抓包辅助 + +### AirSDB:C/C++ 静态分析 + +- 本机与远程 cppcheck +- `--check-level=exhaustive` +- XML / JSON 报告 +- `staticanalysis.md` AI-readable 摘要 + +### AirContext:上下文压缩 + 自动恢复 + +- 规则驱动的外部上下文压缩 +- `aircontext` wrapper 自动恢复会话 +- 在 `AirPlan/AirContext/` 维护每项目配置、规则和 snapshot +- 为 AirArc / AirEng / AirDbg / generic workflow 提供不同压缩模板 + +## 结果完整性保护 + +AirPlan-Para 不允许“看起来完成、实际上没做完”的空结果混进交付流。 + +当前版本已经实现: + +- untouched 默认模板不能 finalize +- `done` 结果不能是逻辑空载荷 +- `worker-state.json` 中的 `resultPath` 是 canonical 结果定位 +- 无真实 blocker 时,worker 默认继续执行到 finalize + +## 自动项目初始化 + +`airarc` 和 `aireng` 在首次启动时会自动检测并补齐缺失的 `AirPlan/` bootstrap 工件,无需先手动初始化一次项目。 + +使用 `aircontext` 时,套件还会在项目内维护: + +- `AirPlan/AirContext/config.yaml` +- `AirPlan/AirContext/rules.md` +- `AirPlan/AirContext/active_rules.md` +- `AirPlan/AirContext/state.json` +- `AirPlan/AirContext/snapshots/` + +## 插件组成 + +AirPlan-Para V1.0.0 当前包含 8 个插件: + +- `airarc 0.3.1` +- `aireng 0.6.0` +- `airdo 0.5.0` +- `airdbg 0.1.4` +- `airxdb 0.2.2` +- `airndb 0.1.2` +- `airsdb 0.1.1` +- `aircontext 0.1.0` + +## 部署方式 + +部署包名称: + +- `AirPlan-Para.zip` + +安装入口: + +- `install_to_home.ps1` +- `install_to_home.cmd` +- `init_project_airplan.ps1` + +仓库中同时包含 `AirContext/` 目录,用于套件里的 context continuity 插件。 + +## 适合什么场景 + +- 长周期项目 +- 多会话恢复 +- 任务级隔离执行 +- 受控并发调度,以并行执行缩短交付周期 +- 需要在 AI 执行过程中随时介入、调整方向的团队 +- GUI、网络、静态分析参与交付验证 +- C/C++、Qt、嵌入式、远程设备 +- 需要工程证据和调试轨迹的团队 +- 需要控制 compact 策略并自动恢复长会话的团队 + +## 一句话总结 + +如果普通 coding agent 解决的是“让 AI 开始写代码”, +那么 AirPlan-Para 解决的是“让 AI 在真实项目里更稳定、更快速、更可协作地持续交付”。 diff --git a/AirPlan/docs/spec/AirPlan-Para_V1.0.0_白皮书版_参考.md b/AirPlan/docs/spec/AirPlan-Para_V1.0.0_白皮书版_参考.md new file mode 100755 index 0000000..4fd64cd --- /dev/null +++ b/AirPlan/docs/spec/AirPlan-Para_V1.0.0_白皮书版_参考.md @@ -0,0 +1,286 @@ +# AirPlan-Para V1.0.0 白皮书参考稿 + +## 摘要 + +AirPlan-Para V1.0.0 是一套面向 Claude Code / Codex 本地工作流的 AI 工程插件套件。它通过 `AirPlan/` 工作流根、结构化上下文资产、任务级 handoff、受控调度、结构化结果合并以及证据驱动验证,解决 AI 辅助开发中的上下文衰减、调试失稳、GUI 误判、网络不可见性与 C/C++ 静态分析缺口等问题。其调度层 aireng 采用非占用式设计,保持开发者对执行过程的全程可见与即时介入能力;同时以隔离并行派发替代串行执行,将独立子任务的交付从线性等待中解放出来。 + +当前真实交付应准确描述为:**8 个插件构成的一体化套件**。其中 `airarc`、`aireng`、`airdo`、`airdbg`、`airxdb`、`airndb`、`airsdb` 负责规划到证据闭环,`aircontext` 负责同一套件内的规则驱动压缩与自动 resume。 + +## 1. 背景问题 + +在长期软件工程中,AI 辅助开发通常会暴露以下结构性问题: + +- 会话级上下文不可持续继承 +- 任务状态缺少持久化载体 +- 规划、执行、调试彼此脱节 +- GUI 行为缺少可靠 test oracle +- 网络问题缺少 packet-level evidence +- C/C++ 风险缺少 static analysis gate +- 多任务执行下,结果路径与最终交付产物容易混淆 +- 默认 compact 策略与当前工程目标不匹配 +- 调度执行过程缺少透明介入点,开发者难以在任务中途补充信息或纠正方向 +- 串行执行模式下独立子任务互相等待,整体周期被不必要地拉长 + +这些问题本质上不是单一模型能力问题,而是缺少可持久化的 canonical context、明确的 workflow contract 和运行时状态约束。 + +## 2. 系统目标 + +AirPlan-Para 当前版本的目标是: + +- 将项目级关键上下文外部化为文件资产 +- 让规划层输出可被调度层直接消费的执行工件 +- 用任务级 handoff 隔离执行上下文 +- 用结构化 result 和 worker-state 管理交付结果 +- 用 GUI / 网络 / 静态分析证据驱动验证 +- 用 repair queue 和 debug workflow 提升失败路径可恢复性 +- 用 `aircontext` 将 compact 策略外部化并在长会话后自动恢复执行 +- 让调度层保持非占用状态,使开发者可随时介入、补充信息或调整约束 +- 以隔离并行执行替代串行等待,用并发换交付速度 + +## 3. 核心上下文资产 + +系统当前围绕以下上下文资产运行: + +```text +AirPlan/AGENTS.md +AirPlan/plan.md +AirPlan/todo.md +AirPlan/docs/architecture/adr/ +AirPlan/docs/architecture/c4/module.md +AirPlan/docs/debug/debug-log.md +AirPlan/docs/debug/gui-debug-log.md +AirPlan/docs/network/airndb-log.md +AirPlan/docs/staticanalysis.md +AirPlan/state/ +AirPlan/AirContext/ +``` + +这些资产分别承担: + +- 项目入口与规则 +- 执行计划与质量门 +- 任务状态账本 +- 架构决策与模块边界 +- 调试轨迹 +- GUI 调试证据 +- 网络证据 +- 静态分析证据 +- 调度与 worker 运行时状态 +- compact / resume 配置与会话快照 + +`airarc` 与 `aireng` 已经支持在首次启动时自动补齐缺失的 `AirPlan/` bootstrap 工件;`aircontext` 则在使用时维护 `AirPlan/AirContext/`。 + +## 4. 插件架构 + +AirPlan-Para V1.0.0 当前包含以下 8 个插件: + +- `airarc 0.3.1` +- `aireng 0.6.0` +- `airdo 0.5.0` +- `airdbg 0.1.4` +- `airxdb 0.2.2` +- `airndb 0.1.2` +- `airsdb 0.1.1` +- `aircontext 0.1.0` + +### 4.1 AirArc + +`airarc` 是 architecture-first planning 插件,当前实现能力包括: + +- 项目规划上下文初始化 +- `plan.md` / `todo.md` 驱动的 planning workflow +- post-plan parallel review +- dependency edges、parallel groups、shared write-set conflicts、serialization points 输出 +- execution plan 产物生成 + +### 4.2 AirEng + +`aireng` 是公共调度插件,在整个套件中承担“将规划转化为执行、再将执行结果收敛为交付产物”的枢纽角色。其设计有两个核心优势: + +**非占用式的开放调度。** 与 worker 不同,aireng 在派发任务后不持续占用上下文窗口去执行具体工作。它多数时候处于空闲可响应状态,开发者在任务执行过程中可以随时与 aireng 对话:补充遗漏的需求细节、调整某个 worker 的约束条件、重新排定剩余任务的优先级,或在发现某个 worker 方向偏差时立即要求更正。调度不是一次性的人工输入,而是一个持续的协作过程。 + +**并行执行替代串行等待。** aireng 按 bounded concurrency 策略生成 dispatch manifest,将无依赖冲突的子任务并行派发给多个隔离的 `airdo` worker。同一波次内的 worker 并发推进、互不阻塞。对于包含多个独立模块、多组件并行开发的工程任务,这种模式将原本串行叠加的时间成本压缩为一轮并发周期,显著缩短端到端交付时间。 + +`aireng` 当前实现能力包括: + +- 读取 `airarc` execution artifacts +- 生成 dispatch manifest +- bounded concurrency 调度多个隔离 `airdo` worker +- 合并结构化 worker result +- repair dispatch / repair queue 准备 +- 文档同步与 merge 后收敛 + +### 4.3 AirDo + +`airdo` 是单任务执行插件,当前实现能力包括: + +- task-local brief / handoff +- 结构化 `result.json` +- finalize 到 `AirPlan/state/airdo/results/.json` +- `worker-state.json` 维护 canonical result path +- GUI 任务自动衔接 `airxdb` +- blocked / failed validation 自动衔接 `airdbg` + +### 4.4 AirDbg + +`airdbg` 是 debug-first repair 插件,当前实现能力包括: + +- reproduce -> RCA -> minimal fix -> verify +- GUI 问题强制引入 `airxdb` 或等效证据 +- 网络问题联动 `airndb` +- C/C++ 静态分析问题联动 `airsdb` +- 同步维护 AGENTS、ADR、C4、debug log + +### 4.5 AirXDB + +`airxdb` 是 Midscene-based GUI 调试插件,当前实现能力包括: + +- 本地与远程截图证据 +- Computer MCP smoke +- Midscene model-family 检查 +- Windows 截图资产修复 +- GUI 调试报告生成 + +### 4.6 AirNDB + +`airndb` 是网络抓包插件,当前实现能力包括: + +- bounded local / remote tcpdump or WinDump capture +- Windows 首次自动下载官方 `WinDump.exe` +- BPF filters +- pcap 读取与摘要 +- 远程 SSH 抓包辅助 + +### 4.7 AirSDB + +`airsdb` 是 C/C++ 静态分析插件,当前实现能力包括: + +- 本机与远程 cppcheck +- `--check-level=exhaustive` +- XML / JSON 报告输出 +- `staticanalysis.md` 摘要维护 + +### 4.8 AirContext + +`aircontext` 是同一套件中的会话压缩与自动恢复插件,当前实现能力包括: + +- wrapper 方式启动 Claude Code +- 在 `SessionStart`、`UserPromptSubmit`、`PostToolUse`、`PreCompact` 事件接入 hook +- 基于阈值与 cooldown 触发外部 compactor +- 使用 OpenAI-compatible backend 执行规则驱动压缩 +- 将摘要与 continuation prompt 追加到 session JSONL +- 自动 `claude --resume ` +- 在 `AirPlan/AirContext/` 下维护配置、活动规则、状态与 snapshots + +## 5. 规划、调度与会话连续性模型 + +当前版本的总体模型为: + +```text +AirArc -> 规划与并行评审 -> Execution Plan +AirEng -> Dispatch Manifest -> Isolated AirDo Workers +AirDo -> Structured Result -> AirEng Merge +AirDbg / AirXDB / AirNDB / AirSDB -> Evidence and Repair +AirContext -> Rule-driven compaction -> Auto resume +``` + +这套模型已经实现: + +- 规划层生成 engine-consumable execution artifacts +- 调度层依据 parallel review 进行 bounded dispatch,以 non-blocking 方式保持开发者介入通道 +- 执行层通过 isolated handoff 保持 task-local context,同一波次内独立 worker 并行推进互不阻塞 +- merge 层以结构化结果与文档更新为准,将并发 worker 的产出收敛为一致的项目状态 +- 会话层在 compact 后自动恢复长任务上下文 + +## 6. 结果完整性机制 + +AirPlan-Para 当前版本已实现以下 result integrity 机制: + +- `worker-state.json` 作为 canonical result locator +- finalize 后结果默认写入 `AirPlan/state/airdo/results/.json` +- untouched 默认模板不能 finalize +- `done` 结果不能是逻辑空载荷 +- `done` 结果至少需带有变更、验证、证据或文档更新之一 +- 无真实 blocker 时,worker 默认继续执行到 finalize + +这保证了系统不会把形式上的“完成”误当作真实交付完成。 + +## 7. 调试与证据模型 + +AirPlan-Para 当前采用 evidence-based verification: + +- GUI:`airxdb` +- 网络:`airndb` +- C/C++:`airsdb` +- 通用调试闭环:`airdbg` +- 长会话压缩与恢复:`aircontext` + +这使得验证行为不再只依赖聊天说明,而是依赖结构化 artifact: + +- screenshot / GUI report +- pcap / network summary +- cppcheck XML / JSON / `staticanalysis.md` +- debug log / RCA record +- compaction snapshots / active rules / continuation chain + +## 8. 断点恢复与可持续执行 + +当前版本已经实现的可恢复能力主要来自: + +- `todo.md` 任务状态账本 +- `AirPlan/state/` 运行时状态 +- dispatch manifest +- repair queue +- worker-state +- canonical result path +- `AirPlan/AirContext/state.json` +- `AirPlan/AirContext/snapshots/` + +因此,系统已经支持: + +- 会话中断后恢复任务状态 +- worker finalize 后准确定位结果产物 +- blocked 结果进入 repair 路径并继续收敛 +- compact 后自动恢复长会话工作流 + +## 9. 部署与安装边界 + +默认部署包负责: + +- 安装 Air 工作流插件、skills 与 shared runtime +- 提供 `install_to_home.ps1` / `init_project_airplan.ps1` +- 自动 bootstrap `AirPlan/` 工作流根 + +仓库中还包含 `AirContext/` 目录,作为同一套件中的 context continuity 插件实现与分发目录。使用时通过 `aircontext` wrapper 启动 Claude Code,并在项目内创建和维护 `AirPlan/AirContext/`。 + +仓库中的 `AirContextServer/` 可视为相关变体或附带目录,不属于当前主工作流介绍口径的核心部分。 + +## 10. 边界说明 + +AirPlan-Para 当前版本应被准确描述为: + +- 本地 AI 工程治理框架 +- 规划、调度、执行、调试与证据插件体系 +- 结构化 project memory + task runtime state + compact/resume layer 组合 + +不应超前描述为: + +- 已实现 file lock / document lock runtime +- 已实现多账号 relay / broker 调度平台 +- 已实现容器化隔离执行 fabric + +## 11. 结论 + +AirPlan-Para V1.0.0 当前已经把 AI 辅助开发中的核心工程问题拆解为可运行插件和文件化上下文体系,并在以下层面完成了工程化落地: + +- planning +- scheduling +- execution +- debugging +- GUI evidence +- network evidence +- static analysis +- resumable workflow +- controllable compaction and auto resume + +它的核心贡献,是把 AI 从“依赖对话记忆的临时助手”推进为“围绕明确上下文资产、运行时状态和受控压缩机制工作的工程参与者”——并且在这个过程中,开发者始终可以通过 aireng 保持对执行过程的可见与介入,同时以并行执行换取交付速度,不因治理而牺牲效率。 diff --git a/AirPlan/docs/spec/AirPlanV2/.claude-plugin/plugin.json b/AirPlan/docs/spec/AirPlanV2/.claude-plugin/plugin.json new file mode 100755 index 0000000..8bafe35 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/.claude-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "airplan-v2", + "version": "2.0.0", + "description": "AirPlan V2 — 统一制品驱动开发调度器。12个子模式:arc,eng,do,dbg,xdb,sdb,ndb,ctx,dep,tst,sec,rvr", + "author": { + "name": "AirPlan Team", + "email": "noreply@airlongdian.fun", + "url": "https://airlongdian.fun" + }, + "license": "MIT", + "keywords": [ + "airplan", + "scheduler", + "orchestrator", + "debugger", + "v2" + ], + "skills": [ + "./skills/" + ], + "commands": [ + "./commands/" + ] +} \ No newline at end of file diff --git a/AirPlan/docs/spec/AirPlanV2/.gitignore b/AirPlan/docs/spec/AirPlanV2/.gitignore new file mode 100755 index 0000000..be20257 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +.bak +*.tmp +*.lock +.DS_Store \ No newline at end of file diff --git a/AirPlan/docs/spec/AirPlanV2/README.md b/AirPlan/docs/spec/AirPlanV2/README.md new file mode 100755 index 0000000..60cb7e3 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/README.md @@ -0,0 +1,87 @@ +# AirPlan V2 + +Claude Code 统一开发调度插件,整合 8 个 V1 插件为 1 个,支持 12 种开发模式。 + +## 功能特性 + +- **Arc** - 架构规划器,产出执行计划和 DAG +- **Eng** - 调度引擎,波次派发、监控、合并 +- **Do** - 任务执行器,单任务切片运行 +- **Dbg** - 调试器,7 步工作流强制追踪 +- **Xdb** - GUI 验证器,截图取证 +- **Sdb** - 静态分析器,多语言支持 +- **Ndb** - 网络调试器,抓包分析 +- **Ctx** - 上下文管理器,Token 估算 +- **Dep** - 部署器,SSH 远程构建 +- **Tst** - 测试运行器,统一多框架 +- **Sec** - 安全扫描器,敏感数据检测 +- **Rvr** - 需求审查器,交付物一致性 + +## L1 代码级保障 + +- 原子写入 + 文件锁 +- 证据门控强制 +- 三阶段架构门控 +- 调试先读后写 +- 边界测试强制 +- 高风险审计 +- UI Skill 路由 + +## 安装 + +```bash +# 克隆插件 +cd ~/.claude/skills +git clone http://git.airlongdian.fun/admin/AirPlan-V2.git airplan-v2 + +# 或使用安装脚本 +bash ~/.claude/skills/airplan-v2/scripts/install.sh +``` + +## 使用 + +```bash +# 架构规划 +/arc + +# 调度引擎 +/eng + +# 任务执行 +/do + +# 调试模式 +/dbg + +# GUI 验证 +/xdb + +# 静态分析 +/sdb + +# 网络调试 +/ndb + +# 上下文管理 +/ctx + +# 部署 +/dep + +# 测试 +/tst + +# 安全扫描 +/sec + +# 需求审查 +/rvr +``` + +## 版本 + +v2.0.0 - 统一插件版本 + +## 许可证 + +MIT \ No newline at end of file diff --git a/AirPlan/docs/spec/AirPlanV2/SKILL.md b/AirPlan/docs/spec/AirPlanV2/SKILL.md new file mode 100755 index 0000000..ea37bf1 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/SKILL.md @@ -0,0 +1,10 @@ +--- +name: airplan-v2 +description: TODO — describe WHEN Claude should use this. Include trigger phrases users + might say ("do X", "set up Y", "review Z"). Be specific; this string is what Claude + matches the user's request against. +--- + +# airplan-v2 + +TODO: what this skill does, and the steps Claude should take. diff --git a/AirPlan/docs/spec/AirPlanV2/airplanV2-Qwen3.7-Max设计.md b/AirPlan/docs/spec/AirPlanV2/airplanV2-Qwen3.7-Max设计.md new file mode 100755 index 0000000..7802223 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/airplanV2-Qwen3.7-Max设计.md @@ -0,0 +1,1842 @@ +# AirPlan V2 设计文档 + +> **版本**: Draft 0.1 +> **日期**: 2026-06-09 +> **基线**: AirPlan V1 (air-suite-20260518, air_runtime ~4,500 行, 8 插件) +> **状态**: 待评审 + +--- + +## 0. 文档目的 + +本文档记录 AirPlan V1 在真实工程项目(DecodePlayer 系列, AirCoding V1.0.0 Alpha)中暴露的系统性缺陷,并定义 V2 的架构改进方向、新增组件设计和分阶段实施计划。 + +V2 的核心命题:**V1 证明了制品驱动 + 上下文隔离 + 波次并行的架构方向成立;V2 要解决可靠性、可观测性和规模化问题。** + +--- + +## 1. V1 问题诊断 + +### 1.1 缺陷分级 + +#### P0 — 已造成实际损失 + +| ID | 缺陷 | 位置 | 影响 | 根因 | +|----|------|------|------|------| +| P0-1 | AirXDB 假阳性阻塞 | `airxdb_runtime.py` `ensure_xdb_sessions_for_result()` | 11+ 任务触发虚假修复循环,每次需人工覆盖 | 证据门控无任务类型感知 | +| P0-2 | 部署验证缺口 | `contracts.py` `validate_for_finalize()` | T-028b 发现 4 个 "done" 任务未实际部署,延迟发布一整天 | 验证只检查结构完整性,不检查部署一致性 | +| P0-3 | 非原子写入 | 全部 `_json_dump` (5 份) | 进程崩溃时 state.json 截断,下次加载 JSONDecodeError | `path.write_text()` 直接覆盖,无 tempfile + rename | +| P0-4 | 零并发控制 | 整个 `air_runtime/` | 两个 Worker 同时完成时 todo.md 读-改-写竞态 | 无任何锁、互斥量或原子操作 | +| P0-5 | AirArc 被 plan 模式劫持 | AirArc SKILL.md / 命令文件 | 架构器频繁被 Agent 内置 plan mode 接管,偏离架构规划职责 | 缺乏 plan mode 阻断机制,SKILL.md 指令不够强硬 | +| P0-6 | AirEng 停下来问而不自主决策 | AirEng SKILL.md / 命令文件 | 调度器用英文反复询问用户确认,而非中文自主推进开发进度 | SKILL.md 未强制"以推进为目标自行决策",语言未锁定中文 | +| P0-7 | AirEng 无子线程状态轮询 | `engine.py` `monitor_engine()` | 子线程卡死后调度器无限等待,必须用户手动发现并告知 | 轮询逻辑依赖 Agent 自觉执行,无硬编码的定时轮询循环 | +| P0-8 | AirDo 不调用 AirDbg | AirDo SKILL.md / `worker.py` `finish_worker()` | 执行器遇到问题或验收时几乎不触发调试,直接报 blocked 或 false-done | 自动路由为建议性而非强制,Worker 倾向于跳过调试直接返回 | +| P0-9 | 安装器脚本路径错误 | 安装脚本 / 插件注册逻辑 | 安装后插件无法识别,AI 修复后可识别但脚本执行失败,路径不正确 | 安装器未正确解析插件脚本的绝对路径,注册的命令路径与实际文件位置不匹配 | +| P0-10 | AirEng 偏离调度亲自写代码 | AirEng SKILL.md / 命令文件 | 调度引擎频繁偏离调度职责自己编写代码,破坏隔离架构。极端情况(子代理循环阻塞需接手合并)允许少量修改,但日常调度中不应发生 | SKILL.md 未明确区分"仅调度"与"极端接管"的边界,无工具限制约束 | + +#### P1 — 限制可靠性与可维护性 + +| ID | 缺陷 | 位置 | 影响 | +|----|------|------|------| +| P1-1 | 硬编码开发者路径 | `debug_runtime.py:130`, `airxdb_runtime.py:156` | `C:\Users\20392\...` 在其他机器静默失败 | +| P1-2 | `_json_dump`/`_json_load` 5 份重复 | engine, worker, airxdb, debug, repair | 修一处漏四处 | +| P1-3 | `_ordered_unique` 4 份重复 | airxdb, debug, repair, contracts | 同上 | +| P1-4 | policy normalization 3 份重复 | airxdb, debug, repair | 同上 | +| P1-5 | merge-into-state 3 份重复 | airxdb, debug, repair | 同上 | +| P1-6 | marker block upsert 2 份(接口不同) | doc_sync, project_bootstrap | 同上 | +| P1-7 | `_session_stamp` 格式不一致 | airxdb, debug vs engine | 时间戳格式在不同模块产生不同文件名 | +| P1-8 | todo.md 列索引硬编码 | `doc_sync.py:154-156` | `cells[1]`=status, `cells[6]`=validation, `cells[7]`=adr — 表头变化时全部失效 | +| P1-9 | 并发度上限硬编码为 3 | `engine.py:527` | 无法根据项目规模调整 | +| P1-10 | 子进程无超时 | airxdb, debug runtime | 挂死时阻塞整个引擎 | +| P1-11 | task_id 路径注入 | `worker.py:59` | 无 `../` 遍历校验 | +| P1-12 | 标记注入风险 | `doc_sync.py` `_replace_marker_block()` | marker 字段含 `-->` 时可注入内容 | +| P1-13 | 静默吞异常 | session 文件损坏时 `except` 后 `continue` | 损坏文件不可见,无日志 | +| P1-14 | Arc 重规划后 Eng 无法衔接 | `engine.py` 计划解析 + `todo.md` 同步 | 中途变更需求后 Arc 重新生成规划,Eng 需多轮 AI 迭代才能恢复调度 | 调度引擎基于静态 todo.md 表格,无法增量吸收 Arc 的动态重规划结果 | +| P1-15 | 同文件无冲突任务被迫串行 | `review.py` 写集冲突检测 | 同文件不同区域(如 Qt 样式 vs 状态机)被判为冲突,被迫串行执行 | 冲突检测粒度为文件级而非区域级,无 worktree 隔离并行能力 | +| P1-16 | AirArc 跳过需求探讨直接生成规划 | AirArc SKILL.md / 命令文件 | 用户刚说一两句就自顾自生成计划并要求执行,未与用户充分探讨需求和分析架构 | SKILL.md 未强制"先探讨后规划"流程,缺少用户确认架构的门控 | +| P1-17 | AirDbg 未取证就盲改代码 | AirDbg SKILL.md / `debug_runtime.py` | 调试器不进行任何取证(抓包/截图/代码分析)就猜测原因并修改代码,引入新问题且污染代码库 | 7 步工作流为建议性不强制,无"先读后写"硬性门控——未执行任何取证行为就不允许修改代码 | +| P1-18 | 项目缺乏标准化日志体系 | 项目引导 / AirArc 规划 | 生成的代码无统一日志输出,debug/release 无法切换,问题排查困难 | 无项目级日志标准要求,AirArc 规划时未强制 spdlog 集成,AirRvr 审查时未检查日志完备性 | +| P1-19 | 边界无测试 + 终审缺高风险检查 | AirDo / AirRvr | 代码边界无接口测试和单元测试,最终审查未着重检查生命周期、空指针、悬垂指针、异常风险,产品交付后短时间内崩溃 | AirArc 规划时未强制测试任务,AirRvr 终审无专项高风险审计环节 | +| P1-20 | 界面设计缺乏专业 Skill 支撑 | AirDo / 安装器 | UI/前端任务由通用 Agent 直接编写,界面质量差,布局、配色、交互不符合设计规范 | 未集成 frontend-design Skill,AirDo 遇到 UI 任务时无专业工具可用,安装器未自动检测并配置 | +| P1-21 | ADR 变更无级联失效机制 | AirArc / AirEng / TaskGraph | 架构方案变更(如 ffmpeg → gstreamer)后,基于旧 ADR 已完成的任务不会自动失效,旧代码残留与新方案冲突,下游任务基于过期产出继续执行 | TaskGraph 无 ADR→任务的溯源链,无已完成任务的失效判定,无回滚清理流程 | +| P1-22 | Dispatch → Worker 启动无桥接 | `eng_mode.py:dispatch_worker_group()` | dispatch 只写 JSON 派发清单,不启动 Worker。Worker 启动依赖 Agent 自觉读 payload 并手动调用 Skill 工具——Agent 不读则 Worker 永不启动,Agent 最终「回退自己执行」 | `dispatch_worker_group()` 与 Worker 启动之间仅有 JSON 文件,无代码层桥接。L1 保障未覆盖 Agent 调度层 | +| P1-23 | Dispatch 指令歧义 | `commands/eng.md` | Eng 的 dispatch 步骤(spawn Worker)是意图描述而非可执行伪代码,Agent 每步都在猜:用什么工具?参数格式?task-text 从哪取?——猜错多一轮,猜不出来 Worker 不启动 | 指令未降到操作级。Arc 和 Eng 的约束非对称性是刻意的(Arc 永不写→硬阻断,Eng 保留极端接管→不硬阻断),P1-23 是纯指令层问题 | +| P1-24 | Merge 后 TaskGraph 状态不同步 | `eng_mode.py:merge_worker_result()` | merge 更新 todo.md 和 state.json 但不动 task-graph.json。已完成任务的节点状态仍是 TODO/DISPATCHED,再次 dispatch 重复派发 | `merge_worker_result()` Phase 5/6 未同步 `task-graph.json` 节点 status 字段 | + +#### P2 — 限制规模化 + +| ID | 缺陷 | 位置 | 影响 | +|----|------|------|------| +| P2-1 | 冲突检测 O(n²) | `review.py` `combinations(active_tasks, 2)` | 100 任务时 ~495,000 次路径比较 | +| P2-2 | state.json 无界增长 | `engine.py` | `mergedResults` 等列表永不截断 | +| P2-3 | todo.md 每次操作全量重解析 | engine 多处调用 `parse_tasks()` | 大 todo 表时性能退化 | +| P2-4 | 零测试覆盖 | 整个 `air_runtime/` | 任何重构都有回归风险 | + +#### P3 — 限制用户体验 + +| ID | 缺陷 | 位置 | 影响 | +|----|------|------|------| +| P3-1 | AGENTS.md 膨胀 | AirEng sync 追加无去重 | 同一任务记录重复 2-3 次 | +| P3-2 | 写集刚性导致级联任务链 | 写集边界设计 | T-028 衍生 fix-001~005 + T-028b + T-028c | +| P3-3 | 并行 Worker 抢占共享硬件 | 无硬件资源感知 | kmsgrab 锁死、负载 7.59 自发重启 | +| P3-4 | 环境特定修复不可持久化 | 部署自动化不完整 | MonitorServiceD、cgroup v1 每次重启需手动修复 | +| P3-5 | 跨项目知识不迁移 | 无模板继承机制 | 每个项目从零积累运维经验 | + +### 1.2 插件级差距 + +| 插件 | V1 差距 | 影响 | +|------|---------|------| +| **AirContext** | 压缩质量无监控;Token 估算 `char_div_3.5` 粗糙;续传 prompt 硬编码中文;锁文件无陈旧检测 | 坏摘要静默损坏上下文 | +| **AirDbg** | 7 步工作流纯建议性不强制;无不可复现 bug 分支;无回滚能力 | 调试质量依赖模型自觉 | +| **AirXDB** | 无 headless CI;无 DRM/KMS 原生截图;无截图 diff;远程探测不含 ffmpeg | 生产渲染路径无法自动验证 | +| **AirNDB** | 无 TLS 解密;大 pcap `tail(8000)` 截断;无 pcapng 支持 | 大规模抓包分析能力不足 | +| **AirSDB** | 仅 C/C++;无 diff 模式;无 compile_commands.json 生成 | 多语言项目零覆盖 | +| **AirArc** | 无规划质量验证;无增量重规划;无执行→规划反馈 | scope 变更必须全量重新生成 | +| **AirEng** | 无级联故障保护;无资源耗尽监控;5 分钟固定轮询;无 Worker 总时间上限 | 大规模调度时稳定性不足 | + +### 1.3 真实项目痛点汇总 + +| 痛点 | 频次 | 根因缺陷 | +|------|------|---------| +| AirXDB 假阳性阻塞 | 11+ 任务 | P0-1 | +| 完成但未部署 | 1 次关键事故 | P0-2 | +| 写集级联任务链 | 5+ 条链 | P3-2 | +| 并行 Worker 抢占硬件 | 3+ 次 | P3-3 | +| 空壳修复循环 | 11+ 次 | P0-1 + P3-4 | +| AGENTS.md 膨胀 | 持续累积 | P3-1 | +| 环境修复不可持久 | 每次重启 | P3-4 | +| AirArc 被 plan 模式劫持 | 频繁 | P0-5 | +| AirEng 反复询问不自主推进 | 每次调度 | P0-6 | +| AirEng 遗忘轮询导致无限等待 | 频繁 | P0-7 | +| AirDo 跳过 AirDbg 直接返回 | 频繁 | P0-8 | +| 安装后插件无法识别或脚本路径错误 | 用户普遍反馈 | P0-9 | +| 需求变更后调度需多轮迭代恢复 | 每次变更 | P1-14 | +| 同文件无冲突任务被迫串行 | 频繁 | P1-15 | +| 实现偏离设计无对照机制 | 持续累积 | AirRvr 设计缺口 | +| AirArc 跳过需求探讨直接生成规划 | 每次启动 | P1-16 | +| AirDbg 不取证就猜测修复污染代码 | 频繁 | P1-17 | +| AirEng 偏离调度亲自写代码 | 频繁 | P0-10 | +| 项目代码缺乏标准化日志体系 | 所有项目 | P1-18 | +| 边界无测试 + 终审缺高风险检查 | 所有项目 | P1-19 | +| 界面设计缺乏专业 Skill 支撑 | UI 任务 | P1-20 | +| ADR 变更后已完成任务不失效 | 架构变更时 | P1-21 | + +--- + +## 2. V2 设计目标 + +### 2.1 核心目标 + +1. **可靠性**:状态写入不丢失,并发操作不竞态,崩溃后可自愈 +2. **可观测性**:所有引擎操作可追溯,指标可导出,异常主动通知 +3. **智能化**:证据门控感知任务类型,轮询频率自适应,修复模式可学习 +4. **规模化**:支持 100+ 任务、5+ 并行 Worker、多项目知识迁移 + +### 2.2 不变量 + +V2 必须保持 V1 的核心不变量: + +| 不变量 | V1 定义 | V2 保持方式 | +|--------|---------|------------| +| INV-1 制品驱动通信 | 插件间通过 AirPlan/ 文件通信 | 保持,增加事件索引层 | +| INV-2 上下文隔离 | Worker `fork_context=false` | 保持,增加选择性上下文继承 | +| INV-3 架构同步强制 | 不更新架构文档不能 DONE | 保持,增加增量同步 | +| INV-4 证据先于修复 | 截图/抓包/静态分析前置 | 保持,增加任务类型感知 | +| INV-5 闭环自动修复 | 执行→失败→调试→修复→重执行 | 保持,增加修复模式学习 | + +--- + +## 3. V2 架构改进 + +### 3.1 基础设施层重构 + +#### 3.1.1 `air_runtime.io` — 统一 I/O 模块 + +消除 5 份 `_json_dump`/`_json_load` 重复,统一为原子写入: + +```python +# air_runtime/io.py + +def atomic_json_write(path: Path, data: dict) -> None: + """POSIX 原子写入:tempfile + os.replace()""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + try: + os.write(fd, json.dumps(data, indent=2, 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 | None: + """安全加载:处理损坏文件,自动从 .bak 恢复""" + try: + return json.loads(path.read_text("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + bak = path.with_suffix(path.suffix + ".bak") + if bak.exists(): + logging.warning("corrupt %s, restoring from %s", path, bak) + return json.loads(bak.read_text("utf-8")) + logging.error("corrupt %s with no backup", path) + return None +``` + +每次写入前自动备份旧文件为 `.bak`(单级轮转),保证至少有一次完整的历史版本。 + +#### 3.1.2 `air_runtime.lock` — 文件级并发控制 + +```python +# air_runtime/lock.py + +class FileLock: + """基于 fcntl.flock 的进程级文件锁""" + + def __init__(self, path: Path, timeout: float = 10.0): + self._path = path.with_suffix(path.suffix + ".lock") + self._timeout = timeout + self._fd = None + + def __enter__(self): + 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: + raise TimeoutError(f"lock timeout: {self._path}") + time.sleep(0.1) + + def __exit__(self, *exc): + fcntl.flock(self._fd, fcntl.LOCK_UN) + os.close(self._fd) +``` + +所有 `state.json` 和 `todo.md` 的读-改-写操作必须持有对应锁。 + +#### 3.1.3 `air_runtime.utils` — 消除代码重复 + +```python +# air_runtime/utils.py + +def ordered_unique(items: list) -> list: + """保序去重""" + seen = 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 normalize_policy(defaults: dict, overrides: dict | None) -> dict: + """通用的策略合并""" + merged = {**defaults} + if overrides: + for k, v in overrides.items(): + if k in merged: + expected_type = type(defaults[k]) + merged[k] = expected_type(v) if not isinstance(v, expected_type) else 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 "") + + +def _strip_merged_refs(row: str) -> str: + """去除行内所有已存在的 引用,避免重复 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" " + + 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 diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/ndb_mode.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/ndb_mode.py new file mode 100755 index 0000000..79c47f5 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/ndb_mode.py @@ -0,0 +1 @@ +from air_runtime.modes.xdb_sdb_ndb_modes import ndb_main as main diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/rvr_mode.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/rvr_mode.py new file mode 100755 index 0000000..02c4a3f --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/rvr_mode.py @@ -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)}") diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/sdb_mode.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/sdb_mode.py new file mode 100755 index 0000000..9adc98a --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/sdb_mode.py @@ -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}") diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/sec_mode.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/sec_mode.py new file mode 100755 index 0000000..8778faa --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/sec_mode.py @@ -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)}") diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/tst_mode.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/tst_mode.py new file mode 100755 index 0000000..97cd6bd --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/tst_mode.py @@ -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)}") diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/xdb_mode.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/xdb_mode.py new file mode 100755 index 0000000..a35e781 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/xdb_mode.py @@ -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}") \ No newline at end of file diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/xdb_sdb_ndb_modes.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/xdb_sdb_ndb_modes.py new file mode 100755 index 0000000..3b5b441 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/modes/xdb_sdb_ndb_modes.py @@ -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)}") diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/partial_replanner.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/partial_replanner.py new file mode 100755 index 0000000..7f8b01f --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/partial_replanner.py @@ -0,0 +1,106 @@ +""" +局部重规划 — P1-21 仅重新生成受 ADR 变更影响的任务子集。 +替代全量重规划,保留未受影响任务的接口约束。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from air_runtime.task_graph import TaskGraph, TaskNode, PlanDelta + + +@dataclass +class Interface: + """未受影响任务暴露的公共接口约束。""" + task_id: str + write_set: list[str] = field(default_factory=list) + adr_refs: list[str] = field(default_factory=list) + + +@dataclass +class ReplanContext: + """受影响任务的上下文信息,供 Arc 局部重规划使用。""" + task_id: str + task: str + files_dirs: str + done_when: str + write_set: list[str] = field(default_factory=list) + adr_refs: list[str] = field(default_factory=list) + status: str = "" + + +class PartialReplanner: + """仅重新生成受 ADR 变更影响的任务子集。 + + 与 incremental_replan_mode 的区别: + - incremental_replan_mode: 全量重建 DAG 再 diff + - PartialReplanner: 只对受影响部分重新规划,保留稳定接口约束 + """ + + def replan(self, graph: TaskGraph, invalidated_ids: list[str], + new_adr_path: Path | None = None) -> PlanDelta: + """局部重规划:仅生成受影响任务的替代任务。 + + Args: + graph: 当前任务图(已包含 INVALIDATED 标记) + invalidated_ids: 被 ADR 变更级联失效的任务 ID 列表 + new_adr_path: 新 ADR 文件路径(可选,供 Arc 参考) + """ + delta = PlanDelta() + + # 1. 收集受影响任务的上下文 + affected_context = self._collect_affected_context(graph, invalidated_ids) + + # 2. 提取未受影响任务的稳定接口 + stable_interfaces = self._extract_stable_interfaces(graph, set(invalidated_ids)) + + # 3. 生成局部重规划指令文件(供 Arc 读取) + replan_request = { + "type": "partial-replan", + "invalidatedTaskIds": invalidated_ids, + "affectedContext": [ctx.__dict__ for ctx in affected_context], + "stableInterfaces": [iface.__dict__ for iface in stable_interfaces], + "newAdrPath": str(new_adr_path) if new_adr_path else None, + } + + # 4. 构建增量 delta + # removed_tasks 已在 invalidate_by_adr 中填充 + # added_tasks 留空——由 Arc 读取 replan-request.json 后生成新任务 + delta.replan_request = replan_request + + return delta + + def _collect_affected_context(self, graph: TaskGraph, + invalidated_ids: list[str]) -> list[ReplanContext]: + """收集受影响任务的上下文。""" + contexts = [] + for tid in invalidated_ids: + node = graph.nodes.get(tid) + if node: + contexts.append(ReplanContext( + task_id=node.id, + task=node.task, + files_dirs=node.files_dirs, + done_when=node.done_when, + write_set=list(node.write_set), + adr_refs=list(node.adr_refs), + status=node.status, + )) + return contexts + + def _extract_stable_interfaces(self, graph: TaskGraph, + invalidated_ids: set) -> list[Interface]: + """提取未受影响 DONE 任务的接口约束,确保重规划不破坏依赖。""" + interfaces = [] + for nid, node in graph.nodes.items(): + if nid not in invalidated_ids and node.status == "DONE": + if node.write_set or node.adr_refs: + interfaces.append(Interface( + task_id=node.id, + write_set=list(node.write_set), + adr_refs=list(node.adr_refs), + )) + return interfaces diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/paths.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/paths.py new file mode 100755 index 0000000..00834c2 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/paths.py @@ -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", + ] diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/project_bootstrap.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/project_bootstrap.py new file mode 100755 index 0000000..1e31d45 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/project_bootstrap.py @@ -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} \ No newline at end of file diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/review.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/review.py new file mode 100755 index 0000000..aec1713 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/review.py @@ -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) \ No newline at end of file diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/review_runtime.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/review_runtime.py new file mode 100755 index 0000000..1195234 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/review_runtime.py @@ -0,0 +1,214 @@ +""" +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 HighRiskFinding: + """P1-19.2: 高风险审计发现项。""" + file: str + line: int + severity: str # critical | high | medium | low + issue: str + + +@dataclass +class HighRiskAudit: + """P1-19.2: 高风险审计报告结构。""" + lifecycle: list[HighRiskFinding] = field(default_factory=list) + nullPointer: list[HighRiskFinding] = field(default_factory=list) + danglingPointer: list[HighRiskFinding] = field(default_factory=list) + exceptionSafety: list[HighRiskFinding] = field(default_factory=list) + concurrency: list[HighRiskFinding] = field(default_factory=list) + overallRisk: str = "low" # critical | high | medium | low + deliveryVerdict: str = "safe-to-ship" # safe-to-ship | needs-fix | block-release + + +@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, + }) + high_risk_audit: HighRiskAudit = field(default_factory=HighRiskAudit) # P1-19.2 + 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": [], "deliveryVerdict": "safe-to-ship"} + report = safe_json_load(report_path) + if not report or not isinstance(report, dict): + return {"verdict": "pass", "reportPath": str(report_path), "residual": [], "deliveryVerdict": "safe-to-ship"} + return { + "verdict": report.get("verdict", "pass"), + "residual": report.get("residual", []), + "reportPath": str(report_path), + "summary": report.get("summary", ""), + "deliveryVerdict": report.get("highRiskAudit", {}).get("deliveryVerdict", "safe-to-ship"), # P1-19.2 + } + + def check_invalidated_cleanup(self, invalidated_task_ids: list[str]) -> dict: + """P1-21: 检查 INVALIDATED 任务的代码是否已清理(无残留)。""" + from air_runtime.io import safe_json_load + residual = [] + for tid in invalidated_task_ids: + # 检查是否有残留的 result 文件(说明旧代码未被 revert) + result_dir = self._state_dir.parent / "airdo" / "tasks" / tid + if result_dir.exists(): + result_file = result_dir / "result.json" + if result_file.exists(): + data = safe_json_load(result_file) + if data and data.get("status") == "done": + residual.append({"taskId": tid, "reason": "done result still exists — code may not be reverted"}) + return { + "cleaned": len(residual) == 0, + "residualCount": len(residual), + "residualDetails": residual, + } + + @staticmethod + def _report_to_dict(report: ReviewReport) -> dict: + # P1-19.2: highRiskAudit 序列化 + hra = report.high_risk_audit + high_risk_audit_dict = { + "lifecycle": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.lifecycle], + "nullPointer": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.nullPointer], + "danglingPointer": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.danglingPointer], + "exceptionSafety": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.exceptionSafety], + "concurrency": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.concurrency], + "overallRisk": hra.overallRisk, + "deliveryVerdict": hra.deliveryVerdict, + } + 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, + "highRiskAudit": high_risk_audit_dict, + "recommendations": report.recommendations, + } + + @staticmethod + def _dict_to_report(data: dict) -> ReviewReport: + # P1-19.2: highRiskAudit 反序列化 + hra_data = data.get("highRiskAudit", {}) + high_risk_audit = HighRiskAudit( + lifecycle=[HighRiskFinding(**f) for f in hra_data.get("lifecycle", [])], + nullPointer=[HighRiskFinding(**f) for f in hra_data.get("nullPointer", [])], + danglingPointer=[HighRiskFinding(**f) for f in hra_data.get("danglingPointer", [])], + exceptionSafety=[HighRiskFinding(**f) for f in hra_data.get("exceptionSafety", [])], + concurrency=[HighRiskFinding(**f) for f in hra_data.get("concurrency", [])], + overallRisk=hra_data.get("overallRisk", "low"), + deliveryVerdict=hra_data.get("deliveryVerdict", "safe-to-ship"), + ) + 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", {}), + high_risk_audit=high_risk_audit, + recommendations=data.get("recommendations", []), + ) diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/sdb_backends.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/sdb_backends.py new file mode 100755 index 0000000..f169428 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/sdb_backends.py @@ -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: ::: warning: [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): + :: + """ + 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: (,): error TS: + 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: (,): error TS1234: + """ + 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(), +} diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/sec_runtime.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/sec_runtime.py new file mode 100755 index 0000000..b3ad2a8 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/sec_runtime.py @@ -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' 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 diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/task_graph.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/task_graph.py new file mode 100755 index 0000000..3c1d120 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/task_graph.py @@ -0,0 +1,315 @@ +""" +动态任务依赖图(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 | INVALIDATED + task: str = "" + files_dirs: str = "" + done_when: str = "" + in_degree: int = 0 + out_edges: list[str] = field(default_factory=list) + write_set: list[str] = field(default_factory=list) + meta: dict[str, Any] = field(default_factory=dict) + test_required: bool = False # P1-19.1: 边界测试强制标记 + adr_refs: list[str] = field(default_factory=list) # P1-21: ADR→任务溯源链 + + +@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 CascadeReport: + """P1-21: ADR 变更级联失效报告。""" + invalidated_completed: int = 0 + terminated_in_progress: int = 0 + cascaded_downstream: int = 0 + rollback_ref: str = "" + invalidated_task_ids: list[str] = 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) + rollback_ref: str = "" # P1-21: 回滚快照引用 + + +class TaskGraph: + """动态任务依赖图,支持增量更新和全量替换。""" + + def __init__(self): + self.nodes: dict[str, TaskNode] = {} + self.edges: list[Edge] = [] + self.dispatch_frozen: bool = False # P1-21: 调度冻结 + + 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,保留已完成任务状态。 + INVALIDATED 状态不保留(已被级联失效标记的任务在全量替换时重置)。""" + done_status = {tid: n.status for tid, n in self.nodes.items() + if n.status in ("DONE", "DISPATCHED")} + 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 的任务。调度冻结时返回空。""" + if self.dispatch_frozen: + return [] + 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,产出 PlanDelta(add/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 + graph.dispatch_frozen = data.get("dispatchFrozen", False) + for nid, nd in data.get("nodes", {}).items(): + graph.nodes[nid] = TaskNode( + id=nd.get("id", nid), + status=nd.get("status", "TODO"), + task=nd.get("task", ""), + files_dirs=nd.get("filesDirs", ""), + done_when=nd.get("doneWhen", ""), + in_degree=nd.get("inDegree", 0), + out_edges=list(nd.get("outEdges", [])), + write_set=list(nd.get("writeSet", [])), + test_required=nd.get("testRequired", False), + adr_refs=list(nd.get("adrRefs", [])), + ) + 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 + # INVALIDATED 可覆盖 DONE/DISPATCHED(P1-21: ADR 级联失效) + if existing_status in ("DISPATCHED", "DONE") and node.status != "INVALIDATED": + 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) + + # P1-21: ADR 级联失效 + + def tasks_by_adr(self, adr_id: str) -> list[TaskNode]: + """查找所有引用指定 ADR 的任务(含已完成)。""" + return [n for n in self.nodes.values() if adr_id in n.adr_refs] + + def _find_downstream(self, task_ids: list[str]) -> list[str]: + """BFS 遍历下游依赖任务。""" + visited: set[str] = set() + queue = list(task_ids) + while queue: + current = queue.pop(0) + if current in visited: + continue + visited.add(current) + node = self.nodes.get(current) + if node: + for target in node.out_edges: + if target not in visited: + queue.append(target) + # 排除起点自身 + return [tid for tid in visited if tid not in set(task_ids)] + + def invalidate_by_adr(self, adr_id: str, delta: PlanDelta) -> CascadeReport: + """P1-21: ADR 变更时级联失效所有相关任务。""" + affected = self.tasks_by_adr(adr_id) + completed = [t for t in affected if t.status == "DONE"] + in_progress = [t for t in affected if t.status == "DISPATCHED"] + pending = [t for t in affected if t.status == "TODO"] + + # 1. 冻结调度 + self.dispatch_frozen = True + + # 2. 标记已完成任务为 INVALIDATED + for t in completed: + t.status = "INVALIDATED" + delta.removed_tasks.append(t.id) + + # 3. 标记进行中任务为 INVALIDATED(调用方负责中止 Worker) + for t in in_progress: + t.status = "INVALIDATED" + delta.removed_tasks.append(t.id) + + # 4. 标记 ADR 直接关联的 TODO 任务为 INVALIDATED + for t in pending: + t.status = "INVALIDATED" + delta.removed_tasks.append(t.id) + + # 5. 级联失效下游 + downstream_ids = self._find_downstream([t.id for t in completed + in_progress + pending]) + cascaded = [] + for tid in downstream_ids: + node = self.nodes.get(tid) + if node and node.status in ("TODO", "DISPATCHED"): + node.status = "INVALIDATED" + delta.removed_tasks.append(tid) + cascaded.append(tid) + + # 6. 回滚快照引用(由调用方在 git revert 后填入) + all_invalidated = [t.id for t in completed + in_progress + pending] + cascaded + + return CascadeReport( + invalidated_completed=len(completed), + terminated_in_progress=len(in_progress), + cascaded_downstream=len(cascaded), + rollback_ref=delta.rollback_ref, + invalidated_task_ids=all_invalidated, + ) + + def unfreeze_dispatch(self) -> None: + """P1-21: 解冻调度,在 Arc 重新生成受影响任务后调用。""" + self.dispatch_frozen = False diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/test_runtime.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/test_runtime.py new file mode 100755 index 0000000..130ba3b --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/test_runtime.py @@ -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__) diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/todo_parser.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/todo_parser.py new file mode 100755 index 0000000..f70b4b5 --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/todo_parser.py @@ -0,0 +1,100 @@ +""" +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;若没有则尝试从开头提取 G-001/H-000 类短 ID + tid_match = re.match(r"\[([A-Za-z0-9_\-\.]+)\]", task_cell) + if tid_match: + task_id = tid_match.group(1) + else: + short_match = re.match(r"^([A-Z]+-\d+[a-z]*)", task_cell) + task_id = short_match.group(1) if short_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 \ No newline at end of file diff --git a/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/utils.py b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/utils.py new file mode 100755 index 0000000..a42ad9b --- /dev/null +++ b/AirPlan/docs/spec/AirPlanV2/lib/air_runtime/utils.py @@ -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 "` 时可注入内容 | +| P1-13 | 静默吞异常 | session 文件损坏时 `except` 后 `continue` | 损坏文件不可见,无日志 | +| P1-14 | Arc 重规划后 Eng 无法衔接 | `engine.py` 计划解析 + `todo.md` 同步 | 中途变更需求后 Arc 重新生成规划,Eng 需多轮 AI 迭代才能恢复调度 | 调度引擎基于静态 todo.md 表格,无法增量吸收 Arc 的动态重规划结果 | +| P1-15 | 同文件无冲突任务被迫串行 | `review.py` 写集冲突检测 | 同文件不同区域(如 Qt 样式 vs 状态机)被判为冲突,被迫串行执行 | 冲突检测粒度为文件级而非区域级,无 worktree 隔离并行能力 | +| P1-16 | AirArc 跳过需求探讨直接生成规划 | AirArc SKILL.md / 命令文件 | 用户刚说一两句就自顾自生成计划并要求执行,未与用户充分探讨需求和分析架构 | SKILL.md 未强制"先探讨后规划"流程,缺少用户确认架构的门控 | +| P1-17 | AirDbg 未取证就盲改代码 | AirDbg SKILL.md / `debug_runtime.py` | 调试器不进行任何取证(抓包/截图/代码分析)就猜测原因并修改代码,引入新问题且污染代码库 | 7 步工作流为建议性不强制,无"先读后写"硬性门控——未执行任何取证行为就不允许修改代码 | +| P1-18 | 项目缺乏标准化日志体系 | 项目引导 / AirArc 规划 | 生成的代码无统一日志输出,debug/release 无法切换,问题排查困难 | 无项目级日志标准要求,AirArc 规划时未强制 spdlog 集成,AirRvr 审查时未检查日志完备性 | +| P1-19 | 边界无测试 + 终审缺高风险检查 | AirDo / AirRvr | 代码边界无接口测试和单元测试,最终审查未着重检查生命周期、空指针、悬垂指针、异常风险,产品交付后短时间内崩溃 | AirArc 规划时未强制测试任务,AirRvr 终审无专项高风险审计环节 | +| P1-20 | 界面设计缺乏专业 Skill 支撑 | AirDo / 安装器 | UI/前端任务由通用 Agent 直接编写,界面质量差,布局、配色、交互不符合设计规范 | 未集成 frontend-design Skill,AirDo 遇到 UI 任务时无专业工具可用,安装器未自动检测并配置 | +| P1-21 | ADR 变更无级联失效机制 | AirArc / AirEng / TaskGraph | 架构方案变更(如 ffmpeg → gstreamer)后,基于旧 ADR 已完成的任务不会自动失效,旧代码残留与新方案冲突,下游任务基于过期产出继续执行 | TaskGraph 无 ADR→任务的溯源链,无已完成任务的失效判定,无回滚清理流程 | +| P1-22 | Dispatch → Worker 启动无桥接 | `eng_mode.py:dispatch_worker_group()` | dispatch 只写 JSON 派发清单,不启动 Worker。Worker 启动依赖 Agent 自觉读 payload 并手动调用 Skill 工具——Agent 不读则 Worker 永不启动,Agent 最终「回退自己执行」 | `dispatch_worker_group()` 与 Worker 启动之间仅有 JSON 文件,无代码层桥接。L1 保障未覆盖 Agent 调度层 | +| P1-23 | Dispatch 指令歧义 | `commands/eng.md` | Eng 的 dispatch 步骤(spawn Worker)是意图描述而非可执行伪代码,Agent 每步都在猜:用什么工具?参数格式?task-text 从哪取?——猜错多一轮,猜不出来 Worker 不启动 | 指令未降到操作级。Arc 和 Eng 的约束非对称性是刻意的(Arc 永不写→硬阻断,Eng 保留极端接管→不硬阻断),P1-23 是纯指令层问题 | +| P1-24 | AirArc 任务描述歧义导致弱模型破坏性执行 | AirArc `review.py` / SKILL.md | 任务粒度太粗、用词有歧义(如"清理"被弱模型理解为"删除全部"),Worker 严格按字面执行导致误删现有代码。真实案例:screenPlayer CMake 重构中 Worker 删除了整个 src/ | Arc 未针对弱模型优化任务描述,无"保留约束"机制,任务粒度未按操作类型拆分 | +| P1-25 | Merge 后 TaskGraph 状态不同步 | `eng_mode.py:merge_worker_result()` | merge 更新 todo.md 和 state.json 但不动 task-graph.json。已完成任务的节点状态仍是 TODO/DISPATCHED,再次 dispatch 重复派发 | `merge_worker_result()` Phase 5/6 未同步 `task-graph.json` 节点 status 字段 | + +#### P2 — 限制规模化 + +| ID | 缺陷 | 位置 | 影响 | +|----|------|------|------| +| P2-1 | 冲突检测 O(n²) | `review.py` `combinations(active_tasks, 2)` | 100 任务时 ~495,000 次路径比较 | +| P2-2 | state.json 无界增长 | `engine.py` | `mergedResults` 等列表永不截断 | +| P2-3 | todo.md 每次操作全量重解析 | engine 多处调用 `parse_tasks()` | 大 todo 表时性能退化 | +| P2-4 | 零测试覆盖 | 整个 `air_runtime/` | 任何重构都有回归风险 | + +#### P3 — 限制用户体验 + +| ID | 缺陷 | 位置 | 影响 | +|----|------|------|------| +| P3-1 | AGENTS.md 膨胀 | AirEng sync 追加无去重 | 同一任务记录重复 2-3 次 | +| P3-2 | 写集刚性导致级联任务链 | 写集边界设计 | T-028 衍生 fix-001~005 + T-028b + T-028c | +| P3-3 | 并行 Worker 抢占共享硬件 | 无硬件资源感知 | kmsgrab 锁死、负载 7.59 自发重启 | +| P3-4 | 环境特定修复不可持久化 | 部署自动化不完整 | MonitorServiceD、cgroup v1 每次重启需手动修复 | +| P3-5 | 跨项目知识不迁移 | 无模板继承机制 | 每个项目从零积累运维经验 | + +### 1.2 插件级差距 + +| 插件 | V1 差距 | 影响 | +|------|---------|------| +| **AirContext** | 压缩质量无监控;Token 估算 `char_div_3.5` 粗糙;续传 prompt 硬编码中文;锁文件无陈旧检测 | 坏摘要静默损坏上下文 | +| **AirDbg** | 7 步工作流纯建议性不强制;无不可复现 bug 分支;无回滚能力 | 调试质量依赖模型自觉 | +| **AirXDB** | 无 headless CI;无 DRM/KMS 原生截图;无截图 diff;远程探测不含 ffmpeg | 生产渲染路径无法自动验证 | +| **AirNDB** | 无 TLS 解密;大 pcap `tail(8000)` 截断;无 pcapng 支持 | 大规模抓包分析能力不足 | +| **AirSDB** | 仅 C/C++;无 diff 模式;无 compile_commands.json 生成 | 多语言项目零覆盖 | +| **AirArc** | 无规划质量验证;无增量重规划;无执行→规划反馈 | scope 变更必须全量重新生成 | +| **AirEng** | 无级联故障保护;无资源耗尽监控;5 分钟固定轮询;无 Worker 总时间上限 | 大规模调度时稳定性不足 | + +### 1.3 真实项目痛点汇总 + +| 痛点 | 频次 | 根因缺陷 | +|------|------|---------| +| AirXDB 假阳性阻塞 | 11+ 任务 | P0-1 | +| 完成但未部署 | 1 次关键事故 | P0-2 | +| 写集级联任务链 | 5+ 条链 | P3-2 | +| 并行 Worker 抢占硬件 | 3+ 次 | P3-3 | +| 空壳修复循环 | 11+ 次 | P0-1 + P3-4 | +| AGENTS.md 膨胀 | 持续累积 | P3-1 | +| 环境修复不可持久 | 每次重启 | P3-4 | +| AirArc 被 plan 模式劫持 | 频繁 | P0-5 | +| AirEng 反复询问不自主推进 | 每次调度 | P0-6 | +| AirEng 遗忘轮询导致无限等待 | 频繁 | P0-7 | +| AirDo 跳过 AirDbg 直接返回 | 频繁 | P0-8 | +| 安装后插件无法识别或脚本路径错误 | 用户普遍反馈 | P0-9 | +| 需求变更后调度需多轮迭代恢复 | 每次变更 | P1-14 | +| 同文件无冲突任务被迫串行 | 频繁 | P1-15 | +| 实现偏离设计无对照机制 | 持续累积 | AirRvr 设计缺口 | +| AirArc 跳过需求探讨直接生成规划 | 每次启动 | P1-16 | +| AirDbg 不取证就猜测修复污染代码 | 频繁 | P1-17 | +| AirEng 偏离调度亲自写代码 | 频繁 | P0-10 | +| 项目代码缺乏标准化日志体系 | 所有项目 | P1-18 | +| 边界无测试 + 终审缺高风险检查 | 所有项目 | P1-19 | +| 界面设计缺乏专业 Skill 支撑 | UI 任务 | P1-20 | +| ADR 变更后已完成任务不失效 | 架构变更时 | P1-21 | +| AirArc 任务描述歧义导致弱模型破坏性执行 | 已造成实际损失 | P1-24 | + +--- + +## 2. V2 设计目标 + +### 2.1 核心目标 + +1. **可靠性**:状态写入不丢失,并发操作不竞态,崩溃后可自愈 +2. **可观测性**:所有引擎操作可追溯,指标可导出,异常主动通知 +3. **智能化**:证据门控感知任务类型,轮询频率自适应,修复模式可学习 +4. **规模化**:支持 100+ 任务、5+ 并行 Worker、多项目知识迁移 + +### 2.2 不变量 + +V2 必须保持 V1 的核心不变量: + +| 不变量 | V1 定义 | V2 保持方式 | +|--------|---------|------------| +| INV-1 制品驱动通信 | 插件间通过 AirPlan/ 文件通信 | 保持,增加事件索引层 | +| INV-2 上下文隔离 | Worker `fork_context=false` | 保持,增加选择性上下文继承 | +| INV-3 架构同步强制 | 不更新架构文档不能 DONE | 保持,增加增量同步 | +| INV-4 证据先于修复 | 截图/抓包/静态分析前置 | 保持,增加任务类型感知 | +| INV-5 闭环自动修复 | 执行→失败→调试→修复→重执行 | 保持,增加修复模式学习 | + +--- + +## 3. V2 架构改进 + +### 3.1 基础设施层重构 + +#### 3.1.1 `air_runtime.io` — 统一 I/O 模块 + +消除 5 份 `_json_dump`/`_json_load` 重复,统一为原子写入: + +```python +# air_runtime/io.py + +def atomic_json_write(path: Path, data: dict) -> None: + """POSIX 原子写入:tempfile + os.replace()""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + try: + os.write(fd, json.dumps(data, indent=2, 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 | None: + """安全加载:处理损坏文件,自动从 .bak 恢复""" + try: + return json.loads(path.read_text("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + bak = path.with_suffix(path.suffix + ".bak") + if bak.exists(): + logging.warning("corrupt %s, restoring from %s", path, bak) + return json.loads(bak.read_text("utf-8")) + logging.error("corrupt %s with no backup", path) + return None +``` + +每次写入前自动备份旧文件为 `.bak`(单级轮转),保证至少有一次完整的历史版本。 + +#### 3.1.2 `air_runtime.lock` — 文件级并发控制 + +```python +# air_runtime/lock.py + +class FileLock: + """基于 fcntl.flock 的进程级文件锁""" + + def __init__(self, path: Path, timeout: float = 10.0): + self._path = path.with_suffix(path.suffix + ".lock") + self._timeout = timeout + self._fd = None + + def __enter__(self): + 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: + raise TimeoutError(f"lock timeout: {self._path}") + time.sleep(0.1) + + def __exit__(self, *exc): + fcntl.flock(self._fd, fcntl.LOCK_UN) + os.close(self._fd) +``` + +所有 `state.json` 和 `todo.md` 的读-改-写操作必须持有对应锁。 + +#### 3.1.3 `air_runtime.utils` — 消除代码重复 + +```python +# air_runtime/utils.py + +def ordered_unique(items: list) -> list: + """保序去重""" + seen = 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 normalize_policy(defaults: dict, overrides: dict | None) -> dict: + """通用的策略合并""" + merged = {**defaults} + if overrides: + for k, v in overrides.items(): + if k in merged: + expected_type = type(defaults[k]) + merged[k] = expected_type(v) if not isinstance(v, expected_type) else 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 "