@caddis/cli 0.4.56 → 0.4.58

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 (45) hide show
  1. package/bundles/antigravity-plugin/caddis_statusline.py +45 -11
  2. package/bundles/antigravity-plugin/guard_agy.py +700 -68
  3. package/bundles/antigravity-plugin/hooks.json +2 -1
  4. package/bundles/antigravity-plugin/plugin.json +1 -1
  5. package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +69 -23
  6. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
  7. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +9 -8
  8. package/bundles/antigravity-plugin/scripts/caddis_gate.py +21 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +39 -2
  10. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
  11. package/bundles/antigravity-plugin/scripts/caddis_review.py +81 -11
  12. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +124 -29
  13. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +80 -7
  14. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +26 -9
  15. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +24 -4
  16. package/bundles/antigravity-plugin/scripts/caddis_spend.py +120 -10
  17. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +45 -11
  18. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +14 -9
  19. package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +12 -0
  20. package/bundles/antigravity-plugin/scripts/hook_log.py +13 -2
  21. package/bundles/antigravity-plugin/scripts/oss_review.py +300 -79
  22. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +20 -6
  23. package/bundles/antigravity-plugin/session_end_agy.py +13 -10
  24. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +53 -8
  25. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
  26. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
  27. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +9 -5
  28. package/bundles/antigravity-plugin/skills/implement/SKILL.md +18 -11
  29. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +4 -1
  30. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +44 -4
  31. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
  32. package/bundles/antigravity-plugin/skills/ship/SKILL.md +26 -7
  33. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +31 -0
  34. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +42 -4
  35. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +3 -2
  36. package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +52 -175
  37. package/bundles/antigravity-plugin/warm_start_agy.py +26 -15
  38. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  39. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  40. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
  41. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +9 -5
  42. package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +52 -175
  43. package/bundles/manifest.json +3 -3
  44. package/dist/cli.js +310 -46
  45. package/package.json +1 -1
@@ -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,12 @@ relay/
1206
1206
  session-state.md
1207
1207
  session-state/
1208
1208
  context-window.json
1209
+ context-window.json.tmp
1210
+ context-window.json.*.tmp
1211
+ context-window/
1212
+ .session-state-*.tmp
1213
+ hook-errors.jsonl
1214
+ .relay-injected/
1209
1215
  PROJECT-FACTS.md
1210
1216
  # lock files beside locked documents, and migration backups (never commit these)
1211
1217
  *.lock
@@ -1416,6 +1422,18 @@ def _user_settings_path(home: Path | None = None) -> Path:
1416
1422
  return root / ".claude" / "settings.json"
1417
1423
 
1418
1424
 
1425
+ def _atomic_write_json(path: Path, data: dict) -> None:
1426
+ """Write `data` to `path` through a temp file. The temp name is unique per process: every
1427
+ SessionStart runs this (inject_relay.py), and two sessions starting at once must not share one."""
1428
+ tmp = path.with_name(f"{path.name}.caddis-tmp-{os.getpid()}")
1429
+ try:
1430
+ tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
1431
+ os.replace(tmp, path)
1432
+ finally:
1433
+ if tmp.exists():
1434
+ tmp.unlink()
1435
+
1436
+
1419
1437
  def ensure_user_env(dry: bool, home: Path | None = None) -> list[str]:
1420
1438
  """Add `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` to the user's global settings, additively.
1421
1439
 
@@ -1466,9 +1484,7 @@ def ensure_user_env(dry: bool, home: Path | None = None) -> list[str]:
1466
1484
  # whole function exists to notice.
1467
1485
  if raw:
1468
1486
  path.with_suffix(".json.caddis-bak").write_text(raw, encoding="utf-8")
1469
- tmp = path.with_suffix(".json.caddis-tmp")
1470
- tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
1471
- os.replace(tmp, path)
1487
+ _atomic_write_json(path, data)
1472
1488
  verb = "would add" if dry else "added"
1473
1489
  return [f"user env: {verb} {TODO_TOOLS_ENV_KEY}=1 to {path}"
1474
1490
  + ("" if not raw else " (previous version saved as settings.json.caddis-bak)")]
@@ -1509,9 +1525,7 @@ def ensure_cleanup_period(dry: bool, home: Path | None = None, days: int = CLEAN
1509
1525
  path.parent.mkdir(parents=True, exist_ok=True)
1510
1526
  if raw:
1511
1527
  path.with_suffix(".json.caddis-retention-bak").write_text(raw, encoding="utf-8")
1512
- tmp = path.with_suffix(".json.caddis-tmp")
1513
- tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
1514
- os.replace(tmp, path)
1528
+ _atomic_write_json(path, data)
1515
1529
  verb = "would set" if dry else "set"
1516
1530
  return f"transcript retention: {verb} {CLEANUP_PERIOD_KEY}={days} in {path}"
1517
1531
 
@@ -61,7 +61,8 @@ def _repo_root(path: str):
61
61
 
62
62
 
63
63
  def _user_artifact_root() -> str:
64
- """Where usage goes when the workspace is in no repository: the user's own folder, never the cwd."""
64
+ """Where usage goes when the workspace is in no repository: the user's own folder, never the cwd.
65
+ The caller writes there only when it already exists."""
65
66
  return _artifact_root(os.path.expanduser("~"))
66
67
 
67
68
 
@@ -121,21 +122,24 @@ def main() -> None:
121
122
  try:
122
123
  roots = _workspace_roots(data) or [os.getcwd()]
123
124
  # A workspace folder may sit inside a repo: the artifact dir is the REPO ROOT's, not the sub-folder's.
124
- resolved = [(_repo_root(r) or str(r)) for r in roots]
125
- # Write the record to every root that ALREADY has a .caddis dir, rather than to
126
- # whichever root happened to be first. A multi-root session's usage belongs to each
127
- # repo that participates in it, and creating .caddis in a repo that never opted in
125
+ # A folder in no repository is skipped: it has no project, and its cwd is never a fallback.
126
+ resolved = [top for top in (_repo_root(r) for r in roots) if top]
127
+ # Write the record only to roots that ALREADY have a .caddis dir. A multi-root session's usage
128
+ # belongs to each repo that participates in it, and creating .caddis in a repo that never opted in
128
129
  # would be caddis littering someone else's tree.
129
130
  targets = []
130
131
  for root in resolved:
131
132
  art = _artifact_root(root)
132
133
  if os.path.isdir(art) and art not in targets:
133
134
  targets.append(art)
135
+ if not resolved:
136
+ # No workspace is in a repository. Usage goes to the user's own folder, and only when
137
+ # that folder already exists. It is never created and never the cwd.
138
+ user_art = _user_artifact_root()
139
+ if os.path.isdir(user_art):
140
+ targets = [user_art]
134
141
  if not targets:
135
- # Nothing has opted in. In a repo, the first root's repo root is the default. Outside any
136
- # repo there is no project to attach usage to, so it goes to the user's folder, not the cwd.
137
- first_repo = _repo_root(roots[0])
138
- targets = [_artifact_root(first_repo) if first_repo else _user_artifact_root()]
142
+ return # nothing has opted in: write nothing
139
143
  record = {
140
144
  "ts": datetime.now(timezone.utc).isoformat(),
141
145
  "event": "session_end",
@@ -146,7 +150,6 @@ def main() -> None:
146
150
  "model": data.get("modelName"),
147
151
  }
148
152
  for art in targets:
149
- os.makedirs(art, exist_ok=True)
150
153
  with open(os.path.join(art, "usage-log.jsonl"), "a", encoding="utf-8") as fh:
151
154
  fh.write(json.dumps(record) + "\n")
152
155
  except Exception as _exc: