@caddis/cli 0.2.5 → 0.3.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.
Files changed (54) hide show
  1. package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
  2. package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
  3. package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
  4. package/bundles/antigravity-plugin/guard_agy.py +23 -4
  5. package/bundles/antigravity-plugin/plugin.json +1 -1
  6. package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
  12. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
  13. package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
  14. package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
  15. package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
  16. package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
  17. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
  18. package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
  19. package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
  20. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
  21. package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
  22. package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
  23. package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
  24. package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
  25. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
  26. package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
  27. package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
  28. package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
  29. package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
  30. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
  31. package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
  32. package/bundles/antigravity-plugin/session_end_agy.py +21 -2
  33. package/bundles/antigravity-plugin/skills/_registry.md +2 -0
  34. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +22 -0
  35. package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
  37. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
  39. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +33 -0
  40. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +213 -0
  41. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
  42. package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
  43. package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
  44. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  45. package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
  46. package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
  47. package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
  48. package/bundles/manifest.json +3 -3
  49. package/dist/claude-deepseek.js +544 -0
  50. package/dist/claude-glm.js +544 -0
  51. package/dist/claude-oss.js +544 -0
  52. package/dist/cli.js +375 -46
  53. package/package.json +5 -2
  54. package/bundles/antigravity-plugin/statusline-command-agy.sh +0 -91
@@ -0,0 +1,718 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-vendor code review via any OpenAI-compatible chat-completions endpoint.
3
+
4
+ A different vendor's model has different blind spots, so a second model reviewing your diff
5
+ catches bugs a same-vendor reviewer misses. This tool is provider-agnostic: point it at
6
+ DeepSeek (default — cheapest + most architecturally distinct from Claude), GLM, OpenRouter,
7
+ or any OpenAI-compatible `/chat/completions` endpoint via three env vars.
8
+
9
+ Config precedence (highest wins): explicit CLI flag > env var > provider preset.
10
+ REVIEW_PROVIDER preset name: deepseek | glm | openrouter (default deepseek)
11
+ REVIEW_BASE_URL base URL, no trailing /chat/completions (overrides the preset's URL)
12
+ REVIEW_MODEL model id (overrides the preset's model)
13
+ REVIEW_API_KEY bearer token (REQUIRED — no key ⇒ exit 3)
14
+
15
+ Future-proofing: model ids and endpoints churn. Two layers protect against that — (1) the PROVIDERS
16
+ table below is the ONE place a renamed model/URL is edited; (2) REVIEW_MODEL / REVIEW_BASE_URL env
17
+ vars always win, so you can point at any new id without touching code. Nothing here is hard-wired.
18
+
19
+ Usage:
20
+ python oss_review.py [--range <git range>] [--cwd <repo>] [--provider P] [--base-url U] [--model M]
21
+ --range e.g. origin/main..HEAD (default: the working tree — staged, unstaged AND
22
+ untracked non-ignored files; a range excludes untracked by design)
23
+
24
+ Exit codes (fail-closed):
25
+ 0 REVIEW: CLEAN — no blocking issues
26
+ 1 REVIEW: BLOCKING — one or more blocking issues
27
+ 2 error — no diff verdict parsed, git failure, endpoint/parse failure, or the diff
28
+ exceeds REVIEW_MAX_DIFF_CHARS (see below — never silently downgraded to CLEAN)
29
+ 3 misconfigured — REVIEW_API_KEY missing (actionable message on stderr)
30
+
31
+ Diff-size ceiling: an oversized diff sent to a chat-completions endpoint has been observed, live, to
32
+ come back two different unsafe ways — an empty `content` field on an HTTP 200 (at least fails closed:
33
+ no verdict line ⇒ exit 2), and, worse, a `REVIEW: CLEAN` verdict with no substantive engagement (a
34
+ silent false negative — the model was overwhelmed, not actually reviewing). Rather than risk the second
35
+ case, a diff over REVIEW_MAX_DIFF_CHARS (default 60,000 chars; override via the env var or
36
+ --max-diff-chars) is SPLIT INTO BATCHES on whole-file boundaries and each batch reviewed separately;
37
+ the verdict is CLEAN only if every batch is clean (aggregation is fail-closed). Exit 2 is now the
38
+ narrower case: a SINGLE file larger than the ceiling, which cannot be split, or more batches than
39
+ MAX_REVIEW_BATCHES. Refusal was the original behaviour and is kept for those two - a silent false
40
+ CLEAN is the worst outcome this tool has - but it no longer fires on an ordinary large phase diff.
41
+
42
+ Stdlib-only (urllib) so it runs anywhere with no pip install.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import argparse
47
+ import http.client
48
+ import json
49
+ import os
50
+ import subprocess
51
+ import sys
52
+ import urllib.error
53
+ import urllib.request
54
+ from pathlib import Path
55
+
56
+ EXIT_CLEAN = 0
57
+ EXIT_BLOCKING = 1
58
+ EXIT_ERROR = 2
59
+ EXIT_CONFIG = 3
60
+
61
+ # Provider presets — the SINGLE place a renamed model id or moved endpoint is edited. Adding a new
62
+ # provider (Qwen, a local vLLM, …) is one new row. Callers can always bypass this via env/flags.
63
+ PROVIDERS: dict[str, dict[str, str]] = {
64
+ "deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash"},
65
+ "glm": {"base_url": "https://api.z.ai/api/coding/paas/v4", "model": "glm-5.2"},
66
+ "openrouter": {"base_url": "https://openrouter.ai/api/v1", "model": "deepseek/deepseek-v4-flash"},
67
+ }
68
+ DEFAULT_PROVIDER = "deepseek"
69
+
70
+ # Tried, in order, ONLY when the primary provider fails at transport level (timeout, HTTP
71
+ # error, unparseable response) AND the user did not name a provider explicitly.
72
+ #
73
+ # Why a fallback rather than just picking one vendor: the whole point of this tool is that the
74
+ # reviewer is a DIFFERENT vendor from the author, so collapsing to a single provider means an
75
+ # outage leaves you with no second opinion and — worse — no signal that you have lost one.
76
+ # Observed on serve-sight across three consecutive phases: GLM timed out every time and the
77
+ # sessions recorded "cross-review inconclusive" and moved on. A review that silently does not
78
+ # happen is the same failure class as a diff that silently is not read.
79
+ #
80
+ # Order matters: DeepSeek leads because it has actually found real bugs (a date-boundary bug
81
+ # two same-vendor passes missed, an anchor bug, an orphaned-permission-rows bug). GLM is the
82
+ # spare tyre, not a co-equal — an alternation policy that sends half the phases to a provider
83
+ # that times out buys variety it never actually collects.
84
+ FALLBACK_PROVIDERS: tuple[str, ...] = ("glm",)
85
+
86
+ # Fail-closed diff-size ceiling (see the module docstring). Observed live: an oversized diff got back
87
+ # an empty `content` field (safe, but wasteful) on one occasion, and — separately, a bulk multi-document
88
+ # diff around 169,000 chars — a `REVIEW: CLEAN` verdict with zero substantive engagement (unsafe: a
89
+ # silent false negative). 60,000 chars is comfortably under both observed failure points while still
90
+ # covering an ordinary multi-file phase diff. REVIEW_MAX_DIFF_CHARS / --max-diff-chars override it.
91
+ DEFAULT_MAX_DIFF_CHARS = 60_000
92
+
93
+ # Every exception class that means "the request did not come back usable", so main() can route it
94
+ # to provider failover instead of letting it escape.
95
+ #
96
+ # `http.client.HTTPException` is the load-bearing entry and was MISSING. Its subclass
97
+ # `IncompleteRead` — a truncated response body, one of the two live failures behind
98
+ # `.caddis/parking-lot/006` — is NOT a ValueError despite the name suggesting a parse problem; its
99
+ # MRO is (IncompleteRead, HTTPException, Exception). So it escaped the handler entirely, Python
100
+ # exited 1, and 1 is EXIT_BLOCKING. A transport truncation was being reported to scripted callers
101
+ # as "the reviewer found blocking issues" — the same class of bug as the `content: null` crash
102
+ # documented in call_llm, in the same file, unfixed on the sibling path.
103
+ TRANSPORT_ERRORS = (
104
+ urllib.error.URLError, urllib.error.HTTPError, http.client.HTTPException,
105
+ RuntimeError, ValueError, TimeoutError, ConnectionError, OSError,
106
+ )
107
+
108
+ # How many times a failing chunk may be halved and retried before the run gives up. Two levels
109
+ # turns one 8-file batch into at most four 2-file requests, which is enough to clear a
110
+ # size-correlated truncation without turning a broken endpoint into sixteen doomed calls.
111
+ RETRY_SPLIT_DEPTH = 2
112
+
113
+
114
+ # A diff over the ceiling is split into batches on whole-FILE boundaries and each batch is
115
+ # reviewed separately, rather than the whole thing being refused. Refusing was correct while
116
+ # there was no alternative — a silent false CLEAN is the worst outcome this tool has — but it
117
+ # put the burden on the author, and the workaround people reached for (`git stash` to split
118
+ # the tree) mutates the working tree mid-verification and cost a hand-resolved merge conflict
119
+ # on serve-sight, 2026-08-03.
120
+ #
121
+ # Batching scales in both directions: a small change is one batch, identical in cost and
122
+ # behaviour to before; a 175k-char phase is three or four, with no judgement call about where
123
+ # to cut. Verdicts aggregate fail-closed (see `aggregate_verdicts`).
124
+ MAX_REVIEW_BATCHES = 12
125
+
126
+
127
+ class ConfigError(Exception):
128
+ """Raised when configuration can't be resolved (unknown provider, or missing API key)."""
129
+
130
+
131
+ DEFAULT_KEYS_FILE = "~/.caddis/keys.env"
132
+
133
+
134
+ def _parse_keys_file(path: str) -> dict[str, str]:
135
+ """Parse an INI/``KEY=VALUE`` keys file; missing file yields ``{}``.
136
+
137
+ Mirror of claude-harness/scripts/oss_model.py::_parse_keys_file — duplicated (15 lines)
138
+ rather than imported so this tool stays a single stdlib-only file that runs from any repo
139
+ the runtime resources are exported into.
140
+ """
141
+ p = Path(path).expanduser()
142
+ if not p.is_file():
143
+ return {}
144
+ out: dict[str, str] = {}
145
+ for raw in p.read_text(encoding="utf-8").splitlines():
146
+ line = raw.strip()
147
+ if not line or line.startswith("#") or "=" not in line:
148
+ continue
149
+ key, val = line.split("=", 1)
150
+ out[key.strip()] = val.strip().strip('"').strip("'")
151
+ return out
152
+
153
+
154
+ def resolve_config(
155
+ args: argparse.Namespace,
156
+ env: dict[str, str],
157
+ provider: str | None = None,
158
+ use_overrides: bool = True,
159
+ ) -> tuple[str, str, str]:
160
+ """(base_url, api_key, model). Precedence: explicit flag > env var > provider preset.
161
+
162
+ Never hard-fails on a model rename: a known --provider supplies sane defaults, and
163
+ REVIEW_MODEL / REVIEW_BASE_URL override them without any code change.
164
+
165
+ `provider` names one explicitly (used for fallback). `use_overrides=False` then ignores
166
+ --model/--base-url/REVIEW_MODEL/REVIEW_BASE_URL, because those were chosen for the PRIMARY
167
+ provider: pointing DeepSeek's model id at GLM's endpoint would turn a clean fallback into
168
+ a confusing 404.
169
+ """
170
+ provider = (
171
+ provider or args.provider or env.get("REVIEW_PROVIDER") or DEFAULT_PROVIDER
172
+ ).strip().lower()
173
+ preset = PROVIDERS.get(provider, {})
174
+ if use_overrides:
175
+ base_url = (args.base_url or env.get("REVIEW_BASE_URL") or preset.get("base_url") or "").rstrip("/")
176
+ model = args.model or env.get("REVIEW_MODEL") or preset.get("model") or ""
177
+ else:
178
+ base_url = (preset.get("base_url") or "").rstrip("/")
179
+ model = preset.get("model") or ""
180
+ if not base_url or not model:
181
+ known = ", ".join(sorted(PROVIDERS))
182
+ raise ConfigError(
183
+ f"could not resolve an endpoint for provider {provider!r}. Use a known --provider "
184
+ f"({known}) or set REVIEW_BASE_URL and REVIEW_MODEL explicitly."
185
+ )
186
+ # Key precedence: REVIEW_API_KEY > provider env (DEEPSEEK_API_KEY, …) > OSS_API_KEY >
187
+ # the caddis keys file — same resolution chain as the claude-oss launcher, so wiring
188
+ # a provider once (in ~/.caddis/keys.env) lights up both lanes.
189
+ key_env = f"{provider.upper()}_API_KEY"
190
+ api_key = ""
191
+ for name in ("REVIEW_API_KEY", key_env, "OSS_API_KEY"):
192
+ api_key = (env.get(name) or "").strip()
193
+ if api_key:
194
+ break
195
+ if not api_key:
196
+ keys_path = env.get("CADDIS_KEYS_FILE") or DEFAULT_KEYS_FILE
197
+ file_keys = _parse_keys_file(keys_path)
198
+ for name in (key_env, "OSS_API_KEY"):
199
+ api_key = (file_keys.get(name) or "").strip()
200
+ if api_key:
201
+ break
202
+ if not api_key:
203
+ # Name the providers that DO have a key. When DeepSeek is unset but GLM is configured, the
204
+ # fix is `--provider glm` — and without this line the reader goes hunting for a credential
205
+ # they already hold, at the least convenient possible moment (a commit pending).
206
+ keys_path = env.get("CADDIS_KEYS_FILE") or DEFAULT_KEYS_FILE
207
+ others = [p for p in configured_providers(env) if p != provider]
208
+ have = (f"\nProviders that DO have a key here: {', '.join(others)}. "
209
+ f"To use one now: --provider {others[0]}." if others else "")
210
+ raise ConfigError(
211
+ f"no API key for provider {provider!r}. Set $REVIEW_API_KEY or ${key_env}, or add\n"
212
+ f" {key_env}=<your-key>\n"
213
+ f"to your keys file ({keys_path}). Override the file path with $CADDIS_KEYS_FILE."
214
+ + have
215
+ )
216
+ return base_url, api_key, model
217
+
218
+
219
+ def configured_providers(env: dict[str, str]) -> list[str]:
220
+ """Every preset provider that currently has a usable key. Never returns or logs a key value.
221
+
222
+ Mirror of claude-harness/scripts/oss_model.py::configured_providers — duplicated for the same
223
+ documented reason as `_parse_keys_file` above: this tool stays a single stdlib-only file that
224
+ runs from any repo the runtime resources are exported into.
225
+
226
+ Exists so a caller can find out BEFORE it needs a key. `/caddis:cross-review` used to discover
227
+ a missing one mid-task with a commit pending, and a command that fails once at an inconvenient
228
+ moment does not get retried — so the safety check it provides is quietly lost, and nothing
229
+ records that it was lost (`.caddis/parking-lot/003`).
230
+ """
231
+ keys_path = env.get("CADDIS_KEYS_FILE") or DEFAULT_KEYS_FILE
232
+ file_keys = _parse_keys_file(keys_path)
233
+ generic = ((env.get("REVIEW_API_KEY") or env.get("OSS_API_KEY") or "").strip()
234
+ or (file_keys.get("OSS_API_KEY") or "").strip())
235
+ out = []
236
+ for name in sorted(PROVIDERS):
237
+ key_env = f"{name.upper()}_API_KEY"
238
+ if generic or (env.get(key_env) or "").strip() or (file_keys.get(key_env) or "").strip():
239
+ out.append(name)
240
+ return out
241
+
242
+
243
+ def _untracked_files(cwd: str) -> list[str]:
244
+ """New, non-ignored files git is not tracking yet. Empty list on any git failure."""
245
+ out = subprocess.run(
246
+ ["git", "ls-files", "--others", "--exclude-standard", "-z"],
247
+ cwd=cwd, capture_output=True, text=True,
248
+ encoding="utf-8", errors="replace", timeout=30,
249
+ )
250
+ if out.returncode != 0:
251
+ return []
252
+ return [p for p in out.stdout.split("\0") if p]
253
+
254
+
255
+ def _untracked_diff(path: str, cwd: str) -> str:
256
+ """A synthetic add-hunk for one untracked file, WITHOUT touching the index.
257
+
258
+ `git add -N` would also work and is shorter, but it WRITES to the index — unacceptable
259
+ in a review tool that routinely runs mid-session against a tree the user is still
260
+ working in. `--no-index` gets the same diff with no side effect.
261
+
262
+ Note the exit code: `git diff --no-index` returns 1 when the files differ, which is the
263
+ normal outcome here. Only >1 is a real failure.
264
+ """
265
+ null = "/dev/null" if os.name != "nt" else "NUL"
266
+ out = subprocess.run(
267
+ ["git", "diff", "--no-index", "--", null, path],
268
+ cwd=cwd, capture_output=True, text=True,
269
+ encoding="utf-8", errors="replace", timeout=30,
270
+ )
271
+ return out.stdout if out.returncode <= 1 else ""
272
+
273
+
274
+ def get_diff(rng: str | None, cwd: str, include_untracked: bool = True) -> str:
275
+ """The unified diff for `rng` (e.g. 'origin/main..HEAD'), or the working tree when None.
276
+
277
+ Working tree = staged + unstaged against HEAD, PLUS untracked non-ignored files.
278
+
279
+ Untracked files are included because `git diff HEAD` reports only paths git already
280
+ tracks, so a change consisting entirely of NEW files produced an empty diff — and the
281
+ empty-diff branch in main() prints `REVIEW: CLEAN` and exits 0 without ever calling the
282
+ model. A brand-new module is exactly the kind of change most worth reviewing, and it was
283
+ the kind that silently got no review at all. Found on serve-sight 2026-08-02: four
284
+ consecutive cross-review runs over a phase of mostly-new files reported CLEAN, and the
285
+ real findings only appeared once the author happened to run `git add -A` first.
286
+
287
+ Untracked files are NOT included for an explicit `rng` — a commit range is a span of
288
+ history, and files that were never committed are correctly outside it.
289
+ """
290
+ cmd = ["git", "diff", rng] if rng else ["git", "diff", "HEAD"]
291
+ # encoding pinned to UTF-8: git emits UTF-8, but text=True would decode with the locale
292
+ # codepage (cp1252 on Windows) — a non-ASCII diff then kills the reader thread and
293
+ # subprocess hands back stdout=None with returncode 0.
294
+ out = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True,
295
+ encoding="utf-8", errors="replace", timeout=30)
296
+ if out.returncode != 0:
297
+ raise RuntimeError(f"git diff failed: {out.stderr.strip()}")
298
+ if rng or not include_untracked:
299
+ return out.stdout
300
+ extra = "".join(_untracked_diff(p, cwd) for p in _untracked_files(cwd))
301
+ # Concatenated BEFORE returning so the caller's REVIEW_MAX_DIFF_CHARS ceiling measures
302
+ # the true payload rather than the tracked-only fraction of it.
303
+ return out.stdout + extra
304
+
305
+
306
+ def split_diff_by_file(diff_text: str) -> list[str]:
307
+ """The diff cut into per-file chunks on `diff --git` boundaries.
308
+
309
+ Splitting on file boundaries, never mid-file: half a file's hunks is worse than no
310
+ review of it, because the model confidently reasons about code it cannot see.
311
+ """
312
+ if not diff_text.strip():
313
+ return []
314
+ parts: list[str] = []
315
+ current: list[str] = []
316
+ for line in diff_text.splitlines(keepends=True):
317
+ if line.startswith("diff --git ") and current:
318
+ parts.append("".join(current))
319
+ current = [line]
320
+ else:
321
+ current.append(line)
322
+ if current:
323
+ parts.append("".join(current))
324
+ return parts
325
+
326
+
327
+ def batch_diff(diff_text: str, max_chars: int) -> list[str]:
328
+ """Pack whole files into as few batches as possible, each under `max_chars`.
329
+
330
+ A single file larger than the ceiling gets its own batch and is sent anyway — it is
331
+ over the limit either way, and reviewing it is strictly better than skipping it. The
332
+ caller reports how many batches were used so an oversized single file is visible.
333
+ """
334
+ files = split_diff_by_file(diff_text)
335
+ if not files:
336
+ return []
337
+ batches: list[str] = []
338
+ current = ""
339
+ for chunk in files:
340
+ if current and len(current) + len(chunk) > max_chars:
341
+ batches.append(current)
342
+ current = chunk
343
+ else:
344
+ current += chunk
345
+ if current:
346
+ batches.append(current)
347
+ return batches
348
+
349
+
350
+ def aggregate_verdicts(verdicts: list[bool | None]) -> bool | None:
351
+ """Fail-closed roll-up: any BLOCKING wins; CLEAN only if EVERY batch was clean.
352
+
353
+ A batch with no parseable verdict (`None`) poisons the result to `None`, which the
354
+ caller maps to EXIT_ERROR — the same fail-closed rule as the single-batch path. An
355
+ unreviewed batch must never be able to produce a CLEAN overall.
356
+ """
357
+ if not verdicts:
358
+ return None
359
+ if any(v is False for v in verdicts):
360
+ return False
361
+ if any(v is None for v in verdicts):
362
+ return None
363
+ return True
364
+
365
+
366
+ def current_branch(cwd: str) -> str:
367
+ try:
368
+ out = subprocess.run(
369
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
370
+ cwd=cwd, capture_output=True, text=True,
371
+ encoding="utf-8", errors="replace", timeout=10,
372
+ )
373
+ return out.stdout.strip() or "the current branch"
374
+ except Exception:
375
+ return "the current branch"
376
+
377
+
378
+ def build_review_prompt(diff_text: str, branch: str, rng: str | None) -> str:
379
+ """Adversarial review prompt — ported from docket runner._review_prompt (kept in sync).
380
+
381
+ Self-contained: the criteria are inline so a real review always happens, and it ends with
382
+ a single machine-parseable verdict line the caller maps to an exit code.
383
+ """
384
+ scope = rng or "the working tree (staged, unstaged and new untracked files)"
385
+ return (
386
+ f"Perform an adversarial code review of the changes on branch '{branch}' ({scope}). "
387
+ "The unified diff is provided below. Judge, in priority order: (1) correctness — logic "
388
+ "bugs, wrong results, missed edge cases; (2) tests — would a test fail without this "
389
+ "change, and are the stated behaviors covered; (3) security — injection, auth, secret "
390
+ "exposure, unvalidated input; (4) conventions — the repo's stated rules; (5) simplicity. "
391
+ "Classify each issue as blocking (must fix before merge), should-fix, or nit. Be "
392
+ "specific: cite the file and line. Do not invent issues; if the diff is clean, say so.\n\n"
393
+ "End with EXACTLY one line and nothing after it: `REVIEW: CLEAN` (no blocking issues) or "
394
+ "`REVIEW: BLOCKING` (one or more blocking issues).\n\n"
395
+ "----- BEGIN DIFF -----\n"
396
+ f"{diff_text}\n"
397
+ "----- END DIFF -----"
398
+ )
399
+
400
+
401
+ def classify_verdict(text: str) -> bool | None:
402
+ """CLEAN→True, BLOCKING→False, neither→None. Fail-closed: any blocking signal wins."""
403
+ u = text.upper()
404
+ if "REVIEW: BLOCKING" in u:
405
+ return False
406
+ if "REVIEW: CLEAN" in u:
407
+ return True
408
+ return None
409
+
410
+
411
+ def call_llm(base_url: str, api_key: str, model: str, prompt: str, timeout: int = 180) -> str:
412
+ """POST to {base_url}/chat/completions (OpenAI-compatible) → the assistant message text.
413
+
414
+ Raises on transport, HTTP, or response-shape failure so main() maps it to EXIT_ERROR.
415
+ """
416
+ payload = json.dumps({
417
+ "model": model,
418
+ "messages": [{"role": "user", "content": prompt}],
419
+ "temperature": 0,
420
+ "stream": False,
421
+ }).encode("utf-8")
422
+ req = urllib.request.Request(
423
+ f"{base_url}/chat/completions",
424
+ data=payload,
425
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
426
+ method="POST",
427
+ )
428
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
429
+ data = json.loads(resp.read().decode("utf-8"))
430
+ try:
431
+ content = data["choices"][0]["message"]["content"]
432
+ except (KeyError, IndexError, TypeError) as exc:
433
+ raise RuntimeError(f"unexpected response shape from endpoint: {exc}") from exc
434
+ # An HTTP 200 carrying empty or null content is a REAL observed failure (see the module
435
+ # docstring: an oversized diff came back with an empty `content` field). Returning it
436
+ # unraised had two bad consequences, both silent:
437
+ # - it never reached the `except` in main(), so the provider FAILOVER never fired — the
438
+ # run died having never tried the spare, which is the exact case failover exists for;
439
+ # - `content: null` (legal in OpenAI-compatible responses) returned None, and
440
+ # `classify_verdict(None)` then raised AttributeError. Uncaught, Python exits 1 —
441
+ # which is EXIT_BLOCKING. A crash was being reported to scripted callers as "the
442
+ # reviewer found blocking issues."
443
+ # Raising here routes both into the existing retry/failover path and kills the crash.
444
+ if not isinstance(content, str) or not content.strip():
445
+ raise RuntimeError(
446
+ "endpoint returned HTTP 200 with empty or absent message content "
447
+ f"(type={type(content).__name__}) — treating as a provider failure"
448
+ )
449
+ return content
450
+
451
+
452
+ def _force_utf8_stdio() -> None:
453
+ """Make stdout/stderr survive a review written by a model, on any console.
454
+
455
+ A review is prose, and prose from a model contains →, —, ’, … or ≥ sooner or later. A
456
+ Windows console defaults to cp1252, where `print(review)` raises UnicodeEncodeError —
457
+ AFTER the provider round-trip has already been paid for (~8 minutes, observed) and, because
458
+ the print sits inside the per-batch loop, killing every remaining batch with it. Worse, the
459
+ caller sees a traceback and a non-zero exit, which is indistinguishable from a provider
460
+ timeout: the documented response is "switch vendor and retry", so the next move burns
461
+ another full review on the other provider and hits the identical crash, because the cause is
462
+ local stdout encoding, not the provider.
463
+
464
+ `errors="replace"` is the load-bearing part: a mangled arrow is a far better outcome than a
465
+ discarded review. Guarded because stdout may already have been swapped for something without
466
+ `.reconfigure` (pytest's capture, a pipe wrapper) — in which case there is nothing to fix.
467
+ """
468
+ for stream in (sys.stdout, sys.stderr):
469
+ reconfigure = getattr(stream, "reconfigure", None)
470
+ if reconfigure is None:
471
+ continue
472
+ try:
473
+ reconfigure(encoding="utf-8", errors="replace")
474
+ except Exception: # pragma: no cover — defensive; never lose a review to this
475
+ pass
476
+
477
+
478
+ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> int:
479
+ _force_utf8_stdio()
480
+ env = os.environ if env is None else env
481
+ parser = argparse.ArgumentParser(description="Cross-vendor code review via an OpenAI-compatible endpoint.")
482
+ parser.add_argument("--range", dest="range", default=None,
483
+ help="git range to review (e.g. origin/main..HEAD); default = working tree")
484
+ parser.add_argument("--cwd", default=".", help="repo directory (default: cwd)")
485
+ parser.add_argument("--provider", default=None,
486
+ help=f"preset: {', '.join(sorted(PROVIDERS))} (default {DEFAULT_PROVIDER}); env REVIEW_PROVIDER")
487
+ parser.add_argument("--base-url", dest="base_url", default=None, help="override REVIEW_BASE_URL / the preset")
488
+ parser.add_argument("--model", default=None, help="override REVIEW_MODEL / the preset")
489
+ parser.add_argument("--max-diff-chars", type=int, default=None,
490
+ help=f"override REVIEW_MAX_DIFF_CHARS (default {DEFAULT_MAX_DIFF_CHARS})")
491
+ parser.add_argument("--check-config", action="store_true",
492
+ help="report whether a usable key exists and exit — no diff, no LLM call. "
493
+ "Exit 0 = ready, 3 = nothing configured.")
494
+ args = parser.parse_args(argv)
495
+
496
+ # --check-config is deliberately NON-INTERACTIVE. This script runs headless — from CI, from a
497
+ # docket runner, from a `claude -p` session — where a prompt on stdin does not ask a question,
498
+ # it hangs forever. The ASKING belongs in `/caddis:cross-review`, which is driven by an agent
499
+ # that can actually talk to a human. This half just answers "is it configured?" cheaply enough
500
+ # to call before you need it, instead of finding out mid-commit.
501
+ if args.check_config:
502
+ available = configured_providers(env)
503
+ if not available:
504
+ sys.stderr.write(
505
+ "no second-vendor API key is configured anywhere.\n"
506
+ " Cross-review needs one — the whole point is a reviewer that does not share this\n"
507
+ " model's blind spots, so a same-vendor fallback would be no review at all.\n"
508
+ f" Add a key to {env.get('CADDIS_KEYS_FILE') or DEFAULT_KEYS_FILE}, e.g.\n"
509
+ " DEEPSEEK_API_KEY=<your-key>\n")
510
+ return EXIT_CONFIG
511
+ print(f"cross-review is ready. Providers with a key: {', '.join(available)}")
512
+ return EXIT_CLEAN
513
+
514
+ try:
515
+ base_url, api_key, model = resolve_config(args, env)
516
+ primary_provider = (
517
+ args.provider or env.get("REVIEW_PROVIDER") or DEFAULT_PROVIDER
518
+ ).strip().lower()
519
+ except ConfigError as exc:
520
+ sys.stderr.write(f"{exc}\n")
521
+ return EXIT_CONFIG
522
+
523
+ try:
524
+ diff_text = get_diff(args.range, args.cwd)
525
+ except Exception as exc:
526
+ sys.stderr.write(f"could not read diff: {exc}\n")
527
+ return EXIT_ERROR
528
+
529
+ if not diff_text.strip():
530
+ # Says what was scanned, not just that nothing was found. This branch used to fire
531
+ # on a tree full of NEW files (they are invisible to `git diff HEAD`) and report
532
+ # CLEAN without calling the model — a silent false negative that reads identically
533
+ # to a real pass. Untracked files are in scope now; naming the scope keeps the
534
+ # message honest if that ever regresses.
535
+ print(f"No changes to review in {args.range or 'the working tree (incl. untracked)'}.")
536
+ print("REVIEW: CLEAN")
537
+ return EXIT_CLEAN
538
+
539
+ max_diff_chars = args.max_diff_chars
540
+ if max_diff_chars is None:
541
+ env_val = (env.get("REVIEW_MAX_DIFF_CHARS") or "").strip()
542
+ max_diff_chars = int(env_val) if env_val.isdigit() else DEFAULT_MAX_DIFF_CHARS
543
+ batches = batch_diff(diff_text, max_diff_chars) or [diff_text]
544
+ # Batching only helps when the diff HAS file boundaries to cut on. A single file (or a
545
+ # blob with no `diff --git` headers) larger than the ceiling cannot be split, and sending
546
+ # it anyway would reintroduce exactly the silent false-CLEAN the ceiling exists to stop.
547
+ # Refuse, as before — the guarantee is absolute: no prompt over the ceiling is ever sent.
548
+ if any(len(b) > max_diff_chars for b in batches):
549
+ biggest = max(batches, key=len)
550
+ first_line = biggest.splitlines()[0][:120] if biggest.strip() else "(no file header)"
551
+ sys.stderr.write(
552
+ f"diff is {len(diff_text):,} chars and contains a single chunk of {len(biggest):,}, "
553
+ f"over the {max_diff_chars:,}-char review ceiling — refusing rather than risk an "
554
+ "unreviewable prompt silently coming back CLEAN.\n"
555
+ f" Largest chunk starts: {first_line}\n"
556
+ + (
557
+ # The working tree includes untracked files by design (that fix stopped a
558
+ # false-CLEAN on all-new-file phases), which means an agent's own uncommitted
559
+ # plans/PRDs are counted as "the diff". Observed live: 77k of untracked
560
+ # .caddis/ markdown pushed a real phase over the ceiling. Reviewing the phase's
561
+ # commit range is both the workaround AND the more accurate scope, so name it
562
+ # as a literal command instead of leaving each caller to rediscover it.
563
+ " You are reviewing the WORKING TREE, which includes untracked files — "
564
+ "uncommitted docs/plans count toward this total.\n"
565
+ " If the phase is already committed, review its commits instead (usually the "
566
+ "scope you actually want):\n"
567
+ " --range HEAD~1..HEAD\n"
568
+ if args.range is None
569
+ else " Narrow --range to a smaller commit span, or review file-by-file.\n"
570
+ )
571
+ + " Override (not recommended without knowing your endpoint's real limit): "
572
+ "--max-diff-chars <n> or $REVIEW_MAX_DIFF_CHARS.\n"
573
+ )
574
+ return EXIT_ERROR
575
+ if len(batches) > MAX_REVIEW_BATCHES:
576
+ # Loud, never a silent truncation: a capped review that reads like a complete one is
577
+ # the same class of lie as an oversized prompt coming back CLEAN.
578
+ sys.stderr.write(
579
+ f"diff is {len(diff_text):,} chars — {len(batches)} batches, over the "
580
+ f"{MAX_REVIEW_BATCHES}-batch cap. Refusing rather than reviewing part of it and "
581
+ "reporting a verdict that looks whole.\n"
582
+ " Narrow --range to a smaller commit span, or raise --max-diff-chars if you know "
583
+ "your endpoint's real limit.\n"
584
+ )
585
+ return EXIT_ERROR
586
+ if len(batches) > 1:
587
+ print(
588
+ f"[cross-review] diff is {len(diff_text):,} chars, over the {max_diff_chars:,} "
589
+ f"ceiling — reviewing in {len(batches)} batches split on file boundaries. "
590
+ "Verdict is CLEAN only if every batch is clean."
591
+ )
592
+
593
+ branch = current_branch(args.cwd)
594
+
595
+ # Fall back to another vendor only when the user did NOT name one. If they asked for a
596
+ # specific provider, silently reviewing with a different one would misreport who checked
597
+ # the code — the single fact this tool exists to establish.
598
+ # "Explicit" must include a custom ENDPOINT, not just a named preset. Someone who set
599
+ # REVIEW_BASE_URL/REVIEW_MODEL (the documented future-proofing path) has named exactly who
600
+ # should review their code; falling back to the GLM preset would misreport the reviewer —
601
+ # the one fact this tool exists to establish.
602
+ explicit = bool(
603
+ args.provider or env.get("REVIEW_PROVIDER")
604
+ or args.base_url or env.get("REVIEW_BASE_URL")
605
+ or args.model or env.get("REVIEW_MODEL")
606
+ )
607
+ chain: list[tuple[str, str, str, str]] = [(primary_provider, base_url, api_key, model)]
608
+ if not explicit:
609
+ for name in FALLBACK_PROVIDERS:
610
+ if name == primary_provider:
611
+ continue
612
+ try:
613
+ fb_url, fb_key, fb_model = resolve_config(args, env, provider=name, use_overrides=False)
614
+ except ConfigError:
615
+ continue # no key for the spare — not an error, just nothing to fall back to
616
+ chain.append((name, fb_url, fb_key, fb_model))
617
+
618
+ def _try_chain(prompt: str, label: str) -> str | None:
619
+ """One payload against every provider in the chain. None when all of them failed."""
620
+ for idx, (name, url, key, mdl) in enumerate(chain):
621
+ try:
622
+ review = call_llm(url, key, mdl, prompt)
623
+ except TRANSPORT_ERRORS as exc:
624
+ remaining = len(chain) - idx - 1
625
+ sys.stderr.write(
626
+ f"review request failed{label} ({mdl} @ {url}): {exc}\n"
627
+ + (
628
+ f" falling back to {chain[idx + 1][0]}...\n"
629
+ if remaining
630
+ else " If the model id was renamed, set REVIEW_MODEL to the current id "
631
+ "(env overrides the preset).\n"
632
+ )
633
+ )
634
+ continue
635
+ if idx:
636
+ # Loud on purpose. A verdict from a different vendor than the one requested is
637
+ # a material fact about the review, not an implementation detail — it belongs
638
+ # in the transcript the phase report quotes.
639
+ print(f"[cross-review] {chain[0][0]} unavailable; reviewed by {name} ({mdl}).")
640
+ return review
641
+ return None
642
+
643
+ def _review_chunk(chunk: str, label: str, depth: int = 0) -> list[str] | None:
644
+ """Review `chunk`, halving it and retrying once per level if every provider failed.
645
+
646
+ WHY HALVE RATHER THAN JUST FAIL. Both observed failures were transport truncation on a
647
+ long reply — an `http.client.IncompleteRead` on one provider and an empty HTTP 200 body on
648
+ another — and both were SIZE-CORRELATED. Retrying the same payload against the same
649
+ endpoint reproduces the same overflow; retrying a smaller one usually does not. The whole
650
+ run used to abort here, which is safe but throws away a review the user has already
651
+ waited minutes for.
652
+
653
+ Bounded at RETRY_SPLIT_DEPTH levels and never splits a single file, so it cannot loop and
654
+ cannot silently shrink a diff to nothing. Returns None when a chunk that cannot be split
655
+ any further still fails — the abort path stays reachable.
656
+ """
657
+ review = _try_chain(build_review_prompt(chunk, branch, args.range), label)
658
+ if review is not None:
659
+ return [review]
660
+ files = split_diff_by_file(chunk)
661
+ if depth >= RETRY_SPLIT_DEPTH or len(files) < 2:
662
+ return None
663
+ half = len(files) // 2
664
+ sys.stderr.write(
665
+ f"[cross-review] retrying{label} as 2 smaller chunks ({half} + {len(files) - half} "
666
+ "file(s)) — both observed failures were size-correlated truncation.\n")
667
+ out: list[str] = []
668
+ for part_no, part in enumerate(("".join(files[:half]), "".join(files[half:])), 1):
669
+ got = _review_chunk(part, f"{label} part {part_no}", depth + 1)
670
+ if got is None:
671
+ return None
672
+ out += got
673
+ return out
674
+
675
+ verdicts: list[bool | None] = []
676
+ for batch_no, batch in enumerate(batches, 1):
677
+ label = f" (batch {batch_no}/{len(batches)})" if len(batches) > 1 else ""
678
+ if len(batches) > 1:
679
+ files = len(split_diff_by_file(batch))
680
+ print(f"\n===== batch {batch_no}/{len(batches)} — {files} file(s), "
681
+ f"{len(batch):,} chars =====")
682
+
683
+ reviews = _review_chunk(batch, label)
684
+ if reviews is None:
685
+ # Every provider failed, and splitting did not rescue it. Do NOT continue to the next
686
+ # batch: a partial sweep that still prints a verdict is the failure mode this whole
687
+ # tool guards against.
688
+ sys.stderr.write(f"batch {batch_no}/{len(batches)} could not be reviewed — aborting.\n")
689
+ return EXIT_ERROR
690
+
691
+ for review in reviews:
692
+ print(review)
693
+ verdicts.append(classify_verdict(review))
694
+
695
+ verdict = aggregate_verdicts(verdicts)
696
+ if len(batches) > 1:
697
+ # A LEDGER, not a summary. The live report behind `.caddis/parking-lot/006` said the
698
+ # reader's only defence was "count the REVIEW: lines against the announced batch count" —
699
+ # so print that arithmetic instead of making a human do it. `reviewed` can exceed
700
+ # `announced` when a batch was split and retried, and that is worth seeing.
701
+ clean = sum(1 for v in verdicts if v is True)
702
+ blocking = sum(1 for v in verdicts if v is False)
703
+ unparsed = sum(1 for v in verdicts if v is None)
704
+ print(f"\n[cross-review] announced {len(batches)} batches · reviewed {len(verdicts)} · "
705
+ f"clean {clean} · blocking {blocking} · no parseable verdict {unparsed}")
706
+ if unparsed:
707
+ print("[cross-review] a chunk with no verdict is NOT a pass — the overall result "
708
+ "below is fail-closed.")
709
+ if verdict is True:
710
+ return EXIT_CLEAN
711
+ if verdict is False:
712
+ return EXIT_BLOCKING
713
+ sys.stderr.write("no REVIEW: CLEAN|BLOCKING verdict line found in the model output\n")
714
+ return EXIT_ERROR
715
+
716
+
717
+ if __name__ == "__main__":
718
+ raise SystemExit(main())