@caddis/cli 0.4.52 → 0.4.53

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 (100) hide show
  1. package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/repo_freshness.py +132 -5
  4. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
  9. package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
  10. package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
  11. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
  12. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
  13. package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
  14. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
  15. package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
  16. package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
  17. package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
  18. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
  19. package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
  20. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
  21. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
  22. package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
  23. package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
  24. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
  25. package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
  26. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
  27. package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
  28. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
  29. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
  30. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
  31. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
  32. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
  33. package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
  34. package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
  35. package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
  36. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
  37. package/bundles/antigravity-plugin/session_end_agy.py +3 -3
  38. package/bundles/antigravity-plugin/skills/_registry.md +2 -6
  39. package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
  40. package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
  41. package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
  42. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
  43. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
  44. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
  45. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
  46. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
  47. package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
  48. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
  50. package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
  51. package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
  52. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
  53. package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
  54. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
  55. package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
  56. package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
  57. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
  58. package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
  59. package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
  60. package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
  61. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
  62. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
  63. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
  64. package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
  65. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  66. package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
  67. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  68. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
  69. package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
  70. package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
  71. package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
  72. package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
  73. package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
  74. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
  75. package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
  76. package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
  77. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
  78. package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
  79. package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
  80. package/bundles/codex/AGENTS.md +5 -4
  81. package/bundles/manifest.json +3 -3
  82. package/package.json +1 -1
  83. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
  84. package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
  85. package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
  86. package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
  87. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
  88. package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
  89. package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
  90. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
  91. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
  92. package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
  93. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
  94. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
  95. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
  96. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
  97. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
  98. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
  99. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
  100. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
@@ -0,0 +1,1309 @@
1
+ """Agentic review: a lane worktree against a card (GLM, DeepSeek), or a plan phase with Claude and the review protocol.
2
+
3
+ `--plan <path> --phase <n> --runs <k>` reviews the phase's commits k times, each run in its own throwaway worktree, merges
4
+ the FINDING lines of all runs and exits by the findings, never by a verdict: a `blocking` finding -> 1; only
5
+ `should-fix`, `nit` or no findings -> 0 (the should-fix and nit findings are listed in the merged report under
6
+ `Follow-up (not blocking)`, for a follow-up list; they never stop a merge); a run that did not complete (cut off, no END
7
+ REVIEW line, wrong count, a line that names a finding but is not one, changed the live repo) -> 3, and so does any
8
+ unexpected error. Exit 4 means the review could not start (bad arguments, `--runs` over 3, no protocol file, no such
9
+ phase, not a git repo, an unreadable rounds file): a caller treats 4 exactly like 3, NOT_RUN.
10
+
11
+ Round limit: every tool run that reached the reviewers is recorded in `<out-dir>/review-<phase>-rounds.json` (one
12
+ entry per run: round, plan, utc, head sha, result, exit; a completed round that is not CLEAN also keeps its
13
+ blocking finding lines, which the next fix-diff review gets again). Only a COMPLETED run (not NOT_RUN) of the same plan is a
14
+ round; a NOT_RUN entry has round null and uses nothing. A phase gets 3 rounds (`MAX_ROUNDS`). The 4th and every later round still runs and writes its report, but a round that completed exits 5
15
+ (`caddis_exit.ROUND_LIMIT`) whatever it found, clean included: stop, the owner decides. A caller treats 5 as a stop, not
16
+ as a pass and not as a reason to start another round. A round that did not complete still exits 3. The count lives in
17
+ the out dir: a new out dir starts again at round 1. Run one review of a phase at a time: the rounds file has no lock.
18
+ Round 2 and later review only the fix diff, `<head of the last completed round>..HEAD`, when that head is an ancestor
19
+ of HEAD after the base; else (no head, not a commit, not an ancestor, the same head) the full range, with a stderr note.
20
+ A fix diff of review tier 0 (`caddis_gate.review_tier` with risk "normal": docs, `.caddis`, tests only) launches no
21
+ reviewer: it exits 0 and records `SKIPPED delta tier 0`, which is not a round.
22
+ On a POSIX host with no usable systemd user scope the governor refuses, so every run there is NOT_RUN (fails safe);
23
+ plan reviews run on the Windows dev box.
24
+
25
+ The live-repo check is a TRIPWIRE, not a sandbox. The reviewer runs with full Bash and the real HOME: it can write
26
+ outside the repo, and nothing stops it. The check only compares a snapshot of the live repo (HEAD, refs, index, config
27
+ files, every `git config` scope with its origin, hooks, info, changed and untracked files, high-risk files inside
28
+ ignored cache folders) before and after each run, and makes the run NOT_RUN when they differ. Known gaps (not seen):
29
+ `objects/info/alternates`, pseudo-refs and MERGE_MSG, submodule configs, and an edit that keeps a file's size and
30
+ mtime (files over 50 MB only). Ignored logs or caches written by other processes during a run make it NOT_RUN: that
31
+ fails safe."""
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import hashlib
37
+ import json
38
+ import os
39
+ import re
40
+ import shutil
41
+ import signal
42
+ import stat
43
+ import subprocess
44
+ import sys
45
+ import tempfile
46
+ import time
47
+ import unicodedata
48
+ import uuid
49
+ from dataclasses import dataclass
50
+ from pathlib import Path
51
+
52
+ try:
53
+ import caddis_exit
54
+ import caddis_redact
55
+ import caddis_routing
56
+ except ModuleNotFoundError as exc:
57
+ if exc.name not in {"caddis_exit", "caddis_redact", "caddis_routing"}:
58
+ raise
59
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
60
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
61
+ import caddis_exit
62
+ import caddis_redact
63
+ import caddis_routing
64
+
65
+ USAGE_LIMIT_RE = re.compile(
66
+ r"Usage limit reached for|Limit Exhausted|Insufficient balance|You.ve hit your .*limit|RESOURCE_EXHAUSTED",
67
+ re.IGNORECASE,
68
+ )
69
+
70
+
71
+ def _review_role(repo: Path) -> list[str]:
72
+ return caddis_routing.resolve(Path.home(), repo).get("review", [])
73
+
74
+
75
+ def provider_order(provider: str | None, repo: Path) -> list[str]:
76
+ role = [p for p in _review_role(repo) if p != "claude"]
77
+ if not provider:
78
+ return role
79
+ order = [provider]
80
+ for p in role:
81
+ if p != provider:
82
+ order.append(p)
83
+ return order
84
+
85
+
86
+ def _find_launcher_ps1() -> Path:
87
+ if hasattr(caddis_routing, "__file__") and caddis_routing.__file__:
88
+ routing_dir = Path(caddis_routing.__file__).resolve().parent
89
+ if (routing_dir / "claude-oss.ps1").is_file():
90
+ return routing_dir / "claude-oss.ps1"
91
+ this_dir = Path(__file__).resolve().parent
92
+ if (this_dir / "claude-oss.ps1").is_file():
93
+ return this_dir / "claude-oss.ps1"
94
+ harness_script = Path(__file__).resolve().parents[1] / "claude-harness" / "scripts" / "claude-oss.ps1"
95
+ if harness_script.is_file():
96
+ return harness_script
97
+ return this_dir / "claude-oss.ps1"
98
+
99
+
100
+ def launch(provider: str, prompt: str, cwd: Path) -> tuple[int, str]:
101
+ shell = "pwsh" if shutil.which("pwsh") else "powershell"
102
+ script = _find_launcher_ps1()
103
+ cmd = [
104
+ shell, "-NoProfile", "-File", str(script),
105
+ provider, "-p", "--permission-mode", "plan",
106
+ "--output-format", "json", prompt,
107
+ ]
108
+ # Print mode returns only the LAST message. A reviewer that starts a background task ends on a note about that task,
109
+ # and the verdict is lost (seen with DeepSeek on 10 Oct 2026). claude-oss.ps1 sets this for GLM only.
110
+ env = {**os.environ, "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1"}
111
+ # stdin closed: started from a background shell with an open stdin, the launcher hung 40 minutes (10 Oct 2026).
112
+ res = subprocess.run(cmd, cwd=cwd, stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8",
113
+ errors="replace", env=env)
114
+ if res.returncode != 0 and res.stderr:
115
+ # A limit or key message often comes on stderr; keep it for the switch decision.
116
+ return res.returncode, (res.stdout + "\n" + res.stderr).strip() + "\n"
117
+ return res.returncode, res.stdout
118
+
119
+
120
+ def _leading_json(output: str) -> object:
121
+ """The JSON value at the start of `output`; text after it (stderr appended on a failed run) is ignored."""
122
+ return json.JSONDecoder().raw_decode(output.lstrip())[0]
123
+
124
+
125
+ def _usage(output: str) -> tuple[int, int] | None:
126
+ """(input tokens, output tokens) from `claude -p --output-format json`, or None when absent."""
127
+ try:
128
+ usage = _leading_json(output).get("usage", {})
129
+ return int(usage.get("input_tokens", 0)) + int(usage.get("cache_read_input_tokens", 0)) + int(
130
+ usage.get("cache_creation_input_tokens", 0)), int(usage.get("output_tokens", 0))
131
+ except (ValueError, AttributeError, TypeError):
132
+ return None
133
+
134
+
135
+ def _verdicts(text: str) -> set[str]:
136
+ found = set()
137
+ for line in text.splitlines():
138
+ cleaned = line.strip().strip("*`").strip()
139
+ if cleaned in ("VERDICT: APPROVE", "VERDICT: CHANGES"):
140
+ found.add(cleaned.split()[-1])
141
+ return found
142
+
143
+
144
+ def _extract_review_text(output: str) -> str:
145
+ try:
146
+ data = _leading_json(output)
147
+ if isinstance(data, dict) and isinstance(data.get("result"), str):
148
+ return data["result"]
149
+ except Exception:
150
+ pass
151
+ return output
152
+
153
+
154
+ def run_review(
155
+ *,
156
+ worktree: Path,
157
+ card: Path,
158
+ providers: list[str],
159
+ out_dir: Path,
160
+ launch,
161
+ base: str = "origin/main",
162
+ contract: Path | None = None,
163
+ budget=None,
164
+ record=None,
165
+ ) -> int:
166
+ """Review the worktree's diff with the first provider that can run. Optional spend hooks (the CLI passes none
167
+ since the programme ledger went, caddis-next Phase 7): `budget(provider)` returns 0 when the call is inside a
168
+ budget, else an exit code (nothing is called). `record(provider, tokens_in, tokens_out)` records one call and
169
+ returns True; False stops the review, because spend that is not recorded would let the cap pass unseen."""
170
+ card = Path(card)
171
+ worktree = Path(worktree)
172
+ out_dir = Path(out_dir)
173
+
174
+ if not card.is_file():
175
+ print(f"caddis_review: card missing: {card}", file=sys.stderr)
176
+ return caddis_exit.ERROR
177
+
178
+ res = subprocess.run(
179
+ ["git", "ls-files", "--others", "--exclude-standard", "-z"],
180
+ cwd=worktree,
181
+ capture_output=True,
182
+ check=True,
183
+ )
184
+ untracked_raw = [f for f in res.stdout.split(b"\0") if f]
185
+ untracked_files = [os.fsdecode(f) for f in untracked_raw]
186
+
187
+ try:
188
+ if untracked_files:
189
+ # Inside the try: a partial add is undone too.
190
+ subprocess.run(["git", "add", "-N", "--", *untracked_files], cwd=worktree, check=True)
191
+ prompt_lines = [
192
+ f"Review the changes in this worktree against card {card}.",
193
+ f"Card: {card}",
194
+ f"Base: git diff {base}",
195
+ ]
196
+ if contract is not None:
197
+ prompt_lines.append(f"Contract: {contract}")
198
+ prompt_lines.extend([
199
+ "Pass criteria: Check correctness against the card's pass criteria.",
200
+ "Checks: a test that cannot fail, silent failures, the repo's own landmines.",
201
+ "Instructions:",
202
+ "- Do not edit files.",
203
+ "- Do not run git commands that change anything.",
204
+ "- Return exactly one line VERDICT: APPROVE or VERDICT: CHANGES, then at most 10 findings with file:line and severity (blocker/major/minor).",
205
+ ])
206
+ # One line: the prompt is an argument to claude-oss.ps1 and then to claude, an npm .cmd shim on Windows that
207
+ # cuts a multi-line argument at its first newline (.caddis/kb/headless-runner-probes.md).
208
+ prompt = " ".join(prompt_lines)
209
+
210
+ out_dir.mkdir(parents=True, exist_ok=True)
211
+ # Provider output can hold a key (a launcher that echoes its env, 10 Oct 2026): mask the values from the
212
+ # keys file before anything is parsed, printed or written.
213
+ masks = caddis_redact.load_secrets(Path.home())
214
+ raw_launch = launch
215
+
216
+ def launch(provider, prompt, cwd): # noqa: F811 - every provider call returns masked output
217
+ code, text = raw_launch(provider, prompt, cwd=cwd)
218
+ return code, caddis_redact.redact(text, masks)
219
+
220
+ idx = 0
221
+ while idx < len(providers):
222
+ provider = providers[idx]
223
+ next_provider = providers[idx + 1] if idx + 1 < len(providers) else None
224
+
225
+ for attempt in range(2):
226
+ budget_code = budget(provider) if budget is not None else caddis_exit.CLEAN
227
+ if budget_code != caddis_exit.CLEAN:
228
+ reason = ("budget cap reached" if budget_code == caddis_exit.BLOCKED
229
+ else f"budget cannot be checked (spend check exit {budget_code})")
230
+ called = "nothing more was called (one billed attempt)" if attempt else "nothing was called"
231
+ print(f"REVIEW {provider}: FAILED {reason}; {called}")
232
+ return caddis_exit.BLOCKED
233
+ t0 = time.monotonic()
234
+ code, output = launch(provider, prompt, cwd=worktree)
235
+ duration = int(time.monotonic() - t0)
236
+ tokens = _usage(output)
237
+ if record is not None:
238
+ # Known usage is always recorded. A completed run with no usage has unknown spend: stop. A failed
239
+ # run with no usage (no key, usage error, limit text) is most likely not billed: warn, then retry
240
+ # or switch as usual.
241
+ if tokens is not None and record(provider, *tokens) is False:
242
+ print(f"REVIEW {provider}: FAILED the call's spend was not recorded; stopping")
243
+ return caddis_exit.BLOCKED
244
+ if tokens is None and code == 0:
245
+ print(f"REVIEW {provider}: FAILED the call completed but reported no usage; stopping")
246
+ return caddis_exit.BLOCKED
247
+ if tokens is None:
248
+ print(f"WARNING: {provider} exit {code} reported no usage; no spend line written")
249
+ if code != 2:
250
+ break
251
+
252
+ # Limit text counts only when the run gave no verdict: a review may quote such text from the code.
253
+ has_usage_limit = bool(USAGE_LIMIT_RE.search(output)) and not (
254
+ code == 0 and _verdicts(_extract_review_text(output)))
255
+ needs_switch = (code == 3) or (code == 2) or has_usage_limit
256
+
257
+ # Again after the JSON decode: an escaped key form that survived in the raw JSON is plain text now.
258
+ review_text = caddis_redact.redact(_extract_review_text(output), masks)
259
+ review_file = out_dir / f"{card.stem}-{provider}.txt"
260
+ content = review_text.rstrip("\r\n") + f"\nEXIT {code} secs {duration}\n"
261
+
262
+ if needs_switch:
263
+ if code == 3:
264
+ reason = "exit 3"
265
+ elif code == 2:
266
+ reason = "exit 2"
267
+ elif has_usage_limit:
268
+ reason = "usage limit reached"
269
+ else:
270
+ reason = f"exit {code}"
271
+
272
+ if next_provider is not None:
273
+ switch_line = f"SWITCH {provider} -> {next_provider}: {reason}"
274
+ print(switch_line)
275
+ content += switch_line + "\n"
276
+ review_file.write_text(content, encoding="utf-8")
277
+ idx += 1
278
+ continue
279
+ else:
280
+ review_file.write_text(content, encoding="utf-8")
281
+ print(f"caddis_review: no further providers available (failed on {provider}: {reason})")
282
+ return caddis_exit.NOT_RUN
283
+
284
+ review_file.write_text(content, encoding="utf-8")
285
+
286
+ verdicts = _verdicts(review_text)
287
+
288
+ if len(verdicts) == 1:
289
+ verdict = next(iter(verdicts))
290
+ print(f"REVIEW {provider}: {verdict}")
291
+ return caddis_exit.CLEAN if verdict == "APPROVE" else caddis_exit.BLOCKED
292
+ else:
293
+ print(f"REVIEW {provider}: FAILED no verdict line")
294
+ return caddis_exit.BLOCKED
295
+
296
+ print("caddis_review: no providers configured", file=sys.stderr)
297
+ return caddis_exit.NOT_RUN
298
+ finally:
299
+ if untracked_files:
300
+ undo = subprocess.run(["git", "reset", "-q", "--", *untracked_files], cwd=worktree, check=False,
301
+ capture_output=True, text=True)
302
+ if undo.returncode != 0:
303
+ print(f"WARNING: intent-to-add not undone in {worktree} (git reset exit {undo.returncode}): "
304
+ f"{undo.stderr.strip()}; run: git reset -q -- <new files>")
305
+
306
+
307
+ # -- plan-phase review: Claude with the review protocol, k independent runs, findings merged ---------------------------
308
+
309
+ _HERE = Path(__file__).resolve().parent
310
+ PROTOCOL_NAME = "review-protocol.md"
311
+ CLAUDE_TIMEOUT_SECS = 30 * 60
312
+ MAX_BUDGET_USD = 5
313
+ # One shell command may run at most this long. A reviewer that ran whole suites in the background and polled them
314
+ # with `sleep 590` spent the full 1800 s on two runs in a row (12 Oct 2026), so every run was NOT_RUN.
315
+ BASH_MAX_MS = 180_000
316
+ TIME_RULE = (
317
+ f"TIME LIMIT: this whole review must finish within {CLAUDE_TIMEOUT_SECS // 60} minutes of wall-clock time; at the "
318
+ f"limit the run is killed and discarded. Each command is cut off after {BASH_MAX_MS // 60_000} minutes. Run only "
319
+ "targeted checks: one test file or a `-k` selection, a dry run, a probe script. Never run a whole test suite (the "
320
+ "implementer and CI run it), never start a command in the background (`&`, `nohup`, `start`), and never wait or "
321
+ "poll (`sleep`, `timeout`, `tail` loops). When a check would take longer, skip it and write one line "
322
+ "`not executed: <command> (too slow)` in step 4."
323
+ )
324
+ HASH_LIMIT_BYTES = 50 * 1024 * 1024
325
+ SEVERITY_RANK = {"nit": 0, "should-fix": 1, "blocking": 2}
326
+ # Look-alike letters of the word "finding" (Cyrillic, Greek, Armenian, IPA) mapped to ASCII, for detection only.
327
+ _CONFUSABLES = str.maketrans({
328
+ "\u03dc": "f", "\u03dd": "f", "\u0493": "f", "\u0492": "f",
329
+ "\u0456": "i", "\u0406": "i", "\u04c0": "i", "\u04cf": "i", "\u03b9": "i", "\u0399": "i", "\u0131": "i",
330
+ "\u026a": "i", "\u0269": "i", "\u01c0": "i", "\u2160": "i",
331
+ "\u039d": "n", "\u0578": "n", "\u0274": "n", "\u043f": "n", "\u0438": "n",
332
+ "\u0501": "d", "\u0500": "d", "\u0257": "d", "\u13e7": "d",
333
+ "\u0261": "g", "\u050d": "g", "\u050c": "g", "\u0262": "g",
334
+ })
335
+ # A list, quote, table or heading prefix: `- `, `* `, `+ `, `> `, `1. `, `1) `, `| `, `#`, backticks and whitespace.
336
+ _LEAD_RE = re.compile(r"^(?:[\s>*+|`#-]|\d+[.)](?=\s))+")
337
+ # Anywhere in a (normalized) line: it names a finding, so it must be one, else the run is NOT_RUN.
338
+ _FINDING_MENTION_RE = re.compile(r"finding\s*[-#:(]?\s*\d", re.IGNORECASE)
339
+ _FINDING_RE = re.compile(r"^finding\s*[#:]?\s*(\d+)\s*\|\s*([a-z_ -]+?)\s*\|\s*([^|]*?\S)\s*\|\s*(.*\S)$",
340
+ re.IGNORECASE)
341
+ # Anywhere in a line: two of these on one line are two findings run together, which the per-line parser cannot split.
342
+ _FINDING_ANYWHERE_RE = re.compile(r"finding\s*[#:]?\s*\d+\s*\|", re.IGNORECASE)
343
+ _END_RE = re.compile(r"^END REVIEW\s+(\d+)$", re.IGNORECASE)
344
+ _HEADING_RE = re.compile(r"^#{1,3} ")
345
+ _FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})")
346
+ # A closing fence: only the fence characters (at least as many as the opening one), nothing after them.
347
+ _FENCE_CLOSE_RE = re.compile(r"^\s*(`{3,}|~{3,})\s*$")
348
+ # A Risk line in any markup: `Risk:`, `**Risk:**`, `__Risk:__`, `**Risk** :`, `Risk level =`, after a list, quote,
349
+ # heading or task-list marker. Not preceded or followed by a letter or digit (`_` counts as markup, unlike `\b`).
350
+ # A Risk MARKER line: the label starts the line, after only markup (list, task box, quote, heading, emphasis).
351
+ # Prose that mentions "risk: high" mid-sentence is not a marker (the real-run review of 12 Oct: Phases 11 and 12
352
+ # say "for risk: high phases" and must not become high).
353
+ _RISK_RE = re.compile(r"^[\s>*_`~#+\-\[\]xX]*(?:\d+[.)]\s*)?[\s*_`~\[\]xX]*risk(?:\s+level)?(?![a-z0-9])[\W_]{0,6}?\s*[:=]",
354
+ re.IGNORECASE)
355
+ # A table row whose cell is `Risk` (or `Risk level`), in any markup: the value is the next cell.
356
+ _RISK_CELL_RE = re.compile(r"^\s*\|\s*[*_`~]*\s*risk(?:\s+level)?\s*[*_`~]*\s*\|([^|]*)", re.IGNORECASE)
357
+ _RISK_ORDER = ("low", "normal", "medium", "high")
358
+ MAX_RUNS = 3
359
+ MAX_ROUNDS = 3 # review rounds per phase; the next one exits ROUND_LIMIT, the owner decides
360
+ # Folders a run may legitimately write to in the live repo (tool caches, installs, builds): not hashed when git reports
361
+ # the folder itself as ignored, except the high-risk files in them (`_risky_file`).
362
+ _SNAPSHOT_SKIP_PARTS = frozenset({".venv", "venv", "node_modules", "dist", "build", "__pycache__", ".mypy_cache",
363
+ ".pytest_cache", ".ruff_cache", ".tox", ".cache", ".next", "coverage", ".git"})
364
+ # High-risk files inside a skipped folder, always hashed: they change what Python or a tool runs next time.
365
+ _RISKY_NAMES = frozenset({"pyvenv.cfg", "sitecustomize.py", "usercustomize.py"})
366
+ _GIT_FLAGS = ("--no-optional-locks", "-c", "core.fsmonitor=false")
367
+ _CARD_FIELDS = ("Goal", "Touches", "Exit gate")
368
+ _PREFIX = "caddis_review: "
369
+ FOLLOW_UP_HEADING = "## Follow-up (not blocking)"
370
+
371
+
372
+ @dataclass(frozen=True)
373
+ class Finding:
374
+ severity: str
375
+ location: str
376
+ scenario: str
377
+
378
+ def line(self, n: int) -> str:
379
+ return f"FINDING {n} | {self.severity} | {self.location} | {self.scenario}"
380
+
381
+
382
+ def find_protocol() -> Path | None:
383
+ """The review protocol shipped with the roadmap skill: the source tree first, then an installed bundle."""
384
+ for candidate in (_HERE.parent / ".github" / "skills" / "workflow" / "roadmap" / "assets" / PROTOCOL_NAME,
385
+ _HERE.parent / "skills" / "roadmap" / "assets" / PROTOCOL_NAME):
386
+ if candidate.is_file():
387
+ return candidate
388
+ return None
389
+
390
+
391
+ def default_governor() -> list[str] | None:
392
+ """`caddis_governor.py exec --scope session --`, preferring the admin-installed copy (as caddis-run.ps1 does)."""
393
+ installed = Path(os.environ.get("ProgramData", r"C:\ProgramData")) / "caddis" / "caddis_governor.py"
394
+ for gov in (installed, _HERE / "caddis_governor.py", _HERE.parent / "claude-harness" / "scripts" / "caddis_governor.py"):
395
+ if gov.is_file():
396
+ return [sys.executable, str(gov), "exec", "--scope", "session", "--"]
397
+ return None
398
+
399
+
400
+ def phase_section(plan: Path, n: int | str) -> str:
401
+ """The text from the heading `### Phase <n> ` to the next heading of level 1-3 outside a code fence."""
402
+ start = f"### Phase {n} "
403
+ section: list[str] | None = None
404
+ fence = ""
405
+ for line in Path(plan).read_text(encoding="utf-8").splitlines():
406
+ heading = not fence and _HEADING_RE.match(line)
407
+ if not fence:
408
+ m = _FENCE_RE.match(line)
409
+ fence = m.group(1) if m else ""
410
+ else:
411
+ m = _FENCE_CLOSE_RE.match(line)
412
+ if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence):
413
+ fence = ""
414
+ if section is None:
415
+ if heading and line.startswith(start):
416
+ section = [line]
417
+ elif heading:
418
+ break
419
+ else:
420
+ section.append(line)
421
+ if section is None:
422
+ raise ValueError(f"no heading '### Phase {n} ' in {plan}")
423
+ return "\n".join(section).rstrip() + "\n"
424
+
425
+
426
+ def phase_risk(plan: Path, n: int | str) -> str:
427
+ """The phase's risk, failing closed. A Risk line is a line (after `_normalize`) that STARTS with `Risk:` or
428
+ `Risk level:` after only markup, or a table row whose first cell is `Risk`. Its value is the first word after it, markup stripped: `normal`,
429
+ `low` or `medium` give that value, anything else (`high`, `very high`, `critical`, none) gives `high`. The highest
430
+ of several Risk lines wins. No Risk line: `normal`."""
431
+ values = []
432
+ for raw in phase_section(plan, n).splitlines():
433
+ line = _normalize(raw)
434
+ cell = _RISK_CELL_RE.search(line)
435
+ m = cell or _RISK_RE.match(line)
436
+ if m is None:
437
+ continue
438
+ rest = cell.group(1) if cell else line[m.end():]
439
+ word = re.match(r"""[\s*_`~>|\[\]()"']*([a-z]+)""", rest.lower())
440
+ value = word.group(1) if word else ""
441
+ values.append(value if value in _RISK_ORDER[:3] else "high")
442
+ return max(values, key=_RISK_ORDER.index) if values else "normal"
443
+
444
+
445
+ def plan_card(plan: Path, n: int | str, plan_ref: str | None = None) -> str:
446
+ """The review card for one plan phase: its heading, Goal, Touches and Exit gate."""
447
+ lines = phase_section(plan, n).splitlines()
448
+ fields = {}
449
+ for line in lines:
450
+ for name in _CARD_FIELDS:
451
+ if line.startswith(f"**{name}:**") and name not in fields:
452
+ fields[name] = line[len(name) + 5:].strip()
453
+ card = [f"# Card: {lines[0].lstrip('#').strip()}", f"Plan: {plan_ref or plan}, section '### Phase {n} '", ""]
454
+ for name in _CARD_FIELDS:
455
+ card.append(f"**{name}:** {fields.get(name, '(not in the plan section; read the section itself)')}")
456
+ return "\n".join(card) + "\n"
457
+
458
+
459
+ def _normalize(line: str) -> str:
460
+ """NFKC (full-width and other compatibility forms become ASCII), then every format character (Unicode category
461
+ Cf: zero-width space and joiners, LRM, RLM, U+202A-202E, word joiner, BOM, soft hyphen) removed."""
462
+ return "".join(c for c in unicodedata.normalize("NFKC", line) if unicodedata.category(c) != "Cf")
463
+
464
+
465
+ def _skeleton(line: str) -> str:
466
+ """For detection only: accents dropped and look-alike letters mapped to ASCII, so `FІNDING 1` (Cyrillic І) or
467
+ `fíndíng 1` still counts as a line that names a finding."""
468
+ decomposed = unicodedata.normalize("NFKD", line.translate(_CONFUSABLES))
469
+ return "".join(c for c in decomposed if not unicodedata.combining(c)).translate(_CONFUSABLES)
470
+
471
+
472
+ def _clean(line: str) -> str:
473
+ """The line after `_normalize`, without a list, quote, table or heading prefix, bold or code marks and a
474
+ trailing `|`."""
475
+ text = _LEAD_RE.sub("", _normalize(line)).rstrip().rstrip("*`").rstrip()
476
+ if text.endswith("|"):
477
+ text = text[:-1].rstrip().rstrip("*`").rstrip()
478
+ return text
479
+
480
+
481
+ def _scan(text: str) -> tuple[list[Finding], list[str]]:
482
+ """(findings, bad lines). Decided per line, failing closed: a line that names a finding anywhere (`finding`, an
483
+ optional `-`, `#`, `:` or `(`, then a digit; any case, after `_normalize`, look-alike letters included) either is
484
+ a full finding after `_clean` or is a bad line, which makes the run NOT_RUN. So is a line with two
485
+ `FINDING <n> |`."""
486
+ findings, bad = [], []
487
+ for raw in text.splitlines():
488
+ line = _clean(raw)
489
+ if len(_FINDING_ANYWHERE_RE.findall(line)) > 1:
490
+ bad.append(line)
491
+ continue
492
+ if not _FINDING_MENTION_RE.search(_skeleton(_normalize(raw))):
493
+ continue
494
+ m = _FINDING_RE.match(line)
495
+ severity = re.sub(r"[_ ]", "-", m.group(2).lower()) if m else ""
496
+ if severity in SEVERITY_RANK:
497
+ findings.append(Finding(severity, m.group(3), m.group(4)))
498
+ else:
499
+ bad.append(line)
500
+ return findings, bad
501
+
502
+
503
+ def parse_findings(text: str) -> list[Finding]:
504
+ """Every `FINDING <n> | <severity> | <file>:<line> | <scenario>` line; other lines (VERDICT included) are ignored."""
505
+ return _scan(text)[0]
506
+
507
+
508
+ def _location_key(location: str) -> str:
509
+ key = location.strip().replace("\\", "/")
510
+ return key[2:] if key.startswith("./") else key
511
+
512
+
513
+ def merge_findings(runs: list[list[Finding]]) -> list[Finding]:
514
+ """Every distinct (location, scenario); an exact duplicate keeps its highest severity. Blocking first, then
515
+ should-fix, then nit; first appearance within a severity."""
516
+ best: dict[tuple[str, str], Finding] = {}
517
+ for run in runs:
518
+ for finding in run:
519
+ key = (_location_key(finding.location), " ".join(finding.scenario.split()).lower())
520
+ kept = best.get(key)
521
+ if kept is None or SEVERITY_RANK[finding.severity] > SEVERITY_RANK[kept.severity]:
522
+ best[key] = finding
523
+ return sorted(best.values(), key=lambda f: -SEVERITY_RANK[f.severity])
524
+
525
+
526
+ def check_complete(output: str) -> tuple[str, str, float | None]:
527
+ """(review text, problem, cost in USD). `problem` is "" only for a run that completed: JSON with `is_error: false`
528
+ and `subtype: "success"`, whose result ends with `END REVIEW <k>`, k = the number of findings, every line that
529
+ starts like a finding is one, and `NO FINDINGS` when k is 0. A run with a problem is NOT_RUN, whatever it found."""
530
+ if output.startswith(_PREFIX):
531
+ return output, output.splitlines()[0][len(_PREFIX):], None
532
+ try:
533
+ data = _leading_json(output)
534
+ except ValueError:
535
+ return output, "output is not JSON (cut off, timed out or not started)", None
536
+ if not isinstance(data, dict):
537
+ return output, "output is not a JSON object", None
538
+ text = data.get("result") if isinstance(data.get("result"), str) else ""
539
+ cost = data.get("total_cost_usd")
540
+ cost = float(cost) if isinstance(cost, (int, float)) and not isinstance(cost, bool) else None
541
+ if data.get("is_error") is not False:
542
+ return text, f"is_error is {data.get('is_error')!r}, not false", cost
543
+ if data.get("subtype") != "success":
544
+ return text, f"subtype is {data.get('subtype')!r}, not 'success'", cost
545
+ lines = [c for c in (_clean(line) for line in text.splitlines()) if c]
546
+ end = _END_RE.match(lines[-1]) if lines else None
547
+ if end is None:
548
+ return text, "the last line is not 'END REVIEW <count>'", cost
549
+ count = int(end.group(1))
550
+ findings, bad = _scan(text)
551
+ if bad:
552
+ return text, (f"{len(bad)} line(s) name a finding but are not "
553
+ f"'FINDING <n> | blocking|should-fix|nit | <file>:<line> | <text>': {bad[0][:120]}"), cost
554
+ if count != len(findings):
555
+ return text, f"END REVIEW {count} but {len(findings)} FINDING line(s)", cost
556
+ no_findings = any(line.upper() == "NO FINDINGS" for line in lines)
557
+ if count == 0 and not no_findings:
558
+ return text, "END REVIEW 0 without a NO FINDINGS line", cost
559
+ if count and no_findings:
560
+ return text, "NO FINDINGS next to FINDING lines", cost
561
+ return text, "", cost
562
+
563
+
564
+ def _kill_tree(proc) -> None:
565
+ """Kill the child and everything it started (it runs in its own process group / session)."""
566
+ if os.name == "nt":
567
+ subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True)
568
+ else:
569
+ try:
570
+ os.killpg(proc.pid, signal.SIGKILL)
571
+ except (ProcessLookupError, PermissionError):
572
+ proc.kill()
573
+
574
+
575
+ def launch_claude(prompt: str, worktree: Path, out: Path) -> str:
576
+ """One review run: `claude -p` in `worktree`, the prompt on stdin. Writes the masked output to `out`, returns it.
577
+ A timeout, a start failure or a non-zero exit returns a `caddis_review: ` line first, which makes the run NOT_RUN."""
578
+ # Not plan mode: in plan mode the reviewer refuses any command that writes a file, so it cannot run the tests
579
+ # (tested 11 Oct 2026). Edit tools are off; the live repo is checked by the caller, because Bash can still write.
580
+ cmd = [shutil.which("claude") or "claude", "-p", "--model", "opus", "--permission-mode", "bypassPermissions",
581
+ "--disallowedTools", "Edit Write NotebookEdit Bash(sleep:*)", "--output-format", "json",
582
+ "--max-budget-usd", str(MAX_BUDGET_USD)]
583
+ governor = default_governor()
584
+ if governor:
585
+ cmd = governor + cmd
586
+ else:
587
+ print("WARNING: caddis_governor.py not found; the review runs without a CPU and memory cap", file=sys.stderr)
588
+ # CADDIS_HEADLESS keeps relay and session-end hooks quiet (claude-oss.ps1 sets it too).
589
+ # The Bash timeouts make a slow or polling command fail fast instead of eating the run's 30 minutes.
590
+ env = {**os.environ, "CADDIS_HEADLESS": "1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1",
591
+ "BASH_DEFAULT_TIMEOUT_MS": str(BASH_MAX_MS), "BASH_MAX_TIMEOUT_MS": str(BASH_MAX_MS)}
592
+ # Its own process group (Windows) or session (POSIX), so a timeout kills the whole tree, not only the top process.
593
+ group = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt"
594
+ else {"start_new_session": True})
595
+ masks = caddis_redact.load_secrets(Path.home())
596
+ try:
597
+ proc = subprocess.Popen(cmd, cwd=worktree, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
598
+ stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", env=env, **group)
599
+ except OSError as exc:
600
+ output = f"{_PREFIX}claude could not start: {exc}\n"
601
+ else:
602
+ finished = False
603
+ try:
604
+ try:
605
+ # Prompt on stdin: the npm .cmd shim on Windows cuts a multi-line argument at its first newline.
606
+ stdout, stderr = proc.communicate(prompt, timeout=CLAUDE_TIMEOUT_SECS)
607
+ except subprocess.TimeoutExpired:
608
+ _kill_tree(proc)
609
+ try:
610
+ stdout, stderr = proc.communicate(timeout=60)
611
+ except subprocess.TimeoutExpired:
612
+ stdout, stderr = "", ""
613
+ output = f"{_PREFIX}claude timed out after {CLAUDE_TIMEOUT_SECS} s; process tree killed\n{stdout or ''}"
614
+ else:
615
+ output = stdout or ""
616
+ if proc.returncode != 0:
617
+ output = f"{_PREFIX}claude exited {proc.returncode}\n{output}\n{stderr or ''}".rstrip() + "\n"
618
+ finished = True
619
+ finally:
620
+ # Ctrl+C (KeyboardInterrupt) or any other exception: kill the tree, so no reviewer runs on orphaned; the
621
+ # exception then propagates.
622
+ if not finished:
623
+ _kill_tree(proc)
624
+ output = caddis_redact.redact(output, masks)
625
+ out.parent.mkdir(parents=True, exist_ok=True)
626
+ out.write_text(output, encoding="utf-8")
627
+ return output
628
+
629
+
630
+ def _git_out(repo: Path, *args: str, ok: tuple[int, ...] = (0,)) -> str:
631
+ """git's stdout; an exit code not in `ok` raises CalledProcessError with stderr. `--no-optional-locks`: a read
632
+ (`git status`) never rewrites the index, so a snapshot does not change what it measures. `core.fsmonitor=false`:
633
+ no fsmonitor hook (a program the repo config names) runs, and status does not trust its answers."""
634
+ res = subprocess.run(["git", *_GIT_FLAGS, *args], cwd=repo, capture_output=True, text=True,
635
+ encoding="utf-8", errors="replace")
636
+ if res.returncode not in ok:
637
+ raise subprocess.CalledProcessError(res.returncode, res.args, res.stdout, res.stderr)
638
+ return res.stdout
639
+
640
+
641
+ def _error_text(exc: BaseException) -> str:
642
+ if isinstance(exc, subprocess.CalledProcessError):
643
+ cmd = exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]
644
+ args = cmd[1 + len(_GIT_FLAGS):] if tuple(cmd[1:1 + len(_GIT_FLAGS)]) == _GIT_FLAGS else cmd[1:]
645
+ return f"git {' '.join(args)} exited {exc.returncode}: {(exc.stderr or '').strip()}"
646
+ return f"{type(exc).__name__}: {exc}"
647
+
648
+
649
+ def _default_base(repo: Path) -> str:
650
+ res = subprocess.run(["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd=repo,
651
+ capture_output=True, text=True)
652
+ return res.stdout.strip() if res.returncode == 0 and res.stdout.strip() else "origin/main"
653
+
654
+
655
+ def _inside(path: Path, folder: Path) -> bool:
656
+ return path == folder or folder in path.parents
657
+
658
+
659
+ def _is_link(st: os.stat_result) -> bool:
660
+ """A symlink, or on Windows any reparse point (a junction included), from `os.lstat`."""
661
+ reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
662
+ return stat.S_ISLNK(st.st_mode) or bool(getattr(st, "st_file_attributes", 0) & reparse)
663
+
664
+
665
+ def _unlink_link(path: str, st: os.stat_result) -> None:
666
+ """Remove the link itself, never its target: on Windows a junction or a directory symlink with os.rmdir, any
667
+ other link with os.unlink."""
668
+ directory = getattr(stat, "FILE_ATTRIBUTE_DIRECTORY", 0x10)
669
+ if os.name == "nt" and getattr(st, "st_file_attributes", 0) & directory:
670
+ os.rmdir(path)
671
+ else:
672
+ os.unlink(path)
673
+
674
+
675
+ def _remove_links(top: Path) -> None:
676
+ """Remove every symlink and junction at or under `top`, bottom-up, never following one. Raises OSError when a link
677
+ cannot be removed: the caller must then delete nothing, because a delete through a link destroys its target."""
678
+ st = os.lstat(top)
679
+ if _is_link(st):
680
+ _unlink_link(str(top), st)
681
+ return
682
+ folders = [str(top)]
683
+ order: list[str] = []
684
+ while folders: # top-down collection of real folders only; a link is never entered
685
+ folder = folders.pop()
686
+ order.append(folder)
687
+ with os.scandir(folder) as entries:
688
+ for entry in entries:
689
+ entry_st = os.lstat(entry.path)
690
+ if not _is_link(entry_st) and stat.S_ISDIR(entry_st.st_mode):
691
+ folders.append(entry.path)
692
+ for folder in reversed(order): # bottom-up removal of the links in each folder
693
+ with os.scandir(folder) as entries:
694
+ found = [(entry.path, os.lstat(entry.path)) for entry in entries]
695
+ for path, entry_st in found:
696
+ if _is_link(entry_st):
697
+ _unlink_link(path, entry_st)
698
+
699
+
700
+ def _file_hash(path: Path) -> str:
701
+ try:
702
+ st = os.lstat(path)
703
+ if _is_link(st):
704
+ return f"link -> {os.readlink(path)}"
705
+ if st.st_size > HASH_LIMIT_BYTES:
706
+ return f"size {st.st_size} mtime {st.st_mtime_ns}"
707
+ return hashlib.sha256(path.read_bytes()).hexdigest()
708
+ except FileNotFoundError:
709
+ return "missing"
710
+ except OSError as exc:
711
+ return f"unreadable ({type(exc).__name__})"
712
+
713
+
714
+ def _is_venv(folder: Path) -> bool:
715
+ return folder.name in (".venv", "venv") or os.path.lexists(folder / "pyvenv.cfg")
716
+
717
+
718
+ def _risky_file(path: Path) -> bool:
719
+ """A file that changes what Python or a tool runs next: `pyvenv.cfg`, a `.pth` file, `sitecustomize.py`,
720
+ `usercustomize.py`, a file directly in a venv's `Scripts/` or `bin/`, or a file in `node_modules/.bin/`."""
721
+ parent = path.parent
722
+ return (path.name in _RISKY_NAMES or path.name.endswith(".pth")
723
+ or (parent.name in ("Scripts", "bin") and _is_venv(parent.parent))
724
+ or (parent.name == ".bin" and parent.parent.name == "node_modules"))
725
+
726
+
727
+ def _entries(current: Path, folder: Path, label: str, snap: dict[str, str]) -> list[tuple[Path, str, os.stat_result]]:
728
+ """(path, snap key, lstat) of each entry of `current`, sorted; a folder or entry that cannot be read is recorded as
729
+ unreadable in `snap`."""
730
+ try:
731
+ with os.scandir(current) as it:
732
+ names = sorted(it, key=lambda e: e.name)
733
+ except FileNotFoundError:
734
+ return []
735
+ except OSError as exc:
736
+ snap[f"{label}{current.relative_to(folder).as_posix()}/"] = f"unreadable ({type(exc).__name__})"
737
+ return []
738
+ found = []
739
+ for entry in names:
740
+ path = Path(entry.path)
741
+ key = f"{label}{path.relative_to(folder).as_posix()}"
742
+ try:
743
+ found.append((path, key, os.lstat(path)))
744
+ except OSError as exc:
745
+ snap[key] = f"unreadable ({type(exc).__name__})"
746
+ return found
747
+
748
+
749
+ def _hash_risky(folder: Path, label: str, snap: dict[str, str]) -> None:
750
+ """Only the `_risky_file` files under `folder` (an ignored cache folder), without following a link. Inside a
751
+ `node_modules` folder only `.bin/` is entered: its packages are not walked (too many files)."""
752
+ stack = [folder]
753
+ while stack:
754
+ current = stack.pop()
755
+ for path, key, st in _entries(current, folder, label, snap):
756
+ if not _is_link(st) and stat.S_ISDIR(st.st_mode):
757
+ if path.name != ".git" and (current.name != "node_modules" or path.name == ".bin"):
758
+ stack.append(path)
759
+ elif _risky_file(path):
760
+ snap[key] = _file_hash(path)
761
+
762
+
763
+ def _hash_folder(folder: Path, label: str, snap: dict[str, str], skip: Path | None = None, skipped=None) -> None:
764
+ """`snap["<label><relative path>"]` = the content hash of every file under `folder`, without following a link and
765
+ without entering `skip`. For a folder where `skipped(path)` is True only its `_risky_file` files are hashed; where
766
+ it is None the folder is left out."""
767
+ stack = [folder]
768
+ while stack:
769
+ current = stack.pop()
770
+ for path, key, st in _entries(current, folder, label, snap):
771
+ if not _is_link(st) and stat.S_ISDIR(st.st_mode):
772
+ if skip is not None and _inside(path.resolve(), skip):
773
+ continue
774
+ verdict = skipped(path) if skipped is not None else False
775
+ if verdict:
776
+ _hash_risky(path, f"{key}/", snap)
777
+ elif verdict is not None:
778
+ stack.append(path)
779
+ else:
780
+ snap[key] = _file_hash(path)
781
+
782
+
783
+ def _snapshot(root: Path, skip: Path) -> dict[str, str]:
784
+ """Everything of the live repo a run could change, as {what: fingerprint}: HEAD and what it points at, `git status`
785
+ (porcelain v2: it holds the HEAD and index object ids), the content of each tracked file changed in the work
786
+ tree, every ref, `.git/config`, `.git/config.worktree`, `.git/index`, every file under `.git/hooks/` and
787
+ `.git/info/`, `git config --list --show-origin --show-scope` (every scope: system, global, local, worktree,
788
+ command, with their include files), and the content of every untracked and ignored file. Not hashed: `skip` (the
789
+ out-dir), nested `.git` folders, files over 50 MB (size and mtime only), and a tool cache or install folder
790
+ (`_SNAPSHOT_SKIP_PARTS`) that git reports as ignored at its own path - except its `_risky_file` files, which are
791
+ always hashed. An untracked folder that is not ignored is always hashed in full, whatever its name. Every git call
792
+ passes `-c core.fsmonitor=false`. A git error raises CalledProcessError; the caller makes the run NOT_RUN."""
793
+ skip = skip.resolve()
794
+ ignored = {name for name in _git_out(root, "ls-files", "--others", "--ignored", "--exclude-standard",
795
+ "--directory", "-z").split("\0") if name.endswith("/")}
796
+
797
+ def skipped_rel(rel: str) -> bool:
798
+ return rel.rstrip("/").rsplit("/", 1)[-1] in _SNAPSHOT_SKIP_PARTS and rel.rstrip("/") + "/" in ignored
799
+
800
+ def skipped(path: Path) -> bool | None:
801
+ """None for a nested `.git` (never hashed); True for a cache folder that git reports as ignored itself."""
802
+ if path.name == ".git":
803
+ return None
804
+ try:
805
+ rel = path.relative_to(root).as_posix()
806
+ except ValueError:
807
+ return False
808
+ return skipped_rel(rel)
809
+
810
+ paths = _git_out(root, "rev-parse", "--git-path", "config", "--git-path", "config.worktree", "--git-path", "index",
811
+ "--git-path", "hooks", "--git-path", "info").splitlines()
812
+ config, config_worktree, index, hooks, info = ((root / p).resolve() for p in paths)
813
+ snap = {"HEAD": _git_out(root, "rev-parse", "HEAD").strip(),
814
+ "symbolic HEAD (git symbolic-ref)": _git_out(root, "symbolic-ref", "-q", "HEAD", ok=(0, 1)).strip(),
815
+ # Every scope with its origin: a global or system change (core.hooksPath, an alias, a credential helper,
816
+ # an include) changes what git runs next, in this repo too.
817
+ "git config (every scope)": _git_out(root, "config", "--list", "--show-origin", "--show-scope"),
818
+ ".git/config": _file_hash(config),
819
+ ".git/config.worktree": _file_hash(config_worktree),
820
+ ".git/index": _file_hash(index)}
821
+ # Not `git diff HEAD`: it refreshes and rewrites the index even with --no-optional-locks (tested 12 Oct 2026).
822
+ # Porcelain v2 holds the HEAD and index object ids; the work-tree content of a changed tracked file is hashed here.
823
+ entries = iter(_git_out(root, "status", "--porcelain=v2", "--untracked-files=normal", "-z").split("\0"))
824
+ for entry in entries:
825
+ kind = entry[:2]
826
+ if kind == "? ":
827
+ if not _inside((root / entry[2:]).resolve(), skip):
828
+ snap[f"status '{entry}'"] = ""
829
+ continue
830
+ fields = {"1 ": 9, "2 ": 10, "u ": 11}.get(kind)
831
+ if fields is None:
832
+ continue
833
+ snap[f"status '{entry}'"] = ""
834
+ if kind == "2 ":
835
+ next(entries, None) # the rename's original path
836
+ name = entry.split(" ", fields - 1)[-1]
837
+ if entry[3] != ".": # changed in the work tree: its content counts, not only its status
838
+ snap[f"worktree {name}"] = _file_hash(root / name)
839
+ for line in _git_out(root, "for-each-ref", "--format=%(refname) %(objectname)").splitlines():
840
+ ref, _sep, obj = line.rpartition(" ")
841
+ snap[f"ref {ref}"] = obj
842
+ _hash_folder(hooks, "hook ", snap)
843
+ _hash_folder(info, "git info/", snap)
844
+ # Without --exclude-standard, --others lists untracked AND ignored entries; --directory lists a folder with no
845
+ # tracked file once (`node_modules/`), so an ignored cache folder is never listed file by file.
846
+ risky_done: set[str] = set()
847
+ for name in _git_out(root, "ls-files", "--others", "--directory", "-z").split("\0"):
848
+ if not name or _inside((root / name).resolve(), skip):
849
+ continue
850
+ parts = name.rstrip("/").split("/")
851
+ if ".git" in parts:
852
+ continue
853
+ cut = next((i for i in range(len(parts)) if skipped_rel("/".join(parts[:i + 1]))), None)
854
+ if cut is not None:
855
+ # An ignored cache folder (or an entry inside one): only its high-risk files.
856
+ prefix = "/".join(parts[:cut + 1])
857
+ if prefix not in risky_done:
858
+ risky_done.add(prefix)
859
+ _hash_risky(root / prefix, f"file {prefix}/", snap)
860
+ continue
861
+ if name.endswith("/"):
862
+ _hash_folder(root / name.rstrip("/"), f"file {name}", snap, skip, skipped)
863
+ else:
864
+ snap[f"file {name}"] = _file_hash(root / name)
865
+ return snap
866
+
867
+
868
+ def _live_changes(before: dict[str, str], after: dict[str, str]) -> list[str]:
869
+ return [key for key in sorted(before.keys() | after.keys()) if before.get(key) != after.get(key)]
870
+
871
+
872
+ def _review_worktrees(root: Path) -> list[Path]:
873
+ listing = _git_out(root, "worktree", "list", "--porcelain")
874
+ return [Path(line[len("worktree "):]) for line in listing.splitlines()
875
+ if line.startswith("worktree ") and Path(line[len("worktree "):]).name.startswith("caddis-review-")]
876
+
877
+
878
+ def _drop_registration(root: Path, path: Path) -> None:
879
+ """Remove the admin folder `<git-common-dir>/worktrees/<name>` of the `caddis-review-*` worktree `path` when its
880
+ folder is gone. Never `git worktree prune`: that would also drop the registrations of other tools."""
881
+ if not path.name.startswith("caddis-review-") or os.path.lexists(path):
882
+ return
883
+ admin_root = (root / _git_out(root, "rev-parse", "--git-common-dir").strip()).resolve() / "worktrees"
884
+ if not admin_root.is_dir():
885
+ return
886
+ for admin in admin_root.iterdir():
887
+ try:
888
+ registered = Path((admin / "gitdir").read_text(encoding="utf-8").strip()).parent
889
+ except OSError:
890
+ continue
891
+ if registered.name == path.name and os.path.normcase(os.path.normpath(registered)) == os.path.normcase(
892
+ os.path.normpath(path)):
893
+ _remove_links(admin)
894
+ shutil.rmtree(admin)
895
+
896
+
897
+ def _prune_stale_review_worktrees(root: Path) -> None:
898
+ """Drop `caddis-review-*` registrations whose folder is gone (a crash, a reboot), locked ones included."""
899
+ for path in _review_worktrees(root):
900
+ _drop_registration(root, path)
901
+
902
+
903
+ def _add_worktree(root: Path, head: str, phase: int | str, run: int) -> Path:
904
+ path = Path(tempfile.gettempdir()) / f"caddis-review-{phase}-{run}-{uuid.uuid4().hex[:8]}"
905
+ _git_out(root, "worktree", "add", "--detach", str(path), head)
906
+ return path
907
+
908
+
909
+ def _remove_worktree(root: Path, path: Path) -> str:
910
+ """Remove the run's worktree. "" when it is gone and unregistered, else the problem (the run is then NOT_RUN).
911
+ First every symlink and junction inside it goes, without following any: `git worktree remove` or rmtree through a
912
+ junction would delete the junction's target (a venv, the live repo). If one cannot go, nothing is deleted."""
913
+ try:
914
+ if os.path.lexists(path):
915
+ _remove_links(path)
916
+ # Twice --force: also when the run locked it.
917
+ subprocess.run(["git", "worktree", "remove", "--force", "--force", str(path)], cwd=root, capture_output=True)
918
+ if os.path.lexists(path):
919
+ shutil.rmtree(path)
920
+ _drop_registration(root, path)
921
+ if os.path.lexists(path) or any(p.name == path.name for p in _review_worktrees(root)):
922
+ problem = f"review worktree not removed: {path}"
923
+ else:
924
+ return ""
925
+ except (OSError, subprocess.CalledProcessError) as exc:
926
+ problem = f"review worktree not removed: {path}: {_error_text(exc)}"
927
+ print(f"WARNING: {problem}; remove its links first, then: git worktree remove --force --force {path}")
928
+ return problem
929
+
930
+
931
+ def plan_prompt(protocol_text: str, card_text: str, base_sha: str, head_sha: str, delta: bool = False,
932
+ prior_blocking: list[str] | None = None) -> str:
933
+ """`delta`: `base_sha` is the head the last completed round reviewed, so the diff is only the fix.
934
+ `prior_blocking`: the blocking findings of that round; the reviewer must check each one at `head_sha`, because
935
+ a fix diff alone cannot show that a bug outside it is still there."""
936
+ _meta, _sep, body = protocol_text.partition("\n---\n") if protocol_text.startswith("---") else ("", "", protocol_text)
937
+ what = ("the head the last review round reviewed; this diff is only the fix made since then" if delta
938
+ else "the merge-base with the default branch")
939
+ return "\n".join([
940
+ "You review one phase of a plan. The working folder is a throwaway checkout of the commit under review.",
941
+ f"Diff to review: git diff {base_sha}..{head_sha}",
942
+ f"(base {base_sha} is {what}; head {head_sha} is the commit under review)",
943
+ "",
944
+ TIME_RULE,
945
+ "",
946
+ card_text.rstrip(),
947
+ "",
948
+ *(["The last review round found these BLOCKING issues. For each one, read the whole affected files at "
949
+ f"{head_sha} (not only the diff) and check that it is fixed. Report each one that is not fixed again "
950
+ "as blocking:", *prior_blocking, ""] if prior_blocking else []),
951
+ body.strip(),
952
+ "",
953
+ ])
954
+
955
+
956
+ def _read_rounds(out_dir: Path, phase: int | str) -> list[dict]:
957
+ """The rounds recorded for `phase` in `out_dir`; [] when there is no file. ValueError for a file that is not a list
958
+ of objects: a damaged count must stop the review, not reset the limit."""
959
+ path = out_dir / f"review-{phase}-rounds.json"
960
+ if not path.is_file():
961
+ return []
962
+ try:
963
+ entries = json.loads(path.read_text(encoding="utf-8"))
964
+ except (OSError, ValueError) as exc:
965
+ raise ValueError(f"{path} cannot be read ({exc}); fix or delete it") from exc
966
+ if not isinstance(entries, list) or not all(isinstance(e, dict) for e in entries):
967
+ raise ValueError(f"{path} is not a list of rounds; fix or delete it")
968
+ return entries
969
+
970
+
971
+ SKIPPED_DELTA = "SKIPPED delta tier 0"
972
+
973
+
974
+ def _is_completed(entry: dict, plan_ref: str) -> bool:
975
+ """A completed round of this plan: not NOT_RUN and not SKIPPED (a skip launched no reviewer). Entries without a
976
+ plan (older files) count for every plan."""
977
+ result = str(entry.get("result", ""))
978
+ return result != "NOT_RUN" and not result.startswith("SKIPPED") and entry.get("plan", plan_ref) == plan_ref
979
+
980
+
981
+ def _prior_blocking(entries: list[dict], plan_ref: str) -> list[str] | None:
982
+ """The blocking finding lines stored in the last completed (not NOT_RUN, not SKIPPED) round of this plan, or None
983
+ when that round has none or has no `blocking` key (older entries). The merged report is not read: a later NOT_RUN
984
+ run writes over it, and the findings must not be lost."""
985
+ last = next((e for e in reversed(entries) if _is_completed(e, plan_ref)), None)
986
+ if last is None:
987
+ return None
988
+ blocking = last.get("blocking")
989
+ if not isinstance(blocking, list) or not all(isinstance(b, str) for b in blocking):
990
+ return None
991
+ return list(blocking) or None
992
+
993
+
994
+ def _completed_rounds(entries: list[dict], plan_ref: str) -> int:
995
+ """Rounds used: completed runs of this plan. NOT_RUN and SKIPPED entries use no round."""
996
+ return sum(_is_completed(e, plan_ref) for e in entries)
997
+
998
+
999
+ def _is_ancestor(root: Path, older: str, newer: str) -> bool:
1000
+ """True only when git says `older` is an ancestor of `newer` (exit 0); exit 1 or any error is False."""
1001
+ res = subprocess.run(["git", *_GIT_FLAGS, "merge-base", "--is-ancestor", older, newer], cwd=root,
1002
+ capture_output=True)
1003
+ return res.returncode == 0
1004
+
1005
+
1006
+ def _delta_base(root: Path, entries: list[dict], plan_ref: str, base_sha: str, head: str) -> str | None:
1007
+ """The head the last completed round of this plan reviewed, when the fix diff `<it>..head` is a real sub-range of
1008
+ `base_sha..head`: a descendant of `base_sha`, an ancestor of `head`, not `head` itself, and the diff changes files.
1009
+ Else None: review the full range. A stderr note says why, only when a completed round exists."""
1010
+ last = next((e for e in reversed(entries) if _is_completed(e, plan_ref)), None)
1011
+ if last is None:
1012
+ return None
1013
+ prev = last.get("head")
1014
+ if not isinstance(prev, str) or not re.fullmatch(r"[0-9a-fA-F]{7,64}", prev):
1015
+ why = f"the last round has no usable head ({prev!r})"
1016
+ else:
1017
+ try:
1018
+ prev = _git_out(root, "rev-parse", "--verify", "--quiet", f"{prev}^{{commit}}").strip()
1019
+ except subprocess.CalledProcessError:
1020
+ prev, why = None, f"its head {prev} is not a commit here"
1021
+ if prev is None:
1022
+ pass
1023
+ elif prev == head:
1024
+ why = f"the last round reviewed this head {head}"
1025
+ elif not _is_ancestor(root, prev, head):
1026
+ why = f"its head {prev} is not an ancestor of HEAD {head}"
1027
+ elif not _is_ancestor(root, base_sha, prev):
1028
+ why = f"its head {prev} is not after the base {base_sha}"
1029
+ elif not _git_out(root, "diff", "--name-only", f"{prev}..{head}").strip():
1030
+ why = f"the diff {prev}..{head} changes no file"
1031
+ else:
1032
+ return prev
1033
+ print(f"{_PREFIX}previous review head: {why}; reviewing the full range {base_sha}..{head}", file=sys.stderr)
1034
+ return None
1035
+
1036
+
1037
+ def _delta_tier(root: Path, rng: str) -> str | None:
1038
+ """`caddis_gate.review_tier` of the fix diff with plan risk "normal" (the plan risk would make a high-risk phase
1039
+ tier 2 always, so a docs-only fix could never skip). None when it cannot be computed: the delta is then reviewed."""
1040
+ try:
1041
+ try:
1042
+ import caddis_gate # lazy: caddis_gate imports caddis_review lazily too, so there is no cycle
1043
+ except ModuleNotFoundError as exc:
1044
+ if exc.name != "caddis_gate":
1045
+ raise
1046
+ sys.path.insert(0, str(_HERE))
1047
+ import caddis_gate
1048
+ files = caddis_gate._numstat(rng, root)
1049
+ if not files: # the range changes files (checked before), so no rows means git failed: never a skip
1050
+ raise ValueError("git diff --numstat returned no rows")
1051
+ return caddis_gate.review_tier(files, "normal")
1052
+ except Exception as exc: # noqa: BLE001 - a tier that cannot be computed is reviewed, never skipped
1053
+ print(f"{_PREFIX}review tier of {rng} not computed ({type(exc).__name__}: {exc}); reviewing it",
1054
+ file=sys.stderr)
1055
+ return None
1056
+
1057
+
1058
+ def _record_round(out_dir: Path, phase: int | str, entries: list[dict], round_no: int | None, plan_ref: str,
1059
+ head: str, result: str, code: int, blocking: list[str] | None = None) -> None:
1060
+ """Append one entry to `review-<phase>-rounds.json`. A completed round that is not CLEAN also stores its blocking
1061
+ finding lines (`blocking`), so the next delta review can give them to the reviewer again."""
1062
+ entry = {"round": round_no, "plan": plan_ref, "utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "head": head,
1063
+ "result": result, "exit": code}
1064
+ if blocking is not None and result != "NOT_RUN" and not result.startswith("SKIPPED") and code != caddis_exit.CLEAN:
1065
+ entry["blocking"] = list(blocking)
1066
+ path = out_dir / f"review-{phase}-rounds.json"
1067
+ tmp = path.with_name(path.name + ".tmp")
1068
+ tmp.write_text(json.dumps(entries + [entry], indent=2) + "\n", encoding="utf-8")
1069
+ os.replace(tmp, path)
1070
+
1071
+
1072
+ def _write_results(out_dir: Path, phase: int | str, header: list[str], problems: list[str], costs: list[float | None],
1073
+ merged: list[Finding], code: int, label: str, notes: list[str] | None = None) -> Path:
1074
+ """`review-<phase>-cost.json` and `review-<phase>-merged.md` (ends with `END REVIEW <count>`). The `blocking`
1075
+ findings come first; `should-fix` and `nit` findings follow under `## Follow-up (not blocking)`."""
1076
+ # A run with unknown cost (timeout, crash, no total_cost_usd) may have spent up to its --max-budget-usd.
1077
+ (out_dir / f"review-{phase}-cost.json").write_text(json.dumps(
1078
+ {"phase": str(phase), "runs_usd": costs,
1079
+ "total_cost_usd": round(sum(MAX_BUDGET_USD if c is None else c for c in costs), 6),
1080
+ "runs_without_cost": sum(c is None for c in costs)}, indent=2) + "\n", encoding="utf-8")
1081
+ report = header + [f"Result: {label} (exit {code})"] + (notes or []) + [""]
1082
+ report += [f"- Run {i}: {f'NOT_RUN {p}' if p else 'complete'}" for i, p in enumerate(problems, 1)]
1083
+ report.append("")
1084
+ lines = [f.line(i) for i, f in enumerate(merged, 1)]
1085
+ blocking = [ln for f, ln in zip(merged, lines) if f.severity == "blocking"]
1086
+ follow_up = [ln for f, ln in zip(merged, lines) if f.severity != "blocking"]
1087
+ if blocking:
1088
+ report += ["## Blocking", *blocking, ""]
1089
+ if follow_up:
1090
+ report += [FOLLOW_UP_HEADING, *follow_up, ""]
1091
+ if not merged:
1092
+ report.append("NO FINDINGS")
1093
+ report.append(f"END REVIEW {len(merged)}")
1094
+ merged_path = out_dir / f"review-{phase}-merged.md"
1095
+ merged_path.write_text("\n".join(report) + "\n", encoding="utf-8")
1096
+ return merged_path
1097
+
1098
+
1099
+ def run_plan_review(*, repo: Path, plan: Path, phase: int | str, runs: int, out_dir: Path, launch=None,
1100
+ base: str | None = None, protocol: Path | None = None) -> int:
1101
+ """Review Phase `phase` of `plan` (the commits of `repo`'s HEAD since the merge-base with `base`) `runs` times,
1102
+ each run in its own throwaway worktree. Exit by the merged findings, never by a verdict; 0 only when every run
1103
+ completed on a non-empty diff, left the live repo as it was, and no finding is blocking. should-fix and nit
1104
+ findings do not stop it: the report lists them under `Follow-up (not blocking)`. A completed round that is the
1105
+ 4th or later of the phase (see `MAX_ROUNDS`) exits ROUND_LIMIT instead, clean or not."""
1106
+ repo, plan, out_dir = Path(repo), Path(plan), Path(out_dir)
1107
+ launch = launch or launch_claude
1108
+ protocol = protocol or find_protocol()
1109
+ if protocol is None:
1110
+ print(f"{_PREFIX}{PROTOCOL_NAME} not found next to {_HERE}", file=sys.stderr)
1111
+ return caddis_exit.ERROR
1112
+ try:
1113
+ root = Path(_git_out(repo, "rev-parse", "--show-toplevel").strip()).resolve()
1114
+ except subprocess.CalledProcessError as exc:
1115
+ print(f"{_PREFIX}not a git repo: {repo} ({exc.stderr.strip()})", file=sys.stderr)
1116
+ return caddis_exit.ERROR
1117
+ if _inside(root, out_dir.resolve()):
1118
+ print(f"{_PREFIX}--out-dir {out_dir} is the repo root or contains it; use a folder inside or outside the repo",
1119
+ file=sys.stderr)
1120
+ return caddis_exit.ERROR
1121
+ try:
1122
+ plan_ref = plan.resolve().relative_to(root).as_posix()
1123
+ except ValueError:
1124
+ plan_ref = str(plan.resolve())
1125
+ try:
1126
+ card_text = plan_card(plan, phase, plan_ref)
1127
+ head = _git_out(root, "rev-parse", "HEAD").strip()
1128
+ base_sha = _git_out(root, "merge-base", base or _default_base(root), head).strip()
1129
+ diff_empty = base_sha == head or not _git_out(root, "diff", "--name-only", f"{base_sha}..{head}").strip()
1130
+ except (OSError, ValueError) as exc:
1131
+ print(f"{_PREFIX}{exc}", file=sys.stderr)
1132
+ return caddis_exit.ERROR
1133
+ except subprocess.CalledProcessError as exc:
1134
+ print(f"{_PREFIX}{_error_text(exc)}", file=sys.stderr)
1135
+ return caddis_exit.ERROR
1136
+
1137
+ out_dir.mkdir(parents=True, exist_ok=True)
1138
+ header = [f"# Review of Phase {phase} - {plan_ref}", "", f"Head: {head}", f"Base: {base_sha}"]
1139
+ if diff_empty:
1140
+ problem = "nothing to review: base == head" if base_sha == head else "nothing to review: the diff is empty"
1141
+ print(f"REVIEW phase {phase}: NOT_RUN {problem}")
1142
+ _write_results(out_dir, phase, header, [problem], [0.0], [], caddis_exit.NOT_RUN, "NOT_RUN")
1143
+ return caddis_exit.NOT_RUN
1144
+ try:
1145
+ rounds = _read_rounds(out_dir, phase)
1146
+ except ValueError as exc:
1147
+ print(f"{_PREFIX}{exc}", file=sys.stderr)
1148
+ return caddis_exit.ERROR
1149
+ round_no = _completed_rounds(rounds, plan_ref) + 1
1150
+ # Round 2 and later read only the fix diff: from the head the last completed round reviewed.
1151
+ review_base = _delta_base(root, rounds, plan_ref, base_sha, head) or base_sha
1152
+ prior_blocking: list[str] | None = None
1153
+ if review_base != base_sha:
1154
+ last = next(e for e in reversed(rounds) if _is_completed(e, plan_ref))
1155
+ last_clean = last.get("exit") == caddis_exit.CLEAN
1156
+ if not last_clean:
1157
+ # After a round that was not CLEAN the fix diff alone cannot show that a blocking bug outside it is still
1158
+ # there: give the reviewer those findings to re-check, and never skip. No readable findings: full range.
1159
+ prior_blocking = _prior_blocking(rounds, plan_ref)
1160
+ if prior_blocking is None:
1161
+ print(f"{_PREFIX}the last round was not CLEAN and its blocking findings cannot be read; reviewing "
1162
+ f"the full range {base_sha}..{head}", file=sys.stderr)
1163
+ review_base = base_sha
1164
+ if review_base != base_sha:
1165
+ header.append(f"Fix diff: {review_base}..{head} (the last completed round reviewed {review_base})")
1166
+ if prior_blocking is None and _delta_tier(root, f"{review_base}..{head}") == "0":
1167
+ note = f"Skipped: the fix diff {review_base}..{head} is tier 0 (no logic changed); no reviewer launched."
1168
+ merged_path = _write_results(out_dir, phase, header, [], [], [], caddis_exit.CLEAN, "CLEAN", [note])
1169
+ _record_round(out_dir, phase, rounds, None, plan_ref, head, SKIPPED_DELTA, caddis_exit.CLEAN)
1170
+ print(f"REVIEW phase {phase}: CLEAN ({SKIPPED_DELTA}: {review_base}..{head}); not counted as a round; "
1171
+ f"{merged_path}")
1172
+ return caddis_exit.CLEAN
1173
+ prompt = plan_prompt(protocol.read_text(encoding="utf-8"), card_text, review_base, head,
1174
+ delta=review_base != base_sha, prior_blocking=prior_blocking)
1175
+ try:
1176
+ _prune_stale_review_worktrees(root)
1177
+ except (OSError, subprocess.CalledProcessError) as exc:
1178
+ # Only old registrations are affected; each run still checks its own worktree is gone.
1179
+ print(f"WARNING: stale caddis-review-* registrations not dropped: {_error_text(exc)}")
1180
+
1181
+ # Provider output can hold a key: mask the values from the keys file before anything is parsed, printed or written.
1182
+ masks = caddis_redact.load_secrets(Path.home())
1183
+ run_findings: list[list[Finding]] = []
1184
+ problems: list[str] = []
1185
+ costs: list[float | None] = []
1186
+ for run in range(1, runs + 1):
1187
+ worktree = None
1188
+ output, problem = "", ""
1189
+ try:
1190
+ before = _snapshot(root, out_dir)
1191
+ except (OSError, subprocess.CalledProcessError) as exc:
1192
+ # Without a snapshot a change to the live repo cannot be seen: no run, never a pass.
1193
+ before, problem = None, f"live repo snapshot failed before the run: {_error_text(exc)}"
1194
+ if before is not None:
1195
+ try:
1196
+ worktree = _add_worktree(root, head, phase, run)
1197
+ output = caddis_redact.redact(launch(prompt, worktree, out_dir / f"review-{phase}-run{run}.json"),
1198
+ masks)
1199
+ except subprocess.CalledProcessError as exc:
1200
+ problem = f"review worktree not created: {_error_text(exc)}"
1201
+ except Exception as exc: # noqa: BLE001 - a launcher that crashed is a run that did not happen, never a pass
1202
+ problem = f"launcher failed: {type(exc).__name__}: {exc}"
1203
+ finally:
1204
+ if worktree is not None:
1205
+ removal = _remove_worktree(root, worktree)
1206
+ problem = problem or removal
1207
+ text, incomplete, cost = check_complete(output)
1208
+ if before is None:
1209
+ cost = 0.0 # nothing was launched
1210
+ problem = problem or incomplete
1211
+ changed: list[str] = []
1212
+ if before is not None:
1213
+ try:
1214
+ changed = _live_changes(before, _snapshot(root, out_dir))
1215
+ except (OSError, subprocess.CalledProcessError) as exc:
1216
+ problem = f"live repo snapshot failed after the run: {_error_text(exc)}" + (
1217
+ f"; {problem}" if problem else "")
1218
+ if changed:
1219
+ problem = "the run changed the live repo: " + "; ".join(changed)
1220
+ text = caddis_redact.redact(text, masks)
1221
+ findings = parse_findings(text)
1222
+ status = f"NOT_RUN {problem}" if problem else f"complete, {len(findings)} finding(s)"
1223
+ (out_dir / f"review-{phase}-run{run}.txt").write_text(
1224
+ text.rstrip("\r\n") + f"\nRUN {run}: {status}\n", encoding="utf-8")
1225
+ print(f"REVIEW run {run}: {status}")
1226
+ run_findings.append(findings)
1227
+ problems.append(problem)
1228
+ costs.append(cost)
1229
+
1230
+ merged = merge_findings(run_findings)
1231
+ notes: list[str] = []
1232
+ if any(problems):
1233
+ code, label = caddis_exit.NOT_RUN, "NOT_RUN"
1234
+ elif any(f.severity == "blocking" for f in merged):
1235
+ code, label = caddis_exit.BLOCKED, "BLOCKED"
1236
+ else:
1237
+ code, label = caddis_exit.CLEAN, "CLEAN"
1238
+ outcome, outcome_code = label, code
1239
+ if round_no > MAX_ROUNDS and code != caddis_exit.NOT_RUN:
1240
+ code, label = caddis_exit.ROUND_LIMIT, "ROUND_LIMIT"
1241
+ notes = [f"Review outcome: {outcome} (exit {outcome_code})",
1242
+ f"Round limit: this is round {round_no}; {MAX_ROUNDS} are allowed. Stop: the owner decides."]
1243
+ merged_path = _write_results(out_dir, phase, header, problems, costs, merged, code, label, notes)
1244
+ blocking_lines = [f.line(i) for i, f in enumerate(merged, 1) if f.severity == "blocking"]
1245
+ _record_round(out_dir, phase, rounds, None if outcome == "NOT_RUN" else round_no, plan_ref, head, outcome, code,
1246
+ blocking=blocking_lines)
1247
+ follow_up = sum(f.severity != "blocking" for f in merged)
1248
+ print(f"REVIEW phase {phase}: {label} ({len(merged)} finding(s) from {runs} run(s), {follow_up} not blocking); "
1249
+ f"{'not counted as a round' if outcome == 'NOT_RUN' else f'round {round_no}'}; {merged_path}")
1250
+ if code == caddis_exit.ROUND_LIMIT:
1251
+ print(f"REVIEW phase {phase}: round limit reached ({MAX_ROUNDS} rounds); the review outcome was {outcome}. "
1252
+ "Stop: the owner decides.")
1253
+ return code
1254
+
1255
+
1256
+ def main(argv: list[str] | None = None) -> int:
1257
+ parser = argparse.ArgumentParser(description=__doc__)
1258
+ parser.add_argument("--worktree", type=Path, required=True, help="path to worktree (with --plan: the repo)")
1259
+ what = parser.add_mutually_exclusive_group(required=True)
1260
+ what.add_argument("--card", type=Path, help="path to card")
1261
+ what.add_argument("--plan", type=Path, help="path to a plan; the card is built from its --phase section")
1262
+ parser.add_argument("--phase", help="with --plan: the phase number")
1263
+ parser.add_argument("--runs", type=int, default=None, help="with --plan: independent review runs (default 1)")
1264
+ parser.add_argument("--provider", default=None,
1265
+ help="preferred provider (--plan: claude only; --card: the review role's order)")
1266
+ parser.add_argument("--base", default=None,
1267
+ help="base git ref (with --card: origin/main; with --plan: origin/<default branch>)")
1268
+ parser.add_argument("--contract", type=Path, default=None, help="path to contract")
1269
+ parser.add_argument("--out-dir", type=Path, default=None,
1270
+ help="output directory for reviews (with --plan it also holds the round count: new dir, round 1)")
1271
+ args = parser.parse_args(argv)
1272
+ if args.plan is None and (args.phase is not None or args.runs is not None):
1273
+ parser.error("--phase and --runs need --plan")
1274
+ if args.plan is not None:
1275
+ if args.phase is None:
1276
+ parser.error("--plan requires --phase")
1277
+ runs = 1 if args.runs is None else args.runs
1278
+ if not 1 <= runs <= MAX_RUNS: # each run may cost up to --max-budget-usd; more needs the owner (cost rule)
1279
+ print(f"caddis_review: --runs must be 1-{MAX_RUNS}", file=sys.stderr)
1280
+ return caddis_exit.ERROR
1281
+ if runs < 1:
1282
+ parser.error("--runs must be 1 or more")
1283
+ if (args.provider or "claude") != "claude":
1284
+ print(f"{_PREFIX}--plan reviews need a provider that can run tests (claude)", file=sys.stderr)
1285
+ return caddis_exit.ERROR
1286
+ out_dir = args.out_dir if args.out_dir is not None else (
1287
+ args.worktree.resolve() / ".caddis" / "reviews" / ".local" / f"review-{args.phase}")
1288
+ try:
1289
+ return run_plan_review(repo=args.worktree, plan=args.plan, phase=args.phase, runs=runs, out_dir=out_dir,
1290
+ launch=launch_claude, base=args.base)
1291
+ except Exception as exc: # noqa: BLE001 - an error the review did not map is a review that did not run
1292
+ message = " ".join(f"{type(exc).__name__}: {exc}".split())
1293
+ print(f"{_PREFIX}NOT_RUN unexpected error: {message}", file=sys.stderr)
1294
+ return caddis_exit.NOT_RUN
1295
+ out_dir = args.out_dir if args.out_dir is not None else (args.worktree.resolve().parent / "reviews")
1296
+ providers = provider_order(args.provider, args.worktree)
1297
+ return run_review(
1298
+ worktree=args.worktree,
1299
+ card=args.card,
1300
+ providers=providers,
1301
+ out_dir=out_dir,
1302
+ launch=launch,
1303
+ base=args.base or "origin/main",
1304
+ contract=args.contract,
1305
+ )
1306
+
1307
+
1308
+ if __name__ == "__main__":
1309
+ raise SystemExit(main())