@tt-a1i/mco 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,190 +1,173 @@
1
- # MCO Docs Index
1
+ # MCO
2
2
 
3
- ## Read First
4
- 1. [multi-cli-orchestrator-proposal.md](./multi-cli-orchestrator-proposal.md)
5
- 2. [capability-research.md](./capability-research.md)
6
- 3. [notes.md](./notes.md)
3
+ **MCO One Prompt. Five AI Agents. One Result.**
7
4
 
8
- ## Gate Artifacts
9
- 1. [capability-probe-spec.md](./capability-probe-spec.md)
10
- 2. [adapter-contract-tests.md](./adapter-contract-tests.md)
11
- 3. [dry-run-plan.md](./dry-run-plan.md)
12
- 4. [implementation-gate-checklist.md](./implementation-gate-checklist.md)
5
+ English | [简体中文](./README.zh-CN.md)
13
6
 
14
- ## Implementation Freeze
15
- 1. [docs/implementation/step0-interface-freeze.md](./docs/implementation/step0-interface-freeze.md)
16
- 2. [docs/contracts/cli-json-v0.1.x.md](./docs/contracts/cli-json-v0.1.x.md)
17
- 3. [docs/contracts/provider-permissions-v0.1.x.md](./docs/contracts/provider-permissions-v0.1.x.md)
7
+ ## What is MCO
18
8
 
19
- ## Planning and Tracking
20
- 1. [task_plan.md](./task_plan.md)
9
+ MCO (Multi-CLI Orchestrator) is a neutral orchestration layer that dispatches a single prompt to multiple AI coding agents in parallel and aggregates their results. No vendor lock-in. No workflow rewrite. Just fan-out, wait-all, and collect.
21
10
 
22
- ## Release Notes
23
- 1. [docs/releases/v0.1.2.md](./docs/releases/v0.1.2.md)
24
- 2. [docs/releases/v0.1.2.zh-CN.md](./docs/releases/v0.1.2.zh-CN.md)
25
- 3. [docs/releases/v0.1.1.md](./docs/releases/v0.1.1.md)
26
- 4. [docs/releases/v0.1.1.zh-CN.md](./docs/releases/v0.1.1.zh-CN.md)
27
- 5. [docs/releases/v0.1.0.md](./docs/releases/v0.1.0.md)
28
- 6. [docs/releases/v0.1.0.zh-CN.md](./docs/releases/v0.1.0.zh-CN.md)
11
+ You keep using Claude Code, Codex CLI, Gemini CLI, OpenCode, and Qwen Code as they are. MCO wires them into a unified execution pipeline with structured output, progress-driven timeouts, and reproducible artifacts.
29
12
 
30
- ## Unified CLI (Step 2)
31
- `mco review` is the unified entrypoint for running a review task.
13
+ ## Key Highlights
32
14
 
33
- `mco run` is the generalized execution entrypoint for agent-style task orchestration (no forced findings schema).
15
+ - **Parallel fan-out** dispatch to all providers simultaneously, wait-all semantics
16
+ - **Progress-driven timeouts** — agents run freely until completion; cancel only when output goes idle
17
+ - **Dual mode** — `mco review` for structured code review findings, `mco run` for general task execution
18
+ - **Provider-neutral** — uniform adapter contract across 5 CLI tools, no favoring any vendor
19
+ - **Machine-readable output** — JSON result payloads and per-provider artifact trees for downstream automation
34
20
 
35
- ## Installation
21
+ ## Supported Providers
36
22
 
37
- Python package (recommended):
23
+ | Provider | CLI | Status |
24
+ |----------|-----|--------|
25
+ | Claude Code | `claude` | Supported |
26
+ | Codex CLI | `codex` | Supported |
27
+ | Gemini CLI | `gemini` | Supported |
28
+ | OpenCode | `opencode` | Supported |
29
+ | Qwen Code | `qwen` | Supported |
30
+
31
+ No project migration. No command relearning. No single-tool lock-in.
32
+
33
+ ## Quick Start
34
+
35
+ Install via npm (Python 3 required on PATH):
38
36
 
39
37
  ```bash
40
- pipx install mco
41
- mco --help
38
+ npm i -g @tt-a1i/mco
42
39
  ```
43
40
 
44
- Install from source (editable):
41
+ Or install from source:
45
42
 
46
43
  ```bash
47
44
  git clone https://github.com/tt-a1i/mco.git
48
45
  cd mco
49
46
  python3 -m pip install -e .
50
- mco --help
51
47
  ```
52
48
 
53
- NPM wrapper (Python 3 required on PATH):
54
-
55
- ```bash
56
- npm i -g @tt-a1i/mco
57
- mco --help
58
- ```
49
+ Run your first multi-agent review:
59
50
 
60
- Quick start:
61
51
  ```bash
62
- ./mco review \
52
+ mco review \
63
53
  --repo . \
64
54
  --prompt "Review this repository for high-risk bugs and security issues." \
65
- --providers claude,codex
55
+ --providers claude,codex,qwen
66
56
  ```
67
57
 
68
- Machine-readable output:
69
- ```bash
70
- ./mco review --repo . --prompt "Review for bugs." --providers claude,codex --json
71
- ```
58
+ ## Usage
59
+
60
+ ### Review Mode
61
+
62
+ Structured code review with findings schema. Each provider returns normalized findings with severity, category, evidence, and recommendations.
72
63
 
73
- Stdout-only result mode (for caller rendering, no `summary.md/decision.md/findings.json/run.json` write):
74
64
  ```bash
75
- ./mco review --repo . --prompt "Review for bugs." --providers claude,codex --result-mode stdout --json
65
+ mco review \
66
+ --repo . \
67
+ --prompt "Review for security vulnerabilities and performance issues." \
68
+ --providers claude,codex,gemini,opencode,qwen \
69
+ --json
76
70
  ```
77
71
 
78
- General run mode:
72
+ ### Run Mode
73
+
74
+ General-purpose multi-agent execution. No forced output schema — providers complete the task freely.
75
+
79
76
  ```bash
80
- ./mco run --repo . --prompt "Summarize the current repo architecture." --providers claude,codex --json
77
+ mco run \
78
+ --repo . \
79
+ --prompt "Summarize the architecture of this project." \
80
+ --providers claude,codex \
81
+ --json
81
82
  ```
82
83
 
83
- Config file (JSON):
84
- ```json
85
- {
86
- "providers": ["claude", "codex"],
87
- "artifact_base": "reports/review",
88
- "state_file": ".mco/state.json",
89
- "policy": {
90
- "timeout_seconds": 180,
91
- "stall_timeout_seconds": 900,
92
- "poll_interval_seconds": 1.0,
93
- "review_hard_timeout_seconds": 1800,
94
- "enforce_findings_contract": false,
95
- "max_retries": 1,
96
- "high_escalation_threshold": 1,
97
- "require_non_empty_findings": true,
98
- "max_provider_parallelism": 0,
99
- "allow_paths": [".", "runtime", "scripts"],
100
- "enforcement_mode": "strict",
101
- "provider_permissions": {
102
- "claude": {
103
- "permission_mode": "plan"
104
- },
105
- "codex": {
106
- "sandbox": "workspace-write"
107
- }
108
- },
109
- "provider_timeouts": {
110
- "claude": 300,
111
- "codex": 240,
112
- "qwen": 240
113
- }
114
- }
115
- }
116
- ```
84
+ ### Result Modes
85
+
86
+ | Mode | Behavior |
87
+ |------|----------|
88
+ | `--result-mode artifact` | Write artifact files, print summary (default) |
89
+ | `--result-mode stdout` | Print full result to stdout, skip artifact files |
90
+ | `--result-mode both` | Write artifacts and print full result |
91
+
92
+ ### Path Constraints
93
+
94
+ Restrict which files agents can access:
117
95
 
118
- Run with config:
119
96
  ```bash
120
- ./mco review --config ./mco.example.json --repo . --prompt "Review for bugs and security issues."
97
+ mco run \
98
+ --repo . \
99
+ --prompt "Analyze the adapter layer." \
100
+ --providers claude,codex \
101
+ --allow-paths runtime,scripts \
102
+ --target-paths runtime/adapters \
103
+ --enforcement-mode strict
121
104
  ```
122
105
 
123
- Override fan-out and per-provider timeout from CLI:
106
+ ## Defaults and Overrides
107
+
108
+ MCO is zero-config by default. You can run it directly with built-in defaults and override behavior with CLI flags only.
109
+
110
+ ### Key Runtime Flags
111
+
112
+ | Flag | Default | Description |
113
+ |------|---------|-------------|
114
+ | `--providers` | `claude,codex` | Comma-separated provider list |
115
+ | `--stall-timeout` | `900` | Cancel when no output progress for this duration |
116
+ | `--review-hard-timeout` | `1800` | Hard deadline for review mode (`0` = disabled) |
117
+ | `--max-provider-parallelism` | `0` | `0` = full parallelism across selected providers |
118
+ | `--enforcement-mode` | `strict` | `strict` fails closed on unmet permissions |
119
+ | `--provider-timeouts` | unset | Per-provider stall-timeout overrides (`provider=seconds`) |
120
+ | `--provider-permissions-json` | unset | Provider permission mapping JSON |
121
+
122
+ Example:
123
+
124
124
  ```bash
125
- ./mco review \
125
+ mco review \
126
126
  --repo . \
127
- --prompt "Review for bugs and security issues." \
128
- --providers claude,codex,gemini,opencode,qwen \
129
- --strict-contract \
130
- --max-provider-parallelism 2 \
127
+ --prompt "Review for bugs." \
128
+ --providers claude,codex,qwen \
131
129
  --stall-timeout 900 \
132
130
  --review-hard-timeout 1800 \
131
+ --max-provider-parallelism 0 \
133
132
  --provider-timeouts qwen=900,codex=900
134
133
  ```
135
134
 
136
- Run mode with hard path constraints:
137
- ```bash
138
- ./mco run \
139
- --repo . \
140
- --prompt "Compare adapter behaviors and return a short markdown summary." \
141
- --providers claude,codex \
142
- --allow-paths runtime,scripts \
143
- --target-paths runtime/adapters,runtime/review_engine.py \
144
- --enforcement-mode strict \
145
- --provider-permissions-json '{"codex":{"sandbox":"workspace-write"},"claude":{"permission_mode":"plan"}}' \
146
- --json
135
+ Run `mco review --help` for the full flag list.
136
+
137
+ ## How It Works
138
+
139
+ ```
140
+ prompt ─> MCO ─┬─> Claude Code ─┐
141
+ ├─> Codex CLI ├─> aggregate ─> artifacts + JSON
142
+ ├─> Gemini CLI │
143
+ ├─> OpenCode │
144
+ └─> Qwen Code ──┘
147
145
  ```
148
146
 
149
- Artifacts are written to:
150
- - `<artifact_base>/<task_id>/summary.md`
151
- - `<artifact_base>/<task_id>/decision.md`
152
- - `<artifact_base>/<task_id>/findings.json`
153
- - `<artifact_base>/<task_id>/run.json`
154
- - `<artifact_base>/<task_id>/providers/*.json`
155
- - `<artifact_base>/<task_id>/raw/*.log`
156
-
157
- `run.json` includes audit fields for reproducibility:
158
- - `effective_cwd`
159
- - `allow_paths_hash`
160
- - `permissions_hash`
161
-
162
- Notes:
163
- - YAML config requires `pyyaml` installed; otherwise use JSON config.
164
- - Review prompt is wrapped with a JSON finding contract, but strict parse enforcement is optional.
165
- - Enable strict gate behavior with `--strict-contract` (or `policy.enforce_findings_contract=true` in config).
166
- - `run` mode does not force findings schema; it focuses on execution aggregation and provider success.
167
- - `result_mode=artifact` (default): write user-facing artifacts and print compact result.
168
- - `result_mode=stdout`: print provider-level result payload to stdout, skip user-facing artifact files.
169
- - `result_mode=both`: write artifacts and print provider-level payload.
170
- - Execution model is `wait-all`: one provider timeout/failure does not stop others.
171
- - Timeout behavior is progress-driven:
172
- - `stall_timeout_seconds`: cancel only when output progress is idle beyond threshold.
173
- - `review_hard_timeout_seconds`: hard deadline applied only in `review` mode.
174
- - `max_provider_parallelism=0` (or omitted) means full parallelism across selected providers.
175
- - `provider_timeouts` are provider-specific stall-timeout overrides.
176
- - `allow_paths` and `target_paths` are validated against `repo_root`; path escape is rejected.
177
- - `enforcement_mode=strict` (default) fails closed when provider permission requirements cannot be honored.
178
-
179
- ## Step5 Benchmark Script
180
- Use this script to generate serial vs full-parallel evidence and write reports under `reports/adapter-contract/<date>/`:
147
+ Each provider runs as an independent subprocess through a uniform adapter contract:
181
148
 
182
- ```bash
183
- ./scripts/run_step5_parallel_benchmark.sh
149
+ 1. **Detect** — check binary presence and auth status
150
+ 2. **Run** — spawn CLI process with prompt, capture stdout/stderr
151
+ 3. **Poll** — monitor process + output byte growth for progress detection
152
+ 4. **Cancel** — SIGTERM/SIGKILL on stall timeout or hard deadline
153
+ 5. **Normalize** — extract structured findings from raw output
154
+
155
+ Execution model is **wait-all**: one provider's timeout or failure never stops others.
156
+
157
+ ## Artifacts
158
+
159
+ Each run produces a structured artifact tree:
160
+
161
+ ```
162
+ reports/review/<task_id>/
163
+ summary.md # Human-readable summary
164
+ decision.md # PASS / FAIL / ESCALATE / PARTIAL
165
+ findings.json # Aggregated normalized findings (review mode)
166
+ run.json # Machine-readable execution metadata
167
+ providers/ # Per-provider result JSON
168
+ raw/ # Raw stdout/stderr logs
184
169
  ```
185
170
 
186
- The generated summary JSON includes separated parse-vs-findings metrics:
187
- - `providers_total`
188
- - `parse_success_rate`
189
- - `effective_findings_count`
190
- - `zero_finding_provider_count`
171
+ ## License
172
+
173
+ UNLICENSED
@@ -0,0 +1,173 @@
1
+ # MCO
2
+
3
+ **MCO — 一条提示词,五个 AI Agent,一份结果。**
4
+
5
+ [English](./README.md) | 简体中文
6
+
7
+ ## MCO 是什么
8
+
9
+ MCO(Multi-CLI Orchestrator)是一个中立的编排层,将单条提示词并行分发给多个 AI 编程 Agent,汇总执行结果。不绑定任何厂商,不改变你的工作流。Fan-out、Wait-all、Collect。
10
+
11
+ 你继续照常使用 Claude Code、Codex CLI、Gemini CLI、OpenCode、Qwen Code。MCO 负责把它们串联成统一的执行管线,提供结构化输出、进度驱动超时、可复现的产物。
12
+
13
+ ## 核心特性
14
+
15
+ - **并行扇出** — 同时分发到所有 provider,wait-all 语义
16
+ - **进度驱动超时** — agent 自由跑完,仅在长时间无输出时取消
17
+ - **双模式** — `mco review` 结构化代码审查,`mco run` 通用任务执行
18
+ - **厂商中立** — 5 个 CLI 工具统一适配器契约,不偏向任何厂商
19
+ - **机器可读输出** — JSON 结果 + 每个 provider 独立产物树,便于下游自动化
20
+
21
+ ## 支持的 Provider
22
+
23
+ | Provider | CLI | 状态 |
24
+ |----------|-----|------|
25
+ | Claude Code | `claude` | 已支持 |
26
+ | Codex CLI | `codex` | 已支持 |
27
+ | Gemini CLI | `gemini` | 已支持 |
28
+ | OpenCode | `opencode` | 已支持 |
29
+ | Qwen Code | `qwen` | 已支持 |
30
+
31
+ 无需迁移项目,无需重学命令,无需绑定单一工具。
32
+
33
+ ## 快速开始
34
+
35
+ 通过 npm 安装(需要系统有 Python 3):
36
+
37
+ ```bash
38
+ npm i -g @tt-a1i/mco
39
+ ```
40
+
41
+ 或从源码安装:
42
+
43
+ ```bash
44
+ git clone https://github.com/tt-a1i/mco.git
45
+ cd mco
46
+ python3 -m pip install -e .
47
+ ```
48
+
49
+ 运行第一次多 Agent 审查:
50
+
51
+ ```bash
52
+ mco review \
53
+ --repo . \
54
+ --prompt "Review this repository for high-risk bugs and security issues." \
55
+ --providers claude,codex,qwen
56
+ ```
57
+
58
+ ## 使用方式
59
+
60
+ ### Review 模式
61
+
62
+ 结构化代码审查,输出标准化的 findings(含严重级别、分类、证据、建议)。
63
+
64
+ ```bash
65
+ mco review \
66
+ --repo . \
67
+ --prompt "Review for security vulnerabilities and performance issues." \
68
+ --providers claude,codex,gemini,opencode,qwen \
69
+ --json
70
+ ```
71
+
72
+ ### Run 模式
73
+
74
+ 通用多 Agent 任务执行,不强制输出格式,provider 自由完成任务。
75
+
76
+ ```bash
77
+ mco run \
78
+ --repo . \
79
+ --prompt "Summarize the architecture of this project." \
80
+ --providers claude,codex \
81
+ --json
82
+ ```
83
+
84
+ ### 结果模式
85
+
86
+ | 模式 | 行为 |
87
+ |------|------|
88
+ | `--result-mode artifact` | 写产物文件,输出摘要(默认) |
89
+ | `--result-mode stdout` | 完整结果输出到 stdout,不写产物文件 |
90
+ | `--result-mode both` | 既写产物又输出完整结果 |
91
+
92
+ ### 路径约束
93
+
94
+ 限制 agent 可访问的文件范围:
95
+
96
+ ```bash
97
+ mco run \
98
+ --repo . \
99
+ --prompt "Analyze the adapter layer." \
100
+ --providers claude,codex \
101
+ --allow-paths runtime,scripts \
102
+ --target-paths runtime/adapters \
103
+ --enforcement-mode strict
104
+ ```
105
+
106
+ ## 默认值与参数覆盖
107
+
108
+ MCO 默认零配置可用。直接运行即可,按需通过命令行参数覆盖行为。
109
+
110
+ ### 关键运行参数
111
+
112
+ | 参数 | 默认值 | 说明 |
113
+ |------|--------|------|
114
+ | `--providers` | `claude,codex` | 逗号分隔 provider 列表 |
115
+ | `--stall-timeout` | `900` | 无输出进展超过此时间才取消 |
116
+ | `--review-hard-timeout` | `1800` | review 模式硬截止(`0` = 禁用) |
117
+ | `--max-provider-parallelism` | `0` | `0` = 选中 provider 全并行 |
118
+ | `--enforcement-mode` | `strict` | 权限不满足时 fail-closed |
119
+ | `--provider-timeouts` | 未设置 | provider 级 stall timeout 覆盖(`provider=seconds`) |
120
+ | `--provider-permissions-json` | 未设置 | provider 权限映射 JSON |
121
+
122
+ 示例:
123
+
124
+ ```bash
125
+ mco review \
126
+ --repo . \
127
+ --prompt "Review for bugs." \
128
+ --providers claude,codex,qwen \
129
+ --stall-timeout 900 \
130
+ --review-hard-timeout 1800 \
131
+ --max-provider-parallelism 0 \
132
+ --provider-timeouts qwen=900,codex=900
133
+ ```
134
+
135
+ 运行 `mco review --help` 查看完整参数列表。
136
+
137
+ ## 工作原理
138
+
139
+ ```
140
+ prompt ─> MCO ─┬─> Claude Code ─┐
141
+ ├─> Codex CLI ├─> 聚合 ─> 产物 + JSON
142
+ ├─> Gemini CLI │
143
+ ├─> OpenCode │
144
+ └─> Qwen Code ──┘
145
+ ```
146
+
147
+ 每个 provider 通过统一的适配器契约作为独立子进程运行:
148
+
149
+ 1. **Detect** — 检测二进制文件和认证状态
150
+ 2. **Run** — 启动 CLI 进程,传入提示词,捕获 stdout/stderr
151
+ 3. **Poll** — 监控进程状态 + 输出字节增长,判断活跃度
152
+ 4. **Cancel** — stall timeout 或硬截止时 SIGTERM/SIGKILL
153
+ 5. **Normalize** — 从原始输出中提取结构化 findings
154
+
155
+ 执行模型是 **wait-all**:单个 provider 超时或失败不会中断其他 provider。
156
+
157
+ ## 产物结构
158
+
159
+ 每次执行生成结构化产物树:
160
+
161
+ ```
162
+ reports/review/<task_id>/
163
+ summary.md # 人类可读摘要
164
+ decision.md # PASS / FAIL / ESCALATE / PARTIAL
165
+ findings.json # 聚合后的标准化 findings(review 模式)
166
+ run.json # 机器可读执行元数据
167
+ providers/ # 各 provider 结果 JSON
168
+ raw/ # 原始 stdout/stderr 日志
169
+ ```
170
+
171
+ ## 许可证
172
+
173
+ UNLICENSED
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tt-a1i/mco",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Node wrapper for the mco CLI (Python runtime required).",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -40,6 +40,19 @@ class ShimRunHandle:
40
40
  stderr_file: TextIO
41
41
 
42
42
 
43
+ _ENV_VARS_TO_STRIP = (
44
+ "CLAUDECODE",
45
+ )
46
+
47
+
48
+ def _sanitize_env() -> Dict[str, str]:
49
+ """Return a copy of os.environ with known conflicting variables removed."""
50
+ env = os.environ.copy()
51
+ for key in _ENV_VARS_TO_STRIP:
52
+ env.pop(key, None)
53
+ return env
54
+
55
+
43
56
  class ShimAdapterBase:
44
57
  id: ProviderId
45
58
 
@@ -104,6 +117,7 @@ class ShimAdapterBase:
104
117
  stderr=stderr_file,
105
118
  text=True,
106
119
  start_new_session=True,
120
+ env=_sanitize_env(),
107
121
  )
108
122
  self._runs[run_id] = ShimRunHandle(
109
123
  process=process,
@@ -123,6 +137,17 @@ class ShimAdapterBase:
123
137
  session_id=None,
124
138
  )
125
139
 
140
+ @staticmethod
141
+ def _close_io(handle: ShimRunHandle) -> None:
142
+ try:
143
+ handle.stdout_file.close()
144
+ except Exception:
145
+ pass
146
+ try:
147
+ handle.stderr_file.close()
148
+ except Exception:
149
+ pass
150
+
126
151
  def poll(self, ref: TaskRunRef) -> TaskStatus:
127
152
  handle = self._runs.get(ref.run_id)
128
153
  if handle is None:
@@ -154,11 +179,7 @@ class ShimAdapterBase:
154
179
  message="running",
155
180
  )
156
181
 
157
- try:
158
- handle.stdout_file.close()
159
- handle.stderr_file.close()
160
- except Exception:
161
- pass
182
+ self._close_io(handle)
162
183
 
163
184
  stdout_text = handle.stdout_path.read_text(encoding="utf-8") if handle.stdout_path.exists() else ""
164
185
  stderr_text = handle.stderr_path.read_text(encoding="utf-8") if handle.stderr_path.exists() else ""
@@ -182,6 +203,7 @@ class ShimAdapterBase:
182
203
  "stderr_path": str(handle.stderr_path),
183
204
  }
184
205
  handle.provider_result_path.write_text(json.dumps(payload, ensure_ascii=True, indent=2), encoding="utf-8")
206
+ self._runs.pop(ref.run_id, None)
185
207
 
186
208
  return TaskStatus(
187
209
  task_id=ref.task_id,
@@ -201,17 +223,27 @@ class ShimAdapterBase:
201
223
  if handle is None:
202
224
  return
203
225
  if handle.process.poll() is not None:
226
+ self._close_io(handle)
227
+ self._runs.pop(ref.run_id, None)
204
228
  return
205
229
  try:
206
230
  os.killpg(os.getpgid(handle.process.pid), signal.SIGTERM)
207
231
  except ProcessLookupError:
232
+ self._close_io(handle)
233
+ self._runs.pop(ref.run_id, None)
208
234
  return
209
235
  time.sleep(0.2)
210
236
  if handle.process.poll() is None:
211
237
  try:
212
238
  os.killpg(os.getpgid(handle.process.pid), signal.SIGKILL)
213
239
  except ProcessLookupError:
240
+ self._close_io(handle)
241
+ self._runs.pop(ref.run_id, None)
214
242
  return
243
+ time.sleep(0.1)
244
+ if handle.process.poll() is not None:
245
+ self._close_io(handle)
246
+ self._runs.pop(ref.run_id, None)
215
247
 
216
248
  def normalize(self, raw: object, ctx: NormalizeContext) -> List[NormalizedFinding]:
217
249
  raise NotImplementedError
package/runtime/cli.py CHANGED
@@ -6,7 +6,7 @@ import sys
6
6
  from pathlib import Path
7
7
  from typing import Dict, List
8
8
 
9
- from .config import ReviewConfig, ReviewPolicy, load_review_config
9
+ from .config import ReviewConfig, ReviewPolicy
10
10
  from .review_engine import ReviewRequest, run_review
11
11
 
12
12
 
@@ -75,18 +75,20 @@ def _parse_provider_timeouts(raw: str) -> Dict[str, int]:
75
75
  return result
76
76
  for chunk in raw.split(","):
77
77
  pair = chunk.strip()
78
- if not pair or "=" not in pair:
78
+ if not pair:
79
79
  continue
80
+ if "=" not in pair:
81
+ raise ValueError(f"invalid provider timeout entry: {pair}")
80
82
  provider, timeout_text = pair.split("=", 1)
81
83
  provider_name = provider.strip()
82
84
  if not provider_name:
83
- continue
85
+ raise ValueError(f"invalid provider timeout entry: {pair}")
84
86
  try:
85
87
  timeout = int(timeout_text.strip())
86
88
  except Exception:
87
- continue
89
+ raise ValueError(f"invalid timeout value for provider '{provider_name}': {timeout_text.strip()}") from None
88
90
  if timeout <= 0:
89
- continue
91
+ raise ValueError(f"timeout must be > 0 for provider '{provider_name}'")
90
92
  result[provider_name] = timeout
91
93
  return result
92
94
 
@@ -102,23 +104,24 @@ def _parse_provider_permissions_json(raw: str) -> Dict[str, Dict[str, str]]:
102
104
  try:
103
105
  payload = json.loads(raw)
104
106
  except Exception:
105
- return {}
107
+ raise ValueError("--provider-permissions-json must be valid JSON") from None
106
108
  if not isinstance(payload, dict):
107
- return {}
109
+ raise ValueError("--provider-permissions-json root must be an object")
108
110
 
109
111
  result: Dict[str, Dict[str, str]] = {}
110
112
  for provider, permissions in payload.items():
111
113
  provider_name = str(provider).strip()
112
- if not provider_name or not isinstance(permissions, dict):
113
- continue
114
+ if not provider_name:
115
+ raise ValueError("--provider-permissions-json contains empty provider name")
116
+ if not isinstance(permissions, dict):
117
+ raise ValueError(f"permissions for provider '{provider_name}' must be an object")
114
118
  normalized: Dict[str, str] = {}
115
119
  for key, value in permissions.items():
116
120
  key_name = str(key).strip()
117
121
  if not key_name:
118
- continue
122
+ raise ValueError(f"provider '{provider_name}' contains empty permission key")
119
123
  normalized[key_name] = str(value)
120
- if normalized:
121
- result[provider_name] = normalized
124
+ result[provider_name] = normalized
122
125
  return result
123
126
 
124
127
 
@@ -138,7 +141,6 @@ def _add_common_execution_args(parser: argparse.ArgumentParser) -> None:
138
141
  parser.add_argument("--repo", default=".", help="Repository root path")
139
142
  parser.add_argument("--prompt", required=True, help="Task prompt")
140
143
  parser.add_argument("--providers", default="", help="Comma-separated providers, e.g. claude,codex")
141
- parser.add_argument("--config", default="", help="Config file path (.json or .yaml/.yml)")
142
144
  parser.add_argument("--artifact-base", default="", help="Artifact base directory override")
143
145
  parser.add_argument("--state-file", default="", help="Runtime state file override")
144
146
  parser.add_argument("--task-id", default="", help="Optional stable task id")
@@ -212,7 +214,7 @@ def build_parser() -> argparse.ArgumentParser:
212
214
 
213
215
 
214
216
  def _resolve_config(args: argparse.Namespace) -> ReviewConfig:
215
- cfg = load_review_config(args.config or None)
217
+ cfg = ReviewConfig()
216
218
  providers = _parse_providers(args.providers) if args.providers else cfg.providers
217
219
  artifact_base = args.artifact_base or cfg.artifact_base
218
220
  state_file = args.state_file or cfg.state_file
@@ -236,7 +238,7 @@ def _resolve_config(args: argparse.Namespace) -> ReviewConfig:
236
238
  review_hard_timeout_seconds = cfg.policy.review_hard_timeout_seconds
237
239
  if args.review_hard_timeout is not None and args.review_hard_timeout >= 0:
238
240
  review_hard_timeout_seconds = args.review_hard_timeout
239
- enforce_findings_contract = cfg.policy.enforce_findings_contract or bool(args.strict_contract)
241
+ enforce_findings_contract = bool(args.strict_contract)
240
242
 
241
243
  policy = ReviewPolicy(
242
244
  timeout_seconds=cfg.policy.timeout_seconds,
@@ -263,7 +265,11 @@ def main(argv: List[str] | None = None) -> int:
263
265
  parser.error("unsupported command")
264
266
  return 2
265
267
 
266
- cfg = _resolve_config(args)
268
+ try:
269
+ cfg = _resolve_config(args)
270
+ except ValueError as exc:
271
+ print(f"Configuration error: {exc}", file=sys.stderr)
272
+ return 2
267
273
  repo_root = str(Path(args.repo).resolve())
268
274
  providers = [item for item in cfg.providers if item in ("claude", "codex", "gemini", "opencode", "qwen")]
269
275
  if not providers:
@@ -283,7 +289,11 @@ def main(argv: List[str] | None = None) -> int:
283
289
  )
284
290
  review_mode = args.command == "review"
285
291
  write_artifacts = args.result_mode in ("artifact", "both")
286
- result = run_review(req, review_mode=review_mode, write_artifacts=write_artifacts)
292
+ try:
293
+ result = run_review(req, review_mode=review_mode, write_artifacts=write_artifacts)
294
+ except ValueError as exc:
295
+ print(f"Input error: {exc}", file=sys.stderr)
296
+ return 2
287
297
 
288
298
  payload = {
289
299
  "command": args.command,
package/runtime/config.py CHANGED
@@ -1,9 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
4
3
  from dataclasses import dataclass, field
5
- from pathlib import Path
6
- from typing import Any, Dict, List, Optional
4
+ from typing import Dict, List
7
5
 
8
6
  DEFAULT_PROVIDER_TIMEOUTS: Dict[str, int] = {
9
7
  }
@@ -32,158 +30,3 @@ class ReviewConfig:
32
30
  artifact_base: str = "reports/review"
33
31
  state_file: str = ".mco/state.json"
34
32
  policy: ReviewPolicy = field(default_factory=ReviewPolicy)
35
-
36
-
37
- def _as_bool(value: Any, default: bool) -> bool:
38
- if isinstance(value, bool):
39
- return value
40
- if isinstance(value, str):
41
- lowered = value.strip().lower()
42
- if lowered in ("true", "1", "yes", "y", "on"):
43
- return True
44
- if lowered in ("false", "0", "no", "n", "off"):
45
- return False
46
- return default
47
-
48
-
49
- def _to_policy(payload: Dict[str, Any]) -> ReviewPolicy:
50
- raw_provider_timeouts = payload.get("provider_timeouts", {})
51
- provider_timeouts: Dict[str, int] = dict(DEFAULT_PROVIDER_TIMEOUTS)
52
- if isinstance(raw_provider_timeouts, dict):
53
- for key, value in raw_provider_timeouts.items():
54
- provider = str(key).strip()
55
- if not provider:
56
- continue
57
- try:
58
- timeout = int(value)
59
- except Exception:
60
- continue
61
- if timeout <= 0:
62
- continue
63
- provider_timeouts[provider] = timeout
64
-
65
- try:
66
- max_parallel = int(payload.get("max_provider_parallelism", 0))
67
- except Exception:
68
- max_parallel = 0
69
- if max_parallel < 0:
70
- max_parallel = 0
71
-
72
- raw_allow_paths = payload.get("allow_paths", ["."])
73
- allow_paths: List[str]
74
- if isinstance(raw_allow_paths, str):
75
- allow_paths = [item.strip() for item in raw_allow_paths.split(",") if item.strip()]
76
- elif isinstance(raw_allow_paths, list):
77
- allow_paths = [str(item).strip() for item in raw_allow_paths if str(item).strip()]
78
- else:
79
- allow_paths = ["."]
80
- if not allow_paths:
81
- allow_paths = ["."]
82
-
83
- raw_provider_permissions = payload.get("provider_permissions", {})
84
- provider_permissions: Dict[str, Dict[str, str]] = {}
85
- if isinstance(raw_provider_permissions, dict):
86
- for provider, permissions in raw_provider_permissions.items():
87
- provider_name = str(provider).strip()
88
- if not provider_name or not isinstance(permissions, dict):
89
- continue
90
- normalized: Dict[str, str] = {}
91
- for key, value in permissions.items():
92
- key_name = str(key).strip()
93
- if not key_name:
94
- continue
95
- normalized[key_name] = str(value)
96
- if normalized:
97
- provider_permissions[provider_name] = normalized
98
-
99
- enforcement_mode = str(payload.get("enforcement_mode", "strict")).strip().lower()
100
- if enforcement_mode not in ("strict", "best_effort"):
101
- enforcement_mode = "strict"
102
-
103
- try:
104
- stall_timeout_seconds = int(payload.get("stall_timeout_seconds", 900))
105
- except Exception:
106
- stall_timeout_seconds = 900
107
- if stall_timeout_seconds <= 0:
108
- stall_timeout_seconds = 900
109
-
110
- try:
111
- poll_interval_seconds = float(payload.get("poll_interval_seconds", 1.0))
112
- except Exception:
113
- poll_interval_seconds = 1.0
114
- if poll_interval_seconds <= 0:
115
- poll_interval_seconds = 1.0
116
-
117
- try:
118
- review_hard_timeout_seconds = int(payload.get("review_hard_timeout_seconds", 1800))
119
- except Exception:
120
- review_hard_timeout_seconds = 1800
121
- if review_hard_timeout_seconds < 0:
122
- review_hard_timeout_seconds = 1800
123
-
124
- return ReviewPolicy(
125
- timeout_seconds=int(payload.get("timeout_seconds", 180)),
126
- stall_timeout_seconds=stall_timeout_seconds,
127
- poll_interval_seconds=poll_interval_seconds,
128
- review_hard_timeout_seconds=review_hard_timeout_seconds,
129
- enforce_findings_contract=_as_bool(payload.get("enforce_findings_contract", False), False),
130
- max_retries=int(payload.get("max_retries", 1)),
131
- high_escalation_threshold=int(payload.get("high_escalation_threshold", 1)),
132
- require_non_empty_findings=_as_bool(payload.get("require_non_empty_findings", True), True),
133
- max_provider_parallelism=max_parallel,
134
- provider_timeouts=provider_timeouts,
135
- allow_paths=allow_paths,
136
- provider_permissions=provider_permissions,
137
- enforcement_mode=enforcement_mode,
138
- )
139
-
140
-
141
- def _normalize_payload(payload: Dict[str, Any]) -> ReviewConfig:
142
- policy_payload = payload.get("policy", {})
143
- if not isinstance(policy_payload, dict):
144
- policy_payload = {}
145
- providers = payload.get("providers", ["claude", "codex"])
146
- if isinstance(providers, str):
147
- providers = [item.strip() for item in providers.split(",") if item.strip()]
148
- if not isinstance(providers, list):
149
- providers = ["claude", "codex"]
150
- providers = [str(item).strip() for item in providers if str(item).strip()]
151
- if not providers:
152
- providers = ["claude", "codex"]
153
-
154
- return ReviewConfig(
155
- providers=providers,
156
- artifact_base=str(payload.get("artifact_base", "reports/review")),
157
- state_file=str(payload.get("state_file", ".mco/state.json")),
158
- policy=_to_policy(policy_payload),
159
- )
160
-
161
-
162
- def load_review_config(config_path: Optional[str]) -> ReviewConfig:
163
- if not config_path:
164
- return ReviewConfig()
165
- path = Path(config_path)
166
- if not path.exists():
167
- raise FileNotFoundError(f"config file not found: {config_path}")
168
-
169
- suffix = path.suffix.lower()
170
- raw_text = path.read_text(encoding="utf-8")
171
- if suffix == ".json":
172
- payload = json.loads(raw_text)
173
- if not isinstance(payload, dict):
174
- raise ValueError("config root must be an object")
175
- return _normalize_payload(payload)
176
-
177
- if suffix in (".yaml", ".yml"):
178
- try:
179
- import yaml # type: ignore
180
- except Exception as exc:
181
- raise RuntimeError(
182
- "YAML config requires pyyaml. Install with: pip install pyyaml, or use a .json config."
183
- ) from exc
184
- payload = yaml.safe_load(raw_text)
185
- if not isinstance(payload, dict):
186
- raise ValueError("config root must be a map")
187
- return _normalize_payload(payload)
188
-
189
- raise ValueError(f"unsupported config format: {config_path}")
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import threading
5
+ import time
5
6
  from dataclasses import dataclass
6
7
  from pathlib import Path
7
8
  from typing import Callable, Dict, List, Optional, Set, Tuple
@@ -50,8 +51,14 @@ class TaskStateMachine:
50
51
 
51
52
 
52
53
  class OrchestratorRuntime:
53
- def __init__(self, retry_policy: Optional[RetryPolicy] = None, state_file: Optional[str] = None) -> None:
54
+ def __init__(
55
+ self,
56
+ retry_policy: Optional[RetryPolicy] = None,
57
+ state_file: Optional[str] = None,
58
+ sleep_fn: Optional[Callable[[float], None]] = None,
59
+ ) -> None:
54
60
  self.retry_policy = retry_policy or RetryPolicy()
61
+ self.sleep_fn = sleep_fn or time.sleep
55
62
  self.dispatch_cache: Dict[str, RunResult] = {}
56
63
  self.idempotency_index: Dict[str, str] = {}
57
64
  self.sent_notifications: Set[Tuple[str, str, str]] = set()
@@ -205,7 +212,9 @@ class OrchestratorRuntime:
205
212
  return final
206
213
 
207
214
  retry_index = attempts
208
- delays.append(self.retry_policy.compute_delay(retry_index))
215
+ delay_seconds = self.retry_policy.compute_delay(retry_index)
216
+ delays.append(delay_seconds)
217
+ self.sleep_fn(delay_seconds)
209
218
 
210
219
  def send_terminal_notification(self, task_id: str, state: TaskState, channel: str) -> bool:
211
220
  with self._lock:
@@ -53,8 +53,6 @@
53
53
  "additionalProperties": true,
54
54
  "required": [
55
55
  "file",
56
- "line",
57
- "symbol",
58
56
  "snippet"
59
57
  ],
60
58
  "properties": {