diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 855c277..8fa4626 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,9 +46,12 @@ jobs: - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master with: toolchain: stable + components: rust-analyzer - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: cargo test run: cargo test --workspace + - name: LSP cold/warm benchmark + run: python3 tests/host-consumer/lsp_benchmark.py --omc target/debug/omc - name: Install cargo-tarpaulin uses: taiki-e/install-action@ec28e287910af896fd98e04056d31fa68607e7ad # v2.77.4 with: diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 36244b8..e74630d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -25,7 +25,7 @@ jobs: - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master with: toolchain: stable - components: llvm-tools-preview + components: llvm-tools-preview, rust-analyzer - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Install cargo-llvm-cov uses: taiki-e/install-action@c070f87102a1c75b3183910f391c1cb887fe13c8 # v2.77.6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90d65da..3e7a883 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,18 +31,21 @@ jobs: - name: Package (linux) if: matrix.os == 'ubuntu-latest' run: | + tar czf omc-agent-tool-linux-x86_64.tar.gz -C target/release omc omc-mcp omc-team tar czf omc-hud-linux-x86_64.tar.gz -C target/release omc-hud tar czf omc-team-linux-x86_64.tar.gz -C target/release omc-team tar czf omc-xcmd-linux-x86_64.tar.gz -C target/release omc-xcmd - name: Package (macos) if: matrix.os == 'macos-latest' run: | + tar czf omc-agent-tool-macos.tar.gz -C target/release omc omc-mcp omc-team tar czf omc-hud-macos.tar.gz -C target/release omc-hud tar czf omc-team-macos.tar.gz -C target/release omc-team tar czf omc-xcmd-macos.tar.gz -C target/release omc-xcmd - name: Package (windows) if: matrix.os == 'windows-latest' run: | + powershell -Command "Compress-Archive -Path target/release/omc.exe,target/release/omc-mcp.exe,target/release/omc-team.exe -DestinationPath omc-agent-tool-windows-x86_64.zip" powershell -Command "Compress-Archive -Path target/release/omc-hud.exe -DestinationPath omc-hud-windows-x86_64.zip" powershell -Command "Compress-Archive -Path target/release/omc-team.exe -DestinationPath omc-team-windows-x86_64.zip" powershell -Command "Compress-Archive -Path target/release/omc-xcmd.exe -DestinationPath omc-xcmd-windows-x86_64.zip" @@ -69,10 +72,11 @@ jobs: run: | ls -la artifacts/ find artifacts -type f -name "*.tar.gz" -o -name "*.zip" | head -20 + find artifacts -type f \( -name "*.tar.gz" -o -name "*.zip" \) -print0 | sort -z | xargs -0 sha256sum > artifacts/SHA256SUMS - name: Create Release uses: softprops/action-gh-release@v2 with: - files: artifacts/**/*.tar.gz,artifacts/**/*.zip + files: artifacts/**/*.tar.gz,artifacts/**/*.zip,artifacts/SHA256SUMS generate_release_notes: true prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }} env: diff --git a/.gitignore b/.gitignore index afe78ac..d8e26cf 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ tmp/ .claude/statusline.* .claude-flow/ .omc/ +.omx/ .swarm/ .gitnexus ruvector.db diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c61b266..203d650 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -44,7 +44,7 @@ oh-my-claudecode-RS/ └── tests/macro-tests/ integration tests for proc macros ``` -18 crates + 1 integration test target. All under one `[workspace]` with shared `[profile.release]` settings. +19 crates plus integration test targets. All under one `[workspace]` with shared `[profile.release]` settings. ## Dependency graph diff --git a/CHANGELOG.md b/CHANGELOG.md index fc8235f..58416be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [0.2.0] — 2026-08-18 + +- Added an MCP-owned, bounded project-level rust-analyzer pool with observable + reuse/PID evidence while preserving the one-shot CLI fallback. +- Added versioned MCP v1 schema compatibility checks that reject removed tools/fields, new required inputs, type changes, narrowed enums, and tightened bounds. +- Added unified `omc status [--json]` platform, host, goal, team, and interop diagnostics. +- Added Python MCP session discovery, explicit close, and idle-session reclamation. +- Made pooled LSP sessions self-healing after transport failures, added accurate + cold/warm reuse telemetry, and gated warm latency with a real MCP benchmark. +- Centralized the 16 released capabilities and their 32 MCP tool mappings in a + single catalog, with dependency availability diagnostics and a registry + consistency gate. +- Added repeatable cold-CLI and warm-MCP discovery latency budgets for release + bundles. + +### Added + +- Added fail-closed `--force` replacement for Claude, Codex, and Hermes MCP + registrations with atomic writes and adjacent configuration backups. +- Added byte-accurate UTF-8 Python output limits with explicit truncation + markers and deterministic UTF-8 subprocess I/O on Windows. +- Split CLI dispatch, MCP agent tools, and DAP/LSP transports into focused + modules so new host-neutral adapters do not accumulate in monolithic files. + +- Added the bounded `omc.debug.v1` / `debug_inspect` adapter for explicit + launch/attach sessions through externally supplied stdio DAP adapters. + +- Unified `omc mcp` stdio entry that reuses the `omc-mcp` server library. +- `omc setup --host codex|claude` registration of the `omc-rs -> omc mcp` + host server, with idempotent writes and fail-closed conflict handling. +- Versioned host-neutral agent-tool contracts for capabilities, routing, + workflow evidence, typed results, hash edits, artifacts, LSP, and Python. + ## [0.1.0] — 2026-05-05 First usable release. 13/13 HUD elements implemented; cold-start under 5ms target (median 3.81ms on Windows 11 / Ryzen 9800X3D, 10-run sample). @@ -56,4 +91,6 @@ First usable release. 13/13 HUD elements implemented; cold-start under 5ms targe - Reference TypeScript implementation: [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode) (Apache 2.0, Yeachan-Heo); only external contracts consumed (Claude Code stdin schema, `~/.claude/settings.json` `statusLine.command` interface, OMC `.omc/state/` path conventions) - Initial production skeleton authored 2026-05-05 by Codex via the `codex-rescue` agent — attribution preserved in commit [`d813abf`](https://github.com/2233admin/oh-my-claudecode-RS/commit/d813abf) +[Unreleased]: https://github.com/2233admin/oh-my-claudecode-RS/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/2233admin/oh-my-claudecode-RS/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/2233admin/oh-my-claudecode-RS/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index fb24095..615fb18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,12 +1,12 @@ # OMC-RS — oh-my-claudecode in Rust -Rust rewrite of oh-my-claudecode: a toolkit for Claude Code that adds agent orchestration, hooks, skills, MCP routing, statusline, context injection, and multi-provider git integration. 18 crates, 51K+ lines, 392 tests. +Rust rewrite of oh-my-claudecode: a toolkit for Claude Code that adds agent orchestration, hooks, skills, MCP routing, statusline, context injection, and multi-provider git integration. 19 crates, 63K+ lines, 1,176 tests. ## Build and Test ```bash cargo build # debug build -cargo test --workspace # run all 392 tests +cargo test --workspace # run all 1,176 tests cargo test -p omc-team # single crate cargo clippy --workspace -- -D warnings cargo fmt --check @@ -92,7 +92,7 @@ protocol_version: "1.0" # absent = v0 (legacy) - Integration tests go in `tests/` directory per crate. - Use `tempfile` for filesystem tests, `tokio::test` for async tests. - Test behavior, not implementation. One logical assertion per test case. -- Current: 392 tests, 0 failures. Do not merge code that breaks this. +- Current: 1,176 tests, 0 failures. Do not merge code that breaks this. ## Commits and PRs diff --git a/Cargo.toml b/Cargo.toml index 30b5315..c7c3cbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ members = [ ] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2024" license = "MIT" authors = ["2233admin"] @@ -46,6 +46,7 @@ thiserror = "2" anyhow = "1" async-trait = "0.1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +sha2 = "0.10" [profile.release] opt-level = "z" diff --git a/README.md b/README.md index c258059..201a9de 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![CI](https://github.com/2233admin/oh-my-claudecode-RS/actions/workflows/ci.yml/badge.svg)](https://github.com/2233admin/oh-my-claudecode-RS/actions) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Rust](https://img.shields.io/badge/Rust-1.85+-orange.svg)](https://www.rust-lang.org) -[![Tests](https://img.shields.io/badge/tests-816-brightgreen.svg)](#build--test) +[![Tests](https://img.shields.io/badge/tests-1176-brightgreen.svg)](#build--test) [![Binary Size](https://img.shields.io/badge/binary-397%20KB-brightgreen.svg)](#performance) ## What is OMC-RS @@ -20,15 +20,27 @@ A **Rust rewrite** of [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-cl | Metric | Value | |--------|-------| -| Crates | **17** | -| Lines of Rust | **42,000+** | -| Tests | **816** | +| Crates | **19** | +| Lines of Rust | **63,000+** | +| Tests | **1,176** | | HUD cold-start median | **3.81ms** (Win11, Ryzen 9800X3D) | | Binary size (HUD) | **397 KB** | | Edition | Rust 2024, rustc 1.85+ | ## Quick Start +For Hermes, download the `omc-agent-tool-*` archive from the latest GitHub +Release, extract all three adjacent binaries, then register the MCP server: + +```bash +omc setup --host hermes +omc doctor --host hermes --json +``` + +Hermes launches `omc mcp`; `omc-mcp` remains the compatibility entrypoint and +`omc-team` provides the existing team process bridge. Keep the three binaries +in the same directory. + ```bash # Clone git clone https://github.com/2233admin/oh-my-claudecode-RS.git @@ -60,14 +72,43 @@ Add to `~/.claude/settings.json`: # HUD cargo run -p omc-hud -# CLI +# CLI help and host setup cargo run -p omc-cli -- --help +cargo run -p omc-cli -- setup --host codex +cargo run -p omc-cli -- doctor --host codex --json +cargo run -p omc-cli -- status --json + +# MCP stdio server for Agent hosts (the legacy omc-mcp binary remains valid) +cargo run -p omc-cli -- mcp + +# The long-running MCP process reuses a bounded rust-analyzer session per project. + +# Host-neutral tools +cargo run -p omc-cli -- tool capabilities +cargo run -p omc-cli -- tool route --task "review the repository architecture" +cargo run -p omc-cli -- tool python-repl --action execute --session-id demo \ + --code "print(6 * 7)" --allow-side-effects +# MCP process lifecycle actions: list_sessions and close +# External stdio DAP adapter; launch/attach requires explicit opt-in +cargo run -p omc-cli -- tool debug-inspect --adapter-command codelldb \ + --adapter-args-json '["--stdio"]' --mode launch --action threads \ + --launch-arguments '{"program":"target/debug/app"}' \ + --allow-side-effects + +# Durable project goal / checkpoint ledger +cargo run -p omc-cli -- goal create --id internalize-omx --objective "absorb portable workflow capabilities" +cargo run -p omc-cli -- goal start --id internalize-omx +cargo run -p omc-cli -- goal checkpoint --id internalize-omx --checkpoint-id s0 --summary "setup and host doctor verified" +cargo run -p omc-cli -- goal show --id internalize-omx # Team cargo run -p omc-team -- init cargo run -p omc-team -- start ./task.md --team-size 3 ``` +`setup` and `doctor` also accept the legacy aliases `omc-setup` and +`omc-doctor`. `doctor --json` is intended for automation and host adapters. + ## Key Features ### Agent Orchestration @@ -141,7 +182,7 @@ omc-shared (foundation -- types, config, routing, resilience) ```bash cargo build --release # optimized binary (~400 KB) -cargo test --workspace # all 816 tests +cargo test --workspace # all 1,176 tests cargo clippy --workspace -- -D warnings cargo fmt --check ``` @@ -224,9 +265,9 @@ Independent re-implementation. No source code copying from upstream. | 指标 | 数值 | |------|------| -| Crates | **17 个** | -| Rust 代码 | **42,000+ 行** | -| 测试 | **816 个** | +| Crates | **19 个** | +| Rust 代码 | **63,000+ 行** | +| 测试 | **1,176 个** | | HUD 冷启动 | **3.81ms** (Win11, Ryzen 9800X3D) | | 二进制大小 | **397 KB** | diff --git a/codex_research/omc-vibe-director-research.md b/codex_research/omc-vibe-director-research.md new file mode 100644 index 0000000..201dee5 --- /dev/null +++ b/codex_research/omc-vibe-director-research.md @@ -0,0 +1,33 @@ +# OMP Vibe / Director 研究记录 + +日期:2026-08-13 + +## 来源 + +- [OMP Vibe mode 官方文档](https://github.com/can1357/oh-my-pi/blob/main/docs/vibe-mode.md) +- [OMP task 官方文档](https://github.com/can1357/oh-my-pi/blob/main/docs/tools/task.md) +- [OMP session 官方文档](https://github.com/can1357/oh-my-pi/blob/main/docs/session.md) +- [OMP magic keywords 官方文档](https://github.com/can1357/oh-my-pi/blob/main/docs/magic-keywords.md) +- [OMP 官方 releases](https://github.com/can1357/oh-my-pi/releases) +- [OMP LICENSE](https://raw.githubusercontent.com/can1357/oh-my-pi/main/LICENSE) + +## 结论 + +`/vibe` 不是一个简单的路由标签,也不是新的 agent 核心。它把顶层交互会话变成 director,把工作交给可持久化的后台 worker;director 的工具集收窄为读操作、可选的 todo 和 worker 控制,worker 继续使用搜索、编辑、执行、构建能力。模式与 worker 状态写入 session,恢复时重新载入。 + +因此 OMC-RS 不应直接复制 `vibe_spawn` 等工具或再造一套执行循环。当前最小映射是: + +1. 继续复用 `omc-team` 的生命周期、任务图、worker health、通信和 runtime 启动能力。 +2. 通过统一 `omc` CLI 暴露 `team` 入口,入口只做进程桥接,不新增调度器。 +3. 等 Hermes/Sentinel 的真实消费契约明确后,再决定是否需要持久化 director/session mode;在此之前只保留 `tool route` 的渐进式路由。 + +## 当前来源状态 + +截至本记录日期,官方 release 页面显示最新版本为 `v17.2.15`,提交为 `06aecdd`;项目许可证为 MIT。release notes 仍在修复 `/vibe` 的工具集与 session mode 行为,说明该能力的生命周期细节仍应以版本锁定后的官方契约为准,不宜只抄名称。 + +## 对 OMC-RS 的边界 + +- 纳入:director/worker 的职责分离、后台任务可恢复、worker 状态可观测、单一入口消费已有 `omc-team`。 +- 暂不纳入:OMP 的 provider/model 体系、`workflowz` eval kernel、`vibe_*` 独立工具集、`omp compress` 等与当前 OMC-RS 契约重复或缺少真实宿主消费者的能力。 +- 验收:统一 `omc team ...` 能调用当前真实 `omc-team` runtime;release binary 在干净临时目录完成 init/session smoke;不能只靠模板输出或静态 help 证明完成。 + diff --git a/crates/omc-cli/Cargo.toml b/crates/omc-cli/Cargo.toml index 4750890..91cf2d4 100644 --- a/crates/omc-cli/Cargo.toml +++ b/crates/omc-cli/Cargo.toml @@ -11,14 +11,24 @@ repository.workspace = true clap = { version = "4", features = ["derive"] } serde = { workspace = true, features = ["derive"] } serde_yaml = "0.9" +serde_json = { workspace = true } +chrono = { workspace = true } dirs = { workspace = true } thiserror = { workspace = true } walkdir = "2.4" regex = "1" omc-host = { path = "../omc-host" } +omc-interop = { path = "../omc-interop" } +omc-mcp = { path = "../omc-mcp" } +omc-python = { path = "../omc-python" } omc-skills = { path = "../omc-skills" } +omc-shared = { path = "../omc-shared" } +omc-team = { path = "../omc-team" } tracing = { workspace = true } +[dev-dependencies] +tempfile = { workspace = true } + [[bin]] name = "omc" path = "src/main.rs" diff --git a/crates/omc-cli/src/commands/agent.rs b/crates/omc-cli/src/commands/agent.rs new file mode 100644 index 0000000..fd89073 --- /dev/null +++ b/crates/omc-cli/src/commands/agent.rs @@ -0,0 +1,359 @@ +#[derive(clap::Subcommand, Debug, Clone)] +pub enum ToolCommand { + /// List host-neutral capabilities + Capabilities { + /// Optional caller correlation ID + #[arg(long)] + request_id: Option, + }, + + /// Route a task by semantic complexity + Route { + /// Task text to route + #[arg(long)] + task: String, + + /// Optional role hint + #[arg(long)] + agent_type: Option, + + /// Number of previous failures for this task + #[arg(long, default_value_t = 0)] + previous_failures: usize, + + /// Optional caller correlation ID + #[arg(long)] + request_id: Option, + }, + + /// Query committed Code Intel artifacts through its read-only CLI surface + CodeIntelQuery { + /// Code Intel repository key, not a filesystem path + #[arg(long)] + repo: String, + + /// Published Code Intel artifact root + #[arg(long)] + artifact_root: Option, + + /// Optional checkout for freshness evaluation + #[arg(long)] + repo_path: Option, + + /// Optional artifact schema filter + #[arg(long)] + artifact_schema: Option, + + /// Optional artifact type filter + #[arg(long)] + artifact_type: Option, + + /// Optional text filter applied by Code Intel + #[arg(long)] + contains: Option, + + /// Optional canonical OMC artifact URI for bounded exact inspection + #[arg(long)] + artifact_uri: Option, + + /// Maximum number of matching artifacts (1..100) + #[arg(long)] + limit: Option, + + /// Optional caller correlation ID + #[arg(long)] + request_id: Option, + }, + + /// Read existing omc-team sessions, usage snapshot, or health + TeamObservability { + /// sessions, top, or doctor + #[arg(long, value_parser = ["sessions", "top", "doctor"])] + view: String, + + /// Project root containing .omc/team + #[arg(long, default_value = ".")] + root: String, + + /// Optional caller correlation ID + #[arg(long)] + request_id: Option, + }, + + /// Read a bounded, read-only OMC/OMX interop state snapshot + InteropSnapshot { + /// Project root containing .omc and .omx state + #[arg(long, default_value = ".")] + root: String, + + /// Maximum records returned per state collection (1..100) + #[arg(long, default_value_t = 20)] + limit: usize, + + /// Optional caller correlation ID + #[arg(long)] + request_id: Option, + }, + + /// Send one explicit task or message through the active OMC/OMX bridge + InteropBridge { + /// send_task or send_message + #[arg(long, value_parser = ["send_task", "send_message"])] + action: String, + + /// Source runtime: omc or omx + #[arg(long, value_parser = ["omc", "omx"])] + source: String, + + /// Target runtime: omc or omx + #[arg(long, value_parser = ["omc", "omx"])] + target: String, + + /// OMC task type, required for send_task + #[arg(long = "type", value_parser = ["analyze", "implement", "review", "test", "custom"])] + task_type: Option, + + /// Task description, required for send_task + #[arg(long)] + description: Option, + + /// Message content, required for send_message + #[arg(long)] + content: Option, + + /// Project root containing .omc interop state + #[arg(long, default_value = ".")] + root: String, + + /// Explicitly allow the durable interop write + #[arg(long)] + allow_side_effects: bool, + + /// Optional caller correlation ID + #[arg(long)] + request_id: Option, + }, + + /// Read Rust document symbols through a one-shot rust-analyzer adapter + LspDocumentSymbols { + /// Project root used to bound the file path + #[arg(long, default_value = ".")] + root: String, + + /// Project-relative Rust source file + #[arg(long)] + file: String, + + /// Request timeout in milliseconds (5000..60000) + #[arg(long)] + timeout_ms: Option, + + /// Optional caller correlation ID + #[arg(long)] + request_id: Option, + }, + + /// Inspect one explicit launch/attach session through an external stdio DAP adapter + DebugInspect { + /// Executable or command name for the external DAP adapter + #[arg(long)] + adapter_command: String, + + /// Optional JSON array of adapter arguments + #[arg(long)] + adapter_args_json: Option, + + /// launch or attach + #[arg(long)] + mode: String, + + /// threads, stackTrace, scopes, variables, modules, loadedSources, or output + #[arg(long)] + action: String, + + /// Project root used as the adapter working directory + #[arg(long, default_value = ".")] + root: String, + + /// JSON object passed to the launch request + #[arg(long)] + launch_arguments: Option, + + /// JSON object passed to the attach request + #[arg(long)] + attach_arguments: Option, + + #[arg(long)] + thread_id: Option, + #[arg(long)] + frame_id: Option, + #[arg(long)] + variables_reference: Option, + + /// Request timeout in milliseconds (5000..300000) + #[arg(long)] + timeout_ms: Option, + + /// Required because launch/attach starts or connects an external session + #[arg(long, default_value_t = false)] + allow_side_effects: bool, + + #[arg(long)] + request_id: Option, + }, + + /// Execute an explicit-side-effect Python cell in a process-local session + PythonRepl { + /// execute, get_state, reset, or interrupt + #[arg(long, value_parser = ["execute", "get_state", "reset", "interrupt"])] + action: String, + + /// Stable session key + #[arg(long)] + session_id: String, + + /// Python cell source; required for execute + #[arg(long)] + code: Option, + + /// Existing working directory for the Python subprocess + #[arg(long, default_value = ".")] + root: String, + + /// Execution timeout in milliseconds (1..300000) + #[arg(long)] + timeout_ms: Option, + + /// Required for execute, reset, or interrupt + #[arg(long, default_value_t = false)] + allow_side_effects: bool, + + #[arg(long)] + request_id: Option, + }, + + /// Advance a host-neutral clarify-plan-execute-verify workflow from supplied evidence + WorkflowAdvance { + /// Current workflow stage + #[arg(long)] + current_stage: String, + + #[arg(long, default_value_t = false)] + requirements_clarified: bool, + #[arg(long, default_value_t = false)] + all_tasks_assigned: bool, + #[arg(long, default_value_t = false)] + plan_approved: bool, + #[arg(long, default_value_t = false)] + all_tasks_completed: bool, + #[arg(long, default_value_t = false)] + verification_passed: bool, + #[arg(long, default_value_t = false)] + has_failures: bool, + #[arg(long, default_value_t = false)] + has_blockers: bool, + #[arg(long, default_value_t = 0)] + fix_attempts: u32, + #[arg(long, default_value_t = 3)] + max_fix_attempts: u32, + + /// Optional caller correlation ID + #[arg(long)] + request_id: Option, + }, + + /// Validate a structured subagent result against required fields + ResultValidate { + #[arg(long)] + result_type: String, + #[arg(long)] + payload: String, + /// Comma-separated required top-level fields + #[arg(long, value_delimiter = ',')] + required_fields: Vec, + #[arg(long)] + request_id: Option, + }, + + /// Apply a stale-safe, hash-anchored file edit + HashEdit { + #[arg(long, default_value = ".")] + root: String, + #[arg(long)] + path: String, + #[arg(long)] + start_line: usize, + #[arg(long)] + end_line: usize, + /// JSON array such as [{"line":1,"sha256":"..."}] + #[arg(long)] + anchors_json: String, + #[arg(long)] + replacement: String, + #[arg(long)] + expected_file_sha256: Option, + #[arg(long)] + request_id: Option, + }, +} + +#[derive(clap::Subcommand, Debug, Clone)] +pub enum GoalCommand { + /// Create a new planned goal + Create { + /// Stable goal identifier + #[arg(long)] + id: String, + + /// Goal objective + #[arg(long)] + objective: String, + + /// Optional agency or human owner + #[arg(long)] + owner: Option, + + /// Optional task ID used for dispatch correlation + #[arg(long)] + task_id: Option, + }, + + /// List all project goals + List, + + /// Show one goal + Show { + #[arg(long)] + id: String, + }, + + /// Move a planned or blocked goal to active + Start { + #[arg(long)] + id: String, + }, + + /// Mark a goal blocked with a durable reason + Block { + #[arg(long)] + id: String, + #[arg(long)] + reason: String, + }, + + /// Append a checkpoint to a goal + Checkpoint { + #[arg(long)] + id: String, + #[arg(long)] + checkpoint_id: String, + #[arg(long)] + summary: String, + }, + + /// Mark an active goal completed + Complete { + #[arg(long)] + id: String, + }, +} diff --git a/crates/omc-cli/src/commands/mod.rs b/crates/omc-cli/src/commands/mod.rs index 7d9d1a7..ef33c8c 100644 --- a/crates/omc-cli/src/commands/mod.rs +++ b/crates/omc-cli/src/commands/mod.rs @@ -2,6 +2,9 @@ use clap::{Parser, Subcommand}; +mod agent; +pub use agent::{GoalCommand, ToolCommand}; + /// oh-my-claudecode CLI dispatcher. /// /// Loads and outputs skill instructions for the given subcommand. @@ -14,17 +17,33 @@ pub struct Cli { /// All available OMC commands. /// -/// Each subcommand maps to a skill template. The dispatcher loads the -/// corresponding SKILL.md, substitutes `$ARGUMENTS`, and prints the -/// rendered content to stdout. +/// Most subcommands map to a skill template. Tool commands use the shared +/// JSON contract directly and do not render a skill. #[derive(Subcommand, Debug)] pub enum Commands { + /// Expose the stable OMC-RS agent tool contract + Tool { + #[command(subcommand)] + command: ToolCommand, + }, + + /// Create and resume a project-scoped goal ledger + Goal { + #[command(subcommand)] + command: GoalCommand, + }, + /// Setup OMC for a specific host + #[command(name = "setup", visible_alias = "omc-setup")] OmcSetup { - /// Target host: claude or codex - #[arg(long, value_parser = ["claude", "codex"])] + /// Target host: claude, codex, or Hermes MCP consumer + #[arg(long, value_parser = ["claude", "codex", "hermes"])] host: Option, + /// Hermes home directory; defaults to HERMES_HOME or ~/.hermes + #[arg(long, value_name = "PATH")] + hermes_home: Option, + /// Force overwrite existing configuration #[arg(long, default_value = "false")] force: bool, @@ -34,8 +53,39 @@ pub enum Commands { args: Vec, }, - /// Diagnose OMC installation and environment - OmcDoctor(SkillArgs), + /// Diagnose OMC host configuration and environment + #[command(name = "doctor", visible_alias = "omc-doctor")] + OmcDoctor { + /// Check only one host; omit to check both Claude Code and Codex CLI + #[arg(long, value_parser = ["claude", "codex"])] + host: Option, + + /// Emit machine-readable JSON instead of a human report + #[arg(long)] + json: bool, + + /// Include the 16 platform capabilities and dependency availability + #[arg(long)] + tools: bool, + }, + + /// Show one read-only project/platform health snapshot + Status { + /// Emit the versioned machine-readable contract + #[arg(long)] + json: bool, + }, + + /// Start the MCP stdio server + Mcp, + + /// Run the existing OMC team runtime through the unified CLI + #[command(name = "team")] + Team { + /// Arguments forwarded to the omc-team runtime + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, /// Configure notification preferences ConfigureNotifications(SkillArgs), diff --git a/crates/omc-cli/src/dispatch.rs b/crates/omc-cli/src/dispatch.rs index 98141b2..0d6edf3 100644 --- a/crates/omc-cli/src/dispatch.rs +++ b/crates/omc-cli/src/dispatch.rs @@ -1,26 +1,74 @@ //! Skill dispatch and template loading. -use crate::commands::{Cli, Commands, SkillArgs}; -use omc_host::HostKind; -use omc_skills::SkillRegistrar; +use crate::commands::{Cli, Commands, GoalCommand, SkillArgs, ToolCommand}; +use chrono::Utc; +use omc_interop::mcp_bridge::{ + InteropBridgeCliArgs, interop_bridge, interop_bridge_request_from_cli, +}; +use omc_interop::read_snapshot; +use omc_mcp::run_stdio; +use omc_python::{ + PythonReplService, PythonSessionError, PythonToolPayload, PythonToolRequest, ReplAction, +}; +use omc_shared::agent_tool::{ + RouteRequest, ToolError, ToolResponse, capabilities_payload, normalize_request_id, + route_agent_task, +}; +use omc_shared::code_intel::{CodeIntelQueryRequest, query_code_intel}; +use omc_shared::dap_adapter::{DebugInspectRequest, inspect_debug}; +use omc_shared::hash_edit::{HashEdit, LineAnchor}; +use omc_shared::lsp_adapter::{LspDocumentSymbolsRequest, query_document_symbols}; +use omc_shared::operation_contract::{ResultSchema, TypedSubagentResult}; +use omc_shared::workflow_contract::{ + WorkflowAdvanceRequest, WorkflowContext, WorkflowStage, advance_workflow, +}; +use omc_shared::{GoalCheckpoint, GoalLedger, GoalRecord, OmcPaths}; +use omc_team::team_observability; +use serde_json::{Value, json}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::process::Command; use thiserror::Error; +mod tool; +use tool::run_tool; +pub use tool::run_tool_value; +mod host; +use host::collect_doctor_reports; +use host::{run_doctor, run_setup_host}; +mod status; +mod templates; +use templates::{list_skills, load_template, substitute_arguments}; + #[derive(Debug, Error)] pub enum DispatchError { #[error("Skill template not found: {0}")] NotFound(String), #[error("I/O error: {0}")] Io(#[from] std::io::Error), + #[error("Serialization error: {0}")] + Serde(#[from] serde_json::Error), + #[error("Goal error: {0}")] + Goal(String), + #[error("State error: {0}")] + State(#[from] omc_shared::state::StateError), + #[error("Host configuration error: {0}")] + Host(String), + #[error("Team runtime error: {0}")] + Team(String), } /// Resolve the canonical skill name for a given command variant. fn skill_name(cmd: &Commands) -> Option<&'static str> { match cmd { + Commands::Tool { .. } => None, + Commands::Goal { .. } => None, Commands::OmcSetup { .. } => Some("omc-setup"), - Commands::OmcDoctor(_) => Some("omc-doctor"), + Commands::OmcDoctor { .. } => None, + Commands::Status { .. } => None, + Commands::Mcp => None, + Commands::Team { .. } => None, Commands::ConfigureNotifications(_) => Some("configure-notifications"), Commands::Hud(_) => Some("hud"), Commands::Skill(_) => Some("skill"), @@ -53,9 +101,14 @@ fn skill_name(cmd: &Commands) -> Option<&'static str> { /// Extract the skill args from any command variant. fn skill_args(cmd: &Commands) -> Option { match cmd { + Commands::Tool { .. } => None, + Commands::Goal { .. } => None, + Commands::OmcDoctor { .. } => None, + Commands::Status { .. } => None, + Commands::Mcp => None, + Commands::Team { .. } => None, Commands::OmcSetup { args, .. } => Some(SkillArgs { args: args.clone() }), - Commands::OmcDoctor(a) - | Commands::ConfigureNotifications(a) + Commands::ConfigureNotifications(a) | Commands::Hud(a) | Commands::Skill(a) | Commands::Skillify(a) @@ -86,6 +139,34 @@ fn skill_args(cmd: &Commands) -> Option { /// Main entry point for the CLI. pub fn run(cli: Cli) -> Result<(), DispatchError> { + if let Commands::Tool { command } = &cli.command { + return run_tool(command); + } + + if matches!(&cli.command, Commands::Mcp) { + run_stdio()?; + return Ok(()); + } + + if let Commands::Team { args } = &cli.command { + return run_team(args); + } + + if let Commands::Goal { command } = &cli.command { + let root = std::env::current_dir().map_err(DispatchError::Io)?; + return run_goal(command, &root); + } + + if let Commands::OmcDoctor { host, json, tools } = &cli.command { + let root = std::env::current_dir().map_err(DispatchError::Io)?; + return run_doctor(&root, host.as_deref(), *json, *tools); + } + + if let Commands::Status { json } = &cli.command { + let root = std::env::current_dir().map_err(DispatchError::Io)?; + return status::run_status(&root, *json); + } + if matches!(&cli.command, Commands::List) { list_skills(); return Ok(()); @@ -95,11 +176,12 @@ pub fn run(cli: Cli) -> Result<(), DispatchError> { if let Commands::OmcSetup { host: Some(host), force, + hermes_home, .. } = &cli.command { let root = std::env::current_dir().map_err(DispatchError::Io)?; - run_setup_host(&root, host, *force)?; + run_setup_host(&root, host, *force, hermes_home.as_deref())?; return Ok(()); } @@ -114,435 +196,122 @@ pub fn run(cli: Cli) -> Result<(), DispatchError> { Ok(()) } -/// Execute the real host setup flow: init project dirs, bootstrap .omc/, -/// discover and register skill sources, generate Codex manifest if needed. -fn run_setup_host(root: &Path, host: &str, force: bool) -> Result<(), DispatchError> { - let host_kind = HostKind::parse(host).map_err(DispatchError::NotFound)?; - - println!("Setting up OMC for host: {host_kind}"); - println!("Project root: {}\n", root.display()); - - // 1. Init host project structure (.claude/ or .codex/) - let adapter = omc_host::create_adapter(host_kind); - let init_report = adapter - .init_project(root) - .map_err(DispatchError::NotFound)?; - println!("Host directories ({}):", host_kind.config_dir_name()); - for p in &init_report.created { - println!(" + {}", p.display()); - } - for p in &init_report.unchanged { - println!(" = {} (exists)", p.display()); +/// Forward team operations to the existing runtime binary. +/// +/// Keeping this as a process bridge preserves omc-team's lifecycle and +/// runtime implementation while making it consumable from the single `omc` +/// entrypoint. It deliberately does not create a second scheduler. +fn run_team(args: &[String]) -> Result<(), DispatchError> { + let command = resolve_team_command(); + let status = Command::new(&command) + .args(args) + .status() + .map_err(DispatchError::Io)?; + if status.success() { + Ok(()) + } else { + Err(DispatchError::Team(format!( + "{} exited with {status}", + command.display() + ))) } +} - // 2. Bootstrap .omc/ directory structure - omc_skills::bootstrap::bootstrap_omc_dir(root).map_err(DispatchError::Io)?; - println!("\n.omc/ directory bootstrapped."); - - // 3. Discover skill sources in .omc/skills/ - let omc_skills_dir = root.join(".omc").join("skills"); - let sources = discover_skill_sources(&omc_skills_dir); - - // 4. Register skill sources into host skills directory - let host_skills_dir = root.join(host_kind.config_dir_name()).join("skills"); - let registrar = SkillRegistrar::new(&host_skills_dir); - - if sources.is_empty() { - println!( - "\nNo skill sources found in {}. Add skills with `omc skill add`.", - omc_skills_dir.display() - ); +fn resolve_team_command() -> PathBuf { + let names = if cfg!(windows) { + ["omc-team.exe", "omc-team"] } else { - println!("\nRegistering {} skill source(s):", sources.len()); - let result = registrar.register_all(&sources); - for linked in &result.linked { - println!(" linked: {}", linked.display()); - } - for copied in &result.copied { - println!(" copied: {}", copied.display()); - } - for skipped in &result.skipped { - println!(" exists: {}", skipped.display()); - } - for (path, err) in &result.errors { - println!(" error: {} — {err}", path.display()); - } + ["omc-team", "omc-team.exe"] + }; - // 5. For Codex: generate skills.toml manifest - if host_kind == HostKind::Codex { - let mut loader = omc_skills::SkillLoader::new(&host_skills_dir); - if let Ok(discovered) = loader.discover_all() { - let manifest = registrar.generate_codex_manifest(&discovered); - let manifest_path = root.join(".codex").join("skills.toml"); - if force || !manifest_path.exists() { - std::fs::write(&manifest_path, &manifest).map_err(DispatchError::Io)?; - println!( - "\nGenerated Codex manifest: {} ({} skills)", - manifest_path.display(), - discovered.len() - ); - } else { - println!( - "\nCodex manifest exists (use --force to overwrite): {}", - manifest_path.display() - ); + if let Ok(executable) = std::env::current_exe() { + let mut directory = executable.parent(); + for _ in 0..=2 { + if let Some(dir) = directory { + for name in names { + let candidate = dir.join(name); + if candidate.is_file() { + return candidate; + } } + directory = dir.parent(); } } } - println!("\nSetup complete for {host_kind}."); - Ok(()) + PathBuf::from(names[0]) } -/// Discover skill source directories under `dir`. -/// -/// Returns `(source_dir, link_name)` pairs for directories containing SKILL.md. -fn discover_skill_sources(dir: &Path) -> Vec<(PathBuf, String)> { - let mut sources = Vec::new(); - if !dir.is_dir() { - return sources; - } - - let Ok(entries) = std::fs::read_dir(dir) else { - return sources; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() && path.join("SKILL.md").exists() { - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); - sources.push((path, name)); +fn run_goal(command: &GoalCommand, root: &Path) -> Result<(), DispatchError> { + let ledger = GoalLedger::new(OmcPaths::new_with_root(root.join(".omc"))); + let now = || Utc::now().to_rfc3339(); + + match command { + GoalCommand::Create { + id, + objective, + owner, + task_id, + } => { + let mut goal = GoalRecord::new(id, objective, now()); + goal.owner = owner.clone(); + if let Some(task_id) = task_id { + goal.attach_task(task_id).map_err(DispatchError::Goal)?; + } + ledger.create(&goal)?; + print_goal(&goal)?; } - } - - sources -} - -/// Load a skill template by name from the skills directory. -/// -/// Search order: -/// 1. `OMC_SKILLS_DIR` environment variable -/// 2. `/crates/omc-skills/src/templates/.md` -/// 3. `~/.omc/skills//SKILL.md` -fn load_template(name: &str) -> Result { - let candidates = template_search_paths(name); - - for path in &candidates { - if path.exists() { - return std::fs::read_to_string(path).map_err(Into::into); + GoalCommand::List => { + println!("{}", serde_json::to_string_pretty(&ledger.list()?)?); } - } - - Err(DispatchError::NotFound(format!( - "{name} (searched: {})", - candidates - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(", ") - ))) -} - -/// Build the ordered list of paths to check for a skill template. -static OMC_SKILLS_DIR: &str = "OMC_SKILLS_DIR"; -static OMC_HOME: &str = "OMC_HOME"; - -fn template_search_paths(name: &str) -> Vec { - let mut paths = Vec::new(); - - // 1. Explicit environment override - if let Ok(dir) = std::env::var(OMC_SKILLS_DIR) { - paths.push(PathBuf::from(dir).join(format!("{name}.md"))); - } - - // 2. Sibling crate templates directory (dev / repo layout) - if let Ok(exe) = std::env::current_exe() { - // Walk up from target//build/omc-cli-*/out or target// - // to find the workspace root, then look in crates/omc-skills/src/templates/ - if let Some(ws) = find_workspace_root(&exe) { - paths.push( - ws.join("crates/omc-skills/src/templates") - .join(format!("{name}.md")), - ); + GoalCommand::Show { id } => { + print_goal(&ledger.load(id)?)?; } - } - - // Also try relative to CWD (useful during development) - if let Ok(cwd) = std::env::current_dir() { - paths.push( - cwd.join("crates/omc-skills/src/templates") - .join(format!("{name}.md")), - ); - // Also check sibling project - paths.push( - cwd.join("../oh-my-claudecode-RS/crates/omc-skills/src/templates") - .join(format!("{name}.md")), - ); - } - - // 3. Installed OMC home directory - if let Some(home) = omc_home() { - paths.push(home.join("skills").join(name).join("SKILL.md")); - } - - paths -} - -/// Attempt to find the workspace root by looking for Cargo.toml with `[workspace]`. -fn find_workspace_root(from: &Path) -> Option { - let mut dir = from.to_path_buf(); - loop { - if !dir.pop() { - break; + GoalCommand::Start { id } => { + let mut goal = ledger.load(id)?; + goal.start(now()).map_err(DispatchError::Goal)?; + ledger.save(&goal)?; + print_goal(&goal)?; } - let cargo_toml = dir.join("Cargo.toml"); - if cargo_toml.exists() - && let Ok(content) = std::fs::read_to_string(&cargo_toml) - && content.contains("[workspace]") - { - return Some(dir); + GoalCommand::Block { id, reason } => { + let mut goal = ledger.load(id)?; + goal.block(reason, now()).map_err(DispatchError::Goal)?; + ledger.save(&goal)?; + print_goal(&goal)?; } - } - None -} - -/// Resolve the OMC home directory. -fn omc_home() -> Option { - if let Ok(home) = std::env::var(OMC_HOME) { - return Some(PathBuf::from(home)); - } - dirs::home_dir().map(|h| h.join(".omc")) -} - -/// Replace `$ARGUMENTS` placeholders in a template with the user's arguments. -fn substitute_arguments(template: &str, arguments: &str) -> String { - template - .replace("{{ARGUMENTS}}", arguments) - .replace("$ARGUMENTS", arguments) -} - -/// List all discoverable skills by scanning the template directories. -fn list_skills() { - let mut skills = BTreeMap::new(); - - // Scan templates directory - let search_roots = template_search_roots(); - for root in &search_roots { - if root.is_dir() { - for entry in std::fs::read_dir(root).into_iter().flatten() { - let entry = match entry { - Ok(e) => e, - Err(_) => continue, - }; - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("md") - && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) - { - let desc = extract_description(&path); - skills.entry(stem.to_string()).or_insert(desc); - } - } + GoalCommand::Checkpoint { + id, + checkpoint_id, + summary, + } => { + let mut goal = ledger.load(id)?; + goal.add_checkpoint(GoalCheckpoint { + checkpoint_id: checkpoint_id.clone(), + summary: summary.clone(), + recorded_at: now(), + artifact_refs: Vec::new(), + }) + .map_err(DispatchError::Goal)?; + goal.updated_at = now(); + ledger.save(&goal)?; + print_goal(&goal)?; } - } - - if skills.is_empty() { - println!("No skills found. Set OMC_SKILLS_DIR or install skills to ~/.omc/skills/"); - return; - } - - println!("{:<30} Description", "Skill"); - println!("{:<30} -----------", "-----"); - for (name, desc) in &skills { - let desc_str = desc.as_deref().unwrap_or(""); - println!("{name:<30} {desc_str}"); - } -} - -/// Get directories to scan for skill listing. -fn template_search_roots() -> Vec { - let mut roots = Vec::new(); - - if let Ok(dir) = std::env::var(OMC_SKILLS_DIR) { - roots.push(PathBuf::from(dir)); - } - - if let Ok(exe) = std::env::current_exe() - && let Some(ws) = find_workspace_root(&exe) - { - roots.push(ws.join("crates/omc-skills/src/templates")); - } - - if let Ok(cwd) = std::env::current_dir() { - let dev_path = cwd.join("crates/omc-skills/src/templates"); - if dev_path.is_dir() { - roots.push(dev_path); + GoalCommand::Complete { id } => { + let mut goal = ledger.load(id)?; + goal.complete(now()).map_err(DispatchError::Goal)?; + ledger.save(&goal)?; + print_goal(&goal)?; } } - if let Some(home) = omc_home() { - roots.push(home.join("skills")); - } - - roots -} - -/// Extract the description from a skill template's YAML frontmatter. -fn extract_description(path: &Path) -> Option { - let content = std::fs::read_to_string(path).ok()?; - parse_frontmatter_description(&content) + Ok(()) } -/// Parse the `description` field from YAML frontmatter delimited by `---`. -fn parse_frontmatter_description(content: &str) -> Option { - let trimmed = content.trim_start(); - if !trimmed.starts_with("---") { - return None; - } - - let after_first = &trimmed[3..]; - let end = after_first.find("---")?; - let frontmatter = &after_first[..end]; - - for line in frontmatter.lines() { - let line = line.trim(); - if let Some(value) = line.strip_prefix("description:") { - let value = value.trim().trim_matches('"').trim_matches('\''); - if !value.is_empty() { - return Some(value.to_string()); - } - } - } - - None +fn print_goal(goal: &GoalRecord) -> Result<(), DispatchError> { + println!("{}", serde_json::to_string_pretty(goal)?); + Ok(()) } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_substitute_arguments_dollar() { - let template = "Run with:\n```text\n$ARGUMENTS\n```\n"; - let result = substitute_arguments(template, "hello world"); - assert_eq!(result, "Run with:\n```text\nhello world\n```\n"); - } - - #[test] - fn test_substitute_arguments_braces() { - let template = "Task: {{ARGUMENTS}}"; - let result = substitute_arguments(template, "hello world"); - assert_eq!(result, "Task: hello world"); - } - - #[test] - fn test_substitute_arguments_both_formats() { - let template = "$ARGUMENTS and {{ARGUMENTS}}"; - let result = substitute_arguments(template, "test"); - assert_eq!(result, "test and test"); - } - - #[test] - fn test_substitute_no_placeholder() { - let template = "No placeholder here"; - let result = substitute_arguments(template, "args"); - assert_eq!(result, "No placeholder here"); - } - - #[test] - fn test_substitute_empty_arguments() { - let template = "Args: $ARGUMENTS"; - let result = substitute_arguments(template, ""); - assert_eq!(result, "Args: "); - } - - #[test] - fn test_parse_frontmatter_description() { - let content = r#"--- -description: "A test skill" -name: test ---- - -# Content"#; - let desc = parse_frontmatter_description(content); - assert_eq!(desc, Some("A test skill".to_string())); - } - - #[test] - fn test_parse_frontmatter_no_description() { - let content = r#"--- -name: test ---- - -# Content"#; - let desc = parse_frontmatter_description(content); - assert_eq!(desc, None); - } - - #[test] - fn test_parse_frontmatter_empty() { - let content = "no frontmatter here"; - let desc = parse_frontmatter_description(content); - assert_eq!(desc, None); - } - - #[test] - fn test_skill_args_joined() { - let args = SkillArgs { - args: vec!["hello".into(), "world".into()], - }; - assert_eq!(args.joined(), "hello world"); - } - - #[test] - fn test_skill_args_empty() { - let args = SkillArgs { args: vec![] }; - assert_eq!(args.joined(), ""); - } - - #[test] - fn test_skill_names_unique() { - // Verify all commands map to distinct skill names (except aliases) - let commands = vec![ - "omc-setup", - "omc-doctor", - "configure-notifications", - "hud", - "skill", - "skillify", - "trace", - "verify", - "visual-verdict", - "wiki", - "learner", - "remember", - "ask", - "autoresearch", - "ccg", - "cancel", - "debug", - "deep-dive", - "deepinit", - "external-context", - "project-session-manager", - "psm", - "release", - "self-improve", - "omc-teams", - "plan", - "deep-interview", - ]; - let mut sorted = commands.clone(); - sorted.sort(); - sorted.dedup(); - assert_eq!( - commands.len(), - sorted.len(), - "duplicate skill names detected" - ); - } -} +#[path = "dispatch/tests.rs"] +mod tests; diff --git a/crates/omc-cli/src/dispatch/host.rs b/crates/omc-cli/src/dispatch/host.rs new file mode 100644 index 0000000..98b7bd0 --- /dev/null +++ b/crates/omc-cli/src/dispatch/host.rs @@ -0,0 +1,221 @@ +use super::*; +use omc_host::{HostDoctorReport, HostKind}; +use omc_skills::SkillRegistrar; + +pub(super) fn collect_doctor_reports( + root: &Path, + host: Option<&str>, +) -> Result, DispatchError> { + let hosts = match host { + Some(host) => vec![HostKind::parse(host).map_err(DispatchError::NotFound)?], + None => vec![HostKind::Claude, HostKind::Codex], + }; + + Ok(hosts + .into_iter() + .map(|kind| omc_host::create_adapter(kind).doctor(root)) + .collect()) +} + +pub(super) fn run_doctor( + root: &Path, + host: Option<&str>, + json: bool, + tools: bool, +) -> Result<(), DispatchError> { + let reports = collect_doctor_reports(root, host)?; + let capabilities = tools.then(omc_shared::agent_tool::capabilities_payload); + + if json { + if let Some(capabilities) = &capabilities { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "hosts": reports, + "capabilities": capabilities.capabilities, + }))? + ); + } else { + println!("{}", serde_json::to_string_pretty(&reports)?); + } + } else { + println!("OMC Doctor"); + println!("Project root: {}\n", root.display()); + for report in &reports { + let status = if report.ready { "READY" } else { "ISSUES" }; + println!("[{}] {}", status, report.host); + for message in &report.messages { + println!(" - {message}"); + } + println!(); + } + if let Some(capabilities) = &capabilities { + println!("Platform capabilities:"); + for capability in &capabilities.capabilities { + println!( + " [{:?}] {}", + capability.availability.status, capability.name + ); + if let Some(reason) = &capability.availability.reason { + println!(" {reason}"); + } + } + } + } + + if reports.iter().all(|report| report.ready) { + Ok(()) + } else { + Err(DispatchError::NotFound( + "one or more requested hosts are not ready".into(), + )) + } +} + +/// Execute the real host setup flow: init project dirs, bootstrap .omc/, +/// discover and register skill sources, generate Codex manifest if needed. +pub(super) fn run_setup_host( + root: &Path, + host: &str, + force: bool, + hermes_home: Option<&Path>, +) -> Result<(), DispatchError> { + if host.eq_ignore_ascii_case("hermes") { + let home = omc_host::mcp_reg::resolve_hermes_home(hermes_home); + let changed = omc_host::mcp_reg::ensure_hermes_mcp_server_with_force( + &home, + &omc_host::mcp_reg::omc_server_definition(), + force, + ) + .map_err(DispatchError::Host)?; + println!("Setting up OMC for Hermes MCP consumer"); + println!("Hermes home: {}", home.display()); + println!( + "MCP server `omc-rs`: {}", + if changed { + "registered" + } else { + "already registered" + } + ); + return Ok(()); + } + + let host_kind = HostKind::parse(host).map_err(DispatchError::NotFound)?; + + println!("Setting up OMC for host: {host_kind}"); + println!("Project root: {}\n", root.display()); + + // 1. Init host project structure (.claude/ or .codex/) + let adapter = omc_host::create_adapter(host_kind); + let init_report = adapter + .init_project(root) + .map_err(DispatchError::NotFound)?; + println!("Host directories ({}):", host_kind.config_dir_name()); + for p in &init_report.created { + println!(" + {}", p.display()); + } + for p in &init_report.unchanged { + println!(" = {} (exists)", p.display()); + } + + let mcp_server = omc_host::mcp_reg::omc_server_definition(); + let mcp_changed = + omc_host::mcp_reg::ensure_mcp_server_with_force(root, host_kind, &mcp_server, force) + .map_err(DispatchError::Host)?; + println!( + "MCP server `omc-rs`: {}", + if mcp_changed { + "registered" + } else { + "already registered" + } + ); + + // 2. Bootstrap .omc/ directory structure + omc_skills::bootstrap::bootstrap_omc_dir(root).map_err(DispatchError::Io)?; + println!("\n.omc/ directory bootstrapped."); + + // 3. Discover skill sources in .omc/skills/ + let omc_skills_dir = root.join(".omc").join("skills"); + let sources = discover_skill_sources(&omc_skills_dir); + + // 4. Register skill sources into host skills directory + let host_skills_dir = root.join(host_kind.config_dir_name()).join("skills"); + let registrar = SkillRegistrar::new(&host_skills_dir); + + if sources.is_empty() { + println!( + "\nNo skill sources found in {}. Add skills with `omc skill add`.", + omc_skills_dir.display() + ); + } else { + println!("\nRegistering {} skill source(s):", sources.len()); + let result = registrar.register_all(&sources); + for linked in &result.linked { + println!(" linked: {}", linked.display()); + } + for copied in &result.copied { + println!(" copied: {}", copied.display()); + } + for skipped in &result.skipped { + println!(" exists: {}", skipped.display()); + } + for (path, err) in &result.errors { + println!(" error: {} — {err}", path.display()); + } + + // 5. For Codex: generate skills.toml manifest + if host_kind == HostKind::Codex { + let mut loader = omc_skills::SkillLoader::new(&host_skills_dir); + if let Ok(discovered) = loader.discover_all() { + let manifest = registrar.generate_codex_manifest(&discovered); + let manifest_path = root.join(".codex").join("skills.toml"); + if force || !manifest_path.exists() { + std::fs::write(&manifest_path, &manifest).map_err(DispatchError::Io)?; + println!( + "\nGenerated Codex manifest: {} ({} skills)", + manifest_path.display(), + discovered.len() + ); + } else { + println!( + "\nCodex manifest exists (use --force to overwrite): {}", + manifest_path.display() + ); + } + } + } + } + + println!("\nSetup complete for {host_kind}."); + Ok(()) +} + +/// Discover skill source directories under `dir`. +/// +/// Returns `(source_dir, link_name)` pairs for directories containing SKILL.md. +pub(super) fn discover_skill_sources(dir: &Path) -> Vec<(PathBuf, String)> { + let mut sources = Vec::new(); + if !dir.is_dir() { + return sources; + } + + let Ok(entries) = std::fs::read_dir(dir) else { + return sources; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && path.join("SKILL.md").exists() { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + sources.push((path, name)); + } + } + + sources +} diff --git a/crates/omc-cli/src/dispatch/status.rs b/crates/omc-cli/src/dispatch/status.rs new file mode 100644 index 0000000..ddbdb00 --- /dev/null +++ b/crates/omc-cli/src/dispatch/status.rs @@ -0,0 +1,187 @@ +use std::path::Path; + +use omc_shared::capability_catalog::{AvailabilityStatus, capabilities}; +use omc_shared::{GoalLedger, GoalStatus, OmcPaths}; +use serde::Serialize; +use serde_json::Value; + +use super::{DispatchError, collect_doctor_reports}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct StatusReport { + pub schema_version: &'static str, + pub overall: &'static str, + pub project_root: String, + pub capabilities: CapabilitySummary, + pub hosts: Vec, + pub goals: Probe, + pub team: Probe, + pub interop: Probe, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct CapabilitySummary { + pub total: usize, + pub mcp_tools: usize, + pub available: usize, + pub conditional: usize, + pub unavailable: usize, +} + +#[derive(Debug, Serialize)] +pub(super) struct Probe { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl Probe { + fn data(value: T) -> Self { + match serde_json::to_value(value) { + Ok(data) => Self { + ok: true, + data: Some(data), + error: None, + }, + Err(error) => Self { + ok: false, + data: None, + error: Some(error.to_string()), + }, + } + } + + fn result(result: Result) -> Self { + match result { + Ok(value) => match serde_json::to_value(value) { + Ok(data) => Self { + ok: true, + data: Some(data), + error: None, + }, + Err(error) => Self { + ok: false, + data: None, + error: Some(error.to_string()), + }, + }, + Err(error) => Self { + ok: false, + data: None, + error: Some(error.to_string()), + }, + } + } +} + +pub(super) fn build_status(root: &Path) -> StatusReport { + let catalog = capabilities(); + let available = catalog + .iter() + .filter(|item| item.availability.status == AvailabilityStatus::Available) + .count(); + let conditional = catalog + .iter() + .filter(|item| item.availability.status == AvailabilityStatus::Conditional) + .count(); + let unavailable = catalog.len() - available - conditional; + let hosts = collect_doctor_reports(root, None).unwrap_or_default(); + let goals = match GoalLedger::new(OmcPaths::new_with_root(root.join(".omc"))).list() { + Ok(goals) => Probe::data(serde_json::json!({ + "total": goals.len(), + "active": goals.iter().filter(|goal| goal.status == GoalStatus::Active).count(), + "blocked": goals.iter().filter(|goal| goal.status == GoalStatus::Blocked).count(), + })), + Err(error) => Probe::result::(Err(error)), + }; + let team = match omc_team::team_observability(root, "doctor") { + Ok(payload) => { + let ok = payload + .data + .get("ok") + .and_then(Value::as_bool) + .unwrap_or(false); + Probe { + ok, + data: Some(payload.data), + error: None, + } + } + Err(error) => Probe::result::(Err(error)), + }; + let interop = match omc_interop::read_snapshot(&root.to_string_lossy(), Some(10)) { + Ok(snapshot) => Probe::data(serde_json::json!({ + "mode": snapshot.interop_mode, + "directWriteEnabled": snapshot.direct_write_enabled, + "taskCount": snapshot.normalized_task_count, + "messageCount": snapshot.shared_message_count, + "omxTeamCount": snapshot.omx_teams.len(), + })), + Err(error) => Probe::result::(Err(error)), + }; + let healthy = unavailable == 0 + && hosts.iter().all(|host| host.ready) + && goals.ok + && team.ok + && interop.ok; + + StatusReport { + schema_version: "omc.status.v1", + overall: if healthy { "ready" } else { "degraded" }, + project_root: root.display().to_string(), + capabilities: CapabilitySummary { + total: catalog.len(), + mcp_tools: catalog.iter().map(|item| item.mcp_tools.len()).sum(), + available, + conditional, + unavailable, + }, + hosts, + goals, + team, + interop, + } +} + +pub(super) fn run_status(root: &Path, json: bool) -> Result<(), DispatchError> { + let status = build_status(root); + if json { + println!("{}", serde_json::to_string_pretty(&status)?); + } else { + println!("OMC Status [{}]", status.overall.to_uppercase()); + println!("Project: {}", status.project_root); + println!( + "Capabilities: {}/{} available, {} conditional, {} unavailable ({} MCP tools)", + status.capabilities.available, + status.capabilities.total, + status.capabilities.conditional, + status.capabilities.unavailable, + status.capabilities.mcp_tools + ); + println!( + "Hosts: {}", + status + .hosts + .iter() + .map(|host| format!( + "{}={}", + host.host, + if host.ready { "ready" } else { "issues" } + )) + .collect::>() + .join(", ") + ); + for (name, probe) in [ + ("Goals", &status.goals), + ("Team", &status.team), + ("Interop", &status.interop), + ] { + println!("{name}: {}", if probe.ok { "ready" } else { "issues" }); + } + } + Ok(()) +} diff --git a/crates/omc-cli/src/dispatch/templates.rs b/crates/omc-cli/src/dispatch/templates.rs new file mode 100644 index 0000000..8e4fcdc --- /dev/null +++ b/crates/omc-cli/src/dispatch/templates.rs @@ -0,0 +1,199 @@ +use super::*; + +/// Load a skill template by name from the skills directory. +/// +/// Search order: +/// 1. `OMC_SKILLS_DIR` environment variable +/// 2. `/crates/omc-skills/src/templates/.md` +/// 3. `~/.omc/skills//SKILL.md` +pub(super) fn load_template(name: &str) -> Result { + let candidates = template_search_paths(name); + + for path in &candidates { + if path.exists() { + return std::fs::read_to_string(path).map_err(Into::into); + } + } + + Err(DispatchError::NotFound(format!( + "{name} (searched: {})", + candidates + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", ") + ))) +} + +/// Build the ordered list of paths to check for a skill template. +static OMC_SKILLS_DIR: &str = "OMC_SKILLS_DIR"; +static OMC_HOME: &str = "OMC_HOME"; + +pub(super) fn template_search_paths(name: &str) -> Vec { + let mut paths = Vec::new(); + + // 1. Explicit environment override + if let Ok(dir) = std::env::var(OMC_SKILLS_DIR) { + paths.push(PathBuf::from(dir).join(format!("{name}.md"))); + } + + // 2. Sibling crate templates directory (dev / repo layout) + if let Ok(exe) = std::env::current_exe() { + // Walk up from target//build/omc-cli-*/out or target// + // to find the workspace root, then look in crates/omc-skills/src/templates/ + if let Some(ws) = find_workspace_root(&exe) { + paths.push( + ws.join("crates/omc-skills/src/templates") + .join(format!("{name}.md")), + ); + } + } + + // Also try relative to CWD (useful during development) + if let Ok(cwd) = std::env::current_dir() { + paths.push( + cwd.join("crates/omc-skills/src/templates") + .join(format!("{name}.md")), + ); + // Also check sibling project + paths.push( + cwd.join("../oh-my-claudecode-RS/crates/omc-skills/src/templates") + .join(format!("{name}.md")), + ); + } + + // 3. Installed OMC home directory + if let Some(home) = omc_home() { + paths.push(home.join("skills").join(name).join("SKILL.md")); + } + + paths +} + +/// Attempt to find the workspace root by looking for Cargo.toml with `[workspace]`. +pub(super) fn find_workspace_root(from: &Path) -> Option { + let mut dir = from.to_path_buf(); + loop { + if !dir.pop() { + break; + } + let cargo_toml = dir.join("Cargo.toml"); + if cargo_toml.exists() + && let Ok(content) = std::fs::read_to_string(&cargo_toml) + && content.contains("[workspace]") + { + return Some(dir); + } + } + None +} + +/// Resolve the OMC home directory. +pub(super) fn omc_home() -> Option { + if let Ok(home) = std::env::var(OMC_HOME) { + return Some(PathBuf::from(home)); + } + dirs::home_dir().map(|h| h.join(".omc")) +} + +/// Replace `$ARGUMENTS` placeholders in a template with the user's arguments. +pub(super) fn substitute_arguments(template: &str, arguments: &str) -> String { + template + .replace("{{ARGUMENTS}}", arguments) + .replace("$ARGUMENTS", arguments) +} + +/// List all discoverable skills by scanning the template directories. +pub(super) fn list_skills() { + let mut skills = BTreeMap::new(); + + // Scan templates directory + let search_roots = template_search_roots(); + for root in &search_roots { + if root.is_dir() { + for entry in std::fs::read_dir(root).into_iter().flatten() { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("md") + && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) + { + let desc = extract_description(&path); + skills.entry(stem.to_string()).or_insert(desc); + } + } + } + } + + if skills.is_empty() { + println!("No skills found. Set OMC_SKILLS_DIR or install skills to ~/.omc/skills/"); + return; + } + + println!("{:<30} Description", "Skill"); + println!("{:<30} -----------", "-----"); + for (name, desc) in &skills { + let desc_str = desc.as_deref().unwrap_or(""); + println!("{name:<30} {desc_str}"); + } +} + +/// Get directories to scan for skill listing. +pub(super) fn template_search_roots() -> Vec { + let mut roots = Vec::new(); + + if let Ok(dir) = std::env::var(OMC_SKILLS_DIR) { + roots.push(PathBuf::from(dir)); + } + + if let Ok(exe) = std::env::current_exe() + && let Some(ws) = find_workspace_root(&exe) + { + roots.push(ws.join("crates/omc-skills/src/templates")); + } + + if let Ok(cwd) = std::env::current_dir() { + let dev_path = cwd.join("crates/omc-skills/src/templates"); + if dev_path.is_dir() { + roots.push(dev_path); + } + } + + if let Some(home) = omc_home() { + roots.push(home.join("skills")); + } + + roots +} + +/// Extract the description from a skill template's YAML frontmatter. +pub(super) fn extract_description(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + parse_frontmatter_description(&content) +} + +/// Parse the `description` field from YAML frontmatter delimited by `---`. +pub(super) fn parse_frontmatter_description(content: &str) -> Option { + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return None; + } + + let after_first = &trimmed[3..]; + let end = after_first.find("---")?; + let frontmatter = &after_first[..end]; + + for line in frontmatter.lines() { + let line = line.trim(); + if let Some(value) = line.strip_prefix("description:") { + let value = value.trim().trim_matches('"').trim_matches('\''); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + + None +} diff --git a/crates/omc-cli/src/dispatch/tests.rs b/crates/omc-cli/src/dispatch/tests.rs new file mode 100644 index 0000000..bfcd019 --- /dev/null +++ b/crates/omc-cli/src/dispatch/tests.rs @@ -0,0 +1,151 @@ +use super::templates::parse_frontmatter_description; +use super::*; +use omc_host::HostKind; + +#[test] +fn test_substitute_arguments_dollar() { + let template = "Run with:\n```text\n$ARGUMENTS\n```\n"; + let result = substitute_arguments(template, "hello world"); + assert_eq!(result, "Run with:\n```text\nhello world\n```\n"); +} + +#[test] +fn test_substitute_arguments_braces() { + let template = "Task: {{ARGUMENTS}}"; + let result = substitute_arguments(template, "hello world"); + assert_eq!(result, "Task: hello world"); +} + +#[test] +fn test_substitute_arguments_both_formats() { + let template = "$ARGUMENTS and {{ARGUMENTS}}"; + let result = substitute_arguments(template, "test"); + assert_eq!(result, "test and test"); +} + +#[test] +fn test_substitute_no_placeholder() { + let template = "No placeholder here"; + let result = substitute_arguments(template, "args"); + assert_eq!(result, "No placeholder here"); +} + +#[test] +fn test_substitute_empty_arguments() { + let template = "Args: $ARGUMENTS"; + let result = substitute_arguments(template, ""); + assert_eq!(result, "Args: "); +} + +#[test] +fn test_parse_frontmatter_description() { + let content = r#"--- +description: "A test skill" +name: test +--- + +# Content"#; + let desc = parse_frontmatter_description(content); + assert_eq!(desc, Some("A test skill".to_string())); +} + +#[test] +fn test_parse_frontmatter_no_description() { + let content = r#"--- +name: test +--- + +# Content"#; + let desc = parse_frontmatter_description(content); + assert_eq!(desc, None); +} + +#[test] +fn test_parse_frontmatter_empty() { + let content = "no frontmatter here"; + let desc = parse_frontmatter_description(content); + assert_eq!(desc, None); +} + +#[test] +fn test_skill_args_joined() { + let args = SkillArgs { + args: vec!["hello".into(), "world".into()], + }; + assert_eq!(args.joined(), "hello world"); +} + +#[test] +fn test_skill_args_empty() { + let args = SkillArgs { args: vec![] }; + assert_eq!(args.joined(), ""); +} + +#[test] +fn test_skill_names_unique() { + // Verify all commands map to distinct skill names (except aliases) + let commands = vec![ + "omc-setup", + "configure-notifications", + "hud", + "skill", + "skillify", + "trace", + "verify", + "visual-verdict", + "wiki", + "learner", + "remember", + "ask", + "autoresearch", + "ccg", + "cancel", + "debug", + "deep-dive", + "deepinit", + "external-context", + "project-session-manager", + "psm", + "release", + "self-improve", + "omc-teams", + "plan", + "deep-interview", + ]; + let mut sorted = commands.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!( + commands.len(), + sorted.len(), + "duplicate skill names detected" + ); +} + +#[test] +fn doctor_reports_selected_ready_host() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".codex")).unwrap(); + std::fs::write(tmp.path().join(".codex/config.toml"), "").unwrap(); + + let reports = collect_doctor_reports(tmp.path(), Some("codex")).unwrap(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].host, HostKind::Codex); + assert!(reports[0].ready); +} + +#[test] +fn doctor_rejects_unknown_host() { + let tmp = tempfile::tempdir().unwrap(); + let error = collect_doctor_reports(tmp.path(), Some("sentinel")).unwrap_err(); + assert!(error.to_string().contains("unknown host")); +} + +#[test] +fn status_has_a_stable_schema_and_complete_catalog_counts() { + let tmp = tempfile::tempdir().unwrap(); + let status = super::status::build_status(tmp.path()); + assert_eq!(status.schema_version, "omc.status.v1"); + assert_eq!(status.capabilities.total, 16); + assert_eq!(status.capabilities.mcp_tools, 32); +} diff --git a/crates/omc-cli/src/dispatch/tool.rs b/crates/omc-cli/src/dispatch/tool.rs new file mode 100644 index 0000000..7742892 --- /dev/null +++ b/crates/omc-cli/src/dispatch/tool.rs @@ -0,0 +1,373 @@ +use super::*; + +mod control; +use control::run_control_tool; + +pub(super) fn run_tool(command: &ToolCommand) -> Result<(), DispatchError> { + let response = run_tool_value(command)?; + println!("{}", serde_json::to_string_pretty(&response)?); + Ok(()) +} + +/// Execute a tool command without rendering it, for host and transport tests. +pub fn run_tool_value(command: &ToolCommand) -> Result { + if let Some(response) = run_control_tool(command)? { + return Ok(response); + } + let response = match command { + ToolCommand::LspDocumentSymbols { + root, + file, + timeout_ms, + request_id, + } => { + let request_id = + normalize_request_id(request_id.as_deref(), "cli-lsp-document-symbols"); + match query_document_symbols(&LspDocumentSymbolsRequest { + working_directory: Some(root.clone()), + file: file.clone(), + timeout_ms: *timeout_ms, + }) { + Ok(payload) => serde_json::to_value(ToolResponse::success(request_id, payload))?, + Err(error) => { + serde_json::to_value(ToolResponse::::failure(request_id, error))? + } + } + } + ToolCommand::DebugInspect { + adapter_command, + adapter_args_json, + mode, + action, + root, + launch_arguments, + attach_arguments, + thread_id, + frame_id, + variables_reference, + timeout_ms, + allow_side_effects, + request_id, + } => { + let request_id = normalize_request_id(request_id.as_deref(), "cli-debug-inspect"); + let parse_json = |raw: &Option, field: &str| -> Result, Value> { + raw.as_deref() + .map(|value| { + serde_json::from_str(value).map_err(|error| { + serde_json::to_value(ToolResponse::::failure( + request_id.clone(), + omc_shared::agent_tool::ToolError::new( + "invalid_request", + format!("{field} must be valid JSON: {error}"), + ), + )) + .unwrap_or_else(|_| json!({"ok": false})) + }) + }) + .transpose() + }; + let adapter_args = match adapter_args_json { + Some(raw) => match serde_json::from_str::>(raw) { + Ok(args) => args, + Err(error) => { + return Ok(serde_json::to_value(ToolResponse::::failure( + request_id, + omc_shared::agent_tool::ToolError::new( + "invalid_request", + format!("adapterArgsJson must be a JSON array: {error}"), + ), + ))?); + } + }, + None => Vec::new(), + }; + let launch_arguments = match parse_json(launch_arguments, "launchArguments") { + Ok(value) => value, + Err(response) => return Ok(response), + }; + let attach_arguments = match parse_json(attach_arguments, "attachArguments") { + Ok(value) => value, + Err(response) => return Ok(response), + }; + let request = match serde_json::from_value::(json!({ + "adapterCommand": adapter_command, + "adapterArgs": adapter_args, + "workingDirectory": root, + "mode": mode, + "action": action, + "launchArguments": launch_arguments, + "attachArguments": attach_arguments, + "threadId": thread_id, + "frameId": frame_id, + "variablesReference": variables_reference, + "timeoutMs": timeout_ms, + "allowSideEffects": allow_side_effects + })) { + Ok(request) => request, + Err(error) => { + return Ok(serde_json::to_value(ToolResponse::::failure( + request_id, + omc_shared::agent_tool::ToolError::new( + "invalid_request", + error.to_string(), + ), + ))?); + } + }; + match inspect_debug(&request) { + Ok(payload) => serde_json::to_value(ToolResponse::success(request_id, payload))?, + Err(error) => { + serde_json::to_value(ToolResponse::::failure(request_id, error))? + } + } + } + ToolCommand::PythonRepl { + action, + session_id, + code, + root, + timeout_ms, + allow_side_effects, + request_id, + } => { + let request_id = normalize_request_id(request_id.as_deref(), "cli-python-repl"); + let action = match serde_json::from_value::(Value::String(action.clone())) { + Ok(action) => action, + Err(error) => { + return Ok(serde_json::to_value(ToolResponse::::failure( + request_id, + omc_shared::agent_tool::ToolError::new( + "invalid_request", + error.to_string(), + ), + ))?); + } + }; + let request = PythonToolRequest { + action: action.clone(), + session_id: session_id.clone(), + code: code.clone(), + execution_timeout: *timeout_ms, + project_dir: Some(root.clone()), + allow_side_effects: *allow_side_effects, + }; + let (input, allow_side_effects) = match request.into_input() { + Ok(value) => value, + Err(error) => { + return Ok(serde_json::to_value(ToolResponse::::failure( + request_id, + python_error(error), + ))?); + } + }; + if !matches!(action, ReplAction::GetState | ReplAction::ListSessions) + && !allow_side_effects + { + return Ok(serde_json::to_value(ToolResponse::::failure( + request_id, + omc_shared::agent_tool::ToolError::new( + omc_shared::agent_tool::error_codes::SIDE_EFFECTS_NOT_ALLOWED, + "allowSideEffects=true is required for Python execution or session mutation", + ), + ))?); + } + let service = PythonReplService::new(); + let result = match action { + ReplAction::Execute => service.execute(&input).and_then(|value| { + serde_json::to_value(value) + .map_err(|e| PythonSessionError::Failed(e.to_string())) + }), + ReplAction::GetState => service.state(session_id, Some(root)).and_then(|value| { + serde_json::to_value(value) + .map_err(|e| PythonSessionError::Failed(e.to_string())) + }), + ReplAction::Reset => service.reset(session_id, Some(root)).and_then(|value| { + serde_json::to_value(value) + .map_err(|e| PythonSessionError::Failed(e.to_string())) + }), + ReplAction::Interrupt => { + service.interrupt(session_id, Some(root)).and_then(|value| { + serde_json::to_value(value) + .map_err(|e| PythonSessionError::Failed(e.to_string())) + }) + } + ReplAction::ListSessions => service.list_sessions().and_then(|value| { + serde_json::to_value(value) + .map_err(|e| PythonSessionError::Failed(e.to_string())) + }), + ReplAction::Close => service.close(session_id, Some(root)).and_then(|closed| { + serde_json::to_value(serde_json::json!({ "closed": closed })) + .map_err(|e| PythonSessionError::Failed(e.to_string())) + }), + }; + match result { + Ok(result) => { + let project_dir = std::path::Path::new( + &input.project_dir.clone().unwrap_or_else(|| ".".into()), + ) + .canonicalize() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + serde_json::to_value(ToolResponse::success( + request_id, + PythonToolPayload { + operation: "python.eval", + action, + session_id: input.research_session_id, + project_dir, + session_scope: "cli-process", + result, + side_effects: if allow_side_effects { + vec!["local Python code may read/write files, spawn processes, or use network".into()] + } else { + Vec::new() + }, + }, + ))? + } + Err(error) => serde_json::to_value(ToolResponse::::failure( + request_id, + python_error(error), + ))?, + } + } + ToolCommand::WorkflowAdvance { + current_stage, + requirements_clarified, + all_tasks_assigned, + plan_approved, + all_tasks_completed, + verification_passed, + has_failures, + has_blockers, + fix_attempts, + max_fix_attempts, + request_id, + } => { + let request_id = normalize_request_id(request_id.as_deref(), "cli-workflow-advance"); + match serde_json::from_value::(Value::String(current_stage.clone())) { + Ok(stage) => serde_json::to_value(ToolResponse::success( + request_id, + advance_workflow(&WorkflowAdvanceRequest { + current_stage: stage, + context: WorkflowContext { + requirements_clarified: *requirements_clarified, + all_tasks_assigned: *all_tasks_assigned, + plan_approved: *plan_approved, + all_tasks_completed: *all_tasks_completed, + verification_passed: *verification_passed, + has_failures: *has_failures, + has_blockers: *has_blockers, + fix_attempts: *fix_attempts, + max_fix_attempts: *max_fix_attempts, + }, + }), + ))?, + Err(error) => serde_json::to_value(ToolResponse::::failure( + request_id, + omc_shared::agent_tool::ToolError::new("invalid_request", error.to_string()), + ))?, + } + } + ToolCommand::ResultValidate { + result_type, + payload, + required_fields, + request_id, + } => { + let request_id = normalize_request_id(request_id.as_deref(), "cli-result-validate"); + match serde_json::from_str::(payload) { + Ok(payload) => { + let schema = ResultSchema::new(result_type.clone(), required_fields.clone()); + match TypedSubagentResult::new(result_type.clone(), payload, Vec::new()) + .and_then(|result| result.validate_against(&schema).map(|_| result)) + { + Ok(result) => { + serde_json::to_value(ToolResponse::success(request_id, result))? + } + Err(error) => serde_json::to_value(ToolResponse::::failure( + request_id, + omc_shared::agent_tool::ToolError::new("invalid_request", error), + ))?, + } + } + Err(error) => serde_json::to_value(ToolResponse::::failure( + request_id, + omc_shared::agent_tool::ToolError::new("invalid_request", error.to_string()), + ))?, + } + } + ToolCommand::HashEdit { + root, + path, + start_line, + end_line, + anchors_json, + replacement, + expected_file_sha256, + request_id, + } => { + let request_id = normalize_request_id(request_id.as_deref(), "cli-hash-edit"); + match serde_json::from_str::>(anchors_json) { + Ok(anchors) if !anchors.is_empty() => { + let mut edit = + HashEdit::new(path, *start_line, *end_line, anchors, replacement); + edit.expected_file_sha256 = expected_file_sha256.clone(); + match edit.apply(Path::new(root)) { + Ok(result) => { + serde_json::to_value(ToolResponse::success(request_id, result))? + } + Err(error) => { + let code = if matches!( + error, + omc_shared::hash_edit::HashEditError::StaleAnchor { .. } + | omc_shared::hash_edit::HashEditError::StaleFile { .. } + ) { + "stale_edit" + } else { + "edit_failed" + }; + serde_json::to_value(ToolResponse::::failure( + request_id, + omc_shared::agent_tool::ToolError::new(code, error.to_string()), + ))? + } + } + } + Ok(_) => serde_json::to_value(ToolResponse::::failure( + request_id, + omc_shared::agent_tool::ToolError::new( + "invalid_request", + "anchors must not be empty", + ), + ))?, + Err(error) => serde_json::to_value(ToolResponse::::failure( + request_id, + omc_shared::agent_tool::ToolError::new("invalid_request", error.to_string()), + ))?, + } + } + _ => { + return Err(DispatchError::NotFound( + "tool command was not handled by the CLI dispatcher".into(), + )); + } + }; + + Ok(response) +} + +fn python_error(error: PythonSessionError) -> omc_shared::agent_tool::ToolError { + let code = match &error { + PythonSessionError::InvalidRequest(_) => { + omc_shared::agent_tool::error_codes::INVALID_REQUEST + } + PythonSessionError::Unavailable(_) => { + omc_shared::agent_tool::error_codes::ADAPTER_UNAVAILABLE + } + PythonSessionError::Timeout(_) => "execution_timeout", + PythonSessionError::Failed(_) => omc_shared::agent_tool::error_codes::UPSTREAM_FAILED, + }; + omc_shared::agent_tool::ToolError::new(code, error.to_string()) +} diff --git a/crates/omc-cli/src/dispatch/tool/control.rs b/crates/omc-cli/src/dispatch/tool/control.rs new file mode 100644 index 0000000..aa2276f --- /dev/null +++ b/crates/omc-cli/src/dispatch/tool/control.rs @@ -0,0 +1,119 @@ +use super::*; + +pub(super) fn run_control_tool(command: &ToolCommand) -> Result, DispatchError> { + let response = match command { + ToolCommand::Capabilities { request_id } => serde_json::to_value(ToolResponse::success( + normalize_request_id(request_id.as_deref(), "cli-capabilities"), + capabilities_payload(), + ))?, + ToolCommand::Route { + task, + agent_type, + previous_failures, + request_id, + } => serde_json::to_value(ToolResponse::success( + normalize_request_id(request_id.as_deref(), "cli-route"), + route_agent_task(&RouteRequest { + task: task.clone(), + agent_type: agent_type.clone(), + previous_failures: Some(*previous_failures), + }), + ))?, + ToolCommand::CodeIntelQuery { + repo, + artifact_root, + repo_path, + artifact_schema, + artifact_type, + contains, + artifact_uri, + limit, + request_id, + } => { + let request_id = normalize_request_id(request_id.as_deref(), "cli-code-intel"); + match query_code_intel(&CodeIntelQueryRequest { + repo: repo.clone(), + artifact_root: artifact_root.clone(), + repo_path: repo_path.clone(), + artifact_schema: artifact_schema.clone(), + artifact_type: artifact_type.clone(), + contains: contains.clone(), + artifact_uri: artifact_uri.clone(), + limit: *limit, + request_id: Some(request_id.clone()), + }) { + Ok(payload) => serde_json::to_value(ToolResponse::success(request_id, payload))?, + Err(error) => { + serde_json::to_value(ToolResponse::::failure(request_id, error))? + } + } + } + ToolCommand::TeamObservability { + view, + root, + request_id, + } => { + let request_id = normalize_request_id(request_id.as_deref(), "cli-team-observability"); + match team_observability(Path::new(root), view) { + Ok(payload) => serde_json::to_value(ToolResponse::success(request_id, payload))?, + Err(error) => serde_json::to_value(ToolResponse::::failure( + request_id, + omc_shared::agent_tool::ToolError::new("upstream_failed", error), + ))?, + } + } + ToolCommand::InteropSnapshot { + root, + limit, + request_id, + } => { + let request_id = normalize_request_id(request_id.as_deref(), "cli-interop-snapshot"); + match read_snapshot(root, Some(*limit)) { + Ok(payload) => serde_json::to_value(ToolResponse::success(request_id, payload))?, + Err(error) => serde_json::to_value(ToolResponse::::failure( + request_id, + omc_shared::agent_tool::ToolError::new("invalid_request", error.to_string()), + ))?, + } + } + ToolCommand::InteropBridge { + action, + source, + target, + task_type, + description, + content, + root, + allow_side_effects, + request_id, + } => { + let request_id = normalize_request_id(request_id.as_deref(), "cli-interop-bridge"); + match interop_bridge_request_from_cli(InteropBridgeCliArgs { + action, + source, + target, + task_type: task_type.as_deref(), + description: description.as_deref(), + content: content.as_deref(), + working_directory: root, + allow_side_effects: *allow_side_effects, + }) { + Ok(request) => match interop_bridge(&request) { + Ok(payload) => { + serde_json::to_value(ToolResponse::success(request_id, payload))? + } + Err(error) => serde_json::to_value(ToolResponse::::failure( + request_id, + ToolError::new(error.code(), error.to_string()), + ))?, + }, + Err(error) => serde_json::to_value(ToolResponse::::failure( + request_id, + ToolError::new(error.code(), error.to_string()), + ))?, + } + } + _ => return Ok(None), + }; + Ok(Some(response)) +} diff --git a/crates/omc-cli/src/lib.rs b/crates/omc-cli/src/lib.rs new file mode 100644 index 0000000..ff2b813 --- /dev/null +++ b/crates/omc-cli/src/lib.rs @@ -0,0 +1,4 @@ +//! Reusable OMC CLI command and dispatch surface. + +pub mod commands; +pub mod dispatch; diff --git a/crates/omc-cli/src/main.rs b/crates/omc-cli/src/main.rs index 87f7be8..67d8a37 100644 --- a/crates/omc-cli/src/main.rs +++ b/crates/omc-cli/src/main.rs @@ -1,9 +1,5 @@ -mod commands; - use clap::Parser; -use commands::Cli; - -mod dispatch; +use omc_cli::{commands::Cli, dispatch}; fn main() { let cli = Cli::parse(); diff --git a/crates/omc-cli/tests/agent_tool_contract.rs b/crates/omc-cli/tests/agent_tool_contract.rs new file mode 100644 index 0000000..542f038 --- /dev/null +++ b/crates/omc-cli/tests/agent_tool_contract.rs @@ -0,0 +1,405 @@ +use clap::Parser; +use omc_cli::commands::{Cli, Commands}; +use omc_cli::dispatch::run_tool_value; +use omc_mcp::McpTool; +use omc_mcp::agent_tools::WorkflowAdvanceTool; +use omc_mcp::python_tools::python_tools; +use serde_json::Value; +use std::io::Write; +use std::process::{Command, Stdio}; + +fn cli_response() -> Value { + let cli = Cli::try_parse_from([ + "omc", + "tool", + "workflow-advance", + "--current-stage", + "planning", + "--all-tasks-assigned", + "--plan-approved", + "--request-id", + "cross-transport", + ]) + .expect("CLI fixture parses"); + + let Commands::Tool { command } = cli.command else { + panic!("fixture must select a tool command"); + }; + run_tool_value(&command).expect("CLI tool response serializes") +} + +fn mcp_response() -> Value { + let result = WorkflowAdvanceTool.handle(serde_json::json!({ + "currentStage": "planning", + "allTasksAssigned": true, + "planApproved": true, + "requestId": "cross-transport" + })); + assert_eq!(result.is_error, None); + serde_json::from_str(&result.content[0].text).expect("MCP tool response is JSON") +} + +#[test] +fn cli_and_mcp_emit_equivalent_workflow_envelopes() { + assert_eq!(cli_response(), mcp_response()); +} + +#[test] +fn cli_team_observability_projects_existing_state_contract() { + let root = tempfile::tempdir().expect("temporary project root"); + let cli = Cli::try_parse_from(vec![ + "omc".to_string(), + "tool".to_string(), + "team-observability".to_string(), + "--view".to_string(), + "sessions".to_string(), + "--root".to_string(), + root.path().to_string_lossy().into_owned(), + "--request-id".to_string(), + "cli-team-observability".to_string(), + ]) + .expect("team observability CLI fixture parses"); + let Commands::Tool { command } = cli.command else { + panic!("fixture must select a tool command"); + }; + let response = run_tool_value(&command).expect("team observability response serializes"); + assert_eq!(response["ok"], true); + assert_eq!( + response["data"]["schemaVersion"], + "omc.team-observability.v1" + ); + assert_eq!(response["data"]["view"], "sessions"); + assert_eq!(response["data"]["data"], serde_json::json!([])); +} + +#[test] +fn cli_python_repl_requires_explicit_side_effect_opt_in() { + let cli = Cli::try_parse_from([ + "omc", + "tool", + "python-repl", + "--action", + "execute", + "--session-id", + "contract-test", + "--code", + "print(1)", + ]) + .expect("Python CLI fixture parses"); + let Commands::Tool { command } = cli.command else { + panic!("fixture must select a tool command"); + }; + let response = run_tool_value(&command).expect("Python CLI response serializes"); + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "side_effects_not_allowed"); +} + +#[test] +fn cli_debug_inspect_requires_explicit_side_effect_opt_in() { + let cli = Cli::try_parse_from([ + "omc", + "tool", + "debug-inspect", + "--adapter-command", + "missing-dap-adapter", + "--mode", + "launch", + "--action", + "threads", + "--launch-arguments", + r#"{"program":"target"}"#, + ]) + .expect("debug CLI fixture parses"); + let Commands::Tool { command } = cli.command else { + panic!("fixture must select a tool command"); + }; + let response = run_tool_value(&command).expect("debug CLI response serializes"); + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "side_effects_not_allowed"); +} + +#[test] +fn mcp_python_repl_keeps_session_state_when_available() { + let tools = python_tools(); + let tool = tools + .iter() + .find(|tool| tool.definition().name == "python_repl") + .expect("python tool is registered"); + let first = tool.handle(serde_json::json!({ + "action": "execute", + "sessionId": "contract-session", + "code": "value = 41", + "allowSideEffects": true, + "requestId": "python-1" + })); + let first: Value = serde_json::from_str(&first.content[0].text).expect("first response JSON"); + if first["ok"] == false && first["error"]["code"] == "adapter_unavailable" { + return; + } + assert_eq!(first["ok"], true); + + let second = tool.handle(serde_json::json!({ + "action": "execute", + "sessionId": "contract-session", + "code": "print(value + 1)", + "allowSideEffects": true, + "requestId": "python-2" + })); + let second: Value = + serde_json::from_str(&second.content[0].text).expect("second response JSON"); + assert_eq!(second["ok"], true); + assert_eq!(second["data"]["result"]["stdout"], "42\n"); +} + +#[cfg(windows)] +#[test] +fn unified_cli_starts_mcp_stdio_server() { + let mut child = Command::new(env!("CARGO_BIN_EXE_omc")) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("unified MCP server starts"); + let mut stdin = child.stdin.take().expect("MCP stdin is piped"); + let stdout = child.stdout.take().expect("MCP stdout is piped"); + let mut reader = std::io::BufReader::new(stdout); + + writeln!( + stdin, + r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{}}}}"# + ) + .expect("initialize request writes"); + stdin.flush().expect("initialize request flushes"); + let mut line = String::new(); + std::io::BufRead::read_line(&mut reader, &mut line).expect("initialize response reads"); + let initialize: Value = serde_json::from_str(&line).expect("initialize response is JSON"); + assert_eq!(initialize["result"]["serverInfo"]["name"], "omc-mcp"); + + writeln!( + stdin, + r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{{}}}}"# + ) + .expect("tools/list request writes"); + stdin.flush().expect("tools/list request flushes"); + line.clear(); + std::io::BufRead::read_line(&mut reader, &mut line).expect("tools/list response reads"); + let tools: Value = serde_json::from_str(&line).expect("tools/list response is JSON"); + let names = tools["result"]["tools"] + .as_array() + .expect("tools/list returns an array") + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect::>(); + assert!(names.contains(&"agent_capabilities")); + assert!(names.contains(&"python_repl")); + assert!(names.contains(&"debug_inspect")); + + drop(stdin); + assert!(child.wait().expect("MCP server exits").success()); +} + +#[test] +fn mcp_service_reuses_the_same_lsp_project_process() { + let analyzer_available = Command::new("rust-analyzer") + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()); + if !analyzer_available { + return; + } + let project = tempfile::tempdir().expect("temporary Rust project"); + std::fs::create_dir(project.path().join("src")).expect("src directory"); + std::fs::write( + project.path().join("Cargo.toml"), + "[package]\nname='lsp-reuse-fixture'\nversion='0.1.0'\nedition='2024'\n", + ) + .expect("manifest writes"); + std::fs::write( + project.path().join("src/lib.rs"), + "pub fn answer() -> u32 { 42 }\n", + ) + .expect("source writes"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_omc")) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("MCP service starts"); + let mut stdin = child.stdin.take().expect("MCP stdin"); + let mut reader = std::io::BufReader::new(child.stdout.take().expect("MCP stdout")); + let call = |id: u8, + stdin: &mut std::process::ChildStdin, + reader: &mut std::io::BufReader| { + let request = serde_json::json!({ + "jsonrpc": "2.0", "id": id, "method": "tools/call", "params": { + "name": "lsp_document_symbols", "arguments": { + "workingDirectory": project.path(), "file": "src/lib.rs", "timeoutMs": 60000 + } + } + }); + writeln!(stdin, "{request}").expect("tool request writes"); + stdin.flush().expect("tool request flushes"); + let mut line = String::new(); + std::io::BufRead::read_line(reader, &mut line).expect("tool response reads"); + let rpc: Value = serde_json::from_str(&line).expect("RPC response JSON"); + let envelope: Value = serde_json::from_str( + rpc["result"]["content"][0]["text"] + .as_str() + .expect("tool envelope text"), + ) + .expect("tool envelope JSON"); + assert_eq!(envelope["ok"], true, "{envelope}"); + envelope["data"].clone() + }; + let cold = call(1, &mut stdin, &mut reader); + let warm = call(2, &mut stdin, &mut reader); + assert_eq!(cold["sessionReused"], false); + assert_eq!(warm["sessionReused"], true); + assert_eq!(cold["serverProcessId"], warm["serverProcessId"]); + drop(stdin); + assert!(child.wait().expect("MCP service exits").success()); +} + +#[cfg(windows)] +#[test] +fn codex_project_smoke_runs_clarify_plan_execute_verify() { + let output = Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + PROJECT_SMOKE_SCRIPT, + ]) + .env("OMC_SMOKE_COMMAND", env!("CARGO_BIN_EXE_omc")) + .output() + .expect("PowerShell smoke starts"); + assert!( + output.status.success(), + "Codex project smoke failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(windows)] +const PROJECT_SMOKE_SCRIPT: &str = r###" +$ErrorActionPreference = 'Stop' +$OmCommand = $env:OMC_SMOKE_COMMAND +$project = Join-Path ([System.IO.Path]::GetTempPath()) ("omc-project-smoke-" + [guid]::NewGuid().ToString('N')) +$locationPushed = $false + +function Invoke-Om { + param([string[]]$Arguments) + + $info = [System.Diagnostics.ProcessStartInfo]::new() + $info.FileName = $OmCommand + $info.WorkingDirectory = (Get-Location).Path + $info.UseShellExecute = $false + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $info.Arguments = ($Arguments | ForEach-Object { + $escaped = $_ -replace '(\\*)"', '$1$1\"' + $escaped = $escaped -replace '(\\+)$', '$1$1' + '"' + $escaped + '"' + }) -join ' ' + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $info + [void]$process.Start() + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + [pscustomobject]@{ ExitCode = $process.ExitCode; Stdout = $stdout; Stderr = $stderr } +} + +function Invoke-OmJson { + param([string[]]$Arguments) + $result = Invoke-Om -Arguments $Arguments + if ($result.ExitCode -ne 0) { throw "omc failed: $($result.Stderr)" } + $result.Stdout | ConvertFrom-Json +} + +function Assert-Equal { + param($Actual, $Expected, [string]$Message) + if ($Actual -ne $Expected) { throw "$Message; expected '$Expected', got '$Actual'" } +} + +try { + New-Item -ItemType Directory -Path (Join-Path $project 'src') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $project 'src/main.rs') -Value 'fn main() { println!("before"); }' -NoNewline + Push-Location $project + $locationPushed = $true + + $setup = Invoke-Om -Arguments @('setup', '--host', 'codex', '--force') + if ($setup.ExitCode -ne 0) { throw "Codex setup failed: $($setup.Stderr)" } + foreach ($path in @('.codex/config.toml', '.codex/hooks.json', '.omc/skills')) { + if (-not (Test-Path (Join-Path $project $path))) { throw "setup did not create $path" } + } + $codexConfig = Get-Content -LiteralPath (Join-Path $project '.codex/config.toml') -Raw + if (-not $codexConfig.Contains('[mcp_servers.omc-rs]') -or + -not $codexConfig.Contains('command = "omc"') -or + -not $codexConfig.Contains('args = ["mcp"]')) { + throw "setup did not register unified omc mcp server: $codexConfig" + } + + $pythonDenied = Invoke-OmJson -Arguments @('tool', 'python-repl', '--action', 'execute', '--session-id', 'project-python', '--code', 'print(42)', '--request-id', 'project-python-denied') + Assert-Equal $pythonDenied.error.code 'side_effects_not_allowed' 'python side-effect gate' + $python = Invoke-OmJson -Arguments @('tool', 'python-repl', '--action', 'execute', '--session-id', 'project-python', '--code', 'print(42)', '--allow-side-effects', '--request-id', 'project-python') + Assert-Equal $python.ok $true 'python execute' + Assert-Equal $python.data.result.stdout.Trim() '42' 'python output' + + Invoke-OmJson -Arguments @('goal', 'create', '--id', 'project-smoke', '--objective', 'change and verify a small Rust project') | Out-Null + Invoke-OmJson -Arguments @('goal', 'start', '--id', 'project-smoke') | Out-Null + Invoke-OmJson -Arguments @('goal', 'checkpoint', '--id', 'project-smoke', '--checkpoint-id', 'clarified', '--summary', 'requirements clarified') | Out-Null + $initializing = Invoke-OmJson -Arguments @('tool', 'workflow-advance', '--current-stage', 'initializing', '--requirements-clarified', '--request-id', 'project-clarify') + Assert-Equal $initializing.data.nextStage 'planning' 'clarify stage' + + Invoke-OmJson -Arguments @('goal', 'checkpoint', '--id', 'project-smoke', '--checkpoint-id', 'planned', '--summary', 'plan approved and task assigned') | Out-Null + $planning = Invoke-OmJson -Arguments @('tool', 'workflow-advance', '--current-stage', 'planning', '--all-tasks-assigned', '--plan-approved', '--request-id', 'project-plan') + Assert-Equal $planning.data.nextStage 'executing' 'planning stage' + + $source = 'fn main() { println!("before"); }' + $sha = [System.Security.Cryptography.SHA256]::Create() + $digest = ([BitConverter]::ToString($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($source)))).Replace('-', '').ToLowerInvariant() + $sha.Dispose() + $anchors = ConvertTo-Json @(@{ line = 1; sha256 = $digest }) -Compress + $edited = Invoke-OmJson -Arguments @( + 'tool', 'hash-edit', '--root', '.', '--path', 'src/main.rs', '--start-line', '1', '--end-line', '1', + '--anchors-json', $anchors, '--replacement', 'fn main() { println!("after"); }', '--request-id', 'project-execute' + ) + if ($edited.ok -ne $true -or $edited.data.applied -ne $true) { throw "hash edit failed: $($edited | ConvertTo-Json -Compress -Depth 8)" } + Assert-Equal (Get-Content -LiteralPath (Join-Path $project 'src/main.rs') -Raw) 'fn main() { println!("after"); }' 'edited source' + + Invoke-OmJson -Arguments @('goal', 'checkpoint', '--id', 'project-smoke', '--checkpoint-id', 'executed', '--summary', 'hash edit applied') | Out-Null + $executing = Invoke-OmJson -Arguments @('tool', 'workflow-advance', '--current-stage', 'executing', '--all-tasks-completed', '--request-id', 'project-execute-complete') + Assert-Equal $executing.data.nextStage 'verifying' 'execution stage' + + $target = Join-Path $project 'target' + New-Item -ItemType Directory -Path $target -Force | Out-Null + $binary = Join-Path $target 'project-smoke.exe' + & rustc (Join-Path $project 'src/main.rs') -o $binary 2>&1 | Out-String | Write-Output + if ($LASTEXITCODE -ne 0) { throw 'rustc verification failed' } + $runOutput = & $binary + if ($LASTEXITCODE -ne 0) { throw 'project binary failed' } + Assert-Equal ($runOutput -join '').Trim() 'after' 'project output' + + $verified = Invoke-OmJson -Arguments @( + 'tool', 'result-validate', '--result-type', 'omc.project.verification.v1', + '--payload', '{"passed":true,"command":"rustc"}', '--required-fields', 'passed', '--request-id', 'project-verify' + ) + Assert-Equal $verified.data.resultType 'omc.project.verification.v1' 'verification result' + Invoke-OmJson -Arguments @('goal', 'checkpoint', '--id', 'project-smoke', '--checkpoint-id', 'verified', '--summary', 'rustc and project binary passed') | Out-Null + + $verifying = Invoke-OmJson -Arguments @('tool', 'workflow-advance', '--current-stage', 'verifying', '--verification-passed', '--request-id', 'project-verify-complete') + Assert-Equal $verifying.data.nextStage 'completed' 'verification stage' + $completed = Invoke-OmJson -Arguments @('goal', 'complete', '--id', 'project-smoke') + Assert-Equal $completed.status 'completed' 'goal status' + Assert-Equal $completed.checkpoints.Count 4 'checkpoint count' +} +finally { + if ($locationPushed) { Pop-Location } + if (Test-Path $project) { Remove-Item -LiteralPath $project -Recurse -Force } +} +"###; diff --git a/crates/omc-hooks/src/registry.rs b/crates/omc-hooks/src/registry.rs index af07dd9..fbfe021 100644 --- a/crates/omc-hooks/src/registry.rs +++ b/crates/omc-hooks/src/registry.rs @@ -81,12 +81,12 @@ impl HookRegistry { /// Returns the host-specific project hooks config path. /// - /// - `"claude"` → `/.claude/hooks.json` + /// - `"claude"` → `/.claude/settings.json` /// - `"codex"` → `/.codex/hooks.json` /// - other → `/.omc/hooks/.json` pub fn host_config_path(root: &Path, host: &str) -> PathBuf { match host { - "claude" => root.join(".claude").join("hooks.json"), + "claude" => root.join(".claude").join("settings.json"), "codex" => root.join(".codex").join("hooks.json"), _ => root.join(".omc").join("hooks").join(format!("{host}.json")), } @@ -687,7 +687,7 @@ mod tests { fn host_config_path_claude() { let root = PathBuf::from("/project"); let path = HookRegistry::host_config_path(&root, "claude"); - assert_eq!(path, PathBuf::from("/project/.claude/hooks.json")); + assert_eq!(path, PathBuf::from("/project/.claude/settings.json")); } #[test] @@ -719,8 +719,8 @@ mod tests { let hooks_dir = root.path().join(".claude"); std::fs::create_dir_all(&hooks_dir).unwrap(); std::fs::write( - hooks_dir.join("hooks.json"), - r#"{"hooks":{"SessionStart":[{"matcher":"*","hooks":[]}]}}"#, + hooks_dir.join("settings.json"), + r#"{"permissions":{},"hooks":{"SessionStart":[{"matcher":"*","hooks":[]}]}}"#, ) .unwrap(); @@ -765,12 +765,12 @@ mod tests { let hooks_dir = root.path().join(".claude"); std::fs::create_dir_all(&hooks_dir).unwrap(); std::fs::write( - hooks_dir.join("hooks.json"), + hooks_dir.join("settings.json"), r#"{"hooks":{"SessionStart":[{"matcher":"*","hooks":[]}]}}"#, ) .unwrap(); - // Loading for "codex" should not pick up .claude/hooks.json + // Loading for "codex" should not pick up .claude/settings.json let registry = HookRegistry::load_for_host(root.path(), "codex").unwrap(); assert_eq!(registry.stats().project_events, 0); } diff --git a/crates/omc-host/Cargo.toml b/crates/omc-host/Cargo.toml index 9ba7677..c8e81a1 100644 --- a/crates/omc-host/Cargo.toml +++ b/crates/omc-host/Cargo.toml @@ -14,9 +14,10 @@ serde_json = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } toml = "0.8" +serde_yaml = "0.9" tracing = { workspace = true } dirs = { workspace = true } +tempfile = { workspace = true } [dev-dependencies] -tempfile = { workspace = true } tokio = { workspace = true } diff --git a/crates/omc-host/src/adapter.rs b/crates/omc-host/src/adapter.rs index 86d98b3..d078a2f 100644 --- a/crates/omc-host/src/adapter.rs +++ b/crates/omc-host/src/adapter.rs @@ -121,7 +121,8 @@ pub trait HostAdapter: Send + Sync { // ── Config Generation ────────────────────────────────────────── /// Generate the host-specific config file content. - /// Claude: settings.json. Codex: config.toml + hooks.json. + /// Claude: settings.json plus project `.mcp.json` when MCP servers exist. + /// Codex: config.toml + hooks.json. fn generate_config(&self, opts: &ConfigGenOptions) -> Result; /// Return the relative path to the host config file. diff --git a/crates/omc-host/src/claude/config.rs b/crates/omc-host/src/claude/config.rs index ad632dc..641590e 100644 --- a/crates/omc-host/src/claude/config.rs +++ b/crates/omc-host/src/claude/config.rs @@ -1,11 +1,11 @@ -//! Claude Code settings.json generation. +//! Claude Code settings.json and project MCP configuration generation. use serde_json::json; use crate::types::{ConfigGenOptions, GeneratedConfig, GeneratedFile}; use std::path::PathBuf; -/// Generate `.claude/settings.json` content. +/// Generate Claude project configuration files. pub fn generate_claude_config(opts: &ConfigGenOptions) -> Result { let mut settings = serde_json::Map::new(); @@ -46,12 +46,6 @@ pub fn generate_claude_config(opts: &ConfigGenOptions) -> Result Result McpServerDef { + McpServerDef { + name: "omc-rs".into(), + command: "omc".into(), + args: vec!["mcp".into()], + env: None, + } +} + +/// Resolve Hermes' global configuration directory without creating it. +pub fn resolve_hermes_home(override_home: Option<&Path>) -> std::path::PathBuf { + if let Some(home) = override_home { + return home.to_path_buf(); + } + if let Ok(home) = std::env::var("HERMES_HOME") + && !home.trim().is_empty() + { + return std::path::PathBuf::from(home); + } + #[cfg(windows)] + { + let base = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join("AppData").join("Local"))) + .unwrap_or_else(|| PathBuf::from(".")); + base.join("hermes") + } + + #[cfg(not(windows))] + { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".hermes") + } +} + +/// Persist the unified OMC MCP server in Hermes' `config.yaml`. +/// +/// Hermes is an MCP consumer, not another OMC host engine, so this path only +/// edits its `mcp_servers` map. Matching entries are left untouched and +/// conflicting entries fail closed. +pub fn ensure_hermes_mcp_server(hermes_home: &Path, server: &McpServerDef) -> Result { + ensure_hermes_mcp_server_with_force(hermes_home, server, false) +} + +pub fn ensure_hermes_mcp_server_with_force( + hermes_home: &Path, + server: &McpServerDef, + force: bool, +) -> Result { + let path = hermes_home.join("config.yaml"); + let mut config = if path.exists() { + let content = + std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?; + serde_yaml::from_str::(&content) + .map_err(|e| format!("parse {}: {e}", path.display()))? + } else { + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()) + }; + + let root = config + .as_mapping_mut() + .ok_or_else(|| format!("{} must contain a YAML mapping", path.display()))?; + let servers_key = serde_yaml::Value::String("mcp_servers".into()); + let servers = root + .entry(servers_key) + .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| format!("{} mcp_servers must be a YAML mapping", path.display()))?; + let name_key = serde_yaml::Value::String(server.name.clone()); + let desired = hermes_server_value(server)?; + + if let Some(existing) = servers.get(&name_key) { + if hermes_server_matches(existing, &desired) { + return Ok(false); + } + if !force { + return Err(format!( + "{} already contains a conflicting MCP server named {}", + path.display(), + server.name + )); + } + } + + servers.insert(name_key, desired); + let content = + serde_yaml::to_string(&config).map_err(|e| format!("serialize {}: {e}", path.display()))?; + write_config_atomically(&path, content.as_bytes())?; + Ok(true) +} + +fn hermes_server_value(server: &McpServerDef) -> Result { + let mut entry = serde_yaml::Mapping::new(); + entry.insert( + serde_yaml::Value::String("command".into()), + serde_yaml::Value::String(server.command.clone()), + ); + entry.insert( + serde_yaml::Value::String("args".into()), + serde_yaml::to_value(&server.args).map_err(|e| e.to_string())?, + ); + if let Some(env) = &server.env + && !env.is_empty() + { + entry.insert( + serde_yaml::Value::String("env".into()), + serde_yaml::to_value(env).map_err(|e| e.to_string())?, + ); + } + Ok(serde_yaml::Value::Mapping(entry)) +} + +fn hermes_server_matches(existing: &serde_yaml::Value, desired: &serde_yaml::Value) -> bool { + let Some(existing) = existing.as_mapping() else { + return false; + }; + let Some(desired) = desired.as_mapping() else { + return false; + }; + desired + .iter() + .all(|(key, expected)| existing.get(key) == Some(expected)) +} + +/// Persist the unified OMC MCP server in a host project configuration. +/// +/// Existing entries under the same name are left untouched only when they +/// already match; a conflicting entry is rejected instead of overwritten. +pub fn ensure_mcp_server( + root: &Path, + host: HostKind, + server: &McpServerDef, +) -> Result { + ensure_mcp_server_with_force(root, host, server, false) +} + +pub fn ensure_mcp_server_with_force( + root: &Path, + host: HostKind, + server: &McpServerDef, + force: bool, +) -> Result { + match host { + HostKind::Claude => ensure_claude_mcp_server(root, server, force), + HostKind::Codex => ensure_codex_mcp_server(root, server, force), + } +} + +fn ensure_claude_mcp_server( + root: &Path, + server: &McpServerDef, + force: bool, +) -> Result { + let path = root.join(".mcp.json"); + let mut config = if path.exists() { + let content = + std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?; + serde_json::from_str::(&content) + .map_err(|e| format!("parse {}: {e}", path.display()))? + } else { + Value::Object(serde_json::Map::new()) + }; + let object = config + .as_object_mut() + .ok_or_else(|| format!("{} must contain a JSON object", path.display()))?; + let servers = object + .entry("mcpServers") + .or_insert_with(|| Value::Object(serde_json::Map::new())); + let servers = servers + .as_object_mut() + .ok_or_else(|| format!("{} mcpServers must be a JSON object", path.display()))?; + let desired = claude_server_value(server); + + if let Some(existing) = servers.get(&server.name) { + if existing == &desired { + return Ok(false); + } + if !force { + return Err(format!( + "{} already contains a conflicting MCP server named {}", + path.display(), + server.name + )); + } + } + + servers.insert(server.name.clone(), desired); + let content = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?; + write_config_atomically(&path, format!("{content}\n").as_bytes())?; + Ok(true) +} + +fn ensure_codex_mcp_server( + root: &Path, + server: &McpServerDef, + force: bool, +) -> Result { + let path = root.join(".codex/config.toml"); + let mut config = if path.exists() { + let content = + std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?; + content + .parse::() + .map_err(|e| format!("parse {}: {e}", path.display()))? + } else { + toml::Value::Table(toml::map::Map::new()) + }; + let root_table = config + .as_table_mut() + .ok_or_else(|| format!("{} must contain a TOML table", path.display()))?; + let servers = root_table + .entry("mcp_servers") + .or_insert_with(|| toml::Value::Table(toml::map::Map::new())); + let servers = servers + .as_table_mut() + .ok_or_else(|| format!("{} mcp_servers must be a TOML table", path.display()))?; + let desired = codex_server_value(server); + + if let Some(existing) = servers.get(&server.name) { + if existing == &desired { + return Ok(false); + } + if !force { + return Err(format!( + "{} already contains a conflicting MCP server named {}", + path.display(), + server.name + )); + } + } + + servers.insert(server.name.clone(), desired); + let content = toml::to_string_pretty(&config).map_err(|e| e.to_string())?; + write_config_atomically(&path, content.as_bytes())?; + Ok(true) +} + +fn write_config_atomically(path: &Path, content: &[u8]) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("{} has no parent directory", path.display()))?; + std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + + if path.exists() { + let backup = path.with_extension(format!( + "{}.bak", + path.extension() + .and_then(|value| value.to_str()) + .unwrap_or("config") + )); + std::fs::copy(path, &backup) + .map_err(|e| format!("backup {} to {}: {e}", path.display(), backup.display()))?; + } + + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .map_err(|e| format!("create temporary config in {}: {e}", parent.display()))?; + temporary + .write_all(content) + .and_then(|_| temporary.as_file().sync_all()) + .map_err(|e| format!("write temporary config for {}: {e}", path.display()))?; + temporary + .persist(path) + .map_err(|e| format!("replace {}: {}", path.display(), e.error))?; + Ok(()) +} + +fn claude_server_value(server: &McpServerDef) -> Value { + let mut entry = serde_json::Map::new(); + entry.insert("command".into(), json!(server.command)); + if !server.args.is_empty() { + entry.insert("args".into(), json!(server.args)); + } + if let Some(env) = &server.env + && !env.is_empty() + { + entry.insert("env".into(), json!(env)); + } + Value::Object(entry) +} + +fn codex_server_value(server: &McpServerDef) -> toml::Value { + let mut entry = toml::map::Map::new(); + entry.insert( + "command".into(), + toml::Value::String(server.command.clone()), + ); + if !server.args.is_empty() { + entry.insert( + "args".into(), + toml::Value::Array( + server + .args + .iter() + .map(|arg| toml::Value::String(arg.clone())) + .collect(), + ), + ); + } + if let Some(env) = &server.env + && !env.is_empty() + { + entry.insert( + "env".into(), + toml::Value::Table( + env.iter() + .map(|(key, value)| (key.clone(), toml::Value::String(value.clone()))) + .collect(), + ), + ); + } + toml::Value::Table(entry) +} + /// Generate Claude Code mcpServers JSON block. pub fn claude_mcp_json(servers: &[McpServerDef]) -> Value { let mut map = serde_json::Map::new(); for s in servers { - let mut entry = serde_json::Map::new(); - entry.insert("command".into(), json!(s.command)); - if !s.args.is_empty() { - entry.insert("args".into(), json!(s.args)); - } - if let Some(ref env) = s.env - && !env.is_empty() - { - entry.insert("env".into(), json!(env)); - } - map.insert(s.name.clone(), Value::Object(entry)); + map.insert(s.name.clone(), claude_server_value(s)); } Value::Object(map) } @@ -27,26 +335,7 @@ pub fn claude_mcp_json(servers: &[McpServerDef]) -> Value { pub fn codex_mcp_toml(servers: &[McpServerDef]) -> Result { let mut toml_map = toml::map::Map::new(); for s in servers { - let mut server_map = toml::map::Map::new(); - server_map.insert("command".into(), toml::Value::String(s.command.clone())); - if !s.args.is_empty() { - let args: Vec = s - .args - .iter() - .map(|a| toml::Value::String(a.clone())) - .collect(); - server_map.insert("args".into(), toml::Value::Array(args)); - } - if let Some(ref env) = s.env - && !env.is_empty() - { - let env_map: toml::map::Map = env - .iter() - .map(|(k, v)| (k.clone(), toml::Value::String(v.clone()))) - .collect(); - server_map.insert("env".into(), toml::Value::Table(env_map)); - } - toml_map.insert(s.name.clone(), toml::Value::Table(server_map)); + toml_map.insert(s.name.clone(), codex_server_value(s)); } let mut root = toml::map::Map::new(); root.insert("mcp_servers".into(), toml::Value::Table(toml_map)); @@ -54,48 +343,5 @@ pub fn codex_mcp_toml(servers: &[McpServerDef]) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - fn sample_servers() -> Vec { - vec![ - McpServerDef { - name: "omc-state".into(), - command: "omc-mcp".into(), - args: vec!["--server".into()], - env: None, - }, - McpServerDef { - name: "omc-memory".into(), - command: "omc-mcp".into(), - args: vec!["--memory".into()], - env: Some([("DEBUG".into(), "1".into())].into_iter().collect()), - }, - ] - } - - #[test] - fn claude_mcp_json_format() { - let json = claude_mcp_json(&sample_servers()); - assert_eq!(json["omc-state"]["command"], "omc-mcp"); - assert_eq!(json["omc-state"]["args"][0], "--server"); - assert_eq!(json["omc-memory"]["env"]["DEBUG"], "1"); - } - - #[test] - fn codex_mcp_toml_format() { - let toml_str = codex_mcp_toml(&sample_servers()).unwrap(); - assert!(toml_str.contains("[mcp_servers.omc-state]")); - assert!(toml_str.contains("command = \"omc-mcp\"")); - assert!(toml_str.contains("[mcp_servers.omc-memory]")); - assert!(toml_str.contains("DEBUG")); - } - - #[test] - fn empty_servers() { - let json = claude_mcp_json(&[]); - assert_eq!(json, serde_json::json!({})); - let toml_str = codex_mcp_toml(&[]).unwrap(); - assert!(toml_str.contains("mcp_servers")); - } -} +#[path = "mcp_reg_tests.rs"] +mod tests; diff --git a/crates/omc-host/src/mcp_reg_tests.rs b/crates/omc-host/src/mcp_reg_tests.rs new file mode 100644 index 0000000..d9c8147 --- /dev/null +++ b/crates/omc-host/src/mcp_reg_tests.rs @@ -0,0 +1,384 @@ +use super::*; +use omc_shared::agent_tool::{RouteRequest, ToolResponse, capabilities_payload, route_agent_task}; +use omc_shared::operation_contract::{ + OperationEvent, ResultSchema, TaskRequest, TaskState, TaskStatus, TypedSubagentResult, +}; +use serde::Deserialize; +use serde_json::json; + +fn sample_servers() -> Vec { + vec![ + McpServerDef { + name: "omc-state".into(), + command: "omc-mcp".into(), + args: vec!["--server".into()], + env: None, + }, + McpServerDef { + name: "omc-memory".into(), + command: "omc-mcp".into(), + args: vec!["--memory".into()], + env: Some([("DEBUG".into(), "1".into())].into_iter().collect()), + }, + ] +} + +#[test] +fn claude_mcp_json_format() { + let json = claude_mcp_json(&sample_servers()); + assert_eq!(json["omc-state"]["command"], "omc-mcp"); + assert_eq!(json["omc-state"]["args"][0], "--server"); + assert_eq!(json["omc-memory"]["env"]["DEBUG"], "1"); +} + +#[test] +fn codex_mcp_toml_format() { + let toml_str = codex_mcp_toml(&sample_servers()).unwrap(); + assert!(toml_str.contains("[mcp_servers.omc-state]")); + assert!(toml_str.contains("command = \"omc-mcp\"")); + assert!(toml_str.contains("[mcp_servers.omc-memory]")); + assert!(toml_str.contains("DEBUG")); +} + +#[test] +fn empty_servers() { + let json = claude_mcp_json(&[]); + assert_eq!(json, serde_json::json!({})); + let toml_str = codex_mcp_toml(&[]).unwrap(); + assert!(toml_str.contains("mcp_servers")); +} + +#[test] +fn setup_registers_unified_server_for_both_hosts_and_is_idempotent() { + let server = omc_server_definition(); + for host in [crate::HostKind::Claude, crate::HostKind::Codex] { + let tmp = tempfile::tempdir().unwrap(); + crate::create_adapter(host) + .init_project(tmp.path()) + .expect("host project initializes"); + + assert!(ensure_mcp_server(tmp.path(), host, &server).unwrap()); + assert!(!ensure_mcp_server(tmp.path(), host, &server).unwrap()); + + match host { + crate::HostKind::Claude => { + let config: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap(), + ) + .unwrap(); + assert_eq!(config["mcpServers"]["omc-rs"]["command"], "omc"); + assert_eq!(config["mcpServers"]["omc-rs"]["args"][0], "mcp"); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap(), + "{\n}\n" + ); + } + crate::HostKind::Codex => { + let config: toml::Value = + std::fs::read_to_string(tmp.path().join(".codex/config.toml")) + .unwrap() + .parse() + .unwrap(); + assert_eq!( + config["mcp_servers"]["omc-rs"]["command"], + toml::Value::String("omc".into()) + ); + assert_eq!( + config["mcp_servers"]["omc-rs"]["args"][0], + toml::Value::String("mcp".into()) + ); + } + } + } +} + +#[test] +fn setup_rejects_conflicting_server_without_overwrite() { + let tmp = tempfile::tempdir().unwrap(); + crate::create_adapter(crate::HostKind::Claude) + .init_project(tmp.path()) + .unwrap(); + let path = tmp.path().join(".mcp.json"); + std::fs::write( + &path, + r#"{"mcpServers":{"omc-rs":{"command":"other","args":[]}}}"#, + ) + .unwrap(); + + let error = ensure_mcp_server( + tmp.path(), + crate::HostKind::Claude, + &omc_server_definition(), + ) + .unwrap_err(); + assert!(error.contains("conflicting MCP server")); + assert!(std::fs::read_to_string(path).unwrap().contains("other")); +} + +#[test] +fn forced_setup_replaces_conflict_and_keeps_backup_for_both_hosts() { + for host in [crate::HostKind::Claude, crate::HostKind::Codex] { + let tmp = tempfile::tempdir().unwrap(); + crate::create_adapter(host) + .init_project(tmp.path()) + .unwrap(); + let path = match host { + crate::HostKind::Claude => tmp.path().join(".mcp.json"), + crate::HostKind::Codex => tmp.path().join(".codex/config.toml"), + }; + let original = match host { + crate::HostKind::Claude => r#"{"mcpServers":{"omc-rs":{"command":"other","args":[]}}}"#, + crate::HostKind::Codex => "[mcp_servers.omc-rs]\ncommand = \"other\"\nargs = []\n", + }; + std::fs::write(&path, original).unwrap(); + + assert!( + ensure_mcp_server_with_force(tmp.path(), host, &omc_server_definition(), true).unwrap() + ); + assert!(std::fs::read_to_string(&path).unwrap().contains("omc")); + let backup = path.with_extension(format!( + "{}.bak", + path.extension().and_then(|value| value.to_str()).unwrap() + )); + assert_eq!(std::fs::read_to_string(backup).unwrap(), original); + } +} + +#[test] +fn hermes_setup_is_idempotent_and_preserves_siblings() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.yaml"); + std::fs::write(&path, "provider: openrouter\nmodel: auto\n").unwrap(); + + let server = omc_server_definition(); + assert!(ensure_hermes_mcp_server(tmp.path(), &server).unwrap()); + assert!(!ensure_hermes_mcp_server(tmp.path(), &server).unwrap()); + + let config: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!(config["provider"].as_str(), Some("openrouter")); + assert_eq!( + config["mcp_servers"]["omc-rs"]["command"].as_str(), + Some("omc") + ); + assert_eq!( + config["mcp_servers"]["omc-rs"]["args"][0].as_str(), + Some("mcp") + ); +} + +#[test] +fn hermes_setup_rejects_conflict_without_overwrite() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.yaml"); + std::fs::write( + &path, + "mcp_servers:\n omc-rs:\n command: other\n args: []\n", + ) + .unwrap(); + + let error = ensure_hermes_mcp_server(tmp.path(), &omc_server_definition()).unwrap_err(); + assert!(error.contains("conflicting MCP server")); + assert!( + std::fs::read_to_string(path) + .unwrap() + .contains("command: other") + ); +} + +#[test] +fn hermes_force_replaces_conflict_and_keeps_backup() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.yaml"); + let original = "provider: local\nmcp_servers:\n omc-rs:\n command: other\n args: []\n"; + std::fs::write(&path, original).unwrap(); + + assert!( + ensure_hermes_mcp_server_with_force(tmp.path(), &omc_server_definition(), true).unwrap() + ); + let config: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(config["provider"].as_str(), Some("local")); + assert_eq!(config["mcp_servers"]["omc-rs"]["command"], "omc"); + assert_eq!( + std::fs::read_to_string(path.with_extension("yaml.bak")).unwrap(), + original + ); +} + +#[test] +fn same_agent_server_is_registered_for_both_hosts() { + let servers = [McpServerDef { + name: "omc-agent-tools".into(), + command: "omc-mcp".into(), + args: Vec::new(), + env: None, + }]; + + let claude = claude_mcp_json(&servers); + assert_eq!(claude["omc-agent-tools"]["command"], "omc-mcp"); + + let codex: toml::Value = codex_mcp_toml(&servers) + .expect("Codex MCP registration is valid TOML") + .parse() + .expect("Codex MCP registration parses"); + assert_eq!( + codex["mcp_servers"]["omc-agent-tools"]["command"], + toml::Value::String("omc-mcp".into()) + ); +} + +#[derive(Debug, Deserialize)] +struct ConsumerEnvelope { + schema_version: String, + request_id: String, + ok: bool, + data: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConsumerEvent { + schema_version: String, + event_id: String, + event_type: String, + correlation_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConsumerResult { + schema_version: String, + result_type: String, + payload: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConsumerTaskStatus { + schema_version: String, + task_id: String, + correlation_id: String, + state: String, + result: Option, +} + +fn consume_tool_response(response: ToolResponse) { + let encoded = serde_json::to_value(response).expect("tool response is serializable"); + let envelope: ConsumerEnvelope = + serde_json::from_value(encoded).expect("consumer envelope parses"); + assert_eq!(envelope.schema_version, "omc.tool.v1"); + assert!(!envelope.request_id.is_empty()); + assert!(envelope.ok); + assert!(envelope.data.is_some()); +} + +fn assert_agent_registration(host: crate::HostKind) { + let registration = crate::create_adapter(host) + .generate_mcp_registration(&[McpServerDef { + name: "omc-agent-tools".into(), + command: "omc-mcp".into(), + args: Vec::new(), + env: None, + }]) + .expect("agent MCP registration is generated"); + + match host { + crate::HostKind::Claude => { + assert_eq!(registration["omc-agent-tools"]["command"], "omc-mcp"); + } + crate::HostKind::Codex => { + let toml_text = registration["toml"] + .as_str() + .expect("Codex registration carries TOML text"); + let config: toml::Value = toml_text.parse().expect("Codex registration parses"); + assert_eq!( + config["mcp_servers"]["omc-agent-tools"]["command"], + toml::Value::String("omc-mcp".into()) + ); + } + } +} + +fn consume_one_agent_contract() { + let capabilities = ToolResponse::success("host-capabilities", capabilities_payload()); + consume_tool_response(capabilities); + + let route = route_agent_task(&RouteRequest { + task: "implement a small repository change and verify it".into(), + agent_type: Some("executor".into()), + previous_failures: None, + }); + consume_tool_response(ToolResponse::success("host-route", route.clone())); + + let task = TaskRequest::new("task-1", "corr-1", "implement and verify"); + task.validate().expect("task request is valid"); + let before = OperationEvent::new( + "event-before", + "task.dispatched", + "corr-1", + "2026-08-12T15:00:00Z", + json!({"surface": route.recommended_surface}), + ); + before.validate().expect("pre-execution event is valid"); + let before_view: ConsumerEvent = serde_json::from_value(serde_json::to_value(before).unwrap()) + .expect("consumer parses pre-event"); + assert_eq!(before_view.schema_version, "omc.event.v1"); + assert_eq!(before_view.event_id, "event-before"); + assert_eq!(before_view.event_type, "task.dispatched"); + assert_eq!(before_view.correlation_id, "corr-1"); + + let schema = ResultSchema::new("omc.agent.change.v1", vec!["summary".into()]); + let result = TypedSubagentResult::new( + schema.schema_id.clone(), + json!({"summary":"change verified"}), + Vec::new(), + ) + .expect("typed result is valid"); + result + .validate_against(&schema) + .expect("typed result matches consumer schema"); + let after = OperationEvent::new( + "event-after", + "task.succeeded", + "corr-1", + "2026-08-12T15:01:00Z", + json!({"resultType": result.result_type}), + ); + after.validate().expect("post-execution event is valid"); + + let status = TaskStatus { + schema_version: "omc.task.v1".into(), + task_id: task.task_id, + correlation_id: task.correlation_id, + state: TaskState::Succeeded, + observed_at: "2026-08-12T15:01:00Z".into(), + artifact_refs: Vec::new(), + result: Some(result), + error: None, + }; + let mut status_view: ConsumerTaskStatus = + serde_json::from_value(serde_json::to_value(status).unwrap()) + .expect("consumer parses task status"); + assert_eq!(status_view.schema_version, "omc.task.v1"); + assert_eq!(status_view.task_id, "task-1"); + assert_eq!(status_view.correlation_id, "corr-1"); + assert_eq!(status_view.state, "succeeded"); + let result_view = status_view.result.take().expect("status carries result"); + assert_eq!(result_view.schema_version, "omc.subagent-result.v1"); + assert_eq!(result_view.result_type, "omc.agent.change.v1"); + assert_eq!(result_view.payload["summary"], "change verified"); + + let after_view: ConsumerEvent = serde_json::from_value(serde_json::to_value(after).unwrap()) + .expect("consumer parses post-event"); + assert_eq!(after_view.event_type, "task.succeeded"); + assert_eq!(after_view.correlation_id, "corr-1"); +} + +#[test] +fn both_hosts_consume_one_agent_contract() { + for host in [crate::HostKind::Claude, crate::HostKind::Codex] { + assert_agent_registration(host); + consume_one_agent_contract(); + } +} diff --git a/crates/omc-interop/src/lib.rs b/crates/omc-interop/src/lib.rs index 20a5d70..408053f 100644 --- a/crates/omc-interop/src/lib.rs +++ b/crates/omc-interop/src/lib.rs @@ -1,3 +1,275 @@ pub mod mcp_bridge; pub mod omx_team_state; pub mod shared_state; + +use serde::{Deserialize, Serialize}; + +use crate::mcp_bridge::{InteropMode, can_use_omx_direct_write_bridge, get_interop_mode}; +use crate::omx_team_state::{OmxTaskStatus, OmxTeamTask, OmxWorkerInfo}; +use crate::shared_state::{ + InteropConfig, InteropSide, SharedMessage, SharedTask, TaskStatus, TaskType, +}; + +pub const INTEROP_SNAPSHOT_SCHEMA_VERSION: &str = "omc.interop.snapshot.v1"; +const DEFAULT_SNAPSHOT_LIMIT: usize = 20; +const MAX_SNAPSHOT_LIMIT: usize = 100; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InteropSnapshot { + pub schema_version: String, + pub read_only: bool, + pub working_directory: String, + pub interop_mode: InteropMode, + pub direct_write_enabled: bool, + pub config: Option, + pub shared_task_count: usize, + pub shared_tasks: Vec, + pub shared_message_count: usize, + pub shared_messages: Vec, + pub normalized_task_count: usize, + pub normalized_tasks: Vec, + pub omx_teams: Vec, +} + +/// Cross-runtime task state. This is a projection only; native state remains +/// owned by the OMC or OMX runtime that wrote it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UnifiedTaskStatus { + Pending, + Blocked, + InProgress, + Completed, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InteropTaskView { + pub id: String, + pub native_id: String, + pub source: InteropSide, + pub target: Option, + pub team: Option, + pub task_type: Option, + pub subject: String, + pub status: UnifiedTaskStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OmxTeamSnapshot { + pub name: String, + pub worker_count: usize, + pub workers: Vec, + pub task_count: usize, + pub tasks: Vec, +} + +fn normalize_shared_status(status: &TaskStatus) -> UnifiedTaskStatus { + match status { + TaskStatus::Pending => UnifiedTaskStatus::Pending, + TaskStatus::InProgress => UnifiedTaskStatus::InProgress, + TaskStatus::Completed => UnifiedTaskStatus::Completed, + TaskStatus::Failed => UnifiedTaskStatus::Failed, + } +} + +fn normalize_omx_status(status: &OmxTaskStatus) -> UnifiedTaskStatus { + match status { + OmxTaskStatus::Pending => UnifiedTaskStatus::Pending, + OmxTaskStatus::Blocked => UnifiedTaskStatus::Blocked, + OmxTaskStatus::InProgress => UnifiedTaskStatus::InProgress, + OmxTaskStatus::Completed => UnifiedTaskStatus::Completed, + OmxTaskStatus::Failed => UnifiedTaskStatus::Failed, + } +} + +fn normalize_shared_task(task: &SharedTask) -> InteropTaskView { + InteropTaskView { + id: format!("omc/{}", task.id), + native_id: task.id.clone(), + source: task.source.clone(), + target: Some(task.target.clone()), + team: None, + task_type: Some(task.task_type.clone()), + subject: task.description.clone(), + status: normalize_shared_status(&task.status), + } +} + +fn normalize_omx_task(team: &str, task: &OmxTeamTask) -> InteropTaskView { + InteropTaskView { + id: format!("omx/{team}/{}", task.id), + native_id: task.id.clone(), + source: InteropSide::Omx, + target: None, + team: Some(team.to_string()), + task_type: None, + subject: task.subject.clone(), + status: normalize_omx_status(&task.status), + } +} + +/// Read the bounded OMC/OMX state projection without starting or mutating a runtime. +pub fn read_snapshot(cwd: &str, limit: Option) -> shared_state::Result { + let limit = limit.unwrap_or(DEFAULT_SNAPSHOT_LIMIT); + if !(1..=MAX_SNAPSHOT_LIMIT).contains(&limit) { + return Err(shared_state::InteropError::InvalidLimit(limit)); + } + + let shared_tasks = shared_state::read_shared_tasks(cwd, None)?; + let shared_messages = shared_state::read_shared_messages(cwd, None)?; + let mut normalized_tasks: Vec<_> = shared_tasks.iter().map(normalize_shared_task).collect(); + let mut omx_teams = Vec::new(); + + for name in omx_team_state::list_omx_teams(cwd).map_err(|error| { + shared_state::InteropError::InvalidName(format!("failed to read OMX teams: {error}")) + })? { + let config = omx_team_state::read_omx_team_config(&name, cwd).map_err(|error| { + shared_state::InteropError::InvalidName(format!( + "failed to read OMX team {name}: {error}" + )) + })?; + let tasks = omx_team_state::list_omx_tasks(&name, cwd).map_err(|error| { + shared_state::InteropError::InvalidName(format!( + "failed to read OMX tasks for {name}: {error}" + )) + })?; + normalized_tasks.extend(tasks.iter().map(|task| normalize_omx_task(&name, task))); + let workers = config + .as_ref() + .map_or_else(Vec::new, |config| config.workers.clone()); + omx_teams.push(OmxTeamSnapshot { + name, + worker_count: workers.len(), + workers, + task_count: tasks.len(), + tasks: tasks.into_iter().take(limit).collect(), + }); + } + + Ok(InteropSnapshot { + schema_version: INTEROP_SNAPSHOT_SCHEMA_VERSION.into(), + read_only: true, + working_directory: cwd.into(), + interop_mode: get_interop_mode(), + direct_write_enabled: can_use_omx_direct_write_bridge(), + config: shared_state::read_interop_config(cwd)?, + shared_task_count: shared_tasks.len(), + shared_tasks: shared_tasks.into_iter().take(limit).collect(), + shared_message_count: shared_messages.len(), + shared_messages: shared_messages.into_iter().take(limit).collect(), + normalized_task_count: normalized_tasks.len(), + normalized_tasks: normalized_tasks.into_iter().take(limit).collect(), + omx_teams, + }) +} + +#[cfg(test)] +mod snapshot_tests { + use super::*; + use crate::omx_team_state::{OmxTaskStatus, OmxTeamConfig}; + use crate::shared_state::{add_shared_message, add_shared_task, init_interop_session}; + use chrono::Utc; + + #[test] + fn snapshot_reads_bounded_omc_and_omx_state() { + let root = tempfile::tempdir().unwrap(); + let cwd = root.path().to_str().unwrap(); + init_interop_session("snapshot-test", cwd, None).unwrap(); + add_shared_task( + cwd, + crate::shared_state::InteropSide::Omc, + crate::shared_state::InteropSide::Omx, + crate::shared_state::TaskType::Analyze, + "inspect", + None, + None, + ) + .unwrap(); + add_shared_message( + cwd, + crate::shared_state::InteropSide::Omx, + crate::shared_state::InteropSide::Omc, + "done", + None, + ) + .unwrap(); + + let team_dir = root.path().join(".omx/state/team/demo"); + std::fs::create_dir_all(team_dir.join("tasks")).unwrap(); + let config = OmxTeamConfig { + name: "demo".into(), + task: "inspect".into(), + agent_type: "executor".into(), + worker_count: 1, + max_workers: 1, + workers: Vec::new(), + created_at: Utc::now(), + tmux_session: "demo".into(), + next_task_id: 2, + }; + std::fs::write( + team_dir.join("config.json"), + serde_json::to_vec(&config).unwrap(), + ) + .unwrap(); + let task = OmxTeamTask { + id: "1".into(), + subject: "inspect".into(), + description: "inspect".into(), + status: OmxTaskStatus::Completed, + requires_code_change: Some(false), + owner: None, + result: Some("done".into()), + error: None, + blocked_by: None, + depends_on: None, + version: Some(1), + created_at: Utc::now(), + completed_at: Some(Utc::now()), + }; + std::fs::write( + team_dir.join("tasks/task-1.json"), + serde_json::to_vec(&task).unwrap(), + ) + .unwrap(); + + let snapshot = read_snapshot(cwd, Some(1)).unwrap(); + assert_eq!(snapshot.schema_version, INTEROP_SNAPSHOT_SCHEMA_VERSION); + assert!(snapshot.read_only); + assert_eq!(snapshot.shared_task_count, 1); + assert_eq!(snapshot.shared_tasks.len(), 1); + assert_eq!(snapshot.shared_message_count, 1); + assert_eq!(snapshot.omx_teams.len(), 1); + assert_eq!(snapshot.omx_teams[0].task_count, 1); + assert_eq!(snapshot.omx_teams[0].tasks.len(), 1); + assert_eq!(snapshot.normalized_task_count, 2); + assert_eq!(snapshot.normalized_tasks.len(), 1); + assert_eq!( + snapshot.normalized_tasks[0].status, + UnifiedTaskStatus::Pending + ); + } + + #[test] + fn normalized_status_preserves_omx_blocked_state() { + assert_eq!( + normalize_omx_status(&OmxTaskStatus::Blocked), + UnifiedTaskStatus::Blocked + ); + assert_eq!( + normalize_shared_status(&TaskStatus::InProgress), + UnifiedTaskStatus::InProgress + ); + } + + #[test] + fn snapshot_rejects_invalid_limit() { + let root = tempfile::tempdir().unwrap(); + let error = read_snapshot(root.path().to_str().unwrap(), Some(0)).unwrap_err(); + assert!(error.to_string().contains("limit")); + } +} diff --git a/crates/omc-interop/src/mcp_bridge.rs b/crates/omc-interop/src/mcp_bridge.rs index 5ae0f50..17808f5 100644 --- a/crates/omc-interop/src/mcp_bridge.rs +++ b/crates/omc-interop/src/mcp_bridge.rs @@ -2,10 +2,11 @@ use std::env; use std::fmt::Write as _; use serde::{Deserialize, Serialize}; +use thiserror::Error; use tracing::warn; use crate::omx_team_state; -use crate::shared_state::{self, InteropSide, TaskType}; +use crate::shared_state::{self, InteropSide, SharedMessage, SharedTask, TaskType}; // ============================================================================ // Interop Mode @@ -53,6 +54,193 @@ pub fn can_use_omx_direct_write_bridge() -> bool { let tools_enabled = env::var(OMC_INTEROP_TOOLS_ENABLED).as_deref() == Ok("1"); interop_enabled && tools_enabled && get_interop_mode() == InteropMode::Active } + +pub const INTEROP_BRIDGE_SCHEMA_VERSION: &str = "omc.interop.bridge.v1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum InteropBridgeAction { + SendTask, + SendMessage, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InteropBridgeRequest { + pub action: InteropBridgeAction, + pub source: InteropSide, + pub target: InteropSide, + #[serde(rename = "type")] + pub task_type: Option, + pub description: Option, + pub content: Option, + pub working_directory: Option, + #[serde(default)] + pub allow_side_effects: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InteropBridgeResponse { + pub schema_version: String, + pub action: InteropBridgeAction, + pub read_only: bool, + pub write_enabled: bool, + pub source: InteropSide, + pub target: InteropSide, + #[serde(skip_serializing_if = "Option::is_none")] + pub task: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Error)] +pub enum InteropBridgeError { + #[error("interop writes require allowSideEffects=true and active interop flags")] + SideEffectsNotAllowed, + + #[error("invalid interop request: {0}")] + InvalidRequest(String), + + #[error("interop write failed: {0}")] + Upstream(#[from] shared_state::InteropError), +} + +impl InteropBridgeError { + pub fn code(&self) -> &'static str { + match self { + Self::SideEffectsNotAllowed => "side_effects_not_allowed", + Self::InvalidRequest(_) => "invalid_request", + Self::Upstream(_) => "upstream_failed", + } + } +} + +fn require_text<'a>(value: Option<&'a String>, field: &str) -> Result<&'a str, InteropBridgeError> { + value + .map(String::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| InteropBridgeError::InvalidRequest(format!("{field} is required"))) +} + +fn bridge_response( + request: &InteropBridgeRequest, + task: Option, + message: Option, +) -> InteropBridgeResponse { + InteropBridgeResponse { + schema_version: INTEROP_BRIDGE_SCHEMA_VERSION.into(), + action: request.action.clone(), + read_only: false, + write_enabled: true, + source: request.source.clone(), + target: request.target.clone(), + task, + message, + } +} + +pub struct InteropBridgeCliArgs<'a> { + pub action: &'a str, + pub source: &'a str, + pub target: &'a str, + pub task_type: Option<&'a str>, + pub description: Option<&'a str>, + pub content: Option<&'a str>, + pub working_directory: &'a str, + pub allow_side_effects: bool, +} + +pub fn interop_bridge_request_from_cli( + args: InteropBridgeCliArgs<'_>, +) -> Result { + let action = match args.action { + "send_task" => InteropBridgeAction::SendTask, + "send_message" => InteropBridgeAction::SendMessage, + other => { + return Err(InteropBridgeError::InvalidRequest(format!( + "invalid action: {other}" + ))); + } + }; + let parse_side = |value: &str| match value { + "omc" => Ok(InteropSide::Omc), + "omx" => Ok(InteropSide::Omx), + other => Err(InteropBridgeError::InvalidRequest(format!( + "invalid runtime: {other}" + ))), + }; + Ok(InteropBridgeRequest { + action, + source: parse_side(args.source)?, + target: parse_side(args.target)?, + task_type: args + .task_type + .map(|value| match value { + "analyze" => Ok(TaskType::Analyze), + "implement" => Ok(TaskType::Implement), + "review" => Ok(TaskType::Review), + "test" => Ok(TaskType::Test), + "custom" => Ok(TaskType::Custom), + other => Err(InteropBridgeError::InvalidRequest(format!( + "invalid task type: {other}" + ))), + }) + .transpose()?, + description: args.description.map(str::to_owned), + content: args.content.map(str::to_owned), + working_directory: Some(args.working_directory.to_owned()), + allow_side_effects: args.allow_side_effects, + }) +} + +/// Execute the small, explicitly gated shared-state bridge. It only writes +/// durable task/message records; it never starts workers or changes task state. +pub fn interop_bridge( + request: &InteropBridgeRequest, +) -> Result { + if request.source == request.target { + return Err(InteropBridgeError::InvalidRequest( + "source and target must differ".into(), + )); + } + if !request.allow_side_effects || !can_use_omx_direct_write_bridge() { + return Err(InteropBridgeError::SideEffectsNotAllowed); + } + + let cwd = request.working_directory.as_deref().unwrap_or("."); + let response = match &request.action { + InteropBridgeAction::SendTask => { + let task_type = request.task_type.clone().ok_or_else(|| { + InteropBridgeError::InvalidRequest("type is required for send_task".into()) + })?; + let description = require_text(request.description.as_ref(), "description")?; + let task = shared_state::add_shared_task( + cwd, + request.source.clone(), + request.target.clone(), + task_type, + description, + None, + None, + )?; + bridge_response(request, Some(task), None) + } + InteropBridgeAction::SendMessage => { + let content = require_text(request.content.as_ref(), "content")?; + let message = shared_state::add_shared_message( + cwd, + request.source.clone(), + request.target.clone(), + content, + None, + )?; + bridge_response(request, None, Some(message)) + } + }; + + Ok(response) +} // MCP tool result envelope // ============================================================================ @@ -134,6 +322,12 @@ pub struct SendTaskArgs { /// Send a task to the other tool (OMC <-> OMX). pub fn interop_send_task(args: &SendTaskArgs) -> ToolResult { + if !can_use_omx_direct_write_bridge() { + return tool_error( + "sending task", + "interop writes are disabled; enable active interop flags explicitly", + ); + } let cwd = args.working_directory.as_deref().unwrap_or("."); let source = args.target.other(); @@ -267,6 +461,12 @@ pub struct SendMessageArgs { /// Send a message to the other tool. pub fn interop_send_message(args: &SendMessageArgs) -> ToolResult { + if !can_use_omx_direct_write_bridge() { + return tool_error( + "sending message", + "interop writes are disabled; enable active interop flags explicitly", + ); + } let cwd = args.working_directory.as_deref().unwrap_or("."); let source = args.target.other(); @@ -664,6 +864,7 @@ pub const TOOL_LIST_OMX_TEAMS: &str = "interop_list_omx_teams"; pub const TOOL_SEND_OMX_MESSAGE: &str = "interop_send_omx_message"; pub const TOOL_READ_OMX_MESSAGES: &str = "interop_read_omx_messages"; pub const TOOL_READ_OMX_TASKS: &str = "interop_read_omx_tasks"; +pub const TOOL_INTEROP_BRIDGE: &str = "interop_bridge"; /// All interop tool names. pub const ALL_TOOLS: &[&str] = &[ @@ -675,6 +876,7 @@ pub const ALL_TOOLS: &[&str] = &[ TOOL_SEND_OMX_MESSAGE, TOOL_READ_OMX_MESSAGES, TOOL_READ_OMX_TASKS, + TOOL_INTEROP_BRIDGE, ]; #[cfg(test)] diff --git a/crates/omc-interop/src/shared_state.rs b/crates/omc-interop/src/shared_state.rs index 5b47b5c..4016a4d 100644 --- a/crates/omc-interop/src/shared_state.rs +++ b/crates/omc-interop/src/shared_state.rs @@ -22,6 +22,9 @@ pub enum InteropError { #[error("invalid name: {0}")] InvalidName(String), + + #[error("limit must be between 1 and 100: {0}")] + InvalidLimit(usize), } fn check_segment(name: &str, kind: &str) -> Result<()> { diff --git a/crates/omc-mcp/Cargo.toml b/crates/omc-mcp/Cargo.toml index 5ffdead..10d2ab3 100644 --- a/crates/omc-mcp/Cargo.toml +++ b/crates/omc-mcp/Cargo.toml @@ -17,6 +17,9 @@ path = "src/lib.rs" [dependencies] omc-shared = { path = "../omc-shared" } +omc-interop = { path = "../omc-interop" } +omc-python = { path = "../omc-python" } +omc-team = { path = "../omc-team" } anyhow = { workspace = true } async-trait = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/omc-mcp/examples/schema_manifest.rs b/crates/omc-mcp/examples/schema_manifest.rs new file mode 100644 index 0000000..2bceb21 --- /dev/null +++ b/crates/omc-mcp/examples/schema_manifest.rs @@ -0,0 +1,7 @@ +fn main() -> Result<(), serde_json::Error> { + println!( + "{}", + serde_json::to_string_pretty(&omc_mcp::schema_contract::current_manifest())? + ); + Ok(()) +} diff --git a/crates/omc-mcp/src/agent_tools.rs b/crates/omc-mcp/src/agent_tools.rs new file mode 100644 index 0000000..e938662 --- /dev/null +++ b/crates/omc-mcp/src/agent_tools.rs @@ -0,0 +1,181 @@ +//! Host-neutral OMC-RS agent tools exposed over MCP. + +use std::collections::HashMap; + +use omc_shared::agent_tool::{ + RouteRequest, ToolError, ToolResponse, capabilities_payload, normalize_request_id, + route_agent_task, +}; +use omc_shared::code_intel::{CodeIntelQueryRequest, query_code_intel}; +use omc_shared::dap_adapter::{DebugInspectRequest, inspect_debug}; +use omc_shared::hash_edit::{HashEdit, HashEditError}; +use omc_shared::lsp_adapter::{LspDocumentSymbolsRequest, LspProjectPool}; +use omc_shared::operation_contract::{ResultSchema, TypedSubagentResult}; +use omc_shared::workflow_contract::{WorkflowAdvanceRequest, advance_workflow}; +use serde::Serialize; +use serde_json::Value; + +use crate::tools::{McpTool, SchemaProperty, ToolDefinition, ToolResult, ToolSchema}; + +mod adapter_tools; +pub use adapter_tools::{DebugInspectTool, LspDocumentSymbolsTool, WorkflowAdvanceTool}; +mod contract_tools; +pub use contract_tools::{CodeIntelArtifactQueryTool, HashEditTool, SubagentResultValidateTool}; + +fn string_property(description: &str) -> SchemaProperty { + SchemaProperty { + prop_type: "string".into(), + description: Some(description.into()), + r#enum: None, + max_length: None, + minimum: None, + maximum: None, + } +} + +fn number_property(description: &str) -> SchemaProperty { + SchemaProperty { + prop_type: "number".into(), + description: Some(description.into()), + r#enum: None, + max_length: None, + minimum: Some(0), + maximum: None, + } +} + +fn boolean_property(description: &str) -> SchemaProperty { + SchemaProperty { + prop_type: "boolean".into(), + description: Some(description.into()), + r#enum: None, + max_length: None, + minimum: None, + maximum: None, + } +} + +fn array_property(description: &str) -> SchemaProperty { + SchemaProperty { + prop_type: "array".into(), + description: Some(description.into()), + r#enum: None, + max_length: None, + minimum: None, + maximum: None, + } +} + +fn encode(response: &ToolResponse, is_error: bool) -> ToolResult { + match serde_json::to_string(response) { + Ok(text) if is_error => ToolResult::error(text), + Ok(text) => ToolResult::ok(text), + Err(error) => ToolResult::error(format!("failed to encode tool response: {error}")), + } +} + +pub struct AgentCapabilitiesTool; + +impl McpTool for AgentCapabilitiesTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "agent_capabilities".into(), + description: "List the stable, host-neutral OMC-RS capability contract. Call this before selecting other OMC tools.".into(), + input_schema: ToolSchema { + schema_type: "object".into(), + properties: HashMap::new(), + required: vec![], + }, + } + } + + fn handle(&self, args: Value) -> ToolResult { + let request_id = normalize_request_id( + args.get("requestId").and_then(Value::as_str), + "mcp-capabilities", + ); + encode( + &ToolResponse::success(request_id, capabilities_payload()), + false, + ) + } +} + +pub struct AgentRouteTool; + +impl McpTool for AgentRouteTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert("task".into(), string_property("Task text to route.")); + properties.insert( + "agentType".into(), + string_property("Optional role hint such as explore, executor, or architect."), + ); + properties.insert( + "previousFailures".into(), + number_property("Number of previous failures for the same task."), + ); + properties.insert( + "requestId".into(), + string_property("Optional caller correlation ID."), + ); + + ToolDefinition { + name: "agent_route".into(), + description: "Route a task progressively. The result exposes a semantic tier and recommended surface, never a provider-specific model ID.".into(), + input_schema: ToolSchema { + schema_type: "object".into(), + properties, + required: vec!["task".into()], + }, + } + } + + fn handle(&self, args: Value) -> ToolResult { + let request_id = + normalize_request_id(args.get("requestId").and_then(Value::as_str), "mcp-route"); + let request: RouteRequest = match serde_json::from_value::(args) { + Ok(request) if !request.task.trim().is_empty() => request, + Ok(_) => { + return encode( + &ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", "task must not be empty"), + ), + true, + ); + } + Err(error) => { + return encode( + &ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", error.to_string()), + ), + true, + ); + } + }; + + encode( + &ToolResponse::success(request_id, route_agent_task(&request)), + false, + ) + } +} + +pub fn agent_tools() -> Vec> { + vec![ + Box::new(AgentCapabilitiesTool), + Box::new(AgentRouteTool), + Box::new(CodeIntelArtifactQueryTool), + Box::new(LspDocumentSymbolsTool::default()), + Box::new(DebugInspectTool), + Box::new(WorkflowAdvanceTool), + Box::new(SubagentResultValidateTool), + Box::new(HashEditTool), + ] +} + +#[cfg(test)] +#[path = "agent_tools_tests.rs"] +mod tests; diff --git a/crates/omc-mcp/src/agent_tools/adapter_tools.rs b/crates/omc-mcp/src/agent_tools/adapter_tools.rs new file mode 100644 index 0000000..349993a --- /dev/null +++ b/crates/omc-mcp/src/agent_tools/adapter_tools.rs @@ -0,0 +1,288 @@ +use super::*; + +pub struct WorkflowAdvanceTool; + +impl McpTool for WorkflowAdvanceTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert( + "currentStage".into(), + string_property("Current workflow stage."), + ); + for (name, description) in [ + ( + "requirementsClarified", + "Requirements interview is complete.", + ), + ( + "allTasksAssigned", + "The host/team has assigned the plan tasks.", + ), + ("planApproved", "The plan has passed its approval gate."), + ( + "allTasksCompleted", + "The host/team reports all execution tasks complete.", + ), + ("verificationPassed", "The verification evidence passed."), + ( + "hasFailures", + "A current execution or verification failure exists.", + ), + ("hasBlockers", "A blocker requires host or human input."), + ] { + properties.insert(name.into(), boolean_property(description)); + } + properties.insert( + "fixAttempts".into(), + number_property("Number of fix attempts already consumed."), + ); + properties.insert( + "maxFixAttempts".into(), + number_property("Maximum fix attempts before the workflow fails."), + ); + properties.insert( + "requestId".into(), + string_property("Optional caller correlation ID."), + ); + ToolDefinition { + name: "workflow_advance".into(), + description: "Advance a clarify-plan-execute-verify workflow only from supplied host evidence; it never starts an agent or task executor.".into(), + input_schema: ToolSchema { + schema_type: "object".into(), + properties, + required: vec!["currentStage".into()], + }, + } + } + + fn handle(&self, args: Value) -> ToolResult { + let request_id = normalize_request_id( + args.get("requestId").and_then(Value::as_str), + "mcp-workflow-advance", + ); + let request: WorkflowAdvanceRequest = match serde_json::from_value(args) { + Ok(request) => request, + Err(error) => { + return encode( + &ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", error.to_string()), + ), + true, + ); + } + }; + encode( + &ToolResponse::success(request_id, advance_workflow(&request)), + false, + ) + } +} + +#[derive(Default)] +pub struct LspDocumentSymbolsTool { + pool: LspProjectPool, +} + +impl McpTool for LspDocumentSymbolsTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert( + "workingDirectory".into(), + string_property("Project root used to bound the file path."), + ); + properties.insert( + "file".into(), + string_property("Project-relative Rust source file."), + ); + properties.insert( + "timeoutMs".into(), + SchemaProperty { + prop_type: "integer".into(), + description: Some("Request timeout in milliseconds (5000..60000).".into()), + r#enum: None, + max_length: None, + minimum: Some(5000), + maximum: Some(60000), + }, + ); + properties.insert( + "requestId".into(), + string_property("Optional caller correlation ID."), + ); + ToolDefinition { + name: "lsp_document_symbols".into(), + description: "Read Rust document symbols through a bounded project-scoped rust-analyzer pool owned by this MCP process.".into(), + input_schema: ToolSchema { + schema_type: "object".into(), + properties, + required: vec!["file".into()], + }, + } + } + + fn handle(&self, args: Value) -> ToolResult { + let request_id = normalize_request_id( + args.get("requestId").and_then(Value::as_str), + "mcp-lsp-document-symbols", + ); + let request: LspDocumentSymbolsRequest = match serde_json::from_value(args) { + Ok(request) => request, + Err(error) => { + return encode( + &ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", error.to_string()), + ), + true, + ); + } + }; + match self.pool.query_document_symbols(&request) { + Ok(payload) => encode(&ToolResponse::success(request_id, payload), false), + Err(error) => encode(&ToolResponse::::failure(request_id, error), true), + } + } +} + +pub struct DebugInspectTool; + +impl McpTool for DebugInspectTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert( + "adapterCommand".into(), + string_property("External DAP adapter executable or command name."), + ); + properties.insert( + "adapterArgs".into(), + array_property("Arguments passed directly to the external adapter, without a shell."), + ); + properties.insert( + "workingDirectory".into(), + string_property("Existing adapter working directory."), + ); + properties.insert( + "mode".into(), + SchemaProperty { + prop_type: "string".into(), + description: Some("Explicit DAP session mode.".into()), + r#enum: Some(vec!["launch".into(), "attach".into()]), + max_length: None, + minimum: None, + maximum: None, + }, + ); + properties.insert( + "action".into(), + SchemaProperty { + prop_type: "string".into(), + description: Some("Read-only DAP inspection action.".into()), + r#enum: Some(vec![ + "threads".into(), + "stackTrace".into(), + "scopes".into(), + "variables".into(), + "modules".into(), + "loadedSources".into(), + "output".into(), + ]), + max_length: None, + minimum: None, + maximum: None, + }, + ); + for name in ["launchArguments", "attachArguments"] { + properties.insert( + name.into(), + SchemaProperty { + prop_type: "object".into(), + description: Some( + "Adapter-specific JSON object; the selected mode must provide one.".into(), + ), + r#enum: None, + max_length: None, + minimum: None, + maximum: None, + }, + ); + } + for (name, description) in [ + ("threadId", "Thread ID required by stackTrace."), + ("frameId", "Frame ID required by scopes."), + ( + "variablesReference", + "Variables reference required by variables.", + ), + ] { + properties.insert( + name.into(), + SchemaProperty { + prop_type: "integer".into(), + description: Some(description.into()), + r#enum: None, + max_length: None, + minimum: Some(1), + maximum: None, + }, + ); + } + properties.insert( + "timeoutMs".into(), + SchemaProperty { + prop_type: "integer".into(), + description: Some("DAP request timeout in milliseconds (5000..300000).".into()), + r#enum: None, + max_length: None, + minimum: Some(5000), + maximum: Some(300000), + }, + ); + properties.insert( + "allowSideEffects".into(), + boolean_property("Required explicit opt-in for launch or attach."), + ); + properties.insert( + "requestId".into(), + string_property("Optional caller correlation ID."), + ); + + ToolDefinition { + name: "debug_inspect".into(), + description: "Launch or attach one external stdio DAP session and return only bounded read-only inspection results; OMC-RS does not install or implement the debugger adapter.".into(), + input_schema: ToolSchema { + schema_type: "object".into(), + properties, + required: vec![ + "adapterCommand".into(), + "mode".into(), + "action".into(), + "allowSideEffects".into(), + ], + }, + } + } + + fn handle(&self, args: Value) -> ToolResult { + let request_id = normalize_request_id( + args.get("requestId").and_then(Value::as_str), + "mcp-debug-inspect", + ); + let request: DebugInspectRequest = match serde_json::from_value(args) { + Ok(request) => request, + Err(error) => { + return encode( + &ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", error.to_string()), + ), + true, + ); + } + }; + match inspect_debug(&request) { + Ok(payload) => encode(&ToolResponse::success(request_id, payload), false), + Err(error) => encode(&ToolResponse::::failure(request_id, error), true), + } + } +} diff --git a/crates/omc-mcp/src/agent_tools/contract_tools.rs b/crates/omc-mcp/src/agent_tools/contract_tools.rs new file mode 100644 index 0000000..802a603 --- /dev/null +++ b/crates/omc-mcp/src/agent_tools/contract_tools.rs @@ -0,0 +1,300 @@ +use super::*; + +pub struct SubagentResultValidateTool; + +impl McpTool for SubagentResultValidateTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert( + "resultType".into(), + string_property("Named result schema ID."), + ); + properties.insert( + "payload".into(), + SchemaProperty { + prop_type: "object".into(), + description: Some("Structured result payload.".into()), + r#enum: None, + max_length: None, + minimum: None, + maximum: None, + }, + ); + properties.insert( + "requiredFields".into(), + array_property("Required top-level payload fields."), + ); + properties.insert( + "requestId".into(), + string_property("Optional caller correlation ID."), + ); + ToolDefinition { + name: "subagent_result_validate".into(), + description: "Validate a typed subagent result without running an agent or provider." + .into(), + input_schema: ToolSchema { + schema_type: "object".into(), + properties, + required: vec!["resultType".into(), "payload".into()], + }, + } + } + + fn handle(&self, args: Value) -> ToolResult { + let request_id = normalize_request_id( + args.get("requestId").and_then(Value::as_str), + "mcp-result-validate", + ); + let result_type = match args + .get("resultType") + .and_then(Value::as_str) + .filter(|v| !v.trim().is_empty()) + { + Some(value) => value, + None => { + return encode( + &ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", "resultType is required"), + ), + true, + ); + } + }; + let payload = match args.get("payload") { + Some(value) if value.is_object() => value.clone(), + _ => { + return encode( + &ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", "payload must be an object"), + ), + true, + ); + } + }; + let required_fields = match args.get("requiredFields") { + Some(value) => match serde_json::from_value::>(value.clone()) { + Ok(fields) => fields, + Err(error) => { + return encode( + &ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", error.to_string()), + ), + true, + ); + } + }, + None => Vec::new(), + }; + let schema = ResultSchema::new(result_type, required_fields); + let result = match TypedSubagentResult::new(schema.schema_id.clone(), payload, Vec::new()) { + Ok(result) => result, + Err(error) => { + return encode( + &ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", error), + ), + true, + ); + } + }; + match result.validate_against(&schema) { + Ok(()) => encode(&ToolResponse::success(request_id, result), false), + Err(error) => encode( + &ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", error), + ), + true, + ), + } + } +} + +pub struct HashEditTool; + +impl McpTool for HashEditTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert("workingDirectory".into(), string_property("Project root.")); + properties.insert( + "path".into(), + string_property("Portable project-relative file path."), + ); + properties.insert( + "startLine".into(), + number_property("First 1-based line to replace."), + ); + properties.insert( + "endLine".into(), + number_property("Last 1-based line to replace."), + ); + properties.insert( + "anchors".into(), + array_property("One SHA-256 anchor per line in the range."), + ); + properties.insert("replacement".into(), string_property("Replacement text.")); + properties.insert( + "expectedFileSha256".into(), + string_property("Optional whole-file SHA-256 precondition."), + ); + properties.insert( + "requestId".into(), + string_property("Optional caller correlation ID."), + ); + ToolDefinition { + name: "hash_edit".into(), + description: + "Apply a hash-anchored edit atomically; stale anchors are rejected before writing." + .into(), + input_schema: ToolSchema { + schema_type: "object".into(), + properties, + required: vec![ + "path".into(), + "startLine".into(), + "endLine".into(), + "anchors".into(), + "replacement".into(), + ], + }, + } + } + + fn handle(&self, args: Value) -> ToolResult { + let request_id = normalize_request_id( + args.get("requestId").and_then(Value::as_str), + "mcp-hash-edit", + ); + let root = args + .get("workingDirectory") + .and_then(Value::as_str) + .unwrap_or("."); + let edit = match serde_json::from_value::(serde_json::json!({ + "schemaVersion": omc_shared::hash_edit::HASH_EDIT_SCHEMA_VERSION, + "path": args.get("path"), + "startLine": args.get("startLine"), + "endLine": args.get("endLine"), + "anchors": args.get("anchors"), + "replacement": args.get("replacement"), + "expectedFileSha256": args.get("expectedFileSha256") + })) { + Ok(edit) => edit, + Err(error) => { + return encode( + &ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", error.to_string()), + ), + true, + ); + } + }; + match edit.apply(std::path::Path::new(root)) { + Ok(result) => encode(&ToolResponse::success(request_id, result), false), + Err(error) => { + let code = if matches!( + error, + HashEditError::StaleAnchor { .. } | HashEditError::StaleFile { .. } + ) { + "stale_edit" + } else { + "edit_failed" + }; + encode( + &ToolResponse::::failure( + request_id, + ToolError::new(code, error.to_string()), + ), + true, + ) + } + } + } +} + +pub struct CodeIntelArtifactQueryTool; + +impl McpTool for CodeIntelArtifactQueryTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert("repo".into(), string_property("Code Intel repository key.")); + properties.insert( + "artifactRoot".into(), + string_property("Published Code Intel artifact root."), + ); + properties.insert( + "repoPath".into(), + string_property("Optional checkout used for freshness evaluation."), + ); + properties.insert( + "artifactSchema".into(), + string_property("Optional artifact schema filter."), + ); + properties.insert( + "artifactType".into(), + string_property("Optional artifact type filter."), + ); + properties.insert( + "contains".into(), + string_property("Optional text filter applied by Code Intel."), + ); + properties.insert( + "artifactUri".into(), + string_property( + "Optional canonical omc://artifact/sha256/ for bounded exact inspection.", + ), + ); + properties.insert( + "limit".into(), + SchemaProperty { + prop_type: "integer".into(), + description: Some("Maximum number of matches, from 1 to 100.".into()), + r#enum: None, + max_length: None, + minimum: Some(1), + maximum: Some(100), + }, + ); + properties.insert( + "requestId".into(), + string_property("Optional caller correlation ID."), + ); + + ToolDefinition { + name: "code_intel_artifact_query".into(), + description: "Read committed Code Intel artifacts through the released read-only query surface; artifactUri enables bounded exact inspection. OMC does not reimplement the scanner or artifact index.".into(), + input_schema: ToolSchema { + schema_type: "object".into(), + properties, + required: vec!["repo".into()], + }, + } + } + + fn handle(&self, args: Value) -> ToolResult { + let request_id = normalize_request_id( + args.get("requestId").and_then(Value::as_str), + "mcp-code-intel", + ); + let request: CodeIntelQueryRequest = match serde_json::from_value(args) { + Ok(request) => request, + Err(error) => { + return encode( + &ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", error.to_string()), + ), + true, + ); + } + }; + match query_code_intel(&request) { + Ok(payload) => encode(&ToolResponse::success(request_id, payload), false), + Err(error) => encode(&ToolResponse::::failure(request_id, error), true), + } + } +} diff --git a/crates/omc-mcp/src/agent_tools_tests.rs b/crates/omc-mcp/src/agent_tools_tests.rs new file mode 100644 index 0000000..3b4513c --- /dev/null +++ b/crates/omc-mcp/src/agent_tools_tests.rs @@ -0,0 +1,119 @@ +use super::*; + +#[test] +fn capabilities_tool_returns_contract_json() { + let result = AgentCapabilitiesTool.handle(serde_json::json!({ + "requestId": "test-1" + })); + assert_eq!(result.is_error, None); + assert!(result.content[0].text.contains("omc.tool.v1")); + assert!(result.content[0].text.contains("agent_route")); +} + +#[test] +fn route_tool_rejects_missing_task() { + let result = AgentRouteTool.handle(serde_json::json!({ + "requestId": "test-2" + })); + assert_eq!(result.is_error, Some(true)); + assert!(result.content[0].text.contains("invalid_request")); +} + +#[test] +fn code_intel_tool_rejects_missing_repository() { + let result = CodeIntelArtifactQueryTool.handle(serde_json::json!({ + "requestId": "test-3" + })); + assert_eq!(result.is_error, Some(true)); + assert!(result.content[0].text.contains("invalid_request")); +} + +#[test] +fn lsp_tool_rejects_missing_file_without_starting_server() { + let result = LspDocumentSymbolsTool::default().handle(serde_json::json!({ + "requestId": "test-lsp-1", + "workingDirectory": "." + })); + assert_eq!(result.is_error, Some(true)); + assert!(result.content[0].text.contains("invalid_request")); +} + +#[test] +fn lsp_tool_definition_is_read_only_by_contract() { + let definition = LspDocumentSymbolsTool::default().definition(); + assert_eq!(definition.name, "lsp_document_symbols"); + assert!(definition.input_schema.properties.contains_key("file")); + assert_eq!( + definition.input_schema.properties["timeoutMs"].minimum, + Some(5000) + ); +} + +#[test] +fn debug_tool_requires_explicit_side_effect_opt_in() { + let result = DebugInspectTool.handle(serde_json::json!({ + "adapterCommand": "missing-dap-adapter", + "mode": "launch", + "action": "threads", + "launchArguments": {"program": "target"}, + "requestId": "debug-test" + })); + assert_eq!(result.is_error, Some(true)); + assert!(result.content[0].text.contains("side_effects_not_allowed")); +} + +#[test] +fn debug_tool_definition_exposes_only_read_actions() { + let definition = DebugInspectTool.definition(); + assert_eq!(definition.name, "debug_inspect"); + assert!( + definition.input_schema.properties["action"] + .r#enum + .as_ref() + .expect("debug action enum") + .contains(&"threads".into()) + ); + assert!( + !definition.input_schema.properties["action"] + .r#enum + .as_ref() + .expect("debug action enum") + .contains(&"continue".into()) + ); +} + +#[test] +fn workflow_tool_advances_only_from_supplied_evidence() { + let result = WorkflowAdvanceTool.handle(serde_json::json!({ + "currentStage": "planning", + "allTasksAssigned": true, + "planApproved": true, + "requestId": "workflow-test" + })); + assert_eq!(result.is_error, None); + let text = &result.content[0].text; + assert!(text.contains("omc.workflow.v1")); + assert!(text.contains("executing")); +} + +#[test] +fn workflow_tool_rejects_unknown_stage() { + let result = WorkflowAdvanceTool.handle(serde_json::json!({ + "currentStage": "not-a-stage", + "requestId": "workflow-invalid" + })); + assert_eq!(result.is_error, Some(true)); + assert!(result.content[0].text.contains("invalid_request")); +} + +#[test] +fn result_tool_validates_required_fields() { + let result = SubagentResultValidateTool.handle(serde_json::json!({ + "resultType": "omc.agent.findings.v1", + "payload": {"summary": "done"}, + "requiredFields": ["summary"], + "requestId": "test-4" + })); + assert_eq!(result.is_error, None); + assert!(result.content[0].text.contains("omc.subagent-result.v1")); +} diff --git a/crates/omc-mcp/src/goal_tools.rs b/crates/omc-mcp/src/goal_tools.rs new file mode 100644 index 0000000..391bce6 --- /dev/null +++ b/crates/omc-mcp/src/goal_tools.rs @@ -0,0 +1,388 @@ +//! Durable project-goal MCP tools. +//! +//! These tools persist goal control-plane state only. They do not dispatch an +//! agent, call a provider, or emulate a task executor. + +use std::collections::HashMap; +use std::path::PathBuf; + +use chrono::Utc; +use omc_shared::operation_contract::ArtifactRef; +use omc_shared::{GoalCheckpoint, GoalLedger, GoalRecord, OmcPaths}; +use serde::Serialize; +use serde_json::Value; + +use crate::tools::{McpTool, SchemaProperty, ToolDefinition, ToolResult, ToolSchema}; + +fn string_property(description: &str) -> SchemaProperty { + SchemaProperty { + prop_type: "string".into(), + description: Some(description.into()), + r#enum: None, + max_length: None, + minimum: None, + maximum: None, + } +} + +fn array_property(description: &str) -> SchemaProperty { + SchemaProperty { + prop_type: "array".into(), + description: Some(description.into()), + r#enum: None, + max_length: None, + minimum: None, + maximum: None, + } +} + +fn object_schema(properties: HashMap, required: Vec) -> ToolSchema { + ToolSchema { + schema_type: "object".into(), + properties, + required, + } +} + +fn working_directory(properties: &mut HashMap) { + properties.insert( + "workingDirectory".into(), + string_property("Project root. Defaults to the current directory."), + ); +} + +fn ledger(args: &Value) -> GoalLedger { + let cwd = args + .get("workingDirectory") + .and_then(Value::as_str) + .unwrap_or("."); + GoalLedger::new(OmcPaths::new_with_root(PathBuf::from(cwd).join(".omc"))) +} + +fn required_string<'a>(args: &'a Value, field: &str) -> Result<&'a str, ToolResult> { + args.get(field) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| ToolResult::error(format!("Missing required parameter: {field}"))) +} + +fn now() -> String { + Utc::now().to_rfc3339() +} + +fn encode(value: &T) -> ToolResult { + match serde_json::to_string_pretty(value) { + Ok(text) => ToolResult::ok(text), + Err(error) => ToolResult::error(format!("failed to encode goal response: {error}")), + } +} + +fn encode_error(error: impl std::fmt::Display) -> ToolResult { + ToolResult::error(format!("goal operation failed: {error}")) +} + +pub struct GoalCreateTool; + +impl McpTool for GoalCreateTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert("goalId".into(), string_property("Stable goal identifier.")); + properties.insert("objective".into(), string_property("Goal objective.")); + properties.insert( + "owner".into(), + string_property("Optional agency or human owner."), + ); + properties.insert( + "taskId".into(), + string_property("Optional task ID used for dispatch correlation."), + ); + working_directory(&mut properties); + ToolDefinition { + name: "goal_create".into(), + description: "Create a planned project goal in the durable OMC ledger.".into(), + input_schema: object_schema(properties, vec!["goalId".into(), "objective".into()]), + } + } + + fn handle(&self, args: Value) -> ToolResult { + let goal_id = match required_string(&args, "goalId") { + Ok(value) => value, + Err(error) => return error, + }; + let objective = match required_string(&args, "objective") { + Ok(value) => value, + Err(error) => return error, + }; + let mut goal = GoalRecord::new(goal_id, objective, now()); + goal.owner = args + .get("owner") + .and_then(Value::as_str) + .map(ToString::to_string); + if let Some(task_id) = args.get("taskId").and_then(Value::as_str) + && let Err(error) = goal.attach_task(task_id) + { + return encode_error(error); + } + match ledger(&args).create(&goal) { + Ok(()) => encode(&goal), + Err(error) => encode_error(error), + } + } +} + +pub struct GoalListTool; + +impl McpTool for GoalListTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + working_directory(&mut properties); + ToolDefinition { + name: "goal_list".into(), + description: "List durable project goals, sorted by goal ID.".into(), + input_schema: object_schema(properties, Vec::new()), + } + } + + fn handle(&self, args: Value) -> ToolResult { + match ledger(&args).list() { + Ok(goals) => encode(&goals), + Err(error) => encode_error(error), + } + } +} + +pub struct GoalGetTool; + +impl McpTool for GoalGetTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert("goalId".into(), string_property("Stable goal identifier.")); + working_directory(&mut properties); + ToolDefinition { + name: "goal_get".into(), + description: "Read one durable project goal.".into(), + input_schema: object_schema(properties, vec!["goalId".into()]), + } + } + + fn handle(&self, args: Value) -> ToolResult { + let goal_id = match required_string(&args, "goalId") { + Ok(value) => value, + Err(error) => return error, + }; + match ledger(&args).load(goal_id) { + Ok(goal) => encode(&goal), + Err(error) => encode_error(error), + } + } +} + +pub struct GoalStartTool; + +impl McpTool for GoalStartTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert("goalId".into(), string_property("Stable goal identifier.")); + working_directory(&mut properties); + ToolDefinition { + name: "goal_start".into(), + description: "Resume a planned or blocked goal as active.".into(), + input_schema: object_schema(properties, vec!["goalId".into()]), + } + } + + fn handle(&self, args: Value) -> ToolResult { + let goal_id = match required_string(&args, "goalId") { + Ok(value) => value, + Err(error) => return error, + }; + let ledger = ledger(&args); + let mut goal = match ledger.load(goal_id) { + Ok(goal) => goal, + Err(error) => return encode_error(error), + }; + if let Err(error) = goal.start(now()) { + return encode_error(error); + } + match ledger.save(&goal) { + Ok(()) => encode(&goal), + Err(error) => encode_error(error), + } + } +} + +pub struct GoalBlockTool; + +impl McpTool for GoalBlockTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert("goalId".into(), string_property("Stable goal identifier.")); + properties.insert("reason".into(), string_property("Durable blocker reason.")); + working_directory(&mut properties); + ToolDefinition { + name: "goal_block".into(), + description: "Persist a blocker and mark a goal blocked.".into(), + input_schema: object_schema(properties, vec!["goalId".into(), "reason".into()]), + } + } + + fn handle(&self, args: Value) -> ToolResult { + let goal_id = match required_string(&args, "goalId") { + Ok(value) => value, + Err(error) => return error, + }; + let reason = match required_string(&args, "reason") { + Ok(value) => value, + Err(error) => return error, + }; + let ledger = ledger(&args); + let mut goal = match ledger.load(goal_id) { + Ok(goal) => goal, + Err(error) => return encode_error(error), + }; + if let Err(error) = goal.block(reason, now()) { + return encode_error(error); + } + match ledger.save(&goal) { + Ok(()) => encode(&goal), + Err(error) => encode_error(error), + } + } +} + +pub struct GoalCheckpointTool; + +impl McpTool for GoalCheckpointTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert("goalId".into(), string_property("Stable goal identifier.")); + properties.insert( + "checkpointId".into(), + string_property("Unique checkpoint identifier within the goal."), + ); + properties.insert("summary".into(), string_property("Checkpoint summary.")); + properties.insert( + "artifactRefs".into(), + array_property("Optional normalized artifact references."), + ); + working_directory(&mut properties); + ToolDefinition { + name: "goal_checkpoint".into(), + description: "Append a durable checkpoint, optionally linking normalized artifacts." + .into(), + input_schema: object_schema( + properties, + vec!["goalId".into(), "checkpointId".into(), "summary".into()], + ), + } + } + + fn handle(&self, args: Value) -> ToolResult { + let goal_id = match required_string(&args, "goalId") { + Ok(value) => value, + Err(error) => return error, + }; + let checkpoint_id = match required_string(&args, "checkpointId") { + Ok(value) => value, + Err(error) => return error, + }; + let summary = match required_string(&args, "summary") { + Ok(value) => value, + Err(error) => return error, + }; + let artifact_refs = match args.get("artifactRefs") { + Some(value) => match serde_json::from_value::>(value.clone()) { + Ok(refs) => refs + .into_iter() + .map(ArtifactRef::with_canonical_uri) + .collect(), + Err(error) => return encode_error(error), + }, + None => Vec::new(), + }; + let ledger = ledger(&args); + let mut goal = match ledger.load(goal_id) { + Ok(goal) => goal, + Err(error) => return encode_error(error), + }; + if let Err(error) = goal.add_checkpoint(GoalCheckpoint { + checkpoint_id: checkpoint_id.to_string(), + summary: summary.to_string(), + recorded_at: now(), + artifact_refs, + }) { + return encode_error(error); + } + match ledger.save(&goal) { + Ok(()) => encode(&goal), + Err(error) => encode_error(error), + } + } +} + +pub struct GoalCompleteTool; + +impl McpTool for GoalCompleteTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert("goalId".into(), string_property("Stable goal identifier.")); + working_directory(&mut properties); + ToolDefinition { + name: "goal_complete".into(), + description: "Mark an active goal completed.".into(), + input_schema: object_schema(properties, vec!["goalId".into()]), + } + } + + fn handle(&self, args: Value) -> ToolResult { + let goal_id = match required_string(&args, "goalId") { + Ok(value) => value, + Err(error) => return error, + }; + let ledger = ledger(&args); + let mut goal = match ledger.load(goal_id) { + Ok(goal) => goal, + Err(error) => return encode_error(error), + }; + if let Err(error) = goal.complete(now()) { + return encode_error(error); + } + match ledger.save(&goal) { + Ok(()) => encode(&goal), + Err(error) => encode_error(error), + } + } +} + +pub fn goal_tools() -> Vec> { + vec![ + Box::new(GoalCreateTool), + Box::new(GoalListTool), + Box::new(GoalGetTool), + Box::new(GoalStartTool), + Box::new(GoalBlockTool), + Box::new(GoalCheckpointTool), + Box::new(GoalCompleteTool), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_tool_rejects_missing_objective() { + let result = GoalCreateTool.handle(serde_json::json!({"goalId": "goal-1"})); + assert_eq!(result.is_error, Some(true)); + assert!(result.content[0].text.contains("objective")); + } + + #[test] + fn goal_tool_definitions_are_unique() { + let tools = goal_tools(); + let mut names: Vec<_> = tools.iter().map(|tool| tool.definition().name).collect(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), tools.len()); + } +} diff --git a/crates/omc-mcp/src/lib.rs b/crates/omc-mcp/src/lib.rs index d50e565..ab52c64 100644 --- a/crates/omc-mcp/src/lib.rs +++ b/crates/omc-mcp/src/lib.rs @@ -1,23 +1,60 @@ //! omc-mcp: MCP Tool Server for oh-my-claudecode-RS //! -//! Provides MCP tools via JSON-RPC over stdio for state management, -//! notepad operations, and project memory. +//! Provides MCP tools via JSON-RPC over stdio for agent capability discovery, +//! routing, state management, notepad operations, and project memory. +pub mod agent_tools; +pub mod goal_tools; pub mod memory_tools; pub mod notepad_tools; pub mod protocol_registry; +pub mod python_tools; +pub mod schema_contract; +pub mod server; pub mod state_tools; +pub mod team_tools; pub mod tool_registry; pub mod tools; +pub use server::run_stdio; pub use tool_registry::McpToolRegistry; pub use tools::{McpTool, ToolDefinition, ToolResult}; /// Collect all registered MCP tools. pub fn all_tools() -> Vec> { let mut tools: Vec> = Vec::new(); + tools.extend(agent_tools::agent_tools()); + tools.extend(goal_tools::goal_tools()); tools.extend(state_tools::state_tools()); tools.extend(notepad_tools::notepad_tools()); tools.extend(memory_tools::memory_tools()); + tools.extend(python_tools::python_tools()); + tools.extend(team_tools::team_tools()); tools } + +#[cfg(test)] +mod catalog_tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn registered_tools_exactly_match_the_shared_catalog() { + let registered = all_tools() + .into_iter() + .map(|tool| tool.definition().name) + .collect::>(); + let catalog = omc_shared::capability_catalog::mcp_tool_names() + .into_iter() + .map(str::to_string) + .collect::>(); + assert_eq!(registered, catalog); + } + + #[test] + fn released_v1_schema_remains_backward_compatible() { + let baseline = include_str!("../../../schemas/mcp-tools-v1.json"); + schema_contract::verify_backward_compatible(baseline, &schema_contract::current_manifest()) + .expect("MCP v1 schema contains an undeclared breaking change"); + } +} diff --git a/crates/omc-mcp/src/main.rs b/crates/omc-mcp/src/main.rs index ee87fec..87e1daf 100644 --- a/crates/omc-mcp/src/main.rs +++ b/crates/omc-mcp/src/main.rs @@ -1,226 +1,3 @@ -//! MCP Tool Server entry point. -//! -//! Implements a JSON-RPC 2.0 server over stdin/stdout following the -//! Model Context Protocol (MCP) specification. -//! -//! Supports the following JSON-RPC methods: -//! - `initialize` - Server initialization handshake -//! - `notifications/initialized` - Client acknowledgment -//! - `tools/list` - List available tools -//! - `tools/call` - Invoke a tool - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::io::{self, BufRead, Write}; - -use omc_mcp::{McpTool, ToolDefinition, all_tools}; - -// ============================================================================ -// JSON-RPC types -// ============================================================================ - -#[derive(Debug, Deserialize)] -struct JsonRpcRequest { - #[allow(dead_code)] - jsonrpc: String, - #[serde(default)] - id: Option, - method: String, - #[serde(default)] - params: Option, -} - -#[derive(Debug, Serialize)] -struct JsonRpcResponse { - jsonrpc: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Debug, Serialize)] -struct JsonRpcError { - code: i64, - message: String, - #[serde(skip_serializing_if = "Option::is_none")] - data: Option, -} - -impl JsonRpcResponse { - fn result(id: Option, result: Value) -> Self { - Self { - jsonrpc: "2.0", - id, - result: Some(result), - error: None, - } - } - - fn error(id: Option, code: i64, message: String) -> Self { - Self { - jsonrpc: "2.0", - id, - result: None, - error: Some(JsonRpcError { - code, - message, - data: None, - }), - } - } - - /// Notifications have no id and no result/error. - fn notification() -> Self { - Self { - jsonrpc: "2.0", - id: None, - result: None, - error: None, - } - } -} - -// ============================================================================ -// MCP server -// ============================================================================ - -struct McpServer { - tools: Vec>, -} - -impl McpServer { - fn new() -> Self { - Self { tools: all_tools() } - } - - fn handle_request(&self, request: JsonRpcRequest) -> JsonRpcResponse { - match request.method.as_str() { - "initialize" => self.handle_initialize(request.id), - "notifications/initialized" => { - // Client acknowledgment, no response needed (notification) - JsonRpcResponse::notification() - } - "tools/list" => self.handle_tools_list(request.id), - "tools/call" => self.handle_tools_call(request.id, request.params), - _ => JsonRpcResponse::error( - request.id, - -32601, - format!("Method not found: {}", request.method), - ), - } - } - - fn handle_initialize(&self, id: Option) -> JsonRpcResponse { - let result = serde_json::json!({ - "protocolVersion": "2024-11-05", - "capabilities": { - "tools": {} - }, - "serverInfo": { - "name": "omc-mcp", - "version": env!("CARGO_PKG_VERSION") - } - }); - JsonRpcResponse::result(id, result) - } - - fn handle_tools_list(&self, id: Option) -> JsonRpcResponse { - let definitions: Vec = self.tools.iter().map(|t| t.definition()).collect(); - let tools: Vec = definitions - .iter() - .map(|d| serde_json::to_value(d).unwrap_or_default()) - .collect(); - - let result = serde_json::json!({ "tools": tools }); - JsonRpcResponse::result(id, result) - } - - fn handle_tools_call(&self, id: Option, params: Option) -> JsonRpcResponse { - let params = match params { - Some(p) => p, - None => { - return JsonRpcResponse::error(id, -32602, "Missing params".into()); - } - }; - - let tool_name = match params.get("name").and_then(|v| v.as_str()) { - Some(n) => n, - None => { - return JsonRpcResponse::error(id, -32602, "Missing tool name".into()); - } - }; - - let args = params - .get("arguments") - .cloned() - .unwrap_or_else(|| Value::Object(serde_json::Map::new())); - - // Find the tool - let tool = match self.tools.iter().find(|t| t.definition().name == tool_name) { - Some(t) => t, - None => { - return JsonRpcResponse::error(id, -32602, format!("Unknown tool: {tool_name}")); - } - }; - - let result = tool.handle(args); - match serde_json::to_value(&result) { - Ok(value) => JsonRpcResponse::result(id, value), - Err(e) => JsonRpcResponse::error(id, -32603, format!("Serialization error: {e}")), - } - } -} - -// ============================================================================ -// Main: JSON-RPC over stdio loop -// ============================================================================ - -fn main() -> io::Result<()> { - let server = McpServer::new(); - let stdin = io::stdin().lock(); - let mut stdout = io::stdout().lock(); - - for line in stdin.lines() { - let line = match line { - Ok(l) => l, - Err(e) => { - eprintln!("omc-mcp: stdin read error: {e}"); - break; - } - }; - - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - let request: JsonRpcRequest = match serde_json::from_str(trimmed) { - Ok(r) => r, - Err(e) => { - let response = JsonRpcResponse::error(None, -32700, format!("Parse error: {e}")); - write_response(&mut stdout, &response)?; - continue; - } - }; - - let is_notification = request.id.is_none(); - let response = server.handle_request(request); - - // Don't send responses for notifications - if !is_notification { - write_response(&mut stdout, &response)?; - } - } - - Ok(()) -} - -fn write_response(stdout: &mut impl Write, response: &JsonRpcResponse) -> io::Result<()> { - let json = serde_json::to_string(response).unwrap_or_default(); - stdout.write_all(json.as_bytes())?; - stdout.write_all(b"\n")?; - stdout.flush() +fn main() -> std::io::Result<()> { + omc_mcp::run_stdio() } diff --git a/crates/omc-mcp/src/python_tools.rs b/crates/omc-mcp/src/python_tools.rs new file mode 100644 index 0000000..f62c1a0 --- /dev/null +++ b/crates/omc-mcp/src/python_tools.rs @@ -0,0 +1,256 @@ +//! Explicit-side-effect Python eval tool. + +use std::collections::HashMap; +use std::path::Path; + +use omc_python::{PythonReplService, PythonSessionError, PythonToolPayload, PythonToolRequest}; +use omc_shared::PYTHON_SCHEMA_VERSION; +use omc_shared::agent_tool::{ToolError, ToolResponse, error_codes, normalize_request_id}; +use serde_json::Value; + +use crate::tools::{McpTool, SchemaProperty, ToolDefinition, ToolResult, ToolSchema}; + +pub struct PythonReplTool { + service: PythonReplService, +} + +impl Default for PythonReplTool { + fn default() -> Self { + Self { + service: PythonReplService::new(), + } + } +} + +pub fn python_tools() -> Vec> { + vec![Box::new(PythonReplTool::default())] +} + +impl McpTool for PythonReplTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert( + "action".into(), + SchemaProperty { + prop_type: "string".into(), + description: Some("Explicit operation for the Python session.".into()), + r#enum: Some(vec![ + "execute".into(), + "get_state".into(), + "reset".into(), + "interrupt".into(), + "list_sessions".into(), + "close".into(), + ]), + max_length: None, + minimum: None, + maximum: None, + }, + ); + properties.insert( + "sessionId".into(), + string_property("Stable session key. State persists within this MCP process."), + ); + properties.insert( + "code".into(), + string_property("Python cell source; required for execute."), + ); + properties.insert( + "projectDir".into(), + string_property("Existing working directory for the Python subprocess."), + ); + properties.insert( + "executionTimeout".into(), + SchemaProperty { + prop_type: "integer".into(), + description: Some("Execution timeout in milliseconds (1..300000).".into()), + r#enum: None, + max_length: None, + minimum: Some(1), + maximum: Some(300_000), + }, + ); + properties.insert( + "allowSideEffects".into(), + boolean_property("Required for execute, reset, or interrupt."), + ); + properties.insert( + "requestId".into(), + string_property("Optional caller correlation ID."), + ); + ToolDefinition { + name: "python_repl".into(), + description: format!( + "{PYTHON_SCHEMA_VERSION}: execute persistent local Python cells. This is not a sandbox; mutating actions require allowSideEffects=true." + ), + input_schema: ToolSchema { + schema_type: "object".into(), + properties, + required: vec!["action".into(), "sessionId".into()], + }, + } + } + + fn handle(&self, args: Value) -> ToolResult { + let request_id = normalize_request_id( + args.get("requestId").and_then(Value::as_str), + "mcp-python-repl", + ); + let request: PythonToolRequest = match serde_json::from_value(args) { + Ok(request) => request, + Err(error) => { + return encode_error( + request_id, + ToolError::new(error_codes::INVALID_REQUEST, error.to_string()), + ); + } + }; + let action = request.action.clone(); + let session_id = request.session_id.clone(); + let project_dir = request.project_dir.clone(); + let (input, allow_side_effects) = match request.into_input() { + Ok(input) => input, + Err(error) => return encode_error(request_id, map_error(error)), + }; + if !matches!( + action, + omc_python::ReplAction::GetState | omc_python::ReplAction::ListSessions + ) && !allow_side_effects + { + return encode_error( + request_id, + ToolError::new( + error_codes::SIDE_EFFECTS_NOT_ALLOWED, + "allowSideEffects=true is required for Python execution or session mutation", + ), + ); + } + + let result = match action { + omc_python::ReplAction::Execute => self.service.execute(&input).map(|value| { + serde_json::to_value(value) + .map_err(|error| PythonSessionError::Failed(error.to_string())) + }), + omc_python::ReplAction::GetState => self + .service + .state(&session_id, project_dir.as_deref()) + .map(|value| { + serde_json::to_value(value) + .map_err(|error| PythonSessionError::Failed(error.to_string())) + }), + omc_python::ReplAction::Reset => self + .service + .reset(&session_id, project_dir.as_deref()) + .map(|value| { + serde_json::to_value(value) + .map_err(|error| PythonSessionError::Failed(error.to_string())) + }), + omc_python::ReplAction::Interrupt => self + .service + .interrupt(&session_id, project_dir.as_deref()) + .map(|value| { + serde_json::to_value(value) + .map_err(|error| PythonSessionError::Failed(error.to_string())) + }), + omc_python::ReplAction::ListSessions => self.service.list_sessions().map(|value| { + serde_json::to_value(value) + .map_err(|error| PythonSessionError::Failed(error.to_string())) + }), + omc_python::ReplAction::Close => self + .service + .close(&session_id, project_dir.as_deref()) + .map(|closed| Ok(serde_json::json!({ "closed": closed }))), + }; + match result.and_then(|value| value) { + Ok(result) => { + let project_dir = project_dir + .as_deref() + .map(Path::new) + .and_then(|path| path.canonicalize().ok()) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()) + .to_string_lossy() + .into_owned(); + encode_success( + request_id, + PythonToolPayload { + operation: "python.eval", + action, + session_id, + project_dir, + session_scope: "mcp-process", + result, + side_effects: if allow_side_effects { + vec!["local Python code may read/write files, spawn processes, or use network".into()] + } else { + Vec::new() + }, + }, + ) + } + Err(error) => encode_error(request_id, map_error(error)), + } + } +} + +fn string_property(description: &str) -> SchemaProperty { + SchemaProperty { + prop_type: "string".into(), + description: Some(description.into()), + r#enum: None, + max_length: None, + minimum: None, + maximum: None, + } +} + +fn boolean_property(description: &str) -> SchemaProperty { + SchemaProperty { + prop_type: "boolean".into(), + description: Some(description.into()), + r#enum: None, + max_length: None, + minimum: None, + maximum: None, + } +} + +fn map_error(error: PythonSessionError) -> ToolError { + let code = match &error { + PythonSessionError::InvalidRequest(_) => error_codes::INVALID_REQUEST, + PythonSessionError::Unavailable(_) => error_codes::ADAPTER_UNAVAILABLE, + PythonSessionError::Timeout(_) => "execution_timeout", + PythonSessionError::Failed(_) => error_codes::UPSTREAM_FAILED, + }; + ToolError::new(code, error.to_string()) +} + +fn encode_success(request_id: String, value: T) -> ToolResult { + match serde_json::to_string(&ToolResponse::success(request_id, value)) { + Ok(text) => ToolResult::ok(text), + Err(error) => ToolResult::error(error.to_string()), + } +} + +fn encode_error(request_id: String, error: ToolError) -> ToolResult { + match serde_json::to_string(&ToolResponse::::failure(request_id, error)) { + Ok(text) => ToolResult::error(text), + Err(error) => ToolResult::error(error.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn python_tool_requires_side_effect_opt_in() { + let result = PythonReplTool::default().handle(json!({ + "action": "execute", + "sessionId": "test", + "code": "print(1)" + })); + assert_eq!(result.is_error, Some(true)); + assert!(result.content[0].text.contains("side_effects_not_allowed")); + } +} diff --git a/crates/omc-mcp/src/schema_contract.rs b/crates/omc-mcp/src/schema_contract.rs new file mode 100644 index 0000000..c7962e9 --- /dev/null +++ b/crates/omc-mcp/src/schema_contract.rs @@ -0,0 +1,243 @@ +//! Versioned compatibility checks for the public MCP tool surface. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::McpToolRegistry; +use crate::tools::{SchemaProperty, ToolSchema}; + +/// Stable, machine-readable MCP schema manifest. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SchemaManifest { + pub schema_version: String, + pub tools: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SchemaTool { + pub name: String, + pub input_schema: StableToolSchema, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StableToolSchema { + #[serde(rename = "type")] + pub schema_type: String, + pub properties: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub required: BTreeSet, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StableProperty { + #[serde(rename = "type")] + pub property_type: String, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub r#enum: BTreeSet, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub minimum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub maximum: Option, +} + +impl From for StableToolSchema { + fn from(schema: ToolSchema) -> Self { + Self { + schema_type: schema.schema_type, + properties: schema + .properties + .into_iter() + .map(|(name, property)| (name, property.into())) + .collect(), + required: schema.required.into_iter().collect(), + } + } +} + +impl From for StableProperty { + fn from(property: SchemaProperty) -> Self { + Self { + property_type: property.prop_type, + r#enum: property.r#enum.unwrap_or_default().into_iter().collect(), + max_length: property.max_length, + minimum: property.minimum, + maximum: property.maximum, + } + } +} + +/// Snapshot the effective production registry, excluding mutable descriptions. +pub fn current_manifest() -> SchemaManifest { + let mut tools = McpToolRegistry::all_enabled() + .into_tools() + .into_iter() + .map(|tool| { + let definition = tool.definition(); + SchemaTool { + name: definition.name, + input_schema: definition.input_schema.into(), + } + }) + .collect::>(); + tools.sort_by(|left, right| left.name.cmp(&right.name)); + SchemaManifest { + schema_version: "omc.mcp-tools.v1".to_string(), + tools, + } +} + +/// Reject changes that would invalidate a client generated from `baseline_json`. +pub fn verify_backward_compatible( + baseline_json: &str, + current: &SchemaManifest, +) -> Result<(), String> { + let baseline: SchemaManifest = + serde_json::from_str(baseline_json).map_err(|error| error.to_string())?; + if baseline.schema_version != current.schema_version { + return Err(format!( + "schema version changed from {} to {}", + baseline.schema_version, current.schema_version + )); + } + + let current_tools = current + .tools + .iter() + .map(|tool| (tool.name.as_str(), tool)) + .collect::>(); + for released in &baseline.tools { + let Some(candidate) = current_tools.get(released.name.as_str()) else { + return Err(format!("released tool removed: {}", released.name)); + }; + compare_schema(released, candidate)?; + } + Ok(()) +} + +fn compare_schema(released: &SchemaTool, candidate: &SchemaTool) -> Result<(), String> { + if released.input_schema.schema_type != candidate.input_schema.schema_type { + return Err(format!("{} input type changed", released.name)); + } + for (name, old) in &released.input_schema.properties { + let Some(new) = candidate.input_schema.properties.get(name) else { + return Err(format!("{}.{} was removed", released.name, name)); + }; + if old.property_type != new.property_type { + return Err(format!("{}.{} type changed", released.name, name)); + } + if (old.r#enum.is_empty() && !new.r#enum.is_empty()) + || (!old.r#enum.is_empty() && !new.r#enum.is_superset(&old.r#enum)) + { + return Err(format!("{}.{} enum was narrowed", released.name, name)); + } + if tighter_max(old.max_length, new.max_length) + || tighter_max_i64(old.maximum, new.maximum) + || tighter_min(old.minimum, new.minimum) + { + return Err(format!( + "{}.{} constraint was tightened", + released.name, name + )); + } + } + for required in &candidate.input_schema.required { + if !released.input_schema.required.contains(required) { + return Err(format!("{}.{} became required", released.name, required)); + } + } + Ok(()) +} + +fn tighter_max(old: Option, new: Option) -> bool { + match (old, new) { + (None, Some(_)) => true, + (Some(old), Some(new)) => new < old, + _ => false, + } +} + +fn tighter_max_i64(old: Option, new: Option) -> bool { + match (old, new) { + (None, Some(_)) => true, + (Some(old), Some(new)) => new < old, + _ => false, + } +} + +fn tighter_min(old: Option, new: Option) -> bool { + match (old, new) { + (None, Some(_)) => true, + (Some(old), Some(new)) => new > old, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adding_an_optional_property_is_compatible() { + let baseline = manifest_with_property(false); + let mut current = baseline.clone(); + current.tools[0].input_schema.properties.insert( + "extra".to_string(), + StableProperty { + property_type: "string".to_string(), + r#enum: BTreeSet::new(), + max_length: None, + minimum: None, + maximum: None, + }, + ); + let json = serde_json::to_string(&baseline).unwrap(); + assert!(verify_backward_compatible(&json, ¤t).is_ok()); + } + + #[test] + fn making_a_property_required_is_breaking() { + let baseline = manifest_with_property(false); + let current = manifest_with_property(true); + let json = serde_json::to_string(&baseline).unwrap(); + assert!(verify_backward_compatible(&json, ¤t).is_err()); + } + + #[test] + fn introducing_an_enum_is_breaking() { + let baseline = manifest_with_property(false); + let mut current = baseline.clone(); + current.tools[0] + .input_schema + .properties + .get_mut("value") + .unwrap() + .r#enum = ["only".to_string()].into_iter().collect(); + let json = serde_json::to_string(&baseline).unwrap(); + assert!(verify_backward_compatible(&json, ¤t).is_err()); + } + + fn manifest_with_property(required: bool) -> SchemaManifest { + let property = StableProperty { + property_type: "string".to_string(), + r#enum: BTreeSet::new(), + max_length: None, + minimum: None, + maximum: None, + }; + SchemaManifest { + schema_version: "omc.mcp-tools.v1".to_string(), + tools: vec![SchemaTool { + name: "example".to_string(), + input_schema: StableToolSchema { + schema_type: "object".to_string(), + properties: [("value".to_string(), property)].into_iter().collect(), + required: required.then(|| "value".to_string()).into_iter().collect(), + }, + }], + } + } +} diff --git a/crates/omc-mcp/src/server.rs b/crates/omc-mcp/src/server.rs new file mode 100644 index 0000000..6d3a4b0 --- /dev/null +++ b/crates/omc-mcp/src/server.rs @@ -0,0 +1,195 @@ +//! MCP JSON-RPC server over stdin/stdout. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::{self, BufRead, Write}; + +use crate::{McpTool, McpToolRegistry, ToolDefinition}; + +#[derive(Debug, Deserialize)] +struct JsonRpcRequest { + #[allow(dead_code)] + jsonrpc: String, + #[serde(default)] + id: Option, + method: String, + #[serde(default)] + params: Option, +} + +#[derive(Debug, Serialize)] +struct JsonRpcResponse { + jsonrpc: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize)] +struct JsonRpcError { + code: i64, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, +} + +impl JsonRpcResponse { + fn result(id: Option, result: Value) -> Self { + Self { + jsonrpc: "2.0", + id, + result: Some(result), + error: None, + } + } + + fn error(id: Option, code: i64, message: String) -> Self { + Self { + jsonrpc: "2.0", + id, + result: None, + error: Some(JsonRpcError { + code, + message, + data: None, + }), + } + } + + /// Notifications have no id and no result/error. + fn notification() -> Self { + Self { + jsonrpc: "2.0", + id: None, + result: None, + error: None, + } + } +} + +struct McpServer { + tools: Vec>, +} + +impl McpServer { + fn new() -> Self { + Self { + tools: McpToolRegistry::all_enabled().into_tools(), + } + } + + fn handle_request(&self, request: JsonRpcRequest) -> JsonRpcResponse { + match request.method.as_str() { + "initialize" => self.handle_initialize(request.id), + "notifications/initialized" => JsonRpcResponse::notification(), + "tools/list" => self.handle_tools_list(request.id), + "tools/call" => self.handle_tools_call(request.id, request.params), + _ => JsonRpcResponse::error( + request.id, + -32601, + format!("Method not found: {}", request.method), + ), + } + } + + fn handle_initialize(&self, id: Option) -> JsonRpcResponse { + let result = serde_json::json!({ + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "omc-mcp", + "version": env!("CARGO_PKG_VERSION") + } + }); + JsonRpcResponse::result(id, result) + } + + fn handle_tools_list(&self, id: Option) -> JsonRpcResponse { + let definitions: Vec = self.tools.iter().map(|t| t.definition()).collect(); + let tools: Vec = definitions + .iter() + .map(|d| serde_json::to_value(d).unwrap_or_default()) + .collect(); + + JsonRpcResponse::result(id, serde_json::json!({ "tools": tools })) + } + + fn handle_tools_call(&self, id: Option, params: Option) -> JsonRpcResponse { + let params = match params { + Some(p) => p, + None => return JsonRpcResponse::error(id, -32602, "Missing params".into()), + }; + + let tool_name = match params.get("name").and_then(|v| v.as_str()) { + Some(n) => n, + None => return JsonRpcResponse::error(id, -32602, "Missing tool name".into()), + }; + + let args = params + .get("arguments") + .cloned() + .unwrap_or_else(|| Value::Object(serde_json::Map::new())); + let tool = match self.tools.iter().find(|t| t.definition().name == tool_name) { + Some(t) => t, + None => { + return JsonRpcResponse::error(id, -32602, format!("Unknown tool: {tool_name}")); + } + }; + + match serde_json::to_value(tool.handle(args)) { + Ok(value) => JsonRpcResponse::result(id, value), + Err(e) => JsonRpcResponse::error(id, -32603, format!("Serialization error: {e}")), + } + } +} + +/// Run the shared MCP stdio server until stdin closes. +pub fn run_stdio() -> io::Result<()> { + let server = McpServer::new(); + let stdin = io::stdin().lock(); + let mut stdout = io::stdout().lock(); + + for line in stdin.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + eprintln!("omc-mcp: stdin read error: {e}"); + break; + } + }; + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let request: JsonRpcRequest = match serde_json::from_str(trimmed) { + Ok(r) => r, + Err(e) => { + let response = JsonRpcResponse::error(None, -32700, format!("Parse error: {e}")); + write_response(&mut stdout, &response)?; + continue; + } + }; + + let is_notification = request.id.is_none(); + let response = server.handle_request(request); + if !is_notification { + write_response(&mut stdout, &response)?; + } + } + + Ok(()) +} + +fn write_response(stdout: &mut impl Write, response: &JsonRpcResponse) -> io::Result<()> { + let json = serde_json::to_string(response).unwrap_or_default(); + stdout.write_all(json.as_bytes())?; + stdout.write_all(b"\n")?; + stdout.flush() +} diff --git a/crates/omc-mcp/src/team_tools.rs b/crates/omc-mcp/src/team_tools.rs new file mode 100644 index 0000000..ed0eaba --- /dev/null +++ b/crates/omc-mcp/src/team_tools.rs @@ -0,0 +1,357 @@ +//! Read-only projections of the existing omc-team observability state. + +use std::collections::HashMap; +use std::path::PathBuf; + +use omc_interop::mcp_bridge::{ + INTEROP_BRIDGE_SCHEMA_VERSION, InteropBridgeRequest, interop_bridge, +}; +use omc_interop::read_snapshot; +use omc_shared::agent_tool::{ + INTEROP_SNAPSHOT_SCHEMA_VERSION, ToolError, ToolResponse, normalize_request_id, +}; +use omc_team::team_observability; +use serde::Serialize; +use serde_json::Value; + +use crate::tools::{McpTool, SchemaProperty, ToolDefinition, ToolResult, ToolSchema}; + +const VIEWS: &[&str] = &["sessions", "top", "doctor"]; + +fn string_property(description: &str, values: Option<&[&str]>) -> SchemaProperty { + SchemaProperty { + prop_type: "string".into(), + description: Some(description.into()), + r#enum: values.map(|items| items.iter().map(|item| (*item).to_string()).collect()), + max_length: None, + minimum: None, + maximum: None, + } +} + +fn encode(response: &ToolResponse) -> ToolResult { + match serde_json::to_string(response) { + Ok(text) => ToolResult::ok(text), + Err(error) => ToolResult::error(format!("failed to encode team observability: {error}")), + } +} + +pub struct TeamObservabilityTool; + +impl McpTool for TeamObservabilityTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert( + "view".into(), + string_property("Read sessions, aggregate usage, or health.", Some(VIEWS)), + ); + properties.insert( + "workingDirectory".into(), + string_property("Project root. Defaults to the current directory.", None), + ); + properties.insert( + "requestId".into(), + string_property("Optional caller correlation ID.", None), + ); + + ToolDefinition { + name: "team_observability".into(), + description: "Read the existing omc-team sessions, top usage snapshot, or observability doctor report without starting or mutating a team.".into(), + input_schema: ToolSchema { + schema_type: "object".into(), + properties, + required: vec!["view".into()], + }, + } + } + + fn handle(&self, args: Value) -> ToolResult { + let request_id = normalize_request_id( + args.get("requestId").and_then(Value::as_str), + "mcp-team-observability", + ); + let view = match args.get("view").and_then(Value::as_str) { + Some(view) if VIEWS.contains(&view) => view, + _ => { + return encode(&ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", "view must be sessions, top, or doctor"), + )); + } + }; + let root = PathBuf::from( + args.get("workingDirectory") + .and_then(Value::as_str) + .unwrap_or("."), + ); + + match team_observability(&root, view) { + Ok(payload) => encode(&ToolResponse::success(request_id, payload)), + Err(error) => encode(&ToolResponse::::failure( + request_id, + ToolError::new("upstream_failed", error), + )), + } + } +} + +pub fn team_tools() -> Vec> { + vec![ + Box::new(TeamObservabilityTool), + Box::new(InteropSnapshotTool), + Box::new(InteropBridgeTool), + ] +} + +fn boolean_property(description: &str) -> SchemaProperty { + SchemaProperty { + prop_type: "boolean".into(), + description: Some(description.into()), + r#enum: None, + max_length: None, + minimum: None, + maximum: None, + } +} + +fn limit_property() -> SchemaProperty { + SchemaProperty { + prop_type: "integer".into(), + description: Some("Maximum records returned per collection (1..100).".into()), + r#enum: None, + max_length: None, + minimum: Some(1), + maximum: Some(100), + } +} + +pub struct InteropSnapshotTool; + +impl McpTool for InteropSnapshotTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert( + "workingDirectory".into(), + string_property( + "Project root containing .omc and .omx state. Defaults to the current directory.", + None, + ), + ); + properties.insert("limit".into(), limit_property()); + properties.insert( + "requestId".into(), + string_property("Optional caller correlation ID.", None), + ); + + ToolDefinition { + name: "interop_snapshot".into(), + description: format!( + "Read a bounded, read-only OMC/OMX interop state snapshot ({INTEROP_SNAPSHOT_SCHEMA_VERSION}); never starts or mutates a runtime." + ), + input_schema: ToolSchema { + schema_type: "object".into(), + properties, + required: Vec::new(), + }, + } + } + + fn handle(&self, args: Value) -> ToolResult { + let request_id = normalize_request_id( + args.get("requestId").and_then(Value::as_str), + "mcp-interop-snapshot", + ); + let limit = match args.get("limit") { + None => None, + Some(value) => match value.as_u64().and_then(|value| usize::try_from(value).ok()) { + Some(limit) => Some(limit), + None => { + return encode(&ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", "limit must be an integer"), + )); + } + }, + }; + let root = args + .get("workingDirectory") + .and_then(Value::as_str) + .unwrap_or("."); + + match read_snapshot(root, limit) { + Ok(payload) => encode(&ToolResponse::success(request_id, payload)), + Err(error) => encode(&ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", error.to_string()), + )), + } + } +} + +pub struct InteropBridgeTool; + +impl McpTool for InteropBridgeTool { + fn definition(&self) -> ToolDefinition { + let mut properties = HashMap::new(); + properties.insert( + "action".into(), + string_property( + "Durable bridge operation.", + Some(&["send_task", "send_message"]), + ), + ); + properties.insert( + "source".into(), + string_property("Source runtime.", Some(&["omc", "omx"])), + ); + properties.insert( + "target".into(), + string_property("Target runtime.", Some(&["omc", "omx"])), + ); + properties.insert( + "type".into(), + string_property( + "Task type required for send_task.", + Some(&["analyze", "implement", "review", "test", "custom"]), + ), + ); + properties.insert( + "description".into(), + string_property("Task description required for send_task.", None), + ); + properties.insert( + "content".into(), + string_property("Message content required for send_message.", None), + ); + properties.insert( + "workingDirectory".into(), + string_property("Project root containing .omc interop state.", None), + ); + properties.insert( + "allowSideEffects".into(), + boolean_property("Must be true for the durable write to proceed."), + ); + properties.insert( + "requestId".into(), + string_property("Optional caller correlation ID.", None), + ); + + ToolDefinition { + name: "interop_bridge".into(), + description: format!( + "Send one explicit OMC/OMX task or message through {INTEROP_BRIDGE_SCHEMA_VERSION}; requires active interop flags and allowSideEffects=true, never starts workers." + ), + input_schema: ToolSchema { + schema_type: "object".into(), + properties, + required: vec![ + "action".into(), + "source".into(), + "target".into(), + "allowSideEffects".into(), + ], + }, + } + } + + fn handle(&self, args: Value) -> ToolResult { + let request_id = normalize_request_id( + args.get("requestId").and_then(Value::as_str), + "mcp-interop-bridge", + ); + match serde_json::from_value::(args) { + Ok(request) => match interop_bridge(&request) { + Ok(payload) => encode(&ToolResponse::success(request_id, payload)), + Err(error) => encode(&ToolResponse::::failure( + request_id, + ToolError::new(error.code(), error.to_string()), + )), + }, + Err(error) => encode(&ToolResponse::::failure( + request_id, + ToolError::new("invalid_request", error.to_string()), + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sessions_view_is_read_only_and_versioned() { + let root = std::env::temp_dir().join(format!( + "omc-team-observability-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&root).unwrap(); + let result = TeamObservabilityTool.handle(serde_json::json!({ + "view": "sessions", + "workingDirectory": &root, + "requestId": "team-test" + })); + let value: Value = serde_json::from_str(&result.content[0].text).unwrap(); + assert_eq!(value["schema_version"], "omc.tool.v1"); + assert_eq!(value["request_id"], "team-test"); + assert_eq!(value["data"]["schemaVersion"], "omc.team-observability.v1"); + assert_eq!(value["data"]["view"], "sessions"); + assert_eq!(value["data"]["data"], serde_json::json!([])); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn unknown_view_is_machine_readable() { + let result = TeamObservabilityTool.handle(serde_json::json!({"view": "start"})); + let value: Value = serde_json::from_str(&result.content[0].text).unwrap(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "invalid_request"); + } + + #[test] + fn interop_snapshot_is_versioned_and_read_only() { + let root = + std::env::temp_dir().join(format!("omc-interop-snapshot-test-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let result = InteropSnapshotTool.handle(serde_json::json!({ + "workingDirectory": &root, + "limit": 1, + "requestId": "interop-test" + })); + let value: Value = serde_json::from_str(&result.content[0].text).unwrap(); + assert_eq!(value["schema_version"], "omc.tool.v1"); + assert_eq!(value["request_id"], "interop-test"); + assert_eq!( + value["data"]["schemaVersion"], + INTEROP_SNAPSHOT_SCHEMA_VERSION + ); + assert_eq!(value["data"]["readOnly"], true); + assert_eq!(value["data"]["sharedTaskCount"], 0); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn interop_snapshot_rejects_invalid_limit() { + let result = InteropSnapshotTool.handle(serde_json::json!({"limit": 0})); + let value: Value = serde_json::from_str(&result.content[0].text).unwrap(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "invalid_request"); + } + + #[test] + fn interop_bridge_requires_explicit_side_effect_gate() { + let result = InteropBridgeTool.handle(serde_json::json!({ + "action": "send_message", + "source": "omc", + "target": "omx", + "content": "hello", + "allowSideEffects": false, + "requestId": "bridge-test" + })); + let value: Value = serde_json::from_str(&result.content[0].text).unwrap(); + assert_eq!(value["schema_version"], "omc.tool.v1"); + assert_eq!(value["request_id"], "bridge-test"); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "side_effects_not_allowed"); + } +} diff --git a/crates/omc-mcp/src/tool_registry.rs b/crates/omc-mcp/src/tool_registry.rs index 2cbc6d7..166a5d9 100644 --- a/crates/omc-mcp/src/tool_registry.rs +++ b/crates/omc-mcp/src/tool_registry.rs @@ -58,11 +58,28 @@ pub struct McpToolRegistry { /// Default tool group definitions. const DEFAULT_GROUPS: &[(&str, &str, bool, &[&str])] = &[ - ("core", "omc-mcp", true, &["state_", "notepad_"]), - ("agents", "omc-mcp", false, &["agent_"]), + ( + "core", + "omc-mcp", + true, + &[ + "state_", + "notepad_", + "goal_", + "workflow_", + "team_", + "interop_", + ], + ), + ("agents", "omc-mcp", true, &["agent_", "subagent_", "hash_"]), ("memory", "omc-mcp", true, &["project_memory_"]), - ("devtools", "omc-mcp", false, &["dev_"]), - ("intelligence", "omc-mcp", false, &["intel_"]), + ("devtools", "omc-mcp", false, &["dev_", "python_", "debug_"]), + ( + "intelligence", + "omc-mcp", + false, + &["intel_", "code_intel_", "lsp_"], + ), ("security", "omc-mcp", false, &["security_"]), ]; @@ -98,6 +115,20 @@ impl McpToolRegistry { } } + /// Build the production registry with every released group enabled. + pub fn all_enabled() -> Self { + let mut registry = Self::new(); + for group in registry.groups.values_mut() { + group.enabled = true; + } + registry + } + + /// Resolve and consume the registry into the server's immutable tool set. + pub fn into_tools(self) -> Vec> { + self.collect_enabled_tools() + } + /// Get the resolved tool list, refreshing the cache if expired. pub fn tools(&mut self) -> &[Box] { if self.cache.is_expired() || self.cache.get().is_empty() { @@ -183,12 +214,26 @@ mod tests { assert!(registry.groups().contains_key("security")); } + #[test] + fn production_registry_exactly_matches_shared_catalog() { + let registered = McpToolRegistry::all_enabled() + .into_tools() + .into_iter() + .map(|tool| tool.definition().name) + .collect::>(); + let catalog = omc_shared::capability_catalog::mcp_tool_names() + .into_iter() + .map(str::to_string) + .collect::>(); + assert_eq!(registered, catalog); + } + #[test] fn default_enabled_groups() { let registry = McpToolRegistry::default(); assert!(registry.is_group_enabled("core")); assert!(registry.is_group_enabled("memory")); - assert!(!registry.is_group_enabled("agents")); + assert!(registry.is_group_enabled("agents")); assert!(!registry.is_group_enabled("devtools")); assert!(!registry.is_group_enabled("intelligence")); assert!(!registry.is_group_enabled("security")); @@ -229,8 +274,12 @@ mod tests { assert!(names.iter().any(|n| n == "project_memory_read")); assert!(names.iter().any(|n| n == "project_memory_write")); - // disabled groups should not appear - assert!(!names.iter().any(|n| n.starts_with("agent_"))); + // agent routing tools are enabled for consumers by default + assert!(names.iter().any(|n| n == "agent_capabilities")); + assert!(names.iter().any(|n| n == "agent_route")); + assert!(names.iter().any(|n| n == "subagent_result_validate")); + assert!(names.iter().any(|n| n == "hash_edit")); + assert!(names.iter().any(|n| n == "workflow_advance")); assert!(!names.iter().any(|n| n.starts_with("dev_"))); assert!(!names.iter().any(|n| n.starts_with("intel_"))); assert!(!names.iter().any(|n| n.starts_with("security_"))); @@ -267,17 +316,51 @@ mod tests { fn enabling_disabled_group_adds_tools() { let mut registry = McpToolRegistry::default(); - // agents disabled by default + // Agents are enabled by default for the distributable tool surface. let names_before: Vec = registry .tools() .iter() .map(|t| t.definition().name.clone()) .collect(); - assert!(!names_before.iter().any(|n| n.starts_with("agent_"))); + assert!(names_before.iter().any(|n| n == "agent_route")); - // Enable agents (no tools registered yet, so still empty -- but group is enabled) + registry.set_group_enabled("agents", false); + assert!( + !registry + .tools() + .iter() + .any(|tool| tool.definition().name == "agent_route") + ); registry.set_group_enabled("agents", true); assert!(registry.is_group_enabled("agents")); + assert!( + registry + .tools() + .iter() + .any(|tool| tool.definition().name == "agent_route") + ); + + registry.set_group_enabled("intelligence", true); + assert!( + registry + .tools() + .iter() + .any(|tool| tool.definition().name == "code_intel_artifact_query") + ); + + registry.set_group_enabled("devtools", true); + assert!( + registry + .tools() + .iter() + .any(|tool| tool.definition().name == "python_repl") + ); + assert!( + registry + .tools() + .iter() + .any(|tool| tool.definition().name == "debug_inspect") + ); } #[test] @@ -324,7 +407,7 @@ mod tests { #[test] fn empty_when_all_groups_disabled() { let mut registry = McpToolRegistry::default(); - for name in ["core", "memory"] { + for name in ["core", "agents", "memory"] { registry.set_group_enabled(name, false); } diff --git a/crates/omc-python/Cargo.toml b/crates/omc-python/Cargo.toml index 0b53b1e..6fa5ddd 100644 --- a/crates/omc-python/Cargo.toml +++ b/crates/omc-python/Cargo.toml @@ -14,3 +14,6 @@ tokio = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } async-trait = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/omc-python/src/executor.rs b/crates/omc-python/src/executor.rs index 7d026f9..74903bf 100644 --- a/crates/omc-python/src/executor.rs +++ b/crates/omc-python/src/executor.rs @@ -99,6 +99,9 @@ pub async fn handle_repl_input( let result = executor.get_state(session_id).await?; Ok(ReplResponse::State(result)) } + ReplAction::ListSessions | ReplAction::Close => Err(ReplError::InvalidSessionId( + "session lifecycle actions require PythonReplService".into(), + )), } } diff --git a/crates/omc-python/src/lib.rs b/crates/omc-python/src/lib.rs index 397ceb3..812a347 100644 --- a/crates/omc-python/src/lib.rs +++ b/crates/omc-python/src/lib.rs @@ -1,8 +1,13 @@ pub mod executor; +mod python_kernel; pub mod repl; +pub mod session; pub use executor::{PythonReplExecutor, ReplError, ReplResponse, handle_repl_input}; pub use repl::{ ExecuteResult, ExecutionError, InterruptResult, MarkerInfo, MemoryInfo, PythonReplInput, ReplAction, ResetResult, StateResult, TimingInfo, }; +pub use session::{ + PythonReplService, PythonSessionError, PythonSessionInfo, PythonToolPayload, PythonToolRequest, +}; diff --git a/crates/omc-python/src/python_kernel.rs b/crates/omc-python/src/python_kernel.rs new file mode 100644 index 0000000..ad5dd51 --- /dev/null +++ b/crates/omc-python/src/python_kernel.rs @@ -0,0 +1,270 @@ +//! Private Python subprocess and NDJSON runner. + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::thread; +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::repl::{ExecuteResult, StateResult}; +use crate::session::{MAX_TIMEOUT_MS, PythonSessionError}; + +pub(crate) struct PythonKernel { + child: Child, + stdin: ChildStdin, + responses: Receiver>, + next_id: u64, + project_dir: PathBuf, +} + +impl PythonKernel { + pub(crate) fn start(project_dir: &Path) -> Result { + let command = std::env::var_os("OMC_PYTHON_COMMAND") + .map(PathBuf::from) + .or_else(|| find_python_command("python")) + .or_else(|| find_python_command("python3")) + .ok_or_else(|| "python or python3 was not found on PATH".to_string())?; + let mut child = Command::new(command) + .arg("-u") + .arg("-c") + .arg(PYTHON_RUNNER) + .env("PYTHONIOENCODING", "utf-8") + .current_dir(project_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| error.to_string())?; + let stdin = child + .stdin + .take() + .ok_or_else(|| "python stdin was unavailable".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "python stdout was unavailable".to_string())?; + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let item = match line { + Ok(line) => serde_json::from_str::(&line).map_err(|e| e.to_string()), + Err(error) => Err(error.to_string()), + }; + if sender.send(item).is_err() { + break; + } + } + }); + Ok(Self { + child, + stdin, + responses: receiver, + next_id: 1, + project_dir: project_dir.to_path_buf(), + }) + } + + pub(crate) fn execute( + &mut self, + code: &str, + timeout_ms: u64, + ) -> Result { + let response = self.call("execute", Some(code), timeout_ms)?; + serde_json::from_value(response).map_err(|error| { + PythonSessionError::Failed(format!("invalid execute response: {error}")) + }) + } + + pub(crate) fn state(&mut self) -> Result { + let response = self.call("state", None, 30_000)?; + serde_json::from_value(response) + .map_err(|error| PythonSessionError::Failed(format!("invalid state response: {error}"))) + } + + fn call( + &mut self, + action: &str, + code: Option<&str>, + timeout_ms: u64, + ) -> Result { + if timeout_ms == 0 || timeout_ms > MAX_TIMEOUT_MS { + return Err(PythonSessionError::InvalidRequest(format!( + "executionTimeout must be between 1 and {MAX_TIMEOUT_MS} milliseconds" + ))); + } + let id = self.next_id; + self.next_id = self.next_id.saturating_add(1); + let line = serde_json::to_string(&json!({"id": id, "action": action, "code": code})) + .map_err(|error| PythonSessionError::Failed(error.to_string()))?; + self.stdin + .write_all(line.as_bytes()) + .and_then(|_| self.stdin.write_all(b"\n")) + .and_then(|_| self.stdin.flush()) + .map_err(|error| PythonSessionError::Failed(error.to_string()))?; + + let response = match self + .responses + .recv_timeout(Duration::from_millis(timeout_ms)) + { + Ok(response) => response.map_err(PythonSessionError::Failed)?, + Err(mpsc::RecvTimeoutError::Timeout) => { + self.restart().map_err(|error| { + PythonSessionError::Failed(format!( + "execution timed out and kernel restart failed: {error}" + )) + })?; + return Err(PythonSessionError::Timeout(timeout_ms)); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(PythonSessionError::Failed( + "python process closed its output".into(), + )); + } + }; + if response.get("id") != Some(&json!(id)) { + return Err(PythonSessionError::Failed( + "python response id did not match request".into(), + )); + } + if response.get("ok") != Some(&Value::Bool(true)) { + return Err(PythonSessionError::Failed( + response + .get("error") + .and_then(Value::as_str) + .unwrap_or("python execution failed") + .to_string(), + )); + } + response + .get("result") + .cloned() + .ok_or_else(|| PythonSessionError::Failed("python response omitted result".into())) + } + + pub(crate) fn restart(&mut self) -> Result<(), PythonSessionError> { + let _ = self.child.kill(); + let _ = self.child.wait(); + let replacement = + Self::start(&self.project_dir).map_err(PythonSessionError::Unavailable)?; + *self = replacement; + Ok(()) + } +} + +impl Drop for PythonKernel { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn find_python_command(name: &str) -> Option { + let candidate = if cfg!(windows) { + format!("{name}.exe") + } else { + name.to_string() + }; + std::env::split_paths(&std::env::var_os("PATH")?) + .map(|dir| dir.join(&candidate)) + .find(|path| path.is_file()) + .or_else(|| Some(PathBuf::from(name))) +} + +const PYTHON_RUNNER: &str = r#" +import contextlib +import datetime +import io +import json +import sys +import time +import traceback + +MAX_OUTPUT = 4 * 1024 * 1024 +namespace = {"__name__": "__main__"} + +class LimitedWriter(io.TextIOBase): + def __init__(self): + self.parts = [] + self.size = 0 + self.truncated = False + + def write(self, value): + if not isinstance(value, str): + value = str(value) + remaining = MAX_OUTPUT - self.size + if remaining <= 0: + self.truncated = True + return len(value) + encoded = value.encode("utf-8") + piece = encoded[:remaining].decode("utf-8", errors="ignore") + piece_size = len(piece.encode("utf-8")) + self.parts.append(piece) + self.size += piece_size + if piece_size != len(encoded): + self.truncated = True + return len(value) + + def flush(self): + return None + + def text(self): + return "".join(self.parts) + +def memory(): + return {"rss_mb": 0.0, "vms_mb": 0.0} + +def variables(): + return sorted(name for name in namespace if not name.startswith("__")) + +def response(req): + action = req.get("action") + if action == "state": + return {"ok": True, "result": {"memory": memory(), "variables": variables(), "variable_count": len(variables())}} + if action != "execute": + return {"ok": False, "error": "unsupported action"} + code = req.get("code") + if not isinstance(code, str) or not code: + return {"ok": False, "error": "code is required"} + stdout = LimitedWriter() + stderr = LimitedWriter() + started = datetime.datetime.now(datetime.timezone.utc).isoformat() + begin = time.monotonic() + error = None + success = True + try: + original_stdin = sys.stdin + sys.stdin = io.StringIO() + try: + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + exec(compile(code, "", "exec"), namespace, namespace) + finally: + sys.stdin = original_stdin + except BaseException as exc: + success = False + error = {"type": type(exc).__name__, "message": str(exc), "traceback": traceback.format_exc()} + markers = [] + if stdout.truncated: + markers.append({"type": "output", "subtype": "stdout_truncated", "content": "stdout exceeded 4 MiB", "line_number": 0, "category": "limit"}) + if stderr.truncated: + markers.append({"type": "output", "subtype": "stderr_truncated", "content": "stderr exceeded 4 MiB", "line_number": 0, "category": "limit"}) + return {"ok": True, "result": { + "success": success, + "stdout": stdout.text(), + "stderr": stderr.text(), + "markers": markers, + "timing": {"started_at": started, "duration_ms": int((time.monotonic() - begin) * 1000)}, + "memory": memory(), + "error": error, + }} + +for line in sys.stdin: + try: + req = json.loads(line) + result = response(req) + print(json.dumps({"id": req.get("id"), **result}, ensure_ascii=False, separators=(",", ":")), flush=True) + except BaseException as exc: + print(json.dumps({"id": None, "ok": False, "error": f"runner error: {exc}"}, separators=(",", ":")), flush=True) +"#; diff --git a/crates/omc-python/src/repl.rs b/crates/omc-python/src/repl.rs index 1b02b8d..416f157 100644 --- a/crates/omc-python/src/repl.rs +++ b/crates/omc-python/src/repl.rs @@ -8,6 +8,8 @@ pub enum ReplAction { Interrupt, Reset, GetState, + ListSessions, + Close, } /// Input for the Python REPL tool. @@ -99,6 +101,18 @@ mod tests { assert_eq!(action, ReplAction::Execute); } + #[test] + fn lifecycle_actions_are_stable_snake_case() { + assert_eq!( + serde_json::to_string(&ReplAction::ListSessions).unwrap(), + "\"list_sessions\"" + ); + assert_eq!( + serde_json::to_string(&ReplAction::Close).unwrap(), + "\"close\"" + ); + } + #[test] fn deserialize_input() { let json = r#"{ diff --git a/crates/omc-python/src/session.rs b/crates/omc-python/src/session.rs new file mode 100644 index 0000000..3dd8e6c --- /dev/null +++ b/crates/omc-python/src/session.rs @@ -0,0 +1,450 @@ +//! Process-local Python session contract and lifecycle store. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use crate::python_kernel::PythonKernel; +use crate::repl::{ + ExecuteResult, InterruptResult, MemoryInfo, PythonReplInput, ReplAction, ResetResult, + StateResult, +}; + +pub(crate) const MAX_TIMEOUT_MS: u64 = 300_000; +const DEFAULT_TIMEOUT_MS: u64 = 30_000; +const MAX_CODE_BYTES: usize = 256 * 1024; +const MAX_SESSIONS: usize = 16; +const DEFAULT_IDLE_TTL: Duration = Duration::from_secs(30 * 60); + +/// Input contract exposed by the OMC Python tool. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PythonToolRequest { + pub action: ReplAction, + pub session_id: String, + #[serde(default)] + pub code: Option, + #[serde(default)] + pub execution_timeout: Option, + #[serde(default)] + pub project_dir: Option, + #[serde(default)] + pub allow_side_effects: bool, +} + +impl PythonToolRequest { + pub fn into_input(self) -> Result<(PythonReplInput, bool), PythonSessionError> { + validate_session_id(&self.session_id)?; + let timeout = self.execution_timeout.unwrap_or(DEFAULT_TIMEOUT_MS); + if timeout == 0 || timeout > MAX_TIMEOUT_MS { + return Err(PythonSessionError::InvalidRequest(format!( + "executionTimeout must be between 1 and {MAX_TIMEOUT_MS} milliseconds" + ))); + } + if self + .code + .as_ref() + .is_some_and(|code| code.len() > MAX_CODE_BYTES) + { + return Err(PythonSessionError::InvalidRequest(format!( + "code exceeds {MAX_CODE_BYTES} bytes" + ))); + } + Ok(( + PythonReplInput { + action: self.action, + research_session_id: self.session_id, + code: self.code, + execution_label: None, + execution_timeout: Some(timeout), + queue_timeout: None, + project_dir: self.project_dir, + }, + self.allow_side_effects, + )) + } +} + +/// One result returned by the session adapter. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PythonToolPayload { + pub operation: &'static str, + pub action: ReplAction, + pub session_id: String, + pub project_dir: String, + pub session_scope: &'static str, + pub result: Value, + pub side_effects: Vec, +} + +#[derive(Debug, Error)] +pub enum PythonSessionError { + #[error("invalid request: {0}")] + InvalidRequest(String), + #[error("python adapter is unavailable: {0}")] + Unavailable(String), + #[error("python session failed: {0}")] + Failed(String), + #[error("python execution timed out after {0}ms")] + Timeout(u64), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct SessionKey { + id: String, + project_dir: PathBuf, +} + +struct PythonSession { + kernel: PythonKernel, + last_used: Instant, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PythonSessionInfo { + pub session_id: String, + pub project_dir: String, + pub idle_ms: u64, +} + +/// Process-local session store. MCP owns one for its process lifetime; the +/// CLI owns one for its current invocation. +pub struct PythonReplService { + // ponytail: one process-global lock serializes cells; split to per-session + // locks if concurrent Python sessions become a measured bottleneck. + sessions: Mutex>, + idle_ttl: Duration, +} + +impl Default for PythonReplService { + fn default() -> Self { + Self::new() + } +} + +impl PythonReplService { + pub fn new() -> Self { + Self { + sessions: Mutex::new(HashMap::new()), + idle_ttl: DEFAULT_IDLE_TTL, + } + } + + pub fn with_idle_ttl(idle_ttl: Duration) -> Self { + Self { + sessions: Mutex::new(HashMap::new()), + idle_ttl, + } + } + + pub fn execute(&self, input: &PythonReplInput) -> Result { + let code = input + .code + .as_deref() + .ok_or_else(|| PythonSessionError::InvalidRequest("code is required".into()))?; + if code.len() > MAX_CODE_BYTES { + return Err(PythonSessionError::InvalidRequest(format!( + "code exceeds {MAX_CODE_BYTES} bytes" + ))); + } + let project_dir = resolve_project_dir(input.project_dir.as_deref())?; + let mut sessions = self.lock_sessions()?; + reap_idle(&mut sessions, self.idle_ttl); + let key = SessionKey { + id: input.research_session_id.clone(), + project_dir: project_dir.clone(), + }; + if !sessions.contains_key(&key) && sessions.len() >= MAX_SESSIONS { + return Err(PythonSessionError::Failed(format!( + "session limit reached ({MAX_SESSIONS}); reset or reuse an existing session" + ))); + } + if !sessions.contains_key(&key) { + let kernel = + PythonKernel::start(&project_dir).map_err(PythonSessionError::Unavailable)?; + sessions.insert( + key.clone(), + PythonSession { + kernel, + last_used: Instant::now(), + }, + ); + } + let session = sessions + .get_mut(&key) + .ok_or_else(|| PythonSessionError::Failed("session was not stored".into()))?; + session.last_used = Instant::now(); + session + .kernel + .execute(code, input.execution_timeout.unwrap_or(DEFAULT_TIMEOUT_MS)) + } + + pub fn interrupt( + &self, + session_id: &str, + project_dir: Option<&str>, + ) -> Result { + self.with_session(session_id, project_dir, |session| { + session.kernel.restart()?; + Ok(InterruptResult { + status: "interrupted".into(), + terminated_by: Some("process_restart".into()), + termination_time_ms: Some(0), + }) + }) + } + + pub fn reset( + &self, + session_id: &str, + project_dir: Option<&str>, + ) -> Result { + self.with_session(session_id, project_dir, |session| { + session.kernel.restart()?; + Ok(ResetResult { + status: "ok".into(), + memory: MemoryInfo { + rss_mb: 0.0, + vms_mb: 0.0, + }, + }) + }) + } + + pub fn state( + &self, + session_id: &str, + project_dir: Option<&str>, + ) -> Result { + self.with_session(session_id, project_dir, |session| session.kernel.state()) + } + + pub fn session_count(&self) -> Result { + Ok(self.list_sessions()?.len()) + } + + pub fn list_sessions(&self) -> Result, PythonSessionError> { + let mut sessions = self.lock_sessions()?; + reap_idle(&mut sessions, self.idle_ttl); + let mut result = sessions + .iter() + .map(|(key, session)| PythonSessionInfo { + session_id: key.id.clone(), + project_dir: key.project_dir.display().to_string(), + idle_ms: session + .last_used + .elapsed() + .as_millis() + .min(u128::from(u64::MAX)) as u64, + }) + .collect::>(); + result.sort_by(|left, right| { + left.session_id + .cmp(&right.session_id) + .then(left.project_dir.cmp(&right.project_dir)) + }); + Ok(result) + } + + pub fn close( + &self, + session_id: &str, + project_dir: Option<&str>, + ) -> Result { + validate_session_id(session_id)?; + let key = SessionKey { + id: session_id.to_string(), + project_dir: resolve_project_dir(project_dir)?, + }; + Ok(self.lock_sessions()?.remove(&key).is_some()) + } + + fn with_session( + &self, + session_id: &str, + project_dir: Option<&str>, + operation: impl FnOnce(&mut PythonSession) -> Result, + ) -> Result { + validate_session_id(session_id)?; + let project_dir = resolve_project_dir(project_dir)?; + let mut sessions = self.lock_sessions()?; + let key = SessionKey { + id: session_id.to_string(), + project_dir, + }; + let session = sessions + .get_mut(&key) + .ok_or_else(|| PythonSessionError::InvalidRequest("session does not exist".into()))?; + session.last_used = Instant::now(); + operation(session) + } + + fn lock_sessions( + &self, + ) -> Result>, PythonSessionError> + { + self.sessions + .lock() + .map_err(|_| PythonSessionError::Failed("session store was poisoned".into())) + } +} + +fn reap_idle(sessions: &mut HashMap, idle_ttl: Duration) { + sessions.retain(|_, session| session.last_used.elapsed() < idle_ttl); +} + +fn validate_session_id(session_id: &str) -> Result<(), PythonSessionError> { + if session_id.trim().is_empty() || session_id.len() > 128 || session_id.contains('\0') { + return Err(PythonSessionError::InvalidRequest( + "sessionId must be 1..128 characters and must not contain NUL".into(), + )); + } + Ok(()) +} + +fn resolve_project_dir(input: Option<&str>) -> Result { + let path = input.map_or_else(|| PathBuf::from("."), PathBuf::from); + let path = path.canonicalize().map_err(|error| { + PythonSessionError::InvalidRequest(format!("projectDir is invalid: {error}")) + })?; + if !path.is_dir() { + return Err(PythonSessionError::InvalidRequest( + "projectDir must be a directory".into(), + )); + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::tempdir; + + #[test] + fn request_requires_explicit_side_effect_opt_in_only_at_boundary() { + let request: PythonToolRequest = serde_json::from_value(json!({ + "action": "execute", + "sessionId": "test", + "code": "x = 1" + })) + .unwrap(); + let (_, allowed) = request.into_input().unwrap(); + assert!(!allowed); + } + + #[test] + fn local_kernel_keeps_state_between_calls() { + let service = PythonReplService::new(); + let root = tempdir().unwrap(); + let first = PythonReplInput { + action: ReplAction::Execute, + research_session_id: "test".into(), + code: Some("value = 41".into()), + execution_label: None, + execution_timeout: Some(10_000), + queue_timeout: None, + project_dir: Some(root.path().to_string_lossy().into_owned()), + }; + let second = PythonReplInput { + code: Some("print(value + 1)".into()), + ..first.clone() + }; + let first_result = service.execute(&first); + if let Err(PythonSessionError::Unavailable(_)) = first_result { + return; + } + assert!(first_result.unwrap().success); + assert_eq!(service.execute(&second).unwrap().stdout.trim(), "42"); + assert_eq!(service.session_count().unwrap(), 1); + } + + #[test] + fn timeout_restarts_kernel_before_next_cell() { + let service = PythonReplService::new(); + let root = tempdir().unwrap(); + let slow = PythonReplInput { + action: ReplAction::Execute, + research_session_id: "timeout-test".into(), + code: Some("import time; time.sleep(0.2)".into()), + execution_label: None, + execution_timeout: Some(20), + queue_timeout: None, + project_dir: Some(root.path().to_string_lossy().into_owned()), + }; + let error = service.execute(&slow).unwrap_err(); + if matches!(error, PythonSessionError::Unavailable(_)) { + return; + } + assert!(matches!(error, PythonSessionError::Timeout(20))); + let fast = PythonReplInput { + code: Some("print(7)".into()), + execution_timeout: Some(10_000), + ..slow + }; + assert_eq!(service.execute(&fast).unwrap().stdout.trim(), "7"); + } + + #[test] + fn unicode_output_is_capped_by_utf8_bytes_and_reports_truncation() { + let service = PythonReplService::new(); + let root = tempdir().unwrap(); + let input = PythonReplInput { + action: ReplAction::Execute, + research_session_id: "utf8-limit".into(), + code: Some("print('🙂' * 1100000, end='')".into()), + execution_label: None, + execution_timeout: Some(30_000), + queue_timeout: None, + project_dir: Some(root.path().to_string_lossy().into_owned()), + }; + let result = match service.execute(&input) { + Ok(result) => result, + Err(PythonSessionError::Unavailable(_)) => return, + Err(error) => panic!("unicode output execution failed: {error}"), + }; + assert!(result.stdout.len() <= 4 * 1024 * 1024); + assert!(result.stdout.is_char_boundary(result.stdout.len())); + assert!( + result + .markers + .iter() + .any(|marker| marker.subtype.as_deref() == Some("stdout_truncated")) + ); + } + + #[test] + fn sessions_can_be_discovered_and_closed() { + let service = PythonReplService::new(); + let root = tempdir().unwrap(); + let input = PythonReplInput { + action: ReplAction::Execute, + research_session_id: "lifecycle".into(), + code: Some("value = 1".into()), + execution_label: None, + execution_timeout: Some(10_000), + queue_timeout: None, + project_dir: Some(root.path().to_string_lossy().into_owned()), + }; + if matches!( + service.execute(&input), + Err(PythonSessionError::Unavailable(_)) + ) { + return; + } + assert_eq!(service.list_sessions().unwrap()[0].session_id, "lifecycle"); + assert!( + service + .close("lifecycle", input.project_dir.as_deref()) + .unwrap() + ); + assert_eq!(service.session_count().unwrap(), 0); + } +} diff --git a/crates/omc-shared/Cargo.toml b/crates/omc-shared/Cargo.toml index 788975c..2d024e6 100644 --- a/crates/omc-shared/Cargo.toml +++ b/crates/omc-shared/Cargo.toml @@ -22,3 +22,4 @@ regex = "1" once_cell = "1" anyhow = { workspace = true } async-trait = { workspace = true } +sha2 = { workspace = true } diff --git a/crates/omc-shared/src/agent_tool.rs b/crates/omc-shared/src/agent_tool.rs new file mode 100644 index 0000000..328f4c0 --- /dev/null +++ b/crates/omc-shared/src/agent_tool.rs @@ -0,0 +1,310 @@ +//! Stable, host-neutral contract for the OMC-RS agent tool surface. +//! +//! This module deliberately exposes routing semantics rather than provider +//! model IDs. Hermes, Sentinel, and other hosts can consume the same result +//! without depending on OMC's internal provider configuration. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +use crate::routing::{ComplexityTier, ModelType, RoutingConfig, RoutingContext, route_task}; + +pub const TOOL_SCHEMA_VERSION: &str = "omc.tool.v1"; +pub const PYTHON_SCHEMA_VERSION: &str = "omc.python.v1"; +pub const DEBUG_SCHEMA_VERSION: &str = crate::dap_adapter::DEBUG_SCHEMA_VERSION; +pub const TEAM_OBSERVABILITY_SCHEMA_VERSION: &str = + crate::team_contract::TEAM_OBSERVABILITY_SCHEMA_VERSION; +pub const INTEROP_SNAPSHOT_SCHEMA_VERSION: &str = "omc.interop.snapshot.v1"; +pub const INTEROP_BRIDGE_SCHEMA_VERSION: &str = "omc.interop.bridge.v1"; + +/// Host-neutral decision contract for clarify/plan/execute/verify workflows. +/// Hosts and the existing team runtime provide evidence; this module never +/// starts an agent or persists lifecycle state. +#[path = "workflow_contract.rs"] +pub mod workflow_contract; + +pub mod error_codes { + pub const INVALID_REQUEST: &str = "invalid_request"; + pub const ADAPTER_UNAVAILABLE: &str = "adapter_unavailable"; + pub const UPSTREAM_FAILED: &str = "upstream_failed"; + pub const UPSTREAM_CONTRACT_INVALID: &str = "upstream_contract_invalid"; + pub const UPSTREAM_RESPONSE_TOO_LARGE: &str = "upstream_response_too_large"; + pub const UPSTREAM_QUERY_TRUNCATED: &str = "upstream_query_truncated"; + pub const ARTIFACT_NOT_FOUND: &str = "artifact_not_found"; + pub const STALE_EDIT: &str = "stale_edit"; + pub const EDIT_FAILED: &str = "edit_failed"; + pub const SIDE_EFFECTS_NOT_ALLOWED: &str = "side_effects_not_allowed"; + pub const DEBUG_TIMEOUT: &str = "debug_timeout"; +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ToolError { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl ToolError { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + details: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ToolResponse { + pub schema_version: String, + pub request_id: String, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub meta: BTreeMap, +} + +impl ToolResponse { + pub fn success(request_id: impl Into, data: T) -> Self { + Self { + schema_version: TOOL_SCHEMA_VERSION.to_string(), + request_id: request_id.into(), + ok: true, + data: Some(data), + error: None, + meta: BTreeMap::new(), + } + } + + pub fn failure(request_id: impl Into, error: ToolError) -> Self { + Self { + schema_version: TOOL_SCHEMA_VERSION.to_string(), + request_id: request_id.into(), + ok: false, + data: None, + error: Some(error), + meta: BTreeMap::new(), + } + } +} + +pub fn normalize_request_id(request_id: Option<&str>, fallback: &str) -> String { + request_id + .filter(|value| !value.trim().is_empty()) + .map_or_else(|| fallback.to_string(), ToString::to_string) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Capability { + pub name: String, + pub description: String, + pub kind: String, + pub side_effects: Vec, + pub mcp_tools: Vec, + pub availability: crate::capability_catalog::CapabilityAvailability, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CapabilitiesPayload { + pub product: String, + pub protocol: String, + pub contracts: Vec, + pub capabilities: Vec, +} + +pub fn capabilities_payload() -> CapabilitiesPayload { + CapabilitiesPayload { + product: "omc-rs".into(), + protocol: TOOL_SCHEMA_VERSION.into(), + contracts: vec![ + TOOL_SCHEMA_VERSION.into(), + crate::operation_contract::TASK_SCHEMA_VERSION.into(), + crate::operation_contract::EVENT_SCHEMA_VERSION.into(), + crate::operation_contract::ARTIFACT_REF_SCHEMA_VERSION.into(), + crate::goal_contract::GOAL_SCHEMA_VERSION.into(), + crate::operation_contract::SUBAGENT_RESULT_SCHEMA_VERSION.into(), + crate::hash_edit::HASH_EDIT_SCHEMA_VERSION.into(), + crate::workflow_contract::WORKFLOW_SCHEMA_VERSION.into(), + PYTHON_SCHEMA_VERSION.into(), + DEBUG_SCHEMA_VERSION.into(), + TEAM_OBSERVABILITY_SCHEMA_VERSION.into(), + INTEROP_SNAPSHOT_SCHEMA_VERSION.into(), + INTEROP_BRIDGE_SCHEMA_VERSION.into(), + ], + capabilities: crate::capability_catalog::capabilities(), + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RouteRequest { + pub task: String, + #[serde(default)] + pub agent_type: Option, + #[serde(default)] + pub previous_failures: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RoutePayload { + pub tier: ComplexityTier, + pub model_role: ModelType, + pub recommended_surface: String, + pub confidence: f64, + pub reasons: Vec, + pub escalated: bool, +} + +pub fn route_agent_task(request: &RouteRequest) -> RoutePayload { + let context = RoutingContext { + task_prompt: request.task.clone(), + agent_type: request.agent_type.clone(), + previous_failures: request.previous_failures, + ..Default::default() + }; + let decision = route_task(&context, &RoutingConfig::default()); + let recommended_surface = match decision.tier { + ComplexityTier::Low => "caller-native", + ComplexityTier::Medium => "caller-native-or-single-agent", + ComplexityTier::High => "omc-team", + }; + + RoutePayload { + tier: decision.tier, + model_role: decision.model_type, + recommended_surface: recommended_surface.into(), + confidence: decision.confidence, + reasons: decision.reasons, + escalated: decision.escalated, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capability_catalog_is_host_neutral() { + let payload = capabilities_payload(); + assert_eq!(payload.product, "omc-rs"); + assert!(payload.contracts.iter().any(|item| item == "omc.task.v1")); + assert!(payload.contracts.iter().any(|item| item == "omc.goal.v1")); + assert!( + payload + .contracts + .iter() + .any(|item| item == PYTHON_SCHEMA_VERSION) + ); + assert!( + payload + .contracts + .iter() + .any(|item| item == "omc.workflow.v1") + ); + assert!( + payload + .capabilities + .iter() + .any(|item| item.name == "agent_route") + ); + assert!( + payload + .capabilities + .iter() + .any(|item| item.name == "code_intel_artifact_query") + ); + assert!( + payload + .capabilities + .iter() + .any(|item| item.name == "lsp_document_symbols") + ); + assert!( + payload + .contracts + .iter() + .any(|item| item == INTEROP_SNAPSHOT_SCHEMA_VERSION) + ); + assert!( + payload + .capabilities + .iter() + .any(|item| item.name == "interop_snapshot") + ); + assert!( + payload + .contracts + .iter() + .any(|item| item == INTEROP_BRIDGE_SCHEMA_VERSION) + ); + assert!( + payload + .capabilities + .iter() + .any(|item| item.name == "interop_bridge") + ); + assert!( + payload + .capabilities + .iter() + .any(|item| item.name == "workflow_advance") + ); + assert!( + payload + .capabilities + .iter() + .any(|item| item.name == "python_repl") + ); + assert!( + payload + .capabilities + .iter() + .any(|item| item.name == "goal_*") + ); + assert!( + !serde_json::to_string(&payload) + .unwrap() + .contains("claude-sonnet") + ); + } + + #[test] + fn simple_task_stays_with_caller() { + let result = route_agent_task(&RouteRequest { + task: "find the config file".into(), + agent_type: None, + previous_failures: None, + }); + assert_eq!(result.tier, ComplexityTier::Low); + assert_eq!(result.recommended_surface, "caller-native"); + } + + #[test] + fn architecture_task_recommends_team_surface() { + let result = route_agent_task(&RouteRequest { + task: "redesign the system architecture across the repository".into(), + agent_type: Some("architect".into()), + previous_failures: Some(1), + }); + assert_eq!(result.tier, ComplexityTier::High); + assert_eq!(result.recommended_surface, "omc-team"); + } + + #[test] + fn response_has_stable_error_shape() { + let response: ToolResponse = ToolResponse::failure( + "req-1", + ToolError::new("invalid_request", "task is required"), + ); + let value = serde_json::to_value(response).unwrap(); + assert_eq!(value["schema_version"], TOOL_SCHEMA_VERSION); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "invalid_request"); + } +} diff --git a/crates/omc-shared/src/capability_catalog.rs b/crates/omc-shared/src/capability_catalog.rs new file mode 100644 index 0000000..cbaa9fa --- /dev/null +++ b/crates/omc-shared/src/capability_catalog.rs @@ -0,0 +1,424 @@ +//! Single source of truth for the stable OMC-RS capability surface. + +use serde::{Deserialize, Serialize}; +use std::env; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use crate::agent_tool::Capability; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AvailabilityStatus { + Available, + Unavailable, + Conditional, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DependencyAvailability { + pub name: String, + pub resolved: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CapabilityAvailability { + pub status: AvailabilityStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dependencies: Vec, +} + +struct Descriptor { + name: &'static str, + description: &'static str, + kind: &'static str, + side_effects: &'static [&'static str], + mcp_tools: &'static [&'static str], + dependency: Dependency, +} + +#[derive(Clone, Copy)] +enum Dependency { + None, + Command(&'static str), + EnvCommand { + variable: &'static str, + fallback: &'static str, + }, + EnvFlags(&'static [&'static str]), + Python, + TeamBinary, + CallerSupplied(&'static str), +} + +const DESCRIPTORS: &[Descriptor] = &[ + descriptor( + "agent_capabilities", + "List the stable OMC-RS capability contract.", + "discovery", + &[], + &["agent_capabilities"], + Dependency::None, + ), + descriptor( + "agent_route", + "Route a task by complexity without exposing provider model IDs.", + "routing", + &[], + &["agent_route"], + Dependency::None, + ), + descriptor( + "code_intel_artifact_query", + "Read committed Code Intel artifacts through its released query surface.", + "repository_intelligence", + &[], + &["code_intel_artifact_query"], + Dependency::EnvCommand { + variable: "OMC_CODE_INTEL_BIN", + fallback: "code-intel", + }, + ), + descriptor( + "lsp_document_symbols", + "Read Rust document symbols through a bounded rust-analyzer adapter.", + "language_intelligence", + &[], + &["lsp_document_symbols"], + Dependency::Command("rust-analyzer"), + ), + descriptor( + "python_repl", + "Execute explicit Python cells in a process-local persistent session.", + "code_execution", + &[ + "executes local Python code", + "may read/write files, spawn processes, or use network according to the code", + ], + &["python_repl"], + Dependency::Python, + ), + descriptor( + "debug_inspect", + "Inspect an explicit launch or attach session through an external stdio DAP adapter.", + "debugging", + &[ + "starts an external DAP adapter process", + "launches or attaches a debuggee when the host opts into side effects", + ], + &["debug_inspect"], + Dependency::CallerSupplied("DAP adapter command"), + ), + descriptor( + "team_observability", + "Read sessions, aggregate usage, or health from the existing omc-team runtime.", + "orchestration_observability", + &[], + &["team_observability"], + Dependency::TeamBinary, + ), + descriptor( + "interop_snapshot", + "Read a bounded, host-neutral snapshot of existing OMC/OMX interop state.", + "orchestration_interop", + &[], + &["interop_snapshot"], + Dependency::None, + ), + descriptor( + "interop_bridge", + "Send one explicit OMC/OMX task or message through a gated durable bridge.", + "orchestration_interop", + &[ + "writes a task or message record when active interop flags and allowSideEffects=true are both present", + ], + &["interop_bridge"], + Dependency::EnvFlags(&[ + "OMX_OMC_INTEROP_MODE=active", + "OMX_OMC_INTEROP_ENABLED=1", + "OMC_INTEROP_TOOLS_ENABLED=1", + ]), + ), + descriptor( + "workflow_advance", + "Advance a host-neutral clarify-plan-execute-verify workflow from supplied evidence.", + "orchestration", + &[], + &["workflow_advance"], + Dependency::None, + ), + descriptor( + "subagent_result_validate", + "Validate a typed subagent result against a small named schema.", + "result_validation", + &[], + &["subagent_result_validate"], + Dependency::None, + ), + descriptor( + "hash_edit", + "Apply a contiguous line edit only when every supplied SHA-256 anchor matches.", + "source_edit", + &["writes one validated project file atomically"], + &["hash_edit"], + Dependency::None, + ), + descriptor( + "state_*", + "Read and write OMC mode state through the existing state tools.", + "state", + &["writes .omc/state when using state_write"], + &[ + "state_read", + "state_write", + "state_clear", + "state_list_active", + "state_get_status", + ], + Dependency::None, + ), + descriptor( + "goal_*", + "Create, resume, checkpoint, block, and complete project goals through the durable OMC ledger.", + "goal_state", + &["writes .omc/state/goals when using goal tools"], + &[ + "goal_create", + "goal_list", + "goal_get", + "goal_start", + "goal_block", + "goal_checkpoint", + "goal_complete", + ], + Dependency::None, + ), + descriptor( + "project_memory_*", + "Read and write project memory through the existing memory tools.", + "memory", + &["writes .omc/project-memory.json when using write tools"], + &[ + "project_memory_read", + "project_memory_write", + "project_memory_add_note", + "project_memory_add_directive", + ], + Dependency::None, + ), + descriptor( + "notepad_*", + "Read and write the shared OMC notepad through existing tools.", + "memory", + &["writes .omc/notepad.md when using write tools"], + &[ + "notepad_read", + "notepad_write_priority", + "notepad_write_working", + "notepad_write_manual", + ], + Dependency::None, + ), +]; + +const fn descriptor( + name: &'static str, + description: &'static str, + kind: &'static str, + side_effects: &'static [&'static str], + mcp_tools: &'static [&'static str], + dependency: Dependency, +) -> Descriptor { + Descriptor { + name, + description, + kind, + side_effects, + mcp_tools, + dependency, + } +} + +pub fn capabilities() -> Vec { + static CATALOG: OnceLock> = OnceLock::new(); + CATALOG + .get_or_init(|| { + DESCRIPTORS + .iter() + .map(|item| Capability { + name: item.name.into(), + description: item.description.into(), + kind: item.kind.into(), + side_effects: item + .side_effects + .iter() + .map(|value| (*value).into()) + .collect(), + mcp_tools: item.mcp_tools.iter().map(|value| (*value).into()).collect(), + availability: availability(item.dependency), + }) + .collect() + }) + .clone() +} + +pub fn mcp_tool_names() -> Vec<&'static str> { + DESCRIPTORS + .iter() + .flat_map(|item| item.mcp_tools.iter().copied()) + .collect() +} + +fn availability(dependency: Dependency) -> CapabilityAvailability { + match dependency { + Dependency::None => ready(Vec::new()), + Dependency::Command(command) => command_availability(command, &[command]), + Dependency::EnvCommand { variable, fallback } => { + let configured = env::var(variable).ok(); + command_availability(fallback, &[configured.as_deref().unwrap_or(fallback)]) + } + Dependency::EnvFlags(flags) => { + let dependencies = flags + .iter() + .map(|flag| { + let (name, expected) = flag.split_once('=').unwrap_or((flag, "")); + DependencyAvailability { + name: (*flag).into(), + resolved: env::var(name).is_ok_and(|value| value == expected), + path: None, + } + }) + .collect::>(); + if dependencies.iter().all(|item| item.resolved) { + ready(dependencies) + } else { + CapabilityAvailability { + status: AvailabilityStatus::Conditional, + reason: Some("interop bridge activation flags are not all enabled".into()), + dependencies, + } + } + } + Dependency::Python => { + let configured = env::var("OMC_PYTHON_COMMAND").ok(); + let candidates = configured.as_deref().map_or_else( + || { + if cfg!(windows) { + vec!["python", "python3"] + } else { + vec!["python3", "python"] + } + }, + |command| vec![command], + ); + command_availability("python", &candidates) + } + Dependency::TeamBinary => { + let adjacent = env::current_exe().ok().and_then(|path| { + let name = if cfg!(windows) { + "omc-team.exe" + } else { + "omc-team" + }; + path.parent() + .map(|parent| parent.join(name)) + .filter(|candidate| candidate.is_file()) + }); + adjacent.map_or_else( + || command_availability("omc-team", &["omc-team"]), + |path| { + ready(vec![DependencyAvailability { + name: "omc-team".into(), + resolved: true, + path: Some(path.display().to_string()), + }]) + }, + ) + } + Dependency::CallerSupplied(name) => CapabilityAvailability { + status: AvailabilityStatus::Conditional, + reason: Some(format!("{name} is resolved per request")), + dependencies: vec![DependencyAvailability { + name: name.into(), + resolved: false, + path: None, + }], + }, + } +} + +fn command_availability(name: &str, candidates: &[&str]) -> CapabilityAvailability { + let resolved = candidates + .iter() + .find_map(|candidate| resolve_command(candidate)); + let dependency = DependencyAvailability { + name: name.into(), + resolved: resolved.is_some(), + path: resolved.as_ref().map(|path| path.display().to_string()), + }; + if resolved.is_some() { + ready(vec![dependency]) + } else { + CapabilityAvailability { + status: AvailabilityStatus::Unavailable, + reason: Some(format!("{name} was not found on PATH")), + dependencies: vec![dependency], + } + } +} + +fn ready(dependencies: Vec) -> CapabilityAvailability { + CapabilityAvailability { + status: AvailabilityStatus::Available, + reason: None, + dependencies, + } +} + +fn resolve_command(command: &str) -> Option { + let path = Path::new(command); + if path.components().count() > 1 && path.is_file() { + return Some(path.to_path_buf()); + } + let suffixes: Vec<&str> = if cfg!(windows) { + vec!["", ".exe", ".cmd", ".bat"] + } else { + vec![""] + }; + env::var_os("PATH") + .into_iter() + .flat_map(|paths| env::split_paths(&paths).collect::>()) + .find_map(|dir| { + suffixes + .iter() + .map(|suffix| dir.join(format!("{command}{suffix}"))) + .find(|candidate| candidate.is_file()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn catalog_has_sixteen_unique_capabilities_and_tools() { + let capabilities = capabilities(); + assert_eq!(capabilities.len(), 16); + assert_eq!( + capabilities + .iter() + .map(|item| &item.name) + .collect::>() + .len(), + 16 + ); + let tools = mcp_tool_names(); + assert_eq!(tools.len(), 32); + assert_eq!(tools.iter().collect::>().len(), 32); + } +} diff --git a/crates/omc-shared/src/code_intel.rs b/crates/omc-shared/src/code_intel.rs new file mode 100644 index 0000000..ce94856 --- /dev/null +++ b/crates/omc-shared/src/code_intel.rs @@ -0,0 +1,394 @@ +//! Thin, read-only adapter for the released `code-intel` CLI. +//! +//! OMC consumes Code Intel's committed artifact query surface. It does not +//! reimplement scanners, indexes, graph providers, or artifact validation. + +use std::env; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::agent_tool::{ToolError, error_codes}; +use crate::operation_contract::ArtifactRef; + +const DEFAULT_LIMIT: u8 = 20; +const MAX_LIMIT: u8 = 100; +const MAX_QUERY_TEXT: usize = 512; +const MAX_STDOUT_BYTES: usize = 16 * 1024 * 1024; +const MAX_STDERR_BYTES: usize = 2 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CodeIntelQueryRequest { + pub repo: String, + #[serde(default)] + pub artifact_root: Option, + #[serde(default)] + pub repo_path: Option, + #[serde(default)] + pub artifact_schema: Option, + #[serde(default)] + pub artifact_type: Option, + #[serde(default)] + pub contains: Option, + #[serde(default)] + pub artifact_uri: Option, + #[serde(default)] + pub limit: Option, + #[serde(default)] + pub request_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CodeIntelQueryPayload { + pub schema_version: String, + pub source: String, + pub operation: String, + pub read_only: bool, + pub repository: String, + pub result: Value, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifact_refs: Vec, +} + +pub fn query_code_intel( + request: &CodeIntelQueryRequest, +) -> Result { + validate_request(request)?; + let env_artifact_root = env::var("CODE_INTEL_ARTIFACT_ROOT").ok(); + let configured_artifact_root = request + .artifact_root + .as_deref() + .or(env_artifact_root.as_deref()); + let artifact_root = resolve_directory(configured_artifact_root, "artifactRoot")?; + if let Some(repo_path) = request.repo_path.as_deref() { + resolve_directory(Some(repo_path), "repoPath")?; + } + + let binary = env::var("OMC_CODE_INTEL_BIN") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "code-intel".into()); + let query_limit = effective_limit(request); + let mut command = Command::new(binary); + command + .args(["artifact", "query", "--artifact-root"]) + .arg(artifact_root) + .args(["--repo", &request.repo]); + if let Some(repo_path) = request.repo_path.as_deref() { + command.args(["--repo-path", repo_path]); + } + if let Some(schema) = request.artifact_schema.as_deref() { + command.args(["--artifact-schema", schema]); + } + if let Some(artifact_type) = request.artifact_type.as_deref() { + command.args(["--type", artifact_type]); + } + if let Some(contains) = request.contains.as_deref() { + command.args(["--contains", contains]); + } + command.args(["--limit", &query_limit.to_string()]); + + let output = command.output().map_err(|error| { + ToolError::new( + error_codes::ADAPTER_UNAVAILABLE, + format!("could not start code-intel read-only adapter: {error}"), + ) + })?; + if output.stdout.len() > MAX_STDOUT_BYTES { + return Err(ToolError::new( + error_codes::UPSTREAM_RESPONSE_TOO_LARGE, + "code-intel query response exceeded the OMC adapter limit", + )); + } + if !output.status.success() { + return Err(ToolError::new( + error_codes::UPSTREAM_FAILED, + format!( + "code-intel artifact query failed (exit {}): {}", + output + .status + .code() + .map_or_else(|| "unknown".into(), |code| code.to_string()), + bounded_text(&output.stderr, MAX_STDERR_BYTES) + ), + )); + } + let mut result: Value = serde_json::from_slice(&output.stdout).map_err(|error| { + ToolError::new( + error_codes::UPSTREAM_CONTRACT_INVALID, + format!("code-intel artifact query returned invalid JSON: {error}"), + ) + })?; + if let Some(uri) = request.artifact_uri.as_deref() { + select_artifact_matches(&mut result, uri, query_limit)?; + } + let artifact_refs = normalize_artifact_refs(&result)?; + + Ok(CodeIntelQueryPayload { + schema_version: "omc.code-intel.query.v1".into(), + source: "code-intel-pipeline".into(), + operation: if request.artifact_uri.is_some() { + "artifact.inspect".into() + } else { + "artifact.query".into() + }, + read_only: true, + repository: request.repo.clone(), + result, + artifact_refs, + }) +} + +fn validate_request(request: &CodeIntelQueryRequest) -> Result<(), ToolError> { + if request.repo.trim().is_empty() + || request.repo.contains('/') + || request.repo.contains('\\') + || request.repo.contains(':') + || request.repo.chars().any(char::is_whitespace) + { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "repo must be a non-empty repository key without path separators", + )); + } + if let Some(limit) = request.limit + && !(1..=MAX_LIMIT).contains(&limit) + { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "limit must be between 1 and 100", + )); + } + for (name, value) in [ + ("artifactSchema", request.artifact_schema.as_deref()), + ("artifactType", request.artifact_type.as_deref()), + ("contains", request.contains.as_deref()), + ] { + if value.is_some_and(|item| item.len() > MAX_QUERY_TEXT) { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + format!("{name} exceeds the {MAX_QUERY_TEXT}-character limit"), + )); + } + } + if let Some(uri) = request.artifact_uri.as_deref() { + ArtifactRef::digest_from_uri(uri) + .map_err(|error| ToolError::new(error_codes::INVALID_REQUEST, error))?; + } + Ok(()) +} + +fn effective_limit(request: &CodeIntelQueryRequest) -> u8 { + if request.artifact_uri.is_some() { + MAX_LIMIT + } else { + request.limit.unwrap_or(DEFAULT_LIMIT) + } +} + +fn select_artifact_matches( + result: &mut Value, + uri: &str, + requested_limit: u8, +) -> Result<(), ToolError> { + let digest = ArtifactRef::digest_from_uri(uri) + .map_err(|error| ToolError::new(error_codes::INVALID_REQUEST, error))?; + let matches = result + .get_mut("matches") + .and_then(Value::as_array_mut) + .ok_or_else(|| { + ToolError::new( + error_codes::ARTIFACT_NOT_FOUND, + format!("no committed artifact matches URI {uri}"), + ) + })?; + let original_len = matches.len(); + let selected: Vec = matches + .iter() + .filter(|item| { + item.get("artifactRef") + .and_then(|reference| reference.get("sha256")) + .and_then(Value::as_str) + == Some(digest.as_str()) + }) + .cloned() + .collect(); + if selected.is_empty() { + let code = if original_len >= usize::from(requested_limit) { + error_codes::UPSTREAM_QUERY_TRUNCATED + } else { + error_codes::ARTIFACT_NOT_FOUND + }; + return Err(ToolError::new( + code, + if code == error_codes::UPSTREAM_QUERY_TRUNCATED { + format!( + "artifact URI {uri} was not found in the bounded {requested_limit}-match query" + ) + } else { + format!("no committed artifact matches URI {uri}") + }, + )); + } + *matches = selected; + Ok(()) +} + +fn resolve_directory(value: Option<&str>, name: &str) -> Result { + let raw = match value { + Some(value) if !value.trim().is_empty() => value, + _ if name == "artifactRoot" => { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "artifactRoot is required, or CODE_INTEL_ARTIFACT_ROOT must be set", + )); + } + _ => { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + format!("{name} is required"), + )); + } + }; + let path = Path::new(raw); + if !path.is_dir() { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + format!("{name} must be an existing directory"), + )); + } + Ok(path.to_path_buf()) +} + +fn normalize_artifact_refs(result: &Value) -> Result, ToolError> { + result + .get("matches") + .and_then(Value::as_array) + .map(|matches| { + matches + .iter() + .map(|item| { + item.get("artifactRef").ok_or_else(|| { + ToolError::new( + error_codes::UPSTREAM_CONTRACT_INVALID, + "code-intel match is missing artifactRef", + ) + }) + }) + .collect::, _>>() + }) + .unwrap_or_else(|| Ok(Vec::new()))? + .into_iter() + .map(|reference| { + ArtifactRef::from_code_intel(reference).map_err(|error| { + ToolError::new( + error_codes::UPSTREAM_CONTRACT_INVALID, + format!("code-intel returned an invalid artifact ref: {error}"), + ) + }) + }) + .collect() +} + +fn bounded_text(bytes: &[u8], max_bytes: usize) -> String { + let text = String::from_utf8_lossy(bytes).trim().to_string(); + if text.len() <= max_bytes { + text + } else { + let clipped: String = text.chars().take(max_bytes).collect(); + format!("{clipped}…") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_rejects_unsafe_repository_key() { + let request = CodeIntelQueryRequest { + repo: "../secret".into(), + artifact_root: Some(".".into()), + repo_path: None, + artifact_schema: None, + artifact_type: None, + contains: None, + artifact_uri: None, + limit: None, + request_id: None, + }; + assert_eq!( + query_code_intel(&request).unwrap_err().code, + error_codes::INVALID_REQUEST + ); + } + + #[test] + fn request_accepts_normalized_query() { + let request = CodeIntelQueryRequest { + repo: "code-intel-pipeline".into(), + artifact_root: Some(".".into()), + repo_path: None, + artifact_schema: Some("agent-code-slice-ranking.v1".into()), + artifact_type: Some("code_evidence.agent_slice".into()), + contains: None, + artifact_uri: None, + limit: Some(10), + request_id: Some("r1".into()), + }; + assert!(validate_request(&request).is_ok()); + } + + #[test] + fn request_rejects_invalid_artifact_uri() { + let request = CodeIntelQueryRequest { + repo: "code-intel-pipeline".into(), + artifact_root: Some(".".into()), + repo_path: None, + artifact_schema: None, + artifact_type: None, + contains: None, + artifact_uri: Some("omc://artifact/sha256/not-a-digest".into()), + limit: None, + request_id: None, + }; + assert_eq!( + query_code_intel(&request).unwrap_err().code, + error_codes::INVALID_REQUEST + ); + } + + #[test] + fn artifact_uri_selection_filters_verified_matches() { + let digest = "a".repeat(64); + let other = "b".repeat(64); + let mut result = serde_json::json!({ + "matches": [ + {"artifactRef": {"sha256": other}}, + {"artifactRef": {"sha256": digest}} + ] + }); + select_artifact_matches(&mut result, &format!("omc://artifact/sha256/{digest}"), 100) + .unwrap(); + assert_eq!(result["matches"].as_array().unwrap().len(), 1); + assert_eq!(result["matches"][0]["artifactRef"]["sha256"], digest); + } + + #[test] + fn artifact_uri_selection_fails_closed_on_bounded_miss() { + let digest = "a".repeat(64); + let mut result = serde_json::json!({ + "matches": (0..100) + .map(|index| serde_json::json!({"artifactRef": {"sha256": format!("{index:064x}")}})) + .collect::>() + }); + let error = + select_artifact_matches(&mut result, &format!("omc://artifact/sha256/{digest}"), 100) + .unwrap_err(); + assert_eq!(error.code, error_codes::UPSTREAM_QUERY_TRUNCATED); + } +} diff --git a/crates/omc-shared/src/config/paths.rs b/crates/omc-shared/src/config/paths.rs index 5b1d989..b7b89df 100644 --- a/crates/omc-shared/src/config/paths.rs +++ b/crates/omc-shared/src/config/paths.rs @@ -21,6 +21,8 @@ pub struct OmcPaths { pub state: PathBuf, /// Sessions directory (~/.omc/state/sessions/) pub sessions: PathBuf, + /// Durable project goals directory (~/.omc/state/goals/) + pub goals: PathBuf, /// Logs directory (~/.omc/logs/) pub logs: PathBuf, /// Prompts directory (~/.omc/prompts/) @@ -53,6 +55,7 @@ impl OmcPaths { home: home.clone(), state: home.join("state"), sessions: home.join("state/sessions"), + goals: home.join("state/goals"), logs: home.join("logs"), prompts: home.join("prompts"), team: home.join("team"), @@ -152,6 +155,7 @@ impl OmcPaths { home: root.clone(), state: root.join("state"), sessions: root.join("state/sessions"), + goals: root.join("state/goals"), logs: root.join("logs"), prompts: root.join("prompts"), team: root.join("team"), @@ -207,6 +211,7 @@ mod tests { // Check subdirectory structure assert!(paths.state.ends_with("state")); assert!(paths.sessions.ends_with("state/sessions")); + assert!(paths.goals.ends_with("state/goals")); assert!(paths.logs.ends_with("logs")); assert!(paths.prompts.ends_with("prompts")); assert!(paths.team.ends_with("team")); diff --git a/crates/omc-shared/src/dap_adapter.rs b/crates/omc-shared/src/dap_adapter.rs new file mode 100644 index 0000000..04ddcca --- /dev/null +++ b/crates/omc-shared/src/dap_adapter.rs @@ -0,0 +1,394 @@ +//! Bounded, explicit-side-effect adapter for an external stdio DAP server. +//! +//! OMC-RS owns the DAP transport and lifecycle boundary only. Debugger-specific +//! launch/attach arguments stay in the caller and the adapter; this module does +//! not download, select, or implement a debugger. + +use std::fmt; +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::agent_tool::{ToolError, error_codes}; + +mod client; +#[cfg(test)] +use client::read_message; +use client::{ + DapClient, DapTransportError, required_positive_id, spawn_reader, terminate_with_error, +}; + +pub const DEBUG_SCHEMA_VERSION: &str = "omc.debug.v1"; + +const DEFAULT_TIMEOUT_MS: u64 = 30_000; +const MIN_TIMEOUT_MS: u64 = 5_000; +const MAX_TIMEOUT_MS: u64 = 300_000; +const MAX_MESSAGE_BYTES: usize = 4 * 1024 * 1024; +const MAX_ADAPTER_ARGS: usize = 128; +const MAX_ADAPTER_ARG_BYTES: usize = 64 * 1024; +const MAX_OBSERVED_EVENTS: usize = 256; +const MAX_OUTPUT_EVENTS: usize = 64; +const MAX_OUTPUT_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum DebugSessionMode { + Launch, + Attach, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum DebugAction { + Threads, + StackTrace, + Scopes, + Variables, + Modules, + LoadedSources, + Output, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DebugInspectRequest { + pub adapter_command: String, + #[serde(default)] + pub adapter_args: Vec, + #[serde(default)] + pub working_directory: Option, + pub mode: DebugSessionMode, + pub action: DebugAction, + #[serde(default)] + pub launch_arguments: Option, + #[serde(default)] + pub attach_arguments: Option, + #[serde(default)] + pub thread_id: Option, + #[serde(default)] + pub frame_id: Option, + #[serde(default)] + pub variables_reference: Option, + #[serde(default)] + pub timeout_ms: Option, + #[serde(default)] + pub allow_side_effects: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DebugOutputEvent { + pub category: Option, + pub output: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DebugInspectPayload { + pub schema_version: String, + pub operation: String, + pub adapter_command: String, + pub working_directory: String, + pub mode: DebugSessionMode, + pub action: DebugAction, + pub capabilities: Value, + pub result: Value, + pub observed_events: Vec, + pub output: Vec, + pub side_effects: Vec, +} + +/// Run one explicit launch/attach session and one read-only inspection action. +pub fn inspect_debug(request: &DebugInspectRequest) -> Result { + let timeout = validate_request(request)?; + let working_directory = resolve_working_directory(request.working_directory.as_deref())?; + let session_arguments = selected_session_arguments(request)?; + + let mut child = Command::new(&request.adapter_command) + .args(&request.adapter_args) + .current_dir(&working_directory) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + ToolError::new( + error_codes::ADAPTER_UNAVAILABLE, + format!("DAP adapter is not available: {}", request.adapter_command), + ) + } else { + ToolError::new( + error_codes::UPSTREAM_FAILED, + format!("failed to start DAP adapter: {error}"), + ) + } + })?; + + let stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => return terminate_with_error(&mut child, "DAP adapter stdin was not available"), + }; + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => return terminate_with_error(&mut child, "DAP adapter stdout was not available"), + }; + + let responses = spawn_reader(stdout); + run_debug_session( + &mut child, + stdin, + responses, + request, + &working_directory, + session_arguments, + timeout, + ) +} + +fn run_debug_session( + child: &mut Child, + stdin: ChildStdin, + responses: Receiver>, + request: &DebugInspectRequest, + working_directory: &Path, + session_arguments: Value, + timeout: Duration, +) -> Result { + let mut client = DapClient::new(stdin, responses); + let result = (|| { + let initialize = client.request( + "initialize", + json!({ + "clientID": "omc-rs", + "clientName": "omc-rs", + "adapterID": "omc-external", + "locale": "en-US", + "linesStartAt1": true, + "columnsStartAt1": true, + "pathFormat": "path", + "supportsVariableType": true, + "supportsRunInTerminalRequest": false + }), + timeout, + )?; + let capabilities = initialize + .get("capabilities") + .cloned() + .unwrap_or_else(|| initialize.clone()); + client.supports_configuration_done = capabilities + .get("supportsConfigurationDoneRequest") + .and_then(Value::as_bool) + .unwrap_or(false); + client.initialized = true; + + let session_command = match request.mode { + DebugSessionMode::Launch => "launch", + DebugSessionMode::Attach => "attach", + }; + client.request(session_command, session_arguments, timeout)?; + + let action_result = match request.action { + DebugAction::Threads => client.request("threads", json!({}), timeout)?, + DebugAction::StackTrace => { + let thread_id = required_positive_id(request.thread_id, "threadId")?; + client.request("stackTrace", json!({"threadId": thread_id}), timeout)? + } + DebugAction::Scopes => { + let frame_id = required_positive_id(request.frame_id, "frameId")?; + client.request("scopes", json!({"frameId": frame_id}), timeout)? + } + DebugAction::Variables => client.request( + "variables", + json!({ + "variablesReference": required_positive_id( + request.variables_reference, + "variablesReference" + )? + }), + timeout, + )?, + DebugAction::Modules => client.request("modules", json!({}), timeout)?, + DebugAction::LoadedSources => client.request("loadedSources", json!({}), timeout)?, + DebugAction::Output => { + client.drain_events(timeout.min(Duration::from_millis(250)))?; + json!({"outputs": client.output.clone()}) + } + }; + + Ok(DebugInspectPayload { + schema_version: DEBUG_SCHEMA_VERSION.into(), + operation: "debug.inspect".into(), + adapter_command: request.adapter_command.clone(), + working_directory: working_directory.to_string_lossy().into_owned(), + mode: request.mode.clone(), + action: request.action.clone(), + capabilities, + result: action_result, + observed_events: client.observed_events.clone(), + output: client.output.clone(), + side_effects: vec![ + "starts an external DAP adapter process".into(), + match request.mode { + DebugSessionMode::Launch => { + "launches a debuggee through the external adapter".into() + } + DebugSessionMode::Attach => { + "attaches to an existing debuggee through the external adapter".into() + } + }, + "disconnects and cleans up the adapter session before returning".into(), + ], + }) + })(); + + // A launch owns the debuggee; an attach must leave it running. + if client.initialized { + let _ = client.request( + "disconnect", + json!({ + "terminateDebuggee": matches!(request.mode, DebugSessionMode::Launch) + }), + timeout.min(Duration::from_secs(2)), + ); + } + let _ = child.kill(); + let _ = child.wait(); + result +} + +fn validate_request(request: &DebugInspectRequest) -> Result { + if request.adapter_command.trim().is_empty() { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "adapterCommand must not be empty", + )); + } + if request.adapter_args.len() > MAX_ADAPTER_ARGS + || request + .adapter_args + .iter() + .any(|value| value.len() > MAX_ADAPTER_ARG_BYTES) + { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "adapterArgs exceed the bounded adapter argument contract", + )); + } + if !request.allow_side_effects { + return Err(ToolError::new( + error_codes::SIDE_EFFECTS_NOT_ALLOWED, + "allowSideEffects=true is required to launch or attach a DAP session", + )); + } + let timeout_ms = request.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS); + if !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&timeout_ms) { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + format!("timeoutMs must be between {MIN_TIMEOUT_MS} and {MAX_TIMEOUT_MS}"), + )); + } + + match request.action { + DebugAction::StackTrace if request.thread_id.is_none() => { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "threadId is required for stackTrace", + )); + } + DebugAction::Scopes if request.frame_id.is_none() => { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "frameId is required for scopes", + )); + } + DebugAction::Variables if request.variables_reference.is_none() => { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "variablesReference is required for variables", + )); + } + _ => {} + } + for (name, value) in [ + ("threadId", request.thread_id), + ("frameId", request.frame_id), + ("variablesReference", request.variables_reference), + ] { + if value.is_some_and(|value| value <= 0) { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + format!("{name} must be positive"), + )); + } + } + Ok(Duration::from_millis(timeout_ms)) +} + +fn selected_session_arguments(request: &DebugInspectRequest) -> Result { + let (selected, forbidden, name) = match request.mode { + DebugSessionMode::Launch => ( + request.launch_arguments.as_ref(), + request.attach_arguments.as_ref(), + "launchArguments", + ), + DebugSessionMode::Attach => ( + request.attach_arguments.as_ref(), + request.launch_arguments.as_ref(), + "attachArguments", + ), + }; + if forbidden.is_some() { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + format!( + "{} is not valid for the selected debug mode", + if name == "launchArguments" { + "attachArguments" + } else { + "launchArguments" + } + ), + )); + } + let Some(value) = selected else { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + format!("{name} is required to identify the debug target"), + )); + }; + if !value.is_object() || value.as_object().is_some_and(serde_json::Map::is_empty) { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + format!("{name} must be a non-empty JSON object"), + )); + } + Ok(value.clone()) +} + +fn resolve_working_directory(input: Option<&str>) -> Result { + let root = input.unwrap_or("."); + let path = Path::new(root).canonicalize().map_err(|error| { + ToolError::new( + error_codes::INVALID_REQUEST, + format!("workingDirectory is not a readable directory: {error}"), + ) + })?; + if !path.is_dir() { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "workingDirectory must be a directory", + )); + } + Ok(path) +} + +#[cfg(test)] +#[path = "dap_adapter_tests.rs"] +mod tests; diff --git a/crates/omc-shared/src/dap_adapter/client.rs b/crates/omc-shared/src/dap_adapter/client.rs new file mode 100644 index 0000000..ddb24bc --- /dev/null +++ b/crates/omc-shared/src/dap_adapter/client.rs @@ -0,0 +1,358 @@ +use super::*; + +pub(super) fn required_positive_id(value: Option, name: &str) -> Result { + value.filter(|value| *value > 0).ok_or_else(|| { + ToolError::new( + error_codes::INVALID_REQUEST, + format!("{name} is required and must be positive"), + ) + }) +} + +pub(super) fn terminate_with_error(child: &mut Child, message: &str) -> Result { + let _ = child.kill(); + let _ = child.wait(); + Err(ToolError::new(error_codes::UPSTREAM_FAILED, message)) +} + +pub(super) struct DapClient { + stdin: ChildStdin, + responses: Receiver>, + next_sequence: i64, + pub(super) initialized: bool, + pub(super) supports_configuration_done: bool, + configuration_done_sent: bool, + pub(super) observed_events: Vec, + pub(super) output: Vec, +} + +impl DapClient { + pub(super) fn new( + stdin: ChildStdin, + responses: Receiver>, + ) -> Self { + Self { + stdin, + responses, + next_sequence: 1, + initialized: false, + supports_configuration_done: false, + configuration_done_sent: false, + observed_events: Vec::new(), + output: Vec::new(), + } + } + + pub(super) fn request( + &mut self, + command: &str, + arguments: Value, + timeout: Duration, + ) -> Result { + let request_sequence = self.next_sequence; + self.next_sequence += 1; + write_message( + &mut self.stdin, + &json!({ + "seq": request_sequence, + "type": "request", + "command": command, + "arguments": arguments + }), + )?; + + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(ToolError::new( + error_codes::DEBUG_TIMEOUT, + format!("DAP timed out waiting for {command}"), + )); + } + let message = self + .responses + .recv_timeout(remaining) + .map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => ToolError::new( + error_codes::DEBUG_TIMEOUT, + format!("DAP timed out waiting for {command}"), + ), + mpsc::RecvTimeoutError::Disconnected => ToolError::new( + error_codes::UPSTREAM_FAILED, + "DAP adapter closed its output before responding", + ), + })? + .map_err(map_transport_error)?; + + let message_type = message.get("type").and_then(Value::as_str).ok_or_else(|| { + ToolError::new( + error_codes::UPSTREAM_CONTRACT_INVALID, + "DAP message omitted type", + ) + })?; + match message_type { + "event" => self.handle_event(&message)?, + "request" => self.reject_reverse_request(&message)?, + "response" => { + if message.get("request_seq").and_then(Value::as_i64) != Some(request_sequence) + { + continue; + } + if message.get("success").and_then(Value::as_bool) == Some(false) { + return Err(ToolError::new( + error_codes::UPSTREAM_FAILED, + message + .get("message") + .and_then(Value::as_str) + .unwrap_or("DAP request failed"), + )); + } + return Ok(message.get("body").cloned().unwrap_or(Value::Null)); + } + other => { + return Err(ToolError::new( + error_codes::UPSTREAM_CONTRACT_INVALID, + format!("unsupported DAP message type: {other}"), + )); + } + } + } + } + + fn handle_event(&mut self, message: &Value) -> Result<(), ToolError> { + let event = message + .get("event") + .and_then(Value::as_str) + .ok_or_else(|| { + ToolError::new( + error_codes::UPSTREAM_CONTRACT_INVALID, + "DAP event omitted event name", + ) + })?; + if self.observed_events.len() < MAX_OBSERVED_EVENTS { + self.observed_events.push(event.into()); + } + if event == "output" + && self.output.len() < MAX_OUTPUT_EVENTS + && let Some(body) = message.get("body") + { + let output = body + .get("output") + .and_then(Value::as_str) + .unwrap_or_default(); + self.output.push(DebugOutputEvent { + category: body + .get("category") + .and_then(Value::as_str) + .map(ToString::to_string), + output: truncate_string(output, MAX_OUTPUT_BYTES), + }); + } + if event == "initialized" + && self.supports_configuration_done + && !self.configuration_done_sent + { + let sequence = self.next_sequence; + self.next_sequence += 1; + write_message( + &mut self.stdin, + &json!({ + "seq": sequence, + "type": "request", + "command": "configurationDone", + "arguments": {} + }), + )?; + self.configuration_done_sent = true; + } + Ok(()) + } + + fn reject_reverse_request(&mut self, message: &Value) -> Result<(), ToolError> { + let request_sequence = message.get("seq").and_then(Value::as_i64).ok_or_else(|| { + ToolError::new( + error_codes::UPSTREAM_CONTRACT_INVALID, + "DAP reverse request omitted seq", + ) + })?; + let command = message + .get("command") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let sequence = self.next_sequence; + self.next_sequence += 1; + write_message( + &mut self.stdin, + &json!({ + "seq": sequence, + "type": "response", + "request_seq": request_sequence, + "command": command, + "success": false, + "message": "OMC-RS does not support DAP reverse requests" + }), + ) + } + + pub(super) fn drain_events(&mut self, timeout: Duration) -> Result<(), ToolError> { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(()); + } + let message = match self.responses.recv_timeout(remaining) { + Ok(message) => message.map_err(map_transport_error)?, + Err(mpsc::RecvTimeoutError::Timeout) => return Ok(()), + Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(()), + }; + match message.get("type").and_then(Value::as_str) { + Some("event") => self.handle_event(&message)?, + Some("request") => self.reject_reverse_request(&message)?, + Some("response") => {} + Some(other) => { + return Err(ToolError::new( + error_codes::UPSTREAM_CONTRACT_INVALID, + format!("unsupported DAP message type: {other}"), + )); + } + None => { + return Err(ToolError::new( + error_codes::UPSTREAM_CONTRACT_INVALID, + "DAP message omitted type", + )); + } + } + } + } +} + +fn truncate_string(value: &str, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value.to_string(); + } + let mut end = max_bytes; + while !value.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &value[..end]) +} + +fn write_message(stdin: &mut ChildStdin, message: &Value) -> Result<(), ToolError> { + let body = serde_json::to_vec(message).map_err(|error| { + ToolError::new( + error_codes::UPSTREAM_CONTRACT_INVALID, + format!("cannot encode DAP request: {error}"), + ) + })?; + write!(stdin, "Content-Length: {}\r\n\r\n", body.len()).map_err(|error| { + ToolError::new( + error_codes::UPSTREAM_FAILED, + format!("cannot write DAP headers: {error}"), + ) + })?; + stdin.write_all(&body).map_err(|error| { + ToolError::new( + error_codes::UPSTREAM_FAILED, + format!("cannot write DAP body: {error}"), + ) + })?; + stdin.flush().map_err(|error| { + ToolError::new( + error_codes::UPSTREAM_FAILED, + format!("cannot flush DAP request: {error}"), + ) + }) +} + +pub(super) fn spawn_reader( + stdout: impl Read + Send + 'static, +) -> Receiver> { + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + match read_message(&mut reader) { + Ok(Some(message)) => { + if sender.send(Ok(message)).is_err() { + break; + } + } + Ok(None) => break, + Err(error) => { + let _ = sender.send(Err(error)); + break; + } + } + } + }); + receiver +} + +pub(super) fn read_message(reader: &mut impl BufRead) -> Result, DapTransportError> { + let mut content_length = None; + loop { + let mut line = String::new(); + let bytes = reader.read_line(&mut line).map_err(DapTransportError::Io)?; + if bytes == 0 { + return Ok(None); + } + if line == "\r\n" || line == "\n" { + break; + } + let Some((name, value)) = line.split_once(':') else { + return Err(DapTransportError::InvalidHeader(line.trim().into())); + }; + if name.eq_ignore_ascii_case("Content-Length") { + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| DapTransportError::InvalidHeader(line.trim().into()))?, + ); + } + } + let length = content_length.ok_or(DapTransportError::MissingContentLength)?; + if length > MAX_MESSAGE_BYTES { + return Err(DapTransportError::ResponseTooLarge(length)); + } + let mut body = vec![0; length]; + reader + .read_exact(&mut body) + .map_err(DapTransportError::Io)?; + serde_json::from_slice(&body).map_err(DapTransportError::Json) +} + +fn map_transport_error(error: DapTransportError) -> ToolError { + match error { + DapTransportError::ResponseTooLarge(length) => ToolError::new( + error_codes::UPSTREAM_RESPONSE_TOO_LARGE, + format!("DAP response exceeds {MAX_MESSAGE_BYTES} bytes: {length}"), + ), + other => ToolError::new(error_codes::UPSTREAM_FAILED, other.to_string()), + } +} + +#[derive(Debug)] +pub(super) enum DapTransportError { + Io(io::Error), + Json(serde_json::Error), + InvalidHeader(String), + MissingContentLength, + ResponseTooLarge(usize), +} + +impl fmt::Display for DapTransportError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(formatter, "DAP I/O error: {error}"), + Self::Json(error) => write!(formatter, "invalid DAP JSON response: {error}"), + Self::InvalidHeader(header) => write!(formatter, "invalid DAP header: {header}"), + Self::MissingContentLength => { + formatter.write_str("DAP response omitted Content-Length") + } + Self::ResponseTooLarge(length) => write!(formatter, "DAP response too large: {length}"), + } + } +} diff --git a/crates/omc-shared/src/dap_adapter_tests.rs b/crates/omc-shared/src/dap_adapter_tests.rs new file mode 100644 index 0000000..c782b95 --- /dev/null +++ b/crates/omc-shared/src/dap_adapter_tests.rs @@ -0,0 +1,69 @@ +use super::*; + +fn request() -> DebugInspectRequest { + serde_json::from_value(json!({ + "adapterCommand": "dap-fixture", + "mode": "launch", + "action": "threads", + "launchArguments": {"program": "target"}, + "allowSideEffects": true + })) + .expect("debug request parses") +} + +#[test] +fn timeout_is_bounded() { + assert!( + validate_request(&DebugInspectRequest { + timeout_ms: Some(MIN_TIMEOUT_MS - 1), + ..request() + }) + .is_err() + ); + assert!( + validate_request(&DebugInspectRequest { + timeout_ms: Some(MIN_TIMEOUT_MS), + ..request() + }) + .is_ok() + ); + assert!( + validate_request(&DebugInspectRequest { + timeout_ms: Some(MAX_TIMEOUT_MS + 1), + ..request() + }) + .is_err() + ); +} + +#[test] +fn launch_requires_explicit_side_effect_opt_in_and_target() { + let mut request = request(); + request.allow_side_effects = false; + assert_eq!( + validate_request(&request).unwrap_err().code, + error_codes::SIDE_EFFECTS_NOT_ALLOWED + ); + + request.allow_side_effects = true; + request.launch_arguments = None; + assert!(selected_session_arguments(&request).is_err()); +} + +#[test] +fn dap_headers_are_bounded_and_utf8_json_is_decoded() { + let body = br#"{"type":"event","event":"initialized"}"#; + let input = format!("Content-Length: {}\r\n\r\n", body.len()); + let mut bytes = input.into_bytes(); + bytes.extend_from_slice(body); + let message = read_message(&mut bytes.as_slice()) + .expect("DAP framing parses") + .expect("DAP message exists"); + assert_eq!(message["event"], "initialized"); + + let oversized = format!("Content-Length: {}\r\n\r\n", MAX_MESSAGE_BYTES + 1); + assert!(matches!( + read_message(&mut oversized.as_bytes()), + Err(DapTransportError::ResponseTooLarge(_)) + )); +} diff --git a/crates/omc-shared/src/goal_contract.rs b/crates/omc-shared/src/goal_contract.rs new file mode 100644 index 0000000..caba5c5 --- /dev/null +++ b/crates/omc-shared/src/goal_contract.rs @@ -0,0 +1,274 @@ +//! Stable goal and checkpoint contract for cross-host OMC workflows. +//! +//! A goal is control-plane state. It does not own an Agent loop, provider +//! call, or task executor. Task IDs may be attached for correlation, while +//! task lifecycle remains governed by omc.task.v1. + +use serde::{Deserialize, Serialize}; + +use crate::operation_contract::ArtifactRef; + +pub const GOAL_SCHEMA_VERSION: &str = "omc.goal.v1"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum GoalStatus { + Planned, + Active, + Blocked, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GoalCheckpoint { + pub checkpoint_id: String, + pub summary: String, + pub recorded_at: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifact_refs: Vec, +} + +impl GoalCheckpoint { + pub fn validate(&self) -> Result<(), String> { + validate_identifier(&self.checkpoint_id, "checkpoint_id")?; + validate_non_empty(&self.summary, "checkpoint summary")?; + validate_non_empty(&self.recorded_at, "checkpoint recorded_at")?; + for reference in &self.artifact_refs { + reference.validate()?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GoalRecord { + pub schema_version: String, + pub goal_id: String, + pub objective: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner: Option, + pub status: GoalStatus, + pub created_at: String, + pub updated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub blocker: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dispatch_task_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub checkpoints: Vec, +} + +impl GoalRecord { + pub fn new( + goal_id: impl Into, + objective: impl Into, + created_at: impl Into, + ) -> Self { + let created_at = created_at.into(); + Self { + schema_version: GOAL_SCHEMA_VERSION.into(), + goal_id: goal_id.into(), + objective: objective.into(), + owner: None, + status: GoalStatus::Planned, + created_at: created_at.clone(), + updated_at: created_at, + blocker: None, + dispatch_task_id: None, + checkpoints: Vec::new(), + } + } + + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != GOAL_SCHEMA_VERSION { + return Err("goal schema version is not supported".into()); + } + validate_identifier(&self.goal_id, "goal_id")?; + validate_non_empty(&self.objective, "goal objective")?; + validate_non_empty(&self.created_at, "goal created_at")?; + validate_non_empty(&self.updated_at, "goal updated_at")?; + if let Some(owner) = &self.owner { + validate_non_empty(owner, "goal owner")?; + } + if let Some(blocker) = &self.blocker { + validate_non_empty(blocker, "goal blocker")?; + } + if self.status == GoalStatus::Blocked && self.blocker.is_none() { + return Err("blocked goals must include a blocker".into()); + } + if self.status != GoalStatus::Blocked && self.blocker.is_some() { + return Err("only blocked goals may include a blocker".into()); + } + if let Some(task_id) = &self.dispatch_task_id { + validate_identifier(task_id, "dispatch_task_id")?; + } + for checkpoint in &self.checkpoints { + checkpoint.validate()?; + } + Ok(()) + } + + pub fn start(&mut self, updated_at: impl Into) -> Result<(), String> { + if !matches!(self.status, GoalStatus::Planned | GoalStatus::Blocked) { + return Err(format!("cannot start a {} goal", status_name(self.status))); + } + self.status = GoalStatus::Active; + self.blocker = None; + self.updated_at = updated_at.into(); + self.validate() + } + + pub fn block( + &mut self, + blocker: impl Into, + updated_at: impl Into, + ) -> Result<(), String> { + if self.status == GoalStatus::Completed { + return Err("cannot block a completed goal".into()); + } + self.status = GoalStatus::Blocked; + self.blocker = Some(blocker.into()); + self.updated_at = updated_at.into(); + self.validate() + } + + pub fn complete(&mut self, updated_at: impl Into) -> Result<(), String> { + if self.status != GoalStatus::Active { + return Err(format!( + "only active goals can be completed; current status is {}", + status_name(self.status) + )); + } + self.status = GoalStatus::Completed; + self.blocker = None; + self.updated_at = updated_at.into(); + self.validate() + } + + pub fn attach_task(&mut self, task_id: impl Into) -> Result<(), String> { + let task_id = task_id.into(); + validate_identifier(&task_id, "dispatch_task_id")?; + self.dispatch_task_id = Some(task_id); + self.validate() + } + + pub fn add_checkpoint(&mut self, checkpoint: GoalCheckpoint) -> Result<(), String> { + if self.status == GoalStatus::Completed { + return Err("cannot add a checkpoint to a completed goal".into()); + } + checkpoint.validate()?; + if self + .checkpoints + .iter() + .any(|existing| existing.checkpoint_id == checkpoint.checkpoint_id) + { + return Err(format!( + "checkpoint already exists: {}", + checkpoint.checkpoint_id + )); + } + self.updated_at = checkpoint.recorded_at.clone(); + self.checkpoints.push(checkpoint); + Ok(()) + } +} + +fn status_name(status: GoalStatus) -> &'static str { + match status { + GoalStatus::Planned => "planned", + GoalStatus::Active => "active", + GoalStatus::Blocked => "blocked", + GoalStatus::Completed => "completed", + } +} + +fn validate_non_empty(value: &str, field: &str) -> Result<(), String> { + if value.trim().is_empty() { + Err(format!("{field} must not be empty")) + } else { + Ok(()) + } +} + +fn validate_identifier(value: &str, field: &str) -> Result<(), String> { + validate_non_empty(value, field)?; + if value.len() > 128 || value == "." || value == ".." { + return Err(format!("{field} is not a valid identifier")); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(format!( + "{field} may contain only ASCII letters, digits, '.', '_' or '-'" + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn goal() -> GoalRecord { + GoalRecord::new("goal-1", "internalize upstream capabilities", "t0") + } + + #[test] + fn lifecycle_requires_active_before_completion() { + let mut goal = goal(); + assert!(goal.complete("t1").is_err()); + goal.start("t2").unwrap(); + goal.complete("t3").unwrap(); + assert_eq!(goal.status, GoalStatus::Completed); + assert!(goal.start("t4").is_err()); + } + + #[test] + fn blocked_goal_can_resume_and_carries_reason() { + let mut goal = goal(); + goal.block("waiting for source confirmation", "t1").unwrap(); + assert_eq!(goal.status, GoalStatus::Blocked); + assert_eq!( + goal.blocker.as_deref(), + Some("waiting for source confirmation") + ); + goal.start("t2").unwrap(); + assert_eq!(goal.status, GoalStatus::Active); + assert!(goal.blocker.is_none()); + } + + #[test] + fn checkpoints_reject_duplicates_and_roundtrip() { + let mut goal = goal(); + goal.add_checkpoint(GoalCheckpoint { + checkpoint_id: "cp-1".into(), + summary: "S0 verified".into(), + recorded_at: "t1".into(), + artifact_refs: Vec::new(), + }) + .unwrap(); + assert!( + goal.add_checkpoint(GoalCheckpoint { + checkpoint_id: "cp-1".into(), + summary: "duplicate".into(), + recorded_at: "t2".into(), + artifact_refs: Vec::new(), + }) + .is_err() + ); + let restored: GoalRecord = + serde_json::from_value(serde_json::to_value(&goal).unwrap()).unwrap(); + assert_eq!(restored.checkpoints.len(), 1); + assert_eq!(restored.schema_version, GOAL_SCHEMA_VERSION); + } + + #[test] + fn validation_rejects_path_like_identifiers() { + let mut goal = goal(); + goal.goal_id = "../escape".into(); + assert!(goal.validate().is_err()); + } +} diff --git a/crates/omc-shared/src/hash_edit.rs b/crates/omc-shared/src/hash_edit.rs new file mode 100644 index 0000000..3a55cfb --- /dev/null +++ b/crates/omc-shared/src/hash_edit.rs @@ -0,0 +1,294 @@ +//! Hash-anchored, atomic source edits. +//! +//! The edit is intentionally narrow: callers name one contiguous line range +//! and provide the hash for every current line. Any stale anchor aborts before +//! the file is written. + +use std::fs; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; +use thiserror::Error; + +pub const HASH_EDIT_SCHEMA_VERSION: &str = "omc.hash-edit.v1"; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LineAnchor { + pub line: usize, + pub sha256: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct HashEdit { + pub schema_version: String, + pub path: String, + pub start_line: usize, + pub end_line: usize, + pub anchors: Vec, + pub replacement: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_file_sha256: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct HashEditResult { + pub schema_version: String, + pub path: String, + pub applied: bool, + pub file_sha256_before: String, + pub file_sha256_after: String, +} + +#[derive(Debug, Error)] +pub enum HashEditError { + #[error("invalid hash edit: {0}")] + Invalid(String), + #[error("hash edit target is outside the project root: {0}")] + OutsideRoot(String), + #[error("hash edit target does not exist: {0}")] + MissingTarget(String), + #[error("stale line anchor at line {line}: expected {expected}, found {actual}")] + StaleAnchor { + line: usize, + expected: String, + actual: String, + }, + #[error("stale file digest: expected {expected}, found {actual}")] + StaleFile { expected: String, actual: String }, + #[error("failed to read hash edit target: {0}")] + Read(#[from] std::io::Error), +} + +impl HashEdit { + pub fn new( + path: impl Into, + start_line: usize, + end_line: usize, + anchors: Vec, + replacement: impl Into, + ) -> Self { + Self { + schema_version: HASH_EDIT_SCHEMA_VERSION.into(), + path: path.into(), + start_line, + end_line, + anchors, + replacement: replacement.into(), + expected_file_sha256: None, + } + } + + pub fn apply(&self, root: &Path) -> Result { + self.validate()?; + let target = resolve_target(root, &self.path)?; + let original = fs::read_to_string(&target)?; + let before = digest(original.as_bytes()); + if let Some(expected) = &self.expected_file_sha256 { + validate_digest(expected, "expected_file_sha256")?; + if expected != &before { + return Err(HashEditError::StaleFile { + expected: expected.clone(), + actual: before, + }); + } + } + + let newline = if original.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + let lines: Vec<&str> = original.lines().collect(); + for anchor in &self.anchors { + let actual_line = lines.get(anchor.line - 1).ok_or_else(|| { + HashEditError::Invalid(format!("anchor line {} is out of range", anchor.line)) + })?; + let actual = digest(actual_line.as_bytes()); + if actual != anchor.sha256 { + return Err(HashEditError::StaleAnchor { + line: anchor.line, + expected: anchor.sha256.clone(), + actual, + }); + } + } + + let replacement_lines: Vec<&str> = if self.replacement.is_empty() { + Vec::new() + } else { + self.replacement.split('\n').collect() + }; + let mut output_lines: Vec<&str> = Vec::new(); + output_lines.extend_from_slice(&lines[..self.start_line - 1]); + output_lines.extend(replacement_lines); + output_lines.extend_from_slice(&lines[self.end_line..]); + let mut updated = output_lines.join(newline); + if original.ends_with(newline) && !updated.ends_with(newline) { + updated.push_str(newline); + } + + let tmp = target.with_extension("omc-hash-edit.tmp"); + fs::write(&tmp, updated.as_bytes())?; + if let Err(error) = fs::rename(&tmp, &target) { + let _ = fs::remove_file(&tmp); + return Err(error.into()); + } + Ok(HashEditResult { + schema_version: HASH_EDIT_SCHEMA_VERSION.into(), + path: self.path.clone(), + applied: true, + file_sha256_before: before, + file_sha256_after: digest(updated.as_bytes()), + }) + } + + fn validate(&self) -> Result<(), HashEditError> { + if self.schema_version != HASH_EDIT_SCHEMA_VERSION { + return Err(HashEditError::Invalid("unsupported schema version".into())); + } + if self.path.is_empty() + || self.path.starts_with('/') + || self.path.starts_with('\\') + || self.path.contains('\\') + || self + .path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err(HashEditError::Invalid( + "path must be a portable relative path".into(), + )); + } + if self.start_line == 0 || self.end_line < self.start_line { + return Err(HashEditError::Invalid( + "line range must be 1-based and contiguous".into(), + )); + } + if self.anchors.len() != self.end_line - self.start_line + 1 + || self.anchors.iter().enumerate().any(|(offset, anchor)| { + anchor.line != self.start_line + offset || !is_digest(&anchor.sha256) + }) + { + return Err(HashEditError::Invalid( + "anchors must cover the full contiguous line range".into(), + )); + } + if let Some(expected) = &self.expected_file_sha256 { + validate_digest(expected, "expected_file_sha256")?; + } + Ok(()) + } +} + +fn resolve_target(root: &Path, path: &str) -> Result { + let root = root.canonicalize()?; + let target = root.join(path); + if !target.exists() { + return Err(HashEditError::MissingTarget(path.into())); + } + let target = target.canonicalize()?; + if !target.starts_with(&root) { + return Err(HashEditError::OutsideRoot(path.into())); + } + Ok(target) +} + +fn is_digest(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn validate_digest(value: &str, field: &str) -> Result<(), HashEditError> { + if is_digest(value) { + Ok(()) + } else { + Err(HashEditError::Invalid(format!( + "{field} must be a SHA-256 digest" + ))) + } +} + +pub fn line_sha256(line: &str) -> String { + digest(line.as_bytes()) +} + +fn digest(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn applies_matching_anchors_and_preserves_newline() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("src.rs"); + fs::write(&path, "one\r\ntwo\r\nthree\r\n").unwrap(); + let edit = HashEdit::new( + "src.rs", + 2, + 2, + vec![LineAnchor { + line: 2, + sha256: line_sha256("two"), + }], + "changed", + ); + let result = edit.apply(dir.path()).unwrap(); + assert!(result.applied); + assert_eq!( + fs::read_to_string(path).unwrap(), + "one\r\nchanged\r\nthree\r\n" + ); + } + + #[test] + fn rejects_stale_anchor_without_writing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("src.rs"); + fs::write(&path, "one\ntwo\n").unwrap(); + let edit = HashEdit::new( + "src.rs", + 1, + 1, + vec![LineAnchor { + line: 1, + sha256: line_sha256("old"), + }], + "changed", + ); + assert!(matches!( + edit.apply(dir.path()), + Err(HashEditError::StaleAnchor { .. }) + )); + assert_eq!(fs::read_to_string(path).unwrap(), "one\ntwo\n"); + } + + #[test] + fn rejects_traversal() { + let edit = HashEdit::new("../secret", 1, 1, vec![], "x"); + assert!(edit.validate().is_err()); + } + + #[test] + fn empty_replacement_removes_lines_without_leading_newline() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("src.rs"); + fs::write(&path, "one\ntwo\nthree\n").unwrap(); + let edit = HashEdit::new( + "src.rs", + 1, + 1, + vec![LineAnchor { + line: 1, + sha256: line_sha256("one"), + }], + "", + ); + edit.apply(dir.path()).unwrap(); + assert_eq!(fs::read_to_string(path).unwrap(), "two\nthree\n"); + } +} diff --git a/crates/omc-shared/src/lib.rs b/crates/omc-shared/src/lib.rs index d969ce8..299327f 100644 --- a/crates/omc-shared/src/lib.rs +++ b/crates/omc-shared/src/lib.rs @@ -1,21 +1,44 @@ //! omc-shared: Shared types, config, and state for oh-my-claudecode-RS +pub mod agent_tool; +pub mod capability_catalog; +pub mod code_intel; pub mod config; pub mod context_strategy; +pub mod dap_adapter; pub mod events; +pub mod goal_contract; +pub mod hash_edit; +pub mod lsp_adapter; pub mod memory; +pub mod operation_contract; pub mod paths; pub mod prelude; pub mod resilience; pub mod routing; +pub mod session_pool; pub mod shared_memory; pub mod state; +pub mod team_contract; pub mod tools; pub mod types; +pub use agent_tool::PYTHON_SCHEMA_VERSION; +pub use agent_tool::workflow_contract; +pub use agent_tool::workflow_contract::{ + WORKFLOW_SCHEMA_VERSION, WorkflowAdvancePayload, WorkflowAdvanceRequest, WorkflowContext, + WorkflowDecision, WorkflowStage, advance_workflow, +}; pub use config::{Config, ConfigError, OmcPaths}; +pub use goal_contract::{GOAL_SCHEMA_VERSION, GoalCheckpoint, GoalRecord, GoalStatus}; +pub use hash_edit::{HASH_EDIT_SCHEMA_VERSION, HashEdit, HashEditResult, LineAnchor}; +pub use operation_contract::{ + ARTIFACT_REF_SCHEMA_VERSION, ARTIFACT_URI_PREFIX, EVENT_SCHEMA_VERSION, + SUBAGENT_RESULT_SCHEMA_VERSION, TASK_SCHEMA_VERSION, +}; pub use shared_memory::{MemoryEntry, SharedMemory, SharedMemoryError}; pub use state::{ - AppState, ContextSample, HudState, SessionInfo, SessionState, StateError, StateReader, - StateWriter, TeamRunRecord, + AppState, ContextSample, GoalLedger, HudState, SessionInfo, SessionState, StateError, + StateReader, StateWriter, TeamRunRecord, }; +pub use team_contract::{TEAM_OBSERVABILITY_SCHEMA_VERSION, TeamObservabilityPayload}; diff --git a/crates/omc-shared/src/lsp_adapter.rs b/crates/omc-shared/src/lsp_adapter.rs new file mode 100644 index 0000000..3e6a624 --- /dev/null +++ b/crates/omc-shared/src/lsp_adapter.rs @@ -0,0 +1,299 @@ +//! Bounded, read-only LSP adapter. +//! +//! Long-running consumers may reuse a small project-scoped pool; the direct +//! function remains a one-shot fallback. This is not a language-server +//! registry or a second agent runtime. + +use std::fmt::{self, Write as FmtWrite}; +use std::fs; +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::Mutex; +use std::sync::mpsc::{self, Receiver}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::agent_tool::{ToolError, error_codes}; + +mod transport; +#[cfg(test)] +use transport::read_message; +use transport::{ + LspTransportError, path_to_file_uri, receive_response, spawn_reader, write_notification, + write_request, +}; + +const RUST_ANALYZER_COMMAND: &str = "rust-analyzer"; +const DEFAULT_TIMEOUT_MS: u64 = 20_000; +const MIN_TIMEOUT_MS: u64 = 5_000; +const MAX_TIMEOUT_MS: u64 = 60_000; +const MAX_MESSAGE_BYTES: usize = 4 * 1024 * 1024; +const MAX_PROJECT_SESSIONS: usize = 4; +const PROJECT_SESSION_TTL: Duration = Duration::from_secs(10 * 60); + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LspDocumentSymbolsRequest { + #[serde(default)] + pub working_directory: Option, + pub file: String, + #[serde(default)] + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LspDocumentSymbolsPayload { + pub operation: String, + pub server: String, + pub working_directory: String, + pub file: String, + pub uri: String, + pub result: Value, + pub server_process_id: u32, + pub session_reused: bool, + pub side_effects: Vec, +} + +mod session; +pub use session::LspProjectPool; + +pub fn query_document_symbols( + request: &LspDocumentSymbolsRequest, +) -> Result { + let timeout = validate_timeout(request.timeout_ms)?; + let (root, file, relative_file) = resolve_source_file(request)?; + let contents = fs::read_to_string(&file).map_err(|error| { + ToolError::new( + error_codes::INVALID_REQUEST, + format!("cannot read Rust source file: {error}"), + ) + })?; + let uri = path_to_file_uri(&file); + + let (result, server_process_id) = run_document_symbols(&root, &uri, &contents, timeout)?; + Ok(LspDocumentSymbolsPayload { + operation: "lsp.document_symbols".into(), + server: RUST_ANALYZER_COMMAND.into(), + working_directory: root.to_string_lossy().into_owned(), + file: relative_file, + uri, + result, + server_process_id, + session_reused: false, + side_effects: Vec::new(), + }) +} + +fn validate_timeout(timeout_ms: Option) -> Result { + let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS); + if !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&timeout_ms) { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + format!("timeoutMs must be between {MIN_TIMEOUT_MS} and {MAX_TIMEOUT_MS}"), + )); + } + Ok(Duration::from_millis(timeout_ms)) +} + +fn resolve_source_file( + request: &LspDocumentSymbolsRequest, +) -> Result<(PathBuf, PathBuf, String), ToolError> { + if request.file.trim().is_empty() { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "file must not be empty", + )); + } + + let root_input = request.working_directory.as_deref().unwrap_or("."); + let root = Path::new(root_input).canonicalize().map_err(|error| { + ToolError::new( + error_codes::INVALID_REQUEST, + format!("workingDirectory is not a readable directory: {error}"), + ) + })?; + if !root.is_dir() { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "workingDirectory must be a directory", + )); + } + + let requested_file = Path::new(&request.file); + if requested_file.is_absolute() { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "file must be project-relative", + )); + } + + let file = root.join(requested_file).canonicalize().map_err(|error| { + ToolError::new( + error_codes::INVALID_REQUEST, + format!("file is not readable: {error}"), + ) + })?; + if !file.starts_with(&root) { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "file must remain inside workingDirectory", + )); + } + if !file.is_file() { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "file must be a regular file", + )); + } + if file.extension().and_then(|value| value.to_str()) != Some("rs") { + return Err(ToolError::new( + error_codes::INVALID_REQUEST, + "lsp_document_symbols currently supports Rust files only", + )); + } + + let relative = file + .strip_prefix(&root) + .map_err(|_| ToolError::new(error_codes::INVALID_REQUEST, "file is outside root"))? + .to_string_lossy() + .replace('\\', "/"); + Ok((root, file, relative)) +} + +fn run_document_symbols( + root: &Path, + uri: &str, + contents: &str, + timeout: Duration, +) -> Result<(Value, u32), ToolError> { + let mut child = Command::new(RUST_ANALYZER_COMMAND) + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + ToolError::new( + error_codes::ADAPTER_UNAVAILABLE, + "rust-analyzer is not available on PATH", + ) + } else { + ToolError::new( + error_codes::UPSTREAM_FAILED, + format!("failed to start rust-analyzer: {error}"), + ) + } + })?; + + let mut stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => { + let _ = child.kill(); + let _ = child.wait(); + return Err(ToolError::new( + error_codes::UPSTREAM_FAILED, + "rust-analyzer stdin was not available", + )); + } + }; + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + let _ = child.kill(); + let _ = child.wait(); + return Err(ToolError::new( + error_codes::UPSTREAM_FAILED, + "rust-analyzer stdout was not available", + )); + } + }; + let responses = spawn_reader(stdout); + + let result = run_lsp_session(&mut stdin, &responses, root, uri, contents, timeout); + + // This adapter is intentionally one-shot. Killing after the exit + // notification bounds cleanup even when a server is still indexing. + let _ = write_notification(&mut stdin, "exit", Value::Null); + let _ = child.kill(); + let _ = child.wait(); + result.map(|value| (value, child.id())) +} + +fn run_lsp_session( + stdin: &mut ChildStdin, + responses: &Receiver>, + root: &Path, + uri: &str, + contents: &str, + timeout: Duration, +) -> Result { + initialize_lsp(stdin, responses, root, timeout)?; + write_notification( + stdin, + "textDocument/didOpen", + json!({ + "textDocument": { + "uri": uri, + "languageId": "rust", + "version": 1, + "text": contents + } + }), + )?; + write_request( + stdin, + 2, + "textDocument/documentSymbol", + json!({"textDocument": {"uri": uri}}), + )?; + receive_response(responses, 2, timeout) +} + +fn initialize_lsp( + stdin: &mut ChildStdin, + responses: &Receiver>, + root: &Path, + timeout: Duration, +) -> Result<(), ToolError> { + write_request( + stdin, + 1, + "initialize", + json!({ + "processId": Value::Null, + "clientInfo": {"name": "omc-rs", "version": env!("CARGO_PKG_VERSION")}, + "rootUri": path_to_file_uri(root), + "workspaceFolders": [{"uri": path_to_file_uri(root), "name": "omc-rs-workspace"}], + "capabilities": { + "workspace": {"workspaceFolders": true}, + "textDocument": {"documentSymbol": {"hierarchicalDocumentSymbolSupport": true}} + }, + "initializationOptions": {}, + "trace": "off" + }), + )?; + let initialized = receive_response(responses, 1, timeout)?; + if initialized + .get("capabilities") + .and_then(Value::as_object) + .is_none() + { + return Err(ToolError::new( + error_codes::UPSTREAM_CONTRACT_INVALID, + "rust-analyzer initialize response omitted capabilities", + )); + } + + write_notification(stdin, "initialized", json!({}))?; + Ok(()) +} + +#[cfg(test)] +#[path = "lsp_adapter_tests.rs"] +mod tests; diff --git a/crates/omc-shared/src/lsp_adapter/session.rs b/crates/omc-shared/src/lsp_adapter/session.rs new file mode 100644 index 0000000..86a10b3 --- /dev/null +++ b/crates/omc-shared/src/lsp_adapter/session.rs @@ -0,0 +1,209 @@ +use super::*; + +use crate::session_pool::{BoundedSessionPool, SessionPoolError}; + +/// Process-local project pool used by long-running consumers such as MCP. +pub struct LspProjectPool { + sessions: Mutex>, +} +struct LspSession { + child: Child, + stdin: ChildStdin, + responses: Receiver>, + next_request_id: u64, + document_versions: std::collections::HashMap, +} + +impl LspSession { + fn open(root: &Path, timeout: Duration) -> Result { + let mut child = spawn_lsp(root)?; + let stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => return terminate_open(&mut child, "rust-analyzer stdin was not available"), + }; + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => return terminate_open(&mut child, "rust-analyzer stdout was not available"), + }; + let responses = spawn_reader(stdout); + let mut session = Self { + child, + stdin, + responses, + next_request_id: 1, + document_versions: std::collections::HashMap::new(), + }; + if let Err(error) = initialize_lsp(&mut session.stdin, &session.responses, root, timeout) { + drop(session); + return Err(error); + } + session.next_request_id = 2; + Ok(session) + } + + fn document_symbols( + &mut self, + uri: &str, + contents: &str, + timeout: Duration, + ) -> Result { + let version = self.document_versions.entry(uri.to_string()).or_insert(0); + *version += 1; + if *version == 1 { + write_notification( + &mut self.stdin, + "textDocument/didOpen", + json!({ + "textDocument": {"uri": uri, "languageId": "rust", "version": version, "text": contents} + }), + )?; + } else { + write_notification( + &mut self.stdin, + "textDocument/didChange", + json!({ + "textDocument": {"uri": uri, "version": version}, + "contentChanges": [{"text": contents}] + }), + )?; + } + let request_id = self.next_request_id; + self.next_request_id += 1; + write_request( + &mut self.stdin, + request_id, + "textDocument/documentSymbol", + json!({"textDocument": {"uri": uri}}), + )?; + receive_response(&self.responses, request_id, timeout) + } +} + +fn terminate_open(child: &mut Child, message: &str) -> Result { + let _ = child.kill(); + let _ = child.wait(); + Err(ToolError::new(error_codes::UPSTREAM_FAILED, message)) +} + +impl Drop for LspSession { + fn drop(&mut self) { + let request_id = self.next_request_id; + let _ = write_request(&mut self.stdin, request_id, "shutdown", Value::Null); + let _ = receive_response(&self.responses, request_id, Duration::from_millis(500)); + let _ = write_notification(&mut self.stdin, "exit", Value::Null); + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn query_with_pool( + sessions: &Mutex>, + request: &LspDocumentSymbolsRequest, +) -> Result { + let timeout = validate_timeout(request.timeout_ms)?; + let (root, file, relative_file) = resolve_source_file(request)?; + let contents = fs::read_to_string(&file).map_err(|error| { + ToolError::new( + error_codes::INVALID_REQUEST, + format!("cannot read Rust source file: {error}"), + ) + })?; + let uri = path_to_file_uri(&file); + let mut pool = sessions.lock().map_err(|_| { + ToolError::new( + error_codes::UPSTREAM_FAILED, + "LSP project session pool was poisoned", + ) + })?; + let (result, server_process_id, reused) = { + let (session, reused) = pool + .get_or_try_insert_with_status(root.clone(), || LspSession::open(&root, timeout)) + .map_err(|error| match error { + SessionPoolError::Capacity { limit } => ToolError::new( + error_codes::UPSTREAM_FAILED, + format!("LSP project session capacity reached ({limit})"), + ), + SessionPoolError::Open(error) => error, + })?; + ( + session.document_symbols(&uri, &contents, timeout), + session.child.id(), + reused, + ) + }; + let result = match result { + Ok(result) => result, + Err(error) => { + pool.remove(&root); + return Err(error); + } + }; + Ok(LspDocumentSymbolsPayload { + operation: "lsp.document_symbols".into(), + server: RUST_ANALYZER_COMMAND.into(), + working_directory: root.to_string_lossy().into_owned(), + file: relative_file, + uri, + result, + server_process_id, + session_reused: reused, + side_effects: Vec::new(), + }) +} + +fn spawn_lsp(root: &Path) -> Result { + Command::new(RUST_ANALYZER_COMMAND) + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + ToolError::new( + error_codes::ADAPTER_UNAVAILABLE, + "rust-analyzer is not available on PATH", + ) + } else { + ToolError::new( + error_codes::UPSTREAM_FAILED, + format!("failed to start rust-analyzer: {error}"), + ) + } + }) +} + +impl Default for LspProjectPool { + fn default() -> Self { + Self::new() + } +} + +impl LspProjectPool { + pub fn new() -> Self { + Self { + sessions: Mutex::new(BoundedSessionPool::new( + MAX_PROJECT_SESSIONS, + PROJECT_SESSION_TTL, + )), + } + } + + pub fn query_document_symbols( + &self, + request: &LspDocumentSymbolsRequest, + ) -> Result { + query_with_pool(&self.sessions, request) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_pool_starts_without_processes() { + let pool = LspProjectPool::new(); + assert!(pool.sessions.lock().unwrap().is_empty()); + } +} diff --git a/crates/omc-shared/src/lsp_adapter/transport.rs b/crates/omc-shared/src/lsp_adapter/transport.rs new file mode 100644 index 0000000..6987179 --- /dev/null +++ b/crates/omc-shared/src/lsp_adapter/transport.rs @@ -0,0 +1,216 @@ +use super::*; + +pub(super) fn write_request( + stdin: &mut ChildStdin, + id: u64, + method: &str, + params: Value, +) -> Result<(), ToolError> { + write_message( + stdin, + &json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}), + ) +} + +pub(super) fn write_notification( + stdin: &mut ChildStdin, + method: &str, + params: Value, +) -> Result<(), ToolError> { + write_message( + stdin, + &json!({"jsonrpc": "2.0", "method": method, "params": params}), + ) +} + +fn write_message(stdin: &mut ChildStdin, message: &Value) -> Result<(), ToolError> { + let body = serde_json::to_vec(message).map_err(|error| { + ToolError::new( + error_codes::UPSTREAM_CONTRACT_INVALID, + format!("cannot encode LSP request: {error}"), + ) + })?; + write!(stdin, "Content-Length: {}\r\n\r\n", body.len()).map_err(|error| { + ToolError::new( + error_codes::UPSTREAM_FAILED, + format!("cannot write LSP headers: {error}"), + ) + })?; + stdin.write_all(&body).map_err(|error| { + ToolError::new( + error_codes::UPSTREAM_FAILED, + format!("cannot write LSP body: {error}"), + ) + })?; + stdin.flush().map_err(|error| { + ToolError::new( + error_codes::UPSTREAM_FAILED, + format!("cannot flush LSP request: {error}"), + ) + }) +} + +pub(super) fn receive_response( + responses: &Receiver>, + expected_id: u64, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(ToolError::new( + error_codes::UPSTREAM_FAILED, + format!("rust-analyzer timed out waiting for response id {expected_id}"), + )); + } + let message = responses + .recv_timeout(remaining) + .map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => ToolError::new( + error_codes::UPSTREAM_FAILED, + format!("rust-analyzer timed out waiting for response id {expected_id}"), + ), + mpsc::RecvTimeoutError::Disconnected => ToolError::new( + error_codes::UPSTREAM_FAILED, + "rust-analyzer closed its output before responding", + ), + })?; + let message = message.map_err(map_transport_error)?; + if message.get("id") != Some(&json!(expected_id)) { + continue; + } + if let Some(error) = message.get("error") { + return Err(ToolError::new( + error_codes::UPSTREAM_FAILED, + format!("rust-analyzer returned an error: {error}"), + )); + } + return message.get("result").cloned().ok_or_else(|| { + ToolError::new( + error_codes::UPSTREAM_CONTRACT_INVALID, + "rust-analyzer response omitted result", + ) + }); + } +} + +pub(super) fn spawn_reader( + stdout: impl Read + Send + 'static, +) -> Receiver> { + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + match read_message(&mut reader) { + Ok(Some(message)) => { + if sender.send(Ok(message)).is_err() { + break; + } + } + Ok(None) => break, + Err(error) => { + let _ = sender.send(Err(error)); + break; + } + } + } + }); + receiver +} + +pub(super) fn read_message(reader: &mut impl BufRead) -> Result, LspTransportError> { + let mut content_length = None; + loop { + let mut line = String::new(); + let bytes = reader.read_line(&mut line).map_err(LspTransportError::Io)?; + if bytes == 0 { + return Ok(None); + } + if line == "\r\n" || line == "\n" { + break; + } + let Some((name, value)) = line.split_once(':') else { + return Err(LspTransportError::InvalidHeader(line.trim().into())); + }; + if name.eq_ignore_ascii_case("Content-Length") { + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| LspTransportError::InvalidHeader(line.trim().into()))?, + ); + } + } + let length = content_length.ok_or(LspTransportError::MissingContentLength)?; + if length > MAX_MESSAGE_BYTES { + return Err(LspTransportError::ResponseTooLarge(length)); + } + let mut body = vec![0; length]; + reader + .read_exact(&mut body) + .map_err(LspTransportError::Io)?; + serde_json::from_slice(&body).map_err(LspTransportError::Json) +} + +fn map_transport_error(error: LspTransportError) -> ToolError { + match error { + LspTransportError::ResponseTooLarge(length) => ToolError::new( + error_codes::UPSTREAM_RESPONSE_TOO_LARGE, + format!("LSP response exceeds {MAX_MESSAGE_BYTES} bytes: {length}"), + ), + other => ToolError::new(error_codes::UPSTREAM_FAILED, other.to_string()), + } +} + +#[derive(Debug)] +pub(super) enum LspTransportError { + Io(io::Error), + Json(serde_json::Error), + InvalidHeader(String), + MissingContentLength, + ResponseTooLarge(usize), +} + +impl fmt::Display for LspTransportError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(formatter, "LSP I/O error: {error}"), + Self::Json(error) => write!(formatter, "invalid LSP JSON response: {error}"), + Self::InvalidHeader(header) => write!(formatter, "invalid LSP header: {header}"), + Self::MissingContentLength => { + formatter.write_str("LSP response omitted Content-Length") + } + Self::ResponseTooLarge(length) => write!(formatter, "LSP response too large: {length}"), + } + } +} + +pub(super) fn path_to_file_uri(path: &Path) -> String { + let normalized = path.to_string_lossy().replace('\\', "/"); + let normalized = normalized.strip_prefix("//?/").unwrap_or(&normalized); + let encoded = percent_encode_path(normalized); + // A Windows path can arrive while the consumer is running on another OS + // (for example, contract tests or a remote host). Choose URI shape from + // the path syntax instead of the build target. + let bytes = normalized.as_bytes(); + let has_windows_drive = + bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/'; + if has_windows_drive { + format!("file:///{encoded}") + } else { + format!("file://{encoded}") + } +} + +fn percent_encode_path(path: &str) -> String { + let mut encoded = String::with_capacity(path.len()); + for byte in path.as_bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'/' | b':') { + encoded.push(*byte as char); + } else { + let _ = write!(encoded, "%{byte:02X}"); + } + } + encoded +} diff --git a/crates/omc-shared/src/lsp_adapter_tests.rs b/crates/omc-shared/src/lsp_adapter_tests.rs new file mode 100644 index 0000000..99e751a --- /dev/null +++ b/crates/omc-shared/src/lsp_adapter_tests.rs @@ -0,0 +1,35 @@ +use super::*; + +#[test] +fn timeout_has_a_bounded_contract() { + let error = validate_timeout(Some(MIN_TIMEOUT_MS - 1)).unwrap_err(); + assert_eq!(error.code, error_codes::INVALID_REQUEST); + assert!(validate_timeout(Some(MIN_TIMEOUT_MS)).is_ok()); + assert!(validate_timeout(Some(MAX_TIMEOUT_MS)).is_ok()); + assert!(validate_timeout(Some(MAX_TIMEOUT_MS + 1)).is_err()); +} + +#[test] +fn file_uri_preserves_windows_drive_and_escapes_spaces() { + let uri = path_to_file_uri(Path::new(r"C:\work dir\src\lib.rs")); + assert_eq!(uri, "file:///C:/work%20dir/src/lib.rs"); +} + +#[test] +fn file_uri_removes_windows_verbatim_prefix() { + let uri = path_to_file_uri(Path::new(r"\\?\D:\work\src\lib.rs")); + assert_eq!(uri, "file:///D:/work/src/lib.rs"); +} + +#[test] +fn file_uri_preserves_unix_absolute_path_shape() { + let uri = path_to_file_uri(Path::new("/work dir/src/lib.rs")); + assert_eq!(uri, "file:///work%20dir/src/lib.rs"); +} + +#[test] +fn oversized_lsp_message_is_rejected_before_allocation() { + let input = format!("Content-Length: {}\r\n\r\n", MAX_MESSAGE_BYTES + 1); + let error = read_message(&mut input.as_bytes()).unwrap_err(); + assert!(matches!(error, LspTransportError::ResponseTooLarge(_))); +} diff --git a/crates/omc-shared/src/operation_contract.rs b/crates/omc-shared/src/operation_contract.rs new file mode 100644 index 0000000..e6958b5 --- /dev/null +++ b/crates/omc-shared/src/operation_contract.rs @@ -0,0 +1,389 @@ +//! Stable operation, task, event, error, and artifact contracts. +//! +//! These types describe control-plane semantics only. They do not own an +//! Agent loop, a provider call, or a domain tool implementation. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::agent_tool::ToolError; + +pub const TASK_SCHEMA_VERSION: &str = "omc.task.v1"; +pub const EVENT_SCHEMA_VERSION: &str = "omc.event.v1"; +pub const ARTIFACT_REF_SCHEMA_VERSION: &str = "omc.artifact-ref.v1"; +pub const ARTIFACT_URI_PREFIX: &str = "omc://artifact/sha256/"; +pub const SUBAGENT_RESULT_SCHEMA_VERSION: &str = "omc.subagent-result.v1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TaskState { + Queued, + Dispatched, + Acknowledged, + Running, + Succeeded, + Failed, + CancelRequested, + Cancelled, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TaskRequest { + pub schema_version: String, + pub task_id: String, + pub correlation_id: String, + pub subject: String, + #[serde(default)] + pub input: Value, + #[serde(default)] + pub requested_capabilities: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_task_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, +} + +impl TaskRequest { + pub fn new( + task_id: impl Into, + correlation_id: impl Into, + subject: impl Into, + ) -> Self { + Self { + schema_version: TASK_SCHEMA_VERSION.into(), + task_id: task_id.into(), + correlation_id: correlation_id.into(), + subject: subject.into(), + input: Value::Null, + requested_capabilities: Vec::new(), + parent_task_id: None, + idempotency_key: None, + } + } + + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != TASK_SCHEMA_VERSION { + return Err("task request schema version is not supported".into()); + } + if self.task_id.trim().is_empty() || self.correlation_id.trim().is_empty() { + return Err("task_id and correlation_id must not be empty".into()); + } + if self.subject.trim().is_empty() { + return Err("task subject must not be empty".into()); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TaskStatus { + pub schema_version: String, + pub task_id: String, + pub correlation_id: String, + pub state: TaskState, + pub observed_at: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifact_refs: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// A host-neutral, schema-checked result emitted by a subagent. +/// +/// OMC owns the envelope and validation boundary, while the producer owns the +/// named result type and payload fields. This keeps result transport stable +/// without copying a provider or agent runtime into OMC. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TypedSubagentResult { + pub schema_version: String, + pub result_type: String, + pub payload: Value, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifact_refs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResultSchema { + pub schema_id: String, + pub required_fields: Vec, +} + +impl ResultSchema { + pub fn new(schema_id: impl Into, required_fields: Vec) -> Self { + Self { + schema_id: schema_id.into(), + required_fields, + } + } + + fn validate(&self) -> Result<(), String> { + if !valid_result_type(&self.schema_id) { + return Err("result schema_id must be a non-empty portable identifier".into()); + } + if self.required_fields.iter().any(|field| { + field.trim().is_empty() + || field.contains('.') + || self + .required_fields + .iter() + .filter(|item| *item == field) + .count() + > 1 + }) { + return Err("result required fields must be unique, non-empty top-level names".into()); + } + Ok(()) + } +} + +impl TypedSubagentResult { + pub fn new( + result_type: impl Into, + payload: Value, + artifact_refs: Vec, + ) -> Result { + let result = Self { + schema_version: SUBAGENT_RESULT_SCHEMA_VERSION.into(), + result_type: result_type.into(), + payload, + artifact_refs, + }; + result.validate_envelope()?; + Ok(result) + } + + pub fn validate_envelope(&self) -> Result<(), String> { + if self.schema_version != SUBAGENT_RESULT_SCHEMA_VERSION { + return Err("subagent result schema version is not supported".into()); + } + if !valid_result_type(&self.result_type) { + return Err("subagent result_type must be a non-empty portable identifier".into()); + } + if !self.payload.is_object() { + return Err("subagent result payload must be a JSON object".into()); + } + for reference in &self.artifact_refs { + reference.validate()?; + } + Ok(()) + } + + pub fn validate_against(&self, schema: &ResultSchema) -> Result<(), String> { + self.validate_envelope()?; + schema.validate()?; + if self.result_type != schema.schema_id { + return Err("subagent result_type does not match the requested result schema".into()); + } + let Some(object) = self.payload.as_object() else { + return Err("subagent result payload must be a JSON object".into()); + }; + if let Some(field) = schema + .required_fields + .iter() + .find(|field| !object.contains_key(*field)) + { + return Err(format!( + "subagent result payload is missing required field {field}" + )); + } + Ok(()) + } +} + +fn valid_result_type(value: &str) -> bool { + !value.trim().is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'/' | b'-' | b':') + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct OperationEvent { + pub schema_version: String, + pub event_id: String, + pub event_type: String, + pub correlation_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub task_id: Option, + pub occurred_at: String, + pub payload: Value, +} + +impl OperationEvent { + pub fn new( + event_id: impl Into, + event_type: impl Into, + correlation_id: impl Into, + occurred_at: impl Into, + payload: Value, + ) -> Self { + Self { + schema_version: EVENT_SCHEMA_VERSION.into(), + event_id: event_id.into(), + event_type: event_type.into(), + correlation_id: correlation_id.into(), + task_id: None, + occurred_at: occurred_at.into(), + payload, + } + } + + pub fn from_agent_event( + event_id: impl Into, + correlation_id: impl Into, + task_id: Option, + occurred_at: impl Into, + event: &crate::events::AgentEvent, + ) -> Result { + let payload = serde_json::to_value(event)?; + let event_type = payload + .get("type") + .and_then(Value::as_str) + .unwrap_or("agent.event") + .to_string(); + Ok(Self { + schema_version: EVENT_SCHEMA_VERSION.into(), + event_id: event_id.into(), + event_type, + correlation_id: correlation_id.into(), + task_id, + occurred_at: occurred_at.into(), + payload, + }) + } + + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != EVENT_SCHEMA_VERSION { + return Err("operation event schema version is not supported".into()); + } + if self.event_id.trim().is_empty() + || self.event_type.trim().is_empty() + || self.correlation_id.trim().is_empty() + || self.occurred_at.trim().is_empty() + { + return Err("operation event identity and timestamp fields must not be empty".into()); + } + Ok(()) + } +} + +/// OMC's normalized view of a producer-owned artifact reference. +/// +/// The original producer payload remains available to the adapter when +/// needed; this shape gives consumers stable names and provenance fields. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactRef { + pub schema_version: String, + pub producer: String, + pub artifact_schema: String, + pub artifact_type: String, + pub path: String, + pub sha256: String, + pub consumed_snapshot_identity: String, + /// Canonical content-addressed URI. Optional for legacy persisted refs; + /// normalized producer output includes it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uri: Option, +} + +impl ArtifactRef { + pub fn from_code_intel(value: &Value) -> Result { + let reference = Self { + schema_version: ARTIFACT_REF_SCHEMA_VERSION.into(), + producer: "code-intel-pipeline".into(), + artifact_schema: required_string(value, "artifactSchema")?, + artifact_type: required_string(value, "type")?, + path: required_string(value, "path")?, + sha256: required_string(value, "sha256")?, + consumed_snapshot_identity: required_string(value, "consumedSnapshotIdentity")?, + uri: value + .get("uri") + .and_then(Value::as_str) + .map(ToString::to_string), + }; + reference.validate()?; + Ok(reference.with_canonical_uri()) + } + + /// Return the stable URI used by OMC consumers to address this artifact. + pub fn canonical_uri(&self) -> String { + format!("{ARTIFACT_URI_PREFIX}{}", self.sha256) + } + + /// Fill the additive URI field while preserving all existing provenance. + pub fn with_canonical_uri(mut self) -> Self { + self.uri = Some(self.canonical_uri()); + self + } + + /// Extract and validate a digest from an OMC artifact URI. + pub fn digest_from_uri(uri: &str) -> Result { + let digest = uri + .strip_prefix(ARTIFACT_URI_PREFIX) + .filter(|value| !value.contains('/') && !value.is_empty()) + .ok_or_else(|| "artifact URI must use omc://artifact/sha256/".to_string())?; + if !is_sha256_digest(digest) { + return Err("artifact URI digest must be a 64-character hexadecimal value".into()); + } + Ok(digest.to_string()) + } + + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != ARTIFACT_REF_SCHEMA_VERSION { + return Err("artifact ref schema version is not supported".into()); + } + if self.producer.is_empty() + || self.artifact_schema.is_empty() + || self.artifact_type.is_empty() + || self.consumed_snapshot_identity.is_empty() + { + return Err("artifact ref contains an empty provenance field".into()); + } + if self.path.is_empty() + || self.path.starts_with('/') + || self.path.starts_with('\\') + || self.path.contains('\\') + || self + .path + .split('/') + .any(|component| component.is_empty() || component == "." || component == "..") + { + return Err("artifact ref path must be a portable relative path".into()); + } + if !is_sha256_digest(&self.sha256) { + return Err("artifact ref sha256 must be a 64-character hexadecimal digest".into()); + } + if let Some(uri) = &self.uri { + let digest = Self::digest_from_uri(uri)?; + if digest != self.sha256 || uri != &self.canonical_uri() { + return Err("artifact ref URI must match its SHA-256 digest".into()); + } + } + Ok(()) + } +} + +fn is_sha256_digest(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn required_string(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_str) + .filter(|item| !item.is_empty()) + .map(ToString::to_string) + .ok_or_else(|| format!("artifact ref field {field} must be a non-empty string")) +} + +#[cfg(test)] +#[path = "operation_contract_tests.rs"] +mod tests; diff --git a/crates/omc-shared/src/operation_contract_tests.rs b/crates/omc-shared/src/operation_contract_tests.rs new file mode 100644 index 0000000..9270b10 --- /dev/null +++ b/crates/omc-shared/src/operation_contract_tests.rs @@ -0,0 +1,128 @@ +use super::*; + +#[test] +fn task_and_event_contracts_use_stable_versions() { + let request = TaskRequest::new("task-1", "corr-1", "inspect repository"); + let event = OperationEvent::new( + "event-1", + "task.queued", + "corr-1", + "2026-08-12T00:00:00Z", + serde_json::json!({"state":"queued"}), + ); + assert_eq!(request.schema_version, TASK_SCHEMA_VERSION); + assert!(request.validate().is_ok()); + assert_eq!(event.schema_version, EVENT_SCHEMA_VERSION); + assert!(event.validate().is_ok()); + assert_eq!( + serde_json::to_value(event).unwrap()["eventType"], + "task.queued" + ); +} + +#[test] +fn agent_event_is_wrapped_with_correlation_and_task_identity() { + let event = crate::events::AgentEvent::Progress { + message: "halfway".into(), + percent: Some(0.5), + }; + let wrapped = OperationEvent::from_agent_event( + "event-2", + "corr-2", + Some("task-2".into()), + "2026-08-12T00:00:00Z", + &event, + ) + .unwrap(); + assert_eq!(wrapped.event_type, "progress"); + assert_eq!(wrapped.task_id.as_deref(), Some("task-2")); +} + +#[test] +fn code_intel_artifact_ref_is_normalized_and_validated() { + let reference = ArtifactRef::from_code_intel(&serde_json::json!({ + "artifactSchema": "agent-code-slice-ranking.v1", + "type": "code_evidence.agent_slice", + "path": "objects/sha256/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "consumedSnapshotIdentity": "snapshot-1" + })) + .unwrap(); + assert_eq!(reference.schema_version, ARTIFACT_REF_SCHEMA_VERSION); + assert_eq!(reference.producer, "code-intel-pipeline"); +} + +#[test] +fn artifact_ref_rejects_path_traversal_and_bad_digest() { + let mut reference = ArtifactRef { + schema_version: ARTIFACT_REF_SCHEMA_VERSION.into(), + producer: "code-intel-pipeline".into(), + artifact_schema: "schema".into(), + artifact_type: "type".into(), + path: "objects/../secret".into(), + sha256: "bad".into(), + consumed_snapshot_identity: "snapshot".into(), + uri: None, + }; + assert!(reference.validate().is_err()); + reference.path = "objects/sha256/a".into(); + reference.sha256 = "a".repeat(64); + assert!(reference.validate().is_ok()); +} + +#[test] +fn code_intel_ref_has_canonical_uri_and_rejects_mismatch() { + let digest = "a".repeat(64); + let reference = ArtifactRef::from_code_intel(&serde_json::json!({ + "artifactSchema": "schema", + "type": "type", + "path": format!("objects/sha256/{digest}"), + "sha256": digest, + "consumedSnapshotIdentity": "snapshot" + })) + .unwrap(); + assert_eq!( + reference.uri.as_deref(), + Some( + "omc://artifact/sha256/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ) + ); + assert_eq!( + ArtifactRef::digest_from_uri(reference.uri.as_deref().unwrap()).unwrap(), + "a".repeat(64) + ); + + let mut mismatched = reference; + mismatched.uri = Some(format!("{ARTIFACT_URI_PREFIX}{}", "b".repeat(64))); + assert!(mismatched.validate().is_err()); + + let mut upstream_mismatch = serde_json::json!({ + "artifactSchema": "schema", + "type": "type", + "path": format!("objects/sha256/{}", "a".repeat(64)), + "sha256": "a".repeat(64), + "consumedSnapshotIdentity": "snapshot", + }); + upstream_mismatch["uri"] = + serde_json::Value::String(format!("{ARTIFACT_URI_PREFIX}{}", "b".repeat(64))); + assert!(ArtifactRef::from_code_intel(&upstream_mismatch).is_err()); +} + +#[test] +fn legacy_artifact_ref_can_be_normalized_without_losing_provenance() { + let reference = ArtifactRef { + schema_version: ARTIFACT_REF_SCHEMA_VERSION.into(), + producer: "producer".into(), + artifact_schema: "schema".into(), + artifact_type: "type".into(), + path: "objects/sha256/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .into(), + sha256: "a".repeat(64), + consumed_snapshot_identity: "snapshot".into(), + uri: None, + } + .with_canonical_uri(); + assert!(reference.validate().is_ok()); + assert_eq!(reference.producer, "producer"); + assert!(reference.uri.is_some()); +} diff --git a/crates/omc-shared/src/session_pool.rs b/crates/omc-shared/src/session_pool.rs new file mode 100644 index 0000000..925bb50 --- /dev/null +++ b/crates/omc-shared/src/session_pool.rs @@ -0,0 +1,98 @@ +//! Small, transport-neutral bounded lifecycle store for process sessions. + +use std::collections::{HashMap, hash_map::Entry as HashEntry}; +use std::hash::Hash; +use std::time::{Duration, Instant}; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SessionPoolError { + #[error("session capacity reached ({limit})")] + Capacity { limit: usize }, + #[error("failed to open session")] + Open(E), +} + +struct Entry { + value: V, + last_used: Instant, +} + +/// In-process storage with reuse, a hard capacity, explicit close, and idle TTL. +pub struct BoundedSessionPool { + entries: HashMap>, + capacity: usize, + idle_ttl: Duration, +} + +impl BoundedSessionPool { + pub fn new(capacity: usize, idle_ttl: Duration) -> Self { + Self { + entries: HashMap::new(), + capacity, + idle_ttl, + } + } + + pub fn get_or_try_insert_with( + &mut self, + key: K, + open: impl FnOnce() -> Result, + ) -> Result<&mut V, SessionPoolError> { + self.get_or_try_insert_with_status(key, open) + .map(|(value, _)| value) + } + + /// Get or open a session and report whether the returned value was reused. + pub fn get_or_try_insert_with_status( + &mut self, + key: K, + open: impl FnOnce() -> Result, + ) -> Result<(&mut V, bool), SessionPoolError> { + self.reap_idle(); + let at_capacity = self.entries.len() >= self.capacity; + match self.entries.entry(key) { + HashEntry::Occupied(mut occupied) => { + occupied.get_mut().last_used = Instant::now(); + Ok((&mut occupied.into_mut().value, true)) + } + HashEntry::Vacant(vacant) => { + if at_capacity { + return Err(SessionPoolError::Capacity { + limit: self.capacity, + }); + } + let value = open().map_err(SessionPoolError::Open)?; + Ok(( + &mut vacant + .insert(Entry { + value, + last_used: Instant::now(), + }) + .value, + false, + )) + } + } + } + + pub fn remove(&mut self, key: &K) -> Option { + self.entries.remove(key).map(|entry| entry.value) + } + + pub fn reap_idle(&mut self) -> usize { + let before = self.entries.len(); + self.entries + .retain(|_, entry| entry.last_used.elapsed() < self.idle_ttl); + before - self.entries.len() + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} diff --git a/crates/omc-shared/src/state/goal_ledger.rs b/crates/omc-shared/src/state/goal_ledger.rs new file mode 100644 index 0000000..87a83d2 --- /dev/null +++ b/crates/omc-shared/src/state/goal_ledger.rs @@ -0,0 +1,163 @@ +//! Project-scoped durable goal ledger. +//! +//! Goal files live under the project OMC state directory. Writes use the same +//! temporary-file plus rename pattern as the existing state writer. + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::StateError; +use crate::config::OmcPaths; +use crate::goal_contract::GoalRecord; + +#[derive(Debug, Clone)] +pub struct GoalLedger { + paths: OmcPaths, +} + +impl GoalLedger { + pub fn new(paths: OmcPaths) -> Self { + Self { paths } + } + + pub fn paths(&self) -> &OmcPaths { + &self.paths + } + + pub fn create(&self, goal: &GoalRecord) -> Result<(), StateError> { + goal.validate().map_err(StateError::Invalid)?; + let path = self.goal_path(&goal.goal_id)?; + if path.exists() { + return Err(StateError::Invalid(format!( + "goal already exists: {}", + goal.goal_id + ))); + } + self.write(&path, goal) + } + + pub fn load(&self, goal_id: &str) -> Result { + let path = self.goal_path(goal_id)?; + if !path.exists() { + return Err(StateError::NotFound(goal_id.to_string())); + } + let content = fs::read_to_string(&path)?; + let goal: GoalRecord = serde_json::from_str(&content)?; + goal.validate().map_err(StateError::Invalid)?; + Ok(goal) + } + + pub fn save(&self, goal: &GoalRecord) -> Result<(), StateError> { + goal.validate().map_err(StateError::Invalid)?; + let path = self.goal_path(&goal.goal_id)?; + if !path.exists() { + return Err(StateError::NotFound(goal.goal_id.clone())); + } + self.write(&path, goal) + } + + pub fn list(&self) -> Result, StateError> { + if !self.paths.goals.exists() { + return Ok(Vec::new()); + } + + let mut goals = Vec::new(); + for entry in fs::read_dir(&self.paths.goals)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let content = fs::read_to_string(&path)?; + let goal: GoalRecord = serde_json::from_str(&content)?; + goal.validate().map_err(StateError::Invalid)?; + goals.push(goal); + } + goals.sort_by(|left, right| left.goal_id.cmp(&right.goal_id)); + Ok(goals) + } + + fn goal_path(&self, goal_id: &str) -> Result { + validate_identifier(goal_id)?; + Ok(self.paths.goals.join(format!("{goal_id}.json"))) + } + + fn write(&self, path: &Path, goal: &GoalRecord) -> Result<(), StateError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("json.tmp"); + let content = serde_json::to_vec_pretty(goal)?; + fs::write(&tmp, content)?; + match fs::rename(&tmp, path) { + Ok(()) => Ok(()), + Err(error) => { + let _ = fs::remove_file(&tmp); + Err(StateError::Io(error)) + } + } + } +} + +fn validate_identifier(value: &str) -> Result<(), StateError> { + if value.is_empty() + || value.len() > 128 + || value == "." + || value == ".." + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(StateError::Invalid(format!( + "invalid goal identifier: {value}" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::goal_contract::{GoalRecord, GoalStatus}; + use tempfile::TempDir; + + fn ledger(tmp: &TempDir) -> GoalLedger { + GoalLedger::new(OmcPaths::new_with_root(tmp.path().join(".omc"))) + } + + #[test] + fn create_load_update_and_list_use_project_state() { + let tmp = TempDir::new().unwrap(); + let ledger = ledger(&tmp); + let mut goal = GoalRecord::new("goal-1", "ship the adapter", "t0"); + + ledger.create(&goal).unwrap(); + goal.start("t1").unwrap(); + ledger.save(&goal).unwrap(); + + let loaded = ledger.load("goal-1").unwrap(); + assert_eq!(loaded.status, GoalStatus::Active); + assert_eq!(ledger.list().unwrap().len(), 1); + assert!(tmp.path().join(".omc/state/goals/goal-1.json").exists()); + assert!(!tmp.path().join(".omc/state/goals/goal-1.json.tmp").exists()); + } + + #[test] + fn duplicate_and_traversal_ids_are_rejected() { + let tmp = TempDir::new().unwrap(); + let ledger = ledger(&tmp); + let goal = GoalRecord::new("goal-1", "ship the adapter", "t0"); + ledger.create(&goal).unwrap(); + assert!(ledger.create(&goal).is_err()); + assert!(ledger.load("../escape").is_err()); + } + + #[test] + fn corrupt_goal_is_not_silently_ignored() { + let tmp = TempDir::new().unwrap(); + let ledger = ledger(&tmp); + fs::create_dir_all(&ledger.paths.goals).unwrap(); + fs::write(ledger.paths.goals.join("bad.json"), "not-json").unwrap(); + assert!(ledger.list().is_err()); + } +} diff --git a/crates/omc-shared/src/state/mod.rs b/crates/omc-shared/src/state/mod.rs index efc7aa2..cbe704a 100644 --- a/crates/omc-shared/src/state/mod.rs +++ b/crates/omc-shared/src/state/mod.rs @@ -4,9 +4,11 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; +pub mod goal_ledger; pub mod reader; pub mod writer; +pub use goal_ledger::GoalLedger; pub use reader::StateReader; pub use writer::StateWriter; diff --git a/crates/omc-shared/src/team_contract.rs b/crates/omc-shared/src/team_contract.rs new file mode 100644 index 0000000..b33e832 --- /dev/null +++ b/crates/omc-shared/src/team_contract.rs @@ -0,0 +1,43 @@ +//! Stable read-only projection of the existing OMC team observability state. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const TEAM_OBSERVABILITY_SCHEMA_VERSION: &str = "omc.team-observability.v1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TeamObservabilityPayload { + pub schema_version: String, + pub operation: String, + pub view: String, + pub data: Value, +} + +impl TeamObservabilityPayload { + pub fn new(view: impl Into, data: Value) -> Self { + Self { + schema_version: TEAM_OBSERVABILITY_SCHEMA_VERSION.into(), + operation: "team.observability".into(), + view: view.into(), + data, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_has_stable_nested_schema() { + let value = serde_json::to_value(TeamObservabilityPayload::new( + "sessions", + serde_json::json!([]), + )) + .unwrap(); + assert_eq!(value["schemaVersion"], TEAM_OBSERVABILITY_SCHEMA_VERSION); + assert_eq!(value["operation"], "team.observability"); + assert_eq!(value["view"], "sessions"); + } +} diff --git a/crates/omc-shared/src/workflow_contract.rs b/crates/omc-shared/src/workflow_contract.rs new file mode 100644 index 0000000..8f6216b --- /dev/null +++ b/crates/omc-shared/src/workflow_contract.rs @@ -0,0 +1,293 @@ +//! Host-neutral decision contract for clarify/plan/execute/verify workflows. +//! +//! Hosts and the existing team runtime provide evidence; this module never +//! starts an agent or persists lifecycle state. + +use serde::{Deserialize, Serialize}; + +pub const WORKFLOW_SCHEMA_VERSION: &str = "omc.workflow.v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowStage { + Initializing, + Clarifying, + Planning, + Executing, + Verifying, + Fixing, + Paused, + Completed, + Failed, +} + +impl WorkflowStage { + pub fn as_str(self) -> &'static str { + match self { + Self::Initializing => "initializing", + Self::Clarifying => "clarifying", + Self::Planning => "planning", + Self::Executing => "executing", + Self::Verifying => "verifying", + Self::Fixing => "fixing", + Self::Paused => "paused", + Self::Completed => "completed", + Self::Failed => "failed", + } + } + + pub fn is_terminal(self) -> bool { + matches!(self, Self::Completed | Self::Failed) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowContext { + #[serde(default)] + pub requirements_clarified: bool, + #[serde(default)] + pub all_tasks_assigned: bool, + #[serde(default)] + pub plan_approved: bool, + #[serde(default)] + pub all_tasks_completed: bool, + #[serde(default)] + pub verification_passed: bool, + #[serde(default)] + pub has_failures: bool, + #[serde(default)] + pub has_blockers: bool, + #[serde(default)] + pub fix_attempts: u32, + #[serde(default = "default_max_fix_attempts")] + pub max_fix_attempts: u32, +} + +impl Default for WorkflowContext { + fn default() -> Self { + Self { + requirements_clarified: false, + all_tasks_assigned: false, + plan_approved: false, + all_tasks_completed: false, + verification_passed: false, + has_failures: false, + has_blockers: false, + fix_attempts: 0, + max_fix_attempts: default_max_fix_attempts(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowAdvanceRequest { + pub current_stage: WorkflowStage, + #[serde(flatten)] + pub context: WorkflowContext, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowDecision { + Advance, + Wait, + Terminal, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowAdvancePayload { + pub schema_version: String, + pub current_stage: WorkflowStage, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_stage: Option, + pub decision: WorkflowDecision, + pub reason: String, +} + +pub fn advance_workflow(request: &WorkflowAdvanceRequest) -> WorkflowAdvancePayload { + let next_stage = next_stage(request.current_stage, &request.context); + let decision = match next_stage { + Some(_) => WorkflowDecision::Advance, + None if request.current_stage.is_terminal() => WorkflowDecision::Terminal, + None => WorkflowDecision::Wait, + }; + let reason = match next_stage { + Some(stage) => format!("{} -> {}", request.current_stage.as_str(), stage.as_str()), + None if request.current_stage.is_terminal() => { + format!("{} is terminal", request.current_stage.as_str()) + } + None if request.context.has_blockers => "waiting for blockers to clear".into(), + None => format!( + "waiting for evidence to leave {}", + request.current_stage.as_str() + ), + }; + WorkflowAdvancePayload { + schema_version: WORKFLOW_SCHEMA_VERSION.into(), + current_stage: request.current_stage, + next_stage, + decision, + reason, + } +} + +fn next_stage(current: WorkflowStage, context: &WorkflowContext) -> Option { + if current.is_terminal() { + return None; + } + match current { + WorkflowStage::Initializing => { + if context.has_failures { + Some(WorkflowStage::Failed) + } else if context.requirements_clarified { + Some(WorkflowStage::Planning) + } else { + Some(WorkflowStage::Clarifying) + } + } + WorkflowStage::Clarifying => { + if context.has_failures { + Some(WorkflowStage::Failed) + } else if context.has_blockers { + Some(WorkflowStage::Paused) + } else if context.requirements_clarified { + Some(WorkflowStage::Planning) + } else { + None + } + } + WorkflowStage::Planning => { + if context.has_failures { + Some(WorkflowStage::Failed) + } else if context.has_blockers { + Some(WorkflowStage::Paused) + } else if context.all_tasks_assigned && context.plan_approved { + Some(WorkflowStage::Executing) + } else { + None + } + } + WorkflowStage::Executing => { + if context.has_failures { + if context.fix_attempts < context.max_fix_attempts { + Some(WorkflowStage::Fixing) + } else { + Some(WorkflowStage::Failed) + } + } else if context.has_blockers { + Some(WorkflowStage::Paused) + } else if context.all_tasks_completed { + Some(WorkflowStage::Verifying) + } else { + None + } + } + WorkflowStage::Verifying => { + if context.has_failures { + if context.fix_attempts < context.max_fix_attempts { + Some(WorkflowStage::Fixing) + } else { + Some(WorkflowStage::Failed) + } + } else if context.has_blockers { + Some(WorkflowStage::Paused) + } else if context.verification_passed { + Some(WorkflowStage::Completed) + } else { + Some(WorkflowStage::Fixing) + } + } + WorkflowStage::Fixing => { + if context.fix_attempts >= context.max_fix_attempts { + Some(WorkflowStage::Failed) + } else if context.has_blockers { + Some(WorkflowStage::Paused) + } else if context.all_tasks_completed { + Some(WorkflowStage::Verifying) + } else { + Some(WorkflowStage::Executing) + } + } + WorkflowStage::Paused => { + if context.has_blockers { + None + } else if context.requirements_clarified && !context.plan_approved { + Some(WorkflowStage::Planning) + } else { + Some(WorkflowStage::Executing) + } + } + WorkflowStage::Completed | WorkflowStage::Failed => None, + } +} + +fn default_max_fix_attempts() -> u32 { + 3 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn follows_clarify_plan_execute_verify_lifecycle() { + let mut request = WorkflowAdvanceRequest { + current_stage: WorkflowStage::Initializing, + context: WorkflowContext::default(), + }; + assert_eq!( + advance_workflow(&request).next_stage, + Some(WorkflowStage::Clarifying) + ); + request.current_stage = WorkflowStage::Clarifying; + request.context.requirements_clarified = true; + assert_eq!( + advance_workflow(&request).next_stage, + Some(WorkflowStage::Planning) + ); + request.current_stage = WorkflowStage::Planning; + request.context.all_tasks_assigned = true; + request.context.plan_approved = true; + assert_eq!( + advance_workflow(&request).next_stage, + Some(WorkflowStage::Executing) + ); + request.current_stage = WorkflowStage::Executing; + request.context.all_tasks_completed = true; + assert_eq!( + advance_workflow(&request).next_stage, + Some(WorkflowStage::Verifying) + ); + request.current_stage = WorkflowStage::Verifying; + request.context.verification_passed = true; + assert_eq!( + advance_workflow(&request).next_stage, + Some(WorkflowStage::Completed) + ); + } + + #[test] + fn failures_are_bounded_and_wait_does_not_fabricate_progress() { + let failed = WorkflowAdvanceRequest { + current_stage: WorkflowStage::Executing, + context: WorkflowContext { + has_failures: true, + fix_attempts: 3, + ..WorkflowContext::default() + }, + }; + assert_eq!( + advance_workflow(&failed).next_stage, + Some(WorkflowStage::Failed) + ); + let waiting = WorkflowAdvanceRequest { + current_stage: WorkflowStage::Planning, + context: WorkflowContext::default(), + }; + assert_eq!(advance_workflow(&waiting).decision, WorkflowDecision::Wait); + assert!(advance_workflow(&waiting).next_stage.is_none()); + } +} diff --git a/crates/omc-shared/tests/agent_tool_consumer.rs b/crates/omc-shared/tests/agent_tool_consumer.rs new file mode 100644 index 0000000..83766dc --- /dev/null +++ b/crates/omc-shared/tests/agent_tool_consumer.rs @@ -0,0 +1,182 @@ +//! Host-side contract fixtures. +//! +//! These tests intentionally deserialize into consumer-owned views instead of +//! OMC-RS types. Hermes, Sentinel, and other hosts should only depend on the +//! versioned JSON envelope and machine-readable error fields. + +use omc_shared::agent_tool::{CapabilitiesPayload, ToolError, ToolResponse, capabilities_payload}; +use omc_shared::lsp_adapter::LspDocumentSymbolsPayload; +use omc_shared::operation_contract::{ArtifactRef, ResultSchema, TypedSubagentResult}; +use omc_shared::workflow_contract::{WorkflowAdvancePayload, WorkflowDecision, WorkflowStage}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct HostResponse { + schema_version: String, + request_id: String, + ok: bool, + data: Option, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct HostError { + code: String, + message: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HostArtifactRef { + schema_version: String, + producer: String, + sha256: String, + uri: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HostLspPayload { + operation: String, + server: String, + file: String, + result: serde_json::Value, + side_effects: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HostWorkflowPayload { + schema_version: String, + current_stage: WorkflowStage, + next_stage: Option, + decision: WorkflowDecision, + reason: String, +} + +#[test] +fn host_consumes_success_without_omc_internal_types() { + let response = ToolResponse::success("hermes-smoke", capabilities_payload()); + let encoded = serde_json::to_value(response).expect("tool response is serializable"); + let host_view: HostResponse = serde_json::from_value(encoded).expect("host view parses"); + + assert_eq!(host_view.schema_version, "omc.tool.v1"); + assert_eq!(host_view.request_id, "hermes-smoke"); + assert!(host_view.ok); + assert!(host_view.error.is_none()); + + let data = host_view.data.expect("success response has data"); + assert_eq!(data["product"], "omc-rs"); + assert_eq!(data["protocol"], "omc.tool.v1"); + assert!(data["capabilities"].is_array()); +} + +#[test] +fn host_consumes_machine_readable_failure() { + let response: ToolResponse = ToolResponse::failure( + "sentinel-smoke", + ToolError::new("invalid_request", "repo is required"), + ); + let encoded = serde_json::to_value(response).expect("tool response is serializable"); + let host_view: HostResponse = serde_json::from_value(encoded).expect("host view parses"); + + assert_eq!(host_view.schema_version, "omc.tool.v1"); + assert_eq!(host_view.request_id, "sentinel-smoke"); + assert!(!host_view.ok); + assert!(host_view.data.is_none()); + + let error = host_view.error.expect("failure response has error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "repo is required"); +} + +#[test] +fn host_consumes_typed_result_without_omc_internal_types() { + let schema = ResultSchema::new("omc.agent.findings.v1", vec!["summary".into()]); + let result = TypedSubagentResult::new( + schema.schema_id.clone(), + serde_json::json!({"summary":"done"}), + Vec::new(), + ) + .expect("typed result envelope is valid"); + result + .validate_against(&schema) + .expect("consumer schema accepts result"); + let encoded = serde_json::to_value(result).expect("typed result is serializable"); + assert_eq!(encoded["schemaVersion"], "omc.subagent-result.v1"); + assert_eq!(encoded["resultType"], "omc.agent.findings.v1"); + assert_eq!(encoded["payload"]["summary"], "done"); +} + +#[test] +fn host_consumes_canonical_artifact_uri_without_omc_internal_types() { + let digest = "a".repeat(64); + let reference = ArtifactRef::from_code_intel(&serde_json::json!({ + "artifactSchema": "agent-code-slice-ranking.v1", + "type": "code_evidence.agent_slice", + "path": format!("objects/sha256/{digest}"), + "sha256": digest, + "consumedSnapshotIdentity": "snapshot-1" + })) + .expect("artifact reference is normalized"); + let encoded = serde_json::to_value(reference).expect("artifact reference is serializable"); + let host_view: HostArtifactRef = + serde_json::from_value(encoded).expect("host view parses canonical URI"); + + assert_eq!(host_view.schema_version, "omc.artifact-ref.v1"); + assert_eq!(host_view.producer, "code-intel-pipeline"); + assert_eq!(host_view.sha256, "a".repeat(64)); + assert_eq!( + host_view.uri, + "omc://artifact/sha256/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); +} + +#[test] +fn host_consumes_lsp_result_without_omc_internal_types() { + let payload = LspDocumentSymbolsPayload { + operation: "lsp.document_symbols".into(), + server: "rust-analyzer".into(), + working_directory: "C:/work/omc-rs".into(), + file: "src/lib.rs".into(), + uri: "file:///C:/work/omc-rs/src/lib.rs".into(), + result: serde_json::json!([{"name":"main","kind":12}]), + server_process_id: 42, + session_reused: true, + side_effects: Vec::new(), + }; + let encoded = serde_json::to_value(ToolResponse::success("hermes-lsp", payload)) + .expect("LSP response is serializable"); + let host_view: HostResponse = serde_json::from_value(encoded).expect("host view parses"); + let data: HostLspPayload = serde_json::from_value(host_view.data.expect("LSP data exists")) + .expect("host LSP view parses"); + + assert_eq!(data.operation, "lsp.document_symbols"); + assert_eq!(data.server, "rust-analyzer"); + assert_eq!(data.file, "src/lib.rs"); + assert_eq!(data.result[0]["name"], "main"); + assert!(data.side_effects.is_empty()); +} + +#[test] +fn host_consumes_workflow_result_without_runtime_types() { + let payload = WorkflowAdvancePayload { + schema_version: "omc.workflow.v1".into(), + current_stage: WorkflowStage::Planning, + next_stage: Some(WorkflowStage::Executing), + decision: WorkflowDecision::Advance, + reason: "planning -> executing".into(), + }; + let encoded = serde_json::to_value(ToolResponse::success("sentinel-workflow", payload)) + .expect("workflow response is serializable"); + let host_view: HostResponse = serde_json::from_value(encoded).expect("host view parses"); + let data: HostWorkflowPayload = + serde_json::from_value(host_view.data.expect("workflow data exists")) + .expect("host workflow view parses"); + + assert_eq!(data.schema_version, "omc.workflow.v1"); + assert_eq!(data.current_stage, WorkflowStage::Planning); + assert_eq!(data.next_stage, Some(WorkflowStage::Executing)); + assert_eq!(data.decision, WorkflowDecision::Advance); + assert_eq!(data.reason, "planning -> executing"); +} diff --git a/crates/omc-shared/tests/dap_debug_contract.rs b/crates/omc-shared/tests/dap_debug_contract.rs new file mode 100644 index 0000000..7628daf --- /dev/null +++ b/crates/omc-shared/tests/dap_debug_contract.rs @@ -0,0 +1,171 @@ +//! Real stdio DAP and host-consumer contract coverage. + +use std::fs; +use std::path::Path; + +use omc_shared::agent_tool::ToolResponse; +use omc_shared::dap_adapter::{DebugAction, DebugInspectRequest, DebugSessionMode, inspect_debug}; +use serde::Deserialize; +use serde_json::json; +use tempfile::tempdir; + +const DAP_FIXTURE: &str = r#" +import json +import subprocess +import sys + +def receive(): + content_length = None + while True: + line = sys.stdin.buffer.readline() + if not line: + return None + if line in (b"\r\n", b"\n"): + break + name, value = line.decode("ascii").split(":", 1) + if name.lower() == "content-length": + content_length = int(value.strip()) + if content_length is None: + raise RuntimeError("missing Content-Length") + return json.loads(sys.stdin.buffer.read(content_length).decode("utf-8")) + +sequence = 1 +target = None + +def send(message): + global sequence + if "seq" not in message: + message["seq"] = sequence + sequence += 1 + body = json.dumps(message, separators=(",", ":")).encode("utf-8") + sys.stdout.buffer.write(("Content-Length: %d\r\n\r\n" % len(body)).encode("ascii")) + sys.stdout.buffer.write(body) + sys.stdout.buffer.flush() + +while True: + request = receive() + if request is None: + break + command = request.get("command") + request_seq = request.get("seq") + if command == "initialize": + send({"type": "response", "request_seq": request_seq, "command": command, + "success": True, "body": {"capabilities": {}}}) + elif command in ("launch", "attach"): + arguments = request.get("arguments") or {} + target_command = arguments.get("targetCommand") + target_args = arguments.get("targetArgs") or [] + if target_command: + target = subprocess.Popen([target_command] + target_args, + cwd=arguments.get("targetCwd") or None, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + send({"type": "event", "event": "initialized"}) + send({"type": "event", "event": "output", + "body": {"category": "console", "output": "fixture target launched\n"}}) + send({"type": "response", "request_seq": request_seq, "command": command, + "success": True, "body": {}}) + elif command == "threads": + thread_id = target.pid if target is not None else 1 + send({"type": "response", "request_seq": request_seq, "command": command, + "success": True, "body": {"threads": [{"id": thread_id, "name": "fixture-target"}]}}) + elif command == "disconnect": + arguments = request.get("arguments") or {} + if target is not None and arguments.get("terminateDebuggee"): + target.terminate() + target.wait(timeout=2) + send({"type": "response", "request_seq": request_seq, "command": command, + "success": True, "body": {}}) + break + else: + send({"type": "response", "request_seq": request_seq, "command": command, + "success": True, "body": {}}) +"#; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HostDebugPayload { + schema_version: String, + operation: String, + action: DebugAction, + result: serde_json::Value, + observed_events: Vec, + side_effects: Vec, +} + +#[test] +fn external_stdio_dap_launches_target_and_host_consumes_read_only_result() { + let root = tempdir().expect("temporary DAP root"); + let script = root.path().join("dap_fixture.py"); + fs::write(&script, DAP_FIXTURE).expect("DAP fixture is written"); + let adapter_command = std::env::var("OMC_PYTHON_COMMAND").unwrap_or_else(|_| "python".into()); + let target_command = std::env::current_exe().expect("test target exists"); + + let request = DebugInspectRequest { + adapter_command, + adapter_args: vec!["-u".into(), script.to_string_lossy().into_owned()], + working_directory: Some(root.path().to_string_lossy().into_owned()), + mode: DebugSessionMode::Launch, + action: DebugAction::Threads, + launch_arguments: Some(json!({ + "targetCommand": target_command, + "targetArgs": ["--help"], + "targetCwd": root.path() + })), + attach_arguments: None, + thread_id: None, + frame_id: None, + variables_reference: None, + timeout_ms: Some(10_000), + allow_side_effects: true, + }; + + let payload = inspect_debug(&request).expect("external DAP session succeeds"); + assert_eq!(payload.schema_version, "omc.debug.v1"); + assert_eq!(payload.operation, "debug.inspect"); + assert_eq!(payload.result["threads"][0]["name"], "fixture-target"); + assert!( + payload + .observed_events + .iter() + .any(|event| event == "initialized") + ); + assert_eq!(payload.output[0].output, "fixture target launched\n"); + + let encoded = serde_json::to_value(ToolResponse::success("hermes-debug", payload)) + .expect("debug response serializes"); + let host_data = encoded["data"].clone(); + let host_view: HostDebugPayload = + serde_json::from_value(host_data).expect("consumer-owned debug view parses"); + assert_eq!(host_view.schema_version, "omc.debug.v1"); + assert_eq!(host_view.operation, "debug.inspect"); + assert_eq!(host_view.action, DebugAction::Threads); + assert_eq!(host_view.result["threads"][0]["name"], "fixture-target"); + assert!(!host_view.side_effects.is_empty()); + assert!( + host_view + .observed_events + .iter() + .any(|event| event == "output") + ); +} + +#[test] +fn debug_request_rejects_attach_without_selected_target_arguments() { + let request = DebugInspectRequest { + adapter_command: "not-used".into(), + adapter_args: Vec::new(), + working_directory: Some(Path::new(".").to_string_lossy().into_owned()), + mode: DebugSessionMode::Attach, + action: DebugAction::Threads, + launch_arguments: None, + attach_arguments: None, + thread_id: None, + frame_id: None, + variables_reference: None, + timeout_ms: Some(5_000), + allow_side_effects: true, + }; + let error = inspect_debug(&request).expect_err("target args are required"); + assert_eq!(error.code, "invalid_request"); +} diff --git a/crates/omc-shared/tests/session_pool_contract.rs b/crates/omc-shared/tests/session_pool_contract.rs new file mode 100644 index 0000000..92d7b59 --- /dev/null +++ b/crates/omc-shared/tests/session_pool_contract.rs @@ -0,0 +1,40 @@ +use std::time::Duration; + +use omc_shared::session_pool::{BoundedSessionPool, SessionPoolError}; + +#[test] +fn pool_reuses_rejects_overflow_closes_and_reaps() { + let mut pool = BoundedSessionPool::new(1, Duration::from_millis(5)); + assert_eq!( + *pool.get_or_try_insert_with("a", || Ok::<_, ()>(7)).unwrap(), + 7 + ); + assert_eq!( + *pool.get_or_try_insert_with("a", || Ok::<_, ()>(8)).unwrap(), + 7 + ); + assert!(matches!( + pool.get_or_try_insert_with("b", || Ok::<_, ()>(9)), + Err(SessionPoolError::Capacity { limit: 1 }) + )); + assert_eq!(pool.remove(&"a"), Some(7)); + pool.get_or_try_insert_with("b", || Ok::<_, ()>(9)).unwrap(); + std::thread::sleep(Duration::from_millis(10)); + assert_eq!(pool.reap_idle(), 1); + assert!(pool.is_empty()); +} + +#[test] +fn access_reports_fresh_after_idle_reap() { + let mut pool = BoundedSessionPool::new(1, Duration::from_millis(5)); + let (_, reused) = pool + .get_or_try_insert_with_status("project", || Ok::<_, ()>(1)) + .unwrap(); + assert!(!reused); + std::thread::sleep(Duration::from_millis(10)); + let (value, reused) = pool + .get_or_try_insert_with_status("project", || Ok::<_, ()>(2)) + .unwrap(); + assert!(!reused); + assert_eq!(*value, 2); +} diff --git a/crates/omc-skills/src/templates.rs b/crates/omc-skills/src/templates.rs index ed3390e..d86546e 100644 --- a/crates/omc-skills/src/templates.rs +++ b/crates/omc-skills/src/templates.rs @@ -12,7 +12,7 @@ pub struct SkillTemplate { pub content: String, } -/// All available built-in skill names (40 total) +/// All available built-in skill names (41 total) pub const SKILL_NAMES: &[&str] = &[ "ai-slop-cleaner", "ask", @@ -30,6 +30,7 @@ pub const SKILL_NAMES: &[&str] = &[ "improve-codebase-architecture", "learner", "mcp-setup", + "omc-agent-tool", "omc-doctor", "omc-setup", "omc-teams", @@ -334,6 +335,26 @@ pub fn get_templates() -> HashMap { }, ); + templates.insert( + "omc-agent-tool".to_string(), + SkillTemplate { + metadata: SkillMetadata { + name: "omc-agent-tool".to_string(), + description: + "Host-neutral OMC-RS capability discovery and progressive task routing" + .to_string(), + argument_hint: None, + level: Some("2".to_string()), + aliases: vec![], + agent: None, + model: None, + hosts: vec!["claude".to_string(), "codex".to_string()], + protocol_version: Some("1.0".to_string()), + }, + content: include_str!("templates/omc-agent-tool.md").to_string(), + }, + ); + templates.insert( "omc-setup".to_string(), SkillTemplate { diff --git a/crates/omc-skills/src/templates/omc-agent-tool.md b/crates/omc-skills/src/templates/omc-agent-tool.md new file mode 100644 index 0000000..1604f4b --- /dev/null +++ b/crates/omc-skills/src/templates/omc-agent-tool.md @@ -0,0 +1,41 @@ +--- +name: omc-agent-tool +description: Use the OMC-RS host-neutral agent tool contract for capability discovery and progressive task routing. +hosts: [claude, codex] +protocol_version: "1.0" +--- + +# OMC-RS Agent Tool + +Use OMC-RS as a capability and routing layer. Do not reimplement an agent loop +inside the host. + +## Progressive routing + +1. Call `agent_capabilities` once when the integration starts. +2. Keep simple reads and focused edits in the current host. +3. Call `agent_route` before work that may cross files, require architecture + decisions, or has already failed. +4. Use the returned semantic `tier` and `recommendedSurface`; never depend on + a provider-specific model ID. +5. Use existing `state_*`, `notepad_*`, and `project_memory_*` tools for durable + context. They are already part of the OMC-MCP surface. +6. Treat `python_repl` as an explicit-side-effect tool, never as a sandbox. It + requires `allowSideEffects=true` for execution or session mutation; use it + only when the host has separately approved local code execution. + +## CLI fallback + +When MCP is unavailable: + +```text +omc tool capabilities +omc tool route --task "..." --agent-type executor --previous-failures 0 +``` + +Both commands return the `omc.tool.v1` JSON envelope. Treat `error.code` as the +machine-readable branch key and `error.message` as display text. + +The current tool surface recommends orchestration; it does not pretend to +start or cancel a runtime. Those lifecycle operations will be added only when +they are backed by the real `omc-team` runtime service. diff --git a/crates/omc-team/Cargo.toml b/crates/omc-team/Cargo.toml index 2e3604f..ee27e61 100644 --- a/crates/omc-team/Cargo.toml +++ b/crates/omc-team/Cargo.toml @@ -8,6 +8,7 @@ authors.workspace = true repository.workspace = true [dependencies] +omc-shared = { path = "../omc-shared" } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } dirs = "5" diff --git a/crates/omc-team/src/dispatch.rs b/crates/omc-team/src/dispatch.rs index af6ee16..7ab07c4 100644 --- a/crates/omc-team/src/dispatch.rs +++ b/crates/omc-team/src/dispatch.rs @@ -3,6 +3,8 @@ use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; +use omc_shared::operation_contract::{ArtifactRef, TaskState, TaskStatus, TypedSubagentResult}; + use crate::worker_health::WorkerHealth; /// Priority level for dispatch tasks. @@ -25,6 +27,19 @@ pub enum DispatchStatus { Cancelled, } +impl From<&DispatchStatus> for TaskState { + fn from(status: &DispatchStatus) -> Self { + match status { + DispatchStatus::Queued => Self::Queued, + DispatchStatus::Dispatched => Self::Dispatched, + DispatchStatus::Acknowledged => Self::Acknowledged, + DispatchStatus::Completed => Self::Succeeded, + DispatchStatus::Failed => Self::Failed, + DispatchStatus::Cancelled => Self::Cancelled, + } + } +} + /// A task in the dispatch system. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DispatchTask { @@ -38,6 +53,33 @@ pub struct DispatchTask { pub completed_at: Option, } +impl DispatchTask { + /// Project the internal dispatch record onto the host-neutral task status + /// contract without exposing queue implementation details. + pub fn operation_status(&self, observed_at: impl Into) -> TaskStatus { + self.operation_status_with_result(observed_at, None) + } + + /// Project a completed worker result without exposing provider/runtime + /// details to the host contract. + pub fn operation_status_with_result( + &self, + observed_at: impl Into, + result: Option, + ) -> TaskStatus { + TaskStatus { + schema_version: omc_shared::operation_contract::TASK_SCHEMA_VERSION.into(), + task_id: self.id.clone(), + correlation_id: self.id.clone(), + state: TaskState::from(&self.status), + observed_at: observed_at.into(), + artifact_refs: Vec::::new(), + result, + error: None, + } + } +} + /// FIFO dispatch queue with concurrency limits and ack timeouts. pub struct DispatchQueue { queue: VecDeque, @@ -429,6 +471,34 @@ mod tests { assert_eq!(completed.status, DispatchStatus::Completed); } + #[test] + fn operation_status_projects_dispatch_state() { + let mut task = make_task("t-1"); + task.status = DispatchStatus::Completed; + let status = task.operation_status("2026-08-12T00:00:00Z"); + assert_eq!(status.schema_version, "omc.task.v1"); + assert_eq!(status.task_id, "t-1"); + assert_eq!(status.correlation_id, "t-1"); + assert_eq!(status.state, TaskState::Succeeded); + assert!(status.artifact_refs.is_empty()); + assert!(status.result.is_none()); + } + + #[test] + fn operation_status_projects_typed_result() { + let mut task = make_task("t-typed"); + task.status = DispatchStatus::Completed; + let result = TypedSubagentResult::new( + "omc.agent.findings.v1", + serde_json::json!({"summary":"done"}), + Vec::new(), + ) + .unwrap(); + let status = + task.operation_status_with_result("2026-08-12T00:00:00Z", Some(result.clone())); + assert_eq!(status.result, Some(result)); + } + // -- AllocationPolicy tests -- #[test] diff --git a/crates/omc-team/src/lib.rs b/crates/omc-team/src/lib.rs index e856119..e71674d 100644 --- a/crates/omc-team/src/lib.rs +++ b/crates/omc-team/src/lib.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; +use omc_shared::TeamObservabilityPayload; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -224,6 +225,20 @@ pub fn init_project(root: &Path) -> Result { Ok(report) } +/// Project the existing team observability files into a read-only host contract. +/// This never starts, stops, or mutates a team runtime. +pub fn team_observability(root: &Path, view: &str) -> Result { + let data = match view { + "sessions" => serde_json::to_value(load_sessions(root)?), + "top" => serde_json::to_value(top_snapshot(root)?), + "doctor" => serde_json::to_value(observability_doctor(root)), + _ => return Err(format!("unknown team observability view: {view}")), + } + .map_err(|error| format!("failed to encode team observability: {error}"))?; + + Ok(TeamObservabilityPayload::new(view, data)) +} + static CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: &str = "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"; pub fn check_claude_ready() -> Result<(), String> { diff --git a/docs/agent-tool-contract.md b/docs/agent-tool-contract.md new file mode 100644 index 0000000..535c593 --- /dev/null +++ b/docs/agent-tool-contract.md @@ -0,0 +1,564 @@ +# OMC-RS Agent Tool Contract + +## Scope + +`omc-rs` is the host-neutral operations surface consumed by Hermes, Sentinel, +Codex, and other Agent systems. It owns capability discovery and progressive +routing. The host continues to own its model loop and native tools. + +The first version deliberately does not expose fake `task.start` or +`task.cancel` operations. Those operations require a real runtime service and +must be added only when their side effects can be verified. + +## Transports + +- MCP stdio: `omc mcp` (preferred unified entry; `omc-mcp` remains the + compatibility binary), tools `agent_capabilities`, `agent_route`, + `code_intel_artifact_query`, `lsp_document_symbols`, `workflow_advance`, + `subagent_result_validate`, `hash_edit`, and + the durable `goal_*` tools, plus the explicitly gated `python_repl` and + `debug_inspect` tools, read-only `interop_snapshot`, and explicitly gated + `interop_bridge`. +- JSON CLI: `omc tool capabilities`, `omc tool route ...`, + `omc tool code-intel-query ...`, `omc tool result-validate ...`, and + `omc tool hash-edit ...`, `omc tool python-repl ...`, + `omc tool debug-inspect ...`, and `omc tool interop-snapshot ...`. + The durable bridge is `omc tool interop-bridge ...`. +- Goal CLI: `omc goal create|list|show|start|block|checkpoint|complete`. +- MCP startup: `omc mcp` owns the stdio process and reuses the same tool + registry as the `omc-mcp` compatibility binary. +- `omc setup --host codex|claude` registers the unified host entry. Claude MCP + uses the project-root `.mcp.json` contract; `.claude/settings.json` remains + the settings/hooks surface. Codex MCP uses `.codex/config.toml`. `omc setup + --host hermes [--hermes-home PATH]` registers the same `omc-rs -> omc mcp` + stdio server in Hermes' `config.yaml` under `mcp_servers`; it does not add a + second agent loop or provider. + An existing matching entry is left unchanged and a + conflicting entry fails closed. + Without `--hermes-home`, native Windows follows Hermes' `%LOCALAPPDATA%\\hermes` + default, while POSIX/WSL follows `~/.hermes`; `HERMES_HOME` remains an explicit + override. +- Skill guidance: `omc-agent-tool`. + +The agent capability, routing, and Code Intel surfaces use the same +`omc.tool.v1` response envelope: + +```json +{ + "schema_version": "omc.tool.v1", + "request_id": "sentinel-123", + "ok": true, + "data": {} +} +``` + +Errors use stable machine-readable codes: + +```json +{ + "schema_version": "omc.tool.v1", + "request_id": "sentinel-123", + "ok": false, + "error": { + "code": "invalid_request", + "message": "task must not be empty" + } +} +``` + +## Operations + +### `agent_capabilities` + +Read-only discovery. No required arguments. Returns the OMC-RS capability +catalog and protocol version. Each of the 16 capability records also lists its +exact MCP tool names and runtime availability (`available`, `unavailable`, or +`conditional`). Dependency-backed capabilities report the resolved executable +path or a machine-readable reason, so hosts can avoid invoking unavailable +Python, Code Intel, rust-analyzer, or team adapters. DAP remains conditional +because its adapter command is supplied per request. + +The catalog in `omc-shared::capability_catalog` is the single source of truth. +An MCP contract test requires the 32 registered tools to match it exactly. + +### `agent_route` + +Read-only routing. MCP arguments: + +```json +{ + "task": "redesign the repository architecture", + "agentType": "architect", + "previousFailures": 1, + "requestId": "sentinel-123" +} +``` + +The result contains a semantic tier (`LOW`, `MEDIUM`, `HIGH`), a model role, +confidence, reasons, and a recommended surface. Provider-specific model IDs +are intentionally excluded. + +### `workflow_advance` + +Read-only staged workflow decision. It maps the portable part of OMX/OMC's +clarify/plan/execute/verify/fix flow to one contract. The host or existing +`omc-team` runtime supplies the evidence; this tool never starts an agent, +creates a task, or persists a lifecycle record. + +Stages are `initializing`, `clarifying`, `planning`, `executing`, `verifying`, +`fixing`, `paused`, `completed`, and `failed`. The result returns `advance`, +`wait`, or `terminal`; `wait` means the evidence is insufficient and must not +be treated as progress. + +CLI example: + +```bash +omc tool workflow-advance --current-stage planning \ + --all-tasks-assigned --plan-approved +``` + +MCP arguments use the same fields in camelCase: + +```json +{ + "currentStage": "verifying", + "allTasksCompleted": true, + "verificationPassed": true, + "requestId": "hermes-workflow-1" +} +``` + +The result is `omc.workflow.v1` inside the normal `omc.tool.v1` envelope. + +### `team_observability` + +This is a read-only projection of the existing `omc-team` runtime. It does not +start, cancel, or mutate a team. The `view` is one of `sessions`, `top`, or +`doctor`; `workingDirectory` selects the project containing `.omc/team`. +The nested payload is versioned as `omc.team-observability.v1` and is returned +inside the normal `omc.tool.v1` envelope. + +CLI example: + +```bash +omc tool team-observability --view sessions --root . +``` + +MCP example: + +```json +{ + "view": "top", + "workingDirectory": "C:/work/project", + "requestId": "sentinel-team-top-1" +} +``` + +The payload is an observation of existing session/usage files. Empty state is +reported as an empty snapshot rather than an invented running task. + +### `interop_snapshot` + +Read-only OMC/OMX interoperability observation. It reuses the existing +`omc-interop` readers for `.omc/state/interop` and `.omx/state/team`; it does +not start workers, send messages, update task status, or mark mailboxes read. +The nested payload is versioned as `omc.interop.snapshot.v1`, bounded by +`limit` (1--100), and reports `directWriteEnabled` so a host can see whether +the separately gated active bridge is enabled without invoking it. +Its `normalizedTasks` projection maps OMC and OMX native statuses to the +portable `pending`/`blocked`/`in_progress`/`completed`/`failed` superset while +retaining native IDs and source/team metadata; native task records remain +available in `sharedTasks` and `omxTeams`. + +CLI example: + +```bash +omc tool interop-snapshot --root . --limit 20 +``` + +MCP example: + +```json +{ + "workingDirectory": "C:/work/project", + "limit": 20, + "requestId": "sentinel-interop-1" +} +``` + +### `interop_bridge` + +Send one explicit task or message between OMC and OMX through the existing +shared-state writer. The payload is versioned as +`omc.interop.bridge.v1`; `source` and `target` are required and must differ. +Every write requires both `allowSideEffects: true` and the process flags +`OMX_OMC_INTEROP_MODE=active`, `OMX_OMC_INTEROP_ENABLED=1`, and +`OMC_INTEROP_TOOLS_ENABLED=1`. It writes a durable record only; it does not +start workers, schedule tasks, or expose `task.start`/`task.cancel`. + +MCP example: + +```json +{ + "action": "send_message", + "source": "omc", + "target": "omx", + "content": "Please inspect the failing adapter", + "workingDirectory": "C:/work/project", + "allowSideEffects": true, + "requestId": "sentinel-bridge-1" +} +``` + +### `code_intel_artifact_query` + +Read-only repository intelligence. The adapter delegates to the released +`code-intel artifact query` command and returns its result plus normalized OMC +artifact references. It never runs a scan, writes an artifact root, changes a +repository, or reimplements Code Intel's index/verification rules. + +MCP arguments: + +```json +{ + "repo": "code-intel-pipeline", + "artifactRoot": "C:/Users/example/AppData/Local/code-intel/artifacts", + "artifactType": "code_evidence.agent_slice", + "artifactUri": "omc://artifact/sha256/", + "limit": 10, + "requestId": "sentinel-124" +} +``` + +Normalized artifact references use `omc.artifact-ref.v1`: + +```json +{ + "schemaVersion": "omc.artifact-ref.v1", + "producer": "code-intel-pipeline", + "artifactSchema": "agent-code-slice-ranking.v1", + "artifactType": "code_evidence.agent_slice", + "path": "objects/sha256/", + "sha256": "<64-hex-digest>", + "consumedSnapshotIdentity": "", + "uri": "omc://artifact/sha256/" +} +``` + +The URI is content-addressed and must match sha256 exactly. Existing persisted +references without uri remain valid on input; Code Intel output and goal +checkpoints normalize them to the canonical URI while preserving producer, +path, and snapshot provenance. OMC-RS does not provide a general URI resolver +or copy the upstream agent runtime. + +When `artifactUri` is supplied, the same read-only query surface performs a +bounded exact inspection: it asks Code Intel for its maximum 100-match page, +filters by the verified artifact digest, and returns only matching previews and +normalized refs. A miss at the page boundary is reported as +`upstream_query_truncated` instead of being presented as a false not-found. +This is an inspection/preview contract, not a raw-byte reader. + +### `debug_inspect` + +This is the first OMP debugger-derived adapter. The protocol boundary follows +the [Debug Adapter Protocol base protocol](https://microsoft.github.io/debug-adapter-protocol/overview): +OMC-RS starts one externally supplied stdio adapter, sends `initialize`, then an +explicit `launch` or `attach` request, and finally one read-only inspection +request. Adapter-specific launch/attach arguments remain opaque JSON owned by +the caller; OMC-RS does not install, select, or implement a debugger. + +`allowSideEffects: true` is mandatory because launch/attach starts or connects +to a real debug session. Supported actions are `threads`, `stackTrace`, +`scopes`, `variables`, `modules`, `loadedSources`, and `output`. Breakpoints, +continue/step, evaluate, memory writes, and reverse requests are outside this +first contract. The adapter is disconnected before returning; launch sessions +request debuggee termination, while attach sessions leave the debuggee running. + +The transport bounds each DAP message to 4 MiB, adapter argument count to 128, +and request timeouts to 5--300 seconds. Timeout and adapter failures are +returned as machine-readable `debug_timeout`, `adapter_unavailable`, or +`upstream_failed` errors inside `omc.tool.v1`; successful data is tagged +`omc.debug.v1` and includes observed events and declared side effects. + +MCP example: + +```json +{ + "adapterCommand": "codelldb", + "adapterArgs": ["--stdio"], + "workingDirectory": "C:/work/project", + "mode": "launch", + "action": "threads", + "launchArguments": {"program": "C:/work/project/target/debug/app"}, + "allowSideEffects": true, + "requestId": "sentinel-debug-1" +} +``` + +CLI JSON arguments use the same fields: + +```bash +omc tool debug-inspect --adapter-command codelldb \ + --adapter-args-json '["--stdio"]' --mode launch --action threads \ + --root . --launch-arguments '{"program":"target/debug/app"}' \ + --allow-side-effects +``` + +The first real regression uses an external stdio DAP fixture that launches an +actual test process, then a consumer-owned JSON view. It does not claim that a +fixture provides debugger semantics; those remain the supplied adapter's +responsibility. + +### `lsp_document_symbols` + +Read-only Rust document symbols through `rust-analyzer`. The direct CLI adapter +is one-shot; the long-running MCP process keeps a bounded project-scoped pool +(maximum 4 projects, 10-minute idle TTL) and evicts a session after a transport +or request failure. +The adapter accepts a project-relative file, canonicalizes both the project +root and file, rejects traversal/symlink escapes, bounds each JSON-RPC message +to 4 MiB, and enforces a 5--60 second timeout. It sends no edit, rename, +formatting, or persistent-server operation and returns `sideEffects: []`. + +CLI: + +```bash +omc tool lsp-document-symbols --root . --file crates/omc-shared/src/lib.rs +``` + +MCP arguments: + +```json +{ + "workingDirectory": "C:/work/omc-rs", + "file": "crates/omc-shared/src/lib.rs", + "timeoutMs": 20000, + "requestId": "sentinel-lsp-1" +} +``` + +The response includes `serverProcessId` and `sessionReused` so an MCP consumer +can verify reuse without depending on Rust types. Server discovery, +configuration, background indexing management, and write-capable LSP +operations remain outside the contract. + +### `python_repl` + +This is the first executable OMP-derived adapter. The source boundary is the +upstream [eval tool contract](https://github.com/can1357/oh-my-pi/blob/main/docs/tools/eval.md): +language selection is explicit, cells run in order, and Python state persists +inside a kernel session. OMC-RS keeps only the local subprocess/session +boundary; it does not import the upstream agent loop, tool bridge, or provider +runtime. + +The MCP server keeps sessions for its process lifetime. The CLI keeps a session +only for the current invocation, so it is a fallback for one cell rather than a +cross-invocation kernel. `projectDir` must already exist. Python execution is +not a sandbox: `execute`, `reset`, and `interrupt` require +`allowSideEffects: true`; code may read/write files, spawn processes, or use +the network according to the local Python environment. `get_state` is +read-only but only works for an existing session. + +MCP example: + +```json +{ + "action": "execute", + "sessionId": "sentinel-python-1", + "projectDir": "C:/work/project", + "code": "value = 41", + "allowSideEffects": true, + "requestId": "sentinel-python-1" +} +``` + +CLI example: + +```bash +omc tool python-repl --action execute --session-id cli-python \ + --root . --code "print(6 * 7)" --allow-side-effects +``` + +The adapter uses `python`/`python3` from `PATH`, or `OMC_PYTHON_COMMAND` when +set. Code is capped at 256 KiB, execution at 300 seconds, output at 4 MiB, +and the process-local session store at 16 sessions. A timeout restarts the +kernel before returning `execution_timeout`; it never leaves a possibly busy +interpreter attached to the next call. Memory fields are best-effort and are +`0` when the platform does not expose a measurement. + +### `goal_*` + +The goal ledger is the cross-host control-plane anchor for long-running work. +It persists project goals under `.omc/state/goals/`, supports planned/active/ +blocked/completed states, and records checkpoints with optional normalized +artifact references. It does not start agents or claim task execution. + +The MCP tools are: + +- `goal_create` +- `goal_list` +- `goal_get` +- `goal_start` +- `goal_block` +- `goal_checkpoint` +- `goal_complete` + +The CLI emits the same goal JSON records: + +```bash +omc goal create --id internalize-omx --objective "absorb portable workflow capabilities" +omc goal start --id internalize-omx +omc goal checkpoint --id internalize-omx --checkpoint-id s0 --summary "setup and host doctor verified" +omc goal show --id internalize-omx +``` + +### `subagent_result_validate` + +Validates a structured result envelope without starting an agent or provider. +The payload must be a JSON object, `resultType` is the named schema ID, and +`requiredFields` checks top-level fields: + +```bash +omc tool result-validate --result-type omc.agent.findings.v1 \ + --payload '{"summary":"done"}' --required-fields summary +``` + +The result envelope is `omc.subagent-result.v1`; prose output is not treated as +a typed result. + +### `hash_edit` + +Applies one contiguous, project-relative line replacement only after every +provided line SHA-256 anchor (and optional whole-file digest) matches. A stale +anchor returns `stale_edit` and leaves the file untouched. Successful writes +use the existing temporary-file-plus-rename atomic-write pattern and return +before/after file digests. + +MCP calls provide `workingDirectory`, `path`, `startLine`, `endLine`, an +`anchors` array, and `replacement`. The CLI equivalent is: + +```bash +omc tool hash-edit --root . --path src/lib.rs --start-line 1 --end-line 1 \ + --anchors-json '[{"line":1,"sha256":"<64-hex-digest>"}]' \ + --replacement 'replacement line' +``` + +### Task, event, and error contracts + +The shared Rust types freeze `omc.task.v1`, `omc.event.v1`, and the error +shape inside `omc.tool.v1`. Task and event types carry correlation IDs and +observed timestamps. A task status may carry verified artifact references and a +typed `omc.subagent-result.v1` result, and a machine-readable error. They are +control-plane contracts; they do not imply that a host runtime has been started. + +`omc-team` projects its internal dispatch states into `TaskStatus`. It does +not claim that a task is externally observable or cancellable unless a real +runtime adapter reports that state. + +## Consumer rule + +Consumers should call native host tools first. Use OMC-RS routing when the task +crosses files, needs a role decision, or has failed. A `HIGH` result recommends +the `omc-team` surface; it does not itself start a runtime. + +## External consumer fixture + +[`tests/host-consumer/consumer.py`](../tests/host-consumer/consumer.py) is a +JSON-only consumer smoke. It imports no OMC-RS Rust types and verifies the CLI +capabilities/failure envelope, team observation, and MCP `initialize`, +`tools/list`, and `agent_capabilities`. Supplying `--artifact-root` additionally +verifies the real `code_intel_artifact_query` through both CLI and MCP, including +committed/current Code Intel evidence: + +```bash +python tests/host-consumer/consumer.py \ + --omc target/release/omc \ + --artifact-root "$CODE_INTEL_ARTIFACT_ROOT" \ + --repo omc-rs-src \ + --repo-path . +``` + +The fixture proves transport and contract consumption only; it is not a +replacement for Hermes/Sentinel private-host integration or their agent loop. + +The current Windows validation uses the Code Intel source release v0.7.2 and +the indexed publication `omc-rs-src-v081`. The installed v0.7.1 binary was not +used as an authority because its manifest reconciliation reported stale +registry findings; OMC-RS does not bypass that check. With the v0.7.2 +publication, both CLI and MCP return `authority.status=committed`, +`runOutcome=completed`, and `freshness.status=current` for the same artifact +snapshot. + +### Release bundle + +The supported Windows release shape is a small adjacent-binary bundle. Build and +verify it with: + +```powershell +.\scripts\package-omc.ps1 -Build +python tests/host-consumer/consumer.py --omc target/omc-package/omc.exe +``` + +The script writes `target/omc-package/omc-bundle.json` with +`omc.release-bundle.v1`, entrypoints, and SHA-256 checksums. The bundle contains +`omc.exe` as the unified CLI/MCP entrypoint, `omc-team.exe` for the existing +`omc team` process bridge, and `omc-mcp.exe` as the compatibility MCP entrypoint. +The adjacent `omc-team.exe` is intentional: it reuses the existing team runtime +and avoids embedding a second scheduler in `omc.exe`. + +Run the release discovery performance budget with: + +```powershell +python tests/host-consumer/benchmark.py --omc target/omc-package/omc.exe +``` + +It measures cold CLI discovery and warm discovery through a persistent MCP +process, while also enforcing the 16-capability/32-tool catalog size. + +Measure the project-scoped LSP cold start and warm reuse path through a real +MCP process with: + +```powershell +python tests/host-consumer/lsp_benchmark.py --omc target/omc-package/omc.exe +``` + +The benchmark verifies that warm calls retain the same `rust-analyzer` process, +report `sessionReused=true`, and remain inside the configured warm P95 budget. + +The host-side envelope fixtures live in +`crates/omc-shared/tests/agent_tool_consumer.rs`. They deliberately deserialize +into consumer-owned views, so a Hermes/Sentinel adapter only needs the JSON +contract. Run them with: + +```bash +cargo test -p omc-shared --test agent_tool_consumer +``` + +CLI/MCP 的同请求 envelope 回归测试位于 +`crates/omc-cli/tests/agent_tool_contract.rs`,运行: + +```bash +cargo test -p omc-cli --test agent_tool_contract +``` + +Windows 上的测试会自动调用内嵌 PowerShell 黑盒,创建并清理临时项目。 +其中也会验证 Python 的副作用门控和真实 CLI 执行;MCP 的跨调用 session +状态由同一测试进程中的 `python_repl` consumer fixture 验证;DAP 的 stdio +和 host-consumer 回归位于 `crates/omc-shared/tests/dap_debug_contract.rs`。 + +Claude/Codex 宿主注册和 discovery → route → event → result 消费回归测试位于 +`crates/omc-host/src/mcp_reg.rs`,运行: + +```bash +cargo test -p omc-host --lib mcp_reg +``` + +Codex 临时项目黑盒测试位于 +`crates/omc-cli/tests/agent_tool_contract.rs` 的 +`codex_project_smoke_runs_clarify_plan_execute_verify`。它会启动真实 `omc` +进程,执行 setup、goal/checkpoint、workflow、hash-edit 和 result validation, +并用 `rustc` 编译运行修改后的项目: + +```bash +cargo test -p omc-cli --test agent_tool_contract +``` diff --git a/docs/roadmap/omc-platform-internalization.md b/docs/roadmap/omc-platform-internalization.md new file mode 100644 index 0000000..09d1315 --- /dev/null +++ b/docs/roadmap/omc-platform-internalization.md @@ -0,0 +1,364 @@ +# OMC-RS 平台化内化排期 + +状态:本轮交付基线已完成。S0--S5 的首批可用切片已经落地并通过 release、 +CLI/MCP 消费和全量质量门禁;Hermes/Sentinel 私有宿主运行时与后续 director +mode 保留为需要真实消费契约后再启动的独立增量。 + +## 目标 + +把 OMC-RS 做成我们自己的、可测试、可复用的 Agent 工具平台:外部 +OMC、OMX、oh-my-pi 以及后续确认的工具只贡献可验证的能力切片;OMC-RS +保留统一契约、路由、状态、记忆、团队调度、宿主适配和 CLI/MCP 消费面。 + +当前押注:产品边界叫 OMC-RS,用户入口叫 `omc`;Codex、Claude Code、 +Hermes、Sentinel 等是宿主或消费者,不把任一宿主的模型循环变成 OMC-RS +的第二套核心。 + +## 当前基线 + +- 共享能力和契约在 `crates/omc-shared/src/agent_tool.rs`、 + `crates/omc-shared/src/operation_contract.rs`、 + `crates/omc-shared/src/code_intel.rs`。 +- 现有团队调度在 `crates/omc-team/src/dispatch.rs`,已经能把内部状态投影到 + `omc.task.v1` 的 `TaskStatus`,并可附带验证后的 typed subagent result。 +- MCP server library 入口是 `crates/omc-mcp/src/server.rs`,统一 CLI 通过 + `omc mcp` 启动;`crates/omc-mcp/src/main.rs` 仅保留兼容启动器。宿主工具在 + `crates/omc-mcp/src/agent_tools.rs`。 +- CLI 入口是 `crates/omc-cli/src/commands/mod.rs` 和 + `crates/omc-cli/src/dispatch.rs`。 +- 宿主适配已有 `crates/omc-host`,跨工具边界已有 `crates/omc-interop`。 +- 当前工具契约见 `docs/agent-tool-contract.md`。 + +## 外部来源判断 + +这些判断基于截至 2026-08-13 的官方仓库页面和发布信息: + +1. [OMX / oh-my-codex](https://github.com/Yeachan-Heo/oh-my-codex) 是 Codex + 的工作流层,明确保留 Codex 作为执行引擎,重点是 + `deep-interview -> ralplan -> ultragoal`、`.omx/` 计划/日志/状态以及 + skills/agents/hooks。它对原生 Windows 和 Codex App 的支持不是默认路径, + 因此在 OMC-RS 中先吸收协议和工作流,不直接依赖其 tmux/npm runtime。 +2. [OMC / oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode) + 是 Claude Code 的 teams-first 编排层;官方文档把 Team 的主流程定义为 + `team-plan -> team-prd -> team-exec -> team-verify -> team-fix`,并区分 + Claude 原生 Team 和 CLI/tmux workers。OMC-RS 应映射到已有 `omc-team` 和 + `omc-host`,而不是再造一套调度器。 +3. [oh-my-pi](https://github.com/can1357/oh-my-pi) 更接近完整 coding-agent + harness:typed subagent 结果、hash-anchored edits、URI 资源、LSP、 + debugger、Python、browser、vibe mode 和插件扩展。它是能力来源,不是 + OMC-RS 的运行时依赖;先拿可跨宿主的 schema、验证和工具边界。 +4. `OMY` 当前不是唯一可识别的官方仓库名。暂按用户此前给出的 + `can1357/oh-my-pi` 处理;如果指的是另一个项目,进入 ask-match 后再纳入。 + +本轮边界审查补充:OMP 的 debugger 是带 action 级权限边界的 DAP session, +browser 则依赖 Puppeteer/CDP、共享 Chromium 和 relay。本轮先实现 debug 的 +受控 DAP adapter;browser 暂不搬入,除非 Hermes/Sentinel 提供真实消费场景。 +两者都不引入第二 Agent loop、adapter 下载器、浏览器 broker 或上游运行时。 + +## 分阶段排期 + +### S0:可正常测试和使用的基线(P0,当前阶段,1--3 个工作日) + +状态:已完成。CLI/MCP、setup/doctor、Code Intel 查询、宿主消费 fixture、 +release 构建和全量测试均已有证据。 + +目标:让当前 OMC-RS agent-tool/Code Intel 改动从“代码已接入”变成“开发者 +能安装、启动、测试、消费”。 + +交付: + +- release/debug 构建入口和 `omc doctor`/环境诊断,明确 Rust、Code Intel、 + MCP、CLI 的缺失项。 +- 一条 CLI smoke:`omc tool capabilities`、`omc tool route`、 + `omc tool code-intel-query`。 +- 一条 MCP stdio smoke:initialize、tools/list、tools/call。 +- 一个不依赖真实 Hermes/Sentinel 私有代码的宿主消费 fixture:只按 + `omc.tool.v1` 解析成功/失败和 artifact 引用,证明消费者不需要知道 Rust + 内部类型。 +- 最小开发文档:安装、运行、示例、失败码、只读边界、如何接入宿主。 + +退出标准:新机器或干净工作树能按文档完成构建;CLI 和 MCP 都能返回稳定 +JSON;Code Intel 查询能返回真实 committed artifact;所有质量门禁通过。 + +### S1:统一能力面和真实生命周期投影(P0,3--5 个工作日) + +目标:把 capabilities、task、event、error、artifact-ref 变成唯一共享面。 + +交付: + +- 所有新工具复用 `omc.tool.v1`,错误码不能靠字符串判断。 +- `omc-team` 的真实 dispatch 状态继续投影到 `omc.task.v1`;不新增没有真实 + runtime side effect 的 `task.start`/`task.cancel`。 +- 只有已有 state、memory、notepad、routing、team 能力可被宿主发现;避免 + 再造 parallel executor、provider registry 或第二套 memory。 +- 加入跨 transport contract tests,CLI/MCP 输出对同一 fixture 等价。 + +退出标准:Hermes/Sentinel 类宿主仅凭 capability discovery 和 JSON schema +即可选择工具;不存在“成功但没有真实状态/产物”的假生命周期。 + +### S2:OMX / OM Codex 能力内化(P1,约 1 周) + +目标:把 Codex 工作流方法变成 OMC-RS 的 skill/plan/goal 资产。 + +优先吸收: + +- `deep-interview` 的问题收敛规则; +- `ralplan` 的计划、架构、批评门禁; +- `ultragoal` 的 durable checkpoint/ledger 思路; +- `ultrawork`/team 的角色分工和验证顺序; +- `.omx/` 下 plans、logs、memory、state 的可迁移布局原则; +- setup/doctor/host guidance 的可验证安装流程。 + +已完成的第一批:`omc.goal.v1`、项目级 `.omc/state/goals/` durable ledger、 +CLI/MCP 的 goal lifecycle 工具,以及 CLI↔MCP 临时项目黑盒恢复测试。目标 +状态只负责控制面;真正的 agent/task 执行仍由宿主或现有 `omc-team` 负责。 + +当前进度:`omc-cli/tests/agent_tool_contract.rs` 已增加真实 Codex 项目黑盒(Windows +由内嵌 PowerShell smoke 驱动): +临时项目执行 `omc setup --host codex`,经历 clarify/plan/execute/verify, +通过 `hash-edit` 修改源码,用 `rustc` 编译并运行,再以 result validation 和 +goal checkpoints 记录证据;测试不把 OMC-RS 伪装成 Agent executor。 + +不吸收:Codex CLI 的模型调用、tmux 运行器、npm 安装器、插件市场实现。 + +退出标准:在 Codex 宿主上可以从“澄清 -> 计划 -> 执行 -> 验证”跑通一个真实 +小项目;计划和 checkpoint 能被 OMC-RS 读取,且执行仍由宿主/现有 team runtime +负责。 + +### S3:OMC / OM Claude Code 能力内化(P1,约 1 周) + +目标:把 Claude Code 侧成熟的团队流程和宿主配置映射到 OMC-RS。 + +优先吸收: + +- Team staged pipeline 的状态/验收语义; +- role/skill catalog 和自动路由的描述格式; +- hooks、setup、doctor、session/replay 的边界与诊断方法; +- Claude/Codex 宿主配置生成和互操作测试。 + +不吸收:Claude Code 专有模型循环、npm/plugin marketplace runtime、与 OMC-RS +已有 `omc-team`/`omc-host` 重复的执行器。 + +当前进度:`omc-host/src/mcp_reg.rs` 已用两个真实 host adapter 注册同一个 +`omc-mcp` server;Claude MCP 写项目根 `.mcp.json`,settings/hooks 仍写 +`.claude/settings.json`,并以真实 Claude CLI `mcp get`、Codex CLI `mcp list` +和 consumer-owned JSON views 验证 +discovery、route、执行前后 event、typed result 和 `omc.task.v1` status;测试 +不启动假的 executor/provider runtime。 + +退出标准:同一个 OMC-RS task contract 能在 Codex 与 Claude Code 两个宿主上 +完成 discovery、route、执行前后事件和结果消费;差异只留在 host adapter。 + +### S4:oh-my-pi / OMP 工具 harness 能力内化(P1,1--2 周) + +目标:提取最能提升工程可靠性的工具边界,不搬完整 Agent。 + +优先顺序: + +1. typed subagent result 与 schema validation; +2. hash-anchored edit / patch verification; +3. URI-shaped artifact/resource reference; +4. LSP、Python 和 debug 的 read-only/explicit-side-effect adapter 规范; + debug 先做 DAP 边界,browser 延后到有真实宿主场景; +5. vibe/director 模式的宿主消费协议。 + +暂缓:provider abstraction、prompt loop、完整插件市场、与 OMC-RS tool wheels +重复的 in-process coreutils。 + +当前进度:typed result 已有共享合约、任务投影、CLI/MCP 验证入口和宿主 +fixture;hash edit 已有共享原子写入、CLI/MCP 入口、陈旧锚点拒绝和前后 +digest 返回;`ArtifactRef` 已有 canonical URI,Code Intel 输出和 goal +checkpoint 会标准化 URI,同时保留 producer/path/snapshot provenance;现有 +Code Intel 查询支持 bounded exact URI inspection,命中返回已验证 preview, +不命中边界 fail-closed,不新增通用 raw-byte reader;LSP 第一批以 +`lsp_document_symbols` 接入 one-shot `rust-analyzer` 只读 adapter,带路径 +越界、超时、响应大小和进程清理边界;阶段化内化第一批以共享 +`omc.workflow.v1`/`workflow_advance` 对齐 OMX 的 clarify/plan/goal 和 OMC +的 team-plan/team-verify/team-fix,只返回基于证据的 advance/wait/terminal, +由已有 `omc-team`/宿主执行,不新增 Agent loop。 + +当前新增:按 OMP `eval` 的边界接入 `omc.python.v1` 的 `python_repl`:MCP +进程内 session-backed 本地 Python kernel、CLI 单进程 fallback、显式 +`allowSideEffects` 门控、代码/超时/输出/session 数量上限,以及超时后的 +kernel 重启。CLI/MCP consumer 和 Windows 项目 smoke 均验证真实 Python +执行与跨调用变量保留;不引入 Python 包管理、tool bridge、provider 或 +第二个 Agent loop。 + +当前新增:按 OMP debugger 的 DAP 边界接入 `omc.debug.v1` 的 `debug_inspect`: +共享层启动外部 stdio adapter,执行 initialize 与显式 launch/attach,再只允许 +threads/stackTrace/scopes/variables/modules/loadedSources/output 只读 action; +4MiB framing、5--300 秒超时、配置完成事件、反向请求拒绝和 launch/attach 清理 +均有边界。CLI/MCP 使用同一请求模型,外部 fixture 启动实际测试进程后由 +consumer-owned JSON view 消费结果;不负责下载/选择 adapter,不在 OMC-RS 内实现 +调试器,不把 breakpoint/continue/evaluate/memory write 伪装成只读工具。 + +退出标准:至少一个真实 code-intel 或 LSP 能力通过统一 artifact/result schema +被 CLI 和 MCP 消费;编辑类能力必须能拒绝过期 hash,不能只靠模型自报成功。 + +### S5:打包、发行和项目化使用(P0/P1,贯穿,首轮完成后 2--3 天) + +目标:把能力变成团队每天可以使用的工具,而不是研究分支。 + +交付: + +- 单一 `omc` CLI:setup、doctor、capabilities、route、team、MCP 启动。 +- Windows 原生路径优先验证;tmux/psmux 作为可选 runtime,不作为基础契约。 +- release 构建、版本化 schema、变更日志和最小升级/回滚说明。 +- 建立 `examples/host-consumer` 或等价 fixture,供 Hermes、Sentinel 以及 + 其他 Agent 做协议回归。 +- 任何外部能力进入前先记录来源版本、许可证、映射文件、测试和删除条件。 + +当前进度:统一 `omc mcp` 已复用 `omc-mcp` library 的同一 stdio server;独立 +`omc-mcp` 入口继续保留。CLI integration test 会启动真实进程,验证 +`initialize`、`tools/list` 及既有工具注册;release smoke、全量质量门禁和 +Sentrux gate 已通过。 + +本轮补齐:omc setup --host codex|claude 现在把 omc-rs 注册为统一的 +omc mcp server;重复运行幂等,冲突配置拒绝覆盖,Claude/Codex 均有 host +adapter 回归,Windows Codex 项目 smoke 会检查真实 config.toml。 + +本轮 Hermes 接入采用 MCP consumer 适配,而不是新增 HostAdapter、Agent loop +或 Provider:官方 `hermes-agent` 的 `~/.hermes/config.yaml` 使用 +`mcp_servers` 映射;`omc setup --host hermes [--hermes-home PATH]` 复用同一 +`omc_server_definition()` 写入 `omc-rs -> omc mcp`。临时 Hermes 配置已通过 +命令级注册、重复运行幂等、保留 provider/model sibling 和冲突拒绝覆盖验证; +真实 Hermes/Sentinel 私有运行时仍需 ask-match,不在本轮猜测其内部 API。 + +发行审计结论:`crates/omc-installer` 暂不作为可用安装入口。它目前没有接入 +`omc` CLI,agent 定义加载仍返回空集合,写入的 `omc-hook ` 也没有对应 +的发行入口;把它直接接到用户环境会制造“安装成功但宿主不可用”的假链路。当前 +可用路径保持为 release `omc`/`omc-mcp` 二进制 + `omc setup` + `omc doctor`; +只有补齐真实资源打包、hook 可执行入口和干净环境 smoke 后,才重新评估是否保留 +该 crate。 + +下一条 release-qa gate:在干净临时目录用 release `omc` 完成 Claude/Codex +setup、MCP discovery 和一个 tool call,并检查配置中的 `omc mcp` 能被实际找到; +若 PATH/安装位置仍没有稳定契约,先补发行说明或可回收的本地 launcher,不引入 +第二套安装器。 + +该 gate 已在 Windows release binary 上通过:临时目录完成两种 host setup, +`doctor --host codex --json` 为 ready,PATH 解析到当前 release `omc.exe`, +MCP initialize/tools/list/tools/call 均成功,且消费到 `omc.tool.v1`。 + +本轮新增统一入口:`omc team ...` 只定位同目录或 PATH 中的现有 +`omc-team` binary 并透传参数,不复制 team runtime、worker lifecycle 或 +调度器。release smoke 已在临时目录通过 `omc team init` 和 +`omc team session list`;因此当前单一 CLI 入口的 team 消费链路是真实可用的。 + +本轮 OMP 研究结论:官方 `/vibe` 是 director/后台 worker/session 持久化 +语义,不是一个可直接复制的路由标签。当前不新增 `vibe_*` 工具或第二套 +执行循环;先复用 `omc-team`,等 Hermes/Sentinel 给出真实 session 消费契约 +后,再决定是否落地 director mode。 + +本轮补齐外部消费回归:新增 `tests/host-consumer/consumer.py`,只用标准库 +通过 release `omc` 验证 CLI capabilities、稳定失败码,以及 MCP +initialize/tools-list/tools-call。它不导入 OMC-RS Rust 类型,已在 Windows +release binary 上实际通过;Hermes/Sentinel 私有宿主接入仍保留为 ask-match。 + +本轮再补 `omc.team-observability.v1`:复用 `omc-team` 已有 sessions/top/doctor +读取逻辑,统一暴露 CLI `team-observability` 和 MCP `team_observability`; +Windows release fixture 已验证 15 项 capability、真实空 session/top 快照和 +嵌套 schema。该切片只观察已有状态,不新增 start/cancel 或第二个 runtime。 +质量信号:shared 的 Sentrux gate 通过;team/mcp/cli 出现小幅 coupling drift +(cycles=0、unresolved imports=0),属于引入共享 team 观察依赖后的 advisory +结构变化,不影响 workspace test、clippy 或 release smoke。 + +本轮再补发布 bundle:`scripts/package-omc.ps1` 复用已构建的 `omc.exe`、 +`omc-mcp.exe`、`omc-team.exe`,生成 `omc.release-bundle.v1` manifest 和 +SHA-256 校验,并对包内 `omc --version`、capability discovery、`omc team` +透传做 smoke。包级 host-consumer fixture 已通过;当前选择相邻二进制 bundle, +不把既有 `omc-team` runtime 重写进 CLI,也不重新启用尚未具备真实资源打包和 +hook 入口的 installer crate。 + +本轮补齐 Code Intel 的权威消费验证:已确认机器上的 v0.7.1 release 因 +manifest reconciliation 的 33 个 stale registry findings 不能作为 authority, +没有绕过该失败;改用源码仓库 release/v0.7.x-rc 构建的 v0.7.2 binary,先通过 +`orchestrate Validate`,再以 `omc-rs-src-v081` 发布完整 committed run。原生 +`artifact query`、`change impact` 均返回 `runOutcome=completed`、当前 snapshot; +Windows JSON-only host consumer 同时通过 release `omc` CLI 和 `omc-mcp` 消费 +同一 `code_evidence.agent_slice`。这样 Code Intel 是真实外部 authority,OMC-RS +只做 adapter 和契约校验,不复制 scanner、index 或 artifact verifier。 + +本轮补齐真实 Hermes 宿主消费:固定官方 `NousResearch/hermes-agent` +`v2026.8.3`(源码版本 0.20.0)并在临时 uv 环境运行;`omc setup --host hermes` +生成 `mcp_servers.omc-rs` 后,官方 `hermes mcp list` 和 `hermes mcp test omc-rs` +均成功,历史 release stdio 握手发现 30 个 OMC 工具。测试未写入用户 Hermes 目录、未使用 +模型凭据;同时把 Windows fallback 对齐官方 `%LOCALAPPDATA%\\hermes`,保留 +`HERMES_HOME` 和显式 `--hermes-home` 覆盖。Hermes 仍只是 MCP consumer,不引入 +第二套 Agent loop 或 Provider。 + +本轮也修正 Claude MCP 的真实配置边界:官方 Claude Code 项目级 MCP 配置是 +根目录 `.mcp.json`,而 `.claude/settings.json` 继续只承载 settings/hooks。 +`omc setup --host claude` 的临时项目 smoke 已由 Claude CLI `mcp get omc-rs` +识别为 Project config;Codex CLI 的 `.codex/config.toml` 注册保持不变。 + +本轮再接入 `omc-interop` 的只读观察切片:统一 CLI `interop-snapshot` 和 MCP +`interop_snapshot` 共用 `omc.interop.snapshot.v1`,读取 OMC shared tasks/messages +以及 OMX team config/tasks,带记录上限并报告当前 interop mode;不发送任务/消息、 +不更新状态、不创建第二套调度器。Windows JSON-only host consumer 已同时验证 +CLI 与 MCP 的 envelope、schema 和 `readOnly` 标记。本轮再补 `normalizedTasks`,把 +OMC/OMX 原生状态映射为统一的 pending/blocked/in_progress/completed/failed 超集, +同时保留 native ID、source/team 元数据。主动 bridge 仍等待真实 Hermes/ +Sentinel session/task 契约与 ask-match;本轮已把已有 shared-state task/message +writer 接入 `omc.interop.bridge.v1`,要求 `source`/`target`、`allowSideEffects` 和 +三项 active 环境门,CLI/MCP 均已用临时目录验证真实文件写入,且不启动 worker。 +当前 release bundle 的 MCP catalog 为 32 个工具;JSON-only host consumer 已同时 +验证 bridge 的 denied 与 active-written 两条路径。 + +本轮 hooks 审计结论:上游 OMC/OMX 的 native hook 入口只是 lifecycle script +dispatch,不是一个可单独复制的空 CLI;真实行为还依赖各宿主 stdin schema、脚本目录 +和失败/超时策略。因此本轮只修正 `HookRegistry` 对 Claude 项目配置的路径错误 +(`.claude/settings.json`),保留现有 unified event mapping;不新增一个没有真实 +宿主输入输出契约的 `omc hook` runner。依据:[OMC hooks reference](https://github.com/Yeachan-Heo/oh-my-claudecode/blob/main/docs/REFERENCE.md#hooks-system)、 +[OMX native hook mapping](https://github.com/Yeachan-Heo/oh-my-codex/blob/main/docs/codex-native-hooks.md)。 + +## Agency / 子任务分配 + +每个 agency 只领取一个边界,主线由 OMC-RS 维护者整合: + +| Lane | 负责内容 | 必须交回的证据 | +|---|---|---| +| upstream-research | 官方仓库、版本、许可证、能力清单 | 来源链接、commit/tag、可迁移/不可迁移结论 | +| contract-core | shared schema、错误码、事件、artifact、compat tests | Rust 单测、JSON fixtures、破坏性变更说明 | +| host-integration | Codex/Claude/Hermes/Sentinel 消费适配 | CLI/MCP e2e、宿主输入输出样例 | +| runtime-mapping | omc-team/state/memory/host 映射 | 状态投影测试、无重复核心的边界说明 | +| release-qa | build、doctor、安装、Windows、回归和文档 | 干净工作树命令记录、失败诊断、发布清单 | + +禁止一个 agency 同时改契约、runtime、宿主配置和发布脚本;每个 lane 的 +结果先回到 contract-core/release-qa 做整合验证。 + +当前派发锚点:所有 agency 任务先创建一个 `omc.goal.v1` goal,领取时关联 +`dispatchTaskId`,每个阶段只提交 checkpoint;阻塞必须写入 blocker,未有 +测试/消费证据不得标记 completed。 + +## Ask-match 决策点 + +这些不阻塞 S0,但进入对应阶段前必须确认: + +1. `OMY` 是否就是 `can1357/oh-my-pi`/`omp`;如果不是,提供准确仓库地址。 +2. 产品公开名称是否继续是 `OMC-RS`,CLI 是否固定为 `omc`;当前不引入 + `omx`/`omy` 兼容别名。 +3. Hermes 与 Sentinel 的第一批真实宿主优先级;当前先做宿主中立 fixture, + 再接真实仓库,避免猜内部 API。 +4. 是否允许引入外部运行时依赖;当前默认“不引入 npm runtime”,优先 Rust + 原生和受控外部 CLI adapter。 + +## 总体验收 + +- `cargo fmt --check`、`cargo clippy --workspace -- -D warnings`、 + `cargo test --workspace --no-fail-fast` 通过。 +- release binary 可启动,doctor 能明确报告依赖与配置状态。 +- CLI/MCP 的 capabilities、route、Code Intel query 有真实成功和失败样例。 +- 至少两个宿主类型消费同一份 `omc.tool.v1` fixture;宿主不依赖 OMC-RS + 内部 Rust 类型。 +- 每个内化能力都有来源、边界、测试、回滚/删除条件。 +- OMC-RS 内没有第二套 Agent loop、Provider、tool wheel、plugin marketplace, + 也没有无法验证副作用的生命周期 API。 + +## Stop / escalate 条件 + +- 外部项目许可证或依赖边界不清:暂停导入实现,只保留研究记录。 +- 需要真实宿主私有仓库、凭据或生产环境才能验证:提交 ask-match,不猜测。 +- 新能力要求修改统一契约:先做版本/兼容性评审,不在 agency 分支直接扩展。 +- Windows 与 tmux 语义无法同时满足:保留宿主无关 JSON/CLI/MCP 契约,runtime + 差异下沉到 adapter,不牺牲基础可用性。 diff --git a/docs/upstream-capability-matrix.md b/docs/upstream-capability-matrix.md new file mode 100644 index 0000000..e63bfbb --- /dev/null +++ b/docs/upstream-capability-matrix.md @@ -0,0 +1,96 @@ +# 外部能力内化矩阵 + +这份矩阵是 agency 领取任务前的边界真相。`Keep` 表示已有实现优先复用; +`Adapt` 表示只吸收协议/方法;`Gap` 表示需要新切片;`Reject` 表示不进入 +OMC-RS 核心。 + +## 来源锁定(2026-08-13) + +这里的版本是研究和能力映射的基准,不是 OMC-RS 的运行时依赖。真正导入 +代码或资产前必须重新核对上游 release、commit 和许可证;只保留可验证的 +协议、边界和测试,不复制上游运行时。 + +| 来源 | 基准 release/tag | commit | 许可证证据 | 本仓库决定 | +|---|---|---|---|---| +| [oh-my-codex](https://github.com/Yeachan-Heo/oh-my-codex) | `v0.20.5` | [`27b3a91`](https://github.com/Yeachan-Heo/oh-my-codex/releases/tag/v0.20.5) | MIT(上游 [`package.json`](https://raw.githubusercontent.com/Yeachan-Heo/oh-my-codex/main/package.json) 的 `license` 字段;根 `LICENSE` 路径未找到) | 只吸收 workflow/skill/state 方法;不引入 npm、Codex provider 或 OMX runtime | +| [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode) | `v4.15.10` | [`115ed1f`](https://github.com/Yeachan-Heo/oh-my-claudecode/releases/tag/v4.15.10) | MIT(上游 [`LICENSE`](https://raw.githubusercontent.com/Yeachan-Heo/oh-my-claudecode/main/LICENSE)) | 映射已有 `omc-team`/`omc-host`;不复制 Claude/npm/plugin runtime | +| [oh-my-pi](https://github.com/can1357/oh-my-pi) | `v17.2.15` | [`06aecdd`](https://github.com/can1357/oh-my-pi/releases/tag/v17.2.15) | MIT(上游 [`LICENSE`](https://raw.githubusercontent.com/can1357/oh-my-pi/main/LICENSE)) | 只吸收 typed result、hash edit、URI、只读 LSP 等已验证 adapter | + +如果 `OMY` 不是 `can1357/oh-my-pi`,上表第三行只视为暂定来源;在用户确认 +准确仓库前,不从其他项目导入代码或设计结论。 + +## OMX / OM Codex + +来源:[Yeachan-Heo/oh-my-codex](https://github.com/Yeachan-Heo/oh-my-codex) + +| 外部能力 | OMC-RS 对应面 | 判断 | 验收 | +|---|---|---|---| +| `deep-interview` | `crates/omc-skills/src/templates/deep-interview.md`、`omc-shared` state tools、`omc.workflow.v1` | Keep/Adapt | 状态可恢复,最终 spec 有明确 acceptance criteria;阶段决策不启动执行器 | +| `ralplan` | `crates/omc-skills/src/templates/ralplan.md`、`omc-team/src/agents/ralplan.rs`、`workflow_advance` | Keep/Adapt | 计划、架构、批评和 approval gate 可被宿主消费 | +| `ultrawork` | `omc-skills/src/templates/ultrawork.md`、已有 task graph/team runtime | Adapt | 只保留并行分工和验证协议,不复制执行器 | +| `ultragoal` durable ledger | `omc.goal.v1`、`.omc/state/goals/`、`GoalLedger` | Adapt/P1 slice done | CLI/MCP 能创建、恢复、阻塞、checkpoint、完成;执行仍由宿主/team 负责 | +| `setup` / `doctor` | `omc-cli`、`omc-host` | Keep | `omc setup`、`omc doctor --json` 在临时项目通过 | +| tmux/psmux runtime | `omc-team` host spawn | Adapt | 只作为可选 worker adapter,Windows 无 tmux 时基础契约仍可用 | +| Codex provider/model loop | OMC-RS routing 只输出 semantic tier | Reject | 不能出现第二套 provider 或模型调用循环 | + +## OMC / OM Claude Code + +来源:[Yeachan-Heo/oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode) + +| 外部能力 | OMC-RS 对应面 | 判断 | 验收 | +|---|---|---|---| +| `team-plan -> team-prd -> team-exec -> team-verify -> team-fix` | `omc.workflow.v1`、`crates/omc-skills/src/templates/team.md`、`omc-team` task graph/phase controller | Keep/Adapt | 阶段状态由宿主证据推进,验证失败能回到 fix;不启动第二套执行器 | +| role/skill catalog | `omc-skills` templates、`omc-team` role router | Keep | 同一 catalog 能被 Codex/Claude host adapter 使用 | +| hooks | `crates/omc-hooks`、`omc-host` unified hooks | Keep | 事件映射有 host-specific 输出和回归测试;实际 lifecycle script/宿主入口仍需真实 Hermes/Sentinel 场景后再接 | +| Claude/Codex CLI worker | `omc-host`、`omc-team` spawn directives | Adapt | worker 是宿主进程,不成为 OMC-RS provider 核心;`omc-host/src/mcp_reg.rs` 验证两宿主消费同一 discovery/route/event/result 契约 | +| session/replay/HUD | `omc-team` observability、`omc-hud`、`team_observability` | Keep/Adapt/P1 slice | CLI/MCP 只读投影真实 sessions/top/doctor;不依赖模型自报,不创建伪 task lifecycle | +| npm/plugin marketplace runtime | Rust CLI/MCP/skills 已有入口 | Reject | 不复制市场和安装运行时;只允许受控资源导入 | + +## oh-my-pi / OMP + +来源:[can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) + +这里先按用户此前提供的仓库理解 `OMY`;若用户指另一个项目,必须重新确认 +来源和许可证。 + +| 外部能力 | OMC-RS 对应面 | 判断 | 验收 | +|---|---|---|---| +| typed subagent result | `operation_contract.rs` 的 `TypedSubagentResult` + `omc-team` result projection + CLI/MCP validator | Adapt/P1 slice done | schema 校验失败可见,宿主不解析 prose | +| hash-anchored edit | `omc-shared::hash_edit` + CLI/MCP `hash_edit` | Adapt/P1 slice done | 过期 hash 明确拒绝,成功结果带前后文件 digest | +| URI-shaped resources/artifacts | `ArtifactRef.uri`、Code Intel/goal checkpoint normalization and bounded URI inspection | Adapt/P1 slice done | `omc://artifact/sha256/` must match digest; legacy path-only refs remain readable; provenance is preserved; no duplicate raw-byte reader | +| LSP/code intelligence | Code Intel adapter + `lsp_document_symbols` one-shot rust-analyzer adapter | Adapt/P1 slice | CLI/MCP 返回统一 `omc.tool.v1`,路径、超时、消息大小和副作用边界可验证 | +| debugger | `omc.debug.v1` + `debug_inspect` 的外部 stdio DAP adapter | Adapt/P2 slice done | 显式 launch/attach target;只读 action;5--300 秒超时、4MiB framing、反向请求拒绝、退出清理和真实 host consumer 验证;不下载或托管 debugger adapter | +| Hermes MCP consumer | `omc setup --host hermes` + existing `omc mcp` | Adapt/P1 slice | native Windows `%LOCALAPPDATA%\\hermes\\config.yaml` and POSIX `~/.hermes/config.yaml` register idempotently, preserve siblings, and reject conflicting `omc-rs`; Hermes remains the host's loop/provider | +| OMC↔OMX interop | existing `omc-interop` readers/writers + CLI/MCP `interop_snapshot`/`interop_bridge` | Adapt/P1 slice done | bounded read-only snapshot plus `omc.interop.bridge.v1` explicit task/message writes; active flags and `allowSideEffects` required; no worker start, second scheduler, or fake lifecycle | +| browser | 暂不接入 | Gap/defer | OMP 的 Puppeteer/CDP/共享 Chromium/relay 依赖独立运行时;除非有真实 Hermes/Sentinel 场景,否则不引入浏览器 broker、Chromium 下载器或 relay | +| Python | `omc-python` session-backed subprocess + `python_repl` CLI/MCP adapter | Adapt/P2 slice done | `omc.python.v1`,明确 side-effect gate、session scope、超时重启和真实跨调用状态;不引入 upstream eval/tool bridge | +| vibe/director mode | routing + task delegation | Adapt | 宿主可选择 director 模式,不能创建第二 Agent loop | +| in-process coreutils/tool wheels | `omc-shared`/`omc-mcp` 现有工具 | Reject/Reuse | 先复用现有工具,除非有基准证明需要新增 | +| provider/interactive prompt loop | OMC-RS host-neutral surface | Reject | OMC-RS 不接管宿主模型循环 | +| plugin marketplace | OMC-RS skills/host registration | Reject | 不引入重复插件市场 | + +## Agency 领取规则 + +每个任务必须包含: + +1. 来源 URL、版本/tag/commit 和许可证确认; +2. 上表中的目标模块和 `Keep/Adapt/Gap/Reject` 决定; +3. 最小实现范围与明确 non-goals; +4. 单元测试、跨进程或宿主消费测试; +5. 删除条件:如果不能证明真实 side effect、artifact provenance 或宿主消费, + 不进入主线。 + +## 当前优先级 + +1. **P0**:统一 contract、setup/doctor、CLI/MCP、Code Intel、宿主 fixture。 +2. **P1**:OMX 的 durable goal/checkpoint ledger、OMC Team 阶段投影,以及 + OMP 的 typed result/hash edit/URI artifact 第一批已完成;当前已补齐 + `omc.workflow.v1`/`workflow_advance` 的 clarify-plan-execute-verify-fix + 决策边界、LSP 第一批 `lsp_document_symbols` 和 Python `python_repl` + adapter;debug 已接入受控 DAP adapter,browser 仍暂缓。 +3. **P2**:browser 只有在 Hermes/Sentinel 给出真实消费场景后再排期, + 随后做现有 LSP/Python/debug adapter 的性能优化。 + 当前互操作已落地只读 `interop_snapshot` 和显式 gated `interop_bridge`; + Hermes/Sentinel 的宿主 session/task 消费契约仍需真实场景 ask-match。 +4. **明确不做**:第二 Agent loop、第二 Provider 层、第二套 tool wheels、插件 + 市场、无真实 runtime 的 task lifecycle API。 diff --git a/schemas/mcp-tools-v1.json b/schemas/mcp-tools-v1.json new file mode 100644 index 0000000..826b4bb --- /dev/null +++ b/schemas/mcp-tools-v1.json @@ -0,0 +1,914 @@ +{ + "schemaVersion": "omc.mcp-tools.v1", + "tools": [ + { + "name": "agent_capabilities", + "input_schema": { + "type": "object", + "properties": {} + } + }, + { + "name": "agent_route", + "input_schema": { + "type": "object", + "properties": { + "agentType": { + "type": "string" + }, + "previousFailures": { + "type": "number", + "minimum": 0 + }, + "requestId": { + "type": "string" + }, + "task": { + "type": "string" + } + }, + "required": [ + "task" + ] + } + }, + { + "name": "code_intel_artifact_query", + "input_schema": { + "type": "object", + "properties": { + "artifactRoot": { + "type": "string" + }, + "artifactSchema": { + "type": "string" + }, + "artifactType": { + "type": "string" + }, + "artifactUri": { + "type": "string" + }, + "contains": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "repo": { + "type": "string" + }, + "repoPath": { + "type": "string" + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "repo" + ] + } + }, + { + "name": "debug_inspect", + "input_schema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "loadedSources", + "modules", + "output", + "scopes", + "stackTrace", + "threads", + "variables" + ] + }, + "adapterArgs": { + "type": "array" + }, + "adapterCommand": { + "type": "string" + }, + "allowSideEffects": { + "type": "boolean" + }, + "attachArguments": { + "type": "object" + }, + "frameId": { + "type": "integer", + "minimum": 1 + }, + "launchArguments": { + "type": "object" + }, + "mode": { + "type": "string", + "enum": [ + "attach", + "launch" + ] + }, + "requestId": { + "type": "string" + }, + "threadId": { + "type": "integer", + "minimum": 1 + }, + "timeoutMs": { + "type": "integer", + "minimum": 5000, + "maximum": 300000 + }, + "variablesReference": { + "type": "integer", + "minimum": 1 + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "action", + "adapterCommand", + "allowSideEffects", + "mode" + ] + } + }, + { + "name": "goal_block", + "input_schema": { + "type": "object", + "properties": { + "goalId": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "goalId", + "reason" + ] + } + }, + { + "name": "goal_checkpoint", + "input_schema": { + "type": "object", + "properties": { + "artifactRefs": { + "type": "array" + }, + "checkpointId": { + "type": "string" + }, + "goalId": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "checkpointId", + "goalId", + "summary" + ] + } + }, + { + "name": "goal_complete", + "input_schema": { + "type": "object", + "properties": { + "goalId": { + "type": "string" + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "goalId" + ] + } + }, + { + "name": "goal_create", + "input_schema": { + "type": "object", + "properties": { + "goalId": { + "type": "string" + }, + "objective": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "goalId", + "objective" + ] + } + }, + { + "name": "goal_get", + "input_schema": { + "type": "object", + "properties": { + "goalId": { + "type": "string" + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "goalId" + ] + } + }, + { + "name": "goal_list", + "input_schema": { + "type": "object", + "properties": { + "workingDirectory": { + "type": "string" + } + } + } + }, + { + "name": "goal_start", + "input_schema": { + "type": "object", + "properties": { + "goalId": { + "type": "string" + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "goalId" + ] + } + }, + { + "name": "hash_edit", + "input_schema": { + "type": "object", + "properties": { + "anchors": { + "type": "array" + }, + "endLine": { + "type": "number", + "minimum": 0 + }, + "expectedFileSha256": { + "type": "string" + }, + "path": { + "type": "string" + }, + "replacement": { + "type": "string" + }, + "requestId": { + "type": "string" + }, + "startLine": { + "type": "number", + "minimum": 0 + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "anchors", + "endLine", + "path", + "replacement", + "startLine" + ] + } + }, + { + "name": "interop_bridge", + "input_schema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "send_message", + "send_task" + ] + }, + "allowSideEffects": { + "type": "boolean" + }, + "content": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requestId": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "omc", + "omx" + ] + }, + "target": { + "type": "string", + "enum": [ + "omc", + "omx" + ] + }, + "type": { + "type": "string", + "enum": [ + "analyze", + "custom", + "implement", + "review", + "test" + ] + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "action", + "allowSideEffects", + "source", + "target" + ] + } + }, + { + "name": "interop_snapshot", + "input_schema": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "requestId": { + "type": "string" + }, + "workingDirectory": { + "type": "string" + } + } + } + }, + { + "name": "lsp_document_symbols", + "input_schema": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "requestId": { + "type": "string" + }, + "timeoutMs": { + "type": "integer", + "minimum": 5000, + "maximum": 60000 + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "file" + ] + } + }, + { + "name": "notepad_read", + "input_schema": { + "type": "object", + "properties": { + "section": { + "type": "string", + "enum": [ + "all", + "manual", + "priority", + "working" + ] + }, + "workingDirectory": { + "type": "string" + } + } + } + }, + { + "name": "notepad_write_manual", + "input_schema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "max_length": 4000 + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "content" + ] + } + }, + { + "name": "notepad_write_priority", + "input_schema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "max_length": 2000 + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "content" + ] + } + }, + { + "name": "notepad_write_working", + "input_schema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "max_length": 4000 + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "content" + ] + } + }, + { + "name": "project_memory_add_directive", + "input_schema": { + "type": "object", + "properties": { + "context": { + "type": "string", + "max_length": 500 + }, + "directive": { + "type": "string", + "max_length": 500 + }, + "priority": { + "type": "string", + "enum": [ + "high", + "normal" + ] + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "directive" + ] + } + }, + { + "name": "project_memory_add_note", + "input_schema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "max_length": 50 + }, + "content": { + "type": "string", + "max_length": 1000 + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "category", + "content" + ] + } + }, + { + "name": "project_memory_read", + "input_schema": { + "type": "object", + "properties": { + "section": { + "type": "string", + "enum": [ + "all", + "build", + "conventions", + "directives", + "notes", + "structure", + "techStack" + ] + }, + "workingDirectory": { + "type": "string" + } + } + } + }, + { + "name": "project_memory_write", + "input_schema": { + "type": "object", + "properties": { + "memory": { + "type": "object" + }, + "merge": { + "type": "boolean" + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "memory" + ] + } + }, + { + "name": "python_repl", + "input_schema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "execute", + "get_state", + "interrupt", + "reset" + ] + }, + "allowSideEffects": { + "type": "boolean" + }, + "code": { + "type": "string" + }, + "executionTimeout": { + "type": "integer", + "minimum": 1, + "maximum": 300000 + }, + "projectDir": { + "type": "string" + }, + "requestId": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "action", + "sessionId" + ] + } + }, + { + "name": "state_clear", + "input_schema": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "autopilot", + "autoresearch", + "deep-interview", + "omc-teams", + "ralph", + "ralplan", + "self-improve", + "skill-active", + "team", + "ultraqa", + "ultrawork" + ] + }, + "session_id": { + "type": "string" + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "mode" + ] + } + }, + { + "name": "state_get_status", + "input_schema": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "autopilot", + "autoresearch", + "deep-interview", + "omc-teams", + "ralph", + "ralplan", + "self-improve", + "skill-active", + "team", + "ultraqa", + "ultrawork" + ] + }, + "session_id": { + "type": "string" + }, + "workingDirectory": { + "type": "string" + } + } + } + }, + { + "name": "state_list_active", + "input_schema": { + "type": "object", + "properties": { + "session_id": { + "type": "string" + }, + "workingDirectory": { + "type": "string" + } + } + } + }, + { + "name": "state_read", + "input_schema": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "autopilot", + "autoresearch", + "deep-interview", + "omc-teams", + "ralph", + "ralplan", + "self-improve", + "skill-active", + "team", + "ultraqa", + "ultrawork" + ] + }, + "session_id": { + "type": "string" + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "mode" + ] + } + }, + { + "name": "state_write", + "input_schema": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "completed_at": { + "type": "string", + "max_length": 100 + }, + "current_phase": { + "type": "string", + "max_length": 200 + }, + "error": { + "type": "string", + "max_length": 2000 + }, + "iteration": { + "type": "number" + }, + "max_iterations": { + "type": "number" + }, + "mode": { + "type": "string", + "enum": [ + "autopilot", + "autoresearch", + "deep-interview", + "omc-teams", + "ralph", + "ralplan", + "self-improve", + "skill-active", + "team", + "ultraqa", + "ultrawork" + ] + }, + "plan_path": { + "type": "string", + "max_length": 500 + }, + "session_id": { + "type": "string" + }, + "started_at": { + "type": "string", + "max_length": 100 + }, + "state": { + "type": "object" + }, + "task_description": { + "type": "string", + "max_length": 2000 + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "mode" + ] + } + }, + { + "name": "subagent_result_validate", + "input_schema": { + "type": "object", + "properties": { + "payload": { + "type": "object" + }, + "requestId": { + "type": "string" + }, + "requiredFields": { + "type": "array" + }, + "resultType": { + "type": "string" + } + }, + "required": [ + "payload", + "resultType" + ] + } + }, + { + "name": "team_observability", + "input_schema": { + "type": "object", + "properties": { + "requestId": { + "type": "string" + }, + "view": { + "type": "string", + "enum": [ + "doctor", + "sessions", + "top" + ] + }, + "workingDirectory": { + "type": "string" + } + }, + "required": [ + "view" + ] + } + }, + { + "name": "workflow_advance", + "input_schema": { + "type": "object", + "properties": { + "allTasksAssigned": { + "type": "boolean" + }, + "allTasksCompleted": { + "type": "boolean" + }, + "currentStage": { + "type": "string" + }, + "fixAttempts": { + "type": "number", + "minimum": 0 + }, + "hasBlockers": { + "type": "boolean" + }, + "hasFailures": { + "type": "boolean" + }, + "maxFixAttempts": { + "type": "number", + "minimum": 0 + }, + "planApproved": { + "type": "boolean" + }, + "requestId": { + "type": "string" + }, + "requirementsClarified": { + "type": "boolean" + }, + "verificationPassed": { + "type": "boolean" + } + }, + "required": [ + "currentStage" + ] + } + } + ] +} diff --git a/scripts/package-omc.ps1 b/scripts/package-omc.ps1 new file mode 100644 index 0000000..fb6f538 --- /dev/null +++ b/scripts/package-omc.ps1 @@ -0,0 +1,85 @@ +[CmdletBinding()] +param( + [string]$ReleaseDirectory = (Join-Path $PSScriptRoot "..\target\release"), + [string]$PackageDirectory = (Join-Path $PSScriptRoot "..\target\omc-package"), + [switch]$Build +) + +$ErrorActionPreference = "Stop" + +function Get-WorkspaceVersion { + $metadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + $cli = $metadata.packages | Where-Object { $_.name -eq "omc-cli" } | Select-Object -First 1 + if ($null -eq $cli) { + throw "workspace package omc-cli was not found" + } + return $cli.version +} + +function Get-RequiredBinary([string]$name) { + $path = Join-Path $ReleaseDirectory "$name.exe" + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "missing release binary: $path (run cargo build --release -p omc-cli -p omc-mcp -p omc-team)" + } + return (Get-Item -LiteralPath $path) +} + +if ($Build) { + cargo build --release -p omc-cli -p omc-mcp -p omc-team + if ($LASTEXITCODE -ne 0) { + throw "release build failed with exit code $LASTEXITCODE" + } +} + +$binaries = @( + Get-RequiredBinary "omc" + Get-RequiredBinary "omc-mcp" + Get-RequiredBinary "omc-team" +) + +New-Item -ItemType Directory -Path $PackageDirectory -Force | Out-Null +foreach ($binary in $binaries) { + Copy-Item -LiteralPath $binary.FullName -Destination (Join-Path $PackageDirectory $binary.Name) -Force +} + +$checksums = [ordered]@{} +foreach ($binary in $binaries) { + $copied = Join-Path $PackageDirectory $binary.Name + $checksums[$binary.Name] = (Get-FileHash -LiteralPath $copied -Algorithm SHA256).Hash.ToLowerInvariant() +} + +$manifest = [ordered]@{ + schemaVersion = "omc.release-bundle.v1" + product = "OMC-RS" + version = Get-WorkspaceVersion + entrypoint = "omc.exe" + binaries = @("omc.exe", "omc-mcp.exe", "omc-team.exe") + notes = @( + "omc.exe is the unified CLI and embedded MCP entrypoint." + "omc team delegates to the adjacent omc-team.exe runtime." + "omc-mcp.exe is retained as a compatibility MCP entrypoint." + ) + sha256 = $checksums +} +$manifest | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $PackageDirectory "omc-bundle.json") -Encoding utf8 + +$versionOutput = & (Join-Path $PackageDirectory "omc.exe") --version 2>&1 +if ($LASTEXITCODE -ne 0) { + throw "packaged omc.exe failed --version: $versionOutput" +} + +$capabilityOutput = & (Join-Path $PackageDirectory "omc.exe") tool capabilities --request-id package-smoke 2>&1 +if ($LASTEXITCODE -ne 0) { + throw "packaged omc.exe failed capability smoke: $capabilityOutput" +} +$capabilityResponse = $capabilityOutput | ConvertFrom-Json +if ($capabilityResponse.ok -ne $true -or $null -eq $capabilityResponse.data.capabilities) { + throw "packaged capability smoke returned an invalid omc.tool.v1 response" +} + +[ordered]@{ + packageDirectory = (Resolve-Path -LiteralPath $PackageDirectory).Path + version = $manifest.version + binaries = $manifest.binaries + capabilityCount = @($capabilityResponse.data.capabilities).Count +} | ConvertTo-Json -Depth 4 diff --git a/tests/host-consumer/README.md b/tests/host-consumer/README.md new file mode 100644 index 0000000..b6fa426 --- /dev/null +++ b/tests/host-consumer/README.md @@ -0,0 +1,49 @@ +# OMC-RS host consumer fixture + +This fixture is intentionally not a Rust crate and imports no OMC-RS module. +It consumes only the public JSON envelope through both supported transports: + +- CLI: `omc tool capabilities` plus a machine-readable side-effect failure; +- CLI/MCP: the read-only `team_observability` projection of existing team state; +- CLI/MCP: `code_intel_artifact_query` against a committed, current Code Intel run; +- MCP stdio: `initialize`, `tools/list`, and `tools/call`. + +Run it against a release build from the repository root: + +```bash +python tests/host-consumer/consumer.py --omc target/release/omc +``` + +On Windows: + +```powershell +python tests/host-consumer/consumer.py --omc .\target\release\omc.exe +``` + +To include the real Code Intel check, provide its published artifact root. The +fixture then requires a committed/current `code_evidence.agent_slice` result on +both CLI and MCP: + +```powershell +$env:CODE_INTEL_ARTIFACT_ROOT = "C:\path\to\code-intel\artifacts" +python tests/host-consumer/consumer.py ` + --omc .\target\release\omc.exe ` + --artifact-root $env:CODE_INTEL_ARTIFACT_ROOT ` + --repo omc-rs-src ` + --repo-path (Get-Location) +``` + +Hermes, Sentinel, or another host can copy the JSON parsing boundary from this +fixture without depending on OMC-RS Rust types. A successful run proves only +transport and contract consumption; it does not claim that a host's own agent +loop or private credentials are available. + +Discovery latency and catalog-size budgets: + +```powershell +python tests/host-consumer/benchmark.py --omc .\target\release\omc.exe +``` + +This measures cold CLI discovery and repeated discovery through one persistent +MCP process. It also fails if the released surface drifts from 16 capabilities +or 32 tools. diff --git a/tests/host-consumer/benchmark.py b/tests/host-consumer/benchmark.py new file mode 100644 index 0000000..6871c5a --- /dev/null +++ b/tests/host-consumer/benchmark.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Measure OMC-RS discovery latency with no third-party dependencies.""" + +import argparse +import json +import statistics +import subprocess +import time + + +def summary(samples): + ordered = sorted(samples) + p95 = ordered[max(0, int(len(ordered) * 0.95) - 1)] + return {"min_ms": round(ordered[0], 2), "median_ms": round(statistics.median(ordered), 2), "p95_ms": round(p95, 2), "max_ms": round(ordered[-1], 2)} + + +def timed_cli(binary, iterations): + samples = [] + count = 0 + for _ in range(iterations): + started = time.perf_counter() + result = subprocess.run([binary, "tool", "capabilities"], check=True, capture_output=True, text=True, encoding="utf-8") + samples.append((time.perf_counter() - started) * 1000) + count = len(json.loads(result.stdout)["data"]["capabilities"]) + return samples, count + + +def rpc(process, request): + started = time.perf_counter() + process.stdin.write(json.dumps(request) + "\n") + process.stdin.flush() + response = json.loads(process.stdout.readline()) + if "error" in response: + raise RuntimeError(response["error"]) + return (time.perf_counter() - started) * 1000, response["result"] + + +def timed_mcp(binary, iterations): + process = subprocess.Popen([binary, "mcp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8") + try: + rpc(process, {"jsonrpc": "2.0", "id": 0, "method": "initialize"}) + list_ms, listed = rpc(process, {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + samples = [] + for index in range(iterations): + elapsed, _ = rpc(process, {"jsonrpc": "2.0", "id": index + 2, "method": "tools/call", "params": {"name": "agent_capabilities", "arguments": {}}}) + samples.append(elapsed) + return samples, list_ms, len(listed["tools"]) + finally: + process.stdin.close() + process.wait(timeout=5) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--omc", required=True) + parser.add_argument("--iterations", type=int, default=20) + parser.add_argument("--cli-p95-budget-ms", type=float, default=250) + parser.add_argument("--mcp-p95-budget-ms", type=float, default=50) + args = parser.parse_args() + if args.iterations < 2: + parser.error("--iterations must be at least 2") + + cli, capability_count = timed_cli(args.omc, args.iterations) + mcp, list_ms, tool_count = timed_mcp(args.omc, args.iterations) + report = { + "schema_version": "omc.performance-baseline.v1", + "iterations": args.iterations, + "capability_count": capability_count, + "tool_count": tool_count, + "cli_cold_discovery": summary(cli), + "mcp_warm_discovery": summary(mcp), + "mcp_tools_list_ms": round(list_ms, 2), + "budgets_ms": {"cli_cold_p95": args.cli_p95_budget_ms, "mcp_warm_p95": args.mcp_p95_budget_ms}, + } + report["within_budget"] = report["cli_cold_discovery"]["p95_ms"] <= args.cli_p95_budget_ms and report["mcp_warm_discovery"]["p95_ms"] <= args.mcp_p95_budget_ms and capability_count == 16 and tool_count == 32 + print(json.dumps(report, indent=2)) + raise SystemExit(0 if report["within_budget"] else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/host-consumer/consumer.py b/tests/host-consumer/consumer.py new file mode 100644 index 0000000..cb29235 --- /dev/null +++ b/tests/host-consumer/consumer.py @@ -0,0 +1,567 @@ +#!/usr/bin/env python3 +"""Consume OMC-RS through JSON only, without importing OMC-RS internals.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from typing import Any + + +SCHEMA_VERSION = "omc.tool.v1" + + +class ConsumerError(RuntimeError): + pass + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ConsumerError(message) + + +def decode_json(raw: str, label: str) -> dict[str, Any]: + try: + value = json.loads(raw) + except json.JSONDecodeError as error: + raise ConsumerError(f"{label} was not JSON: {error}: {raw!r}") from error + require(isinstance(value, dict), f"{label} must be a JSON object") + return value + + +def validate_tool_response(value: dict[str, Any], request_id: str) -> dict[str, Any]: + require(value.get("schema_version") == SCHEMA_VERSION, "wrong tool schema version") + require(value.get("request_id") == request_id, "request ID was not preserved") + return value + + +def validate_code_intel_response(value: dict[str, Any], request_id: str) -> dict[str, Any]: + response = validate_tool_response(value, request_id) + require(response.get("ok") is True, "Code Intel query was not successful") + data = response.get("data") + require(isinstance(data, dict), "Code Intel data is not an object") + require( + data.get("schemaVersion") == "omc.code-intel.query.v1", + "Code Intel query schema missing", + ) + require(data.get("readOnly") is True, "Code Intel query was not read-only") + refs = data.get("artifactRefs") + require(isinstance(refs, list) and refs, "Code Intel query returned no artifact refs") + result = data.get("result") + require(isinstance(result, dict), "Code Intel result is not an object") + require(result.get("runOutcome") == "completed", "Code Intel run was not completed") + require( + result.get("authority", {}).get("status") == "committed", + "Code Intel result was not committed", + ) + require( + result.get("freshness", {}).get("status") == "current", + "Code Intel result was not current", + ) + return response + + +def validate_interop_response(value: dict[str, Any], request_id: str) -> dict[str, Any]: + response = validate_tool_response(value, request_id) + require(response.get("ok") is True, "interop snapshot was not successful") + data = response.get("data") + require(isinstance(data, dict), "interop snapshot data is not an object") + require( + data.get("schemaVersion") == "omc.interop.snapshot.v1", + "interop snapshot schema missing", + ) + require(data.get("readOnly") is True, "interop snapshot was not read-only") + require(isinstance(data.get("sharedTaskCount"), int), "interop task count missing") + normalized = data.get("normalizedTasks") + require(isinstance(normalized, list), "normalized interop task list missing") + statuses = {"pending", "blocked", "in_progress", "completed", "failed"} + require( + all(isinstance(task, dict) and task.get("status") in statuses for task in normalized), + "normalized interop task status was not portable", + ) + require(isinstance(data.get("omxTeams"), list), "interop OMX team list missing") + return response + + +def validate_bridge_response(value: dict[str, Any], request_id: str) -> dict[str, Any]: + response = validate_tool_response(value, request_id) + require(response.get("ok") is True, "interop bridge was not successful") + data = response.get("data") + require(isinstance(data, dict), "interop bridge data is not an object") + require( + data.get("schemaVersion") == "omc.interop.bridge.v1", + "interop bridge schema missing", + ) + require(data.get("readOnly") is False, "interop bridge unexpectedly read-only") + require(data.get("writeEnabled") is True, "interop bridge write gate was not reported") + require(data.get("source") != data.get("target"), "interop bridge endpoints collapsed") + require(isinstance(data.get("task"), dict) or isinstance(data.get("message"), dict), "interop bridge payload missing") + return response + + +def run_cli( + omc: str, + artifact_root: str | None, + repo: str, + repo_path: str, + interop_root: str, + require_active: bool, +) -> dict[str, Any]: + success = subprocess.run( + [omc, "tool", "capabilities", "--request-id", "host-consumer-cli"], + capture_output=True, + text=True, + check=False, + ) + require(success.returncode == 0, f"CLI capabilities failed: {success.stderr}") + capabilities = validate_tool_response( + decode_json(success.stdout, "CLI capabilities"), "host-consumer-cli" + ) + require(capabilities.get("ok") is True, "CLI capabilities was not successful") + data = capabilities.get("data") + require(isinstance(data, dict), "CLI capabilities data is not an object") + require(data.get("product") == "omc-rs", "unexpected OMC product") + names = sorted( + item.get("name") + for item in data.get("capabilities", []) + if isinstance(item, dict) and isinstance(item.get("name"), str) + ) + require("agent_route" in names, "CLI catalog omitted agent_route") + require("team_observability" in names, "CLI catalog omitted team_observability") + + team = subprocess.run( + [ + omc, + "tool", + "team-observability", + "--view", + "sessions", + "--root", + ".", + "--request-id", + "host-consumer-team", + ], + capture_output=True, + text=True, + check=False, + ) + require(team.returncode == 0, f"CLI team observability failed: {team.stderr}") + team_response = validate_tool_response( + decode_json(team.stdout, "CLI team observability"), "host-consumer-team" + ) + require(team_response.get("ok") is True, "CLI team observability was not successful") + require( + team_response.get("data", {}).get("schemaVersion") == "omc.team-observability.v1", + "CLI team observability schema missing", + ) + + interop = subprocess.run( + [ + omc, + "tool", + "interop-snapshot", + "--root", + ".", + "--limit", + "5", + "--request-id", + "host-consumer-interop", + ], + capture_output=True, + text=True, + check=False, + ) + require(interop.returncode == 0, f"CLI interop snapshot failed: {interop.stderr}") + interop_response = validate_interop_response( + decode_json(interop.stdout, "CLI interop snapshot"), "host-consumer-interop" + ) + + bridge = subprocess.run( + [ + omc, + "tool", + "interop-bridge", + "--action", + "send_message", + "--source", + "omc", + "--target", + "omx", + "--content", + "host consumer bridge", + "--root", + interop_root, + "--allow-side-effects", + "--request-id", + "host-consumer-bridge", + ], + capture_output=True, + text=True, + check=False, + ) + require(bridge.returncode == 0, f"CLI interop bridge failed: {bridge.stderr}") + bridge_value = validate_tool_response( + decode_json(bridge.stdout, "CLI interop bridge"), "host-consumer-bridge" + ) + if require_active: + validate_bridge_response(bridge_value, "host-consumer-bridge") + else: + require(bridge_value.get("ok") is False, "CLI bridge unexpectedly wrote state") + require( + bridge_value.get("error", {}).get("code") == "side_effects_not_allowed", + "CLI bridge denial did not expose a stable error code", + ) + + code_intel_response: dict[str, Any] | None = None + if artifact_root is not None: + code_intel = subprocess.run( + [ + omc, + "tool", + "code-intel-query", + "--repo", + repo, + "--artifact-root", + artifact_root, + "--repo-path", + repo_path, + "--artifact-type", + "code_evidence.agent_slice", + "--limit", + "3", + "--request-id", + "host-consumer-code-intel", + ], + capture_output=True, + text=True, + check=False, + ) + require(code_intel.returncode == 0, f"CLI Code Intel failed: {code_intel.stderr}") + code_intel_response = validate_code_intel_response( + decode_json(code_intel.stdout, "CLI Code Intel"), + "host-consumer-code-intel", + ) + + denied = subprocess.run( + [ + omc, + "tool", + "python-repl", + "--action", + "execute", + "--session-id", + "host-consumer-denied", + "--code", + "print(1)", + "--request-id", + "host-consumer-failure", + ], + capture_output=True, + text=True, + check=False, + ) + require(denied.returncode == 0, f"CLI failure fixture process failed: {denied.stderr}") + failure = validate_tool_response( + decode_json(denied.stdout, "CLI failure fixture"), "host-consumer-failure" + ) + require(failure.get("ok") is False, "CLI failure fixture unexpectedly succeeded") + require( + failure.get("error", {}).get("code") == "side_effects_not_allowed", + "CLI failure did not expose a stable error code", + ) + result = { + "capability_count": len(names), + "failure_code": failure["error"]["code"], + "team_view": team_response["data"]["view"], + "interop_mode": interop_response["data"]["interopMode"], + "interop_omx_team_count": len(interop_response["data"]["omxTeams"]), + "interop_normalized_task_count": interop_response["data"]["normalizedTaskCount"], + "interop_bridge": "written" if require_active else "denied", + } + if code_intel_response is not None: + code_intel_data = code_intel_response["data"] + result["code_intel_artifact_count"] = len(code_intel_data["artifactRefs"]) + result["code_intel_run"] = code_intel_data["result"]["run"] + return result + + +class McpClient: + def __init__(self, omc: str) -> None: + self.process = subprocess.Popen( + [omc, "mcp"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + ) + require(self.process.stdin is not None, "MCP stdin was not opened") + require(self.process.stdout is not None, "MCP stdout was not opened") + + def call(self, request_id: int, method: str, params: dict[str, Any]) -> dict[str, Any]: + request = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} + assert self.process.stdin is not None + assert self.process.stdout is not None + self.process.stdin.write(json.dumps(request) + "\n") + self.process.stdin.flush() + line = self.process.stdout.readline() + require(line != "", f"MCP closed before replying to {method}") + response = decode_json(line, f"MCP {method}") + require(response.get("id") == request_id, f"MCP ID mismatch for {method}") + require("error" not in response, f"MCP returned an error for {method}: {response}") + result = response.get("result") + require(isinstance(result, dict), f"MCP {method} result is not an object") + return result + + def close(self) -> None: + if self.process.stdin is not None: + self.process.stdin.close() + self.process.wait(timeout=10) + require(self.process.returncode == 0, "MCP process did not exit cleanly") + + +def run_mcp( + omc: str, + artifact_root: str | None, + repo: str, + repo_path: str, + interop_root: str, + require_active: bool, +) -> dict[str, Any]: + client = McpClient(omc) + try: + initialized = client.call(1, "initialize", {}) + require(initialized.get("serverInfo", {}).get("name") == "omc-mcp", "wrong MCP server") + + listed = client.call(2, "tools/list", {}) + tools = listed.get("tools") + require(isinstance(tools, list), "MCP tools/list did not return an array") + names = sorted( + tool.get("name") + for tool in tools + if isinstance(tool, dict) and isinstance(tool.get("name"), str) + ) + require("agent_capabilities" in names, "MCP catalog omitted agent_capabilities") + require("team_observability" in names, "MCP catalog omitted team_observability") + if artifact_root is not None: + require( + "code_intel_artifact_query" in names, + "MCP catalog omitted code_intel_artifact_query", + ) + + called = client.call( + 3, + "tools/call", + { + "name": "agent_capabilities", + "arguments": {"requestId": "host-consumer-mcp"}, + }, + ) + content = called.get("content") + require(isinstance(content, list) and content, "MCP tool result has no content") + block = content[0] + require(isinstance(block, dict) and isinstance(block.get("text"), str), "MCP text block missing") + response = validate_tool_response( + decode_json(block["text"], "MCP agent_capabilities"), "host-consumer-mcp" + ) + require(response.get("ok") is True, "MCP capabilities was not successful") + team_called = client.call( + 4, + "tools/call", + { + "name": "team_observability", + "arguments": { + "view": "sessions", + "workingDirectory": ".", + "requestId": "host-consumer-team-mcp", + }, + }, + ) + team_content = team_called.get("content") + require( + isinstance(team_content, list) and team_content, + "MCP team observability result has no content", + ) + team_block = team_content[0] + require( + isinstance(team_block, dict) and isinstance(team_block.get("text"), str), + "MCP team observability text block missing", + ) + team_response = validate_tool_response( + decode_json(team_block["text"], "MCP team observability"), + "host-consumer-team-mcp", + ) + require(team_response.get("ok") is True, "MCP team observability was not successful") + require( + team_response.get("data", {}).get("schemaVersion") + == "omc.team-observability.v1", + "MCP team observability schema missing", + ) + interop_called = client.call( + 5, + "tools/call", + { + "name": "interop_snapshot", + "arguments": { + "workingDirectory": ".", + "limit": 5, + "requestId": "host-consumer-interop-mcp", + }, + }, + ) + interop_content = interop_called.get("content") + require( + isinstance(interop_content, list) and interop_content, + "MCP interop snapshot result has no content", + ) + interop_block = interop_content[0] + require( + isinstance(interop_block, dict) and isinstance(interop_block.get("text"), str), + "MCP interop snapshot text block missing", + ) + interop_response = validate_interop_response( + decode_json(interop_block["text"], "MCP interop snapshot"), + "host-consumer-interop-mcp", + ) + bridge_called = client.call( + 6, + "tools/call", + { + "name": "interop_bridge", + "arguments": { + "action": "send_message", + "source": "omc", + "target": "omx", + "content": "host consumer bridge", + "workingDirectory": interop_root, + "allowSideEffects": True, + "requestId": "host-consumer-bridge-mcp", + }, + }, + ) + bridge_content = bridge_called.get("content") + require( + isinstance(bridge_content, list) and bridge_content, + "MCP interop bridge result has no content", + ) + bridge_block = bridge_content[0] + require( + isinstance(bridge_block, dict) and isinstance(bridge_block.get("text"), str), + "MCP interop bridge text block missing", + ) + bridge_value = validate_tool_response( + decode_json(bridge_block["text"], "MCP interop bridge"), + "host-consumer-bridge-mcp", + ) + if require_active: + validate_bridge_response(bridge_value, "host-consumer-bridge-mcp") + else: + require(bridge_value.get("ok") is False, "MCP bridge unexpectedly wrote state") + require( + bridge_value.get("error", {}).get("code") == "side_effects_not_allowed", + "MCP bridge denial did not expose a stable error code", + ) + code_intel_response: dict[str, Any] | None = None + if artifact_root is not None: + code_intel_called = client.call( + 7, + "tools/call", + { + "name": "code_intel_artifact_query", + "arguments": { + "repo": repo, + "artifactRoot": artifact_root, + "repoPath": repo_path, + "artifactType": "code_evidence.agent_slice", + "limit": 3, + "requestId": "host-consumer-code-intel-mcp", + }, + }, + ) + code_intel_content = code_intel_called.get("content") + require( + isinstance(code_intel_content, list) and code_intel_content, + "MCP Code Intel result has no content", + ) + code_intel_block = code_intel_content[0] + require( + isinstance(code_intel_block, dict) + and isinstance(code_intel_block.get("text"), str), + "MCP Code Intel text block missing", + ) + code_intel_response = validate_code_intel_response( + decode_json(code_intel_block["text"], "MCP Code Intel"), + "host-consumer-code-intel-mcp", + ) + + result = { + "tool_count": len(names), + "capability_count": len(response["data"]["capabilities"]), + "team_view": team_response["data"]["view"], + "interop_mode": interop_response["data"]["interopMode"], + "interop_omx_team_count": len(interop_response["data"]["omxTeams"]), + "interop_normalized_task_count": interop_response["data"]["normalizedTaskCount"], + "interop_bridge": "written" if require_active else "denied", + } + if code_intel_response is not None: + code_intel_data = code_intel_response["data"] + result["code_intel_artifact_count"] = len(code_intel_data["artifactRefs"]) + result["code_intel_run"] = code_intel_data["result"]["run"] + return result + finally: + client.close() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--omc", default="omc", help="omc executable or PATH name") + parser.add_argument( + "--artifact-root", + help="published Code Intel artifact root; enables real Code Intel CLI/MCP checks", + ) + parser.add_argument("--repo", default="omc-rs-src", help="Code Intel repository key") + parser.add_argument( + "--repo-path", + default=".", + help="checkout used for Code Intel freshness evaluation", + ) + parser.add_argument( + "--interop-root", + default=".", + help="temporary project root used by the interop bridge fixture", + ) + parser.add_argument( + "--require-active-interop", + action="store_true", + help="require the active bridge to write a real task/message record", + ) + args = parser.parse_args() + try: + result = { + "consumer": "json-only-host-consumer", + "cli": run_cli( + args.omc, + args.artifact_root, + args.repo, + args.repo_path, + args.interop_root, + args.require_active_interop, + ), + "mcp": run_mcp( + args.omc, + args.artifact_root, + args.repo, + args.repo_path, + args.interop_root, + args.require_active_interop, + ), + } + except (ConsumerError, OSError, subprocess.SubprocessError) as error: + print(f"host-consumer failed: {error}", file=sys.stderr) + return 1 + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/host-consumer/lsp_benchmark.py b/tests/host-consumer/lsp_benchmark.py new file mode 100644 index 0000000..8ff6df0 --- /dev/null +++ b/tests/host-consumer/lsp_benchmark.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Measure cold/warm project LSP latency through one real MCP process.""" + +import argparse +import json +import math +import pathlib +import statistics +import subprocess +import tempfile +import time + + +def rpc(process, request): + started = time.perf_counter() + process.stdin.write(json.dumps(request) + "\n") + process.stdin.flush() + response = json.loads(process.stdout.readline()) + elapsed_ms = (time.perf_counter() - started) * 1000 + envelope = json.loads(response["result"]["content"][0]["text"]) + if not envelope.get("ok"): + raise RuntimeError(envelope) + return elapsed_ms, envelope["data"] + + +def percentile_95(samples): + ordered = sorted(samples) + return ordered[max(0, math.ceil(len(ordered) * 0.95) - 1)] + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--omc", required=True) + parser.add_argument("--warm-iterations", type=int, default=5) + parser.add_argument("--warm-p95-budget-ms", type=float, default=1000) + args = parser.parse_args() + if args.warm_iterations < 2: + parser.error("--warm-iterations must be at least 2") + + with tempfile.TemporaryDirectory(prefix="omc-lsp-benchmark-") as directory: + root = pathlib.Path(directory) + (root / "src").mkdir() + (root / "Cargo.toml").write_text( + "[package]\nname='omc-lsp-benchmark'\nversion='0.1.0'\nedition='2024'\n", + encoding="utf-8", + ) + (root / "src" / "lib.rs").write_text("pub fn answer() -> u32 { 42 }\n", encoding="utf-8") + process = subprocess.Popen( + [args.omc, "mcp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, encoding="utf-8", + ) + try: + def request(identifier): + return rpc(process, {"jsonrpc": "2.0", "id": identifier, "method": "tools/call", "params": { + "name": "lsp_document_symbols", "arguments": { + "workingDirectory": str(root), "file": "src/lib.rs", "timeoutMs": 60000, + }, + }}) + + cold_ms, cold = request(1) + warm_samples = [] + warm_payloads = [] + for identifier in range(2, args.warm_iterations + 2): + elapsed, payload = request(identifier) + warm_samples.append(elapsed) + warm_payloads.append(payload) + finally: + process.stdin.close() + process.wait(timeout=5) + + warm_p95 = percentile_95(warm_samples) + same_process = all(item["serverProcessId"] == cold["serverProcessId"] for item in warm_payloads) + reused = all(item["sessionReused"] is True for item in warm_payloads) + within_budget = warm_p95 <= args.warm_p95_budget_ms and warm_p95 < cold_ms and same_process and reused + report = { + "schemaVersion": "omc.lsp-benchmark.v1", + "coldMs": round(cold_ms, 2), + "warm": { + "medianMs": round(statistics.median(warm_samples), 2), + "p95Ms": round(warm_p95, 2), + "samples": len(warm_samples), + }, + "warmP95BudgetMs": args.warm_p95_budget_ms, + "sameProcess": same_process, + "sessionReused": reused, + "withinBudget": within_budget, + } + print(json.dumps(report, indent=2)) + raise SystemExit(0 if within_budget else 1) + + +if __name__ == "__main__": + main()