@caddis/cli 0.4.16 → 0.4.19

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.
@@ -0,0 +1,530 @@
1
+ #!/usr/bin/env python3
2
+ """Multi-Model Review & Runner (MMR) for Caddis.
3
+
4
+ Executes ad-hoc tasks across model lanes (GLM -> Codex -> Claude) in an isolated
5
+ sandbox with automatic rate-limit failover, test gates, cross-vendor review,
6
+ and immediate worktree cleanup. Also performs direct diff reviews.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ if hasattr(sys.stdout, "reconfigure"):
21
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
22
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
23
+
24
+ EXIT_OK = 0
25
+ EXIT_FAIL = 1
26
+ EXIT_USAGE = 2
27
+ EXIT_CONFIG = 3
28
+
29
+
30
+ def repo_root(start: Path | None = None) -> Path:
31
+ anchor = Path.cwd() if start is None else start.resolve()
32
+ try:
33
+ raw = subprocess.check_output(
34
+ ["git", "-C", str(anchor), "rev-parse", "--show-toplevel"],
35
+ text=True,
36
+ encoding="utf-8",
37
+ errors="replace",
38
+ ).strip()
39
+ return Path(raw).resolve()
40
+ except subprocess.CalledProcessError as exc:
41
+ sys.stderr.write(f"[mmr] error: {anchor} is not inside a git repository\n")
42
+ sys.exit(EXIT_CONFIG)
43
+
44
+
45
+ def git_output(repo: Path, *args: str) -> str:
46
+ res = subprocess.run(
47
+ ["git", "-C", str(repo), *args],
48
+ capture_output=True,
49
+ text=True,
50
+ encoding="utf-8",
51
+ errors="replace",
52
+ )
53
+ if res.returncode != 0:
54
+ return ""
55
+ return res.stdout.strip()
56
+
57
+
58
+ def check_dirty(repo: Path) -> list[str]:
59
+ output = git_output(repo, "status", "--porcelain")
60
+ lines = [line.strip() for line in output.splitlines() if line.strip()]
61
+ dirty_files: list[str] = []
62
+ for line in lines:
63
+ parts = line.split(maxsplit=1)
64
+ if len(parts) == 2:
65
+ dirty_files.append(parts[1])
66
+ return dirty_files
67
+
68
+
69
+ def check_keys(repo: Path, keys_file: Path | None = None) -> dict[str, bool]:
70
+ kfile = keys_file if keys_file is not None else Path.home() / ".caddis" / "keys.env"
71
+ file_env: dict[str, str] = {}
72
+ if kfile.is_file():
73
+ try:
74
+ for line in kfile.read_text(encoding="utf-8").splitlines():
75
+ line = line.strip()
76
+ if line and not line.startswith("#") and "=" in line:
77
+ k, _, v = line.partition("=")
78
+ file_env[k.strip()] = v.strip()
79
+ except OSError:
80
+ pass
81
+
82
+ def has_key(var: str) -> bool:
83
+ return bool(os.environ.get(var) or file_env.get(var))
84
+
85
+ return {
86
+ "deepseek": has_key("DEEPSEEK_API_KEY") or has_key("REVIEW_API_KEY"),
87
+ "glm": has_key("GLM_API_KEY"),
88
+ "openai": has_key("OPENAI_API_KEY") or has_key("CODEX_API_KEY"),
89
+ "anthropic": has_key("ANTHROPIC_API_KEY"),
90
+ }
91
+
92
+
93
+ def find_lanes_script(repo: Path) -> Path:
94
+ sibling = Path(__file__).resolve().with_name("caddis_lanes.py")
95
+ if sibling.is_file():
96
+ return sibling
97
+ in_repo = repo / "scripts" / "caddis_lanes.py"
98
+ if in_repo.is_file():
99
+ return in_repo
100
+ plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT")
101
+ if plugin_root:
102
+ candidate = Path(plugin_root) / "scripts" / "caddis_lanes.py"
103
+ if candidate.is_file():
104
+ return candidate
105
+ raise FileNotFoundError("caddis_lanes.py not found")
106
+
107
+
108
+ def slugify_prompt(prompt: str) -> str:
109
+ first_line = prompt.strip().splitlines()[0] if prompt.strip() else "task"
110
+ cleaned = re.sub(r"[^\w\s-]", "", first_line).strip().lower()
111
+ words = cleaned.split()[:5]
112
+ slug = "-".join(words)[:50].rstrip("-")
113
+ if not slug:
114
+ slug = "task"
115
+ date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
116
+ return f"{date_str}-{slug}"
117
+
118
+
119
+ def synthesize_plan(
120
+ repo: Path,
121
+ prompt: str,
122
+ task_type: str,
123
+ ) -> tuple[Path, str, str]:
124
+ slug = slugify_prompt(prompt)
125
+ first_line = prompt.strip().splitlines()[0] if prompt.strip() else "Ad-hoc task"
126
+ title = first_line[:60].strip()
127
+
128
+ adhoc_dir = repo / ".caddis" / "orchestrator" / "adhoc"
129
+ adhoc_dir.mkdir(parents=True, exist_ok=True)
130
+
131
+ plan_path = adhoc_dir / f"{slug}.md"
132
+ plan_content = f"""# Ad-hoc Task: {title}
133
+
134
+ ### Phase 1 — {title}
135
+ **Lane:** auto (via caddis_lanes)
136
+ **Type:** {task_type}
137
+
138
+ **Task:**
139
+ {prompt.strip()}
140
+
141
+ **Constraints:**
142
+ - Do NOT install or introduce new third-party dependencies. Use existing repository packages and standard library only.
143
+ - Follow TDD: write or update test assertions first, then implement.
144
+ - Leave all changes in the working directory. Do NOT run git commit, push, or merge.
145
+
146
+ **Done when:**
147
+ Task requirements are met, existing tests pass, and new test assertions verify the implementation.
148
+ """
149
+ plan_path.write_text(plan_content, encoding="utf-8")
150
+ return plan_path, slug, title
151
+
152
+
153
+ def adhoc_latest_path(repo: Path) -> Path:
154
+ return repo / ".caddis" / "orchestrator" / "adhoc" / "latest.json"
155
+
156
+
157
+ def read_latest(repo: Path) -> dict | None:
158
+ path = adhoc_latest_path(repo)
159
+ if not path.is_file():
160
+ return None
161
+ try:
162
+ return json.loads(path.read_text(encoding="utf-8"))
163
+ except (OSError, json.JSONDecodeError):
164
+ return None
165
+
166
+
167
+ def run_adhoc(
168
+ repo: Path,
169
+ prompt: str,
170
+ task_type: str = "backend",
171
+ lane: str | None = None,
172
+ allow_load: bool = False,
173
+ ) -> int:
174
+ dirty = check_dirty(repo)
175
+ head_sha = git_output(repo, "rev-parse", "HEAD")
176
+ if dirty:
177
+ print(f"[mmr] Notice: Working tree has {len(dirty)} uncommitted changes.")
178
+ print(f"[mmr] Sandbox branched from HEAD ({head_sha[:7]}). Uncommitted files remain untouched.")
179
+ for d in dirty:
180
+ if d.lower() in prompt.lower():
181
+ print(f"[mmr] Warning: '{d}' has local uncommitted changes. Sandbox runs against committed HEAD.")
182
+
183
+ keys = check_keys(repo)
184
+ if not keys.get("glm") and not keys.get("openai") and not lane:
185
+ print("[mmr] Note: Secondary vendor keys (GLM/Codex) not detected in ~/.caddis/keys.env.")
186
+ print("[mmr] Running on available lanes. Run 'caddis keys' to enable cheap background lanes.")
187
+
188
+ plan_path, slug, title = synthesize_plan(repo, prompt, task_type)
189
+ plan_rel = plan_path.relative_to(repo).as_posix()
190
+
191
+ try:
192
+ lanes_script = find_lanes_script(repo)
193
+ except FileNotFoundError as exc:
194
+ sys.stderr.write(f"[mmr] error: {exc}\n")
195
+ return EXIT_CONFIG
196
+
197
+ cmd = [
198
+ sys.executable,
199
+ str(lanes_script),
200
+ "run",
201
+ "--plan",
202
+ plan_rel,
203
+ "--phase",
204
+ "1",
205
+ "--type",
206
+ task_type,
207
+ ]
208
+ if lane:
209
+ cmd.extend(["--lane", lane])
210
+ if allow_load:
211
+ cmd.append("--allow-load")
212
+
213
+ print(f"[mmr] Starting isolated task run: {title}")
214
+ res = subprocess.run(cmd, cwd=repo)
215
+
216
+ packet_path = (
217
+ repo
218
+ / ".caddis"
219
+ / "orchestrator"
220
+ / "lane-runs"
221
+ / slug
222
+ / "phase-01.attempt-1.json"
223
+ )
224
+ if not packet_path.is_file():
225
+ sys.stderr.write(f"[mmr] error: lane run packet not generated at {packet_path}\n")
226
+ return res.returncode or EXIT_FAIL
227
+
228
+ try:
229
+ packet = json.loads(packet_path.read_text(encoding="utf-8"))
230
+ except (OSError, json.JSONDecodeError) as exc:
231
+ sys.stderr.write(f"[mmr] error: failed to read packet: {exc}\n")
232
+ return EXIT_FAIL
233
+
234
+ worktree_str = packet.get("worktree")
235
+ changed_files = packet.get("changed_files", [])
236
+ gates_passed = packet.get("gates_passed", False)
237
+ review_info = packet.get("review", {})
238
+ review_status = review_info.get("status", "MISSING")
239
+ assigned_lane = packet.get("lane", "")
240
+ assigned_model = packet.get("model", "")
241
+ adhoc_branch = f"lane/adhoc-{slug}"
242
+ commit_sha = packet.get("base_sha", head_sha)
243
+
244
+ # Teardown worktree immediately to prevent leaks
245
+ if worktree_str:
246
+ wt_path = Path(worktree_str)
247
+ if wt_path.exists():
248
+ if changed_files:
249
+ try:
250
+ subprocess.run(
251
+ ["git", "-C", str(wt_path), "add", "--", *changed_files],
252
+ check=True,
253
+ capture_output=True,
254
+ )
255
+ commit_res = subprocess.run(
256
+ ["git", "-C", str(wt_path), "commit", "-m", f"adhoc({slug}): {title}"],
257
+ capture_output=True,
258
+ text=True,
259
+ encoding="utf-8",
260
+ )
261
+ if commit_res.returncode == 0:
262
+ commit_sha = git_output(wt_path, "rev-parse", "HEAD")
263
+ except Exception as exc:
264
+ print(f"[mmr] notice: commit in worktree skipped: {exc}")
265
+
266
+ # Point adhoc_branch to the result commit
267
+ subprocess.run(
268
+ ["git", "-C", str(repo), "branch", "-f", adhoc_branch, commit_sha],
269
+ capture_output=True,
270
+ )
271
+
272
+ # Cleanly remove the worktree
273
+ subprocess.run(
274
+ ["git", "-C", str(repo), "worktree", "remove", "--force", str(wt_path)],
275
+ capture_output=True,
276
+ )
277
+ subprocess.run(["git", "-C", str(repo), "worktree", "prune"], capture_output=True)
278
+
279
+ # Clean up the phase-specific attempt branch
280
+ attempt_branch = packet.get("branch")
281
+ if attempt_branch and attempt_branch != adhoc_branch:
282
+ subprocess.run(
283
+ ["git", "-C", str(repo), "branch", "-D", attempt_branch],
284
+ capture_output=True,
285
+ )
286
+
287
+ diff_stat = git_output(repo, "diff", "--shortstat", packet.get("base_sha", head_sha), adhoc_branch)
288
+ diff_names = git_output(repo, "diff", "--name-only", packet.get("base_sha", head_sha), adhoc_branch).splitlines()
289
+
290
+ # Check for dependency file modifications
291
+ dep_files = {"package.json", "requirements.txt", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml"}
292
+ modified_deps = [f for f in diff_names if Path(f).name in dep_files]
293
+
294
+ # Save state to latest.json
295
+ state = {
296
+ "slug": slug,
297
+ "title": title,
298
+ "task": prompt,
299
+ "task_type": task_type,
300
+ "branch": adhoc_branch,
301
+ "base_sha": packet.get("base_sha", head_sha),
302
+ "commit_sha": commit_sha,
303
+ "changed_files": diff_names,
304
+ "lane": assigned_lane,
305
+ "model": assigned_model,
306
+ "gates_passed": gates_passed,
307
+ "review_status": review_status,
308
+ "diff_stat": diff_stat.strip(),
309
+ "modified_deps": modified_deps,
310
+ "plan_path": plan_rel,
311
+ "created": datetime.now(timezone.utc).isoformat(),
312
+ }
313
+ adhoc_latest_path(repo).write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
314
+
315
+ # Render Verdict Card
316
+ is_success = gates_passed and review_status == "CLEAN" and not modified_deps
317
+ status_label = "PASS" if is_success else "FAIL"
318
+
319
+ card = [
320
+ "┌────────────────────────────────────────────────────────┐",
321
+ f"│ MMR VERDICT: {status_label:<42}│",
322
+ f"│ Task: {title[:48]:<49}│",
323
+ f"│ Branch: {adhoc_branch:<47}│",
324
+ f"│ Model: {assigned_lane or 'auto'} ({assigned_model or 'default'}){' ' * max(0, 39 - len(assigned_lane or '') - len(assigned_model or ''))}│",
325
+ f"│ Diff: {(diff_stat or 'no changes')[:48]:<49}│",
326
+ f"│ Gates: {'PASS' if gates_passed else 'FAILED':<48}│",
327
+ f"│ Review: {review_status:<47}│",
328
+ ]
329
+ if modified_deps:
330
+ card.append(f"│ WARN: New dependencies added ({', '.join(modified_deps)}){' ' * max(0, 16 - len(','.join(modified_deps)))}│")
331
+ card.extend(
332
+ [
333
+ "│ │",
334
+ "│ Actions: │",
335
+ "│ /mmr keep Keep branch; offers /ship-pr │",
336
+ "│ /mmr drop Discard branch and delete task files │",
337
+ "└────────────────────────────────────────────────────────┘",
338
+ ]
339
+ )
340
+ print("\n" + "\n".join(card) + "\n")
341
+ return EXIT_OK if is_success else EXIT_FAIL
342
+
343
+
344
+ def keep_adhoc(repo: Path) -> int:
345
+ state = read_latest(repo)
346
+ if state is None:
347
+ print("[mmr] No active task found to keep. Run /mmr \"<task>\" first.")
348
+ return EXIT_USAGE
349
+
350
+ branch = state.get("branch", "")
351
+ if not branch:
352
+ print("[mmr] Active task record has no branch.")
353
+ return EXIT_USAGE
354
+
355
+ branch_sha = git_output(repo, "rev-parse", "--verify", branch)
356
+ if not branch_sha:
357
+ print(f"[mmr] Branch '{branch}' does not exist.")
358
+ return EXIT_FAIL
359
+
360
+ current_branch = git_output(repo, "branch", "--show-current")
361
+ if current_branch in ("main", "master"):
362
+ print(f"[mmr] Kept branch: {branch}")
363
+ print(f"[mmr] Direct merge into '{current_branch}' is disabled to protect trunk.")
364
+ print("Recommended next step: Run /ship-pr to open a pull request and verify CI.")
365
+ return EXIT_OK
366
+
367
+ dirty = check_dirty(repo)
368
+ if dirty:
369
+ print(f"[mmr] Kept branch: {branch}")
370
+ print(f"[mmr] Working tree has {len(dirty)} uncommitted files. Branch was not merged automatically.")
371
+ print(f"To merge when clean: git merge --squash {branch}")
372
+ return EXIT_OK
373
+
374
+ print(f"[mmr] Kept branch: {branch}")
375
+ print(f"To squash onto current branch: git merge --squash {branch}")
376
+ print("Or run /ship-pr to open a pull request.")
377
+ return EXIT_OK
378
+
379
+
380
+ def drop_adhoc(repo: Path) -> int:
381
+ state = read_latest(repo)
382
+ if state is None:
383
+ print("[mmr] No active task found to drop.")
384
+ return EXIT_USAGE
385
+
386
+ branch = state.get("branch", "")
387
+ slug = state.get("slug", "")
388
+ title = state.get("title", slug)
389
+
390
+ if branch:
391
+ subprocess.run(["git", "-C", str(repo), "branch", "-D", branch], capture_output=True)
392
+
393
+ plan_path = state.get("plan_path")
394
+ if plan_path:
395
+ full_plan = repo / plan_path
396
+ if full_plan.is_file():
397
+ full_plan.unlink(missing_ok=True)
398
+
399
+ run_dir = repo / ".caddis" / "orchestrator" / "lane-runs" / slug
400
+ if run_dir.is_dir():
401
+ shutil.rmtree(run_dir, ignore_errors=True)
402
+
403
+ latest_file = adhoc_latest_path(repo)
404
+ if latest_file.is_file():
405
+ latest_file.unlink(missing_ok=True)
406
+
407
+ print(f"[mmr] Dropped task '{title}'.")
408
+ print(f"[mmr] Branch '{branch}' deleted. Workspace is clean and untouched.")
409
+ return EXIT_OK
410
+
411
+
412
+ def status_adhoc(repo: Path) -> int:
413
+ keys = check_keys(repo)
414
+ print("=== Multi-Model Review & Runner (MMR) Status ===")
415
+ print("Lane Providers:")
416
+ print(f" Anthropic / Claude : {'Ready' if keys.get('anthropic') else 'Default'}")
417
+ print(f" DeepSeek (Review) : {'Ready' if keys.get('deepseek') else 'Not configured'}")
418
+ print(f" GLM-4 / GLM-5 : {'Ready' if keys.get('glm') else 'Not configured'}")
419
+ print(f" OpenAI / Codex : {'Ready' if keys.get('openai') else 'Not configured'}")
420
+
421
+ state = read_latest(repo)
422
+ if state:
423
+ print("\nActive Task:")
424
+ print(f" Title : {state.get('title')}")
425
+ print(f" Branch : {state.get('branch')}")
426
+ print(f" Model : {state.get('lane')} ({state.get('model')})")
427
+ print(f" Gates : {'PASS' if state.get('gates_passed') else 'FAILED'}")
428
+ print(f" Review : {state.get('review_status')}")
429
+ print(f" Diff : {state.get('diff_stat') or 'none'}")
430
+ else:
431
+ print("\nActive Task: None")
432
+
433
+ branches_output = git_output(repo, "branch", "--list", "lane/adhoc-*")
434
+ adhoc_branches = [b.strip().lstrip("* ") for b in branches_output.splitlines() if b.strip()]
435
+ if adhoc_branches:
436
+ print(f"\nUnmerged Ad-hoc Branches ({len(adhoc_branches)}):")
437
+ for b in adhoc_branches:
438
+ print(f" - {b}")
439
+
440
+ return EXIT_OK
441
+
442
+
443
+ def review_diff(repo: Path, git_range: str | None = None) -> int:
444
+ # Resolve oss_review.py
445
+ candidates = [
446
+ repo / "scripts" / "oss_review.py",
447
+ repo / ".github" / "tools" / "oss_review.py",
448
+ Path(__file__).resolve().parent / "oss_review.py",
449
+ Path(__file__).resolve().parents[1] / ".github" / "tools" / "oss_review.py",
450
+ ]
451
+ tool = next((c for c in candidates if c.is_file()), None)
452
+ if tool is None:
453
+ sys.stderr.write("[mmr] error: oss_review.py not found in repo or tools\n")
454
+ return EXIT_CONFIG
455
+
456
+ cmd = [sys.executable, str(tool), "--cwd", str(repo)]
457
+ if git_range:
458
+ cmd.extend(["--range", git_range])
459
+
460
+ print(f"[mmr] Running Multi-Model Review with {tool.name}...")
461
+ res = subprocess.run(cmd)
462
+ return res.returncode
463
+
464
+
465
+ def main(argv: list[str] | None = None) -> int:
466
+ args_list = sys.argv[1:] if argv is None else argv
467
+ parser = argparse.ArgumentParser(
468
+ description="Multi-Model Review & Runner (MMR)",
469
+ add_help=True,
470
+ )
471
+ subparsers = parser.add_subparsers(dest="subcommand")
472
+
473
+ # run subcommand
474
+ run_parser = subparsers.add_parser("run", help="Run an ad-hoc task across model lanes")
475
+ run_parser.add_argument("prompt", type=str, help="Task prompt or description")
476
+ run_parser.add_argument(
477
+ "--type",
478
+ choices=["backend", "tests", "docs", "ui", "hard"],
479
+ default="backend",
480
+ help="Task type for chain resolution (default: backend)",
481
+ )
482
+ run_parser.add_argument("--lane", type=str, default=None, help="Start chain at specific lane")
483
+ run_parser.add_argument("--allow-load", action="store_true", help="Allow load/stress wording")
484
+
485
+ # keep subcommand
486
+ subparsers.add_parser("keep", help="Keep the last ad-hoc branch")
487
+
488
+ # drop subcommand
489
+ subparsers.add_parser("drop", help="Discard the last ad-hoc task and clean up")
490
+
491
+ # status subcommand
492
+ subparsers.add_parser("status", help="Show lane readiness and active task status")
493
+
494
+ # review subcommand
495
+ review_parser = subparsers.add_parser("review", help="Run multi-model review on a git diff")
496
+ review_parser.add_argument("--range", type=str, default=None, help="Git range (e.g. origin/main..HEAD)")
497
+
498
+ # Direct shorthand: if first argument is not a known subcommand and not a flag, treat as 'run'
499
+ if args_list and args_list[0] not in ("run", "keep", "drop", "status", "review", "-h", "--help"):
500
+ args_list = ["run", *args_list]
501
+
502
+ if not args_list:
503
+ # Default with no arguments: review the current diff
504
+ args_list = ["review"]
505
+
506
+ parsed = parser.parse_args(args_list)
507
+ repo = repo_root()
508
+
509
+ if parsed.subcommand == "run":
510
+ return run_adhoc(
511
+ repo,
512
+ parsed.prompt,
513
+ task_type=parsed.type,
514
+ lane=parsed.lane,
515
+ allow_load=parsed.allow_load,
516
+ )
517
+ elif parsed.subcommand == "keep":
518
+ return keep_adhoc(repo)
519
+ elif parsed.subcommand == "drop":
520
+ return drop_adhoc(repo)
521
+ elif parsed.subcommand == "status":
522
+ return status_adhoc(repo)
523
+ elif parsed.subcommand == "review":
524
+ return review_diff(repo, git_range=parsed.range)
525
+
526
+ return EXIT_USAGE
527
+
528
+
529
+ if __name__ == "__main__":
530
+ sys.exit(main())
@@ -580,6 +580,10 @@ def oss_review_path() -> Path | None:
580
580
 
581
581
 
582
582
  def run_review(worktree: Path, env: dict, timeout_s: int = 900) -> ReviewResult:
583
+ fake_review = os.environ.get("CADDIS_REVIEW_FAKE")
584
+ if fake_review:
585
+ return ReviewResult(fake_review, 0 if fake_review == "CLEAN" else 1, f"fake review: {fake_review}")
586
+
583
587
  path = oss_review_path()
584
588
  if path is None:
585
589
  return ReviewResult("MISSING", None, "oss_review.py not found")
@@ -1210,6 +1214,10 @@ def _run_command(
1210
1214
  "base_sha": wt.base_sha,
1211
1215
  }
1212
1216
  )
1217
+ wt_plan = wt.path / plan_rel
1218
+ if not wt_plan.exists() and plan.exists():
1219
+ wt_plan.parent.mkdir(parents=True, exist_ok=True)
1220
+ shutil.copy2(plan, wt_plan)
1213
1221
 
1214
1222
  for lane in chain:
1215
1223
  model = models[lane]