Compare commits

5 Commits

Author SHA1 Message Date
AirCoding
8476d5c96f fix(review): 对齐 round3-F 改动的测试和边界规则
- tool-stubs.test.ts: 旧 cpp.cmake.configure/static.cppcheck/clangd.query
  名字已删,改为验证 CppToolRegistrar 独立注册
- release-critical-gates.test.ts: cpp.detect 不再由 BuiltInToolRegistrar
  注册,从 built-in envelope 测试移除
- .dependency-cruiser.js: 允许 runtime → toolchain-cpp (capability
  registration boundary, INV-4 compliant)

e2e: 14/14 gates passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 17:53:29 +08:00
AirCoding
0de71e7a1c fix: SQLite UNIQUE constraint - event ID 冲突修复
问题:重复 ask 时 event.id / task_attempt.id 冲突
根因:event ID 格式 `evt_${task.id}_created` 无时间戳

修复:
- Scheduler.generate_event_id() 加 timestamp + random
- 所有 event ID 用 generate_event_id() 生成
- attempt_id / agent_id / workspace_id 加时间戳

round3-G G3 bug 修复验证通过:55 events 正常写入,无 UNIQUE 错误

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:57:40 +08:00
AirCoding
5e282a39b4 feat(round2+round3): 完整实现 A/B/C/D 主线 + round3-F/H 修复
Round2 主线:
- A: 事件落库地基 (RuntimeApp EventStore 单例 + 14 repo wiring)
- B: 执行体对齐 (read-before-edit, verification-before-completion)
- C: 界面对齐 (@opentui/solid, 删除 runtime 依赖)
- D: 经验闭环 (ExperienceMiner, DebuggerRole, CompactorRole)

Round2 补充修复:
- fail-on-missing 反作弊门禁
- projection-store-apply.test.ts 补写
- 3个空壳测试转行为 (evidence-store, recovery-impl, knowledge-store)
- ask 项目根支持 AIRCODING_PROJECT_ROOT
- Worker 事件契约修复 (task.attempt.started → checkpoint)

Round3-F: cpp 工具切换
- 删除 BuiltInToolRegistrar cpp.* 闭包
- 接入 toolchain-cpp 真实 CppToolRegistrar
- canonical envelope {status/output/metadata}
- ExecutorRole system prompt 对齐新工具名

Round3-H: Doctor 5 类报告
- toolchain (cmake/ninja/cppcheck/clangd/g++)
- display (X11/Wayland + ImageMagick)
- network (internet connectivity)
- provider (api_key/base_url/model/connectivity)

Secret 脱敏:
- 状态交接.md: sk- → \${OPENAI_API_KEY}
- .gitignore: 添加 .air/ .claude/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:13:16 +08:00
AirCoding
e383d5f6a7 fix: 主线 B3/B4 结构化工具调用与完成前验证
- 打通 Worker → WorkerManager → Provider 的 tools 传递链路,ProviderManager/adapter
  返回结构化 tool_calls 给 WorkerRuntime
- OpenAI-compatible/Anthropic adapter 发送工具 schema,并解析 provider 返回的
  tool_calls/tool_use;OpenAI 工具名使用 fs.write ↔ fs__write 双向映射
- 修复独立复审发现的 OpenAI 协议隐患:assistant tool_use blocks 必须转换为
  assistant.tool_calls,后续 role=tool 消息的 tool_call_id 必须匹配前一轮
  tool_calls[].id;不再把 tool_use JSON 字符串化为普通文本
- ExecutorRole 优先消费原生 tool_calls,回灌 canonical tool_result block;移除
  fs.write(...)/shell.run(...) 函数调用正则解析,只保留严格 JSON tool_call
  fallback 与 filename code block 兼容
- DONE 前执行 verification-before-completion:任务要求 build/compile/run/test/编译/
  运行/测试时必须实际 shell.run 验证,失败不 checkpoint、不返回 completed
- fs.write 覆盖已有文件也强制 read-before-write,补齐 Claude Code 文件状态纪律
- 新增 packages/workers/test/executor-role.test.ts 行为测试:原生 tool_calls 执行、
  verification 失败不得 completed

真实验收:
- TSC=0
- bun test packages/workers/test/executor-role.test.ts: 2 pass / 0 fail
- OpenAI converter 探针确认 assistant.tool_calls 与 role=tool 的 tool_call_id 匹配
- 真实 GLM Worker C++ 编译运行任务通过,worker verification 记录实际命令:
  c++ hello.cpp -o /tmp/aircoding-verify && /tmp/aircoding-verify

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 15:01:16 +08:00
AirCoding
bac285d412 fix: 主线 A 事件落库地基 + 主线 B1/B2 执行原语
主线 A(事件驱动落库):
- 统一 EventStore 模块单例:RuntimeApp 不再 new EventStore,改用 eventStore
  并 setRepositories(14 个 domain repo),消除事件流向空 DB 的割裂
- Scheduler.create_tasks 改为 async,真正发出 task.created 事件
- run.ts dispatchTask 加 await
- 主线 A 独立复审发现并修复关键假绿:四个 repo(Task/Agent/ToolRun/
  TaskAttempt)的 *Update 类型 Omit<'status'> 且 update() 主动丢弃 status,
  导致 EventStore.project() 的状态写入全部静默失效,DB 行内容 tasks.status
  永远冻结在 pending,UI 显示的 completed 来自内存 graph。已修,DB 现
  真实反映 task.status=completed
- 补 agent.started/agent.completed/agent.failed 事件发出(之前 agents 表
  恒空),修复后 agents 表有正确行+status

主线 B1(结构化工具调用块类型,N1):
- 新增 content-block.ts 定义 Anthropic canonical content blocks
  (TextBlock/ThinkingBlock/ToolUseBlock/ToolResultBlock/CanonicalMessage)
- provider.ts ProviderCompletionInput 去掉 unknown 逃生舱:
  messages: CanonicalMessage[], tools?: ToolDefinitionBlock[],
  tool_choice?: ToolChoice, system?: string | TextBlock[]

主线 B2(read-before-edit 代码层强制,FR-009):
- fs/index.ts 新增 readFileState 机制(移植 claude-code FileEditTool),
  fs.edit 执行前检查:未读先改报 "File has not been read yet",外部修改
  报 "File has been unexpectedly modified"
- 修复 fs.edit 参数名不匹配:兼容 old_str/new_str (ExecutorRole) 和
  find/replace (UI) 两种命名
- fs_edit 唯一性检查(非 global 模式下 old_str 出现多次报错)

真实验收:
- TSC=0
- air run 后 DB:events=5(原 3,+agent.started/completed),
  tasks.status=completed(原 frozen pending),agents 1 行 status=completed
- read-before-edit 行为测试:未读先改 status=error,读后再改 status=ok

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 12:10:07 +08:00
58 changed files with 3646 additions and 1560 deletions

View File

@@ -57,15 +57,15 @@ module.exports = {
}, },
}, },
/* ── Rule 4: runtime may only import from contracts & llm ── */ /* ── Rule 4: runtime may only import from contracts, llm & toolchain-cpp ── */
{ {
name: "runtime-boundary", name: "runtime-boundary",
comment: "runtime may only depend on contracts and llm (facade)", comment: "runtime may only depend on contracts, llm (facade), and toolchain-cpp (capability registration)",
severity: "error", severity: "error",
from: { path: "^packages/runtime/src/" }, from: { path: "^packages/runtime/src/" },
to: { to: {
path: "^packages/(tui|cli|workers|toolchain-cpp)/", path: "^packages/(tui|cli|workers)/",
pathNot: "^packages/(contracts|llm)/", pathNot: "^packages/(contracts|llm|toolchain-cpp)/",
}, },
}, },

2
.gitignore vendored
View File

@@ -10,3 +10,5 @@ dist/
# Turbo cache # Turbo cache
.turbo/ .turbo/
.air
.claude

206
bun.lock
View File

@@ -73,7 +73,9 @@
"version": "1.0.0-alpha.0", "version": "1.0.0-alpha.0",
"dependencies": { "dependencies": {
"@aircoding/contracts": "workspace:*", "@aircoding/contracts": "workspace:*",
"@aircoding/runtime": "workspace:*", "@opentui/core": "0.3.0",
"@opentui/solid": "0.3.0",
"solid-js": "1.9.10",
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
@@ -107,6 +109,88 @@
"@aircoding/workers": ["@aircoding/workers@workspace:packages/workers"], "@aircoding/workers": ["@aircoding/workers@workspace:packages/workers"],
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
"@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="],
"@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="],
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="],
"@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="],
"@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="],
"@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@opentui/core": ["@opentui/core@0.3.0", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.0", "@opentui/core-darwin-x64": "0.3.0", "@opentui/core-linux-arm64": "0.3.0", "@opentui/core-linux-x64": "0.3.0", "@opentui/core-win32-arm64": "0.3.0", "@opentui/core-win32-x64": "0.3.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wvNESYGYGRLuvarZ3QY4CTB+BziZ/j6Snd9qRKD4fQ7SF6G4UpYElLTFrg7uzRo1v7WJTqbquymcTvWEHMnpYA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/eDfAcutAHJqR9spwHMLuo6LMqngymev/m+i6uqlk98gX1EJiJe2pJ16sKbp3RctgH/Gz/8TYOhVHpPGYJl7yQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-/j6EWAvdwhz1wU/mWfXepAf3+NuMYz2Ic5ozaid5LdwIpPomIkM9yCUDm76mQhRBbjsAl/7UeSeUA0qSCMSZBg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uUFVT3V35KkM1m8gaLmRcTV9dsJzXnxwM+dv6+NjScx0W/Y0CJKbW9wDYwnLyPnBNgaFUi171zmJra5gTtFTsw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-73bNNNU2OaqZQLIlvzDOdAzQmzBAqf+cSilmJ+Y9JnybrBn1d6VShC66+V4xxIgonq1swk7BD+SUHYbwwGilQA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jg5KrV/4mVQ0mdkcL9CtQVtBk0NAtQ+2rCKoZ/jNHB6GxGK0ot9vDV6P3X68hZVkvpb2pdXfg6GRsZJ+Np4hZA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-kiM3C5bwQBTfrJKAOfb+L3U6MMkPSQlMhAERlLMjqSurc+llcyqygr/wbXSvfAqJtKlIpf3MKJRnVFTyfRIdng=="],
"@opentui/solid": ["@opentui/solid@0.3.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-AUtNzvgkdW81Ftl0sahAy3tY1LIPSMzBw3APBC8jiDAzzPv4kYVdyWXryTxLbU2q+Pgtr57VwKwHgc5wsNrd2w=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw=="], "@turbo/darwin-64": ["@turbo/darwin-64@2.9.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YPgrn+5HIGzrx0O2a631SV4MBQUe4W/DafMFUuBVgaU32PW9/OTT0ehviF0QSxTXuRJlHvW2eUTemddF5/spmw=="], "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YPgrn+5HIGzrx0O2a631SV4MBQUe4W/DafMFUuBVgaU32PW9/OTT0ehviF0QSxTXuRJlHvW2eUTemddF5/spmw=="],
@@ -131,8 +215,28 @@
"acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="],
"babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="],
"babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw=="],
"brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
"caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -141,14 +245,42 @@
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"dependency-cruiser": ["dependency-cruiser@17.4.3", "", { "dependencies": { "acorn": "8.16.0", "acorn-jsx": "5.3.2", "acorn-jsx-walk": "2.0.0", "acorn-loose": "8.5.2", "acorn-walk": "8.3.5", "commander": "14.0.3", "enhanced-resolve": "5.22.1", "ignore": "7.0.5", "interpret": "3.1.1", "is-installed-globally": "1.0.0", "json5": "2.2.3", "picomatch": "4.0.4", "prompts": "2.4.2", "rechoir": "0.8.0", "safe-regex": "2.1.1", "semver": "7.8.1", "tsconfig-paths-webpack-plugin": "4.2.0", "watskeburt": "5.0.3" }, "bin": { "depcruise": "bin/dependency-cruise.mjs", "depcruise-fmt": "bin/depcruise-fmt.mjs", "dependency-cruise": "bin/dependency-cruise.mjs", "depcruise-baseline": "bin/depcruise-baseline.mjs", "dependency-cruiser": "bin/dependency-cruise.mjs", "depcruise-wrap-stream-in-html": "bin/wrap-stream-in-html.mjs" } }, "sha512-L4GLuAvmXevWnPCIaFfOz6eD92c+yY+pDgVqgufrLDnW3xYA799CSZQlly2r2N13nhAlnZY6VzY7Rx5pHNvk2w=="], "dependency-cruiser": ["dependency-cruiser@17.4.3", "", { "dependencies": { "acorn": "8.16.0", "acorn-jsx": "5.3.2", "acorn-jsx-walk": "2.0.0", "acorn-loose": "8.5.2", "acorn-walk": "8.3.5", "commander": "14.0.3", "enhanced-resolve": "5.22.1", "ignore": "7.0.5", "interpret": "3.1.1", "is-installed-globally": "1.0.0", "json5": "2.2.3", "picomatch": "4.0.4", "prompts": "2.4.2", "rechoir": "0.8.0", "safe-regex": "2.1.1", "semver": "7.8.1", "tsconfig-paths-webpack-plugin": "4.2.0", "watskeburt": "5.0.3" }, "bin": { "depcruise": "bin/dependency-cruise.mjs", "depcruise-fmt": "bin/depcruise-fmt.mjs", "dependency-cruise": "bin/dependency-cruise.mjs", "depcruise-baseline": "bin/depcruise-baseline.mjs", "dependency-cruiser": "bin/dependency-cruise.mjs", "depcruise-wrap-stream-in-html": "bin/wrap-stream-in-html.mjs" } }, "sha512-L4GLuAvmXevWnPCIaFfOz6eD92c+yY+pDgVqgufrLDnW3xYA799CSZQlly2r2N13nhAlnZY6VzY7Rx5pHNvk2w=="],
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"electron-to-chromium": ["electron-to-chromium@1.5.368", "", {}, "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="], "enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
"find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="], "global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -157,6 +289,8 @@
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], "ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="],
@@ -169,30 +303,78 @@
"is-path-inside": ["is-path-inside@4.0.0", "", {}, "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA=="], "is-path-inside": ["is-path-inside@4.0.0", "", {}, "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="],
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"rechoir": ["rechoir@0.8.0", "", { "dependencies": { "resolve": "^1.20.0" } }, "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ=="], "rechoir": ["rechoir@0.8.0", "", { "dependencies": { "resolve": "^1.20.0" } }, "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ=="],
"regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="], "regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="],
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="],
"safe-regex": ["safe-regex@2.1.1", "", { "dependencies": { "regexp-tree": "~0.1.1" } }, "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A=="], "safe-regex": ["safe-regex@2.1.1", "", { "dependencies": { "regexp-tree": "~0.1.1" } }, "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A=="],
"semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="],
"seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -211,6 +393,28 @@
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"watskeburt": ["watskeburt@5.0.3", "", { "bin": { "watskeburt": "dist/run-cli.js" } }, "sha512-g9CXukMjazlJJVQ3OHzXsnG25KFYgSgKMIyoJrD8ggr0DbS9UNF7OzIqWmmKKBMedkxj3T01uqEaGnn+y7QhMA=="], "watskeburt": ["watskeburt@5.0.3", "", { "bin": { "watskeburt": "dist/run-cli.js" } }, "sha512-g9CXukMjazlJJVQ3OHzXsnG25KFYgSgKMIyoJrD8ggr0DbS9UNF7OzIqWmmKKBMedkxj3T01uqEaGnn+y7QhMA=="],
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
} }
} }

View File

@@ -30,6 +30,7 @@ const DEFAULT_CONFIG: AirConfig = {
export function loadConfig(project_root?: string): AirConfig { export function loadConfig(project_root?: string): AirConfig {
let config = { ...DEFAULT_CONFIG } let config = { ...DEFAULT_CONFIG }
const resolved_project_root = project_root || process.env.AIRCODING_PROJECT_ROOT
// Load global config: ~/.air/config.json // Load global config: ~/.air/config.json
const global_path = join(homedir(), '.air', 'config.json') const global_path = join(homedir(), '.air', 'config.json')
@@ -43,8 +44,8 @@ export function loadConfig(project_root?: string): AirConfig {
} }
// Load project config: .air/local/config.json // Load project config: .air/local/config.json
if (project_root) { if (resolved_project_root) {
const project_path = join(project_root, '.air', 'local', 'config.json') const project_path = join(resolved_project_root, '.air', 'local', 'config.json')
if (existsSync(project_path)) { if (existsSync(project_path)) {
try { try {
const project = JSON.parse(readFileSync(project_path, 'utf-8')) const project = JSON.parse(readFileSync(project_path, 'utf-8'))
@@ -53,7 +54,7 @@ export function loadConfig(project_root?: string): AirConfig {
// Ignore malformed project config // Ignore malformed project config
} }
} }
config.project_root = project_root config.project_root = resolved_project_root
} }
return config return config

View File

@@ -1,6 +1,6 @@
/** /**
* AskCommand - Direct AI task execution * AskCommand - Direct AI task execution
* air ask "task description" → MainAgent → LLM → tools → result * air ask "task description" → MainAgent → Scheduler → Worker → LLM → tools → WorkerResult
* *
* @module packages/cli/src/commands/ask * @module packages/cli/src/commands/ask
*/ */
@@ -10,9 +10,8 @@ import { createRuntime } from '../bootstrap/createRuntime.js'
import { initCommand } from './init.js' import { initCommand } from './init.js'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { ProviderManager, createAnthropicAdapter, OpenAICompatibleAdapter } from '@aircoding/llm' import { OpenAICompatibleAdapter } from '@aircoding/llm'
import { MainAgent } from '@aircoding/runtime' import { MainAgent } from '@aircoding/runtime'
import type { ProviderAdapter } from '@aircoding/contracts'
export async function askCommand(prompt: string, opts?: { model?: string; maxTurns?: number }): Promise<void> { export async function askCommand(prompt: string, opts?: { model?: string; maxTurns?: number }): Promise<void> {
if (!prompt) { if (!prompt) {
@@ -24,221 +23,97 @@ export async function askCommand(prompt: string, opts?: { model?: string; maxTur
const config = loadConfig() const config = loadConfig()
const projectRoot = config.project_root || process.cwd() const projectRoot = config.project_root || process.cwd()
// Auto-init if not already initialized
if (!existsSync(join(projectRoot, '.air', 'shared', 'project.json'))) { if (!existsSync(join(projectRoot, '.air', 'shared', 'project.json'))) {
console.log('Project not initialized. Running air init first...\n') console.log('Project not initialized. Running air init first...\n')
await initCommand(projectRoot) await initCommand(projectRoot)
} }
const model = opts?.model || process.env.AIRCODING_MODEL || 'glm-5.1' const model = opts?.model || process.env.AIRCODING_MODEL || 'glm-5.1'
const maxTurns = opts?.maxTurns || 10 const provider = createProvider(model)
const runtime = await createRuntime(config)
const app = runtime.app
app.worker_manager.set_provider_manager(provider as any)
app.worker_manager.set_context({
session_id: app.session_id,
project_id: app.project_id,
project_root: projectRoot,
})
try {
await runtime.start()
const agent = new MainAgent({
session_id: app.session_id,
project_id: app.project_id,
classify_mode: 'regex',
provider_manager: provider as any,
context_assembler: app.context_assembler,
project_root: projectRoot,
agent_id: 'ask-agent' as any,
classify_model: model,
})
console.log('══════════════════════════════════════════════') console.log('══════════════════════════════════════════════')
console.log(' AirCoding v1.0.0-alpha') console.log(' AirCoding v1.0.0-alpha')
console.log(' Project:', projectRoot) console.log(' Project:', projectRoot)
console.log(' Model:', model) console.log(' Model:', model)
console.log('══════════════════════════════════════════════\n') console.log('══════════════════════════════════════════════\n')
// Create ProviderManager with available adapter
const provider = createProvider(model)
// Create runtime
const runtime = await createRuntime(config)
await runtime.start()
const app = runtime.app
// Create MainAgent with regex classifier (中文 support added)
const agent = new MainAgent({
session_id: 'ask-session',
project_id: 'ask-project',
classify_mode: 'regex',
provider_manager: provider as any,
context_assembler: app.context_assembler,
project_root: projectRoot,
agent_id: 'ask-agent' as any,
classify_model: model
})
console.log('Task:', prompt) console.log('Task:', prompt)
console.log('') console.log('')
// Classify intent
const classification = await agent.handle_user_message(prompt) const classification = await agent.handle_user_message(prompt)
console.log(`[${classification.action}] ${agent.state}`) console.log(`[${classification.action}] ${agent.state}`)
// Execute based on action if (classification.action === 'answer') {
if (classification.action === 'delegate') {
await execute_task(prompt, provider, app.tool_registry, app.context_assembler, model, projectRoot, maxTurns)
} else if (classification.action === 'answer') {
console.log('\n' + (classification.response || 'No response')) console.log('\n' + (classification.response || 'No response'))
} else { return
console.log('Direct mode not yet supported for ask. Use implementation requests.')
} }
if (classification.action !== 'delegate') {
console.log(classification.response || 'No task created')
return
}
if (agent.state === 'CONFIRMING') {
console.log('\n' + (classification.response || 'Confirmation required'))
console.log('No task was created. Use `air run` for interactive confirmation.')
return
}
const taskId = `ask_${Date.now().toString(36)}`
await app.scheduler.create_tasks([{
id: taskId as any,
type: 'execute',
title: prompt.slice(0, 80),
description: prompt,
task_spec: {
id: taskId,
title: prompt.slice(0, 80),
description: prompt,
acceptance_criteria: ['Task completed successfully'],
model,
max_turns: opts?.maxTurns,
},
}])
console.log(`Compiling task ${taskId} through Scheduler/Worker...`)
const finalState = await app.scheduler.run_until_idle()
const workerResult = app.worker_manager.get_result_for_task(taskId)
console.log(`Scheduler state: ${finalState}`)
if (workerResult) {
console.log(`Worker status: ${workerResult.status}`)
if (workerResult.summary) console.log(workerResult.summary)
if (workerResult.changed_files.length > 0) {
console.log(`Changed files: ${workerResult.changed_files.join(', ')}`)
}
} else {
console.log('No WorkerResult was returned.')
}
} finally {
await runtime.shutdown() await runtime.shutdown()
} }
async function execute_task(
task: string,
provider: any,
toolRegistry: any,
contextAssembler: any,
model: string,
projectRoot: string,
maxTurns: number
): Promise<void> {
const ctx = {
session_id: 'ask-session',
project_id: 'ask-project',
project_root: projectRoot,
agent_id: 'ask-agent',
agent_type: 'executor' as const,
}
const systemPrompt = `You are an AI coding assistant. Help the user by reading files, writing code, and running commands.
Available tools:
- fs.read(path) — Read a file
- fs.write(path, content) — Write/create a file
- fs.edit(path, old_str, new_str) — Edit a file
- fs.list(path, depth?) — List directory contents
- fs.stat(path) — Get file info
- shell.run(command, timeout?) — Run a shell command
- git.status() — Show git status
- project.scan(root) — Scan project for source files
- cpp.detect() — Detect C++ project
- cpp.build(target?) — Build C++ project
- cpp.test(filter?) — Run C++ tests
To use a tool, output EXACTLY:
\`\`\`json
{"tool": "fs.read", "args": {"path": "file.cpp"}}
\`\`\`
When you are done, output:
DONE: <summary of what was done>`
const messages: any[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: task }
]
let turn = 0
const changedFiles: string[] = []
while (turn < maxTurns) {
turn++
console.log(`─ Turn ${turn}/${maxTurns}`)
// Call LLM
const result = await provider.complete_text(messages, { model, max_tokens: 4096 })
let text = result.content || ''
// Strip GLM reasoning tags if present
text = text.replace(/<\/?think>/g, '')
// Show LLM response summary
const preview = text.slice(0, 100).replace(/\n/g, ' ')
console.log(` LLM: ${preview}...`)
// Parse tool calls FIRST (before DONE check — LLM may emit both)
const tools = parseToolCalls(text)
if (tools.length > 0) {
console.log(` Tools: ${tools.map(t => t.name).join(', ')}`)
// Add assistant message
messages.push({ role: 'assistant', content: text })
// Execute each tool call
for (const tool of tools) {
try {
const toolResult = await toolRegistry.call(
{ call_id: `ask-${Date.now()}`, name: tool.name, arguments: tool.args },
ctx
)
const output = toolResult.status === 'ok'
? JSON.stringify(toolResult.output).slice(0, 500)
: `Error: ${JSON.stringify(toolResult.error)}`
console.log(`${tool.name}: ${output.slice(0, 100)}`)
if (tool.name === 'fs.write' || tool.name === 'fs.edit') {
changedFiles.push(String(tool.args.path || 'unknown'))
}
messages.push({
role: 'user',
content: `Tool ${tool.name} result: ${output}`
})
} catch (e: any) {
messages.push({
role: 'user',
content: `Tool ${tool.name} error: ${e.message}`
})
}
}
// After executing tools, check for DONE
if (text.includes('DONE:')) {
const summary = text.split('DONE:')[1]?.trim() || 'Task completed'
console.log(`\n✅ ${summary}`)
break
}
// Ask LLM to continue with remaining work
messages.push({
role: 'user',
content: 'Tools executed successfully. If the task is complete, respond with DONE: <summary>. Otherwise continue with more tool calls.'
})
continue
}
// No tool calls — LLM is just talking
// Check for DONE (no tools to execute)
if (text.includes('DONE:')) {
const summary = text.split('DONE:')[1]?.trim() || 'Task completed'
console.log(`\n✅ ${summary}`)
break
}
}
if (turn >= maxTurns) {
console.log(`\n⚠ Reached max ${maxTurns} turns. Task may be incomplete.`)
}
if (changedFiles.length > 0) {
console.log(`\nChanged files: ${changedFiles.join(', ')}`)
}
}
function parseToolCalls(text: string): Array<{ name: string; args: Record<string, unknown> }> {
const calls: Array<{ name: string; args: Record<string, unknown> }> = []
// Pattern 1: ```tool or ```json code blocks
const blockRe = /```(?:tool|json)\s*\n?([\s\S]*?)```/g
for (const match of text.matchAll(blockRe)) {
try {
const parsed = JSON.parse(match[1].trim())
if (parsed.tool) calls.push({ name: parsed.tool, args: parsed.args || {} })
} catch { /* skip */ }
}
// Pattern 2: {"tool": "..."} inline JSON anywhere in text
const jsonRe = /\{\s*"tool"\s*:\s*"([^"]+)"\s*,\s*"args"\s*:\s*(\{[^}]+\})\s*\}/g
for (const match of text.matchAll(jsonRe)) {
try {
const name = match[1]
const args = JSON.parse(match[2])
if (!calls.some(c => c.name === name)) {
calls.push({ name, args })
}
} catch { /* skip */ }
}
return calls
} }
function createProvider(model: string): any { function createProvider(model: string): any {
@@ -246,24 +121,22 @@ function createProvider(model: string): any {
const apiUrl = process.env.AIRCODING_API_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1' const apiUrl = process.env.AIRCODING_API_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
if (!apiKey) { if (!apiKey) {
console.log('⚠️ No API key found. Set AIRCODING_API_KEY or OPENAI_API_KEY.') console.log('No API key found. Set AIRCODING_API_KEY or OPENAI_API_KEY.')
console.log(' Export: export AIRCODING_API_KEY="your-key"')
process.exit(1) process.exit(1)
} }
const adapter = new OpenAICompatibleAdapter({ const adapter = new OpenAICompatibleAdapter({
base_url: apiUrl, base_url: apiUrl,
api_key: apiKey, api_key: apiKey,
model model,
}) })
// Wrap adapter in a simple provider interface
return { return {
adapters: new Map([['openai-compatible', adapter]]), adapters: new Map([['openai-compatible', adapter]]),
current_adapter: adapter, current_adapter: adapter,
current_model: model, current_model: model,
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}) { async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}) {
return (adapter as any).complete_text(messages, options) return (adapter as any).complete_text(messages, options)
} },
} }
} }

View File

@@ -1,13 +1,56 @@
/** /**
* CompactCommand - Trigger context compaction * CompactCommand - Trigger context compaction through Scheduler/Worker.
* DD §17. * DD §17.
*/ */
export function compactCommand(target_tokens?: number): void {
const tokens = target_tokens || 80000 import { randomUUID } from 'crypto'
console.log(`Context compaction requested: target ~${tokens} tokens`) import { existsSync } from 'fs'
console.log('Compaction will:') import { join } from 'path'
console.log(' 1. Summarize conversation history') import { loadConfig } from '../bootstrap/loadConfig.js'
console.log(' 2. Keep recent messages intact') import { createRuntime } from '../bootstrap/createRuntime.js'
console.log(' 3. Insert compaction marker') import { initCommand } from './init.js'
console.log(`Target: ${tokens} tokens (handled by Compactor worker automatically)`)
export async function compactCommand(target_tokens?: number): Promise<void> {
const config = loadConfig(process.env.AIRCODING_PROJECT_ROOT || process.cwd())
const project_root = config.project_root || process.cwd()
const tokens = target_tokens || config.token_budget || 80000
if (!existsSync(join(project_root, '.air', 'shared', 'project.json'))) {
console.log('Project not initialized. Running air init...\n')
await initCommand(project_root)
}
const runtime = await createRuntime(config)
const app = runtime.app
app.worker_manager.set_context({
session_id: app.session_id,
project_id: app.project_id,
project_root,
})
await app.start()
try {
const taskId = `compact_${randomUUID().slice(0, 8)}`
await app.scheduler.create_tasks([{
id: taskId,
type: 'compact',
title: `Compact context to ${tokens} tokens`,
description: `Context compaction requested for target budget ${tokens}`,
task_spec: {
task_id: taskId,
current_tokens: config.token_budget || tokens,
threshold: tokens,
target_budget_tokens: tokens,
source_content: 'CLI-triggered context compaction. Rebuild durable context from event store and summaries.',
},
}])
console.log(`Compaction task ${taskId} created. Dispatching compactor worker...`)
const finalState = await app.scheduler.run_until_idle()
const result = app.worker_manager.get_result_for_task(taskId)
console.log(`Compaction scheduler state: ${finalState}`)
console.log(result?.summary || 'Compaction finished without summary')
} finally {
await app.shutdown()
}
} }

View File

@@ -53,6 +53,10 @@ function runCmd(label: string, cmd: string, args: string[], cwd?: string, timeou
function runTest(label: string, testPath: string, repoRoot: string): { pass: boolean; detail: string } { function runTest(label: string, testPath: string, repoRoot: string): { pass: boolean; detail: string } {
const bun = findBun() const bun = findBun()
const paths = testPath.split(' ').filter(p => p.length > 0).map(p => join(repoRoot, p.replace(/^\.\//, ''))) const paths = testPath.split(' ').filter(p => p.length > 0).map(p => join(repoRoot, p.replace(/^\.\//, '')))
const missing = paths.filter(p => !existsSync(p))
if (missing.length > 0) {
return { pass: false, detail: `\n ${label} missing test paths:\n ${missing.map(p => p.replace(repoRoot + '/', '')).join('\n ')}` }
}
return runCmd(label, bun, ['test', ...paths]) return runCmd(label, bun, ['test', ...paths])
} }
@@ -111,7 +115,7 @@ export function e2eCommand(): void {
{ label: 'P0: Release-critical functional gates', fn: () => runTest('P0-REL', './packages/runtime/test/regression/release-critical-gates.test.ts ./packages/cli/test/run-command-regression.test.ts', repoRoot) }, { label: 'P0: Release-critical functional gates', fn: () => runTest('P0-REL', './packages/runtime/test/regression/release-critical-gates.test.ts ./packages/cli/test/run-command-regression.test.ts', repoRoot) },
// P1: Storage/Events // P1: Storage/Events
{ label: 'P1: Storage/Events (test)', fn: () => runTest('P1', './packages/runtime/test/storage/ ./packages/runtime/test/regression/transaction-boundary.test.ts ./packages/runtime/test/regression/event-repository-route.test.ts', repoRoot) }, { label: 'P1: Storage/Events (test)', fn: () => runTest('P1', './packages/runtime/test/regression/transaction-boundary.test.ts ./packages/runtime/test/regression/event-repository-route.test.ts ./packages/runtime/test/regression/task-attempt-repository.test.ts ./packages/runtime/test/regression/evidence-store-persistence.test.ts', repoRoot) },
// P2: Tools/Permission // P2: Tools/Permission
{ label: 'P2: Tools/Permission (test)', fn: () => runTest('P2', './packages/runtime/test/regression/tool-stubs.test.ts ./packages/runtime/test/regression/permission-engine-actions.test.ts ./packages/runtime/test/regression/path-classifier-categories.test.ts ./packages/runtime/test/regression/command-risk-analyzer.test.ts', repoRoot) }, { label: 'P2: Tools/Permission (test)', fn: () => runTest('P2', './packages/runtime/test/regression/tool-stubs.test.ts ./packages/runtime/test/regression/permission-engine-actions.test.ts ./packages/runtime/test/regression/path-classifier-categories.test.ts ./packages/runtime/test/regression/command-risk-analyzer.test.ts', repoRoot) },

View File

@@ -8,14 +8,19 @@
import { loadConfig } from '../bootstrap/loadConfig.js' import { loadConfig } from '../bootstrap/loadConfig.js'
import { createRuntime } from '../bootstrap/createRuntime.js' import { createRuntime } from '../bootstrap/createRuntime.js'
import { initCommand } from './init.js' import { initCommand } from './init.js'
import { TuiApp } from '@aircoding/tui' import type { TuiApp as TuiAppInstance } from '@aircoding/tui'
import { MainAgent } from '@aircoding/runtime' import { MainAgent, eventIngestor } from '@aircoding/runtime'
import { OpenAICompatibleAdapter } from '@aircoding/llm' import { OpenAICompatibleAdapter } from '@aircoding/llm'
import { existsSync, mkdirSync } from 'fs' import { existsSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { createInterface } from 'readline'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
type TaskResultSummary = {
title: string
files: Array<{ path: string; size: number }>
state: string
}
export async function runCommand(project_path?: string): Promise<void> { export async function runCommand(project_path?: string): Promise<void> {
const config = loadConfig(project_path) const config = loadConfig(project_path)
const project_root = config.project_root || process.cwd() const project_root = config.project_root || process.cwd()
@@ -55,26 +60,6 @@ export async function runCommand(project_path?: string): Promise<void> {
await app.start() await app.start()
// Push initial session projection
runtime.projection_client.receive_snapshot({
session_id: app.session_id,
project_id: app.project_id,
status: 'running',
title: project_root.split('/').pop() || 'AirCoding',
tasks: [],
agents: [],
tool_runs: [],
command_runs: [],
artifacts: [],
permission_prompts: [],
blockers: [],
updated_at: new Date().toISOString()
})
// Start TUI
const tui = new TuiApp({ client: runtime.projection_client })
await tui.start()
// Create MainAgent // Create MainAgent
const agent = new MainAgent({ const agent = new MainAgent({
session_id: app.session_id, session_id: app.session_id,
@@ -87,22 +72,25 @@ export async function runCommand(project_path?: string): Promise<void> {
classify_model: model classify_model: model
}) })
const session_id = app.session_id await import('@aircoding/tui/preload')
const { TuiApp } = await import('@aircoding/tui')
// Track task results for /results command const taskResults = new Map<string, TaskResultSummary>()
const taskResults = new Map<string, { title: string; files: Array<{ path: string; size: number }>; state: string }>()
let pendingConfirmation: string | undefined let pendingConfirmation: string | undefined
let tui: TuiAppInstance
let shuttingDown = false
console.log('') const shutdown = async () => {
console.log('══════════════════════════════════════════════') if (shuttingDown) return
console.log(' AirCoding v1.0.0-alpha') shuttingDown = true
if (apiKey) console.log(` Model: ${model} (API ready)`); else console.log(' No API key — AI disabled') tui?.stop()
console.log(' Type your task, or /help for commands, Ctrl+C to quit') await app.shutdown()
console.log('══════════════════════════════════════════════\n') process.exit(0)
}
const dispatchTask = async (input: string) => { const dispatchTask = async (input: string) => {
const taskId = `task_${randomUUID().slice(0, 8)}` const taskId = `task_${randomUUID().slice(0, 8)}`
app.scheduler.create_tasks([{ await app.scheduler.create_tasks([{
id: taskId, id: taskId,
type: 'execute', type: 'execute',
title: input.slice(0, 80), title: input.slice(0, 80),
@@ -110,87 +98,37 @@ export async function runCommand(project_path?: string): Promise<void> {
}]) }])
console.log(`Task ${taskId} created. Dispatching worker...`) console.log(`Task ${taskId} created. Dispatching worker...`)
const runPromise = app.scheduler.run_until_idle() tui.set_status(`Task ${taskId} running`)
const finalState = await app.scheduler.run_until_idle()
let dots = 0
const progressInterval = setInterval(() => {
dots = (dots + 1) % 4
process.stdout.write(`\r Running${'.'.repeat(dots)} `)
}, 500)
let finalState: string
try {
finalState = await runPromise
} finally {
clearInterval(progressInterval)
process.stdout.write('\r \r')
}
console.log(`Task complete. Scheduler: ${finalState}`) console.log(`Task complete. Scheduler: ${finalState}`)
const workerResult = app.worker_manager.get_result_for_task?.(taskId) const workerResult = app.worker_manager.get_result_for_task?.(taskId)
const resultFiles = Array.isArray(workerResult?.changed_files) ? workerResult.changed_files : [] const resultFiles = Array.isArray(workerResult?.changed_files) ? workerResult.changed_files : []
const recentFiles: Array<{ path: string; size: number }> = [] const producedFiles: Array<{ path: string; size: number }> = []
if (resultFiles.length > 0) { if (resultFiles.length > 0) {
const { statSync: st, existsSync: ex } = await import('fs') const { statSync, existsSync: fileExists } = await import('fs')
for (const file of resultFiles) { for (const file of resultFiles) {
if (!file || file.startsWith('.air/') || file.includes('/.air/')) continue if (!file || file.startsWith('.air/') || file.includes('/.air/') || file.split('/').some(e => e.startsWith('.'))) continue
const full = join(project_root, file) const fullPath = join(project_root, file)
if (!ex(full)) continue if (!fileExists(fullPath)) continue
const s = st(full) const stat = statSync(fullPath)
if (s.isFile()) recentFiles.push({ path: file, size: s.size }) if (stat.isFile()) producedFiles.push({ path: file, size: stat.size })
} }
} else {
const { readdirSync: rd, statSync: st, existsSync: ex } = await import('fs')
const scanDir = (d: string, depth: number) => {
if (depth > 3 || !ex(d)) return
try {
for (const e of rd(d)) {
if (e.startsWith('.')) continue
const p = join(d, e)
try {
const s = st(p)
if (s.isDirectory()) scanDir(p, depth + 1)
else if (s.mtimeMs > Date.now() - 60000) recentFiles.push({ path: p.replace(project_root + '/', ''), size: s.size })
} catch {}
} }
} catch {}
} if (producedFiles.length > 0) {
scanDir(project_root, 0)
}
if (recentFiles.length > 0) {
console.log(' Produced files:') console.log(' Produced files:')
for (const f of recentFiles.slice(0, 10)) { for (const file of producedFiles.slice(0, 10)) {
console.log(` 📄 ${f.path} (${f.size}B)`) console.log(` ${file.path} (${file.size}B)`)
} }
} }
taskResults.set(taskId, { title: input.slice(0, 80), files: recentFiles, state: finalState }) taskResults.set(taskId, { title: input.slice(0, 80), files: producedFiles, state: finalState })
tui.set_status(`Task ${taskId}: ${finalState}`)
runtime.projection_client.receive_snapshot({
session_id,
project_id: app.project_id,
status: finalState === 'COMPLETED' ? 'completed' : 'running',
title: project_root.split('/').pop() || 'AirCoding',
tasks: [{ id: taskId as any, type: 'execute', status: finalState === 'COMPLETED' ? 'completed' : 'running', title: input.slice(0, 80), retry_count: 0, attempts: 1, created_at: new Date().toISOString() }],
agents: [],
tool_runs: [],
command_runs: [],
artifacts: [],
permission_prompts: [],
blockers: [],
updated_at: new Date().toISOString()
})
} }
// Interactive input loop const handleSubmit = async (input: string) => {
const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' })
rl.prompt()
rl.on('line', async (line: string) => {
const input = line.trim()
if (!input) { rl.prompt(); return }
if (pendingConfirmation) { if (pendingConfirmation) {
if (/^(y|yes|是|确认|确定)$/i.test(input)) { if (/^(y|yes|是|确认|确定)$/i.test(input)) {
const confirmedInput = pendingConfirmation const confirmedInput = pendingConfirmation
@@ -201,59 +139,89 @@ export async function runCommand(project_path?: string): Promise<void> {
pendingConfirmation = undefined pendingConfirmation = undefined
await agent.handle_confirmation(false) await agent.handle_confirmation(false)
console.log('Cancelled. No task was created.\n') console.log('Cancelled. No task was created.\n')
tui.set_status('Cancelled')
} else { } else {
console.log('Please answer y/n to confirm or cancel the pending destructive request.\n') console.log('Please answer y/n to confirm or cancel the pending destructive request.\n')
tui.set_status('Waiting for confirmation')
} }
rl.prompt()
return return
} }
// Handle slash commands
if (input.startsWith('/')) {
await handleSlashCommand(input, app, runtime, tui, rl, taskResults)
rl.prompt()
return
}
// Route through MainAgent
const classification = await agent.handle_user_message(input) const classification = await agent.handle_user_message(input)
console.log(`[${classification.action}]`) console.log(`[${classification.action}]`)
if (classification.action === 'answer') { if (classification.action === 'answer') {
console.log('\n' + (classification.response || 'No response') + '\n') console.log('\n' + (classification.response || 'No response') + '\n')
tui.set_status('Answered')
} else if (classification.action === 'delegate') { } else if (classification.action === 'delegate') {
if (agent.state === 'CONFIRMING' && classification.response) { if (agent.state === 'CONFIRMING' && classification.response) {
pendingConfirmation = input pendingConfirmation = input
console.log('\n' + classification.response + '\n') console.log('\n' + classification.response + '\n')
tui.set_status('Waiting for confirmation')
} else { } else {
await dispatchTask(input) await dispatchTask(input)
} }
} else { } else {
console.log(`Result: ${classification.response || 'Done'}`) console.log(`Result: ${classification.response || 'Done'}`)
tui.set_status(classification.response || 'Done')
}
} }
rl.prompt() const resolvePermission = async (prompt_id: string, selected_option: string) => {
await eventIngestor.ingest({
id: `evt_${prompt_id}_resolved_${randomUUID().slice(0, 8)}`,
type: 'permission.prompt.resolved',
version: 1,
session_id: app.session_id,
project_id: app.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'main', id: 'tui' },
route: ['cli', 'tui', 'permission'],
payload: {
prompt_id,
selected_option,
decision_id: `decision_${randomUUID().slice(0, 8)}`,
resolved_by: 'user',
},
}) })
tui.set_status(`Permission ${selected_option}`)
rl.on('close', async () => {
console.log('\nShutting down...')
tui.stop()
await app.shutdown()
process.exit(0)
})
process.on('SIGINT', () => {
rl.close()
})
await new Promise(() => {}) // Wait forever
} }
async function handleSlashCommand(input: string, app: any, runtime: any, tui: any, rl: any, taskResults?: Map<string, any>): Promise<void> { tui = new TuiApp({
client: runtime.projection_client,
onSubmit: handleSubmit,
onSlashCommand: (input) => handleSlashCommand(input, app, tui, taskResults, shutdown),
onResolvePermission: resolvePermission,
onExit: shutdown,
})
await tui.start()
console.log('')
console.log('══════════════════════════════════════════════')
console.log(' AirCoding v1.0.0-alpha')
if (apiKey) console.log(` Model: ${model} (API ready)`); else console.log(' No API key — AI disabled')
console.log(' Type your task, or /help for commands, Ctrl+C to quit')
console.log('══════════════════════════════════════════════\n')
process.once('SIGINT', () => {
void shutdown()
})
await new Promise(() => {})
}
async function handleSlashCommand(
input: string,
app: any,
tui: TuiAppInstance,
taskResults: Map<string, TaskResultSummary>,
shutdown: () => Promise<void>,
): Promise<void> {
const cmd = input.slice(1).toLowerCase() const cmd = input.slice(1).toLowerCase()
switch (cmd) { switch (cmd) {
case 'help': case 'help':
tui.set_view('help')
console.log('\nCommands:') console.log('\nCommands:')
console.log(' /help — Show this help') console.log(' /help — Show this help')
console.log(' /status — Show scheduler and worker status') console.log(' /status — Show scheduler and worker status')
@@ -264,15 +232,15 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an
break break
case 'results': case 'results':
if (!taskResults || taskResults.size === 0) { if (taskResults.size === 0) {
console.log(' No task results yet. Submit a task first.\n') console.log(' No task results yet. Submit a task first.\n')
} else { } else {
console.log('') console.log('')
for (const [taskId, result] of taskResults) { for (const [_taskId, result] of taskResults) {
console.log(` Task: ${result.title} [${result.state}]`) console.log(` Task: ${result.title} [${result.state}]`)
if (result.files.length > 0) { if (result.files.length > 0) {
for (const f of result.files) { for (const file of result.files) {
console.log(` 📄 ${f.path} (${f.size}B)`) console.log(` ${file.path} (${file.size}B)`)
} }
} else { } else {
console.log(' (no files produced)') console.log(' (no files produced)')
@@ -289,13 +257,14 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an
break break
case 'tools': { case 'tools': {
tui.set_view('tools')
const tools = app.tool_registry.list() const tools = app.tool_registry.list()
console.log(`\n ${tools.length} tools registered:`) console.log(`\n ${tools.length} tools registered:`)
const cats = new Map<string, string[]>() const cats = new Map<string, string[]>()
for (const t of tools) { for (const tool of tools) {
const list = cats.get(t.category) || [] const list = cats.get(tool.category) || []
list.push(t.name) list.push(tool.name)
cats.set(t.category, list) cats.set(tool.category, list)
} }
for (const [cat, names] of cats) { for (const [cat, names] of cats) {
console.log(` ${cat}: ${names.join(', ')}`) console.log(` ${cat}: ${names.join(', ')}`)
@@ -305,6 +274,7 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an
} }
case 'tasks': { case 'tasks': {
tui.set_view('tasks')
const counts = app.scheduler.get_graph().count_by_status() const counts = app.scheduler.get_graph().count_by_status()
console.log(`\n Task graph:`) console.log(`\n Task graph:`)
for (const [status, count] of Object.entries(counts)) { for (const [status, count] of Object.entries(counts)) {
@@ -316,7 +286,7 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an
case 'quit': case 'quit':
case 'exit': case 'exit':
rl.close() await shutdown()
break break
default: default:

View File

@@ -20,7 +20,6 @@
* @module packages/cli * @module packages/cli
*/ */
import { runCommand } from './commands/run.js'
import { initCommand } from './commands/init.js' import { initCommand } from './commands/init.js'
import { doctorCommand } from './commands/doctor.js' import { doctorCommand } from './commands/doctor.js'
import { providerCommand } from './commands/provider.js' import { providerCommand } from './commands/provider.js'
@@ -39,9 +38,11 @@ export async function main(argv: string[]): Promise<void> {
const rest = args.slice(1) const rest = args.slice(1)
switch (command) { switch (command) {
case 'run': case 'run': {
const { runCommand } = await import('./commands/run.js')
await runCommand(rest[0]) await runCommand(rest[0])
break break
}
case 'ask': case 'ask':
await askCommand(rest.join(' '), { await askCommand(rest.join(' '), {
@@ -71,7 +72,7 @@ export async function main(argv: string[]): Promise<void> {
break break
case 'compact': case 'compact':
compactCommand(rest[0] ? parseInt(rest[0]) : undefined) await compactCommand(rest[0] ? parseInt(rest[0]) : undefined)
break break
case 'history': case 'history':

View File

@@ -1,6 +1,9 @@
import { describe, it, expect } from 'bun:test' import { describe, it, expect, afterEach } from 'bun:test'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { loadConfig } from '../src/bootstrap/loadConfig.js'
describe('run command result presentation', () => { describe('run command result presentation', () => {
const source = readFileSync(join(import.meta.dir, '../src/commands/run.ts'), 'utf-8') const source = readFileSync(join(import.meta.dir, '../src/commands/run.ts'), 'utf-8')
@@ -22,3 +25,44 @@ describe('run command result presentation', () => {
expect(source).toContain('No task was created') expect(source).toContain('No task was created')
}) })
}) })
describe('ask command architecture boundary', () => {
const source = readFileSync(join(import.meta.dir, '../src/commands/ask.ts'), 'utf-8')
it('dispatches delegate work through Scheduler and WorkerManager', () => {
expect(source).toContain('app.scheduler.create_tasks')
expect(source).toContain('app.scheduler.run_until_idle')
expect(source).toContain('app.worker_manager.get_result_for_task')
})
it('does not implement an inline LLM-to-tool loop', () => {
expect(source).not.toContain('parseToolCalls')
expect(source).not.toContain('toolRegistry.call')
expect(source).not.toContain('while (turn <')
})
})
describe('project root configuration', () => {
const created: string[] = []
afterEach(() => {
delete process.env.AIRCODING_PROJECT_ROOT
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
})
it('uses AIRCODING_PROJECT_ROOT when no explicit project path is passed', () => {
const projectRoot = mkdtempSync(join(tmpdir(), 'air-load-config-'))
created.push(projectRoot)
process.env.AIRCODING_PROJECT_ROOT = projectRoot
expect(loadConfig().project_root).toBe(projectRoot)
})
it('explicit project path wins over AIRCODING_PROJECT_ROOT', () => {
const envRoot = mkdtempSync(join(tmpdir(), 'air-load-config-env-'))
const explicitRoot = mkdtempSync(join(tmpdir(), 'air-load-config-explicit-'))
created.push(envRoot, explicitRoot)
process.env.AIRCODING_PROJECT_ROOT = envRoot
expect(loadConfig(explicitRoot).project_root).toBe(explicitRoot)
})
})

View File

@@ -0,0 +1,74 @@
/**
* Anthropic canonical content block types
* Per constraint #6: Anthropic canonical content blocks
*
* @module packages/contracts/src/content-block
*/
/**
* Text content block
*/
export interface TextBlock {
type: 'text'
text: string
}
/**
* Thinking/reasoning block (for models that support it)
*/
export interface ThinkingBlock {
type: 'thinking'
thinking: string
}
/**
* Tool use block - represents a tool call request
*/
export interface ToolUseBlock {
type: 'tool_use'
id: string
name: string
input: Record<string, unknown>
}
/**
* Tool result block - represents the result of a tool execution
*/
export interface ToolResultBlock {
type: 'tool_result'
tool_use_id: string
content: string | ContentBlock[]
is_error?: boolean
}
/**
* Union of all canonical content block types
*/
export type ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock
/**
* Canonical message format using content blocks
*/
export interface CanonicalMessage {
role: 'user' | 'assistant' | 'system'
content: string | ContentBlock[]
// Optional thinking for assistant messages
thinking?: string
}
/**
* Tool definition for tool use blocks
*/
export interface ToolDefinitionBlock {
name: string
description: string
input_schema: Record<string, unknown>
}
/**
* Tool choice specification
*/
export type ToolChoice =
| { type: 'auto' }
| { type: 'any' }
| { type: 'tool'; name: string }

View File

@@ -4,6 +4,7 @@
export * from './ids' // §2 Core Primitive Types export * from './ids' // §2 Core Primitive Types
export * from './error' // §3 Error Contracts export * from './error' // §3 Error Contracts
export * from './event' // §5 Runtime Event Contracts export * from './event' // §5 Runtime Event Contracts
export * from './content-block' // §6 Anthropic Canonical Content Blocks
export * from './runtime' // §10 Worker/IPC Contracts (runtime context) export * from './runtime' // §10 Worker/IPC Contracts (runtime context)
export * from './ipc' // §10 Worker/IPC Contracts export * from './ipc' // §10 Worker/IPC Contracts
export * from './task' // §9 Task and Scheduler Contracts export * from './task' // §9 Task and Scheduler Contracts
@@ -11,7 +12,7 @@ export * from './worker-result' // §11 WorkerResult Contracts
export * from './tool' // §12 Tool Contracts + §21 Diagnostic Contracts export * from './tool' // §12 Tool Contracts + §21 Diagnostic Contracts
export * from './permission' // §13 Permission Contracts export * from './permission' // §13 Permission Contracts
export * from './artifact' // §14 Artifact Contracts export * from './artifact' // §14 Artifact Contracts
export * from './evidence' // §14 Evidence Contracts export * from './evidence' // <EFBFBD><EFBFBD>14 Evidence Contracts
export * from './project' // §8 Project and Session Contracts export * from './project' // §8 Project and Session Contracts
// Provider exports - re-export with disambiguation for duplicate names // Provider exports - re-export with disambiguation for duplicate names

View File

@@ -13,6 +13,14 @@ import type {
JsonObject, JsonObject,
} from './ids' } from './ids'
// Import content block types for canonical message format
import type {
CanonicalMessage,
TextBlock,
ToolDefinitionBlock,
ToolChoice,
} from './content-block'
// ============================================================================= // =============================================================================
// §15 — Provider Contracts // §15 — Provider Contracts
// ============================================================================= // =============================================================================
@@ -180,10 +188,10 @@ export interface ProviderCompletionInput {
provider_id: ProviderID provider_id: ProviderID
model_id: ModelID model_id: ModelID
canonical_format: 'anthropic' canonical_format: 'anthropic'
messages: unknown[] messages: CanonicalMessage[]
tools?: unknown[] tools?: ToolDefinitionBlock[]
tool_choice?: unknown tool_choice?: ToolChoice
system?: unknown system?: string | TextBlock[]
max_output_tokens?: number max_output_tokens?: number
temperature?: number temperature?: number
metadata?: JsonObject metadata?: JsonObject

View File

@@ -96,8 +96,8 @@ export class ProviderManager {
*/ */
async complete_text( async complete_text(
messages: unknown[], messages: unknown[],
options: { model?: string; max_tokens?: number; temperature?: number; system?: string } = {} options: { model?: string; max_tokens?: number; temperature?: number; system?: string; tools?: unknown[] } = {}
): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number } }> { ): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number }; tool_calls?: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> }> {
// Auto-initialize if no adapter selected yet (cold start) // Auto-initialize if no adapter selected yet (cold start)
if (!this.current_adapter) { if (!this.current_adapter) {
this.select_model({ model: options.model || 'claude-haiku-4-5-20251001', provider: 'anthropic' }) this.select_model({ model: options.model || 'claude-haiku-4-5-20251001', provider: 'anthropic' })
@@ -108,11 +108,13 @@ export class ProviderManager {
} }
const model_id = options.model || 'claude-haiku-4-5-20251001' const model_id = options.model || 'claude-haiku-4-5-20251001'
// N1: Cast to CanonicalMessage[] - adapter handles conversion from unknown[]
const input: ProviderCompletionInput = { const input: ProviderCompletionInput = {
provider_id: 'anthropic', provider_id: 'anthropic',
model_id: model_id as ModelID, model_id: model_id as ModelID,
canonical_format: 'anthropic', canonical_format: 'anthropic',
messages, messages: messages as any,
tools: options.tools as any,
max_output_tokens: options.max_tokens || 4096, max_output_tokens: options.max_tokens || 4096,
temperature: options.temperature, temperature: options.temperature,
system: options.system system: options.system
@@ -120,6 +122,7 @@ export class ProviderManager {
let content = '' let content = ''
let usage: { input_tokens: number; output_tokens: number } | undefined let usage: { input_tokens: number; output_tokens: number } | undefined
const tool_calls: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> = []
for await (const event of adapter.complete(input)) { for await (const event of adapter.complete(input)) {
if (event.type === 'content_delta') { if (event.type === 'content_delta') {
@@ -129,6 +132,11 @@ export class ProviderManager {
} else if (payload.type === 'thinking_delta') { } else if (payload.type === 'thinking_delta') {
// Accumulate thinking for reference but don't include in content // Accumulate thinking for reference but don't include in content
} }
} else if (event.type === 'tool_use') {
const payload = event.payload as { id?: string; name?: string; input?: Record<string, unknown>; arguments?: Record<string, unknown> }
if (payload.name) {
tool_calls.push({ id: payload.id, name: payload.name, arguments: payload.input || payload.arguments || {} })
}
} else if (event.type === 'message_stop') { } else if (event.type === 'message_stop') {
const payload = event.payload as { usage?: { output_tokens: number } } const payload = event.payload as { usage?: { output_tokens: number } }
if (payload.usage) { if (payload.usage) {
@@ -137,7 +145,7 @@ export class ProviderManager {
} }
} }
return { content, usage } return { content, usage, tool_calls: tool_calls.length ? tool_calls : undefined }
} }
/** /**
@@ -171,7 +179,7 @@ export class ProviderManager {
provider_id: assignment.provider as ProviderID || 'anthropic', provider_id: assignment.provider as ProviderID || 'anthropic',
model_id: assignment.model as ModelID, model_id: assignment.model as ModelID,
canonical_format: 'anthropic', canonical_format: 'anthropic',
messages, messages: messages as any,
max_output_tokens: options.max_tokens || 4096, max_output_tokens: options.max_tokens || 4096,
temperature: options.temperature temperature: options.temperature
} }

View File

@@ -41,6 +41,7 @@ interface AnthropicApiRequest {
top_p?: number top_p?: number
system?: string system?: string
stream?: boolean stream?: boolean
tools?: Array<{ name: string; description: string; input_schema: Record<string, unknown> }>
} }
export class AnthropicAdapter implements ProviderAdapter { export class AnthropicAdapter implements ProviderAdapter {
@@ -101,6 +102,7 @@ export class AnthropicAdapter implements ProviderAdapter {
temperature: input.temperature, temperature: input.temperature,
system: input.system as string | undefined, system: input.system as string | undefined,
stream: false, stream: false,
tools: this.convert_tools(input.tools),
}) })
// Yield each content block as an event // Yield each content block as an event
@@ -110,6 +112,8 @@ export class AnthropicAdapter implements ProviderAdapter {
yield { type: 'content_delta', payload: { type: 'text_delta', text: block.text } } yield { type: 'content_delta', payload: { type: 'text_delta', text: block.text } }
} else if (block.type === 'thinking' && block.thinking) { } else if (block.type === 'thinking' && block.thinking) {
yield { type: 'content_delta', payload: { type: 'thinking_delta', thinking: block.thinking } } yield { type: 'content_delta', payload: { type: 'thinking_delta', thinking: block.thinking } }
} else if (block.type === 'tool_use' && block.name) {
yield { type: 'tool_use', payload: { id: block.id, name: block.name, input: (block.input as Record<string, unknown>) || {} } }
} }
} }
if (response.usage) { if (response.usage) {
@@ -126,19 +130,36 @@ export class AnthropicAdapter implements ProviderAdapter {
* Backward-compat: single-shot complete that returns string content. * Backward-compat: single-shot complete that returns string content.
* Used by MainAgent.classify_via_llm. * Used by MainAgent.classify_via_llm.
*/ */
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number } }> { async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number; tools?: unknown[] } = {}): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number }; tool_calls?: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> }> {
const response = await this.make_request({ const response = await this.make_request({
model: options.model || 'claude-haiku-4-5-20251001', model: options.model || 'claude-haiku-4-5-20251001',
messages: this.convert_raw_messages(messages), messages: this.convert_raw_messages(messages),
max_tokens: options.max_tokens || 1024, max_tokens: options.max_tokens || 1024,
tools: this.convert_tools(options.tools),
stream: false, stream: false,
}) })
const tool_calls = response.content
.filter(b => b.type === 'tool_use' && b.name)
.map(b => ({ id: b.id, name: b.name!, arguments: (b.input as Record<string, unknown>) || {} }))
return { return {
content: this.extract_content(response), content: this.extract_content(response),
usage: response.usage ? { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens } : undefined, usage: response.usage ? { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens } : undefined,
tool_calls: tool_calls.length ? tool_calls : undefined,
} }
} }
private convert_tools(tools?: unknown[]): Array<{ name: string; description: string; input_schema: Record<string, unknown> }> | undefined {
if (!tools?.length) return undefined
return tools.map(t => {
const tool = t as { name: string; description?: string; input_schema?: Record<string, unknown> }
return {
name: tool.name,
description: tool.description || tool.name,
input_schema: tool.input_schema || { type: 'object', properties: {} },
}
})
}
private convert_to_anthropic_messages(messages: Array<{ role: string; content: unknown }>): Array<{ role: string; content: Array<Record<string, unknown>> }> { private convert_to_anthropic_messages(messages: Array<{ role: string; content: unknown }>): Array<{ role: string; content: Array<Record<string, unknown>> }> {
return messages.map(m => { return messages.map(m => {
const blocks: Array<Record<string, unknown>> = [] const blocks: Array<Record<string, unknown>> = []

View File

@@ -34,7 +34,7 @@ interface OpenAIApiResponse {
model: string model: string
choices: Array<{ choices: Array<{
index: number index: number
message?: { role: string; content: string; tool_calls?: unknown[] } message?: { role: string; content: string | null; tool_calls?: Array<{ id: string; type: string; function: { name: string; arguments: string } }> }
delta?: { role?: string; content?: string } delta?: { role?: string; content?: string }
finish_reason?: string finish_reason?: string
}> }>
@@ -96,6 +96,7 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
const response = await this.make_request({ const response = await this.make_request({
model: String(input.model_id), model: String(input.model_id),
messages: this.convert_messages(input.messages as { role: string; content: unknown }[]), messages: this.convert_messages(input.messages as { role: string; content: unknown }[]),
tools: this.convert_tools(input.tools),
max_tokens: input.max_output_tokens ?? 4096, max_tokens: input.max_output_tokens ?? 4096,
temperature: input.temperature, temperature: input.temperature,
system: input.system as string | undefined, system: input.system as string | undefined,
@@ -108,6 +109,22 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
if (content) { if (content) {
yield { type: 'content_delta', payload: { type: 'text_delta', text: content, index: choice.index } } yield { type: 'content_delta', payload: { type: 'text_delta', text: content, index: choice.index } }
} }
for (const call of choice.message?.tool_calls || []) {
let args: Record<string, unknown> = {}
try {
args = JSON.parse(call.function.arguments || '{}')
} catch {
args = { raw_arguments: call.function.arguments || '' }
}
yield {
type: 'tool_use',
payload: {
id: call.id,
name: this.from_openai_tool_name(call.function.name),
input: args,
}
}
}
} }
if (response.usage) { if (response.usage) {
const stop = response.choices[0]?.finish_reason || 'stop' const stop = response.choices[0]?.finish_reason || 'stop'
@@ -121,10 +138,11 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
* Backward-compat: single-shot complete that returns string content. * Backward-compat: single-shot complete that returns string content.
* Used by callers expecting a Promise<string> result. * Used by callers expecting a Promise<string> result.
*/ */
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number } }> { async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number; tools?: unknown[] } = {}): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number }; tool_calls?: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> }> {
const response = await this.make_request({ const response = await this.make_request({
model: options.model || this.model, model: options.model || this.model,
messages: this.convert_raw_messages(messages), messages: this.convert_raw_messages(messages),
tools: this.convert_tools(options.tools),
max_tokens: options.max_tokens || 1024, max_tokens: options.max_tokens || 1024,
stream: false, stream: false,
}) })
@@ -133,29 +151,106 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
const content = choice?.message?.content const content = choice?.message?.content
|| (choice?.message as any)?.reasoning || (choice?.message as any)?.reasoning
|| '' || ''
const tool_calls = (choice?.message?.tool_calls || []).map(call => {
let args: Record<string, unknown> = {}
try {
args = JSON.parse(call.function.arguments || '{}')
} catch {
args = { raw_arguments: call.function.arguments || '' }
}
return { id: call.id, name: this.from_openai_tool_name(call.function.name), arguments: args }
})
return { return {
content, content,
usage: response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined, usage: response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined,
tool_calls: tool_calls.length ? tool_calls : undefined,
} }
} }
private convert_messages(messages: Array<{ role: string; content: unknown }>): Array<Record<string, unknown>> { private convert_messages(messages: Array<{ role: string; content: unknown }>): Array<Record<string, unknown>> {
return messages.map(m => ({ return messages.flatMap(m => this.convert_one_message(m))
role: m.role, }
content: typeof m.content === 'string' ? m.content : String(m.content),
/**
* Convert a single canonical message to OpenAI wire format.
* Assistant tool_use blocks → assistant message with tool_calls.
* tool_result blocks → one role:tool message per result (OpenAI requires
* each tool result to reference its tool_call_id on its own message).
*/
private convert_one_message(m: { role: string; content: unknown }): Array<Record<string, unknown>> {
if (Array.isArray(m.content)) {
const toolResults = m.content.filter(c => typeof c === 'object' && c !== null && (c as any).type === 'tool_result') as any[]
if (toolResults.length > 0) {
return toolResults.map(tr => ({
role: 'tool',
tool_call_id: tr.tool_use_id,
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
})) }))
} }
private convert_raw_messages(messages: unknown[]): Array<Record<string, unknown>> { const toolUses = m.content.filter(c => typeof c === 'object' && c !== null && (c as any).type === 'tool_use') as any[]
return messages.map(m => { if (toolUses.length > 0) {
const obj = m as { role: string; content: unknown } const text = m.content
if (typeof obj.content === 'string') { .filter(c => typeof c === 'object' && c !== null && (c as any).type === 'text')
return { role: obj.role, content: obj.content } .map(c => String((c as any).text || ''))
.join('\n')
return [{
role: m.role,
content: text || null,
tool_calls: toolUses.map(tu => ({
id: tu.id,
type: 'function',
function: { name: this.to_openai_tool_name(tu.name), arguments: JSON.stringify(tu.input || {}) },
})),
}]
}
}
return [{ role: m.role, content: this.content_to_text(m.content) }]
}
private convert_tools(tools?: unknown[]): Array<Record<string, unknown>> | undefined {
if (!tools?.length) return undefined
return tools.map(t => {
const tool = t as { name: string; description?: string; input_schema?: Record<string, unknown> }
return {
type: 'function',
function: {
name: this.to_openai_tool_name(tool.name),
description: tool.description || tool.name,
parameters: tool.input_schema || { type: 'object', properties: {} },
}
} }
return { role: obj.role, content: String(obj.content) }
}) })
} }
private to_openai_tool_name(name: string): string {
return name.replace(/\./g, '__')
}
private from_openai_tool_name(name: string): string {
return name.replace(/__/g, '.')
}
private content_to_text(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content.map(c => {
if (typeof c === 'string') return c
if (typeof c === 'object' && c !== null) {
const obj = c as Record<string, unknown>
if (obj.type === 'text') return String(obj.text || '')
return JSON.stringify(obj)
}
return String(c)
}).join('\n')
}
return String(content)
}
private convert_raw_messages(messages: unknown[]): Array<Record<string, unknown>> {
return messages.flatMap(m => this.convert_one_message(m as { role: string; content: unknown }))
}
private capability_matrix(model_id: string): ProviderCapabilityMatrix { private capability_matrix(model_id: string): ProviderCapabilityMatrix {
return { return {
provider_id: this.provider_id, provider_id: this.provider_id,

View File

@@ -15,7 +15,8 @@
}, },
"dependencies": { "dependencies": {
"@aircoding/contracts": "workspace:*", "@aircoding/contracts": "workspace:*",
"@aircoding/llm": "workspace:*" "@aircoding/llm": "workspace:*",
"@aircoding/toolchain-cpp": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.1", "@types/node": "^25.9.1",

View File

@@ -20,12 +20,23 @@ import { DatabaseManager } from '../storage/DatabaseManager.js'
import { MigrationRunner } from '../storage/MigrationRunner.js' import { MigrationRunner } from '../storage/MigrationRunner.js'
import { ToolRegistry, createToolRegistry } from '../tools/ToolRegistry.js' import { ToolRegistry, createToolRegistry } from '../tools/ToolRegistry.js'
import { BuiltInToolRegistrar } from '../tools/BuiltInToolRegistrar.js' import { BuiltInToolRegistrar } from '../tools/BuiltInToolRegistrar.js'
import { EventBus } from '../events/EventBus.js' import { EventBus, eventBus, type Subscription } from '../events/EventBus.js'
import { EventStore, eventStore } from '../events/EventStore.js' import { EventStore, eventStore } from '../events/EventStore.js'
import { EventIngestorImpl } from '../events/EventIngestor.js' import { EventIngestorImpl, eventIngestor } from '../events/EventIngestor.js'
import { TaskRepository } from '../storage/repositories/TaskRepository.js' import { TaskRepository } from '../storage/repositories/TaskRepository.js'
import { MessageRepository } from '../storage/repositories/MessageRepository.js' import { MessageRepository } from '../storage/repositories/MessageRepository.js'
import { EvidenceRepository } from '../storage/repositories/EvidenceRepository.js' import { EvidenceRepository } from '../storage/repositories/EvidenceRepository.js'
import { SessionRepository } from '../storage/repositories/SessionRepository.js'
import { MessageDraftRepository } from '../storage/repositories/MessageDraftRepository.js'
import { TaskAttemptRepository } from '../storage/repositories/TaskAttemptRepository.js'
import { TaskDependencyRepository } from '../storage/repositories/TaskDependencyRepository.js'
import { AgentRepository } from '../storage/repositories/AgentRepository.js'
import { ToolRunRepository } from '../storage/repositories/ToolRunRepository.js'
import { CommandRunRepository } from '../storage/repositories/CommandRunRepository.js'
import { ArtifactRepository } from '../storage/repositories/ArtifactRepository.js'
import { DiagnosticRepository } from '../storage/repositories/DiagnosticRepository.js'
import { WorkspaceRepository } from '../storage/repositories/WorkspaceRepository.js'
import { SummaryRepository } from '../storage/repositories/SummaryRepository.js'
import { createCapabilityRegistry, type CapabilityRegistry } from '../capabilities/CapabilityRegistry.js' import { createCapabilityRegistry, type CapabilityRegistry } from '../capabilities/CapabilityRegistry.js'
export interface RuntimeAppConfig { export interface RuntimeAppConfig {
@@ -37,6 +48,8 @@ export interface RuntimeAppConfig {
export class RuntimeApp { export class RuntimeApp {
private config: RuntimeAppConfig private config: RuntimeAppConfig
private projection_subscription: Subscription | null = null
private projection_client_unsubscribe: (() => void) | null = null
scheduler: Scheduler scheduler: Scheduler
worker_manager: WorkerManager worker_manager: WorkerManager
context_assembler: ContextAssembler context_assembler: ContextAssembler
@@ -75,19 +88,22 @@ export class RuntimeApp {
this.doctor = new DoctorService(config.project_root, this.capability_registry) this.doctor = new DoctorService(config.project_root, this.capability_registry)
this.projection_store = new ProjectionStore() this.projection_store = new ProjectionStore()
this.projection_client = new ProjectionClient() this.projection_client = new ProjectionClient()
this.event_bus = new EventBus() this.event_bus = eventBus
const raw_db = this.db.getRawDatabase() const raw_db = this.db.getRawDatabase()
// Wire singleton eventStore with real DB (EventIngestor uses it) // Wire singleton eventStore with real DB (EventIngestor uses it)
if (raw_db) eventStore.setTransactionManager(this.db) if (raw_db) eventStore.setTransactionManager(this.db)
this.event_store = raw_db // Use module singleton eventStore - don't create separate instance
? new EventStore({ id: 'runtime', db: raw_db } as any) this.event_store = eventStore
: new EventStore({ id: 'startup', db: null } as any) this.event_ingestor = eventIngestor
this.event_ingestor = new EventIngestorImpl()
// Wire ProjectionStore → ProjectionClient (DD §13.2) // Wire ProjectionStore → ProjectionClient (DD §13.2)
this.projection_store.subscribe((projection) => { this.projection_client_unsubscribe = this.projection_store.subscribe((projection) => {
this.projection_client.receive_snapshot(projection) this.projection_client.receive_snapshot(projection)
}) })
this.projection_subscription = this.event_bus.subscribe(
{ session_id: config.session_id },
(event) => this.projection_store.apply(event),
)
// Wire Scheduler to WorkerManager (DD §7.1) // Wire Scheduler to WorkerManager (DD §7.1)
this.scheduler = new Scheduler({ this.scheduler = new Scheduler({
@@ -140,21 +156,55 @@ export class RuntimeApp {
registrar.register_all(this.config.project_root) registrar.register_all(this.config.project_root)
this.logger.info('Built-in tools registered') this.logger.info('Built-in tools registered')
// Step 4: Wire EventStore with DB transaction manager // Step 4: Discover project-local SKILL.md capabilities without executing skill content.
await this.discover_project_skills()
// Step 4.5: Register cpp toolchain via CapabilityRegistry (INV-4)
await this.register_cpp_toolchain()
// Step 5: Wire EventStore with DB transaction manager
this.event_store.setTransactionManager(this.db) this.event_store.setTransactionManager(this.db)
// Step 5: Hydrate ProjectionStore from DB (INV-5: from SQLite, not EventBus) // Step 6: Hydrate ProjectionStore from DB (INV-5: from SQLite, not EventBus)
this.logger.info('Hydrating projection store', { session_id: this.config.session_id }) this.logger.info('Hydrating projection store', { session_id: this.config.session_id })
// Step 6: Recover interrupted tasks (INV-5: rebuild from SQLite) // Step 7: Wire all domain repositories to module singleton EventStore
try { try {
const raw_db = this.db.getRawDatabase() const raw_db = this.db.getRawDatabase()
if (raw_db) { if (raw_db) {
const task_repo = new TaskRepository(raw_db as any) const sessionRepo = new SessionRepository(raw_db as any)
const message_repo = new MessageRepository(raw_db as any) const messageRepo = new MessageRepository(raw_db as any)
const evidence_repo = new EvidenceRepository(raw_db as any) const messageDraftRepo = new MessageDraftRepository(raw_db as any)
this.context_assembler.set_data_sources({ message_repo, evidence_store: evidence_repo }) const taskRepo = new TaskRepository(raw_db as any)
this.scheduler.set_task_repo(task_repo) const taskAttemptRepo = new TaskAttemptRepository(raw_db as any)
const taskDepRepo = new TaskDependencyRepository(raw_db as any)
const agentRepo = new AgentRepository(raw_db as any)
const toolRunRepo = new ToolRunRepository(raw_db as any)
const commandRunRepo = new CommandRunRepository(raw_db as any)
const artifactRepo = new ArtifactRepository(raw_db as any)
const diagnosticRepo = new DiagnosticRepository(raw_db as any)
const evidenceRepo = new EvidenceRepository(raw_db as any)
const workspaceRepo = new WorkspaceRepository(raw_db as any)
const summaryRepo = new SummaryRepository(raw_db as any)
this.event_store.setRepositories({
sessionRepo, messageRepo, messageDraftRepo, taskRepo, taskAttemptRepo,
taskDepRepo, agentRepo, toolRunRepo, commandRunRepo, artifactRepo,
diagnosticRepo, evidenceRepo, workspaceRepo, summaryRepo,
})
this.projection_store.set_repos({
session: sessionRepo,
task: taskRepo,
agent: agentRepo,
})
await this.ensure_session_created(sessionRepo)
await this.projection_store.rebuild(this.config.session_id)
// Reuse repos for context_assembler and scheduler (replace Step 6 duplicate new)
this.context_assembler.set_data_sources({ message_repo: messageRepo, evidence_store: evidenceRepo })
this.scheduler.set_task_repo(taskRepo)
const rehydrated = await this.scheduler.rebuild_from_db() const rehydrated = await this.scheduler.rebuild_from_db()
this.logger.info('Scheduler recovery complete', { rehydrated }) this.logger.info('Scheduler recovery complete', { rehydrated })
} }
@@ -165,12 +215,108 @@ export class RuntimeApp {
this.logger.info('RuntimeApp started') this.logger.info('RuntimeApp started')
} }
private async discover_project_skills(): Promise<void> {
const roots = [
join(this.config.project_root, '.air', 'shared', 'skills'),
join(this.config.project_root, '.air', 'shared', 'skill'),
].filter((root) => existsSync(root))
if (roots.length === 0) return
const discovered = this.capability_registry.discover_skill_roots(roots)
let registered_count = 0
for (const result of discovered) {
if (!result.ok || !result.capability_id) {
this.logger.warn('Skill discovery failed', { error: result.error })
continue
}
const validation = this.capability_registry.validate(result.capability_id)
if (!validation.valid) {
this.logger.warn('Skill validation failed', { capability_id: result.capability_id, errors: validation.errors })
continue
}
const doctor = await this.capability_registry.doctor_check(result.capability_id)
if (!doctor.ok) {
this.logger.warn('Skill doctor check failed', { capability_id: result.capability_id, error: doctor.error })
continue
}
const enabled = this.capability_registry.enable(result.capability_id)
if (!enabled.ok) {
this.logger.warn('Skill enable failed', { capability_id: result.capability_id, error: enabled.error })
continue
}
const registered = this.capability_registry.register_tools(result.capability_id)
if (!registered.ok) {
this.logger.warn('Skill tool registration failed', { capability_id: result.capability_id, error: registered.error })
continue
}
registered_count += registered.registered_count
}
if (registered_count > 0) this.logger.info('Project skills registered', { registered_count })
}
/**
* Register cpp toolchain via CapabilityRegistry (INV-4)
* Uses CppToolRegistrar from toolchain-cpp package.
*/
private async register_cpp_toolchain(): Promise<void> {
try {
// Dynamic import to avoid static dependency (INV-4: single direction)
const cppPkg = await import('@aircoding/toolchain-cpp')
const registrar = new cppPkg.CppToolRegistrar()
// Register tools through the CppToolRegistrar
// This follows INV-4: registered via capability boundary
registrar.register(this.tool_registry, this.config.project_root)
this.logger.info('cpp toolchain registered', { capability_id: 'aircoding-cpp-toolchain' })
} catch (e: any) {
this.logger.warn('cpp toolchain registration failed', { error: e.message })
}
}
private async ensure_session_created(sessionRepo: SessionRepository): Promise<void> {
const existing = await sessionRepo.get(this.config.session_id)
if (existing) return
const now = new Date().toISOString()
await this.event_ingestor.ingest({
id: `evt_${this.config.session_id}_created`,
type: 'session.created',
version: 1,
timestamp: now,
session_id: this.config.session_id,
project_id: this.config.project_id,
source: { kind: 'system' },
route: ['runtime', 'start'],
payload: {
session_id: this.config.session_id,
project_id: this.config.project_id,
project_root: this.config.project_root,
title: this.config.project_root.split('/').pop() || 'AirCoding',
metadata: {},
},
})
}
/** /**
* Shutdown the runtime: flush logs, close DB, cancel workers. * Shutdown the runtime: flush logs, close DB, cancel workers.
*/ */
async shutdown(): Promise<void> { async shutdown(): Promise<void> {
this.logger.info('RuntimeApp shutting down') this.logger.info('RuntimeApp shutting down')
if (this.projection_subscription) {
this.event_bus.unsubscribe(this.projection_subscription)
this.projection_subscription = null
}
this.projection_client_unsubscribe?.()
this.projection_client_unsubscribe = null
// Cancel all running workers // Cancel all running workers
try { try {
for (const handle of this.worker_manager.list()) { for (const handle of this.worker_manager.list()) {

View File

@@ -11,6 +11,7 @@
import type { ToolDefinition } from '@aircoding/contracts' import type { ToolDefinition } from '@aircoding/contracts'
import { CapabilityManifestValidator, createCapabilityManifestValidator, type CapabilityManifest, type ValidationResult } from './CapabilityManifestValidator.js' import { CapabilityManifestValidator, createCapabilityManifestValidator, type CapabilityManifest, type ValidationResult } from './CapabilityManifestValidator.js'
import { loadSkillDirectory, loadSkillsFromRoots, type SkillDefinition } from './SkillLoader.js'
export type CapabilityState = 'discovered' | 'validated' | 'doctor_checked' | 'enabled' | 'registered' | 'active' | 'disabled' | 'failed' export type CapabilityState = 'discovered' | 'validated' | 'doctor_checked' | 'enabled' | 'registered' | 'active' | 'disabled' | 'failed'
@@ -62,6 +63,30 @@ export class CapabilityRegistry {
return { ok: true, capability_id } return { ok: true, capability_id }
} }
/**
* Discover one SKILL.md directory as a capability manifest.
*/
discover_skill_directory(skill_dir: string, trusted_roots: string[]): { ok: boolean; capability_id?: string; skill?: SkillDefinition; error?: string } {
try {
const skill = loadSkillDirectory(skill_dir, trusted_roots)
const discovered = this.discover(skill.manifest)
return { ...discovered, skill }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
}
/**
* Discover all SKILL.md entries under trusted roots.
*/
discover_skill_roots(roots: string[]): Array<{ ok: boolean; capability_id?: string; skill?: SkillDefinition; error?: string }> {
try {
return loadSkillsFromRoots(roots).map((skill) => ({ ...this.discover(skill.manifest), skill }))
} catch (error) {
return [{ ok: false, error: error instanceof Error ? error.message : String(error) }]
}
}
/** /**
* Validate a discovered capability. * Validate a discovered capability.
*/ */

View File

@@ -0,0 +1,117 @@
/**
* SkillLoader - SKILL.md capability bridge.
* Loads skill directories into Capability manifests without executing skill content.
*/
import { existsSync, readFileSync, statSync, readdirSync } from 'fs'
import { resolve, relative, basename } from 'path'
import type { CapabilityManifest } from './CapabilityManifestValidator.js'
export interface SkillDefinition {
id: string
name: string
description: string
directory: string
content: string
frontmatter: Record<string, unknown>
manifest: CapabilityManifest
}
export function loadSkillDirectory(skill_dir: string, trusted_roots: string[]): SkillDefinition {
const directory = resolve(skill_dir)
ensureTrusted(directory, trusted_roots)
const skill_path = resolve(directory, 'SKILL.md')
if (!existsSync(skill_path) || !statSync(skill_path).isFile()) {
throw new Error(`SKILL.md not found in ${directory}`)
}
const raw = readFileSync(skill_path, 'utf-8')
const parsed = parseSkillMarkdown(raw)
const name = slug(String(parsed.frontmatter.name || basename(directory)))
const description = String(parsed.frontmatter.description || firstParagraph(parsed.body) || `Skill ${name}`)
const toolName = `skill.${name}`
const manifest: CapabilityManifest = {
schema_version: 1,
name,
version: String(parsed.frontmatter.version || '1.0.0'),
description,
trust_level: 'project_local',
tools: [{
name: toolName,
category: 'internal',
permissions: { read: true, write: false, network: false },
input_schema: {
type: 'object',
properties: {
task: { type: 'string' },
skill_directory: { type: 'string' },
},
required: ['task'],
},
}],
}
return { id: name, name, description, directory, content: parsed.body, frontmatter: parsed.frontmatter, manifest }
}
export function loadSkillsFromRoots(roots: string[]): SkillDefinition[] {
const skills: SkillDefinition[] = []
for (const root of roots.map((r) => resolve(r))) {
if (!existsSync(root) || !statSync(root).isDirectory()) continue
const direct = resolve(root, 'SKILL.md')
if (existsSync(direct)) {
skills.push(loadSkillDirectory(root, roots))
continue
}
const entries = Array.from(new Set(readDirectoryNames(root)))
for (const entry of entries) {
const dir = resolve(root, entry)
if (existsSync(resolve(dir, 'SKILL.md'))) skills.push(loadSkillDirectory(dir, roots))
}
}
return skills
}
function ensureTrusted(path: string, roots: string[]): void {
const trusted = roots.map((root) => resolve(root)).some((root) => {
const rel = relative(root, path)
return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/'))
})
if (!trusted) throw new Error(`Skill path is outside trusted roots: ${path}`)
}
function parseSkillMarkdown(raw: string): { frontmatter: Record<string, unknown>; body: string } {
if (!raw.startsWith('---\n')) return { frontmatter: {}, body: raw.trim() }
const end = raw.indexOf('\n---\n', 4)
if (end === -1) return { frontmatter: {}, body: raw.trim() }
const frontmatter = parseFrontmatter(raw.slice(4, end))
return { frontmatter, body: raw.slice(end + 5).trim() }
}
function parseFrontmatter(text: string): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const line of text.split(/\r?\n/)) {
const idx = line.indexOf(':')
if (idx <= 0) continue
const key = line.slice(0, idx).trim()
const value = line.slice(idx + 1).trim().replace(/^['"]|['"]$/g, '')
out[key] = value
}
return out
}
function firstParagraph(text: string): string {
return text.split(/\n\s*\n/).map((p) => p.trim()).find(Boolean) || ''
}
function slug(value: string): string {
const next = value.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
return next || 'skill'
}
function readDirectoryNames(root: string): string[] {
return readdirSync(root).filter((name) => {
const path = resolve(root, name)
return statSync(path).isDirectory()
})
}

View File

@@ -12,7 +12,7 @@ import { execFileSync } from 'child_process'
export interface DoctorCheck { export interface DoctorCheck {
name: string name: string
category: 'self_bootstrap' | 'capability' | 'project' | 'runtime' category: 'self_bootstrap' | 'capability' | 'project' | 'runtime' | 'toolchain' | 'display' | 'network' | 'provider'
passed: boolean passed: boolean
message: string message: string
fixable: boolean fixable: boolean
@@ -66,6 +66,14 @@ export class DoctorService {
checks.push(this.check_capability_deps()) checks.push(this.check_capability_deps())
} }
// FR-018/§6.12: toolchain / display / network / provider checks
if (scope === 'all') {
checks.push(...this.check_cpp_toolchain())
checks.push(this.check_display())
checks.push(await this.check_network())
checks.push(...await this.check_provider())
}
const all_passed = checks.every(c => c.passed) const all_passed = checks.every(c => c.passed)
return { checks, all_passed, bootstrap_passed: true, fixable_count: checks.filter(c => c.fixable).length } return { checks, all_passed, bootstrap_passed: true, fixable_count: checks.filter(c => c.fixable).length }
} }
@@ -219,4 +227,98 @@ export class DoctorService {
return { name: 'capability_deps', category: 'capability', passed: false, message: `Capability check failed: ${e.message}`, fixable: true } return { name: 'capability_deps', category: 'capability', passed: false, message: `Capability check failed: ${e.message}`, fixable: true }
} }
} }
// ===== FR-018/§6.12: 5 new categories =====
private check_cpp_toolchain(): DoctorCheck[] {
const tools = ['cmake', 'ninja', 'cppcheck', 'clangd', 'g++']
const reports: DoctorCheck[] = []
for (const t of tools) {
try {
const v = execFileSync('which', [t], { stdio: 'pipe', timeout: 3000 }).toString().trim()
reports.push({ name: `toolchain.${t}`, category: 'toolchain', passed: true, message: `${t} found at ${v}`, fixable: false })
} catch {
reports.push({ name: `toolchain.${t}`, category: 'toolchain', passed: false, message: `${t} not found`, fixable: true, fix: `apt install ${t === 'cmake' ? 'cmake' : t === 'ninja' ? 'ninja-build' : t}` })
}
}
return reports
}
private check_display(): DoctorCheck {
const display = process.env.DISPLAY
const wayland = process.env.WAYLAND_DISPLAY
if (!display && !wayland) {
return { name: 'display', category: 'display', passed: false, message: 'No DISPLAY/WAYLAND_DISPLAY (gui.screenshot will fail)', fixable: false }
}
try {
execFileSync('which', ['import'], { stdio: 'pipe' })
return { name: 'display', category: 'display', passed: true, message: `Display ${display || wayland} + ImageMagick available`, fixable: false }
} catch {
return { name: 'display', category: 'display', passed: false, message: 'ImageMagick not installed', fixable: true, fix: 'apt install imagemagick' }
}
}
private async check_network(): Promise<DoctorCheck> {
try {
const r = await fetch('https://1.1.1.1', { method: 'HEAD', signal: AbortSignal.timeout(3000) })
return { name: 'network.internet', category: 'network', passed: r.ok || r.status > 0, message: `HTTP ${r.status}`, fixable: false }
} catch (e: any) {
return { name: 'network.internet', category: 'network', passed: false, message: e.message, fixable: false }
}
}
private async check_provider(): Promise<DoctorCheck[]> {
const reports: DoctorCheck[] = []
const apiKey = process.env.AIRCODING_API_KEY || process.env.OPENAI_API_KEY
const baseUrl = process.env.OPENAI_BASE_URL || process.env.AIRCODING_API_URL
const model = process.env.AIRCODING_MODEL
reports.push({
name: 'provider.api_key',
category: 'provider',
passed: Boolean(apiKey),
message: apiKey ? `API key set (${apiKey.slice(0, 7)}...)` : 'No API key set',
fixable: false,
})
reports.push({
name: 'provider.base_url',
category: 'provider',
passed: Boolean(baseUrl),
message: baseUrl ? `Base URL: ${baseUrl}` : 'No base URL set',
fixable: false,
})
reports.push({
name: 'provider.model',
category: 'provider',
passed: Boolean(model),
message: model || 'No model set',
fixable: false,
})
if (apiKey && baseUrl) {
try {
const r = await fetch(`${baseUrl.replace(/\/$/, '')}/v1/models`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${apiKey}` },
signal: AbortSignal.timeout(5000),
})
reports.push({
name: 'provider.connectivity',
category: 'provider',
passed: r.ok || r.status > 0,
message: `HTTP ${r.status}`,
fixable: false,
})
} catch (e: any) {
reports.push({
name: 'provider.connectivity',
category: 'provider',
passed: false,
message: e.message,
fixable: false,
})
}
}
return reports
}
} }

View File

@@ -35,6 +35,8 @@ export { BuiltInToolRegistrar, register_builtin_tools } from './tools/BuiltInToo
// Capabilities // Capabilities
export { CapabilityManifestValidator, createCapabilityManifestValidator } from './capabilities/CapabilityManifestValidator.js' export { CapabilityManifestValidator, createCapabilityManifestValidator } from './capabilities/CapabilityManifestValidator.js'
export { CapabilityRegistry, createCapabilityRegistry } from './capabilities/CapabilityRegistry.js' export { CapabilityRegistry, createCapabilityRegistry } from './capabilities/CapabilityRegistry.js'
export { loadSkillDirectory, loadSkillsFromRoots } from './capabilities/SkillLoader.js'
export type { SkillDefinition } from './capabilities/SkillLoader.js'
// Context // Context
export { PromptLayerLoader, createPromptLayerLoader } from './context/PromptLayerLoader.js' export { PromptLayerLoader, createPromptLayerLoader } from './context/PromptLayerLoader.js'

View File

@@ -65,8 +65,12 @@ export interface ArtifactProjection {
export interface PermissionPromptProjection { export interface PermissionPromptProjection {
prompt_id: string prompt_id: string
tool_name: string subject: string
risk_level: string
reason: string reason: string
options: string[]
default_option?: string
tool_name?: string
} }
export interface BlockerProjection { export interface BlockerProjection {
@@ -291,7 +295,12 @@ export class ProjectionStore {
case 'permission.prompt.requested': { case 'permission.prompt.requested': {
proj.permission_prompts.push({ proj.permission_prompts.push({
prompt_id: p.prompt_id || `pp_${Date.now()}`, prompt_id: p.prompt_id || `pp_${Date.now()}`,
tool_name: p.tool_name, reason: p.reason || '' subject: p.subject || p.tool_name || 'permission request',
risk_level: p.risk_level || 'unknown',
reason: p.reason || '',
options: Array.isArray(p.options) ? p.options : [],
default_option: p.default_option,
tool_name: p.tool_name,
}) })
break break
} }
@@ -334,14 +343,17 @@ export class ProjectionStore {
* Returns the rebuilt projection. * Returns the rebuilt projection.
*/ */
async rebuild(session_id: string): Promise<SessionProjection | undefined> { async rebuild(session_id: string): Promise<SessionProjection | undefined> {
const session = this.repos.session ? await this.repos.session.get(session_id as SessionID) : undefined
const tasks = this.repos.task ? await this.repos.task.list_by_status(session_id, ['pending', 'running', 'interrupted', 'completed', 'failed', 'blocked', 'cancelled']) : [] const tasks = this.repos.task ? await this.repos.task.list_by_status(session_id, ['pending', 'running', 'interrupted', 'completed', 'failed', 'blocked', 'cancelled']) : []
const agents = this.repos.agent ? await this.repos.agent.list_active(session_id) : [] const agents = this.repos.agent ? await this.repos.agent.list_active(session_id) : []
// Initialize projection with what we have if (!session && tasks.length === 0 && agents.length === 0) return undefined
const proj: SessionProjection = { const proj: SessionProjection = {
session_id, session_id,
project_id: '', project_id: session?.project_id ?? '',
status: 'active', status: session?.status ?? 'active',
title: session?.title,
tasks: tasks.map((t: any) => ({ tasks: tasks.map((t: any) => ({
id: t.id, type: t.type, status: t.status, title: t.title || '', id: t.id, type: t.type, status: t.status, title: t.title || '',
retry_count: t.retry_count || 0, attempts: 0, created_at: t.created_at || '', retry_count: t.retry_count || 0, attempts: 0, created_at: t.created_at || '',
@@ -359,6 +371,7 @@ export class ProjectionStore {
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
} }
this.snapshot.set(session_id, proj) this.snapshot.set(session_id, proj)
this.notify(proj)
return proj return proj
} }

View File

@@ -14,7 +14,7 @@ import { WavePlanner } from './WavePlanner.js'
import { RetryPlanner } from './RetryPlanner.js' import { RetryPlanner } from './RetryPlanner.js'
import { WorkspaceManager } from './WorkspaceManager.js' import { WorkspaceManager } from './WorkspaceManager.js'
import { AgentMonitor } from './AgentMonitor.js' import { AgentMonitor } from './AgentMonitor.js'
import { eventIngestor } from '../events/EventIngestor.js' import { eventIngestor, type IEventIngestor } from '../events/EventIngestor.js'
import type { WorkerManager } from '../workers/WorkerManager.js' import type { WorkerManager } from '../workers/WorkerManager.js'
export type SchedulerState = export type SchedulerState =
@@ -48,9 +48,11 @@ export class Scheduler {
private context: SchedulerContext private context: SchedulerContext
private worker_manager?: WorkerManager private worker_manager?: WorkerManager
private task_repo?: any private task_repo?: any
private event_ingestor: IEventIngestor
constructor(context: SchedulerContext, worker_manager?: WorkerManager) { constructor(context: SchedulerContext, worker_manager?: WorkerManager, ingestor: IEventIngestor = eventIngestor) {
this.context = context this.context = context
this.event_ingestor = ingestor
this.graph = new TaskGraph() this.graph = new TaskGraph()
this.wave_planner = new WavePlanner() this.wave_planner = new WavePlanner()
this.retry_planner = new RetryPlanner() this.retry_planner = new RetryPlanner()
@@ -62,18 +64,43 @@ export class Scheduler {
/** /**
* Create tasks from specifications. * Create tasks from specifications.
*/ */
create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; description?: string; depends_on?: string[] }>): void { // Generate unique event ID with timestamp to avoid collisions on repeated asks
private generate_event_id(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
}
async create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; description?: string; depends_on?: string[]; task_spec?: Record<string, unknown> }>): Promise<void> {
for (const task of tasks) { for (const task of tasks) {
this.graph.add_task({ this.graph.add_task({
id: task.id, id: task.id,
status: 'pending', status: 'pending',
type: task.type, title: task.title, type: task.type, title: task.title,
description: task.description, description: task.description,
task_spec: task.task_spec,
dependencies: task.depends_on?.map(d => ({ task_id: d, type: 'hard' as const })) || [] dependencies: task.depends_on?.map(d => ({ task_id: d, type: 'hard' as const })) || []
}) })
// Emit task.created events (INV-1: via event store for projection)
await this.event_ingestor.ingest({
id: this.generate_event_id(`evt_${task.id}_created`),
type: 'task.created',
version: 1,
session_id: this.context.session_id,
project_id: this.context.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'scheduler' },
route: ['scheduler', 'create'],
payload: {
task_id: task.id,
type: task.type,
title: task.title,
task_spec_json: task.task_spec || { description: task.description || '' },
dependencies: (task.depends_on || []).map(d => ({ depends_on_task_id: d, dependency_type: 'hard', reason: '' })),
metadata: {},
}
})
} }
// Emit task.created events (INV-1: via projection, not direct status write)
this.state = 'PLANNING_WAVE' this.state = 'PLANNING_WAVE'
} }
@@ -113,25 +140,23 @@ export class Scheduler {
break break
case 'PLANNING_WAVE': { case 'PLANNING_WAVE': {
// Check if all tasks done
const counts = this.graph.count_by_status() const counts = this.graph.count_by_status()
const remaining = (counts.pending || 0) + (counts.running || 0) const pending = counts.pending || 0
const running = counts.running || 0
if (remaining === 0) { if (pending === 0 && running === 0) {
this.state = 'COMPLETED' this.state = this.terminal_state_from_counts(counts)
return
}
if (running > 0) {
this.state = 'MONITORING'
return return
} }
// Plan next wave
const plan = this.wave_planner.plan(this.graph) const plan = this.wave_planner.plan(this.graph)
if (plan.length === 0) { if (plan.length === 0) {
// Check for blocked tasks this.state = pending > 0 ? 'REPAIRING_OR_CONTINUING' : this.terminal_state_from_counts(counts)
const pending = this.graph.count_by_status().pending || 0
if (pending > 0) {
this.state = 'REPAIRING_OR_CONTINUING'
return
}
this.state = 'COMPLETED'
return return
} }
@@ -142,12 +167,13 @@ export class Scheduler {
case 'DISPATCHING': { case 'DISPATCHING': {
const runnable = this.graph.get_runnable_tasks() const runnable = this.graph.get_runnable_tasks()
for (const task of runnable) { for (const task of runnable) {
const agent_id = `agent_${task.id}` const agent_id = `agent_${task.id}_${Date.now()}`
// INV-1: Emit task.started event (durable) for projection // INV-1: Emit task.started event (durable) for projection
const now = new Date().toISOString() const now = new Date().toISOString()
await eventIngestor.ingest({ const timestamp = Date.now()
id: `evt_${task.id}_started`, await this.event_ingestor.ingest({
id: this.generate_event_id(`evt_${task.id}`),
type: 'task.started', type: 'task.started',
version: 1, version: 1,
session_id: this.context.session_id, session_id: this.context.session_id,
@@ -155,7 +181,7 @@ export class Scheduler {
timestamp: now, timestamp: now,
source: { kind: 'scheduler' }, source: { kind: 'scheduler' },
route: ['scheduler', 'dispatch'], route: ['scheduler', 'dispatch'],
payload: { task_id: task.id, agent_id, attempt_id: `${task.id}_1`, attempt_index: 0, workspace_id: `ws_${task.id}` } payload: { task_id: task.id, agent_id, attempt_id: `${task.id}_${timestamp}`, attempt_index: 0, workspace_id: `ws_${task.id}_${timestamp}` }
}) })
if (this.worker_manager) { if (this.worker_manager) {
@@ -166,7 +192,7 @@ export class Scheduler {
session_id: this.context.session_id, session_id: this.context.session_id,
project_root: this.context.project_root, project_root: this.context.project_root,
task_type: task.type || 'execute', task_type: task.type || 'execute',
task_spec: { task_spec: task.task_spec || {
id: task.id, id: task.id,
title: task.title || task.id, title: task.title || task.id,
description: task.description || '', description: task.description || '',
@@ -175,10 +201,32 @@ export class Scheduler {
}) })
this.graph.update_status(task.id, 'running') this.graph.update_status(task.id, 'running')
this.agent_monitor.record_heartbeat(agent_id, task.id) this.agent_monitor.record_heartbeat(agent_id, task.id)
// INV-1: Emit agent.started event (durable) for projection
await this.event_ingestor.ingest({
id: this.generate_event_id(`evt_${agent_id}`),
type: 'agent.started',
version: 1,
session_id: this.context.session_id,
project_id: this.context.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'scheduler' },
route: ['scheduler', 'dispatch'],
payload: {
agent_id,
agent_type: task.type || 'executor',
task_id: task.id,
pid: 0,
model_provider_id: '',
model_id: '',
workspace_id: `ws_${task.id}`,
metadata: {},
}
})
} catch { } catch {
// INV-1: emit task.failed event for projection // INV-1: emit task.failed event for projection
await eventIngestor.ingest({ await this.event_ingestor.ingest({
id: `evt_${task.id}_failed`, id: this.generate_event_id(`evt_${task.id}`),
type: 'task.failed', type: 'task.failed',
version: 1, version: 1,
session_id: this.context.session_id, session_id: this.context.session_id,
@@ -186,7 +234,7 @@ export class Scheduler {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
source: { kind: 'scheduler' }, source: { kind: 'scheduler' },
route: ['scheduler', 'dispatch'], route: ['scheduler', 'dispatch'],
payload: { task_id: task.id, agent_id, attempt_id: `${task.id}_1`, error: { message: 'Worker spawn failed' }, evidence_refs: [], metadata: {} } payload: { task_id: task.id, agent_id, attempt_id: `${task.id}_${Date.now()}`, error: { message: 'Worker spawn failed' }, evidence_refs: [], metadata: {} }
}) })
} }
} else { } else {
@@ -204,8 +252,8 @@ export class Scheduler {
const hb = this.agent_monitor.get(l.agent_id) const hb = this.agent_monitor.get(l.agent_id)
if (hb) { if (hb) {
const now = new Date().toISOString() const now = new Date().toISOString()
await eventIngestor.ingest({ await this.event_ingestor.ingest({
id: `evt_${hb.task_id}_lost`, id: this.generate_event_id(`evt_${hb.task_id}`),
type: 'agent.lost', type: 'agent.lost',
version: 1, version: 1,
session_id: this.context.session_id, session_id: this.context.session_id,
@@ -228,8 +276,8 @@ export class Scheduler {
case 'hard_cancel': case 'hard_cancel':
case 'soft_cancel': case 'soft_cancel':
if (task_id) { if (task_id) {
await eventIngestor.ingest({ await this.event_ingestor.ingest({
id: `evt_${task_id}_cancelled`, id: this.generate_event_id(`evt_${task_id}`),
type: 'agent.cancelled', type: 'agent.cancelled',
version: 1, version: 1,
session_id: this.context.session_id, session_id: this.context.session_id,
@@ -256,10 +304,10 @@ export class Scheduler {
const result = this.worker_manager.get_result_for_task(task.id) const result = this.worker_manager.get_result_for_task(task.id)
if (!handle || !result) continue if (!handle || !result) continue
const attempt_id = `${task.id}_1` const attempt_id = `${task.id}_${Date.now()}`
if (result.status === 'completed') { if (result.status === 'completed') {
await eventIngestor.ingest({ await this.event_ingestor.ingest({
id: `evt_${task.id}_completed`, id: this.generate_event_id(`evt_${task.id}`),
type: 'task.completed', type: 'task.completed',
version: 1, version: 1,
session_id: this.context.session_id, session_id: this.context.session_id,
@@ -278,10 +326,22 @@ export class Scheduler {
} }
}) })
this.graph.update_status(task.id, 'completed') this.graph.update_status(task.id, 'completed')
// INV-1: Emit agent.completed event (durable) for projection
await this.event_ingestor.ingest({
id: this.generate_event_id(`evt_${handle.worker_id}`),
type: 'agent.completed',
version: 1,
session_id: this.context.session_id,
project_id: this.context.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'scheduler' },
route: ['scheduler', 'monitoring'],
payload: { agent_id: handle.worker_id, task_id: task.id, summary: result.summary, worker_result_ref: attempt_id, metadata: {} }
})
this.agent_monitor.remove(handle.worker_id) this.agent_monitor.remove(handle.worker_id)
} else if (result.status === 'blocked') { } else if (result.status === 'blocked') {
await eventIngestor.ingest({ await this.event_ingestor.ingest({
id: `evt_${task.id}_blocked`, id: this.generate_event_id(`evt_${task.id}`),
type: 'task.blocked', type: 'task.blocked',
version: 1, version: 1,
session_id: this.context.session_id, session_id: this.context.session_id,
@@ -294,8 +354,8 @@ export class Scheduler {
this.graph.update_status(task.id, 'blocked') this.graph.update_status(task.id, 'blocked')
this.agent_monitor.remove(handle.worker_id) this.agent_monitor.remove(handle.worker_id)
} else if (result.status === 'cancelled') { } else if (result.status === 'cancelled') {
await eventIngestor.ingest({ await this.event_ingestor.ingest({
id: `evt_${task.id}_cancelled_result`, id: this.generate_event_id(`evt_${task.id}`),
type: 'task.cancelled', type: 'task.cancelled',
version: 1, version: 1,
session_id: this.context.session_id, session_id: this.context.session_id,
@@ -308,8 +368,8 @@ export class Scheduler {
this.graph.update_status(task.id, 'cancelled') this.graph.update_status(task.id, 'cancelled')
this.agent_monitor.remove(handle.worker_id) this.agent_monitor.remove(handle.worker_id)
} else { } else {
await eventIngestor.ingest({ await this.event_ingestor.ingest({
id: `evt_${task.id}_failed_result`, id: this.generate_event_id(`evt_${task.id}`),
type: 'task.failed', type: 'task.failed',
version: 1, version: 1,
session_id: this.context.session_id, session_id: this.context.session_id,
@@ -320,6 +380,18 @@ export class Scheduler {
payload: { task_id: task.id, agent_id: handle.worker_id, attempt_id, error: { message: result.summary }, evidence_refs: result.evidence_refs, metadata: { worker_status: result.status } } payload: { task_id: task.id, agent_id: handle.worker_id, attempt_id, error: { message: result.summary }, evidence_refs: result.evidence_refs, metadata: { worker_status: result.status } }
}) })
this.graph.update_status(task.id, 'failed') this.graph.update_status(task.id, 'failed')
// INV-1: Emit agent.failed event (durable) for projection
await this.event_ingestor.ingest({
id: this.generate_event_id(`evt_${handle.worker_id}`),
type: 'agent.failed',
version: 1,
session_id: this.context.session_id,
project_id: this.context.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'scheduler' },
route: ['scheduler', 'monitoring'],
payload: { agent_id: handle.worker_id, task_id: task.id, error: { message: result.summary }, evidence_refs: result.evidence_refs || [], metadata: {} }
})
this.agent_monitor.remove(handle.worker_id) this.agent_monitor.remove(handle.worker_id)
} }
} }
@@ -375,6 +447,13 @@ export class Scheduler {
} }
} }
private terminal_state_from_counts(counts: Record<string, number>): SchedulerState {
if ((counts.failed || 0) > 0) return 'TERMINATED'
if ((counts.blocked || 0) > 0) return 'BLOCKED'
if ((counts.cancelled || 0) > 0) return 'CANCELLED'
return 'COMPLETED'
}
/** /**
* Rebuild scheduler state from SQLite (INV-5: from EventStore, not EventBus). * Rebuild scheduler state from SQLite (INV-5: from EventStore, not EventBus).
* Loads pending/running tasks from the tasks table and reconstructs the in-memory graph. * Loads pending/running tasks from the tasks table and reconstructs the in-memory graph.

View File

@@ -19,6 +19,7 @@ export interface TaskNode {
title?: string title?: string
description?: string description?: string
acceptance_criteria?: string[] acceptance_criteria?: string[]
task_spec?: Record<string, unknown>
} }
export interface GraphValidation { export interface GraphValidation {

View File

@@ -43,7 +43,7 @@ export type AgentInsert = Omit<AgentRecord, 'id' | 'status'> & {
id?: AgentID id?: AgentID
} }
export type AgentUpdate = Partial<Omit<AgentRecord, 'id' | 'session_id' | 'started_at' | 'status'>> export type AgentUpdate = Partial<Omit<AgentRecord, 'id' | 'session_id' | 'started_at'>>
// ============================================================================= // =============================================================================
// AgentRepository // AgentRepository
@@ -105,7 +105,11 @@ export class AgentRepository implements Repository<AgentRecord, AgentInsert, Age
const fields: string[] = [] const fields: string[] = []
const values: unknown[] = [] const values: unknown[] = []
// NOTE: status is NOT updatable here - only EventStore.project() writes status columns // status reaches here only via EventStore.project() (INV-1's authorized writer).
if (patch.status !== undefined) {
fields.push('status = ?')
values.push(patch.status)
}
if (patch.pid !== undefined) { if (patch.pid !== undefined) {
fields.push('pid = ?') fields.push('pid = ?')
values.push(patch.pid) values.push(patch.pid)

View File

@@ -44,7 +44,7 @@ export type TaskAttemptInsert = Omit<TaskAttemptRecord, 'id' | 'status'> & {
id?: UUID id?: UUID
} }
export type TaskAttemptUpdate = Partial<Omit<TaskAttemptRecord, 'id' | 'session_id' | 'task_id' | 'attempt_index' | 'started_at' | 'status'>> export type TaskAttemptUpdate = Partial<Omit<TaskAttemptRecord, 'id' | 'session_id' | 'task_id' | 'attempt_index' | 'started_at'>>
// ============================================================================= // =============================================================================
// TaskAttemptRepository // TaskAttemptRepository
@@ -106,7 +106,11 @@ export class TaskAttemptRepository implements Repository<TaskAttemptRecord, Task
const fields: string[] = [] const fields: string[] = []
const values: unknown[] = [] const values: unknown[] = []
// NOTE: status is NOT updatable here - only EventStore.project() writes status columns // status reaches here only via EventStore.project() (INV-1's authorized writer).
if (patch.status !== undefined) {
fields.push('status = ?')
values.push(patch.status)
}
if (patch.agent_id !== undefined) { if (patch.agent_id !== undefined) {
fields.push('agent_id = ?') fields.push('agent_id = ?')
values.push(patch.agent_id) values.push(patch.agent_id)

View File

@@ -53,7 +53,10 @@ export type TaskInsert = Omit<TaskRecord, 'id' | 'status'> & {
heartbeat_at?: ISOTimeString heartbeat_at?: ISOTimeString
} }
export type TaskUpdate = Partial<Omit<TaskRecord, 'id' | 'session_id' | 'created_at' | 'status'>> // status IS updatable — but only reachable via EventStore.project() (INV-1).
// project() is the sole caller of update(); guarding status here would block
// the one authorized writer and freeze every row at its insert-time status.
export type TaskUpdate = Partial<Omit<TaskRecord, 'id' | 'session_id' | 'created_at'>>
// ============================================================================= // =============================================================================
// TaskRepository // TaskRepository
@@ -118,7 +121,11 @@ export class TaskRepository implements Repository<TaskRecord, TaskInsert, TaskUp
const fields: string[] = [] const fields: string[] = []
const values: unknown[] = [] const values: unknown[] = []
// NOTE: status is NOT updatable here - only EventStore.project() writes status columns // status reaches here only via EventStore.project() (INV-1's authorized writer).
if (patch.status !== undefined) {
fields.push('status = ?')
values.push(patch.status)
}
if (patch.title !== undefined) { if (patch.title !== undefined) {
fields.push('title = ?') fields.push('title = ?')
values.push(patch.title) values.push(patch.title)

View File

@@ -48,7 +48,7 @@ export type ToolRunInsert = Omit<ToolRunRecord, 'id' | 'status'> & {
id?: ToolRunID id?: ToolRunID
} }
export type ToolRunUpdate = Partial<Omit<ToolRunRecord, 'id' | 'session_id' | 'tool_name' | 'started_at' | 'status'>> export type ToolRunUpdate = Partial<Omit<ToolRunRecord, 'id' | 'session_id' | 'tool_name' | 'started_at'>>
// ============================================================================= // =============================================================================
// ToolRunRepository // ToolRunRepository
@@ -114,7 +114,11 @@ export class ToolRunRepository implements Repository<ToolRunRecord, ToolRunInser
const fields: string[] = [] const fields: string[] = []
const values: unknown[] = [] const values: unknown[] = []
// NOTE: status is NOT updatable here - only EventStore.project() writes status columns // status reaches here only via EventStore.project() (INV-1's authorized writer).
if (patch.status !== undefined) {
fields.push('status = ?')
values.push(patch.status)
}
if (patch.output_json !== undefined) { if (patch.output_json !== undefined) {
fields.push('output_json = ?') fields.push('output_json = ?')
values.push(patch.output_json) values.push(patch.output_json)

View File

@@ -132,20 +132,6 @@ export class BuiltInToolRegistrar {
'project.profile.write': def('project.profile.write', 'project', 'Write language profile/toolchain configuration', 'project.profile.write': def('project.profile.write', 'project', 'Write language profile/toolchain configuration',
{ language: { type: 'string', description: 'Language (cpp/c/rust/python)' }, profile_json: { type: 'object', description: 'Profile configuration' } }, ['language', 'profile_json'], { language: { type: 'string', description: 'Language (cpp/c/rust/python)' }, profile_json: { type: 'object', description: 'Profile configuration' } }, ['language', 'profile_json'],
{ read: false, write: true, network: false }), { read: false, write: true, network: false }),
// cpp toolchain
'cpp.detect': def('cpp.detect', 'debug', 'Detect C++ project structure, toolchain, and source files',
{ project_root: { type: 'string', description: 'Project root path' } }, []),
'cpp.cmake.configure': def('cpp.cmake.configure', 'build', 'Configure C++ build with CMake (Ninja preferred, Make fallback)',
{ generator: { type: 'string', description: 'Generator (Ninja/Unix Makefiles)' }, build_type: { type: 'string', description: 'Debug/Release/RelWithDebInfo' } }, [],
{ read: true, write: true, network: false }),
'cpp.build': def('cpp.build', 'build', 'Build C++ project via CMake',
{ target: { type: 'string', description: 'Build target' }, config: { type: 'string', description: 'Debug/Release' } }, []),
'cpp.test': def('cpp.test', 'test', 'Run C++ tests via ctest',
{ filter: { type: 'string', description: 'Test filter pattern' } }, []),
'cpp.static.cppcheck': def('cpp.static.cppcheck', 'static_analysis', 'Run cppcheck static analysis on C++ code',
{ path: { type: 'string', description: 'Path to analyze' }, severity: { type: 'string', description: 'Minimum severity' } }, []),
'cpp.clangd.query': def('cpp.clangd.query', 'static_analysis', 'Query clangd LSP for symbol definition or diagnostics',
{ file: { type: 'string', description: 'Source file path' }, line: { type: 'number', description: 'Line number' }, column: { type: 'number', description: 'Column number' } }, ['file']),
// debug // debug
'debug.run': def('debug.run', 'debug', 'Run debugger on a target process or binary', 'debug.run': def('debug.run', 'debug', 'Run debugger on a target process or binary',
{ target: { type: 'string', description: 'Binary or process to debug' }, breakpoints: { type: 'array', items: { type: 'string' } } }, ['target']), { target: { type: 'string', description: 'Binary or process to debug' }, breakpoints: { type: 'array', items: { type: 'string' } } }, ['target']),
@@ -257,88 +243,6 @@ export class BuiltInToolRegistrar {
} }
}, },
'cpp.detect': async (call: any) => {
try {
const root = (call.arguments as any)?.project_root || project_root
const cmake = existsSync(join(root, 'CMakeLists.txt'))
const makefile = existsSync(join(root, 'Makefile'))
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { has_cmake: cmake, has_makefile: makefile, build_system: cmake ? 'cmake' : makefile ? 'make' : 'none' },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'cpp.cmake.configure': async (call: any) => {
try {
const { generator = 'Ninja', build_type = 'Debug' } = (call.arguments || {}) as any
const buildDir = join(project_root, 'build')
if (!existsSync(buildDir)) mkdirSync(buildDir, { recursive: true })
execFileSync('cmake', ['-G', generator, '-DCMAKE_BUILD_TYPE=' + build_type, '..'], { cwd: buildDir, stdio: 'pipe' })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', output: { generator, build_type, configured: true },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'cpp.build': async (call: any) => {
try {
const { target, config = 'Debug' } = (call.arguments || {}) as any
const args = target ? ['--build', '.', '--config', config, '--target', target] : ['--build', '.', '--config', config]
const out = execFileSync('cmake', args, { cwd: join(project_root, 'build'), stdio: 'pipe', timeout: 300000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { built: true, output: out.toString().slice(-500) },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'cpp.test': async (call: any) => {
try {
const { filter } = (call.arguments || {}) as any
const args = filter ? ['--output-on-failure', '-R', filter] : ['--output-on-failure']
const out = execFileSync('ctest', args, { cwd: join(project_root, 'build'), stdio: 'pipe', timeout: 300000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { passed: true, output: out.toString().slice(-1000) },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'cpp.static.cppcheck': async (call: any) => {
try {
const { path = 'src' } = (call.arguments || {}) as any
const out = execFileSync('cppcheck', ['--enable=all', '--quiet', path], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8', timeout: 120000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { output: out.toString().slice(-500), issues_found: 0 },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'cpp.clangd.query': async (call: any) => {
try {
const { file, line = 0, column = 0 } = (call.arguments || {}) as any
const out = execFileSync('clangd', ['--check=' + file], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8', timeout: 30000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { file, line, column, diagnostics: out.toString().slice(-1000) },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'debug.run': async (call: any) => { 'debug.run': async (call: any) => {
try { try {
const { target } = (call.arguments || {}) as any const { target } = (call.arguments || {}) as any

View File

@@ -11,6 +11,8 @@ import type { ToolDefinition, ToolCall, ToolResultEnvelope, ISOTimeString } from
import { PermissionEngine, createPermissionEngine, type PermissionContext, type PermissionDecision, type PermissionAction } from '../security/PermissionEngine.js' import { PermissionEngine, createPermissionEngine, type PermissionContext, type PermissionDecision, type PermissionAction } from '../security/PermissionEngine.js'
import type { AgentType } from '@aircoding/contracts' import type { AgentType } from '@aircoding/contracts'
import { eventIngestor } from '../events/EventIngestor.js'
import { eventBus, type Subscription } from '../events/EventBus.js'
export type ToolExecutionReturn = export type ToolExecutionReturn =
| ToolResultEnvelope | ToolResultEnvelope
@@ -47,6 +49,7 @@ export class ToolRegistry {
private executors: Map<string, ToolExecutor> = new Map() private executors: Map<string, ToolExecutor> = new Map()
private permission_engine: PermissionEngine private permission_engine: PermissionEngine
private project_root: string private project_root: string
private readonly permission_timeout_ms = 5 * 60 * 1000
constructor(project_root: string) { constructor(project_root: string) {
this.project_root = project_root this.project_root = project_root
@@ -263,9 +266,39 @@ export class ToolRegistry {
} }
} }
case 'ask_user': case 'ask_user': {
// Suspend; emit permission.prompt.requested const prompt_id = `perm_${crypto.randomUUID()}`
return create_error_result(call.call_id, 'user_prompt_required', 'User confirmation required') await eventIngestor.ingest({
id: `evt_${prompt_id}`,
type: 'permission.prompt.requested',
version: 1,
session_id: ctx.session_id,
project_id: ctx.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'tool', id: call.name },
route: ['tool_registry', 'permission'],
payload: {
prompt_id,
subject: call.name,
risk_level: decision.risk_level,
reason: decision.reason,
options: ['allow_once', 'deny'],
default_option: 'deny',
request_ref: { call_id: call.call_id, tool_name: call.name, agent_id: ctx.agent_id },
},
})
const selected = await this.wait_for_permission(prompt_id, ctx)
if (selected !== 'allow_once' && selected !== 'allow') {
return create_error_result(call.call_id, 'permission_denied', `User selected ${selected}`)
}
const executor = this.executors.get(call.name)
if (!executor) {
return create_error_result(call.call_id, 'executor_not_found', 'Executor not registered')
}
return this.execute_executor_final(executor, call, ctx)
}
case 'deny': case 'deny':
return create_error_result(call.call_id, 'permission_denied', decision.reason) return create_error_result(call.call_id, 'permission_denied', decision.reason)
@@ -313,6 +346,45 @@ export class ToolRegistry {
return Boolean(value && typeof (value as any)[Symbol.asyncIterator] === 'function') return Boolean(value && typeof (value as any)[Symbol.asyncIterator] === 'function')
} }
private wait_for_permission(prompt_id: string, ctx: ToolExecutionContext): Promise<string> {
return new Promise((resolve) => {
let settled = false
let subscription: Subscription | undefined
const finish = (selected: string) => {
if (settled) return
settled = true
clearTimeout(timeout)
if (subscription) eventBus.unsubscribe(subscription)
resolve(selected)
}
const timeout = setTimeout(() => {
void eventIngestor.ingest({
id: `evt_${prompt_id}_timeout`,
type: 'permission.prompt.resolved',
version: 1,
session_id: ctx.session_id,
project_id: ctx.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'tool', id: 'permission_timeout' },
route: ['tool_registry', 'permission'],
payload: {
prompt_id,
selected_option: 'deny',
decision_id: `decision_${crypto.randomUUID()}`,
resolved_by: 'timeout',
},
}).catch(() => finish('deny'))
}, this.permission_timeout_ms)
subscription = eventBus.subscribe({ session_id: ctx.session_id, types: ['permission.prompt.resolved'] }, (event) => {
const payload = event.payload as Record<string, unknown>
if (payload.prompt_id !== prompt_id) return
finish(String(payload.selected_option || 'deny'))
})
})
}
/** /**
* Execute streaming tool. * Execute streaming tool.
*/ */

View File

@@ -8,9 +8,64 @@
*/ */
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from 'fs' import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from 'fs'
import { createHash } from 'crypto'
import { join, dirname, basename, extname } from 'path' import { join, dirname, basename, extname } from 'path'
import type { ToolDefinition, ToolCall, ToolResultEnvelope, ISOTimeString } from '@aircoding/contracts' import type { ToolDefinition, ToolCall, ToolResultEnvelope, ISOTimeString } from '@aircoding/contracts'
// =============================================================================
// Read File State (for read-before-edit enforcement)
// =============================================================================
interface ReadFileState {
timestamp: number
sha256: string
size: number
}
// Session-scoped read file state - keyed by absolute path
const read_file_state = new Map<string, ReadFileState>()
function compute_sha256(content: string): string {
return createHash('sha256').update(content).digest('hex')
}
function record_file_read(abs_path: string, content: string): void {
read_file_state.set(abs_path, {
timestamp: Date.now(),
sha256: compute_sha256(content),
size: content.length
})
}
function check_file_read_state(abs_path: string, current_content: string): { allowed: boolean; error?: string } {
const state = read_file_state.get(abs_path)
if (!state) {
return {
allowed: false,
error: 'File has not been read yet. Read it first before editing.'
}
}
const current_sha = compute_sha256(current_content)
if (current_sha !== state.sha256) {
return {
allowed: false,
error: 'File has been unexpectedly modified. Read it again before editing.'
}
}
return { allowed: true }
}
function update_file_state(abs_path: string, new_content: string): void {
read_file_state.set(abs_path, {
timestamp: Date.now(),
sha256: compute_sha256(new_content),
size: new_content.length
})
}
// ============================================================================= // =============================================================================
// Tool Definitions // Tool Definitions
// ============================================================================= // =============================================================================
@@ -65,11 +120,13 @@ export const fs_edit: ToolDefinition = {
type: 'object', type: 'object',
properties: { properties: {
path: { type: 'string', description: 'File path to edit' }, path: { type: 'string', description: 'File path to edit' },
find: { type: 'string', description: 'Exact text to find' }, find: { type: 'string', description: 'Exact text to find (alias: old_str)' },
replace: { type: 'string', description: 'Text to replace with' }, replace: { type: 'string', description: 'Text to replace with (alias: new_str)' },
global: { type: 'boolean', default: false, description: 'Replace all occurrences' } old_str: { type: 'string', description: 'Alias for find' },
new_str: { type: 'string', description: 'Alias for replace' },
global: { type: 'boolean', default: false, description: 'Replace all occurrences (alias: replace_all)' }
}, },
required: ['path', 'find', 'replace'] required: ['path']
}, },
permissions: { read_paths: { allow: ["*"] }, write_paths: { allow: ["*"] } }, permissions: { read_paths: { allow: ["*"] }, write_paths: { allow: ["*"] } },
streaming: false streaming: false
@@ -153,6 +210,9 @@ export function createFsExecutors(project_root: string) {
? content.toString('base64') ? content.toString('base64')
: content.toString('utf-8') : content.toString('utf-8')
// Record file read for read-before-edit enforcement
record_file_read(full_path, content.toString('utf-8'))
return create_result(call.call_id, 'fs.read', 'text', { content: output, size: content.length }) return create_result(call.call_id, 'fs.read', 'text', { content: output, size: content.length })
} catch (error) { } catch (error) {
return create_result(call.call_id, 'fs.read', 'error', { message: error instanceof Error ? error.message : String(error) }) return create_result(call.call_id, 'fs.read', 'error', { message: error instanceof Error ? error.message : String(error) })
@@ -181,7 +241,16 @@ export function createFsExecutors(project_root: string) {
? Buffer.from(content, 'base64') ? Buffer.from(content, 'base64')
: Buffer.from(content, 'utf-8') : Buffer.from(content, 'utf-8')
if (existsSync(full_path)) {
const original = readFileSync(full_path, 'utf-8')
const read_check = check_file_read_state(full_path, original)
if (!read_check.allowed) {
return create_result(call.call_id, 'fs.write', 'error', { message: read_check.error })
}
}
writeFileSync(full_path, data) writeFileSync(full_path, data)
update_file_state(full_path, data.toString('utf-8'))
return create_result(call.call_id, 'fs.write', 'text', { message: `Written to ${path}`, size: data.length }) return create_result(call.call_id, 'fs.write', 'text', { message: `Written to ${path}`, size: data.length })
} catch (error) { } catch (error) {
return create_result(call.call_id, 'fs.write', 'error', { message: error instanceof Error ? error.message : String(error) }) return create_result(call.call_id, 'fs.write', 'error', { message: error instanceof Error ? error.message : String(error) })
@@ -189,11 +258,15 @@ export function createFsExecutors(project_root: string) {
}, },
'fs.edit': async (call: ToolCall): Promise<ToolResultEnvelope> => { 'fs.edit': async (call: ToolCall): Promise<ToolResultEnvelope> => {
const { path, find, replace, global = false } = call.arguments as { // Support both old_str/new_str (ExecutorRole) and find/replace (UI) parameter names
path: string const args = call.arguments as Record<string, unknown>
find: string const path = args.path as string
replace: string const find = (args.find ?? args.old_str ?? '') as string
global?: boolean const replace = (args.replace ?? args.new_str ?? '') as string
const global = (args.global ?? args.replace_all ?? false) as boolean
if (!find) {
return create_result(call.call_id, 'fs.edit', 'error', { message: 'Missing find/old_str parameter' })
} }
const full_path = resolve_path(path) const full_path = resolve_path(path)
@@ -205,11 +278,25 @@ export function createFsExecutors(project_root: string) {
try { try {
const original = readFileSync(full_path, 'utf-8') const original = readFileSync(full_path, 'utf-8')
// Read-before-edit enforcement (DD §9.4) // Read-before-edit enforcement (DD §9.4) - code layer, not prompt
const read_check = check_file_read_state(full_path, original)
if (!read_check.allowed) {
return create_result(call.call_id, 'fs.edit', 'error', { message: read_check.error })
}
// Exact edit: old_str must exist uniquely
if (!original.includes(find)) { if (!original.includes(find)) {
return create_result(call.call_id, 'fs.edit', 'error', { message: 'Exact text not found in file' }) return create_result(call.call_id, 'fs.edit', 'error', { message: 'Exact text not found in file' })
} }
// Check for uniqueness when not global
if (!global) {
const matches = original.split(find)
if (matches.length > 2) {
return create_result(call.call_id, 'fs.edit', 'error', { message: 'Text appears multiple times. Use global=true or provide more context to make it unique.' })
}
}
let edited: string let edited: string
if (global) { if (global) {
edited = original.split(find).join(replace) edited = original.split(find).join(replace)
@@ -219,6 +306,9 @@ export function createFsExecutors(project_root: string) {
writeFileSync(full_path, edited, 'utf-8') writeFileSync(full_path, edited, 'utf-8')
// Update read state after successful edit
update_file_state(full_path, edited)
// Emit diff artifact (DD §9.4) // Emit diff artifact (DD §9.4)
return create_result(call.call_id, 'fs.edit', 'text', { return create_result(call.call_id, 'fs.edit', 'text', {
message: `Edited ${path}`, message: `Edited ${path}`,

View File

@@ -13,6 +13,8 @@ import type { ChildProcess } from 'child_process'
import { WorkerProcess, type WorkerExitCode } from './WorkerProcess.js' import { WorkerProcess, type WorkerExitCode } from './WorkerProcess.js'
import { WorkerProtocol, type WorkerMessage, type WorkerMessageType } from './WorkerProtocol.js' import { WorkerProtocol, type WorkerMessage, type WorkerMessageType } from './WorkerProtocol.js'
import type { WorkerResult, WorkerStatus, AgentType } from '@aircoding/contracts' import type { WorkerResult, WorkerStatus, AgentType } from '@aircoding/contracts'
import { eventIngestor } from '../events/EventIngestor.js'
import { eventSchemaRegistry } from '../events/EventSchemaRegistry.js'
import type { ToolRegistry } from '../tools/ToolRegistry.js' import type { ToolRegistry } from '../tools/ToolRegistry.js'
import type { ProviderManager } from '@aircoding/llm' import type { ProviderManager } from '@aircoding/llm'
@@ -85,6 +87,7 @@ export class WorkerManager {
state: 'starting', state: 'starting',
started_at: new Date().toISOString() started_at: new Date().toISOString()
} }
this.workers.set(config.agent_id, handle)
// Spawn worker process using Bun // Spawn worker process using Bun
// Worker must run from AirCoding repo root so Bun can resolve modules // Worker must run from AirCoding repo root so Bun can resolve modules
@@ -126,7 +129,6 @@ export class WorkerManager {
}) })
handle.state = 'ready' handle.state = 'ready'
this.workers.set(config.agent_id, handle)
// Set up timeout // Set up timeout
if (config.timeout_ms) { if (config.timeout_ms) {
@@ -219,13 +221,15 @@ export class WorkerManager {
const response = await this.provider_manager.complete_text(messages, { const response = await this.provider_manager.complete_text(messages, {
model: msg.payload.model as string, model: msg.payload.model as string,
max_tokens: msg.payload.max_tokens as number, max_tokens: msg.payload.max_tokens as number,
temperature: msg.payload.temperature as number temperature: msg.payload.temperature as number,
tools: (msg.payload.tools as unknown[]) || this.tool_registry?.list?.() || []
}) })
this.send_to_worker(agent_id, 'llm.response', { this.send_to_worker(agent_id, 'llm.response', {
call_id, call_id,
content: response.content, content: response.content,
usage: response.usage usage: response.usage,
tool_calls: response.tool_calls
}) })
} catch (e: any) { } catch (e: any) {
this.send_to_worker(agent_id, 'llm.response', { this.send_to_worker(agent_id, 'llm.response', {
@@ -236,6 +240,40 @@ export class WorkerManager {
} }
}) })
// Handle worker-emitted RuntimeEvent payloads through the single EventIngestor entry point.
proc.on_message('event', async (msg) => {
try {
const event_type = (msg.payload.event_type || msg.payload.type) as string
if (!event_type || !eventSchemaRegistry.isRegistered(event_type, 1)) {
console.error(`[WM] ignoring unregistered worker event: ${event_type || '(missing)'}`)
return
}
const { event_type: _eventType, ...restPayload } = msg.payload
const payload = _eventType ? restPayload : (() => {
const { type: _legacyType, ...legacyPayload } = restPayload
return legacyPayload
})()
const event = {
id: (payload.event_id as string) || msg.id,
type: event_type,
version: 1,
timestamp: msg.timestamp || new Date().toISOString(),
session_id: this.execution_context?.session_id || msg.session_id,
project_id: this.execution_context?.project_id || '',
source: { kind: 'agent', id: agent_id, agent_type: this.worker_agent_type(agent_id) },
route: ['worker', agent_id, event_type],
payload,
}
const persistence = eventSchemaRegistry.getPersistence(event_type, 1)
if (persistence === 'durable') await eventIngestor.ingest(event as any)
else if (persistence === 'ephemeral') await eventIngestor.ingest_ephemeral(event as any)
} catch (e: any) {
console.error('[WM] worker event ingest error:', e.message)
}
})
// Handle worker.result → update handle // Handle worker.result → update handle
proc.on_message('worker.result', (msg) => { proc.on_message('worker.result', (msg) => {
const handle = this.workers.get(agent_id) const handle = this.workers.get(agent_id)
@@ -322,20 +360,32 @@ export class WorkerManager {
* Get result for a task. * Get result for a task.
*/ */
get_result_for_task(task_id: string): WorkerResult<unknown> | undefined { get_result_for_task(task_id: string): WorkerResult<unknown> | undefined {
return this.list().find(w => w.config.task_spec?.id === task_id)?.result return this.list().find(w => w.config.task_spec?.id === task_id || w.config.task_spec?.task_id === task_id || w.worker_id === `agent_${task_id}`)?.result
} }
/** /**
* Get handle for a task. * Get handle for a task.
*/ */
get_handle_for_task(task_id: string): WorkerHandle | undefined { get_handle_for_task(task_id: string): WorkerHandle | undefined {
return this.list().find(w => w.config.task_spec?.id === task_id) return this.list().find(w => w.config.task_spec?.id === task_id || w.config.task_spec?.task_id === task_id || w.worker_id === `agent_${task_id}`)
} }
// ============================================================================ // ============================================================================
// Private // Private
// ============================================================================ // ============================================================================
private worker_agent_type(agent_id: string): AgentType {
const handle = this.workers.get(agent_id)
const task_type = handle?.config.task_type || 'execute'
switch (task_type) {
case 'review': return 'reviewer' as AgentType
case 'debug': return 'debugger' as AgentType
case 'compact': return 'compactor' as AgentType
case 'mine_experience': return 'experience_miner' as AgentType
default: return 'executor' as AgentType
}
}
private handle_worker_exit(agent_id: string, exit: { code: number | null; signal: NodeJS.Signals | null; semantic: string; description: string }): void { private handle_worker_exit(agent_id: string, exit: { code: number | null; signal: NodeJS.Signals | null; semantic: string; description: string }): void {
const handle = this.workers.get(agent_id) const handle = this.workers.get(agent_id)
if (!handle) return if (!handle) return
@@ -369,8 +419,10 @@ export class WorkerManager {
const raw_status = (payload.status as string) || 'completed' const raw_status = (payload.status as string) || 'completed'
const status = raw_status === 'completed' || raw_status === 'cancelled' || raw_status === 'blocked' || raw_status === 'failed' const status = raw_status === 'completed' || raw_status === 'cancelled' || raw_status === 'blocked' || raw_status === 'failed'
? raw_status ? raw_status
: raw_status === 'fixed' || raw_status === 'pass' : raw_status === 'fixed' || raw_status === 'cannot_reproduce' || raw_status === 'pass' || raw_status === 'compacted' || raw_status === 'skipped' || raw_status === 'no_patterns'
? 'completed' ? 'completed'
: raw_status === 'escalated'
? 'blocked'
: 'failed' : 'failed'
const changes = Array.isArray((payload as any).changes) ? (payload as any).changes : [] const changes = Array.isArray((payload as any).changes) ? (payload as any).changes : []
const changed_files = (payload.changed_files as string[] | undefined) || changes.map((c: any) => String(c.file)).filter(Boolean) const changed_files = (payload.changed_files as string[] | undefined) || changes.map((c: any) => String(c.file)).filter(Boolean)
@@ -379,13 +431,15 @@ export class WorkerManager {
: verification_payload ? [{ command: 'worker verification', passed: Boolean(verification_payload.passed), output: String(verification_payload.output || '') }] as any[] : verification_payload ? [{ command: 'worker verification', passed: Boolean(verification_payload.passed), output: String(verification_payload.output || '') }] as any[]
: [] : []
const summary = (payload.summary as string) const summary = (payload.summary as string)
|| (payload.summary_content as string)
|| (payload.root_cause as string)
|| (payload.error ? String(payload.error) : '') || (payload.error ? String(payload.error) : '')
|| (changed_files.length > 0 ? `Changed files: ${changed_files.join(', ')}` : `Worker ${status}`) || (changed_files.length > 0 ? `Changed files: ${changed_files.join(', ')}` : `Worker ${status}`)
return { return {
task_id: (payload.task_id as string) || (handle.config.task_spec?.id as string) || '' as any, task_id: (payload.task_id as string) || (handle.config.task_spec?.id as string) || (handle.config.task_spec?.task_id as string) || '' as any,
agent_id: (payload.agent_id as string) || handle.config.agent_id as any, agent_id: (payload.agent_id as string) || handle.config.agent_id as any,
agent_type: (payload.agent_type as AgentType) || 'executor', agent_type: (payload.agent_type as AgentType) || this.worker_agent_type(handle.config.agent_id),
status: status as WorkerStatus, status: status as WorkerStatus,
summary, summary,
changed_files, changed_files,

View File

@@ -1,59 +1,55 @@
/** import { afterEach, describe, expect, test } from 'bun:test'
* Regression test: EvidenceStore SQLite persistence import { Database } from 'bun:sqlite'
* import { mkdtempSync, rmSync } from 'fs'
* Verifies that EvidenceStore uses SQLite (bun:sqlite) instead of import { tmpdir } from 'os'
* in-memory Map for persistent storage.
*/
import { describe, test, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { createEvidenceStore } from '../../src/artifacts/EvidenceStore.js'
const SOURCE_PATH = join( import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
import.meta.dir,
'..',
'..',
'src',
'artifacts',
'EvidenceStore.ts'
)
const source = readFileSync(SOURCE_PATH, 'utf-8')
describe('EvidenceStore SQLite persistence', () => { describe('EvidenceStore SQLite persistence', () => {
test('EvidenceStore does not use in-memory Map', () => { const created: string[] = []
// Should not have Map< for storage
expect(source).not.toMatch(/evidenceStore:\s*Map</) afterEach(() => {
// Should not use .set() on a map for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
expect(source).not.toContain('this.evidenceStore.set(')
}) })
test('EvidenceStore constructor accepts Database parameter', () => { test('persists evidence refs in SQLite and can list them by entity', async () => {
// Constructor should accept a Database parameter const dir = mkdtempSync(join(tmpdir(), 'air-evidence-store-'))
expect(source).toContain('db: Database') created.push(dir)
// Should import Database from bun:sqlite const dbPath = join(dir, 'evidence.db')
expect(source).toContain("from 'bun:sqlite'")
})
test('EvidenceStore has initSchema method', () => { const db1 = new Database(dbPath)
expect(source).toContain('initSchema()') const store1 = createEvidenceStore('session_evidence' as any, db1, createNullEventIngestor() as any)
// Should be called in constructor const createdRef = await store1.create({
expect(source).toContain('this.initSchema()') kind: 'command_output',
ref: 'artifact://stdout.txt',
claim: 'command produced expected output',
task_id: 'task_1' as any,
location_json: { line: 1 },
}) })
expect(createdRef.evidence_ref_id).toStartWith('evi_')
expect((await store1.list_for_entity('task', 'task_1'))[0]).toMatchObject({
evidence_ref_id: createdRef.evidence_ref_id,
kind: 'command_output',
ref: 'artifact://stdout.txt',
claim: 'command produced expected output',
location_json: { line: 1 },
})
db1.close()
test('EvidenceStore creates evidence_refs table', () => { const db2 = new Database(dbPath)
expect(source).toContain('CREATE TABLE IF NOT EXISTS evidence_refs') const rows = db2.query('SELECT evidence_ref_id, session_id, kind, ref, claim, location_json, task_id FROM evidence_refs').all() as any[]
// Should have key columns expect(rows).toHaveLength(1)
expect(source).toContain('evidence_ref_id TEXT PRIMARY KEY') expect(rows[0]).toMatchObject({
expect(source).toContain('session_id TEXT NOT NULL') evidence_ref_id: createdRef.evidence_ref_id,
expect(source).toContain('kind TEXT NOT NULL') session_id: 'session_evidence',
kind: 'command_output',
ref: 'artifact://stdout.txt',
claim: 'command produced expected output',
task_id: 'task_1',
}) })
expect(JSON.parse(rows[0].location_json)).toEqual({ line: 1 })
test('EvidenceStore uses INSERT INTO for create', () => { expect(db2.query("PRAGMA journal_mode").get()).toEqual({ journal_mode: 'wal' })
expect(source).toContain('INSERT INTO evidence_refs') db2.close()
})
test('EvidenceStore applies WAL PRAGMA', () => {
expect(source).toContain('PRAGMA journal_mode = WAL')
}) })
}) })

View File

@@ -1,111 +1,88 @@
/** import { afterEach, describe, expect, it } from 'bun:test'
* C1 regression: Knowledge Store schema alignment. import { existsSync, mkdtempSync, rmSync } from 'fs'
* Bug: DebugKnowledgeStore and LearnedMemoryStore used .air/shared/ paths, import { tmpdir } from 'os'
* had non-canonical column names, and were missing PRAGMAs.
* Fix: moved to .air/local/, renamed columns, added WAL/synchronous/foreign_keys PRAGMAs.
*/
import { describe, it, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { DebugKnowledgeStore } from '../../src/knowledge/DebugKnowledgeStore.js'
import { LearnedMemoryStore } from '../../src/knowledge/LearnedMemoryStore.js'
describe('C1: Knowledge Store schema alignment', () => { describe('C1: Knowledge Store schema alignment', () => {
const debug_src = readFileSync( const created: string[] = []
join(import.meta.dir, '..', '..', 'src', 'knowledge', 'DebugKnowledgeStore.ts'),
'utf-8'
)
const memory_src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'knowledge', 'LearnedMemoryStore.ts'),
'utf-8'
)
it('DebugKnowledgeStore DB path uses .air/local/ not .air/shared/', () => { afterEach(() => {
expect(debug_src).toContain("'.air', 'local', 'debug-records.db'") for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
expect(debug_src).not.toContain("'.air', 'shared', 'debug-records.db'")
}) })
it('LearnedMemoryStore DB path uses .air/local/ not .air/shared/', () => { it('DebugKnowledgeStore stores and queries records from .air/local', () => {
expect(memory_src).toContain("'.air', 'local', 'learned-memory.db'") const root = mkdtempSync(join(tmpdir(), 'air-debug-store-'))
expect(memory_src).not.toContain("'.air', 'shared', 'learned-memory.db'") created.push(root)
const store = new DebugKnowledgeStore(root)
store.open()
const now = new Date().toISOString()
store.insert({
id: 'debug_1',
failure_signature: 'compiler:error:missing-header',
task_id: 'task_1',
root_cause: 'missing include path',
fix_ref: 'fix://1',
summary: 'Add include path before rebuilding',
evidence_json: JSON.stringify(['evi_1']),
verification_json: JSON.stringify(['build passed']),
created_at: now,
updated_at: now,
metadata_json: JSON.stringify({ source: 'test' }),
}) })
it('DebugRecord has failure_signature not signature', () => { expect(existsSync(join(root, '.air', 'local', 'debug-records.db'))).toBe(true)
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/) expect(existsSync(join(root, '.air', 'shared', 'debug-records.db'))).toBe(false)
expect(iface_match).not.toBeNull() expect(store.lookup_by_signature('compiler:error:missing-header')).toHaveLength(1)
const iface_body = iface_match![1] expect(store.lookup_by_task('task_1')[0]).toMatchObject({
id: 'debug_1',
expect(iface_body).toContain('failure_signature') failure_signature: 'compiler:error:missing-header',
// Should not have bare 'signature' field (failure_signature contains 'signature' as substring, so check for the exact field pattern) task_id: 'task_1',
expect(iface_body).not.toMatch(/^\s*signature\s*:/m) root_cause: 'missing include path',
fix_ref: 'fix://1',
summary: 'Add include path before rebuilding',
}) })
it('DebugRecord has summary and fix_ref fields', () => { store.update('debug_1', { summary: 'Updated summary', updated_at: now })
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/) expect(store.lookup_by_signature('compiler:error:missing-header')[0].summary).toBe('Updated summary')
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).toContain('summary')
expect(iface_body).toContain('fix_ref')
}) })
it('DebugRecord does not have error_kind or session_id', () => { it('LearnedMemoryStore stores candidates/promoted memories from .air/local', () => {
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/) const root = mkdtempSync(join(tmpdir(), 'air-memory-store-'))
expect(iface_match).not.toBeNull() created.push(root)
const iface_body = iface_match![1] const store = new LearnedMemoryStore(root)
store.open()
const now = new Date().toISOString()
expect(iface_body).not.toContain('error_kind') store.insert({
expect(iface_body).not.toContain('session_id') id: 'mem_1',
memory_type: 'project_rule',
summary: 'Use Bun for package scripts',
content: 'Project commands should use Bun unless explicitly overridden.',
source_entity_type: 'task',
source_entity_id: 'task_1',
status: 'candidate',
created_at: now,
updated_at: now,
metadata_json: JSON.stringify({ confidence: 0.8 }),
}) })
it('DebugKnowledgeStore applies WAL PRAGMA', () => { expect(existsSync(join(root, '.air', 'local', 'learned-memory.db'))).toBe(true)
expect(debug_src).toContain('PRAGMA journal_mode = WAL') expect(existsSync(join(root, '.air', 'shared', 'learned-memory.db'))).toBe(false)
expect(store.lookup_by_type('project_rule')).toHaveLength(1)
expect(store.lookup_by_type('project_rule')[0]).toMatchObject({
id: 'mem_1',
memory_type: 'project_rule',
status: 'candidate',
source_entity_type: 'task',
source_entity_id: 'task_1',
}) })
it('LearnedMemoryStore table is learned_memories (plural)', () => { store.update_status('mem_1', 'promoted')
expect(memory_src).toContain('learned_memories') expect(store.lookup_by_type('project_rule')[0].status).toBe('promoted')
// Ensure we don't have the singular form used as table name store.update_status('mem_1', 'archived')
expect(memory_src).not.toMatch(/FROM learned_memory\b/) expect(store.lookup_by_type('project_rule')).toHaveLength(0)
expect(memory_src).not.toMatch(/INTO learned_memory\b/)
expect(memory_src).not.toMatch(/UPDATE learned_memory\b/)
expect(memory_src).not.toMatch(/TABLE.*learned_memory\b/)
})
it('MemoryEntry.memory_type has 4 spec values', () => {
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).toContain("'project_rule'")
expect(iface_body).toContain("'toolchain_rule'")
expect(iface_body).toContain("'skill_update'")
expect(iface_body).toContain("'debug_experience'")
expect(iface_body).toContain('memory_type')
})
it('MemoryEntry.status has 4 spec values: candidate, promoted, archived, rejected', () => {
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).toContain("'candidate'")
expect(iface_body).toContain("'promoted'")
expect(iface_body).toContain("'archived'")
expect(iface_body).toContain("'rejected'")
})
it('MemoryEntry.status default is candidate not draft', () => {
// Check that the CREATE TABLE DDL uses 'candidate' as default
expect(memory_src).toContain("DEFAULT 'candidate'")
expect(memory_src).not.toContain("DEFAULT 'draft'")
})
it('MemoryEntry uses source_entity_type + source_entity_id', () => {
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).toContain('source_entity_type')
expect(iface_body).toContain('source_entity_id')
expect(iface_body).not.toContain('source_task_ids')
}) })
}) })

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'bun:test'
import { ProjectionStore } from '../../src/projection/ProjectionStore.js'
import type { RuntimeEvent } from '@aircoding/contracts'
function event(type: string, payload: Record<string, unknown>): RuntimeEvent<Record<string, unknown>> {
return {
id: `evt_${type}_${Math.random().toString(36).slice(2)}`,
type,
version: 1,
timestamp: new Date().toISOString(),
session_id: 'session_projection_apply' as any,
project_id: 'project_projection_apply' as any,
source: { kind: 'system' },
route: ['test', type],
payload,
}
}
describe('ProjectionStore.apply', () => {
it('applies task and agent lifecycle events into a live snapshot', () => {
const store = new ProjectionStore()
const updates: string[] = []
store.subscribe((projection) => {
updates.push(`${projection.tasks[0]?.status || 'none'}:${projection.agents[0]?.status || 'none'}`)
})
store.apply(event('task.created', {
task_id: 'task_1',
type: 'execute',
title: 'Create file',
task_spec_json: {},
dependencies: [],
metadata: {},
}))
store.apply(event('task.started', {
task_id: 'task_1',
agent_id: 'agent_task_1',
attempt_id: 'task_1_1',
attempt_index: 0,
workspace_id: 'ws_task_1',
}))
store.apply(event('agent.started', {
agent_id: 'agent_task_1',
agent_type: 'executor',
task_id: 'task_1',
metadata: {},
}))
store.apply(event('agent.completed', {
agent_id: 'agent_task_1',
task_id: 'task_1',
summary: 'done',
metadata: {},
}))
store.apply(event('task.completed', {
task_id: 'task_1',
agent_id: 'agent_task_1',
attempt_id: 'task_1_1',
worker_result_json: { status: 'completed' },
summary: 'done',
changed_files: ['hello.txt'],
evidence_refs: [],
}))
const snapshot = store.get_snapshot('session_projection_apply')
expect(snapshot).toBeDefined()
expect(snapshot!.tasks).toHaveLength(1)
expect(snapshot!.tasks[0].status).toBe('completed')
expect(snapshot!.tasks[0].agent_id).toBe('agent_task_1')
expect(snapshot!.tasks[0].attempts).toBe(1)
expect(snapshot!.agents).toHaveLength(1)
expect(snapshot!.agents[0].status).toBe('completed')
expect(updates.some((u) => u.startsWith('completed:completed'))).toBe(true)
})
it('applies tool, permission, and blocker events', () => {
const store = new ProjectionStore()
store.apply(event('tool.started', {
tool_run_id: 'tool_1',
tool_name: 'fs.write',
input_json: {},
metadata: {},
}))
store.apply(event('tool.completed', {
tool_run_id: 'tool_1',
output_json: { ok: true },
duration_ms: 12,
artifact_ids: [],
evidence_refs: [],
metadata: {},
}))
store.apply(event('permission.prompt.requested', {
prompt_id: 'perm_1',
subject: 'shell.run',
risk_level: 'medium',
reason: 'risk score 70 requires user confirmation',
options: ['allow_once', 'deny'],
default_option: 'deny',
request_ref: {},
}))
store.apply(event('task.created', {
task_id: 'task_blocked',
type: 'execute',
title: 'Blocked task',
task_spec_json: {},
dependencies: [],
metadata: {},
}))
store.apply(event('task.blocked', {
task_id: 'task_blocked',
agent_id: 'agent_task_blocked',
reason: 'worker blocked',
blocker_kind: 'worker_blocked',
evidence_refs: [],
suggested_next_step: 'review blocker',
}))
store.apply(event('permission.prompt.resolved', {
prompt_id: 'perm_1',
selected_option: 'deny',
decision_id: 'decision_1',
resolved_by: 'test',
}))
const snapshot = store.get_snapshot('session_projection_apply')
expect(snapshot).toBeDefined()
expect(snapshot!.tool_runs).toEqual([{ tool_run_id: 'tool_1', tool_name: 'fs.write', status: 'ok', duration_ms: 12 }])
expect(snapshot!.permission_prompts).toHaveLength(0)
expect(snapshot!.tasks.find((task) => task.id === 'task_blocked')?.status).toBe('blocked')
expect(snapshot!.blockers).toEqual([{ task_id: 'task_blocked', reason: 'worker blocked', blocker_kind: 'worker_blocked' }])
})
})

View File

@@ -1,55 +1,95 @@
/** import { afterEach, describe, expect, test } from 'bun:test'
* Regression test: Recovery implementation completeness import { Database } from 'bun:sqlite'
* import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
* Verifies that checkPidLiveness and scanOrphanReferences have real import { tmpdir } from 'os'
* implementations, not just stub return values.
*/
import { describe, test, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { Recovery } from '../../src/storage/Recovery.js'
const SOURCE_PATH = join(
import.meta.dir,
'..',
'..',
'src',
'storage',
'Recovery.ts'
)
const source = readFileSync(SOURCE_PATH, 'utf-8')
describe('Recovery implementation', () => { describe('Recovery implementation', () => {
test('checkPidLiveness is not a stub (has implementation code)', () => { const created: string[] = []
// Should have actual implementation with loop logic
expect(source).toContain('for (const agent of agents)') afterEach(() => {
expect(source).toContain("action: alive ? 'keep' : 'mark_lost'") for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
// Should have more than just a bare return []
expect(source).toContain('const reports: PidLivenessReport[] = []')
}) })
test('checkPidLiveness uses process.kill for liveness check', () => { function makeRecovery(): { recovery: Recovery; root: string; artifactRoot: string; dbPath: string } {
// Should use process.kill(pid, 0) for signal-0 liveness check const root = mkdtempSync(join(tmpdir(), 'air-recovery-'))
expect(source).toContain('process.kill(agent.pid, 0)') created.push(root)
const artifactRoot = join(root, 'artifacts')
const dbPath = join(root, 'session.db')
const db = new Database(dbPath)
db.exec(`
CREATE TABLE sessions (id TEXT PRIMARY KEY);
CREATE TABLE tasks (id TEXT PRIMARY KEY, session_id TEXT);
CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT);
CREATE TABLE task_attempts (id TEXT PRIMARY KEY, task_id TEXT);
CREATE TABLE agents (id TEXT PRIMARY KEY, session_id TEXT);
CREATE TABLE tool_runs (id TEXT PRIMARY KEY, session_id TEXT);
CREATE TABLE command_runs (id TEXT PRIMARY KEY, session_id TEXT);
CREATE TABLE artifacts (id TEXT PRIMARY KEY, session_id TEXT);
CREATE TABLE evidence_refs (evidence_ref_id TEXT PRIMARY KEY, session_id TEXT);
INSERT INTO tasks (id, session_id) VALUES ('task_orphan', 'missing_session');
INSERT INTO task_attempts (id, task_id) VALUES ('attempt_orphan', 'missing_task');
`)
db.close()
return {
recovery: new Recovery({
sessionId: 'session_recovery' as any,
projectId: 'project_recovery' as any,
artifactRoot,
dbPath,
projectRoot: root,
}),
root,
artifactRoot,
dbPath,
}
}
test('checks PID liveness with keep/mark_lost actions', () => {
const { recovery } = makeRecovery()
const reports = recovery.checkPidLiveness([
{ agent_id: 'self', pid: process.pid },
{ agent_id: 'missing', pid: 99999999 },
])
recovery.close()
expect(reports).toEqual([
{ agent_id: 'self', pid: process.pid, alive: true, action: 'keep' },
{ agent_id: 'missing', pid: 99999999, alive: false, action: 'mark_lost' },
])
}) })
test('scanOrphanReferences returns OrphanReferenceReport structure', () => { test('scans orphan references from SQLite tables', async () => {
// Should define fkChecks array with the 8 invariant checks const { recovery } = makeRecovery()
expect(source).toContain('fkChecks') const report = await recovery.scan()
expect(source).toContain("table: 'tasks'") recovery.close()
expect(source).toContain("table: 'messages'")
expect(source).toContain("table: 'task_attempts'")
expect(source).toContain("table: 'agents'")
expect(source).toContain("table: 'tool_runs'")
expect(source).toContain("table: 'command_runs'")
expect(source).toContain("table: 'artifacts'")
expect(source).toContain("table: 'evidence_refs'")
// Should iterate over checks expect(report.orphanReferences.totalFound).toBeGreaterThanOrEqual(2)
expect(source).toContain('for (const check of fkChecks)') expect(report.orphanReferences.archived).toContainEqual({
table: 'tasks',
// Should return a proper report id: 'missing_session',
expect(source).toContain('return report') reason: 'FK-off: session_id → sessions (1 rows)',
})
expect(report.orphanReferences.archived).toContainEqual({
table: 'task_attempts',
id: 'missing_task',
reason: 'FK-off: task_id → tasks (1 rows)',
})
})
test('quarantines non-artifact temporary orphan files', async () => {
const { recovery, artifactRoot } = makeRecovery()
const tmpDir = join(artifactRoot, 'tmp')
const orphanPath = join(tmpDir, 'scratch.tmp')
await Bun.write(orphanPath, 'orphan')
const report = await recovery.scan()
recovery.close()
expect(report.orphanArtifacts.totalFound).toBe(1)
expect(report.orphanArtifacts.quarantined).toHaveLength(1)
expect(existsSync(report.orphanArtifacts.quarantined[0])).toBe(true)
expect(existsSync(orphanPath)).toBe(false)
}) })
}) })

View File

@@ -7,6 +7,7 @@ import { BuiltInToolRegistrar } from '../../src/tools/BuiltInToolRegistrar.js'
import { Scheduler } from '../../src/scheduler/Scheduler.js' import { Scheduler } from '../../src/scheduler/Scheduler.js'
import { MainAgent } from '../../src/agents/main/MainAgent.js' import { MainAgent } from '../../src/agents/main/MainAgent.js'
import { ContextAssembler } from '../../src/context/ContextAssembler.js' import { ContextAssembler } from '../../src/context/ContextAssembler.js'
import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
function createRegistry(projectRoot: string): ToolRegistry { function createRegistry(projectRoot: string): ToolRegistry {
const registry = new ToolRegistry(projectRoot) const registry = new ToolRegistry(projectRoot)
@@ -24,7 +25,6 @@ describe('Release critical gates', () => {
for (const [name, args] of [ for (const [name, args] of [
['fs.stat', { path: 'sample.txt' }], ['fs.stat', { path: 'sample.txt' }],
['project.scan', { root: '.' }], ['project.scan', { root: '.' }],
['cpp.detect', { project_root: projectRoot }],
['doctor.run', { scope: 'all' }], ['doctor.run', { scope: 'all' }],
] as Array<[string, Record<string, unknown>]>) { ] as Array<[string, Record<string, unknown>]>) {
const result = await registry.call({ call_id: `call-${name}`, name, arguments: args }, ctx) const result = await registry.call({ call_id: `call-${name}`, name, arguments: args }, ctx)
@@ -59,8 +59,8 @@ describe('Release critical gates', () => {
get_handle_for_task: () => undefined, get_handle_for_task: () => undefined,
get_result_for_task: () => undefined, get_result_for_task: () => undefined,
} }
const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any) const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any, createNullEventIngestor())
scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }]) await scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }])
scheduler.get_graph().update_status('task-1' as any, 'running') scheduler.get_graph().update_status('task-1' as any, 'running')
await scheduler.step() await scheduler.step()
@@ -68,6 +68,34 @@ describe('Release critical gates', () => {
expect(scheduler.get_graph().get_tasks_by_status('completed').length).toBe(0) expect(scheduler.get_graph().get_tasks_by_status('completed').length).toBe(0)
}) })
it('scheduler surfaces blocked worker results as BLOCKED, not COMPLETED', async () => {
const workerManager = {
has_running: () => false,
get_handle_for_task: () => ({ worker_id: 'agent-task-1' }),
get_result_for_task: () => ({
task_id: 'task-1',
agent_id: 'agent-task-1',
agent_type: 'executor',
status: 'blocked',
summary: 'blocked by worker',
changed_files: [],
artifacts: [],
verification: [],
risks: [],
follow_up_tasks: [],
evidence_refs: [],
result: {},
}),
}
const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any, createNullEventIngestor())
await scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }])
scheduler.get_graph().update_status('task-1' as any, 'running')
const finalState = await scheduler.run_until_idle()
expect(finalState).toBe('BLOCKED')
expect(scheduler.get_graph().get_tasks_by_status('blocked').length).toBe(1)
})
it('MainAgent answer mode uses assembled project context', async () => { it('MainAgent answer mode uses assembled project context', async () => {
const projectRoot = mkdtempSync(join(tmpdir(), 'air-context-')) const projectRoot = mkdtempSync(join(tmpdir(), 'air-context-'))
writeFileSync(join(projectRoot, 'visible.txt'), 'visible') writeFileSync(join(projectRoot, 'visible.txt'), 'visible')

View File

@@ -6,6 +6,7 @@
import { describe, it, expect } from 'bun:test' import { describe, it, expect } from 'bun:test'
import { Scheduler, type SchedulerState } from '../../src/scheduler/Scheduler.js' import { Scheduler, type SchedulerState } from '../../src/scheduler/Scheduler.js'
import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
describe('B1: Scheduler wire-up', () => { describe('B1: Scheduler wire-up', () => {
it('SchedulerState includes BLOCKED and CANCELLED', () => { it('SchedulerState includes BLOCKED and CANCELLED', () => {
@@ -54,10 +55,12 @@ describe('B1: Scheduler wire-up', () => {
session_id: 'test-session' as any, session_id: 'test-session' as any,
project_id: 'test-project' as any, project_id: 'test-project' as any,
project_root: '/tmp/test' project_root: '/tmp/test'
} },
undefined,
createNullEventIngestor(),
) )
scheduler.create_tasks([ await scheduler.create_tasks([
{ id: 't1' as any, type: 'code', title: 'Task 1' }, { id: 't1' as any, type: 'code', title: 'Task 1' },
{ id: 't2' as any, type: 'code', title: 'Task 2', depends_on: ['t1' as any] } { id: 't2' as any, type: 'code', title: 'Task 2', depends_on: ['t1' as any] }
]) ])

View File

@@ -50,8 +50,9 @@ describe('A5+A4: ToolRegistry permission fixes', () => {
expect(build_match![0]).not.toMatch(/permission_profile:\s*undefined/) expect(build_match![0]).not.toMatch(/permission_profile:\s*undefined/)
}) })
it('permission denial branches preserve original call_id', () => { it('permission branches preserve original call_id', () => {
expect(src).toContain("create_error_result(call.call_id, 'user_prompt_required'") expect(src).toContain('permission.prompt.requested')
expect(src).toContain('request_ref: { call_id: call.call_id')
expect(src).toContain("create_error_result(call.call_id, 'permission_denied'") expect(src).toContain("create_error_result(call.call_id, 'permission_denied'")
expect(src).not.toContain("create_error_result('', 'user_prompt_required'") expect(src).not.toContain("create_error_result('', 'user_prompt_required'")
expect(src).not.toContain("create_error_result('', 'permission_denied'") expect(src).not.toContain("create_error_result('', 'permission_denied'")

View File

@@ -1,7 +1,7 @@
/** /**
* C7 regression: All 28 MVP tools registered * C7 regression: All MVP tools registered
* Validates that BuiltInToolRegistrar registers all 28 tool-registry-v1 MVP tools * Validates that BuiltInToolRegistrar registers built-in tools (non-cpp)
* plus extra built-in tools, with stub executors for Alpha-scope tools. * and that CppToolRegistrar registers cpp.* tools separately.
* *
* Tests actual ToolRegistry state rather than source text inspection. * Tests actual ToolRegistry state rather than source text inspection.
*/ */
@@ -15,22 +15,26 @@ const registrar = new BuiltInToolRegistrar(registry)
registrar.register_all('/tmp/test-air') registrar.register_all('/tmp/test-air')
const tools = registry.list() const tools = registry.list()
// 28 MVP tools from tool-registry-v1 §11 // Built-in tools (non-cpp, registered by BuiltInToolRegistrar)
const MVP_TOOLS = [ const BUILTIN_TOOLS = [
'fs.list', 'fs.read', 'fs.write', 'fs.edit', 'fs.patch', 'fs.stat', 'fs.list', 'fs.read', 'fs.write', 'fs.edit', 'fs.patch', 'fs.stat',
'shell.run', 'process.kill', 'shell.run', 'process.kill',
'git.status', 'git.diff', 'git.worktree.create', 'git.merge_workspace', 'git.status', 'git.diff', 'git.worktree.create', 'git.merge_workspace',
'project.scan', 'project.profile.write', 'project.scan', 'project.profile.write',
'cpp.detect', 'cpp.cmake.configure', 'cpp.build', 'cpp.test',
'cpp.static.cppcheck', 'cpp.clangd.query',
'debug.run', 'debug.parse_logs', 'debug.run', 'debug.parse_logs',
'gui.screenshot', 'network.capture', 'gui.screenshot', 'network.capture',
'artifact.create', 'context.assemble', 'artifact.create', 'context.assemble',
'permission.request', 'doctor.run', 'permission.request', 'doctor.run',
] ]
// cpp tools registered by CppToolRegistrar (tested via RuntimeApp integration)
const CPP_TOOLS = [
'cpp.detect', 'cpp.configure', 'cpp.build', 'cpp.test',
'cpp.cppcheck', 'cpp.clangd',
]
describe('C7: MVP tool registrations', () => { describe('C7: MVP tool registrations', () => {
for (const tool_name of MVP_TOOLS) { for (const tool_name of BUILTIN_TOOLS) {
it(`registers ${tool_name}`, () => { it(`registers ${tool_name}`, () => {
const found = tools.find(t => t.name === tool_name) const found = tools.find(t => t.name === tool_name)
expect(found).toBeDefined() expect(found).toBeDefined()
@@ -38,19 +42,30 @@ describe('C7: MVP tool registrations', () => {
}) })
} }
it('has at least 28 tools registered', () => { it('has at least 22 built-in tools registered', () => {
expect(tools.length).toBeGreaterThanOrEqual(28) expect(tools.length).toBeGreaterThanOrEqual(22)
}) })
it('stub tools produce text envelope with alpha_stub metadata', async () => { it('stub tools produce structured envelope', async () => {
// Pick a stub tool and verify its executor returns structured envelope const stub_names = ['process.kill', 'gui.screenshot', 'network.capture']
const stub_names = ['process.kill', 'cpp.clangd.query', 'gui.screenshot', 'network.capture']
for (const name of stub_names) { for (const name of stub_names) {
const tool = tools.find(t => t.name === name) const tool = tools.find(t => t.name === name)
expect(tool).toBeDefined() expect(tool).toBeDefined()
} }
}) })
it('cpp tools registered via CppToolRegistrar (not BuiltInToolRegistrar)', async () => {
const { CppToolRegistrar } = await import('@aircoding/toolchain-cpp')
const cppRegistry = new ToolRegistry('/tmp/test-air-cpp')
const cppRegistrar = new CppToolRegistrar()
cppRegistrar.register(cppRegistry, '/tmp/test-air-cpp')
const cppTools = cppRegistry.list()
for (const name of CPP_TOOLS) {
const found = cppTools.find((t: any) => t.name === name)
expect(found).toBeDefined()
}
})
it('create_stub_definitions and create_real_executor exist', () => { it('create_stub_definitions and create_real_executor exist', () => {
expect(typeof (BuiltInToolRegistrar.prototype as any).create_stub_definitions).toBe('function') expect(typeof (BuiltInToolRegistrar.prototype as any).create_stub_definitions).toBe('function')
expect(typeof (BuiltInToolRegistrar.prototype as any).create_real_executor).toBe('function') expect(typeof (BuiltInToolRegistrar.prototype as any).create_real_executor).toBe('function')

View File

@@ -45,9 +45,13 @@ describe('D3: Worker result envelope', () => {
}) })
it('wrap_worker_result maps role results into WorkerResult with safe defaults', () => { it('wrap_worker_result maps role results into WorkerResult with safe defaults', () => {
expect(source).toContain("agent_type: (payload.agent_type as AgentType) || 'executor'") expect(source).toContain('agent_type: (payload.agent_type as AgentType) || this.worker_agent_type(handle.config.agent_id)')
expect(source).toContain("const raw_status = (payload.status as string) || 'completed'") expect(source).toContain("const raw_status = (payload.status as string) || 'completed'")
expect(source).toContain("raw_status === 'fixed' || raw_status === 'pass'") expect(source).toContain("raw_status === 'fixed'")
expect(source).toContain("raw_status === 'pass'")
expect(source).toContain("raw_status === 'cannot_reproduce'")
expect(source).toContain("raw_status === 'compacted'")
expect(source).toContain("raw_status === 'no_patterns'")
expect(source).toContain('changes.map((c: any) => String(c.file))') expect(source).toContain('changes.map((c: any) => String(c.file))')
expect(source).toContain("verification_payload ? [{ command: 'worker verification'") expect(source).toContain("verification_payload ? [{ command: 'worker verification'")
expect(source).toContain('result: (payload.result as unknown) || payload') expect(source).toContain('result: (payload.result as unknown) || payload')

View File

@@ -36,7 +36,7 @@ export class CppToolRegistrar {
permissions: { read: true, write: false, network: false }, streaming: false permissions: { read: true, write: false, network: false }, streaming: false
}, async (call) => { }, async (call) => {
const result = detector.detect() const result = detector.detect()
return { call_id: call.id, tool_name: 'cpp.detect', type: 'text', content: result, metadata: { timestamp: new Date().toISOString() } } return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.detect', output: result, metadata: { timestamp: new Date().toISOString() } }
}) })
// cpp.configure // cpp.configure
@@ -47,7 +47,11 @@ export class CppToolRegistrar {
permissions: { read: true, write: true, network: false }, streaming: false permissions: { read: true, write: true, network: false }, streaming: false
}, async (call) => { }, async (call) => {
const result = configurator.configure({ project_root, generator: call.arguments?.generator as any, build_type: call.arguments?.build_type as any }) const result = configurator.configure({ project_root, generator: call.arguments?.generator as any, build_type: call.arguments?.build_type as any })
return { call_id: call.id, tool_name: 'cpp.configure', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } } if (result.ok) {
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.configure', output: result, metadata: { timestamp: new Date().toISOString() } }
} else {
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.configure', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.error || 'configure failed', retryability: 'not_retryable', semantic_signature: 'cpp.configure' }, metadata: { timestamp: new Date().toISOString() } }
}
}) })
// cpp.build // cpp.build
@@ -58,7 +62,11 @@ export class CppToolRegistrar {
permissions: { read: true, write: true, network: false }, streaming: false permissions: { read: true, write: true, network: false }, streaming: false
}, async (call) => { }, async (call) => {
const result = builder.build(project_root + '/build', call.arguments?.target as string) const result = builder.build(project_root + '/build', call.arguments?.target as string)
return { call_id: call.id, tool_name: 'cpp.build', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } } if (result.ok) {
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.build', output: { built: true, output: result.output, diagnostics: result.diagnostics, elapsed_ms: result.elapsed_ms }, metadata: { timestamp: new Date().toISOString() } }
} else {
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.build', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.output || 'build failed', retryability: 'not_retryable', semantic_signature: 'cpp.build' }, metadata: { timestamp: new Date().toISOString() } }
}
}) })
// cpp.test // cpp.test
@@ -69,7 +77,11 @@ export class CppToolRegistrar {
permissions: { read: true, write: false, network: false }, streaming: false permissions: { read: true, write: false, network: false }, streaming: false
}, async (call) => { }, async (call) => {
const result = tester.run_tests(project_root + '/build') const result = tester.run_tests(project_root + '/build')
return { call_id: call.id, tool_name: 'cpp.test', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } } if (result.ok) {
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.test', output: result, metadata: { timestamp: new Date().toISOString() } }
} else {
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.test', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.output || 'test failed', retryability: 'not_retryable', semantic_signature: 'cpp.test' }, metadata: { timestamp: new Date().toISOString() } }
}
}) })
// cpp.cppcheck // cpp.cppcheck
@@ -80,7 +92,11 @@ export class CppToolRegistrar {
permissions: { read: true, write: false, network: false }, streaming: false permissions: { read: true, write: false, network: false }, streaming: false
}, async (call) => { }, async (call) => {
const result = cppcheck.run(project_root, { enable_all: call.arguments?.enable_all as boolean, check_config: call.arguments?.check_config as boolean }) const result = cppcheck.run(project_root, { enable_all: call.arguments?.enable_all as boolean, check_config: call.arguments?.check_config as boolean })
return { call_id: call.id, tool_name: 'cpp.cppcheck', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } } if (result.ok) {
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.cppcheck', output: result, metadata: { timestamp: new Date().toISOString() } }
} else {
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.cppcheck', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.output || 'cppcheck failed', retryability: 'not_retryable', semantic_signature: 'cpp.cppcheck' }, metadata: { timestamp: new Date().toISOString() } }
}
}) })
// cpp.clangd // cpp.clangd
@@ -91,7 +107,11 @@ export class CppToolRegistrar {
permissions: { read: true, write: false, network: false }, streaming: false permissions: { read: true, write: false, network: false }, streaming: false
}, async (call) => { }, async (call) => {
const result = await clangd.query_symbol(call.arguments?.file as string, call.arguments?.line as number, call.arguments?.column as number) const result = await clangd.query_symbol(call.arguments?.file as string, call.arguments?.line as number, call.arguments?.column as number)
return { call_id: call.id, tool_name: 'cpp.clangd', type: 'text', content: result, metadata: { timestamp: new Date().toISOString() } } if (result.ok) {
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.clangd', output: result, metadata: { timestamp: new Date().toISOString() } }
} else {
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.clangd', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.error || 'clangd query failed', retryability: 'not_retryable', semantic_signature: 'cpp.clangd' }, metadata: { timestamp: new Date().toISOString() } }
}
}) })
} }
} }

View File

@@ -6,7 +6,8 @@
"main": "./src/index.ts", "main": "./src/index.ts",
"types": "./src/index.ts", "types": "./src/index.ts",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts",
"./preload": "./src/preload.ts"
}, },
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@@ -15,7 +16,9 @@
}, },
"dependencies": { "dependencies": {
"@aircoding/contracts": "workspace:*", "@aircoding/contracts": "workspace:*",
"@aircoding/runtime": "workspace:*" "@opentui/core": "0.3.0",
"@opentui/solid": "0.3.0",
"solid-js": "1.9.10"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.1", "@types/node": "^25.9.1",

View File

@@ -1,53 +1,28 @@
/** /**
* ProjectionClient - Local TUI-side projection consumer * ProjectionClient - Local TUI-side projection consumer.
* TUI copies minimal projection types from contracts to avoid INV-4 violation * TUI keeps a contracts-only copy of the ProjectionClient surface so it never imports runtime.
* (TUI must only depend on contracts; dd §13.2 / c4/code-view §2 rule 3).
*
* The runtime package provides the authoritative ProjectionClient in
* `runtime/projection/ProjectionClient.ts`. TUI defines its own local copy
* with the same surface so that subscriptions work in-process.
* *
* @module packages/tui/src/ProjectionClient * @module packages/tui/src/ProjectionClient
*/ */
import type { SessionID, ProjectID, TaskID, AgentID, ISOTimeString } from '@aircoding/contracts' import type { ProjectionSubscriber, SessionProjection } from './types.js'
export interface SessionProjection { export type {
session_id: SessionID SessionProjection,
project_id: ProjectID TaskProjection,
status: string AgentProjection,
title?: string ToolRunProjection,
tasks: TaskProjection[] CommandRunProjection,
agents: AgentProjection[] ArtifactProjection,
} PermissionPromptProjection,
BlockerProjection,
export interface TaskProjection { ProjectionSubscriber,
id: TaskID } from './types.js'
type: string
status: string
title: string
retry_count: number
attempts: number
created_at: string
}
export interface AgentProjection {
id: AgentID
type: string
status: string
task_id?: TaskID
last_heartbeat?: string
}
export type ProjectionSubscriber = (projection: SessionProjection) => void
export class ProjectionClient { export class ProjectionClient {
private snapshot: SessionProjection | null = null private snapshot: SessionProjection | null = null
private subscribers: Set<ProjectionSubscriber> = new Set() private subscribers: Set<ProjectionSubscriber> = new Set()
/**
* Receive and cache a projection snapshot.
*/
receive_snapshot(projection: SessionProjection): void { receive_snapshot(projection: SessionProjection): void {
this.snapshot = projection this.snapshot = projection
for (const sub of this.subscribers) { for (const sub of this.subscribers) {
@@ -55,17 +30,11 @@ export class ProjectionClient {
} }
} }
/**
* Subscribe to projection updates.
*/
subscribe(subscriber: ProjectionSubscriber): () => void { subscribe(subscriber: ProjectionSubscriber): () => void {
this.subscribers.add(subscriber) this.subscribers.add(subscriber)
return () => this.subscribers.delete(subscriber) return () => this.subscribers.delete(subscriber)
} }
/**
* Get current snapshot.
*/
get_snapshot(): SessionProjection | null { get_snapshot(): SessionProjection | null {
return this.snapshot return this.snapshot
} }

View File

@@ -1,311 +1,712 @@
/** @jsxImportSource @opentui/solid */
/** /**
* TuiApp - Main TUI application shell * TuiApp - OpenTUI/Solid application shell
* DD §13.2. Real terminal rendering using ANSI escape codes. * DD §13.2. Projection-only display plus single OpenTUI textarea input owner.
* *
* @module packages/tui/src/TuiApp * @module packages/tui/src/TuiApp
*/ */
import { SessionView } from './components/SessionView.js' import { createCliRenderer, type CliRenderer, type TextareaRenderable, type KeyEvent } from '@opentui/core'
import { TaskListView } from './components/TaskListView.js' import { render, useRenderer, useTerminalDimensions } from '@opentui/solid'
import { AgentStatusView } from './components/AgentStatusView.js' import { createEffect, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
import { HudView } from './components/HudView.js' import type { SessionProjection } from './types.js'
import type { SessionProjection } from './ProjectionClient.js'
export interface TuiAppProps { export interface TuiAppProps {
client: { client: {
subscribe(handler: (projection: SessionProjection) => void): () => void subscribe(handler: (projection: SessionProjection) => void): () => void
receive_snapshot(projection: SessionProjection): void
get_snapshot(): SessionProjection | null get_snapshot(): SessionProjection | null
} }
onSubmit?: (input: string) => void | Promise<void>
onSlashCommand?: (input: string) => void | Promise<void>
onResolvePermission?: (prompt_id: string, selected_option: string) => void | Promise<void>
onExit?: () => void | Promise<void>
} }
export interface TuiAppState { export interface TuiAppState {
projection: SessionProjection | null projection: SessionProjection | null
active_view: 'tasks' | 'agents' | 'tools' | 'diff' | 'help' active_view: 'tasks' | 'agents' | 'tools' | 'diff' | 'help'
busy: boolean
status: string
} }
const ANSI = { const THEME = {
reset: '\x1b[0m', bg: '#0b0f14',
bright: '\x1b[1m', surface: '#111827',
dim: '\x1b[2m', surface2: '#1f2937',
underscore: '\x1b[4m', text: '#e5e7eb',
blink: '\x1b[5m', muted: '#9ca3af',
reverse: '\x1b[7m', faint: '#6b7280',
hidden: '\x1b[8m', accent: '#22d3ee',
fg: { success: '#22c55e',
black: '\x1b[30m', warning: '#f59e0b',
red: '\x1b[31m', error: '#ef4444',
green: '\x1b[32m', border: '#374151',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
gray: '\x1b[90m',
},
bg: {
black: '\x1b[40m',
red: '\x1b[41m',
green: '\x1b[42m',
yellow: '\x1b[43m',
blue: '\x1b[44m',
magenta: '\x1b[45m',
cyan: '\x1b[46m',
white: '\x1b[47m',
},
clear: '\x1b[2J\x1b[H',
clearLine: '\x1b[2K',
cursor: {
home: '\x1b[H',
save: '\x1b[s',
restore: '\x1b[u',
hide: '\x1b[?25l',
show: '\x1b[?25h',
up: (n = 1) => `\x1b[${n}A`,
down: (n = 1) => `\x1b[${n}B`,
right: (n = 1) => `\x1b[${n}C`,
left: (n = 1) => `\x1b[${n}D`,
} }
const PROMPT_HISTORY_LIMIT = 200
const TEXTAREA_MIN_ROWS = 1
const TEXTAREA_MAX_ROWS = 6
const EXIT_CONFIRM_MS = 5000
type PromptHistoryState = {
items: string[]
index: number | null
draft: string
}
type PromptHistoryMove = {
state: PromptHistoryState
apply: boolean
text?: string
cursor?: number
}
function createPromptHistory(): PromptHistoryState {
return { items: [], index: null, draft: '' }
}
function pushPromptHistory(state: PromptHistoryState, prompt: string): PromptHistoryState {
const text = prompt.trim()
if (!text) return state
if (state.items[state.items.length - 1] === text) {
return { ...state, index: null, draft: '' }
}
return { items: [...state.items, text].slice(-PROMPT_HISTORY_LIMIT), index: null, draft: '' }
}
function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: string, cursor: number): PromptHistoryMove {
if (state.items.length === 0) return { state, apply: false }
if (dir === -1 && cursor !== 0) return { state, apply: false }
if (dir === 1 && cursor !== text.length) return { state, apply: false }
if (state.index === null) {
if (dir === 1) return { state, apply: false }
const idx = state.items.length - 1
return { state: { ...state, index: idx, draft: text }, text: state.items[idx], cursor: 0, apply: true }
}
const idx = state.index + dir
if (idx < 0) return { state, apply: false }
if (idx >= state.items.length) {
return { state: { ...state, index: null }, text: state.draft, cursor: state.draft.length, apply: true }
}
return { state: { ...state, index: idx }, text: state.items[idx], cursor: dir === -1 ? 0 : state.items[idx].length, apply: true }
} }
export class TuiApp { export class TuiApp {
private client: TuiAppProps['client'] private client: TuiAppProps['client']
private state: TuiAppState private onSubmit?: TuiAppProps['onSubmit']
private unsubscribe: (() => void) | null = null private onSlashCommand?: TuiAppProps['onSlashCommand']
private running: boolean = false private onResolvePermission?: TuiAppProps['onResolvePermission']
private onExit?: TuiAppProps['onExit']
private renderer: CliRenderer | null = null
private unsubscribeClient: (() => void) | null = null
private setProjection?: (projection: SessionProjection | null) => void
private setView?: (view: TuiAppState['active_view']) => void
private setBusy?: (busy: boolean) => void
private setStatus?: (status: string) => void
constructor(props: TuiAppProps) { constructor(props: TuiAppProps) {
this.client = props.client this.client = props.client
this.state = { projection: null, active_view: 'tasks' } this.onSubmit = props.onSubmit
this.onSlashCommand = props.onSlashCommand
this.onResolvePermission = props.onResolvePermission
this.onExit = props.onExit
} }
async start(): Promise<void> { async start(): Promise<void> {
this.unsubscribe = this.client.subscribe((projection) => { if (this.renderer) return
this.state.projection = projection
this.render() this.renderer = await createCliRenderer({
targetFps: 30,
maxFps: 60,
useMouse: false,
autoFocus: false,
exitOnCtrlC: false,
screenMode: 'alternate-screen',
externalOutputMode: 'capture-stdout',
consoleMode: 'disabled',
clearOnShutdown: true,
openConsoleOnError: false,
useKittyKeyboard: {},
backgroundColor: THEME.bg,
}) })
this.renderer.setBackgroundColor(THEME.bg)
const snapshot = this.client.get_snapshot() await render(() => (
if (snapshot) { <AirCodingView
this.state.projection = snapshot initialProjection={this.client.get_snapshot()}
} bindState={(bindings) => {
this.setProjection = bindings.setProjection
this.setView = bindings.setView
this.setBusy = bindings.setBusy
this.setStatus = bindings.setStatus
}}
onSubmit={(input) => this.submit(input)}
onSlashCommand={(input) => this.slash(input)}
onResolvePermission={(prompt_id, selected_option) => this.resolvePermission(prompt_id, selected_option)}
onExit={() => this.exit()}
/>
), this.renderer)
this.running = true this.unsubscribeClient = this.client.subscribe((projection) => {
this.setup_input() this.setProjection?.(projection)
this.render() })
} }
stop(): void { stop(): void {
this.running = false this.unsubscribeClient?.()
this.unsubscribe?.() this.unsubscribeClient = null
this.unsubscribe = null this.setProjection = undefined
process.stdout.write(ANSI.cursor.show + ANSI.reset) this.setView = undefined
this.setBusy = undefined
this.setStatus = undefined
if (this.renderer && !this.renderer.isDestroyed) {
this.renderer.setTerminalTitle('')
this.renderer.externalOutputMode = 'passthrough'
this.renderer.destroy()
}
this.renderer = null
} }
set_view(view: TuiAppState['active_view']): void { set_view(view: TuiAppState['active_view']): void {
this.state.active_view = view this.setView?.(view)
this.render()
} }
private setup_input(): void { set_busy(busy: boolean, status?: string): void {
if (process.stdin.isTTY) { this.setBusy?.(busy)
process.stdin.setRawMode(true) if (status) this.setStatus?.(status)
process.stdin.resume() }
process.stdin.setEncoding('utf8')
process.stdin.on('data', (key: string) => { set_status(status: string): void {
this.handle_input(key) this.setStatus?.(status)
}
private async submit(input: string): Promise<void> {
const text = input.trim()
if (!text) return
this.setBusy?.(true)
this.setStatus?.(`Running: ${text.slice(0, 72)}`)
try {
await this.onSubmit?.(text)
this.setStatus?.('Ready')
} catch (error) {
this.setStatus?.(error instanceof Error ? error.message : String(error))
throw error
} finally {
this.setBusy?.(false)
}
}
private async slash(input: string): Promise<void> {
const text = input.trim()
if (!text) return
if (text === '/quit' || text === '/exit') {
await this.exit()
return
}
this.setStatus?.(`Command: ${text}`)
await this.onSlashCommand?.(text)
}
private async resolvePermission(prompt_id: string, selected_option: string): Promise<void> {
if (!this.onResolvePermission) {
this.setStatus?.('Permission selection requires runtime resolver')
return
}
this.setStatus?.(`Permission: ${selected_option}`)
await this.onResolvePermission(prompt_id, selected_option)
}
private async exit(): Promise<void> {
await this.onExit?.()
}
}
type StateBindings = {
setProjection: (projection: SessionProjection | null) => void
setView: (view: TuiAppState['active_view']) => void
setBusy: (busy: boolean) => void
setStatus: (status: string) => void
}
type FooterPhase = 'idle' | 'running' | 'permission' | 'confirm_exit' | 'error'
function AirCodingView(props: {
initialProjection: SessionProjection | null
bindState: (bindings: StateBindings) => void
onSubmit: (input: string) => void | Promise<void>
onSlashCommand: (input: string) => void | Promise<void>
onResolvePermission: (prompt_id: string, selected_option: string) => void | Promise<void>
onExit: () => void | Promise<void>
}) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const [projection, setProjection] = createSignal<SessionProjection | null>(props.initialProjection)
const [activeView, setActiveView] = createSignal<TuiAppState['active_view']>('tasks')
const [busy, setBusy] = createSignal(false)
const [status, setStatus] = createSignal('Ready')
const [footerPhase, setFooterPhase] = createSignal<FooterPhase>('idle')
const [toast, setToast] = createSignal('')
let textarea: TextareaRenderable | undefined
let history = createPromptHistory()
let pasteTick: ReturnType<typeof setTimeout> | undefined
let exitConfirmUntil = 0
props.bindState({
setProjection,
setView: setActiveView,
setBusy: (next) => {
setBusy(next)
setFooterPhase(next ? 'running' : 'idle')
},
setStatus: (next) => {
setStatus(next)
if (/error|failed|blocked|失败|错误/i.test(next)) setFooterPhase('error')
else if (!busy()) setFooterPhase('idle')
},
}) })
}
const focusPrompt = () => {
if (textarea && !textarea.isDestroyed) textarea.focus()
} }
private handle_input(key: string): void { const submitPrompt = () => {
switch (key) { if (!textarea || textarea.isDestroyed || busy()) return
case 'q': const text = textarea.plainText.trim()
case '': // Ctrl+C if (!text) return
this.stop() history = pushPromptHistory(history, text)
process.exit(0) textarea.setText('')
break exitConfirmUntil = 0
case '1': setFooterPhase('running')
this.set_view('tasks') setStatus(text.startsWith('/') ? `Command: ${text}` : `Sending: ${text.slice(0, 72)}`)
break if (text.startsWith('/')) {
case '2': void props.onSlashCommand(text)
this.set_view('agents')
break
case '3':
this.set_view('tools')
break
case '4':
this.set_view('diff')
break
case '?':
case 'h':
this.set_view('help')
break
}
}
private render(): void {
if (!this.running) return
const p = this.state.projection
const lines: string[] = []
// Header
lines.push(ANSI.clear)
lines.push(ANSI.fg.cyan + ANSI.bright + '═══════════════════════════════════════════════════════════════' + ANSI.reset)
lines.push(ANSI.fg.cyan + ANSI.bright + ' AirCoding v1.0.0-alpha' + ANSI.reset + ANSI.fg.gray + ' │ ' + (p ? `${p.tasks.length} tasks` : 'No session') + ' │ ' + this.get_status_indicator(p) + ANSI.reset)
lines.push(ANSI.fg.cyan + '═══════════════════════════════════════════════════════════════' + ANSI.reset)
// Navigation hints
lines.push(ANSI.fg.gray + ' [1]Tasks [2]Agents [3]Tools [4]Diff [h]Help [q]Quit' + ANSI.reset)
// Content area
lines.push('')
if (this.state.active_view === 'help') {
lines.push(...this.render_help())
} else if (!p) {
lines.push(ANSI.fg.yellow + ' No active session. Run "air init" then "air run".' + ANSI.reset)
lines.push('')
lines.push(ANSI.fg.gray + ' Press q to exit.' + ANSI.reset)
} else { } else {
switch (this.state.active_view) { void props.onSubmit(text)
case 'tasks': }
lines.push(...this.render_tasks(p)) focusPrompt()
break }
case 'agents':
lines.push(...this.render_agents(p)) const refreshPasteLayout = () => {
break if (pasteTick) clearTimeout(pasteTick)
case 'tools': pasteTick = setTimeout(() => {
lines.push(...this.render_tools(p)) pasteTick = undefined
break if (!textarea || textarea.isDestroyed) return
case 'diff': textarea.getLayoutNode().markDirty()
lines.push(...this.render_diff(p)) renderer.requestRender()
break void renderer.idle().then(() => renderer.requestRender()).catch(() => {})
}, 0)
}
const applyHistoryMove = (dir: -1 | 1) => {
if (!textarea || textarea.isDestroyed) return false
const text = textarea.plainText
const move = movePromptHistory(history, dir, text, textarea.cursorOffset)
history = move.state
if (!move.apply) return false
textarea.setText(move.text ?? '')
textarea.cursorOffset = move.cursor ?? 0
textarea.getLayoutNode().markDirty()
renderer.requestRender()
return true
}
const activePermission = () => projection()?.permission_prompts[0]
const resolvePermissionByIndex = (index: number) => {
const prompt = activePermission()
if (!prompt) return false
const options = prompt.options.length > 0 ? prompt.options : ['allow', 'deny']
const selected = options[index]
if (!selected) return false
setFooterPhase('permission')
setStatus(`Permission: ${selected}`)
void props.onResolvePermission(prompt.prompt_id, selected)
return true
}
const handleKeyDown = (event: KeyEvent) => {
const prompt = activePermission()
if (prompt) {
if (event.name === 'left' || event.name === 'h') {
event.preventDefault()
setToast('Use 1/2/3 to choose a permission option')
return
}
if (/^[1-9]$/.test(event.name) && resolvePermissionByIndex(Number(event.name) - 1)) {
event.preventDefault()
return
}
if ((event.name === 'a' || event.name === 'y') && resolvePermissionByIndex(0)) {
event.preventDefault()
return
}
if ((event.name === 'd' || event.name === 'n') && resolvePermissionByIndex(Math.min(1, (prompt.options.length || 2) - 1))) {
event.preventDefault()
return
} }
} }
// Footer if (event.ctrl && event.name === 'c') {
lines.push('') event.preventDefault()
lines.push(ANSI.fg.gray + '─'.repeat(76) + ANSI.reset) if (textarea && !textarea.isDestroyed && textarea.plainText.length > 0) {
lines.push(ANSI.fg.gray + ' Status: ' + this.get_status_text(p) + ' │ Session: ' + (p?.session_id || 'N/A') + ANSI.reset) textarea.setText('')
history = { ...history, index: null, draft: '' }
process.stdout.write(lines.join('\n') + '\n') setFooterPhase('idle')
setStatus('Draft cleared; press Ctrl+C again to exit')
focusPrompt()
return
}
const now = Date.now()
if (now < exitConfirmUntil) {
void props.onExit()
return
}
exitConfirmUntil = now + EXIT_CONFIRM_MS
setFooterPhase('confirm_exit')
setStatus('Press Ctrl+C again within 5s to exit')
return
} }
private get_status_indicator(p: SessionProjection | null): string { if (event.name === 'up' && applyHistoryMove(-1)) {
if (!p) return ANSI.fg.gray + 'IDLE' + ANSI.reset event.preventDefault()
switch (p.status) { return
case 'running': return ANSI.fg.green + '● RUNNING' + ANSI.reset }
case 'completed': return ANSI.fg.blue + '● COMPLETED' + ANSI.reset
case 'error': return ANSI.fg.red + '● ERROR' + ANSI.reset if (event.name === 'down' && applyHistoryMove(1)) {
default: return ANSI.fg.gray + '● ' + p.status.toUpperCase() + ANSI.reset event.preventDefault()
return
}
if (event.name === 'escape') {
event.preventDefault()
exitConfirmUntil = 0
setActiveView('tasks')
setFooterPhase(busy() ? 'running' : activePermission() ? 'permission' : 'idle')
focusPrompt()
return
}
if (event.ctrl && event.name === 'l') {
event.preventDefault()
renderer.requestRender()
return
}
if (!event.ctrl || event.meta) return
const next = shortcutToView(event.name)
if (next) {
event.preventDefault()
setActiveView(next)
focusPrompt()
} }
} }
private get_status_text(p: SessionProjection | null): string { onMount(() => {
if (!p) return 'No session' renderer.setTerminalTitle('AirCoding')
return `${p.status} | ${p.tasks.length} tasks | ${p.agents.length} agents` focusPrompt()
})
onCleanup(() => {
if (pasteTick) clearTimeout(pasteTick)
renderer.setTerminalTitle('')
})
createEffect(() => {
const hasPermission = (projection()?.permission_prompts.length ?? 0) > 0
if (hasPermission) setFooterPhase('permission')
else if (!busy() && footerPhase() === 'permission') setFooterPhase('idle')
})
createEffect(() => {
projection()
activeView()
busy()
status()
footerPhase()
toast()
renderer.requestRender()
})
return (
<box width="100%" height="100%" flexDirection="column" backgroundColor={THEME.bg}>
<Header projection={projection()} />
<Nav active={activeView()} />
<box flexGrow={1} flexShrink={1} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<Content projection={projection()} active={activeView()} height={Math.max(8, dimensions().height - 11)} />
</box>
<Prompt
busy={busy()}
phase={footerPhase()}
status={status()}
toast={toast()}
permission={activePermission()}
textareaRef={(area) => { textarea = area }}
onSubmit={submitPrompt}
onKeyDown={handleKeyDown}
onPaste={refreshPasteLayout}
onContentChange={() => renderer.requestRender()}
/>
</box>
)
} }
private render_help(): string[] { function Header(props: { projection: SessionProjection | null }) {
return [ const taskCount = () => props.projection?.tasks.length ?? 0
ANSI.fg.cyan + ANSI.bright + ' Help' + ANSI.reset, return (
'', <box flexDirection="column" paddingLeft={2} paddingRight={2} paddingTop={1} backgroundColor={THEME.surface}>
' Keyboard shortcuts:', <box flexDirection="row" justifyContent="space-between">
' 1 - Tasks view Show task list and status', <text fg={THEME.accent}>AirCoding v1.0.0-alpha</text>
' 2 - Agents view Show agent status and activity', <text fg={statusColor(props.projection?.status)}>{(props.projection?.status ?? 'idle').toUpperCase()}</text>
' 3 - Tools view Show available tools and usage', </box>
' 4 - Diff view Show file changes', <text fg={THEME.muted}>{props.projection?.title ?? 'No active session'} · {taskCount()} tasks · {props.projection?.agents.length ?? 0} agents</text>
' h - Help Show this help', </box>
' q - Quit Exit AirCoding', )
'', }
' Getting started:',
' air init <project> Initialize a project', function Nav(props: { active: TuiAppState['active_view'] }) {
' air run Start coding session', const items: Array<[TuiAppState['active_view'], string]> = [
' air doctor Run diagnostics', ['tasks', 'Ctrl+1 Tasks'],
['agents', 'Ctrl+2 Agents'],
['tools', 'Ctrl+3 Tools'],
['diff', 'Ctrl+4 Diff'],
['help', 'Ctrl+H Help'],
] ]
return (
<box flexDirection="row" gap={1} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} backgroundColor={THEME.surface2}>
<For each={items}>{([view, label]) => (
<text fg={props.active === view ? THEME.accent : THEME.muted}>{label}</text>
)}</For>
</box>
)
} }
private render_tasks(p: SessionProjection): string[] { function Content(props: { projection: SessionProjection | null; active: TuiAppState['active_view']; height: number }) {
const lines: string[] = [] const currentProjection = () => props.projection
lines.push(ANSI.fg.cyan + ANSI.bright + ' Tasks' + ANSI.reset)
if (p.tasks.length === 0) { return (
lines.push(ANSI.fg.gray + ' No tasks yet.' + ANSI.reset) <Show when={currentProjection()} fallback={<EmptySession />}>
return lines <box flexDirection="column" gap={1} height={props.height}>
<Show when={props.active === 'tasks'}>
<TasksView projection={currentProjection()!} />
</Show>
<Show when={props.active === 'agents'}>
<AgentsView projection={currentProjection()!} />
</Show>
<Show when={props.active === 'tools'}>
<ToolsView projection={currentProjection()!} />
</Show>
<Show when={props.active === 'diff'}>
<DiffView projection={currentProjection()!} />
</Show>
<Show when={props.active === 'help'}>
<HelpView />
</Show>
</box>
</Show>
)
} }
for (const task of p.tasks.slice(0, 10)) { function EmptySession() {
const status_color = task.status === 'completed' ? ANSI.fg.green : task.status === 'failed' ? ANSI.fg.red : ANSI.fg.yellow return (
lines.push(` ${status_color}${ANSI.reset} ${task.title || task.id}`) <box flexDirection="column" gap={1}>
lines.push(ANSI.fg.gray + ` ID: ${task.id} | Status: ${task.status}` + ANSI.reset) <text fg={THEME.warning}>No active session projection.</text>
<text fg={THEME.muted}>Run air init and air run from a project directory.</text>
</box>
)
} }
if (p.tasks.length > 10) { function TasksView(props: { projection: SessionProjection }) {
lines.push(ANSI.fg.gray + ` ... and ${p.tasks.length - 10} more tasks` + ANSI.reset) return (
<box flexDirection="column" gap={1}>
<text fg={THEME.accent}>Tasks</text>
<Show when={props.projection.tasks.length > 0} fallback={<text fg={THEME.muted}>No tasks yet.</text>}>
<For each={props.projection.tasks.slice(0, 14)}>{(task) => (
<box flexDirection="column">
<text fg={statusColor(task.status)}>{statusMark(task.status)} {task.title || task.id}</text>
<text fg={THEME.faint}> {task.id} · {task.type} · {task.status} · attempts {task.attempts}</text>
</box>
)}</For>
</Show>
</box>
)
} }
return lines function AgentsView(props: { projection: SessionProjection }) {
return (
<box flexDirection="column" gap={1}>
<text fg={THEME.accent}>Agents</text>
<Show when={props.projection.agents.length > 0} fallback={<text fg={THEME.muted}>No agents yet.</text>}>
<For each={props.projection.agents.slice(0, 14)}>{(agent) => (
<box flexDirection="column">
<text fg={statusColor(agent.status)}>{statusMark(agent.status)} {agent.type}</text>
<text fg={THEME.faint}> {agent.id} · {agent.status}{agent.task_id ? ` · task ${agent.task_id}` : ''}</text>
</box>
)}</For>
</Show>
</box>
)
} }
private render_agents(p: SessionProjection): string[] { function ToolsView(props: { projection: SessionProjection }) {
const lines: string[] = [] const p = () => props.projection as SessionProjection & { tool_runs?: Array<{ tool_run_id: string; tool_name: string; status: string; duration_ms?: number }> }
lines.push(ANSI.fg.cyan + ANSI.bright + ' Agents' + ANSI.reset) return (
<box flexDirection="column" gap={1}>
if (p.agents.length === 0) { <text fg={THEME.accent}>Tool runs</text>
lines.push(ANSI.fg.gray + ' No active agents.' + ANSI.reset) <Show when={(p().tool_runs ?? []).length > 0} fallback={<text fg={THEME.muted}>No tool runs yet.</text>}>
return lines <For each={(p().tool_runs ?? []).slice(-14).reverse()}>{(tool) => (
<box flexDirection="row" gap={1}>
<text fg={statusColor(tool.status)}>{statusMark(tool.status)}</text>
<text fg={THEME.text}>{tool.tool_name}</text>
<text fg={THEME.faint}>{tool.status}{tool.duration_ms ? ` · ${tool.duration_ms}ms` : ''}</text>
</box>
)}</For>
</Show>
</box>
)
} }
for (const agent of p.agents.slice(0, 10)) { function DiffView(props: { projection: SessionProjection }) {
const status_color = agent.status === 'running' ? ANSI.fg.green : agent.status === 'idle' ? ANSI.fg.gray : ANSI.fg.yellow const completed = () => props.projection.tasks.filter((task) => task.status === 'completed').length
lines.push(` ${status_color}${ANSI.reset} ${agent.type}`) const failed = () => props.projection.tasks.filter((task) => task.status === 'failed').length
lines.push(ANSI.fg.gray + ` ID: ${agent.id?.slice(0, 8)}... | Status: ${agent.status}` + ANSI.reset) const blocked = () => props.projection.tasks.filter((task) => task.status === 'blocked').length
return (
<box flexDirection="column" gap={1}>
<text fg={THEME.accent}>Session summary</text>
<text fg={THEME.success}>Completed: {completed()}</text>
<text fg={THEME.error}>Failed: {failed()}</text>
<text fg={THEME.warning}>Blocked: {blocked()}</text>
<text fg={THEME.muted}>Use /results for produced files. Projection data is sourced from runtime events.</text>
</box>
)
} }
return lines function HelpView() {
return (
<box flexDirection="column" gap={1}>
<text fg={THEME.accent}>Help</text>
<text fg={THEME.text}>Type a task in the prompt and press Enter.</text>
<text fg={THEME.text}>Slash commands: /help, /status, /tools, /tasks, /results, /quit.</text>
<text fg={THEME.text}>Navigation: Ctrl+1 tasks, Ctrl+2 agents, Ctrl+3 tools, Ctrl+4 diff, Ctrl+H help, Esc tasks.</text>
<text fg={THEME.text}>Prompt: Up/Down browse history at text boundaries; paste refreshes layout automatically.</text>
<text fg={THEME.muted}>Ctrl+C clears draft first, then asks for a second Ctrl+C within 5s to exit. Permission prompts use 1/2/3 or a/d.</text>
</box>
)
} }
private render_tools(p: SessionProjection): string[] { function Prompt(props: {
const lines: string[] = [] busy: boolean
lines.push(ANSI.fg.cyan + ANSI.bright + ' Recent Tool Calls' + ANSI.reset) phase: FooterPhase
status: string
const recent_calls = p.tasks.slice(0, 10) toast: string
permission?: SessionProjection['permission_prompts'][number]
if (recent_calls.length === 0) { textareaRef: (area?: TextareaRenderable) => void
lines.push(ANSI.fg.gray + ' No tool calls yet.' + ANSI.reset) onSubmit: () => void
return lines onKeyDown: (event: KeyEvent) => void
onPaste: () => void
onContentChange: () => void
}) {
const permissionOptions = () => props.permission?.options.length ? props.permission.options : ['allow', 'deny']
const phaseLabel = () => {
if (props.permission) return 'Permission'
if (props.phase === 'confirm_exit') return 'Confirm exit'
if (props.phase === 'error') return 'Attention'
return props.busy ? 'Running' : 'Ready'
}
const phaseColor = () => {
if (props.permission || props.phase === 'confirm_exit') return THEME.warning
if (props.phase === 'error') return THEME.error
return props.busy ? THEME.warning : THEME.success
} }
for (const call of recent_calls) { return (
lines.push(` ${ANSI.fg.green}${ANSI.reset} ${call.title || call.type}`) <box flexDirection="column" paddingLeft={2} paddingRight={2} paddingBottom={1} backgroundColor={THEME.surface}>
lines.push(ANSI.fg.gray + ` Task: ${call.id?.slice(0, 8)}... | Status: ${call.status}` + ANSI.reset) <Show when={props.permission}>
<box flexDirection="column" paddingBottom={1}>
<text fg={THEME.warning}>Permission required: {props.permission?.subject || props.permission?.tool_name || props.permission?.prompt_id}</text>
<text fg={THEME.muted}>Risk: {props.permission?.risk_level || 'unknown'} · {props.permission?.reason || 'No reason provided'}</text>
<box flexDirection="row" gap={1}>
<For each={permissionOptions()}>{(option, index) => (
<text fg={index() === 0 ? THEME.success : THEME.warning}>{index() + 1}. {option}</text>
)}</For>
</box>
</box>
</Show>
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
<text fg={phaseColor()}>{phaseLabel()}</text>
<text fg={props.phase === 'error' ? THEME.error : THEME.muted}>{props.toast || props.status}</text>
</box>
<textarea
width="100%"
minHeight={TEXTAREA_MIN_ROWS}
maxHeight={TEXTAREA_MAX_ROWS}
wrapMode="word"
placeholder={props.busy ? 'Task is running...' : 'Ask AirCoding to change this project, or type /help'}
placeholderColor={THEME.faint}
textColor={THEME.text}
focusedTextColor={THEME.text}
backgroundColor={THEME.bg}
focusedBackgroundColor={THEME.bg}
cursorColor={THEME.accent}
focused={!props.busy}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
onPaste={props.onPaste}
onContentChange={props.onContentChange}
ref={props.textareaRef}
/>
</box>
)
} }
return lines function shortcutToView(name: string): TuiAppState['active_view'] | undefined {
} switch (name) {
case '1': return 'tasks'
private render_diff(p: SessionProjection): string[] { case '2': return 'agents'
const lines: string[] = [] case '3': return 'tools'
lines.push(ANSI.fg.cyan + ANSI.bright + ' Recent Changes' + ANSI.reset) case '4': return 'diff'
case 'h': return 'help'
const task_count = p.tasks.length default: return undefined
const completed = p.tasks.filter(t => t.status === 'completed').length }
const failed = p.tasks.filter(t => t.status === 'failed').length }
if (task_count === 0) { function statusColor(status: string | undefined): string {
lines.push(ANSI.fg.gray + ' No changes yet.' + ANSI.reset) switch (status) {
return lines case 'completed':
} case 'ok':
case 'active':
lines.push(` ${ANSI.fg.green}${ANSI.reset} Completed: ${completed}`) case 'running':
lines.push(` ${ANSI.fg.red}${ANSI.reset} Failed: ${failed}`) return THEME.success
lines.push(` ${ANSI.fg.yellow}${ANSI.reset} Pending: ${task_count - completed - failed}`) case 'failed':
lines.push('') case 'error':
lines.push(ANSI.fg.gray + ` Total tasks: ${task_count}` + ANSI.reset) case 'lost':
return THEME.error
return lines case 'blocked':
case 'pending':
case 'cancelled':
return THEME.warning
default:
return THEME.muted
}
}
function statusMark(status: string | undefined): string {
switch (status) {
case 'completed':
case 'ok':
return '✓'
case 'failed':
case 'error':
return '✗'
case 'running':
case 'active':
return '●'
case 'blocked':
return '!'
default:
return '○'
} }
} }

View File

@@ -1,7 +1,7 @@
/** /**
* TUI package — Terminal UI components * TUI package — Terminal UI components
* *
* INV-4: TUI imports ONLY contracts + ProjectionClient from runtime. * INV-4: TUI imports no runtime package; it consumes projection snapshots through a local client surface.
* Uses OpenTUI @opentui/* as renderer (npm-dep, do NOT reimplement). * Uses OpenTUI @opentui/* as renderer (npm-dep, do NOT reimplement).
* *
* @module packages/tui * @module packages/tui

4
packages/tui/src/preload.ts Executable file
View File

@@ -0,0 +1,4 @@
const openTuiPreload = '@opentui/solid/preload'
await import(openTuiPreload)
export {}

View File

@@ -1,11 +1,17 @@
/** /**
* TUI shared types * TUI shared projection types.
* TUI imports ONLY contracts. No runtime imports. * TUI stays runtime-free and consumes projection snapshots only.
* *
* @module packages/tui/src/types * @module packages/tui/src/types
*/ */
import type { SessionID, ProjectID, TaskID, AgentID, ToolRunID, ISOTimeString } from '@aircoding/contracts' type SessionID = string
type ProjectID = string
type TaskID = string
type AgentID = string
type ToolRunID = string
type CommandRunID = string
type ArtifactID = string
export interface SessionProjection { export interface SessionProjection {
session_id: SessionID session_id: SessionID
@@ -14,6 +20,12 @@ export interface SessionProjection {
title?: string title?: string
tasks: TaskProjection[] tasks: TaskProjection[]
agents: AgentProjection[] agents: AgentProjection[]
tool_runs: ToolRunProjection[]
command_runs: CommandRunProjection[]
artifacts: ArtifactProjection[]
permission_prompts: PermissionPromptProjection[]
blockers: BlockerProjection[]
updated_at: string
} }
export interface TaskProjection { export interface TaskProjection {
@@ -24,6 +36,7 @@ export interface TaskProjection {
retry_count: number retry_count: number
attempts: number attempts: number
created_at: string created_at: string
agent_id?: AgentID
} }
export interface AgentProjection { export interface AgentProjection {
@@ -34,4 +47,40 @@ export interface AgentProjection {
last_heartbeat?: string last_heartbeat?: string
} }
export interface ToolRunProjection {
tool_run_id: ToolRunID
tool_name: string
status: string
duration_ms?: number
}
export interface CommandRunProjection {
command_run_id: CommandRunID
command: string
status: string
exit_code?: number
}
export interface ArtifactProjection {
artifact_id: ArtifactID
type: string
uri: string
}
export interface PermissionPromptProjection {
prompt_id: string
subject: string
risk_level: string
reason: string
options: string[]
default_option?: string
tool_name?: string
}
export interface BlockerProjection {
task_id: TaskID
reason: string
blocker_kind: string
}
export type ProjectionSubscriber = (projection: SessionProjection) => void export type ProjectionSubscriber = (projection: SessionProjection) => void

View File

@@ -35,7 +35,7 @@ export interface LLMRequest {
export interface LLMResponse { export interface LLMResponse {
content: string content: string
usage?: { input_tokens: number; output_tokens: number } usage?: { input_tokens: number; output_tokens: number }
tool_calls?: Array<{ name: string; arguments: Record<string, unknown> }> tool_calls?: Array<{ id?: string; name: string; arguments: Record<string, unknown> }>
} }
export class WorkerRuntime { export class WorkerRuntime {
@@ -111,7 +111,7 @@ export class WorkerRuntime {
* Emit an event to the parent. * Emit an event to the parent.
*/ */
emit(type: string, payload: Record<string, unknown>): void { emit(type: string, payload: Record<string, unknown>): void {
this.send_message('event', { type, ...payload }) this.send_message('event', { event_type: type, ...payload })
} }
/** /**

View File

@@ -8,6 +8,11 @@
import { WorkerRuntime } from '../WorkerRuntime.js' import { WorkerRuntime } from '../WorkerRuntime.js'
const SUMMARY_PREFIX = `This is a compacted summary of earlier context. Treat it as reference only.
The latest user message and any newer runtime events after this summary are the source of truth.
If this summary conflicts with newer instructions, follow the newer instructions.
Preserve active tasks, unresolved questions, architectural constraints, verification status, and remaining work.`
export interface CompactorResult { export interface CompactorResult {
status: 'compacted' | 'skipped' | 'blocked' status: 'compacted' | 'skipped' | 'blocked'
summary_content: string summary_content: string
@@ -22,7 +27,15 @@ export class CompactorRole {
this.runtime = runtime this.runtime = runtime
} }
async run(compact_spec: { task_id: string; current_tokens: number; threshold: number }): Promise<CompactorResult> { async run(compact_spec: {
task_id?: string
current_tokens?: number
threshold?: number
target_budget_tokens?: number
range_start_message_id?: string
range_end_message_id?: string
source_content?: string
}): Promise<CompactorResult> {
const result: CompactorResult = { const result: CompactorResult = {
status: 'skipped', status: 'skipped',
summary_content: '', summary_content: '',
@@ -30,48 +43,105 @@ export class CompactorRole {
compacted_layers: [] compacted_layers: []
} }
try { const task_id = compact_spec.task_id || 'compact_task'
this.runtime.emit('compaction.started', { task_id: compact_spec.task_id }) const current_tokens = compact_spec.current_tokens ?? 0
const threshold = compact_spec.threshold ?? compact_spec.target_budget_tokens ?? 80000
const range_start_message_id = compact_spec.range_start_message_id || ''
const range_end_message_id = compact_spec.range_end_message_id || ''
// Check if compaction is needed try {
if (compact_spec.current_tokens < compact_spec.threshold) { this.runtime.emit('context.compaction.started', {
result.status = 'skipped' event_id: `evt_compaction_started_${crypto.randomUUID()}`,
result.summary_content = `Tokens (${compact_spec.current_tokens}) below threshold (${compact_spec.threshold}) — no compaction needed` task_id,
agent_id: process.env.AIRCODING_AGENT_ID || 'compactor',
range_start_message_id,
range_end_message_id,
})
if (current_tokens > 0 && current_tokens < threshold) {
result.summary_content = `Tokens (${current_tokens}) below threshold (${threshold}); no compaction needed.`
return result return result
} }
// Use LLM to generate summary of the conversation const token_estimate_before = current_tokens || threshold
const tokens_to_free = compact_spec.current_tokens - Math.floor(compact_spec.threshold * 0.6) const target_after = Math.max(1, Math.floor(threshold * 0.6))
const compaction_messages = [ const source_content = compact_spec.source_content || `Current token estimate: ${token_estimate_before}; target budget: ${threshold}.`
{ role: 'system', content: 'Summarize the key facts, decisions, and code changes from the conversation history. Keep it concise but complete. Include file paths, function names, and architectural decisions.' },
{ role: 'user', content: `Compaction requested: ${compact_spec.current_tokens} tokens in context, threshold is ${compact_spec.threshold}. Generate a compact summary to free approximately ${tokens_to_free} tokens.` }
]
try { const summary = await this.build_summary(source_content, token_estimate_before, threshold)
const summary = await this.runtime.call_llm({ const summary_id = `summary_${crypto.randomUUID()}`
messages: compaction_messages, const token_estimate_after = Math.min(target_after, Math.max(1, Math.floor(summary.length / 4)))
max_tokens: 2048,
temperature: 0.2 result.summary_content = summary
result.tokens_freed = Math.max(0, token_estimate_before - token_estimate_after)
result.compacted_layers = ['conversation', 'tool_output', 'images']
result.status = 'compacted'
this.runtime.emit('summary.created', {
event_id: `evt_${summary_id}`,
summary_id,
type: 'compaction',
range_start_message_id,
range_end_message_id,
content_json: {
prefix: SUMMARY_PREFIX,
summary,
active_task: task_id,
remaining_work: [],
resolved_questions: [],
pending_questions: [],
},
metadata: {
token_estimate_before,
token_estimate_after,
compacted_layers: result.compacted_layers,
},
}) })
result.summary_content = summary.content || '# Compaction Summary\n\nContext has been compacted to reduce token usage.' this.runtime.emit('context.compaction.completed', {
result.tokens_freed = tokens_to_free event_id: `evt_compaction_completed_${crypto.randomUUID()}`,
result.compacted_layers = ['conversation', 'tool_output'] task_id,
result.status = 'compacted' agent_id: process.env.AIRCODING_AGENT_ID || 'compactor',
} catch { summary_id,
result.summary_content = '# Compaction Summary\n\nSummary generation failed — using basic compaction.' range_start_message_id,
result.tokens_freed = compact_spec.current_tokens - Math.floor(compact_spec.current_tokens * 0.6) range_end_message_id,
result.compacted_layers = ['conversation'] token_estimate_before,
result.status = 'compacted' token_estimate_after,
} })
this.runtime.checkpoint('compaction_completed', { task_id: compact_spec.task_id }) this.runtime.checkpoint('compaction_completed', { task_id, summary_id, tokens_freed: result.tokens_freed })
return result return result
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error)
result.status = 'blocked' result.status = 'blocked'
result.summary_content = error instanceof Error ? error.message : String(error) result.summary_content = message
this.runtime.emit('context.compaction.failed', {
event_id: `evt_compaction_failed_${crypto.randomUUID()}`,
task_id,
agent_id: process.env.AIRCODING_AGENT_ID || 'compactor',
range_start_message_id,
range_end_message_id,
error: { message },
evidence_refs: [],
metadata: {},
})
return result return result
} }
} }
private async build_summary(source_content: string, current_tokens: number, threshold: number): Promise<string> {
try {
const response = await this.runtime.call_llm({
messages: [
{ role: 'system', content: `${SUMMARY_PREFIX}\n\nReturn a structured summary with sections: Active task, Key facts, Decisions, Changed files, Verification, Remaining work, Pending questions.` },
{ role: 'user', content: `Compact this context from ~${current_tokens} tokens toward ${threshold}.\n\n${source_content}` },
],
max_tokens: 2048,
temperature: 0.2,
})
return `${SUMMARY_PREFIX}\n\n${(response.content || '').trim() || 'No detailed summary was produced.'}`
} catch {
return `${SUMMARY_PREFIX}\n\nActive task: context compaction.\nKey facts: source context was too large or summarizer was unavailable.\nRemaining work: rehydrate from durable events and latest user message before continuing.`
}
}
} }

View File

@@ -8,6 +8,38 @@
import { WorkerRuntime } from '../WorkerRuntime.js' import { WorkerRuntime } from '../WorkerRuntime.js'
type FailoverReason =
| 'auth'
| 'auth_permanent'
| 'billing'
| 'rate_limit'
| 'overloaded'
| 'server_error'
| 'timeout'
| 'context_overflow'
| 'payload_too_large'
| 'image_too_large'
| 'model_not_found'
| 'provider_policy_blocked'
| 'content_policy_blocked'
| 'format_error'
| 'invalid_encrypted_content'
| 'multimodal_tool_content_unsupported'
| 'thinking_signature'
| 'long_context_tier'
| 'oauth_long_context_beta_forbidden'
| 'llama_cpp_grammar_pattern'
| 'unknown'
interface ClassifiedError {
reason: FailoverReason
message: string
retryable: boolean
should_compress: boolean
should_rotate_credential: boolean
should_fallback: boolean
}
export interface DebuggerResult { export interface DebuggerResult {
status: 'fixed' | 'cannot_reproduce' | 'blocked' | 'escalated' status: 'fixed' | 'cannot_reproduce' | 'blocked' | 'escalated'
root_cause: string root_cause: string
@@ -23,7 +55,7 @@ export class DebuggerRole {
this.runtime = runtime this.runtime = runtime
} }
async run(debug_spec: { task_id: string; error_report: string; affected_files: string[] }): Promise<DebuggerResult> { async run(debug_spec: { task_id?: string; error_report?: string; affected_files?: string[]; verification_refs?: string[] }): Promise<DebuggerResult> {
const result: DebuggerResult = { const result: DebuggerResult = {
status: 'cannot_reproduce', status: 'cannot_reproduce',
root_cause: '', root_cause: '',
@@ -32,54 +64,56 @@ export class DebuggerRole {
} }
try { try {
this.runtime.emit('debug.started', { task_id: debug_spec.task_id }) const task_id = debug_spec.task_id || 'unknown_task'
const error_report = debug_spec.error_report || ''
const affected_files = debug_spec.affected_files ?? []
const classified = this.classify_error(error_report)
// Step 1: Gather evidence — read affected files result.diagnostic_chain.push(`1. Classified failure as ${classified.reason}`)
result.diagnostic_chain.push('1. Gathering evidence from affected files') result.diagnostic_chain.push(` retryable=${classified.retryable} compress=${classified.should_compress} rotate_credential=${classified.should_rotate_credential} fallback=${classified.should_fallback}`)
for (const file of debug_spec.affected_files) {
result.diagnostic_chain.push('2. Gathering evidence from affected files')
for (const file of affected_files) {
try { try {
await this.runtime.call_tool('fs.read', { path: file }) const read = await this.runtime.call_tool('fs.read', { path: file })
if (read.type === 'error') {
result.diagnostic_chain.push(` Failed to read: ${file}`)
} else {
result.evidence_refs.push(`file:${file}`) result.evidence_refs.push(`file:${file}`)
}
} catch { } catch {
result.diagnostic_chain.push(` Failed to read: ${file}`) result.diagnostic_chain.push(` Failed to read: ${file}`)
} }
} }
// Step 2: Analyze error signatures using LLM result.diagnostic_chain.push('3. Analyzing root cause')
result.diagnostic_chain.push('2. Analyzing error signatures')
const messages = [
{ role: 'system', content: 'You are a debugging expert. Analyze the error report and suggest a fix.' },
{ role: 'user', content: `Error report:\n${debug_spec.error_report}\n\nAffected files: ${debug_spec.affected_files.join(', ')}\n\nDiagnose the root cause and propose a fix. Be specific about which file and what change.` }
]
try { try {
const analysis = await this.runtime.call_llm({ messages, max_tokens: 2048, temperature: 0.3 }) const analysis = await this.runtime.call_llm({
result.root_cause = analysis.content || 'Unable to determine root cause' messages: [
result.diagnostic_chain.push(` Analysis: ${result.root_cause.slice(0, 100)}...`) { role: 'system', content: 'You are a diagnostic agent. Identify the likely root cause and recovery path. Do not claim a fix was applied unless a tool edit actually succeeded.' },
{ role: 'user', content: `Classified error: ${JSON.stringify(classified)}\n\nError report:\n${error_report}\n\nAffected files: ${affected_files.join(', ') || '(none)'}` },
],
max_tokens: 2048,
temperature: 0.2,
})
result.root_cause = (analysis.content || '').trim() || this.default_root_cause(classified)
} catch { } catch {
result.root_cause = 'LLM analysis unavailable — manual diagnosis required' result.root_cause = this.default_root_cause(classified)
} }
// Step 3: Attempt fix result.status = this.status_for(classified)
result.diagnostic_chain.push('3. Attempting fix') const debug_record_id = `debug_${crypto.randomUUID()}`
if (result.root_cause.includes('fix:') || result.root_cause.includes('change:') || result.root_cause.includes('Fix:')) { this.runtime.emit('debug.record.created', {
const fix_match = result.root_cause.match(/fix:\s*([^\n]+)/i) || result.root_cause.match(/change:\s*([^\n]+)/i) event_id: `evt_${debug_record_id}`,
if (fix_match && debug_spec.affected_files.length > 0) { debug_record_id,
result.fix_applied = { file: debug_spec.affected_files[0], change: fix_match[1] } task_id,
result.status = 'fixed' failure_signature: classified.reason,
result.diagnostic_chain.push(' Fix applied to ' + debug_spec.affected_files[0]) summary: result.root_cause.slice(0, 1000),
} evidence_refs: result.evidence_refs,
} verification_refs: debug_spec.verification_refs ?? [],
})
// Step 4: Verify fix this.runtime.checkpoint('debug_completed', { task_id, reason: classified.reason, status: result.status })
if (result.status === 'fixed') {
result.diagnostic_chain.push('4. Verification')
try {
await this.runtime.call_tool('shell.run', { command: 'echo "Verification passed — fix applied"', timeout: 30000 })
} catch { /* verification skipped */ }
}
this.runtime.checkpoint('debug_completed', { task_id: debug_spec.task_id })
return result return result
} catch (error) { } catch (error) {
@@ -88,4 +122,53 @@ export class DebuggerRole {
return result return result
} }
} }
private classify_error(report: string): ClassifiedError {
const text = report.toLowerCase()
const reason: FailoverReason = this.reason_for(text)
return {
reason,
message: report,
retryable: !['auth_permanent', 'billing', 'model_not_found', 'provider_policy_blocked', 'content_policy_blocked', 'format_error', 'invalid_encrypted_content'].includes(reason),
should_compress: reason === 'context_overflow' || reason === 'payload_too_large' || reason === 'image_too_large',
should_rotate_credential: reason === 'auth' || reason === 'auth_permanent',
should_fallback: ['rate_limit', 'overloaded', 'server_error', 'timeout', 'model_not_found', 'long_context_tier', 'oauth_long_context_beta_forbidden'].includes(reason),
}
}
private reason_for(text: string): FailoverReason {
if (/context|token|maximum context|too many tokens|context_length/.test(text)) return 'context_overflow'
if (/payload too large|request too large|413/.test(text)) return 'payload_too_large'
if (/image.*too large|vision.*size/.test(text)) return 'image_too_large'
if (/rate limit|too many requests|429/.test(text)) return 'rate_limit'
if (/overloaded|capacity|529/.test(text)) return 'overloaded'
if (/timeout|timed out|etimedout|504/.test(text)) return 'timeout'
if (/500|502|503|server error|bad gateway|service unavailable/.test(text)) return 'server_error'
if (/invalid api key|unauthorized|401|forbidden|403|auth/.test(text)) return /invalid api key|revoked|expired/.test(text) ? 'auth_permanent' : 'auth'
if (/billing|quota|insufficient credits|payment/.test(text)) return 'billing'
if (/model.*not found|unknown model|404/.test(text)) return 'model_not_found'
if (/policy|safety|blocked by provider/.test(text)) return 'provider_policy_blocked'
if (/content policy|unsafe content/.test(text)) return 'content_policy_blocked'
if (/json|schema|format|parse/.test(text)) return 'format_error'
if (/encrypted content/.test(text)) return 'invalid_encrypted_content'
if (/multimodal.*tool|tool.*image/.test(text)) return 'multimodal_tool_content_unsupported'
if (/thinking.*signature|signature mismatch/.test(text)) return 'thinking_signature'
if (/long context/.test(text)) return 'long_context_tier'
if (/oauth.*long context|beta.*forbidden/.test(text)) return 'oauth_long_context_beta_forbidden'
if (/grammar|llama.cpp|llama_cpp/.test(text)) return 'llama_cpp_grammar_pattern'
return 'unknown'
}
private default_root_cause(classified: ClassifiedError): string {
if (classified.should_compress) return `Likely ${classified.reason}; compress context or reduce payload before retry.`
if (classified.should_rotate_credential) return `Likely ${classified.reason}; credential or authorization requires attention before retry.`
if (classified.should_fallback) return `Likely ${classified.reason}; retry with backoff or fallback provider/model.`
return `Failure classified as ${classified.reason}; manual diagnosis required.`
}
private status_for(classified: ClassifiedError): DebuggerResult['status'] {
if (classified.should_rotate_credential || classified.reason === 'billing' || classified.reason === 'content_policy_blocked') return 'escalated'
if (classified.retryable || classified.should_compress || classified.should_fallback) return 'cannot_reproduce'
return 'blocked'
}
} }

View File

@@ -16,6 +16,11 @@ export interface ExecutorResult {
evidence_refs?: string[] evidence_refs?: string[]
} }
type ExecutorAction =
| { type: 'text' }
| { type: 'code_block'; filename: string; content: string }
| { type: 'tool_call'; id: string; name: string; args: Record<string, unknown> }
export class ExecutorRole { export class ExecutorRole {
private runtime: WorkerRuntime private runtime: WorkerRuntime
private max_turns: number = 15 private max_turns: number = 15
@@ -25,7 +30,7 @@ export class ExecutorRole {
} }
async run(task_spec: { id: string; title: string; description: string; acceptance_criteria: string[] }): Promise<ExecutorResult> { async run(task_spec: { id: string; title: string; description: string; acceptance_criteria: string[] }): Promise<ExecutorResult> {
this.runtime.emit('task.attempt.started', { task_id: task_spec.id }) this.runtime.checkpoint('task_attempt_started', { task_id: task_spec.id })
const model = (task_spec as any).model || process.env.AIRCODING_MODEL || 'glm-5.1' const model = (task_spec as any).model || process.env.AIRCODING_MODEL || 'glm-5.1'
const projectRoot = process.env.AIRCODING_PROJECT_ROOT || '.' const projectRoot = process.env.AIRCODING_PROJECT_ROOT || '.'
@@ -36,25 +41,23 @@ export class ExecutorRole {
role: 'system', role: 'system',
content: `You are an AI coding assistant. Complete coding tasks by writing code files. content: `You are an AI coding assistant. Complete coding tasks by writing code files.
You can write files by outputting code blocks with a language tag that includes the filename: Use structured tool calls whenever possible. Available tools include:
- fs.read, fs.write, fs.edit, fs.list — filesystem operations
- shell.run — shell command execution
- cpp.detect, cpp.configure, cpp.build, cpp.test, cpp.cppcheck, cpp.clangd — C++ toolchain
If native tools are unavailable, output strict JSON tool calls only in this form:
\`\`\`json
{"tool":"fs.write","args":{"path":"src/main.cpp","content":"..."}}
\`\`\`
You may write new files by outputting code blocks with a language tag that includes the filename:
\`\`\`cpp:src/main.cpp \`\`\`cpp:src/main.cpp
// C++ code here // C++ code here
\`\`\` \`\`\`
\`\`\`cmake:CMakeLists.txt After all required files are written and required verification has passed, write a line containing exactly: DONE`
# CMake code here
\`\`\`
Or any language: python, javascript, txt, etc.
The filename goes after the language tag, separated by colon.
You can also call tools directly:
fs.read("path/to/file") — read a file
fs.write("path/to/file", "content") — write a file
shell.run("command") — run a shell command
fs.list("dir") — list a directory
After completing ALL required files, write: DONE`
}, },
{ {
role: 'user', role: 'user',
@@ -81,8 +84,8 @@ After completing ALL required files, write: DONE`
.replace(/<\|assistant\|>/g, '') .replace(/<\|assistant\|>/g, '')
.trim() .trim()
// Parse ALL actions from the response // Parse structured actions from native tool_calls first, then strict JSON/code-block fallback
const actions = this.parse_actions(text) const actions = this.parse_actions(text, llm_response.tool_calls || [])
// Debug // Debug
const actionSummary = actions.map(a => { const actionSummary = actions.map(a => {
@@ -97,7 +100,7 @@ After completing ALL required files, write: DONE`
let allSucceeded = true let allSucceeded = true
if (hadActions) { if (hadActions) {
messages.push({ role: 'assistant', content: text }) messages.push({ role: 'assistant', content: this.assistant_content_for_actions(text, actions) })
for (const action of actions) { for (const action of actions) {
if (action.type === 'code_block') { if (action.type === 'code_block') {
@@ -116,7 +119,7 @@ After completing ALL required files, write: DONE`
messages.push({ role: 'user', content: `Error writing ${filename}: ${e.message}` }) messages.push({ role: 'user', content: `Error writing ${filename}: ${e.message}` })
} }
} else if (action.type === 'tool_call') { } else if (action.type === 'tool_call') {
const { name, args } = action as { name: string; args: Record<string, unknown> } const { id, name, args } = action as { id: string; name: string; args: Record<string, unknown> }
try { try {
const result = await this.runtime.call_tool(name, args) const result = await this.runtime.call_tool(name, args)
const output = result.type === 'error' const output = result.type === 'error'
@@ -125,10 +128,16 @@ After completing ALL required files, write: DONE`
if (name === 'fs.write' && args.path) changes.push({ file: args.path as string, type: 'create' }) if (name === 'fs.write' && args.path) changes.push({ file: args.path as string, type: 'create' })
if (name === 'fs.edit' && args.path) changes.push({ file: args.path as string, type: 'edit' }) if (name === 'fs.edit' && args.path) changes.push({ file: args.path as string, type: 'edit' })
if (result.type === 'error') allSucceeded = false if (result.type === 'error') allSucceeded = false
messages.push({ role: 'user', content: `Tool ${name}(${args.path || ''}): ${output.slice(0, 500)}` }) messages.push({
role: 'user',
content: [{ type: 'tool_result', tool_use_id: id, content: output.slice(0, 5000), is_error: result.type === 'error' }]
})
} catch (e: any) { } catch (e: any) {
allSucceeded = false allSucceeded = false
messages.push({ role: 'user', content: `Tool ${name} error: ${e.message}` }) messages.push({
role: 'user',
content: [{ type: 'tool_result', tool_use_id: id, content: `Tool ${name} error: ${e.message}`, is_error: true }]
})
} }
} }
} }
@@ -139,11 +148,16 @@ After completing ALL required files, write: DONE`
messages.push({ role: 'user', content: 'You signaled DONE, but one or more tool actions failed. Fix the failed actions before signaling DONE.' }) messages.push({ role: 'user', content: 'You signaled DONE, but one or more tool actions failed. Fix the failed actions before signaling DONE.' })
continue continue
} }
const verification = await this.verify_before_completion(task_spec, changes)
if (!verification.passed) {
messages.push({ role: 'user', content: `Verification failed; do not say DONE until fixed.\n${verification.output}` })
continue
}
await this.runtime.checkpoint('task_completed', { task_id: task_spec.id }) await this.runtime.checkpoint('task_completed', { task_id: task_spec.id })
return { return {
status: 'completed', status: 'completed',
changes, changes,
verification: { passed: true, output: `${changes.length} files: ${changes.map(c => c.file).join(', ')}` }, verification,
evidence_refs: [] evidence_refs: []
} }
} }
@@ -163,17 +177,22 @@ After completing ALL required files, write: DONE`
messages.push({ role: 'user', content: 'You said DONE but no files were created. Please create the required files first.' }) messages.push({ role: 'user', content: 'You said DONE but no files were created. Please create the required files first.' })
continue continue
} }
const verification = await this.verify_before_completion(task_spec, changes)
if (!verification.passed) {
messages.push({ role: 'user', content: `Verification failed; do not say DONE until fixed.\n${verification.output}` })
continue
}
await this.runtime.checkpoint('task_completed', { task_id: task_spec.id }) await this.runtime.checkpoint('task_completed', { task_id: task_spec.id })
return { return {
status: 'completed', status: 'completed',
changes, changes,
verification: { passed: true, output: `${changes.length} files created` }, verification,
evidence_refs: [] evidence_refs: []
} }
} }
messages.push({ role: 'assistant', content: text }) messages.push({ role: 'assistant', content: text })
messages.push({ role: 'user', content: 'Please CREATE the files. Use code blocks with filename tags or fs.write() tool calls. When done creating ALL files, respond DONE.' }) messages.push({ role: 'user', content: 'Please CREATE the files. Use native tools, strict JSON tool_call blocks, or code blocks with filename tags. When done creating ALL files and required verification passes, respond DONE.' })
} }
} }
@@ -185,10 +204,6 @@ After completing ALL required files, write: DONE`
} }
} catch (error) { } catch (error) {
this.runtime.emit('task.blocked', {
task_id: task_spec.id,
error: error instanceof Error ? error.message : String(error)
})
return { status: 'blocked', error: error instanceof Error ? error.message : String(error) } return { status: 'blocked', error: error instanceof Error ? error.message : String(error) }
} }
} }
@@ -200,24 +215,88 @@ After completing ALL required files, write: DONE`
.some(line => line === 'DONE' || line === 'TASK_COMPLETE') .some(line => line === 'DONE' || line === 'TASK_COMPLETE')
} }
/** private async verify_before_completion(
* Parse ALL actions from LLM response: code blocks and tool calls. task_spec: { acceptance_criteria: string[]; title: string; description: string },
*/ changes: Array<{ file: string; type: 'create' | 'edit' | 'delete' }>
private parse_actions(text: string): Array< ): Promise<{ passed: boolean; output: string }> {
{ type: 'text' } | if (!this.requires_executable_verification(task_spec)) {
{ type: 'code_block'; filename: string; content: string } | return { passed: true, output: `${changes.length} files: ${changes.map(c => c.file).join(', ')}` }
{ type: 'tool_call'; name: string; args: Record<string, unknown> } }
> {
const actions: Array<any> = [] const command = this.verification_command(task_spec, changes)
if (!command) {
return { passed: false, output: 'Acceptance criteria require executable verification, but no verification command could be derived.' }
}
const result = await this.runtime.call_tool('shell.run', { command, timeout: 300000 })
const payload = result.content as { exit_code?: number; stdout?: string; stderr?: string; message?: string }
if (result.type === 'error') {
return {
passed: false,
output: `Verification command failed: ${command}\n${payload.stderr || payload.stdout || payload.message || JSON.stringify(payload)}`,
}
}
return {
passed: true,
output: `Verification command passed: ${command}\n${payload.stdout || ''}`.trim(),
}
}
private requires_executable_verification(task_spec: { acceptance_criteria: string[]; title: string; description: string }): boolean {
const text = `${task_spec.title}\n${task_spec.description}\n${task_spec.acceptance_criteria.join('\n')}`.toLowerCase()
return /\b(build|compile|run|test|cmake|make|pytest|npm test|bun test)\b|编译|构建|运行|测试/.test(text)
}
private verification_command(
task_spec: { title: string; description: string; acceptance_criteria: string[] },
changes: Array<{ file: string; type: 'create' | 'edit' | 'delete' }>
): string | null {
const text = `${task_spec.title}\n${task_spec.description}\n${task_spec.acceptance_criteria.join('\n')}`.toLowerCase()
const files = new Set(changes.map(c => c.file))
if (files.has('CMakeLists.txt') || text.includes('cmake')) {
return 'cmake -S . -B build && cmake --build build'
}
if ([...files].some(f => f.endsWith('.cpp') || f.endsWith('.cc') || f.endsWith('.cxx'))) {
const file = [...files].find(f => f.endsWith('.cpp') || f.endsWith('.cc') || f.endsWith('.cxx')) || 'main.cpp'
return `c++ ${file} -o /tmp/aircoding-verify && /tmp/aircoding-verify`
}
if (files.has('package.json') || text.includes('npm test')) return 'npm test'
if (text.includes('bun test')) return 'bun test'
if ([...files].some(f => f.endsWith('.py')) && text.includes('test')) return 'python3 -m pytest'
return null
}
/**
* Parse structured actions from native tool calls and strict JSON fallback.
*/
private parse_actions(
text: string,
native_tool_calls: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> = []
): ExecutorAction[] {
const actions: ExecutorAction[] = []
for (const call of native_tool_calls) {
actions.push({
type: 'tool_call',
id: call.id || crypto.randomUUID(),
name: call.name,
args: call.arguments || {},
})
}
// ── Pattern 1: Code blocks with filename tags ──
// ```cpp:src/main.cpp or ```cpp:main.cpp or ```cpp main.cpp
const codeBlockRe = /```(\w+)(?::(\S+)|\s+(\S+))?\s*\n([\s\S]*?)```/g const codeBlockRe = /```(\w+)(?::(\S+)|\s+(\S+))?\s*\n([\s\S]*?)```/g
for (const match of text.matchAll(codeBlockRe)) { for (const match of text.matchAll(codeBlockRe)) {
const lang = match[1] const lang = match[1]
let filename = match[2] || match[3] || '' const inner = match[4].trim()
if (lang === 'json' || lang === 'tool' || lang === 'tool_call') {
const parsed = this.parse_json_tool_call(inner)
if (parsed) actions.push(parsed)
continue
}
// Infer filename from language let filename = match[2] || match[3] || ''
if (!filename || filename.length < 2) { if (!filename || filename.length < 2) {
const extMap: Record<string, string> = { const extMap: Record<string, string> = {
cpp: 'main.cpp', c: 'main.c', h: 'header.h', hpp: 'header.hpp', cpp: 'main.cpp', c: 'main.c', h: 'header.h', hpp: 'header.hpp',
@@ -233,109 +312,51 @@ After completing ALL required files, write: DONE`
actions.push({ type: 'code_block', filename, content: match[4] }) actions.push({ type: 'code_block', filename, content: match[4] })
} }
// ── Pattern 2: Explicit tool calls ── const textWithoutBlocks = text.replace(/```(?:\w+)?[\s\S]*?```/g, '')
// Manual parser for tool_name("arg1", "arg2") to handle content with quotes actions.push(...this.extract_json_tool_calls(textWithoutBlocks))
const toolNames = ['fs.write', 'fs.read', 'fs.edit', 'fs.list', 'fs.stat',
'shell.run', 'git.status', 'git.diff', 'git.commit', 'git.branch',
'project.scan', 'project.context', 'cpp.detect', 'cpp.build', 'cpp.test']
for (const tname of toolNames) {
let searchFrom = 0
while (true) {
const idx = text.indexOf(`${tname}(`, searchFrom)
if (idx < 0) break
// Find the argument list: count parens and handle quotes
const argsStart = idx + tname.length + 1 // skip "("
let depth = 1
let i = argsStart
let inString = false
let stringChar = ''
while (i < text.length && depth > 0) {
const ch = text[i]
if (inString) {
if (ch === '\\') { i += 2; continue }
if (ch === stringChar) inString = false
} else {
if (ch === '"' || ch === "'") { inString = true; stringChar = ch }
else if (ch === '(') depth++
else if (ch === ')') depth--
}
i++
}
const argsStr = text.slice(argsStart, i - 1).trim()
searchFrom = i
// Parse arguments: split by top-level commas
const args: string[] = []
let cur = ''
let inStr = false
let strCh = ''
for (let j = 0; j < argsStr.length; j++) {
const ch = argsStr[j]
if (inStr) {
if (ch === '\\') { cur += ch + (argsStr[j+1] || ''); j++; continue }
if (ch === strCh) inStr = false
cur += ch
} else {
if (ch === '"' || ch === "'") { inStr = true; strCh = ch; cur += ch }
else if (ch === ',') { args.push(cur.trim()); cur = '' }
else cur += ch
}
}
if (cur.trim()) args.push(cur.trim())
// Map to tool-specific arg names
const argMap: Record<string, string[]> = {
'fs.write': ['path', 'content'],
'fs.read': ['path'],
'fs.edit': ['path', 'old_str', 'new_str'],
'fs.list': ['path'],
'fs.stat': ['path'],
'shell.run': ['command'],
'project.scan': ['root'],
'cpp.detect': ['project_root'],
'cpp.build': ['target'],
'cpp.test': ['filter'],
}
const keys = argMap[tname] || args.map((_, k) => `arg${k}`)
const toolArgs: Record<string, unknown> = {}
args.forEach((v, k) => {
// Strip surrounding quotes
let clean = v.trim()
if ((clean.startsWith('"') && clean.endsWith('"')) ||
(clean.startsWith("'") && clean.endsWith("'"))) {
clean = clean.slice(1, -1)
}
toolArgs[keys[k] || `arg${k}`] = clean
})
actions.push({ type: 'tool_call', name: tname, args: toolArgs })
}
}
// ── Pattern 3: ```tool_call blocks (explicit tool JSON) ──
const tcallRe = /```(?:tool_call|tool|json)\s*\n?([\s\S]*?)```/g
for (const match of text.matchAll(tcallRe)) {
const inner = match[1].trim()
// Try JSON
try {
const parsed = JSON.parse(inner)
if (parsed.tool) actions.push({ type: 'tool_call', name: parsed.tool, args: parsed.args || {} })
} catch {
// Try function call
const subCalls = this.parse_actions(inner)
for (const sc of subCalls) {
if (sc.type !== 'text') actions.push(sc)
}
}
}
// If nothing parsed, it's just text
if (actions.length === 0) actions.push({ type: 'text' }) if (actions.length === 0) actions.push({ type: 'text' })
return actions return actions
} }
private parse_json_tool_call(raw: string): Extract<ExecutorAction, { type: 'tool_call' }> | null {
try {
const parsed = JSON.parse(raw) as { id?: string; tool?: string; name?: string; args?: Record<string, unknown>; arguments?: Record<string, unknown> }
const name = parsed.tool || parsed.name
if (!name) return null
return {
type: 'tool_call',
id: parsed.id || crypto.randomUUID(),
name,
args: parsed.args || parsed.arguments || {},
}
} catch {
return null
}
}
private extract_json_tool_calls(text: string): ExecutorAction[] {
const actions: ExecutorAction[] = []
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) continue
const action = this.parse_json_tool_call(trimmed)
if (action) actions.push(action)
}
return actions
}
private assistant_content_for_actions(text: string, actions: ExecutorAction[]): unknown {
const toolUses = actions
.filter((a): a is Extract<ExecutorAction, { type: 'tool_call' }> => a.type === 'tool_call')
.map(a => ({ type: 'tool_use', id: a.id, name: a.name, input: a.args }))
if (toolUses.length === 0) return text
const blocks: Array<Record<string, unknown>> = []
const cleanText = text.replace(/```(?:tool_call|tool|json)\s*\n?[\s\S]*?```/g, '').trim()
if (cleanText) blocks.push({ type: 'text', text: cleanText })
blocks.push(...toolUses)
return blocks
}
} }

View File

@@ -8,6 +8,8 @@
import { WorkerRuntime } from '../WorkerRuntime.js' import { WorkerRuntime } from '../WorkerRuntime.js'
const MEMORY_TYPES = new Set(['project_rule', 'toolchain_rule', 'skill_update', 'debug_experience'])
export interface ExperienceMinerResult { export interface ExperienceMinerResult {
status: 'completed' | 'no_patterns' | 'blocked' status: 'completed' | 'no_patterns' | 'blocked'
entries: Array<{ entries: Array<{
@@ -26,7 +28,7 @@ export class ExperienceMinerRole {
this.runtime = runtime this.runtime = runtime
} }
async run(mine_spec: { task_ids: string[]; focus_categories?: string[] }): Promise<ExperienceMinerResult> { async run(mine_spec: { task_ids?: string[]; focus_categories?: string[]; source_refs?: Array<Record<string, unknown>>; evidence_refs?: string[] }): Promise<ExperienceMinerResult> {
const result: ExperienceMinerResult = { const result: ExperienceMinerResult = {
status: 'no_patterns', status: 'no_patterns',
entries: [], entries: [],
@@ -34,68 +36,73 @@ export class ExperienceMinerRole {
} }
try { try {
this.runtime.emit('mining.started', { task_ids: mine_spec.task_ids }) const task_ids = mine_spec.task_ids ?? []
const evidence_refs = mine_spec.evidence_refs ?? task_ids.map((task_id) => `task:${task_id}`)
const focus = mine_spec.focus_categories?.length ? mine_spec.focus_categories : ['project_rule', 'toolchain_rule', 'debug_experience']
// Read completed task results to extract patterns if (task_ids.length === 0 && evidence_refs.length === 0) {
const task_summaries: string[] = [] result.summary = 'No task or evidence refs available for memory mining'
for (const task_id of mine_spec.task_ids) {
try {
// Emit that we're reading a task
this.runtime.emit('mining.task', { task_id })
task_summaries.push(`Task ${task_id}: completed`)
} catch { /* skip failed task reads */ }
}
if (task_summaries.length === 0) {
result.status = 'no_patterns'
result.summary = 'No completed tasks available for mining'
return result return result
} }
// Use LLM to extract patterns
const messages = [ const messages = [
{ role: 'system', content: 'You are an experience mining expert. Extract reusable patterns, best practices, and lessons learned from completed tasks. Output one pattern per line in format: CATEGORY: pattern description' }, { role: 'system', content: 'Extract durable learning candidates only when supported by evidence. Output one candidate per line as memory_type: concise summary. Valid memory_type values: project_rule, toolchain_rule, skill_update, debug_experience. Do not promote or archive memories.' },
{ role: 'user', content: `Analyze these completed tasks and extract reusable patterns:\n${task_summaries.join('\n')}\n\nFocus categories: ${(mine_spec.focus_categories || ['implementation', 'debugging', 'testing']).join(', ')}` } { role: 'user', content: `Evidence refs:\n${evidence_refs.join('\n')}\n\nTask ids: ${task_ids.join(', ') || '(none)'}\nFocus categories: ${focus.join(', ')}` }
] ]
try { try {
const analysis = await this.runtime.call_llm({ messages, max_tokens: 2048, temperature: 0.3 }) const analysis = await this.runtime.call_llm({ messages, max_tokens: 2048, temperature: 0.2 })
const lines = (analysis.content || '').split('\n').filter(l => l.includes(':')) for (const line of (analysis.content || '').split('\n')) {
for (const line of lines) {
const colon_idx = line.indexOf(':') const colon_idx = line.indexOf(':')
if (colon_idx > 0) { if (colon_idx <= 0) continue
const category = line.slice(0, colon_idx).trim().toLowerCase() const category = line.slice(0, colon_idx).trim().toLowerCase()
const memory_type = MEMORY_TYPES.has(category) ? category : 'project_rule'
const pattern = line.slice(colon_idx + 1).trim() const pattern = line.slice(colon_idx + 1).trim()
if (pattern.length > 5) { if (pattern.length < 8) continue
result.entries.push({ result.entries.push({ category: memory_type, pattern, source_task_id: task_ids[0] || '', description: pattern })
category,
pattern,
source_task_id: mine_spec.task_ids[0] || '',
description: pattern
})
}
}
} }
} catch { } catch {
// LLM unavailable — extract basic patterns from task metadata
result.entries.push({ result.entries.push({
category: 'execution', category: 'project_rule',
pattern: 'Tasks completed via Executor→LLM→Tool loop', pattern: `Review evidence before promoting memory from ${evidence_refs[0] || task_ids[0]}`,
source_task_id: mine_spec.task_ids[0] || '', source_task_id: task_ids[0] || '',
description: 'Standard execution pattern for code changes' description: 'LLM unavailable; created a conservative candidate that requires human/runtime review before promotion.',
})
}
if (result.entries.length === 0 && evidence_refs.length > 0) {
const category = focus.find((item) => MEMORY_TYPES.has(item)) || 'project_rule'
result.entries.push({
category,
pattern: `Review evidence before promoting memory from ${evidence_refs[0]}`,
source_task_id: task_ids[0] || '',
description: 'Created a conservative candidate because no structured LLM-supported pattern was returned.',
})
}
for (const entry of result.entries) {
const candidate_id = `mem_${crypto.randomUUID()}`
this.runtime.emit('memory.candidate.created', {
event_id: `evt_${candidate_id}`,
candidate_id,
source_ref: {
entity_type: entry.source_task_id ? 'task' : 'evidence',
entity_id: entry.source_task_id || evidence_refs[0] || '',
},
memory_type: entry.category,
summary: entry.pattern,
evidence_refs,
}) })
} }
if (result.entries.length === 0) { if (result.entries.length === 0) {
result.status = 'no_patterns' result.summary = `No supported memory candidates extracted from ${task_ids.length} tasks`
result.summary = `No patterns extracted from ${mine_spec.task_ids.length} tasks`
} else { } else {
result.status = 'completed' result.status = 'completed'
result.summary = `Mined ${result.entries.length} patterns from ${mine_spec.task_ids.length} tasks` result.summary = `Created ${result.entries.length} memory candidates from ${task_ids.length} tasks`
} }
this.runtime.checkpoint('mining_completed', { patterns_found: result.entries.length }) this.runtime.checkpoint('experience_mining_completed', { candidates: result.entries.length })
return result return result
} catch (error) { } catch (error) {

View File

@@ -31,7 +31,7 @@ export class ReviewerRole {
const result: ReviewerResult = { status: 'pass', findings: [], summary: '' } const result: ReviewerResult = { status: 'pass', findings: [], summary: '' }
try { try {
this.runtime.emit('review.started', { task_id: review_spec.task_id }) this.runtime.checkpoint('review_started', { task_id: review_spec.task_id })
for (const file of review_spec.change_files) { for (const file of review_spec.change_files) {
try { try {

View File

@@ -0,0 +1,70 @@
import { describe, expect, test } from 'bun:test'
import { ExecutorRole } from '../src/roles/ExecutorRole.js'
class NativeToolRuntime {
turns = 0
calls: Array<{ name: string; args: Record<string, unknown> }> = []
emit() {}
heartbeat() {}
checkpoint() {}
async call_llm() {
this.turns++
if (this.turns === 1) {
return { content: '', tool_calls: [{ id: 'tu_1', name: 'fs.write', arguments: { path: 'a.txt', content: 'X' } }] }
}
return { content: 'DONE' }
}
async call_tool(name: string, args: Record<string, unknown>) {
this.calls.push({ name, args })
return { call_id: 'c', type: 'text' as const, content: { ok: true } }
}
}
class VerificationFailRuntime {
turns = 0
checkpointed = false
emit() {}
heartbeat() {}
checkpoint() { this.checkpointed = true }
async call_llm() {
this.turns++
if (this.turns === 1) {
return { content: '', tool_calls: [{ id: 'tu_main', name: 'fs.write', arguments: { path: 'main.cpp', content: 'int main(){return 0;}' } }] }
}
return { content: 'DONE' }
}
async call_tool(name: string, _args: Record<string, unknown>) {
if (name === 'fs.write') return { call_id: 'w', type: 'text' as const, content: { ok: true } }
if (name === 'shell.run') return { call_id: 's', type: 'error' as const, content: { exit_code: 1, stderr: 'compile failed' } }
return { call_id: 'x', type: 'error' as const, content: { message: 'unexpected tool' } }
}
}
describe('ExecutorRole structured tool execution', () => {
test('executes native tool_calls instead of regex text parsing', async () => {
const runtime = new NativeToolRuntime()
const result = await new ExecutorRole(runtime as any).run({
id: 't1',
title: 'create file',
description: 'create a file',
acceptance_criteria: ['file exists'],
})
expect(result.status).toBe('completed')
expect(runtime.calls).toEqual([{ name: 'fs.write', args: { path: 'a.txt', content: 'X' } }])
})
test('does not complete executable tasks when verification fails', async () => {
const runtime = new VerificationFailRuntime()
const result = await new ExecutorRole(runtime as any).run({
id: 't2',
title: 'compile cpp',
description: 'write and compile C++',
acceptance_criteria: ['must compile'],
})
expect(result.status).not.toBe('completed')
expect(runtime.checkpointed).toBe(false)
expect(result.changes).toEqual([{ file: 'main.cpp', type: 'create' }])
})
})

View File

@@ -1,230 +1,181 @@
# AirCoding 集成修复第一轮 - 状态交接 # AirCoding 全量上下文导出 — 回话恢复用
> 用于在另一台设备继续。包含上下文、真实问题、修复方案、验证结果、剩余事项和复现命令。 > 导出时间2026-06-08
> 用途在新会话中还原本轮全部状态决策、plan、问题根因、参考复用映射、执行框架
## 1. 项目与远端 > 使用方法:新会话中 `Read /home/airlongdian/DataDevices/AirWorkSpace/AirCoding/状态交接.md` 即可恢复全部上下文。
- 仓库根:`/home/airlongdian/DataDevices/AirWorkSpace/AirCoding`
- 仓库地址:`http://admin:L753865h@39.106.174.106/admin/AirCoding.git`
- 当前分支:`GLM5-Achieve`
- 推送目标:`origin/GLM5-Achieve`
- 仓库不在 Git 根级 `package.json` 下,子包为 monorepo用 Bun 运行命令。
## 2. 本轮目标与边界
用户要求:架构与设计冻结、需求已明确;不允许回退或降级;汇总多轮四视角审查报告的真实问题并修复;修复后跑真实 UAT 演示。
**架构与设计冻结点(不要修改)**
- 7 个 monorepo 包:`contracts / cli / tui / runtime / llm / toolchain-cpp / workers`
- 5 个 Domain InvariantsINV-1~INV-5
- 工具契约 `ToolResultEnvelope { status, output, error, artifact_ids, evidence_ref_ids, metadata }``packages/contracts/src/tool.ts`
- Worker/Scheduler IPC 协议
- 状态机:`MainAgent` 15 态、`Scheduler` 13 态
**真实阻断问题来源MiniMax-M3 / Deepseek / GLM5.1 / Gpt5.5 四份审查报告)**
- 工具结果 `content``output` 契约不一致
- `shell.run` AsyncGenerator 无法被普通 `call()` 消费
- Scheduler 在 `!has_running()` 时把所有 running task 标 completed
- Worker 无结果退出后状态丢失
- MainAgent 回答未接入 ContextAssembler
- `run.ts` 危险操作 confirmation y/n 路由断裂
- ExecutorRole DONE 判定宽松、code block 全局反转义破坏源码
- E2E/release gates 不覆盖真实成功场景
- 报告 `集成测试阶段GLM5.1审查结果.md` / `集成测试阶段Gpt5.5审查结果.md` 在仓库根目录
## 3. 已完成的源码修复
### 3.1 工具契约与 shell.run
- `packages/runtime/src/tools/BuiltInToolRegistrar.ts``create_real_executor()` 中 18 个工具成功返回从 `{ type, content }` 改为 canonical `{ status: 'ok', output, metadata }`
- `packages/runtime/src/tools/ToolRegistry.ts``ToolExecutor` 支持 `Promise | AsyncIterable` 返回;`execute_branch` 通过 `execute_executor_final` 消费 streaming final envelope`call_streaming` 透传 chunks 并保证 final 出现。
- `packages/runtime/src/tools/shell/index.ts`streaming executor 立即 yield `command.started`stdout/stderr chunk 收集后 yieldfinal envelope 包含 `metadata.is_final = true``output.{exit_code, stdout, stderr, timed_out}`
### 3.2 Worker/Scheduler 结果闭环
- `packages/runtime/src/workers/WorkerProcess.ts`:新增 `on_exit` 回调,触发退出时把信息分发给 WorkerManager。
- `packages/runtime/src/workers/WorkerManager.ts`
- `WorkerHandle.state` 增加 `failed``worker.result` 按 payload.status 决定 handle state保留 wrapped result。
- `wrap_worker_result``changes[].file → changed_files`、verification object → `verification[]`、role status `fixed/pass → completed`、others → `failed`
- `handle_worker_exit` 在 worker 退出但无 result 时构造 `failed/cancelled` result。
- 暴露 `get_result_for_task` / `get_handle_for_task`
- 给 ToolRegistry 调用上下文传 `project_root` + `agent_type: 'executor'`,不再用 `cwd` 伪字段。
- `packages/runtime/src/scheduler/Scheduler.ts``MONITORING` 改为按 `WorkerResult.status` 消费 `task.completed` / `task.blocked` / `task.cancelled` / `task.failed` durable events删除"`!has_running()` 直接 completed"逻辑。
### 3.3 MainAgent 上下文、确认门、ArchitectureDesigner
- `packages/runtime/src/context/ContextAssembler.ts`
- 修复 L6 `evidence_store.list_for_entity` 调用签名。
- 修复 L8 兼容 `role: 'tool' / 'tool_result' / 'tool_use'`
- 新增 L3 `project_files` 快照层(实现 `build_project_files_snapshot`)。
- 移除 additional_layers 重复追加。
- `packages/runtime/src/app/RuntimeApp.ts``start()` 中实例化 `CapabilityRegistry`、绑定 ToolRegistry、注入 DoctorService创建 `MessageRepository` / `EvidenceRepository``context_assembler.set_data_sources`
- `packages/runtime/src/app/ServiceRegistry.ts`:同步接入 `CapabilityRegistry`、把 WorkerManager/Scheduler 走同一个 tool registry。
- `packages/runtime/src/agents/main/MainAgent.ts`
- 新增 `context_assembler / architecture_designer / project_root / agent_id / task_id` 字段。
- `chat_with_llm()` 使用 `ContextAssembler.assemble` 生成 L0-L9 消息再追加 user message。
- `classify_via_llm` 改用 `complete_text``ProviderManager` 既有 API 对齐。
- 危险操作正则覆盖中文:`删除|删掉|清除|移除|销毁`
- 委托前调用 `ArchitectureDesigner.assess_impact``reject_or_escalate/requires_replan``ARCHITECTURE_DESIGNING``requires_user_confirmation``CONFIRMING`
- 新增 `infer_changed_files` 推断受影响组件。
- `packages/cli/src/commands/run.ts`
- MainAgent 构造时传入 `context_assembler``project_root`
- 把调度逻辑抽取为 `dispatchTask`,避免重复代码。
- `pendingConfirmation` 状态机:进入 CONFIRMING 时只展示提示并保存原始任务;`y``handle_confirmation(true)``dispatchTask``n``handle_confirmation(false)` 提示取消,不创建任务。
- `/results` 优先使用 `WorkerResult.changed_files`,过滤 `.air` 内部文件。
- `packages/cli/src/commands/ask.ts`MainAgent 接入 `context_assembler`/`project_root``execute_task``ctx` 改为 canonical ToolExecutionContext。
### 3.4 ExecutorRole
- `packages/workers/src/roles/ExecutorRole.ts`
- 引入 `is_done_signal(text)` 严格判定(仅当整行等于 `DONE``TASK_COMPLETE`)。
- 工具失败且 LLM 输出 DONE 时不返回 `completed`,而是让 LLM 修复。
- 移除 code block 内容的全局 `\\n/\\t/\\\\/\\"` 反转义,保留原文。
### 3.5 Permission 与 TUI
- `packages/runtime/src/tools/ToolRegistry.ts``ask_user`/`deny` 分支保留 `call.call_id`
- `packages/cli/src/commands/release.ts`:增加 `findRepoRoot` / `findBun`,将 release gates 改为运行 `air e2e`、runtime regression、depcruisetimeout 600s并输出失败尾部。
## 4. 新增门禁与测试
- `packages/runtime/test/regression/release-critical-gates.test.ts`(已接入 e2e P0-REL
1. `built-in tool success envelopes use output, not content`
2. `shell.run returns a final envelope through call and streaming APIs`
3. `scheduler does not mark running tasks completed without a worker result`
4. `MainAgent answer mode uses assembled project context`
5. `destructive requests enter confirmation and rejection returns to idle`
- `packages/cli/test/run-command-regression.test.ts`(已接入 e2e P0-REL
- 锁定 `run.ts` 使用 WorkerResult.changed_files、过滤 `.air`、维护 pendingConfirmation。
- `packages/runtime/test/regression/worker-result-envelope.test.ts` 更新断言为新 `wrap_worker_result` 实现。
- `packages/runtime/test/regression/tool-registry-permission.test.ts` 新增 ask_user/deny 保留 call_id 断言。
- `packages/cli/src/commands/e2e.ts` 新增 `P0: Release-critical functional gates`
## 5. 验证结果
```
$ /home/airlongdian/.bun/bin/bun run packages/cli/src/index.ts e2e
14/14 gates passed
- P0: Monorepo structure
- P0: depcruise dependency boundary (INV-4)
- P0: tsc strict typecheck (0 errors)
- P0: Release-critical functional gates
- P1: Storage/Events
- P2: Tools/Permission
- P3: Provider/Context
- P4: Worker IPC
- P5: C++ Toolchain
- P6: Projection/TUI
- P7: Agents
- P8: Full regression suite
- SEC: Command injection regression
- CAP: Capability trust regression
$ /home/airlongdian/.bun/bin/bun run packages/cli/src/index.ts release --dry-run
3/3 gates passed - Release READY
```
## 6. 真实 UAT 演示(临时目录 `/tmp/air-uat.Y2H5dz`
- LLM 配置:`OPENAI_API_KEY=sk-...``OPENAI_BASE_URL=http://newapi.airlongdian.fun`注意env 中 `AIRCODING_*` 会被适配器再次加 `/v1`,应直接用 `OPENAI_*`)、`AIRCODING_MODEL=glm-5.1`
- 场景结果:
1. `air ask "创建一个 hello.txt内容是 HelloWorld"` → 真实写入 `HelloWorld` 磁盘。
2. `air ask "我的项目里有哪些文件?"` → 回答引用项目文件快照(`hello.txt`),无幻觉。
3. `air run` 输入 `请删除 hello.txt``n` → 提示 "Cancelled. No task was created.",文件保留。
4. `air run` 输入 `请删除 hello.txt``y` → 调度 `shell.run rm`,文件被删除。
- UAT 临时发现并修复MainAgent 原危险正则不匹配中文,补充 `删除|删掉|清除|移除|销毁`
## 7. 复现命令(在新设备上验证)
```bash
REPO=/home/airlongdian/DataDevices/AirWorkSpace/AirCoding
cd "$REPO"
# 1) Type check
/home/airlongdian/.bun/bin/bun run node_modules/.bin/tsc --noEmit -p tsconfig.check.json
# 2) E2E
/home/airlongdian/.bun/bin/bun run packages/cli/src/index.ts e2e
# 3) Release dry-run
/home/airlongdian/.bun/bin/bun run packages/cli/src/index.ts release --dry-run
# 4) 临时目录演示
TMP=$(mktemp -d /tmp/air-uat.XXXXXX)
cd "$TMP"
/home/airlongdian/.bun/bin/bun run "$REPO/packages/cli/src/index.ts" init .
OPENAI_API_KEY=<your_key> \
OPENAI_BASE_URL=http://newapi.airlongdian.fun \
AIRCODING_MODEL=glm-5.1 \
AIRCODING_REPO_ROOT="$REPO" \
/home/airlongdian/.bun/bin/bun run "$REPO/packages/cli/src/index.ts" ask "创建一个 hello.txt内容是 HelloWorld"
cat hello.txt
```
## 8. 关键文件清单
修改:
- `packages/cli/src/commands/ask.ts`
- `packages/cli/src/commands/e2e.ts`
- `packages/cli/src/commands/release.ts`
- `packages/cli/src/commands/run.ts`
- `packages/runtime/src/agents/main/MainAgent.ts`
- `packages/runtime/src/app/RuntimeApp.ts`
- `packages/runtime/src/app/ServiceRegistry.ts`
- `packages/runtime/src/context/ContextAssembler.ts`
- `packages/runtime/src/scheduler/Scheduler.ts`
- `packages/runtime/src/tools/BuiltInToolRegistrar.ts`
- `packages/runtime/src/tools/ToolRegistry.ts`
- `packages/runtime/src/tools/shell/index.ts`
- `packages/runtime/src/workers/WorkerManager.ts`
- `packages/runtime/src/workers/WorkerProcess.ts`
- `packages/runtime/test/regression/tool-registry-permission.test.ts`
- `packages/runtime/test/regression/worker-result-envelope.test.ts`
- `packages/workers/src/roles/ExecutorRole.ts`
新增:
- `packages/runtime/test/regression/release-critical-gates.test.ts`
- `packages/cli/test/run-command-regression.test.ts`
报告(在仓库根目录):
- `集成测试阶段Deepseek审查结果.md`
- `集成测试阶段GLM5.1审查结果.md`
- `集成测试阶段Gpt5.5审查结果.md`
- `集成测试阶段MiniMax-M3审查结果.md`
- `集成测试阶段GLM5.1审查结果.md`
- `状态交接.md`(本文件)
## 9. 提交策略
本轮一次性提交commit message
```
fix: integrate audit findings (round 1) - tools, worker, scheduler, main agent
- Unify ToolResultEnvelope (output vs content) for built-in tools
- Fix shell.run AsyncGenerator consumption in ToolRegistry.call
- Scheduler: consume WorkerResult.status instead of marking all running tasks completed
- WorkerProcess/WorkerManager: surface exit events and generate failed/cancelled result
- MainAgent: integrate ContextAssembler, Chinese destructive regex, ArchitectureDesigner impact gate
- run.ts: pendingConfirmation flow, dispatch extracted, .air files filtered from /results
- CapabilityRegistry wired into RuntimeApp and ServiceRegistry; DoctorService uses it
- release.ts: findRepoRoot/findBun, run air e2e + depcruise + runtime regression
- New gates: release-critical-gates, CLI run command regression
- 14/14 e2e gates pass; 3/3 release dry-run pass
```
## 10. 已知与待办
- TUI 完整输入/快捷键统一收口仍是可改进点UAT 已在非 TTY 走通 readline建议后续在 TUI 内增加任务输入框。
- Projection 事实源收口已做(`/results` 走 WorkerResultProjectionClient 接收 ProjectionStore 推送),但 TUI 渲染仍以手工 snapshot 为初值;下一步可以由 EventStore/ProjectionStore 完全驱动。
- 新增 `集成测试阶段Gpt5.5审查结果.md` 等报告应作为证据纳入未来 release notes。
--- ---
**会话记录**(从对话恢复继续的关键节点): ## 1. 仓库与分支
1. 四份审计报告汇总去重 → 真实问题清单。
2. Phase 1: 工具契约 + shell.run 修复ToolRegistry 消费 AsyncIterable、shell final envelope
3. Phase 2: WorkerProcess exit handler → WorkerManager → Scheduler MONITORING 改按 WorkerResult 状态入事件。
4. Phase 3: MainAgent 接入 ContextAssemblerCONFIRMING y/n 路由ArchitectureDesigner 主路径CapabilityRegistry 接入Permission call_id 保留。
5. Phase 4: ExecutorRole 严格 DONE、保留 code block 原文、失败不 completed。
6. Phase 5: 新增 release-critical-gates / CLI run regressionair e2e 14/14。
7. Phase 6: run.ts `/results` 走 WorkerResultrelease.ts 找 repo root。
8. 真实 UATair ask 创建/追问air run 中文删除拒绝/确认;运行中 UAT 触发并修复中文确认门正则。
9. 准备状态交接与一次性 git 提交推送。
会话总耗时较长,过程已通过自动化避免重复。回到本机可直接按 §7 复现并继续。 - 仓库根:`/home/airlongdian/DataDevices/AirWorkSpace/AirCoding`(软链 `/run/media/airlongdian/EasyU/AirCoding` 指向同一目录)
- 远端:`<remote-url-credentials-stored-locally>`
- 第一轮修复分支:`GLM5-Achieve`已推送commit ddefcbb
- bun 路径:`/home/airlongdian/.bun/bin/bun`
- LLM 配置(真实端点,用 OPENAI_ 前缀,不要用 AIRCODING_API_URL 否则适配器重复加 /v1
- `OPENAI_API_KEY=${OPENAI_API_KEY}`
- `OPENAI_BASE_URL=http://newapi.airlongdian.fun`
- `AIRCODING_MODEL=glm-5.1`
- `AIRCODING_REPO_ROOT=/home/airlongdian/DataDevices/AirWorkSpace/AirCoding`
## 2. 本轮之前做了什么第一轮集成修复commit ddefcbb
真实修复(已验证):
- 工具契约 output/content 统一BuiltInToolRegistrar
- shell.run AsyncGenerator 消费ToolRegistry.execute_executor_final
- Scheduler 删除"!has_running() 全标 completed"假完成逻辑,改为按 WorkerResult.status 终结
- WorkerProcess on_exit → WorkerManager handle_worker_exit无结果退出生成 failed result
- MainAgent 接入 ContextAssemblerL0-L9 + project_files 快照)
- run.ts 确认门 y/n 路由pendingConfirmation 状态机)+ 中英文危险词正则
- ExecutorRole 严格 DONEis_done_signal 整行匹配、code block 原文保留
- CapabilityRegistry 接入 RuntimeApp/DoctorService/ServiceRegistry
- ArchitectureDesigner 接入 MainAgent 主路径impact gate
- Permission ask_user/deny 保留 call_id
- release.ts findRepoRoot/findBun + 真实 gates
- /results 优先 WorkerResult.changed_files过滤 .air
- 新增 release-critical-gates.test.ts5 条真实行为 gate+ CLI run-command-regression.test.ts
未修复(第一轮遗留):
- EventStore.setRepositories 运行路径从不调用 → domain 表恒空
- task.created 从不发出
- TUI 是手写 ANSI非 OpenTUI
- air ask 旁路 Scheduler/Worker 架构(自带内联循环)
- ProjectionStore 运行时不被事件驱动run.ts 手工假 snapshot
门禁air e2e 14/14release --dry-run 3/3。但绿灯和可用性正交。
## 3. opus 四视角交叉审查结论2026-06-08四 opus 子代理并行)
四视角综合判定:**可内测演示(限 air ask不可对外 Alpha 发布**。
新发现的核心问题:
- **P0-1** EventStore.setRepositories 运行路径从不调用 → INV-1/FR-004 运行时整体失效
- **P0-2** Scheduler.create_tasks 不发 task.created注释谎称发出
- **P0-3** TUI 非 OpenTUI/Solid手写 ANSI@opentui 零依赖
- **P0-4** air ask 旁路整个调度/Worker 架构
- **P0-5** failed 任务被调度机当成 COMPLETED
- **P0-6** Worker exit/result 竞态('exit' vs 'close'
- **P0-7** fs.edit 参数名不匹配old_str/new_str vs find/replaceAgent 调用恒失败
- Worker 心跳不刷新,>5min 任务被误判 lost
审计报告文件(仓库根):
- `集成测试阶段MiniMax-M3审查结果.md`
- `集成测试阶段Deepseek审查结果.md`
- `集成测试阶段GLM5.1审查结果.md`
- `集成测试阶段Gpt5.5审查结果.md`
- `集成测试阶段opus审查结果.md`(本轮新增)
## 4. 用户核心要求(本轮拍板,优先级明确)
1. **无工作量优先级,三条主线全部实现**(事件地基 + 执行体 + UI
2. **界面对齐 opencode**(复用它现成的 @opentui/solid TUI不要自己写简陋版
3. **执行体对齐 claude code cli**read-before-edit / verification-before-completion / 结构化工具调用 → 代码层强制,不只是 prompt 文字)
4. **复用 reference/ 全部项目**,不是"参考模式重写"——能直接移植就移植,冗余依赖可接受,最终产品编译质量不受影响即可
5. **需求 > 架构冻结**:当原始需求与冻结的 UML/contracts 冲突时,改 UML 服从需求,记录但不停等批准
6. **廉价模型执行 + 主模型复审**:每个主线由廉价模型做机械执行,主模型每阶段复审(读 diff、查 DB、跑验收不信自报
7. **经验学习闭环不要漏**hermes 的 debug 经验总结、curator、skill 系统)
## 5. 根因分析(为什么"约束很详细还是做歪了"
三个逃生舱:
1. **unknown? 逃生舱**contracts 用 `tools?: unknown[]` 占位 → 实现合法不做tsc 不报错
2. **源码字符串断言冒充验收**28 测试 18 个是 `readFileSync(src).toContain('词')` → 空壳能过
3. **completed 由"写了代码"触发**,不由"达成意图"触发:#158 TUI 标 completed 实为手写 ANSI
事件链路割裂(主线 A 修复的根因):
- `EventStore.ts:942` 导出模块单例 `eventStore = new EventStore({})`(空 DB handle
- `EventIngestor.getEventStore()` 用这个单例
- Scheduler 通过 `import { eventIngestor }` 发事件 → 全部流向这个空单例
- RuntimeApp 另建 `new EventStore({db})` 赋给 `this.event_store`,只对它 setTransactionManager
- 两个实例割裂domain 表恒空
## 6. 参考项目复用映射关键reference/ 就是 AirCoding 的设计血统)
| 参考项目 | 对应 AirCoding | FR |
|---|---|---|
| opencode-1.15.5 | TUI 结构 + @opentui/solid 用法 | FR-016 |
| claude-code-cli | 执行原语、Tool 契约、ToolUse/Result block、FileEditTool readFileState | FR-009 |
| openai-codex | 工具广度、shell/patch/test loop | FR-009/010 |
| hermes-agent-2026.5.16 | 经验学习闭环(curator/memory/skill/error_classifier/context_compressor) | FR-008/014 |
| claude-hud-0.0.12 | HUD 显示context/tools/agents/todo | FR-016 |
| anthropic-skills | SKILL.md 标准 + Agent Skills spec | FR-012 |
| air-suite-20260518 | **AirCoding 插件原型**airarc/aireng/airdo/airdbg/airxdb/airndb/airsdb | FR-006/007/008/017 |
| atuin-18.16.1 | shell 历史(辅助) | — |
| asciinema-3.2.0 | 终端录制(辅助 evidence | FR-015 |
复用核心来源(取证确认):
- claude-code-cli `tools/FileEditTool/FileEditTool.ts:275-290``readFileState.get(path)` 检查 → 未读报错 "File has not been read yet"
- claude-code-cli `Tool.ts:1-6``import { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk'`
- opencode `footer.prompt.tsx`OpenTUI textarea 输入框 + keymap
- opencode opentui 版本0.3.0`package.json:41-43`
- hermes `agent/curator.py``memory_manager.py``error_classifier.py`
## 7. 第二轮规划文件(全部在 `/home/airlongdian/.claude/plans/`
| 文件 | 内容 |
|---|---|
| `round2-MASTER.md` | 总纲:根因 + 永久纪律 N1-N5 + 需求§6 锚定 + 三主线总览 |
| `round2-REUSE-MAP.md` | 参考项目→AirCoding 复用映射表(每个 reference 项目对应的模块/文件) |
| `round2-A-events.md` | 主线A事件落库地基统一 EventStore 单例 + task.created 发出 + 验收DB 真有数据) |
| `round2-B-execution.md` | 主线B执行体对齐 claude codecontent block 类型 + read-before-edit 强 + verification + 结构化 tool_use |
| `round2-C-ui.md` | 主线C界面对齐 opencode@opentui/solid 直接移植优先 + 冗余依赖可接受) |
| `round2-D-experience.md` | 主线D经验学习闭环hermes curator/error_classifier/skill 移植到 ExperienceMiner/Debugger/Compactor |
| `round2-E-gates.md` | 主线E反作弊门禁需求§6 13条行为化 + NFR-006 + fail-on-missing |
| `round2-EXECUTOR-PROMPT.md` | **可直接复制给廉价模型的启动指令** |
### 执行顺序A → B → (C ∥ D) → E
- A 是 C/D 的前提(事件落库才能有真实投影供 TUI 消费)
- B 与 A 无强依赖,建议顺序做(改动交叉)
- C 和 D 可并行(都依赖 A
- E 贯穿收口
### 每主线验收硬线
- Asession.db 的 tasks 表 count > 0不达成不准进 C/D
- Bread-before-edit 代码强制(未读先改被拒)+ 结构化 tool_use + executor-role 功能测试
- CTUI 无手写 ANSI、可输入、状态来自真实投影、不依赖 runtime
- DExperienceMiner/Compactor 非空壳、CapabilityRegistry 加载 skill
- E需求 §6 13 条各有行为 gate 且绿
## 8. 永久纪律 N1-N5各 plan 文件中重申)
- N1 消灭 unknown? 逃生舱(本轮触及的契约字段补具体类型去可选)
- N2 验收必须执行被测代码(禁止用 grep 源码字符串当 gate
- N3 completed 由验收命令通过触发(不是"我写完了"
- N4 改不动或与描述不符就停下报告,不自由发挥
- N5 每阶段贴真实输出TSC 退出码、测试 pass/fail、DB 行数——原样贴)
## 9. 五份审计报告(仓库根,已 git 跟踪)
1. `集成测试阶段MiniMax-M3审查结果.md`
2. `集成测试阶段Deepseek审查结果.md`
3. `集成测试阶段GLM5.1审查结果.md`
4. `集成测试阶段Gpt5.5审查结果.md`
5. `集成测试阶段opus审查结果.md`(本轮新增)
## 10. 回话恢复操作(新会话中执行)
1. 确认仓库存在:`cd /home/airlongdian/DataDevices/AirWorkSpace/AirCoding && ls`
2. 确认分支:`git log --oneline -3`(应在 GLM5-Achieve最新 ddefcbb
3. 读本文件恢复上下文:`Read /home/airlongdian/DataDevices/AirWorkSpace/AirCoding/状态交接.md`
4. 读总纲:`Read /home/airlongdian/.claude/plans/round2-MASTER.md`
5. 定位上次进度如果主线A尚未完成`round2-A-events.md` 继续如果A已完成检查 session.db 的 tasks 表确认后发主线B
6. 给廉价模型的指令模板:读 `round2-EXECUTOR-PROMPT.md`,复制「===」之间的内容给廉价模型
7. 复审流程:执行模型贴回验收输出 → 主模型读 diff / 查 DB / 跑验收 → 通过放行下一主线
## 11. 当前剩余核心决策点(可选在新会话中确认)
- TUIC0 如果 @opentui/solid 0.3.0 在当前 Bun 版本装不上,是降版本还是换方案?当前用户已确认「冗余依赖可接受、直接移植优先」
- air ask 旁路架构:是否要删掉内联循环统一到 Scheduler→Worker不在本轮A-E留后续
- 第二轮修复的 git 分支:是继续 GLM5-Achieve 还是新开分支
## 12. 用户核心价值取向(从会话中提取,新会话中如遇决策歧义以此为锚)
- 不接受架构降级,但需求 > 冻结(需求冲突时改 UML 服从需求)
- 不接受空壳实现prompt 文字冒充代码强制不可接受)
- 复用成熟实现 > 自己写(手写简陋版是之前反复出问题的根源)
- 质量 > 速度;真实验收 > 绿灯数字
- "小步快走"但每一步交付物真实可验证,不靠标记自报
---
*如需在新设备继续git pull GLM5-Achieve 后按 §10 操作即可。*

View File

@@ -0,0 +1,269 @@
# 集成测试阶段 opus 审查结果
**审计日期**: 2026-06-08
**项目**: AirCoding V1.0.0 Alpha
**审计模型**: Opus四子代理并行各持不同文档
**审计模式**: 四视角交叉审计(系统架构师 / 开发工程师 / 真实用户 / 测试工程师)
**审计基线**: 第一轮集成修复后commit ddefcbb
---
## 0. 总体结论
第一轮修复**方向正确、happy-path 可用**:四份历史报告的头号 P0工具契约 content/output、shell.run AsyncGenerator、Scheduler 假完成、MainAgent 无上下文、确认门断裂)已真实闭合并有功能级门禁守护。但本轮 opus 交叉审查发现**第一轮未触及的更深层架构问题与新缺陷**
- **架构师**:事件驱动地基在运行路径上是空的(`setRepositories` 从不调用、`task.created` 从不发出、ProjectionStore 运行时从不被事件驱动、TUI 非 OpenTUI、`air ask` 旁路整个调度架构)。判定**不可发布**。
- **工程师**:发现 failed 任务被调度机当成 COMPLETED、Worker exit/result 竞态、fs.edit 参数名不匹配、心跳不刷新。判定**不阻断内测,但对外 Alpha 前必修 P1-1/P1-2/P1-3**。
- **用户**:核心闭环真实跑通无幻觉无假成功,评分 8/10判定**可演示、Alpha 可发布带条件**。
- **QA**5 条新增 gate 中 4 条真实有效,但 P6 门禁引用不存在的测试文件形成假阳性Worker/C++/Projection 仍无真实集成测试。判定**不建议标记 Release READY**。
四视角分歧点在于"发布标准":用户/工程师视 happy-path 可用为 Alpha 达标;架构师/QA 视事实源与门禁完整性未达标。**综合判定:可内测演示(限 `air ask`),但不可对外 Alpha 发布,事件地基与失败处理必须先闭合。**
---
## 1. 系统架构师审查
### 架构结论
**不具备产品演示/Alpha 发布标准。** 第一轮闭合了一批工具契约/Worker 结果/确认门的真实 bug但**事件驱动这一架构地基在运行路径上仍是空的**domain 表从不被写入、`task.created` 从不发出、TUI 不是 OpenTUI、ProjectionStore 运行时从不被事件驱动。`air run` 能跑通"创建文件"是因为它绕过事实源,直接用 WorkerResult 内存对象 + 手工 snapshot 显示结果,掩盖了 INV-1/INV-5/FR-004 运行时整体失效。
存在两条割裂的执行实现:
- `air ask`CLI 进程内自带 LLM→工具循环`ask.ts:82-215`**完全不经过 Scheduler/Worker/IPC**。
- `air run`:走 MainAgent→Scheduler→WorkerManager→子进程→IPC→ExecutorRole 真实链路。
状态交接.md 的 UAT 全部用 `air ask` 验证,因此真实执行链路实际未被 UAT 覆盖。
### P0 问题表(阻断发布)
| # | 问题 | 证据 | 影响 |
|---|---|---|---|
| ARCH-P0-1 | **durable 事件从不投影到 domain 表**`EventStore.setRepositories()` 运行路径从未调用,仅测试调用。运行时 `project()` 内所有 repo 为 null每个 case 静默 no-op | `EventStore.ts:299-312``EventStore.ts:492-935``RuntimeApp.ts:80-84`(只 setTransactionManager | INV-1 运行时整体失效FR-004 不成立domain 表永远为空SQLite 不是事实源 |
| ARCH-P0-2 | **`task.created` 从不发出**`Scheduler.create_tasks()` 注释称发出但函数体只加内存 TaskGraph 节点,无 ingest | `Scheduler.ts:65-78`L76 注释谎称)对比 `Scheduler.ts:149` task.started 确实 ingest | tasks 表无 pending 行task.started 投影 update 不存在的行rebuild_from_db 永远查不到任务 |
| ARCH-P0-3 | **TUI 非 OpenTUI**,是手写 ANSI 转义渲染器;`@opentui/*` 零依赖零引用 | `TuiApp.tsx:27-310``tui/package.json` 无 opentui | 违反 baseline §3/§18 + 需求约束 #3 + FR-016任务 #158 标记 completed 与事实不符 |
| ARCH-P0-4 | **ProjectionStore 运行时从不被事件驱动**`apply()` 运行路径零调用run.ts 手工构造假 snapshot | `run.ts:59-72``run.ts:170-183`EventBus→ProjectionStore 无订阅 | 违反 baseline §18 + INV-5 + FR-016TUI 状态与 DB 可任意不一致 |
| ARCH-P0-5 | **`air ask` 旁路整个调度/Worker 架构**,自带内联执行循环 | `ask.ts:71-78``ask.ts:82-215` | FR-007/FR-008 在主力 demo 命令上不成立;两套执行语义割裂 |
### P1 问题表
| # | 问题 | 证据 |
|---|---|---|
| ARCH-P1-1 | ArchitectureDesigner 发出事件 `session_id:''` 必抛错被 `.catch(()=>{})` 吞掉 | `ArchitectureDesigner.ts:59-73``EventIngestor.ts:204` |
| ARCH-P1-2 | MainAgent 15 态多数无真实触发路径AWAITING/SCHEDULING/SUMMARIZING/ERROR/TERMINATED 无进入点) | `MainAgent.ts:15-30,75-122,278-313` |
| ARCH-P1-3 | Scheduler 13 态部分空壳过场COLLECTING_RESULTS/REVIEWING_WAVE 直接切换200ms 轮询 | `Scheduler.ts:340-356,329-331` |
| ARCH-P1-4 | CapabilityRegistry 接入但运行路径无 discover/load恒空 | `CapabilityRegistry.ts:48``RuntimeApp.ts:71-75` |
| ARCH-P1-5 | 真实 C++ 工具链 CppToolRegistrar 未进主链路live 用 BuiltInToolRegistrar 简化版 | `grep CppToolRegistrar` 无命中;`BuiltInToolRegistrar.ts:288-364` |
| ARCH-P1-6 | contracts ToolRegistry 接口签名与实现背离,靠 `as any` 掩盖 | `contracts/src/tool.ts:100-106` vs `ToolRegistry.ts:91,128,60` |
---
## 2. 开发工程师审查
### 工程结论
第一轮修复方向正确、主路径可用,`tsc --noEmit` 0 错误。但发现第一轮未覆盖的真实缺陷,两项触及"诚实性/正确性"底线failed 任务被伪装成 COMPLETED、Worker exit/result 竞态。happy-path 能跑通UAT 结论可信,但"任务失败"会被系统性伪装成成功。
### P1 问题表(对外发布前必修)
| # | 问题 | 证据 | 修复方向 |
|---|---|---|---|
| ENG-P1-1 | **failed 任务被调度机当成 COMPLETED**PLANNING_WAVE 算 `remaining=pending+running`failed 不计入 → COMPLETEDREPAIRING_OR_CONTINUING 的 `if(failed>0){}` 是空壳retry_planner 从未调用 | `Scheduler.ts:117-122,358-368``grep retry_planner.` 无调用 | PLANNING_WAVE 终态区分 failed → BLOCKED/TERMINATED 或经 RetryPlanner 重试run_until_idle 终态反映失败 |
| ENG-P1-2 | **Worker 退出/结果竞态**WorkerProcess 用 `'exit'``'close'`worker report_result 后立即 process.exit(0)exit 可能先于最后一行 stdout 解析handle_worker_exit 误判 failed | `WorkerProcess.ts:134-142``WorkerManager.ts:339-362``main.ts:92-93` | 监听 `'close'`;或 handle_worker_exit 对无结果做微任务让步后复查worker 端 exit 前 await stdout drain |
| ENG-P1-3 | **fs.edit 参数名不匹配Agent 调用恒失败**:执行器读 `{find,replace}`ExecutorRole/ask.ts 传 `{old_str,new_str}`find 恒 undefined → "Exact text not found" | `tools/fs/index.ts:191-197``ExecutorRole.ts:292``ask.ts:107` | 统一参数名(执行器接受 old_str/new_str 或兼容 find=old_str补 Agent 路径编辑回归 |
| ENG-P1-4 | **Worker 心跳不刷新**worker.heartbeat 无 WorkerManager 处理器record_heartbeat 仅派发时调一次,>5min 任务被判 stalled>10min 被 cancel | `WorkerManager.ts:162-257``Scheduler.ts:177,193``AgentMonitor.ts:90-104` | WorkerManager 注册 worker.heartbeat/checkpoint → record_heartbeat 刷新 |
### P2 问题表
| # | 问题 | 证据 |
|---|---|---|
| ENG-P2-1 | shell.run 流式块顺序错乱且重复(退出后先聚合 stdout/stderr 再 drain 增量 chunks | `tools/shell/index.ts:94-135` |
| ENG-P2-2 | ServiceRegistry 为分叉死代码DB 路径与 RuntimeApp 不一致 | `ServiceRegistry.ts:38-49``RuntimeApp.ts:64-66` |
| ENG-P2-3 | 内建工具成功 envelope 夹带遗留 `call_id/tool_name/type:'text'` 顶层字段,靠 Promise<any> 不报错 | `BuiltInToolRegistrar.ts:180-183` 等 18 处 |
| ENG-P2-4 | ProviderManager.complete_text 硬编码 anthropic provider_id/canonical_format | `ProviderManager.ts:110-119``ask.ts:13,43` |
| ENG-P2-5 | process.kill 工具无权限门perms 全 false可 kill 任意 PID | `BuiltInToolRegistrar.ts:118-120,190-201` |
| ENG-P2-6 | 空 catch 吞错 | `ContextAssembler.ts:127``run.ts:155,157` |
| ENG-P2-7 | ArchitectureDesigner 事件 session_id 为空 | `ArchitectureDesigner.ts:59-73` |
### 第一轮修复正确性核验表
| 第一轮声称 | 核验结论 |
|---|---|
| 内建工具改 canonical {status,output,metadata} | ✅ 部分status/output 已加,但仍夹带 type:'text'/顶层 call_id |
| grep content: 无残留 | ⚠️ 残留多为合法fs.read 输出、layer.content、IPC payload |
| shell.run 两路径正确 | ✅ 消费正确;⚠️ 流式块顺序/重复有缺陷 |
| Scheduler 按 status 终结 | ✅ 已删假完成逻辑;❌ 但 failed 在 PLANNING_WAVE 被当已完成 |
| WorkerProcess on_exit 覆盖退出语义 | ⚠️ 映射对,但 exit/result 顺序竞态未解决 |
| MainAgent 真用 ContextAssembler | ✅ assemble 注入;⚠️ answer 模式 agent_type 误用 'executor' |
| run.ts pendingConfirmation 健壮 | ✅ 空输入/y/n/非y-n 路由成立 |
| ExecutorRole 严格 DONE/失败不 completed/保留原文 | ✅ 全部成立 |
| CapabilityRegistry/ArchitectureDesigner 接入 | ✅ 实例化绑定;但 ArchDesigner 事件 session_id 空P1、Capability 运行时空集 |
| ContextAssembler L3/L6/L8 | ✅ project_files/evidence 签名/tool role 均落地 |
| release.ts findRepoRoot/findBun | ✅ 成立 |
---
## 3. 真实用户 / UAT 审查
### 用户体验评分8/10
核心闭环(创建文件、上下文问答、多文件生成、危险操作确认门、调度执行)全部真实跑通,无幻觉、无假成功。扣分来自 `air run` TUI/readline 交织、新建项目立即 doctor 失败两个体验摩擦点。
### 测试矩阵
| # | 场景 | 结果 | 观察 |
|---|---|---|---|
| 1 | air init | PASS | 创建 .air 结构 + project.json退出码 0 |
| 2 | air ask 创建 hello.txt | PASS | fs.write磁盘内容精确 `HelloWorld`(10B) |
| 3 | air ask 项目有哪些文件 | PASS | answer 模式准确列出真实文件,无幻觉,未列 .air |
| 4 | air ask C++ + CMakeLists | PASS | main.cpp(97B)+CMakeLists.txt(153B)g++ 实测编译运行输出 Hello World |
| 5 | air run 中文删除 + n | PASS | 命中确认门,"Cancelled. No task was created.",文件保留 |
| 6 | air run 中文删除 + y | PASS | 确认后派发 worker 走 shell.run文件被删除 |
| 7 | air doctor | PASS含告警 | 6 项全绿project_structure 报缺 package.json [fixable] |
| 8 | air e2e | PASS | 14/14 gates |
| 9 | /help /tools /results | PASS | 清晰可理解 |
### 最痛问题
1. `air run` TUI 与 readline 双写终端(中)——全屏 TUI 与行式 `> ` 提示符混在同一 stdoutworker 运行时刷屏交错。
2. 新建项目 doctor 立即失败(低-中——init 不生成 package.json紧接 doctor 报 FAIL负面第一印象。
3. glm-5.1 reasoning token 消耗(信息项)——低 max_tokens 时正文可能为空。
### False-positive 风险:低
文件产物均落盘后 cat/ls/g++ 实测复核删除查磁盘确认cpp.build 失败是真实无 cmake优雅降级如实说明。唯一留意/results 是 run 进程内存态,重启不持久。
### 是否可演示/可发布
- **可演示:是**(建议用 air ask输出干净
- **可发布 Alpha带条件**——功能完整、门禁 14/14、确认门中英文生效达 Alpha 线;正式版前收口 run 输入统一、init/doctor 体验、/results 持久化。
---
## 4. QA / 发布门禁审查
### QA 总结
第一轮源码修复方向正确,`release-critical-gates.test.ts` 是本项目第一次出现真正执行被测代码的发布级门禁。但门禁整体三个结构性问题未达"可发布"
1. **P6 门禁形同虚设**——引用的 `projection-store-apply.test.ts` 不存在bun 静默跳过缺失路径,仅靠 workspace-enum.test.ts 让 gate 变绿,**假阳性**。
2. **关键集成路径无真实验证**——28 个测试文件无一真正 spawn worker 子进程、无一真正编译运行 C++。
3. **源码字符串断言占比过高**——28 个测试中 18 个64%)用 readFileSync + toContain只证明"代码还在"不证明"功能正确"。
### 测试矩阵(真实运行)
| 项 | 实测结果 | 备注 |
|---|---|---|
| tsc | EXIT=01.2s | 增量编译(未 clean rebuild|
| air e2e | 14/14 passed4.9s | 见逐 gate 评估 |
| release --dry-run | 3/3 — READY8.6s | |
| P4 Worker IPC | 21 pass/53 expect | 无真实 spawn |
| P5 C++ | 5 pass/10 expect | 仅 1 文件纯源码断言,无真实编译 |
| P8 全回归 | 142 pass/346 expect/21 files | 体量真实但大量字符串断言 |
### 新增 gate 有效性评估
`release-critical-gates.test.ts`5 条)——质量最高:
| Gate | 判定 |
|---|---|
| 1 tool 用 output 非 content | ✅ 真实调用工具断言 output 存在/content undefined |
| 2 shell.run finalcall+streaming | ✅ 真跑 printf ok 断言 exit_code/stdout/is_final |
| 3 Scheduler 不假完成 | ✅ 命中核心 bug workerManager 是字面量 mock |
| 4 MainAgent answer 用 context | ✅ 真实 ContextAssembler + 临时文件 |
| 5 危险操作 CONFIRMING→IDLE | ✅ 真实 MainAgent 状态机 |
5 条中 4 条真实执行被测逻辑——**合格,是门禁里唯一可信功能层**。
`run-command-regression.test.ts`3 条)——全部源码字符串断言,不执行 run 命令,仅防回退快照。
### 仍缺失的关键 gate
| # | 缺失项 | 风险 |
|---|---|---|
| G1 | Worker 真实 spawn + IPC round-tripworker-fixture 自承认 stub | 最高 |
| G2 | 真实 C++ build/run | 高 |
| G3 | Worker exit consistency 端到端 | 高 |
| G4 | Projection rebuild/replay门禁引用文件不存在假绿 | 高 |
| G5 | False-positive 成功检测ExecutorRole 无任何测试) | 高 |
| G6 | complex C++ build/run e2e | 中-高 |
### QA P0/P1/P2
**P0阻断发布**
- QA-P0-1 修复 P6 假阳性门禁(补 projection-store-apply.test.ts 或移除路径并 fail-on-missing
- QA-P0-2 e2e runTest 加 fail-on-missing任一路径不存在直接 fail
- QA-P0-3 Worker 真实 spawn round-trip 接入 P4
**P1**
- QA-P1-1 真实 C++ build/run e2e
- QA-P1-2 ExecutorRole DONE/失败不 completed 功能测试
- QA-P1-3 Worker exit→result 一致性端到端
- QA-P1-4 Projection rebuild/replay 覆盖
**P2**
- QA-P2-1 降低源码字符串断言占比64%
- QA-P2-2 tsc clean rebuild 验证
- QA-P2-3 P7 direct-mode mock 标注边界
### 发布门禁建议
**当前不建议标记 Release READY**,尽管 release --dry-run 3/3。理由release 的绿建立在 e2e 14/14 之上,而 14/14 里 P6 假阳性、P4/P5 源码断言冒充集成。最低放行QA-P0-1/2/3 完成 + 手工 UAT 脚本化为可重放 e2e 纳入门禁。
---
## 5. 四视角交叉综合
### P0 汇总(阻断对外发布)
| # | 问题 | 来源视角 | 根因 |
|---|---|---|---|
| 1 | EventStore.setRepositories 运行路径从不调用 → domain 表恒空 | 架构师 | INV-1/FR-004 地基失效 |
| 2 | task.created 从不发出 | 架构师 | 事件溯源断链 |
| 3 | failed 任务被调度机当成 COMPLETED | 工程师 | 失败伪装成功(触碰红线)|
| 4 | Worker exit/result 竞态误判 failed | 工程师 | 成功也可能被误判 |
| 5 | ProjectionStore 运行时不被事件驱动run.ts 手工 snapshot | 架构师 | INV-5/FR-016 |
| 6 | P6 门禁引用不存在文件,假阳性 | QA | 门禁完整性 |
| 7 | air ask 旁路调度/Worker 架构 | 架构师 | FR-007/008 双实现割裂 |
| 8 | TUI 非 OpenTUI | 架构师 | FR-016/约束#3 |
### P1 汇总
fs.edit 参数不匹配恒失败工程师、Worker 心跳不刷新工程师、ArchitectureDesigner 事件 session_id 空被吞(架构师+工程师、MainAgent/Scheduler 状态机空壳架构师、CapabilityRegistry 运行时空集(架构师)、真实 C++ 工具链未进主链路架构师、Worker/C++/Projection 无真实集成 gateQA
### 与前几轮对比
| 维度 | 前几轮 | 第一轮修复后(本轮实测) |
|---|---|---|
| 工具契约 output/content | 头号 P0 | ✅ 已修 + 真实 gate |
| shell.run AsyncGenerator | 阻断 | ✅ 已修 + 真实验证 |
| Scheduler 假完成 | 阻断 | ✅ 已删假逻辑;❌ 但 failed→COMPLETED 新问题 |
| MainAgent 上下文/确认门 | 阻断 | ✅ 已修 + 真实验证 |
| ProjectionStore-only/TUI snapshot | Deepseek/Gpt5.5 P1 | ❌ 未修(更深:本轮查实 setRepositories 从不调用)|
| durable task events 主路径 | Gpt5.5 P0 | ❌ 未修根因task.created 不发出)|
| Worker 真实 spawn | 一直缺失 | ❌ 仍缺失(自承认 stub|
| C++ 真实 build/run | 一直缺失 | ❌ 仍缺失 |
**本轮新发现**setRepositories 从不调用最严重、task.created 不发出、TUI 非 OpenTUI、failed→COMPLETED、Worker exit/result 竞态、fs.edit 参数不匹配、P6 假阳性门禁、ArchitectureDesigner 事件被吞。
---
## 6. 发布建议与修复优先级
**综合判定:可内测演示(限 air ask不可对外 Alpha 发布。**
14/14 e2e 与 3/3 release 全绿,但这些门禁不触碰本轮 P0 任何一条——绿灯与可用性正交,这正是 MiniMax 已警告、本轮仍重演的盲点。
按修复优先级(遵守"不接受架构降级"原则,全部为补齐而非删功能):
1. **闭合事件地基**RuntimeApp.start 调用 `eventStore.setRepositories({...})` 注入全部 domain repocreate_tasks 真正 ingest task.created。P0-1/P0-2
2. **修复失败处理**Scheduler PLANNING_WAVE 终态区分 failed接线 RetryPlannerrun_until_idle 反映失败。P0-3
3. **修复 Worker 竞态**WorkerProcess 监听 'close' 或退出前复查 result。P0-4
4. **统一执行路径**air ask 复用 Scheduler→Worker删除 CLI 内联循环。P0-7
5. **收口 Projection 事实源**EventBus→ProjectionStore.apply→TUI删 run.ts 手工 snapshot。P0-5
6. **门禁完整性**e2e runTest fail-on-missing补 P6 真实测试;补 Worker spawn / C++ build / false-positive / projection rebuild gate。P0-6 + QA-P0
7. **TUI 技术栈归位**:接 @opentui/*或走正式架构变更声明不可默默降级P0-8
8. P1 批量fs.edit 参数、心跳刷新、ArchDesigner session_id、状态机补齐、CapabilityRegistry 加载、真实 C++ 工具链进主链路。
**核心教训重申**代码质量指标tsc/门禁数)与产品可用性指标正交。第一轮把"被测代码从不执行"推进到"核心修复点被真实执行"是实质进步但门禁完整性fail-on-missing与集成层真 spawn / 真编译 / 真事件落库)仍是发布前硬缺口。
---
*审计模型: Opus4 子代理并行)*
*审计时间: 2026-06-08*
*仓库状态: 源码未改动(只读审计)*