@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.
- package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +132 -5
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
- package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
- package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
- package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
- package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
- package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
- package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
- package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
- package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
- package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
- package/bundles/antigravity-plugin/session_end_agy.py +3 -3
- package/bundles/antigravity-plugin/skills/_registry.md +2 -6
- package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
- package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
- package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
- package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
- package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
- package/bundles/codex/AGENTS.md +5 -4
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
- package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
- 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())
|