@caddis/cli 0.4.56 → 0.4.57

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 (31) hide show
  1. package/bundles/antigravity-plugin/guard_agy.py +700 -68
  2. package/bundles/antigravity-plugin/hooks.json +2 -1
  3. package/bundles/antigravity-plugin/plugin.json +1 -1
  4. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
  5. package/bundles/antigravity-plugin/scripts/caddis_gate.py +21 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +35 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
  8. package/bundles/antigravity-plugin/scripts/caddis_review.py +59 -9
  9. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +120 -24
  10. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +80 -7
  11. package/bundles/antigravity-plugin/scripts/oss_review.py +300 -79
  12. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +4 -0
  13. package/bundles/antigravity-plugin/session_end_agy.py +13 -10
  14. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +53 -8
  15. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
  16. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
  17. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +9 -5
  18. package/bundles/antigravity-plugin/skills/implement/SKILL.md +18 -11
  19. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
  20. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +44 -4
  21. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
  22. package/bundles/antigravity-plugin/skills/ship/SKILL.md +26 -7
  23. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +31 -0
  24. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +42 -4
  25. package/bundles/antigravity-plugin/warm_start_agy.py +26 -15
  26. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  27. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  28. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
  29. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +9 -5
  30. package/bundles/manifest.json +3 -3
  31. package/package.json +1 -1
@@ -19,6 +19,7 @@ from the files is escaped: a plan, a relay or a hook message can hold any text.
19
19
  from __future__ import annotations
20
20
 
21
21
  import datetime
22
+ import functools
22
23
  import hashlib
23
24
  import html
24
25
  import json
@@ -452,11 +453,13 @@ def default_page_path(root: Path, slug: str, home: Path | None = None) -> Path:
452
453
  return pages_root(home) / f"{main.name}-{tag}" / f"{slug}.html"
453
454
 
454
455
 
455
- def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path:
456
+ def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path | None:
456
457
  """The page. At the default path it also writes `<slug>.json`, the one-row summary the hub is built from; an
457
- `--out` page is a one-off copy and stays out of the hub."""
458
- model = build(root, slug, roadmap_mod)
458
+ `--out` page is a one-off copy and stays out of the hub. None: a newer caddis wrote this page, so it is kept."""
459
459
  path = Path(out) if out else default_page_path(root, slug)
460
+ if out is None and _owned_by_newer(slug, path.with_suffix(".json")): # before build: a skip does no work
461
+ return None
462
+ model = build(root, slug, roadmap_mod)
460
463
  path.parent.mkdir(parents=True, exist_ok=True)
461
464
  _write_atomic(path, render_html(model))
462
465
  if out is None:
@@ -468,6 +471,66 @@ def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path:
468
471
  return path
469
472
 
470
473
 
474
+ # ---- the version gate: a session on an old plugin never overwrites what a newer caddis wrote (robustness #8) -------
475
+
476
+ @functools.lru_cache(maxsize=8)
477
+ def generator_version(here: Path | None = None) -> str:
478
+ """This caddis's version: the Claude plugin's `.claude-plugin/plugin.json` or the agy plugin's root
479
+ `plugin.json` (both beside `scripts/`), else the source checkout's `caddis` entry in
480
+ `.github/runtime-targets.json`, else "dev"."""
481
+ base = Path(here or Path(__file__).resolve().parent).parent
482
+ for manifest in (base / ".claude-plugin" / "plugin.json", base / "plugin.json"):
483
+ data, _ = _read_json(manifest)
484
+ if data and data.get("version"):
485
+ return str(data["version"])
486
+ data, _ = _read_json(base / ".github" / "runtime-targets.json")
487
+ for target in (data or {}).get("targets") or []:
488
+ plugin = target.get("plugin") if isinstance(target, dict) else None
489
+ if isinstance(plugin, dict) and plugin.get("name") == "caddis" and plugin.get("version"):
490
+ return str(plugin["version"])
491
+ return "dev"
492
+
493
+
494
+ def _version_key(text) -> tuple[int, ...] | None:
495
+ parts = str(text or "").strip().split(".")
496
+ return tuple(int(p) for p in parts) if all(p.isdigit() for p in parts) else None
497
+
498
+
499
+ def is_newer(theirs, mine) -> bool:
500
+ """True only when both are numeric versions and `theirs` is the higher one; "dev" or "" never blocks."""
501
+ a, b = _version_key(theirs), _version_key(mine)
502
+ return a is not None and b is not None and a > b
503
+
504
+
505
+ GENERATOR_OWNERSHIP_S = 1800 # a newer caddis owns a page only while it keeps writing it (review of #71)
506
+
507
+
508
+ def _newer_owner(summary: Path) -> tuple[str, bool]:
509
+ """(version, live): the newer caddis that wrote `summary` ("" when it is not newer), and whether it wrote it in
510
+ the last GENERATOR_OWNERSHIP_S. An unreadable time is not live: a downgrade must never freeze a page."""
511
+ data, _ = _read_json(summary)
512
+ theirs = str((data or {}).get("generator") or "")
513
+ if not is_newer(theirs, generator_version()):
514
+ return "", False
515
+ age_h = _age_h(str((data or {}).get("generated_utc") or ""), datetime.datetime.now(datetime.timezone.utc))
516
+ return theirs, age_h is not None and age_h * 3600 <= GENERATOR_OWNERSHIP_S
517
+
518
+
519
+ def _gate(label: str, theirs: str, live: bool) -> bool:
520
+ """True: skip the write. Prints one line when a newer caddis owns it, or when this one takes it over."""
521
+ if theirs and live:
522
+ print(f"[ROADMAP] {label}: a newer caddis ({theirs}) owns this page; skipped")
523
+ return True
524
+ if theirs:
525
+ print(f"[ROADMAP] {label}: taking over from caddis {theirs} (it has not written for "
526
+ f"{GENERATOR_OWNERSHIP_S // 60} min)")
527
+ return False
528
+
529
+
530
+ def _owned_by_newer(slug: str, summary: Path) -> bool:
531
+ return _gate(slug, *_newer_owner(summary))
532
+
533
+
471
534
  # ---- the hub: one page over every roadmap page on this machine, grouped per repo ---------------------------------
472
535
 
473
536
  HUB_STALE_H = 24 # a summary older than this is marked "not refreshed"
@@ -507,7 +570,7 @@ def page_summary(model: dict, main: Path, path: Path) -> dict:
507
570
  else f"stopped: {stop.get('reason')}" if stop.get("reason") else "")
508
571
  nxt = model.get("next") or {}
509
572
  return {
510
- "version": 1, "repo": main.name, "checkout": str(main), "folder": path.parent.name, "slug": model["roadmap"],
573
+ "version": 1, "generator": generator_version(), "repo": main.name, "checkout": str(main), "folder": path.parent.name, "slug": model["roadmap"],
511
574
  "branch": model["branch"], "origin_commit": model.get("origin_commit", ""),
512
575
  "generated_utc": model["generated"],
513
576
  "milestones_done": model["done"], "milestones_total": model["total"],
@@ -530,7 +593,7 @@ def prune_folder(folder: Path, keep: set[str]) -> int:
530
593
  if not folder.is_dir():
531
594
  return removed
532
595
  for js in folder.glob("*.json"):
533
- if js.stem in keep:
596
+ if js.stem in keep or _newer_owner(js)[1]: # a live newer caddis may know a roadmap this one cannot list
534
597
  continue
535
598
  for f in (js, js.with_suffix(".html")):
536
599
  try:
@@ -667,11 +730,21 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime) -> s
667
730
  return "".join(out)
668
731
 
669
732
 
670
- def write_hub(home: Path | None = None, now: datetime.datetime | None = None) -> Path:
671
- """`~/.caddis/roadmap-pages/index.html`: every roadmap page on this machine, grouped per repo."""
733
+ def write_hub(home: Path | None = None, now: datetime.datetime | None = None) -> Path | None:
734
+ """`~/.caddis/roadmap-pages/index.html`: every roadmap page on this machine, grouped per repo. None: a newer
735
+ caddis wrote the hub (its version is in `.hub-generator`), so it is kept."""
672
736
  root_dir = pages_root(home)
673
737
  root_dir.mkdir(parents=True, exist_ok=True)
738
+ stamp = root_dir / ".hub-generator"
739
+ try:
740
+ theirs = stamp.read_text(encoding="utf-8").strip()
741
+ live = time.time() - stamp.stat().st_mtime <= GENERATOR_OWNERSHIP_S
742
+ except (OSError, UnicodeDecodeError):
743
+ theirs, live = "", False
744
+ if _gate("hub", theirs if is_newer(theirs, generator_version()) else "", live):
745
+ return None
674
746
  rows, errors = read_summaries(root_dir)
675
747
  path = root_dir / "index.html"
676
748
  _write_atomic(path, render_hub(rows, errors, now or datetime.datetime.now(datetime.timezone.utc)))
749
+ _write_atomic(stamp, generator_version() + "\n")
677
750
  return path
@@ -29,12 +29,22 @@ Exit codes (fail-closed):
29
29
  0 REVIEW: CLEAN — no blocking issues
30
30
  1 REVIEW: BLOCKING — one or more blocking issues
31
31
  2 unused — advisory is not a review verdict
32
- 3 misconfigured — REVIEW_API_KEY missing, OR the pre-send secret filter refused (a
33
- denylisted path / possible secret; see secret_filter.py) — actionable
34
- message on stderr/stdout either way
35
- 4 error — no diff verdict parsed, git failure, endpoint/parse failure, or the diff
32
+ 3 could not run — REVIEW_API_KEY missing, OR the pre-send secret filter refused (a
33
+ denylisted path / possible secret; see secret_filter.py), OR every
34
+ provider failed with a PROVIDER failure: HTTP 429, any 5xx, a timeout, a
35
+ connection error, an empty reply, an agy error status, or the agy spend
36
+ cap refused — actionable message on stderr/stdout either way
37
+ 4 error — no diff verdict parsed, git failure, other endpoint/parse failure, or the diff
36
38
  exceeds REVIEW_MAX_DIFF_CHARS (see below — never silently downgraded to CLEAN)
37
39
 
40
+ Provider chain (owner policy, 2026-10-16): deepseek -> agy-argon -> glm. A named --provider goes first,
41
+ then the rest in policy order; a provider with no key (or no agy on PATH) is skipped. A provider failure
42
+ moves to the next provider and never splits the diff. On HTTP 429 a `Retry-After` is honoured ONCE per
43
+ provider (capped at 60 s), then the next provider is tried. A provider that failed gets no more calls in
44
+ the same run. agy runs through `caddis_spend.py run` (the EUR spend cap), in plan mode, with the diff in
45
+ the prompt (no tools needed). The secret filter runs once, before any provider, agy included. The
46
+ provider that gave each verdict is printed.
47
+
38
48
  Diff-size ceiling: an oversized diff sent to a chat-completions endpoint has been observed, live, to
39
49
  come back two different unsafe ways — an empty `content` field on an HTTP 200 (at least fails closed:
40
50
  no verdict line ⇒ exit 4), and, worse, a `REVIEW: CLEAN` verdict with no substantive engagement (a
@@ -54,8 +64,11 @@ import argparse
54
64
  import http.client
55
65
  import json
56
66
  import os
67
+ import shutil
68
+ import signal
57
69
  import subprocess
58
70
  import sys
71
+ import time
59
72
  import urllib.error
60
73
  import urllib.request
61
74
  from pathlib import Path
@@ -88,8 +101,9 @@ PROVIDERS: dict[str, dict[str, str]] = {
88
101
  }
89
102
  DEFAULT_PROVIDER = "deepseek"
90
103
 
91
- # Tried, in order, ONLY when the primary provider fails at transport level (timeout, HTTP
92
- # error, unparseable response) AND the user did not name a provider explicitly.
104
+ # The review chain, in owner-policy order (2026-10-16): DeepSeek, then agy Argon, then GLM. A named
105
+ # provider goes first and the rest follow in this order; a custom endpoint (REVIEW_BASE_URL /
106
+ # REVIEW_MODEL) never falls back, because its owner named exactly who reviews.
93
107
  #
94
108
  # Why a fallback rather than just picking one vendor: the whole point of this tool is that the
95
109
  # reviewer is a DIFFERENT vendor from the author, so collapsing to a single provider means an
@@ -102,7 +116,20 @@ DEFAULT_PROVIDER = "deepseek"
102
116
  # two same-vendor passes missed, an anchor bug, an orphaned-permission-rows bug). GLM is the
103
117
  # spare tyre, not a co-equal — an alternation policy that sends half the phases to a provider
104
118
  # that times out buys variety it never actually collects.
105
- FALLBACK_PROVIDERS: tuple[str, ...] = ("glm",)
119
+ AGY_PROVIDER = "agy-argon"
120
+ REVIEW_ORDER: tuple[str, ...] = ("deepseek", AGY_PROVIDER, "glm")
121
+
122
+ # agy runs headless through the spend cap: plan mode (it needs no tools, the diff is in the prompt).
123
+ AGY_MODEL = "gemini-4-argon-high"
124
+ AGY_TIMEOUT_S = 15 * 60
125
+ # The prompt is one command-line argument (`--print=<prompt>`). A Windows command line holds at most
126
+ # 32,767 characters, so a larger prompt skips agy instead of failing to start.
127
+ AGY_MAX_PROMPT_CHARS = 28_000
128
+ # `caddis_spend.py run` refusal codes: 75 over the cap or no lock, 78 cap not configured. Never agy's own.
129
+ SPEND_REFUSED = (75, 78)
130
+
131
+ # On HTTP 429, wait for `Retry-After` once per provider per run, at most this long.
132
+ RETRY_AFTER_CAP_S = 60.0
106
133
 
107
134
  # Fail-closed diff-size ceiling (see the module docstring). Observed live: an oversized diff got back
108
135
  # an empty `content` field (safe, but wasteful) on one occasion, and — separately, a bulk multi-document
@@ -149,6 +176,56 @@ class ConfigError(Exception):
149
176
  """Raised when configuration can't be resolved (unknown provider, or missing API key)."""
150
177
 
151
178
 
179
+ class ProviderFailure(RuntimeError):
180
+ """The provider could not answer: HTTP 429, a 5xx, a timeout, a connection error, an empty reply, an agy
181
+ error status. NOT_RUN (exit 3), never a verdict, never a reason to split the diff."""
182
+
183
+ def __init__(self, message: str, retry_after: float | None = None):
184
+ super().__init__(message)
185
+ self.retry_after = retry_after
186
+
187
+
188
+ class ProviderSkipped(Exception):
189
+ """The provider is not available for this run (the spend cap refused agy, agy is missing, the prompt
190
+ is too long for the agy command line). The chain moves on; nothing was reviewed. `for_run=False`: skip only
191
+ this prompt (it was too long), so a smaller batch may still use the provider."""
192
+
193
+ def __init__(self, message: str, for_run: bool = True):
194
+ super().__init__(message)
195
+ self.for_run = for_run
196
+
197
+
198
+ def _retry_after(exc: BaseException) -> float | None:
199
+ """Seconds from a 429's `Retry-After` header (delta-seconds form only), else None."""
200
+ if isinstance(exc, ProviderFailure):
201
+ return exc.retry_after
202
+ if isinstance(exc, urllib.error.HTTPError) and exc.code == 429 and exc.headers is not None:
203
+ try:
204
+ value = float((exc.headers.get("Retry-After") or "").strip())
205
+ except ValueError:
206
+ return None
207
+ return value if value >= 0 else None
208
+ return None
209
+
210
+
211
+ def failure_kind(exc: BaseException) -> str:
212
+ """'provider' (NOT_RUN: next provider, no split), 'truncation' (size signal: may split), or 'other'.
213
+
214
+ Order matters: HTTPError is a URLError, and RemoteDisconnected is both a ConnectionError and an
215
+ HTTPException (a dropped connection, not a truncated body)."""
216
+ if isinstance(exc, (ProviderFailure, ProviderSkipped)):
217
+ return "provider"
218
+ if isinstance(exc, urllib.error.HTTPError):
219
+ return "provider" if exc.code == 429 or exc.code >= 500 else "other"
220
+ if isinstance(exc, (urllib.error.URLError, TimeoutError, ConnectionError)):
221
+ return "provider"
222
+ if isinstance(exc, http.client.HTTPException):
223
+ return "truncation"
224
+ if isinstance(exc, OSError):
225
+ return "provider"
226
+ return "other"
227
+
228
+
152
229
  DEFAULT_KEYS_FILE = "~/.caddis/keys.env"
153
230
 
154
231
 
@@ -490,14 +567,116 @@ def call_llm(base_url: str, api_key: str, model: str, prompt: str, timeout: int
490
567
  # which is EXIT_BLOCKING. A crash was being reported to scripted callers as "the
491
568
  # reviewer found blocking issues."
492
569
  # Raising here routes both into the existing retry/failover path and kills the crash.
570
+ # Since 2026-10-19 this is a ProviderFailure: NOT_RUN and the next provider, never a split. An empty
571
+ # reply from a throttled DeepSeek is not size-correlated, and halving it sent MORE calls to it.
493
572
  if not isinstance(content, str) or not content.strip():
494
- raise RuntimeError(
573
+ raise ProviderFailure(
495
574
  "endpoint returned HTTP 200 with empty or absent message content "
496
575
  f"(type={type(content).__name__}) — treating as a provider failure"
497
576
  )
498
577
  return content
499
578
 
500
579
 
580
+ def _spend_script() -> Path | None:
581
+ """caddis_spend.py: beside this file in a bundle, else the source repo's scripts/ folder."""
582
+ here = Path(__file__).resolve().parent
583
+ for candidate in (here / "caddis_spend.py", here.parents[1] / "scripts" / "caddis_spend.py"):
584
+ if candidate.is_file():
585
+ return candidate
586
+ return None
587
+
588
+
589
+ def agy_launcher() -> list[str] | None:
590
+ """The command prefix that runs agy under the spend cap, or None when agy or caddis_spend is missing.
591
+ Every agy call goes through `caddis_spend.py run` (owner policy: agy only through the spend cap)."""
592
+ agy = shutil.which("agy")
593
+ spend = _spend_script()
594
+ if not agy or spend is None:
595
+ return None
596
+ return [sys.executable, str(spend), "run", "--caller", "oss-review", "--", agy]
597
+
598
+
599
+ def _kill_tree(proc) -> None:
600
+ """Kill the child and everything it started (it runs in its own process group / session)."""
601
+ if os.name == "nt":
602
+ subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True)
603
+ else:
604
+ try:
605
+ os.killpg(proc.pid, signal.SIGKILL)
606
+ except (ProcessLookupError, PermissionError):
607
+ proc.kill()
608
+
609
+
610
+ def _run_capped(argv: list[str], timeout: float) -> tuple[int, str, str]:
611
+ """Run `argv` with stdin closed; (exit, stdout, stderr). On timeout kill the whole tree and raise
612
+ TimeoutError: a killed caddis_spend must not leave agy running on."""
613
+ group = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt"
614
+ else {"start_new_session": True})
615
+ env = {**os.environ, "CADDIS_HEADLESS": "1"}
616
+ proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
617
+ text=True, encoding="utf-8", errors="replace", env=env, **group)
618
+ try:
619
+ stdout, stderr = proc.communicate(timeout=timeout)
620
+ except subprocess.TimeoutExpired:
621
+ _kill_tree(proc)
622
+ try:
623
+ proc.communicate(timeout=30)
624
+ except subprocess.TimeoutExpired:
625
+ pass
626
+ raise TimeoutError(f"agy timed out after {int(timeout)} s; process tree killed") from None
627
+ except BaseException:
628
+ _kill_tree(proc)
629
+ raise
630
+ return proc.returncode, stdout or "", stderr or ""
631
+
632
+
633
+ def _agy_document(stdout: str) -> dict | None:
634
+ """The last agy JSON document (keys status/response) that starts a line of `stdout`.
635
+
636
+ Mirror of scripts/caddis_spend.py::_agy_document, duplicated so this tool stays one stdlib-only file.
637
+ A `{` inside the model reply never starts a line of the raw output (it is inside a JSON string)."""
638
+ decoder = json.JSONDecoder()
639
+ text = stdout or ""
640
+ starts = [0] if text.startswith("{") else []
641
+ starts += [i + 1 for i, ch in enumerate(text) if ch == "\n" and text[i + 1:i + 2] == "{"]
642
+ found = None
643
+ for start in starts:
644
+ try:
645
+ obj, _end = decoder.raw_decode(text, start)
646
+ except json.JSONDecodeError:
647
+ continue
648
+ if isinstance(obj, dict) and {"status", "response"} <= obj.keys():
649
+ found = obj
650
+ return found
651
+
652
+
653
+ def call_agy(launcher: list[str], prompt: str, timeout: float = AGY_TIMEOUT_S) -> str:
654
+ """One review by agy Argon through the spend cap -> the reply text.
655
+
656
+ ProviderSkipped: the cap refused, agy is missing, or the prompt is too long for one argument.
657
+ ProviderFailure: a non-zero exit, a status other than SUCCESS (ERROR, RESOURCE_EXHAUSTED), no JSON
658
+ document, or an empty reply."""
659
+ if len(prompt) > AGY_MAX_PROMPT_CHARS:
660
+ raise ProviderSkipped(f"prompt is {len(prompt):,} chars, over the {AGY_MAX_PROMPT_CHARS:,}-char "
661
+ "agy command-line limit", for_run=False)
662
+ argv = [*launcher, "--model", AGY_MODEL, "--mode", "plan", "--output-format", "json",
663
+ f"--print={prompt}"]
664
+ code, stdout, stderr = _run_capped(argv, timeout)
665
+ if code in SPEND_REFUSED:
666
+ raise ProviderSkipped(f"the agy spend cap refused the call (caddis_spend exit {code}): "
667
+ f"{stderr.strip()[-300:]}")
668
+ if code == 127:
669
+ raise ProviderSkipped(f"agy could not start: {stderr.strip()[-300:]}")
670
+ doc = _agy_document(stdout)
671
+ status = str(doc.get("status")) if doc else "no JSON document"
672
+ if code != 0 or status != "SUCCESS":
673
+ raise ProviderFailure(f"agy exit {code}, status {status}: {(stderr or stdout).strip()[-300:]}")
674
+ response = doc.get("response")
675
+ if not isinstance(response, str) or not response.strip():
676
+ raise ProviderFailure("agy returned an empty response")
677
+ return response
678
+
679
+
501
680
  def _force_utf8_stdio() -> None:
502
681
  """Make stdout/stderr survive a review written by a model, on any console.
503
682
 
@@ -555,7 +734,9 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
555
734
  help="git range to review (e.g. origin/main..HEAD); default = working tree")
556
735
  parser.add_argument("--cwd", default=".", help="repo directory (default: cwd)")
557
736
  parser.add_argument("--provider", default=None,
558
- help=f"preset: {', '.join(sorted(PROVIDERS))} (default {DEFAULT_PROVIDER}); env REVIEW_PROVIDER")
737
+ help=f"preset: {', '.join(sorted(PROVIDERS))} or {AGY_PROVIDER} (default "
738
+ f"{DEFAULT_PROVIDER}); env REVIEW_PROVIDER. A provider failure falls back in "
739
+ f"the order {' -> '.join(REVIEW_ORDER)}")
559
740
  parser.add_argument("--base-url", dest="base_url", default=None, help="override REVIEW_BASE_URL / the preset")
560
741
  parser.add_argument("--model", default=None, help="override REVIEW_MODEL / the preset")
561
742
  parser.add_argument("--max-diff-chars", type=int, default=None,
@@ -623,11 +804,29 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
623
804
  return EXIT_CONFIG
624
805
  diff_text = filter_result.kept_diff
625
806
 
807
+ primary_provider = (
808
+ args.provider or env.get("REVIEW_PROVIDER") or DEFAULT_PROVIDER
809
+ ).strip().lower()
810
+ # A custom ENDPOINT names exactly who reviews (REVIEW_BASE_URL/REVIEW_MODEL, the documented
811
+ # future-proofing path): it never falls back. A named preset falls back on a provider failure,
812
+ # and the switch is printed, so the reviewer is never misreported.
813
+ custom = bool(args.base_url or env.get("REVIEW_BASE_URL") or args.model or env.get("REVIEW_MODEL"))
814
+ agy_cmd = agy_launcher()
815
+
816
+ def _http_entry(name: str, url: str, key: str, mdl: str):
817
+ return (name, mdl, url, lambda prompt: call_llm(url, key, mdl, prompt))
818
+
819
+ def _agy_entry(launcher: list[str]):
820
+ return (AGY_PROVIDER, AGY_MODEL, "caddis_spend run", lambda prompt: call_agy(launcher, prompt))
821
+
626
822
  try:
627
- base_url, api_key, model = resolve_config(args, env)
628
- primary_provider = (
629
- args.provider or env.get("REVIEW_PROVIDER") or DEFAULT_PROVIDER
630
- ).strip().lower()
823
+ if primary_provider == AGY_PROVIDER and not custom:
824
+ if agy_cmd is None:
825
+ raise ConfigError(f"provider {AGY_PROVIDER!r} needs agy on PATH and caddis_spend.py "
826
+ "(beside this script or in the repo's scripts/ folder).")
827
+ chain = [_agy_entry(agy_cmd)]
828
+ else:
829
+ chain = [_http_entry(primary_provider, *resolve_config(args, env))]
631
830
  except ConfigError as exc:
632
831
  sys.stderr.write(f"{exc}\n")
633
832
  return EXIT_CONFIG
@@ -698,85 +897,100 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
698
897
 
699
898
  branch = current_branch(args.cwd)
700
899
 
701
- # Fall back to another vendor only when the user did NOT name one. If they asked for a
702
- # specific provider, silently reviewing with a different one would misreport who checked
703
- # the code — the single fact this tool exists to establish.
704
- # "Explicit" must include a custom ENDPOINT, not just a named preset. Someone who set
705
- # REVIEW_BASE_URL/REVIEW_MODEL (the documented future-proofing path) has named exactly who
706
- # should review their code; falling back to the GLM preset would misreport the reviewer —
707
- # the one fact this tool exists to establish.
708
- explicit = bool(
709
- args.provider or env.get("REVIEW_PROVIDER")
710
- or args.base_url or env.get("REVIEW_BASE_URL")
711
- or args.model or env.get("REVIEW_MODEL")
712
- )
713
- chain: list[tuple[str, str, str, str]] = [(primary_provider, base_url, api_key, model)]
714
- if not explicit:
715
- for name in FALLBACK_PROVIDERS:
716
- if name == primary_provider:
900
+ # The rest of the chain in policy order (DeepSeek -> agy Argon -> GLM). A provider with no key, or
901
+ # agy with no agy on PATH, is skipped: not an error, just nothing to fall back to.
902
+ if not custom:
903
+ for name in REVIEW_ORDER:
904
+ if name == chain[0][0]:
905
+ continue
906
+ if name == AGY_PROVIDER:
907
+ if agy_cmd is not None:
908
+ chain.append(_agy_entry(agy_cmd))
717
909
  continue
718
910
  try:
719
- fb_url, fb_key, fb_model = resolve_config(args, env, provider=name, use_overrides=False)
911
+ chain.append(_http_entry(name, *resolve_config(args, env, provider=name, use_overrides=False)))
720
912
  except ConfigError:
721
- continue # no key for the spare — not an error, just nothing to fall back to
722
- chain.append((name, fb_url, fb_key, fb_model))
723
-
724
- def _try_chain(prompt: str, label: str) -> str | None:
725
- """One payload against every provider in the chain. None when all of them failed."""
726
- for idx, (name, url, key, mdl) in enumerate(chain):
727
- try:
728
- review = call_llm(url, key, mdl, prompt)
729
- except TRANSPORT_ERRORS as exc:
730
- remaining = len(chain) - idx - 1
731
- sys.stderr.write(
732
- f"review request failed{label} ({mdl} @ {url}): {exc}\n"
733
- + (
734
- f" falling back to {chain[idx + 1][0]}...\n"
735
- if remaining
736
- else " If the model id was renamed, set REVIEW_MODEL to the current id "
737
- "(env overrides the preset).\n"
738
- )
739
- )
740
913
  continue
741
- if idx:
742
- # Loud on purpose. A verdict from a different vendor than the one requested is
743
- # a material fact about the review, not an implementation detail — it belongs
744
- # in the transcript the phase report quotes.
745
- print(f"[cross-review] {chain[0][0]} unavailable; reviewed by {name} ({mdl}).")
746
- return review
747
- return None
748
-
749
- def _review_chunk(chunk: str, label: str, depth: int = 0) -> list[str] | None:
750
- """Review `chunk`, halving it and retrying once per level if every provider failed.
751
914
 
752
- WHY HALVE RATHER THAN JUST FAIL. Both observed failures were transport truncation on a
753
- long reply — an `http.client.IncompleteRead` on one provider and an empty HTTP 200 body on
754
- another — and both were SIZE-CORRELATED. Retrying the same payload against the same
755
- endpoint reproduces the same overflow; retrying a smaller one usually does not. The whole
756
- run used to abort here, which is safe but throws away a review the user has already
757
- waited minutes for.
915
+ # Per run: a provider that failed (429, 5xx, timeout, empty reply, cap refusal) gets no more calls,
916
+ # and each provider waits for a 429 Retry-After at most once. Hammering a throttled provider with
917
+ # more, smaller calls is the 18-19 Oct 2026 failure.
918
+ down: set[str] = set()
919
+ waited: set[str] = set()
920
+
921
+ def _try_chain(prompt: str, label: str) -> tuple[str | None, list[str]]:
922
+ """One payload against every provider still up. (review, []) or (None, the failure kinds)."""
923
+ kinds: list[str] = []
924
+ for idx, (name, mdl, where, call) in enumerate(chain):
925
+ if name in down:
926
+ continue
927
+ while True:
928
+ try:
929
+ review = call(prompt)
930
+ except ProviderSkipped as exc:
931
+ sys.stderr.write(f"[cross-review] {name} skipped{label}: {exc}\n")
932
+ if exc.for_run:
933
+ down.add(name)
934
+ kinds.append("provider")
935
+ break
936
+ except TRANSPORT_ERRORS as exc:
937
+ kind = failure_kind(exc)
938
+ retry = _retry_after(exc)
939
+ if kind == "provider" and retry is not None and name not in waited:
940
+ waited.add(name)
941
+ delay = min(retry, RETRY_AFTER_CAP_S)
942
+ sys.stderr.write(f"[cross-review] {name} is rate-limited (HTTP 429){label}; waiting "
943
+ f"{delay:g} s once (Retry-After), then one retry\n")
944
+ time.sleep(delay)
945
+ continue
946
+ kinds.append(kind)
947
+ if kind == "provider":
948
+ down.add(name)
949
+ left = [n for n, *_ in chain[idx + 1:] if n not in down]
950
+ sys.stderr.write(
951
+ f"review request failed{label} ({mdl} @ {where}): {exc}\n"
952
+ + (f" falling back to {left[0]}...\n" if left
953
+ else " If the model id was renamed, set REVIEW_MODEL to the current id "
954
+ "(env overrides the preset).\n" if kind == "other"
955
+ else " no provider left to try.\n")
956
+ )
957
+ break
958
+ # Loud on purpose, every time: which vendor gave the verdict is a material fact about
959
+ # the review, and it belongs in the transcript the phase report quotes.
960
+ if idx:
961
+ print(f"[cross-review] {chain[0][0]} unavailable; reviewed by {name} ({mdl}).")
962
+ else:
963
+ print(f"[cross-review] reviewed by {name} ({mdl}).")
964
+ return review, []
965
+ return None, kinds or ["provider"] # nothing tried: every provider was already down
966
+
967
+ def _review_chunk(chunk: str, label: str, depth: int = 0) -> tuple[list[str] | None, list[str]]:
968
+ """Review `chunk`; on a size-truncation failure, halve it and retry once per level.
969
+
970
+ WHY HALVE. The observed truncations (`http.client.IncompleteRead`, parking-lot 006) were
971
+ SIZE-CORRELATED: a smaller payload usually gets through. Only that signal splits. A provider
972
+ failure (429, 5xx, timeout, empty reply) does NOT: halving a throttled call sends it more calls.
758
973
 
759
974
  Bounded at RETRY_SPLIT_DEPTH levels and never splits a single file, so it cannot loop and
760
- cannot silently shrink a diff to nothing. Returns None when a chunk that cannot be split
761
- any further still fails — the abort path stays reachable.
975
+ cannot silently shrink a diff to nothing. Returns (None, kinds) when the chunk failed.
762
976
  """
763
- review = _try_chain(build_review_prompt(chunk, branch, args.range, whole_change), label)
977
+ review, kinds = _try_chain(build_review_prompt(chunk, branch, args.range, whole_change), label)
764
978
  if review is not None:
765
- return [review]
979
+ return [review], []
766
980
  files = split_diff_by_file(chunk)
767
- if depth >= RETRY_SPLIT_DEPTH or len(files) < 2:
768
- return None
981
+ if "truncation" not in kinds or depth >= RETRY_SPLIT_DEPTH or len(files) < 2:
982
+ return None, kinds
769
983
  half = len(files) // 2
770
984
  sys.stderr.write(
771
985
  f"[cross-review] retrying{label} as 2 smaller chunks ({half} + {len(files) - half} "
772
- "file(s)) — both observed failures were size-correlated truncation.\n")
986
+ "file(s)) — a truncated reply is size-correlated.\n")
773
987
  out: list[str] = []
774
988
  for part_no, part in enumerate(("".join(files[:half]), "".join(files[half:])), 1):
775
- got = _review_chunk(part, f"{label} part {part_no}", depth + 1)
989
+ got, sub_kinds = _review_chunk(part, f"{label} part {part_no}", depth + 1)
776
990
  if got is None:
777
- return None
991
+ return None, kinds + sub_kinds
778
992
  out += got
779
- return out
993
+ return out, []
780
994
 
781
995
  whole_change = changed_paths(diff_text)
782
996
  verdicts: list[bool | None] = []
@@ -787,13 +1001,20 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
787
1001
  print(f"\n===== batch {batch_no}/{len(batches)} — {files} file(s), "
788
1002
  f"{len(batch):,} chars =====")
789
1003
 
790
- reviews = _review_chunk(batch, label)
1004
+ reviews, kinds = _review_chunk(batch, label)
791
1005
  if reviews is None:
792
1006
  # Every provider failed, and splitting did not rescue it. Do NOT continue to the next
793
1007
  # batch: a partial sweep that still prints a verdict is the failure mode this whole
794
- # tool guards against.
795
- sys.stderr.write(f"batch {batch_no}/{len(batches)} could not be reviewed — aborting.\n")
796
- return EXIT_ERROR
1008
+ # tool guards against. Only provider failures: could not run (3). Anything else: 4.
1009
+ not_run = all(k == "provider" for k in kinds)
1010
+ sys.stderr.write(f"batch {batch_no}/{len(batches)} could not be reviewed — aborting"
1011
+ + (" (every provider failed: NOT_RUN).\n" if not_run else ".\n"))
1012
+ if any(v is False for v in verdicts):
1013
+ # An earlier batch already found a blocking issue. A later failure must not hide it behind
1014
+ # NOT_RUN (3), which reads as "nothing found, retry". Fail closed and report the finding.
1015
+ print("REVIEW: BLOCKING (an earlier batch blocked; the remaining batches were not reviewed)")
1016
+ return EXIT_BLOCKING
1017
+ return EXIT_CONFIG if not_run else EXIT_ERROR
797
1018
 
798
1019
  for review in reviews:
799
1020
  print(review)
@@ -1206,6 +1206,10 @@ relay/
1206
1206
  session-state.md
1207
1207
  session-state/
1208
1208
  context-window.json
1209
+ context-window.json.tmp
1210
+ .session-state-*.tmp
1211
+ hook-errors.jsonl
1212
+ .relay-injected/
1209
1213
  PROJECT-FACTS.md
1210
1214
  # lock files beside locked documents, and migration backups (never commit these)
1211
1215
  *.lock