@tt-a1i/mco 0.1.2 → 0.1.3

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,5 +1,7 @@
1
1
  # MCO Docs Index
2
2
 
3
+ English | [简体中文](./README.zh-CN.md)
4
+
3
5
  ## Read First
4
6
  1. [multi-cli-orchestrator-proposal.md](./multi-cli-orchestrator-proposal.md)
5
7
  2. [capability-research.md](./capability-research.md)
@@ -34,10 +36,10 @@
34
36
 
35
37
  ## Installation
36
38
 
37
- Python package (recommended):
39
+ NPM wrapper (available now, Python 3 required on PATH):
38
40
 
39
41
  ```bash
40
- pipx install mco
42
+ npm i -g @tt-a1i/mco
41
43
  mco --help
42
44
  ```
43
45
 
@@ -50,12 +52,9 @@ python3 -m pip install -e .
50
52
  mco --help
51
53
  ```
52
54
 
53
- NPM wrapper (Python 3 required on PATH):
54
-
55
- ```bash
56
- npm i -g @tt-a1i/mco
57
- mco --help
58
- ```
55
+ Python package via PyPI:
56
+ - Not published yet.
57
+ - Publish workflow is ready and will be enabled after PyPI Trusted Publisher setup.
59
58
 
60
59
  Quick start:
61
60
  ```bash
@@ -0,0 +1,196 @@
1
+ # MCO 文档索引
2
+
3
+ [English](./README.md) | 简体中文
4
+
5
+ ## 先读这些
6
+ 1. [multi-cli-orchestrator-proposal.md](./multi-cli-orchestrator-proposal.md)
7
+ 2. [capability-research.md](./capability-research.md)
8
+ 3. [notes.md](./notes.md)
9
+
10
+ ## 门禁产物
11
+ 1. [capability-probe-spec.md](./capability-probe-spec.md)
12
+ 2. [adapter-contract-tests.md](./adapter-contract-tests.md)
13
+ 3. [dry-run-plan.md](./dry-run-plan.md)
14
+ 4. [implementation-gate-checklist.md](./implementation-gate-checklist.md)
15
+
16
+ ## 接口冻结
17
+ 1. [docs/implementation/step0-interface-freeze.md](./docs/implementation/step0-interface-freeze.md)
18
+ 2. [docs/contracts/cli-json-v0.1.x.md](./docs/contracts/cli-json-v0.1.x.md)
19
+ 3. [docs/contracts/provider-permissions-v0.1.x.md](./docs/contracts/provider-permissions-v0.1.x.md)
20
+
21
+ ## 计划与跟踪
22
+ 1. [task_plan.md](./task_plan.md)
23
+
24
+ ## 发布说明
25
+ 1. [docs/releases/v0.1.2.md](./docs/releases/v0.1.2.md)
26
+ 2. [docs/releases/v0.1.2.zh-CN.md](./docs/releases/v0.1.2.zh-CN.md)
27
+ 3. [docs/releases/v0.1.1.md](./docs/releases/v0.1.1.md)
28
+ 4. [docs/releases/v0.1.1.zh-CN.md](./docs/releases/v0.1.1.zh-CN.md)
29
+ 5. [docs/releases/v0.1.0.md](./docs/releases/v0.1.0.md)
30
+ 6. [docs/releases/v0.1.0.zh-CN.md](./docs/releases/v0.1.0.zh-CN.md)
31
+
32
+ ## 统一 CLI(Step 2)
33
+ `mco review`:统一的审查入口。
34
+ `mco run`:通用任务执行入口(不强制 findings schema)。
35
+
36
+ ## 安装
37
+
38
+ npm 包装器(当前可用,系统需有 Python 3):
39
+
40
+ ```bash
41
+ npm i -g @tt-a1i/mco
42
+ mco --help
43
+ ```
44
+
45
+ 源码可编辑安装:
46
+
47
+ ```bash
48
+ git clone https://github.com/tt-a1i/mco.git
49
+ cd mco
50
+ python3 -m pip install -e .
51
+ mco --help
52
+ ```
53
+
54
+ Python 包(PyPI):
55
+ - 目前尚未发布。
56
+ - 发布流程已就绪,待完成 PyPI Trusted Publisher 配置后开启。
57
+
58
+ 快速开始:
59
+
60
+ ```bash
61
+ ./mco review \
62
+ --repo . \
63
+ --prompt "Review this repository for high-risk bugs and security issues." \
64
+ --providers claude,codex
65
+ ```
66
+
67
+ 机器可读输出:
68
+
69
+ ```bash
70
+ ./mco review --repo . --prompt "Review for bugs." --providers claude,codex --json
71
+ ```
72
+
73
+ 仅 stdout 输出结果(不写 `summary.md/decision.md/findings.json/run.json`):
74
+
75
+ ```bash
76
+ ./mco review --repo . --prompt "Review for bugs." --providers claude,codex --result-mode stdout --json
77
+ ```
78
+
79
+ 通用 run 模式:
80
+
81
+ ```bash
82
+ ./mco run --repo . --prompt "Summarize the current repo architecture." --providers claude,codex --json
83
+ ```
84
+
85
+ 配置文件(JSON):
86
+
87
+ ```json
88
+ {
89
+ "providers": ["claude", "codex"],
90
+ "artifact_base": "reports/review",
91
+ "state_file": ".mco/state.json",
92
+ "policy": {
93
+ "timeout_seconds": 180,
94
+ "stall_timeout_seconds": 900,
95
+ "poll_interval_seconds": 1.0,
96
+ "review_hard_timeout_seconds": 1800,
97
+ "enforce_findings_contract": false,
98
+ "max_retries": 1,
99
+ "high_escalation_threshold": 1,
100
+ "require_non_empty_findings": true,
101
+ "max_provider_parallelism": 0,
102
+ "allow_paths": [".", "runtime", "scripts"],
103
+ "enforcement_mode": "strict",
104
+ "provider_permissions": {
105
+ "claude": {
106
+ "permission_mode": "plan"
107
+ },
108
+ "codex": {
109
+ "sandbox": "workspace-write"
110
+ }
111
+ },
112
+ "provider_timeouts": {
113
+ "claude": 300,
114
+ "codex": 240,
115
+ "qwen": 240
116
+ }
117
+ }
118
+ }
119
+ ```
120
+
121
+ 按配置运行:
122
+
123
+ ```bash
124
+ ./mco review --config ./mco.example.json --repo . --prompt "Review for bugs and security issues."
125
+ ```
126
+
127
+ CLI 覆盖并发与超时:
128
+
129
+ ```bash
130
+ ./mco review \
131
+ --repo . \
132
+ --prompt "Review for bugs and security issues." \
133
+ --providers claude,codex,gemini,opencode,qwen \
134
+ --strict-contract \
135
+ --max-provider-parallelism 2 \
136
+ --stall-timeout 900 \
137
+ --review-hard-timeout 1800 \
138
+ --provider-timeouts qwen=900,codex=900
139
+ ```
140
+
141
+ 带路径硬约束的 run 模式:
142
+
143
+ ```bash
144
+ ./mco run \
145
+ --repo . \
146
+ --prompt "Compare adapter behaviors and return a short markdown summary." \
147
+ --providers claude,codex \
148
+ --allow-paths runtime,scripts \
149
+ --target-paths runtime/adapters,runtime/review_engine.py \
150
+ --enforcement-mode strict \
151
+ --provider-permissions-json '{"codex":{"sandbox":"workspace-write"},"claude":{"permission_mode":"plan"}}' \
152
+ --json
153
+ ```
154
+
155
+ 产物目录:
156
+ - `<artifact_base>/<task_id>/summary.md`
157
+ - `<artifact_base>/<task_id>/decision.md`
158
+ - `<artifact_base>/<task_id>/findings.json`
159
+ - `<artifact_base>/<task_id>/run.json`
160
+ - `<artifact_base>/<task_id>/providers/*.json`
161
+ - `<artifact_base>/<task_id>/raw/*.log`
162
+
163
+ `run.json` 审计字段:
164
+ - `effective_cwd`
165
+ - `allow_paths_hash`
166
+ - `permissions_hash`
167
+
168
+ 说明:
169
+ - YAML 配置需要 `pyyaml`;否则使用 JSON 配置。
170
+ - review 提示词会附加 JSON finding 合同;是否强制由策略控制。
171
+ - 可用 `--strict-contract`(或配置 `policy.enforce_findings_contract=true`)开启严格合同。
172
+ - `run` 模式不强制 findings schema,聚焦执行与聚合。
173
+ - `result_mode=artifact`(默认):写产物并输出简报。
174
+ - `result_mode=stdout`:输出 provider 结果,不写用户侧产物。
175
+ - `result_mode=both`:既写产物又输出 provider 结果。
176
+ - 执行模型为 `wait-all`:单 provider 失败/超时不会中断其它 provider。
177
+ - 超时是进度驱动:
178
+ - `stall_timeout_seconds`:仅在长时间无输出进展时取消。
179
+ - `review_hard_timeout_seconds`:仅 `review` 模式硬截止。
180
+ - `max_provider_parallelism=0`(或省略)表示全并行。
181
+ - `provider_timeouts` 为 provider 级 stall-timeout 覆盖项。
182
+ - `allow_paths` 与 `target_paths` 会对 `repo_root` 做越界校验。
183
+ - `enforcement_mode=strict`(默认)下,权限要求不满足会 fail-closed。
184
+
185
+ ## Step5 性能脚本
186
+ 用于产出串行 vs 全并行对比报告(写入 `reports/adapter-contract/<date>/`):
187
+
188
+ ```bash
189
+ ./scripts/run_step5_parallel_benchmark.sh
190
+ ```
191
+
192
+ 生成的 summary JSON 包含:
193
+ - `providers_total`
194
+ - `parse_success_rate`
195
+ - `effective_findings_count`
196
+ - `zero_finding_provider_count`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tt-a1i/mco",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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
@@ -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
 
@@ -263,7 +266,11 @@ def main(argv: List[str] | None = None) -> int:
263
266
  parser.error("unsupported command")
264
267
  return 2
265
268
 
266
- cfg = _resolve_config(args)
269
+ try:
270
+ cfg = _resolve_config(args)
271
+ except (FileNotFoundError, RuntimeError, ValueError) as exc:
272
+ print(f"Configuration error: {exc}", file=sys.stderr)
273
+ return 2
267
274
  repo_root = str(Path(args.repo).resolve())
268
275
  providers = [item for item in cfg.providers if item in ("claude", "codex", "gemini", "opencode", "qwen")]
269
276
  if not providers:
@@ -283,7 +290,11 @@ def main(argv: List[str] | None = None) -> int:
283
290
  )
284
291
  review_mode = args.command == "review"
285
292
  write_artifacts = args.result_mode in ("artifact", "both")
286
- result = run_review(req, review_mode=review_mode, write_artifacts=write_artifacts)
293
+ try:
294
+ result = run_review(req, review_mode=review_mode, write_artifacts=write_artifacts)
295
+ except ValueError as exc:
296
+ print(f"Input error: {exc}", file=sys.stderr)
297
+ return 2
287
298
 
288
299
  payload = {
289
300
  "command": args.command,
@@ -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": {