@caddis/cli 0.4.30 → 0.4.33
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/mcp_ping_server.py +1 -1
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +301 -66
- package/bundles/antigravity-plugin/scripts/caddis_exit.py +15 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +145 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +33 -10
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +5 -2
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +13 -1
- package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +2 -2
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +9 -14
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +66 -0
- package/bundles/antigravity-plugin/scripts/new-machine.ps1 +1 -1
- package/bundles/antigravity-plugin/scripts/oss_ask.py +6 -5
- package/bundles/antigravity-plugin/scripts/oss_model.py +1 -1
- package/bundles/antigravity-plugin/scripts/oss_review.py +84 -15
- package/bundles/antigravity-plugin/scripts/secret_filter.py +316 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +9 -4
- package/bundles/antigravity-plugin/skills/_registry.md +5 -3
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +37 -2
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/digress/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +8 -1
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +3 -1
- package/bundles/antigravity-plugin/skills/install-statusline/SKILL.md +105 -0
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +14 -8
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +2 -32
- package/bundles/antigravity-plugin/skills/review/SKILL.md +2 -96
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +100 -0
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +2 -101
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +3 -3
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
- package/bundles/codex/.codex/config.toml.example +0 -16
- package/bundles/codex/AGENTS.md +2 -2
- package/bundles/manifest.json +3 -3
- package/dist/claude-deepseek.js +1 -1
- package/dist/claude-glm.js +1 -1
- package/dist/claude-oss.js +1 -1
- package/dist/cli.js +1 -1
- package/package.json +1 -1
|
@@ -37,6 +37,14 @@ import subprocess
|
|
|
37
37
|
import sys
|
|
38
38
|
from pathlib import Path
|
|
39
39
|
|
|
40
|
+
try:
|
|
41
|
+
import caddis_frontmatter
|
|
42
|
+
except ModuleNotFoundError as exc:
|
|
43
|
+
if exc.name != "caddis_frontmatter":
|
|
44
|
+
raise
|
|
45
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
46
|
+
import caddis_frontmatter
|
|
47
|
+
|
|
40
48
|
if hasattr(sys.stdout, "reconfigure"):
|
|
41
49
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
42
50
|
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
@@ -145,16 +153,7 @@ def gate_lane(plan: Path, phase: int) -> int:
|
|
|
145
153
|
# ── gate: verdict-gate ──────────────────────────────────────────────────────
|
|
146
154
|
|
|
147
155
|
def _frontmatter(p: Path) -> dict:
|
|
148
|
-
|
|
149
|
-
if not t.startswith("---"):
|
|
150
|
-
return {}
|
|
151
|
-
end = t.find("\n---", 3)
|
|
152
|
-
out = {}
|
|
153
|
-
for line in t[3:end if end > 0 else 400].splitlines():
|
|
154
|
-
if ":" in line:
|
|
155
|
-
k, _, v = line.partition(":")
|
|
156
|
-
out[k.strip()] = v.strip()
|
|
157
|
-
return out
|
|
156
|
+
return caddis_frontmatter.parse(p.read_text(encoding="utf-8", errors="ignore"))
|
|
158
157
|
|
|
159
158
|
|
|
160
159
|
def _covers(phase_field: str, n: int) -> bool:
|
|
@@ -602,6 +601,22 @@ def gate_review_trigger(repo_root: Path, rev_range: str | None = None) -> int:
|
|
|
602
601
|
return EXIT_NOTE
|
|
603
602
|
|
|
604
603
|
|
|
604
|
+
def gate_docs_check(root: Path) -> int:
|
|
605
|
+
"""Report every typed .caddis Markdown document with an invalid header."""
|
|
606
|
+
failures = 0
|
|
607
|
+
for path in sorted((root / ".caddis").rglob("*.md")):
|
|
608
|
+
relative = path.relative_to(root).as_posix()
|
|
609
|
+
if not caddis_frontmatter.doc_in_scope(relative, root):
|
|
610
|
+
continue
|
|
611
|
+
error = caddis_frontmatter.validate(
|
|
612
|
+
path.read_text(encoding="utf-8"), caddis_frontmatter.ALLOWED_TYPES
|
|
613
|
+
)
|
|
614
|
+
if error:
|
|
615
|
+
print(f"{relative}: {error}")
|
|
616
|
+
failures += 1
|
|
617
|
+
return EXIT_BLOCKED if failures else EXIT_OK
|
|
618
|
+
|
|
619
|
+
|
|
605
620
|
def main(argv: list[str] | None = None) -> int:
|
|
606
621
|
ap = argparse.ArgumentParser(description="caddis machine gates")
|
|
607
622
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
@@ -625,9 +640,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
625
640
|
hc = sub.add_parser("handover-check")
|
|
626
641
|
hc.add_argument("--doc", required=True)
|
|
627
642
|
hc.add_argument("--repo-root", default=".")
|
|
643
|
+
dc = sub.add_parser("docs-check")
|
|
644
|
+
dc.add_argument("--root", default=".")
|
|
628
645
|
a = ap.parse_args(argv)
|
|
629
646
|
|
|
630
647
|
plan = Path(getattr(a, "plan", "") or ".")
|
|
648
|
+
if a.cmd == "docs-check":
|
|
649
|
+
try:
|
|
650
|
+
return gate_docs_check(Path(a.root).resolve())
|
|
651
|
+
except Exception as exc:
|
|
652
|
+
sys.stderr.write(f"[caddis-gate] docs-check cannot evaluate: {type(exc).__name__}: {exc}\n")
|
|
653
|
+
return EXIT_BLOCKED # CI must not pass without completing the scan.
|
|
631
654
|
try:
|
|
632
655
|
if a.cmd == "lane-check":
|
|
633
656
|
return gate_lane(plan, a.phase)
|
|
@@ -55,8 +55,11 @@ import sys
|
|
|
55
55
|
from dataclasses import dataclass
|
|
56
56
|
from pathlib import Path
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
59
|
+
import caddis_exit # noqa: E402
|
|
60
|
+
|
|
61
|
+
EXIT_USAGE = caddis_exit.ERROR
|
|
62
|
+
EXIT_CONFIG = caddis_exit.NOT_RUN
|
|
60
63
|
SCOPES = ("session", "lane")
|
|
61
64
|
MAX_CPU_PERCENT = 100
|
|
62
65
|
MAX_MEMORY_MB = 1_048_576
|
|
@@ -20,11 +20,23 @@ from dataclasses import dataclass
|
|
|
20
20
|
from datetime import datetime, timezone
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
|
+
try:
|
|
24
|
+
import caddis_exit
|
|
25
|
+
except ModuleNotFoundError as exc:
|
|
26
|
+
if exc.name != "caddis_exit":
|
|
27
|
+
raise
|
|
28
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
29
|
+
import caddis_exit
|
|
30
|
+
|
|
23
31
|
if hasattr(sys.stdout, "reconfigure"):
|
|
24
32
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
25
33
|
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
26
34
|
|
|
27
|
-
EXIT_OK
|
|
35
|
+
EXIT_OK = caddis_exit.CLEAN
|
|
36
|
+
EXIT_FAIL = caddis_exit.BLOCKED
|
|
37
|
+
EXIT_USAGE = caddis_exit.ERROR
|
|
38
|
+
EXIT_CONFIG = caddis_exit.NOT_RUN
|
|
39
|
+
EXIT_ALL_OUT = caddis_exit.NOT_RUN
|
|
28
40
|
RESULT_OK = "OK"
|
|
29
41
|
RESULT_LANE_FAILED = "LANE-FAILED"
|
|
30
42
|
RESULT_OUT_OF_BUDGET = "OUT-OF-BUDGET"
|
|
@@ -45,7 +45,7 @@ NEW_DIR = ".caddis"
|
|
|
45
45
|
OLD_DIR = ".claudster"
|
|
46
46
|
|
|
47
47
|
#: Append-only JSONL logs: safe to concatenate when both dirs carry one (legacy lines first).
|
|
48
|
-
JSONL_MERGE_NAMES = frozenset({"usage-log.jsonl", "agent-log.jsonl"
|
|
48
|
+
JSONL_MERGE_NAMES = frozenset({"usage-log.jsonl", "agent-log.jsonl"})
|
|
49
49
|
|
|
50
50
|
#: Files whose CONTENT may name the old dir and is read live after the migration. Historical
|
|
51
51
|
#: artifacts (plans, handoffs, past reviews) are deliberately NOT rewritten — they are a record of
|
|
@@ -273,7 +273,7 @@ def read_context_pct(repo_root: Path) -> tuple[float | None, str]:
|
|
|
273
273
|
age = max(0, int(time.time()) - int(raw.get("at", 0)))
|
|
274
274
|
except Exception:
|
|
275
275
|
return None, ("no runtime context figure at "
|
|
276
|
-
+ f"{ART}/context-window.json — run `/caddis:statusline` to install the "
|
|
276
|
+
+ f"{ART}/context-window.json — run `/caddis:install-statusline` to install the "
|
|
277
277
|
"status line that writes it")
|
|
278
278
|
if age > CONTEXT_MAX_AGE_S:
|
|
279
279
|
return pct, f"runtime figure, STALE by {age}s (a lower bound — the real number is >= this)"
|
|
@@ -386,7 +386,7 @@ def _gate_context(p: Preflight, repo_root: Path, self_reported: float | None) ->
|
|
|
386
386
|
if pct is None:
|
|
387
387
|
p.refusals.append(
|
|
388
388
|
"no context figure, so the 95% gate cannot be applied — and a gate that defaults to "
|
|
389
|
-
"proceed is not a gate. Either run `/caddis:statusline` (its status line caches the "
|
|
389
|
+
"proceed is not a gate. Either run `/caddis:install-statusline` (its status line caches the "
|
|
390
390
|
"runtime figure for this repo), or pass `--context-pct <n>` with the number your "
|
|
391
391
|
"harness reports. A self-reported figure is accepted and recorded as such.")
|
|
392
392
|
return
|
|
@@ -38,6 +38,14 @@ import sys
|
|
|
38
38
|
from dataclasses import dataclass, field
|
|
39
39
|
from pathlib import Path
|
|
40
40
|
|
|
41
|
+
try:
|
|
42
|
+
import caddis_frontmatter
|
|
43
|
+
except ModuleNotFoundError as exc:
|
|
44
|
+
if exc.name != "caddis_frontmatter":
|
|
45
|
+
raise
|
|
46
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
47
|
+
import caddis_frontmatter
|
|
48
|
+
|
|
41
49
|
TERMINAL_STATUSES = {"done", "superseded", "shipped", "implemented"}
|
|
42
50
|
|
|
43
51
|
# Legacy prompts written before the frontmatter convention existed (2026-07-23). They were
|
|
@@ -95,20 +103,7 @@ PARKING_LOT_MAX_BYTES = 20_000
|
|
|
95
103
|
|
|
96
104
|
|
|
97
105
|
def _parse_frontmatter(text: str) -> dict:
|
|
98
|
-
|
|
99
|
-
if not text.startswith("---"):
|
|
100
|
-
return {}
|
|
101
|
-
lines = text.splitlines()
|
|
102
|
-
if lines[0].strip() != "---":
|
|
103
|
-
return {}
|
|
104
|
-
fm: dict[str, str] = {}
|
|
105
|
-
for line in lines[1:]:
|
|
106
|
-
if line.strip() == "---":
|
|
107
|
-
break
|
|
108
|
-
if ":" in line:
|
|
109
|
-
k, _, v = line.partition(":")
|
|
110
|
-
fm[k.strip()] = v.strip()
|
|
111
|
-
return fm
|
|
106
|
+
return caddis_frontmatter.parse(text)
|
|
112
107
|
|
|
113
108
|
|
|
114
109
|
def _phases_all_done(text: str) -> bool:
|
|
@@ -15,11 +15,14 @@ Usage:
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import argparse
|
|
18
|
+
import importlib.util
|
|
18
19
|
import json
|
|
19
20
|
import os
|
|
21
|
+
import re
|
|
20
22
|
import shutil
|
|
21
23
|
import subprocess
|
|
22
24
|
import sys
|
|
25
|
+
import time
|
|
23
26
|
from datetime import datetime, timezone
|
|
24
27
|
from pathlib import Path
|
|
25
28
|
|
|
@@ -97,6 +100,28 @@ def rules_findings(dest: Path) -> list[str]:
|
|
|
97
100
|
return out
|
|
98
101
|
|
|
99
102
|
|
|
103
|
+
def has_document_frontmatter_rule(dest: Path) -> bool:
|
|
104
|
+
"""True when root AGENTS.md puts type: in its Document frontmatter section."""
|
|
105
|
+
path = Path(dest) / "AGENTS.md"
|
|
106
|
+
try:
|
|
107
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
108
|
+
except OSError:
|
|
109
|
+
return False
|
|
110
|
+
section = False
|
|
111
|
+
for line in lines:
|
|
112
|
+
if re.match(r"^\s*(?:#{1,6}\s+|[-*]\s+\*\*)Document frontmatter(?:\*\*)?\s*:?.*$", line):
|
|
113
|
+
section = True
|
|
114
|
+
if "type:" in line:
|
|
115
|
+
return True
|
|
116
|
+
continue
|
|
117
|
+
if section and ("type:" in line or re.match(r"^\s*[-*]\s+\*\*type\*\*\s*:", line)):
|
|
118
|
+
return True
|
|
119
|
+
if section and (re.match(r"^#{1,6}\s+", line) or
|
|
120
|
+
re.match(r"^\s*[-*]\s+\*\*[^*]+\*\*\s*:", line)):
|
|
121
|
+
section = False
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
100
125
|
def oversize_rules_files(dest: Path, budget: int = AGENTS_MD_BUDGET) -> list[tuple[str, int]]:
|
|
101
126
|
"""Always-loaded rules files (AGENTS.md, or CLAUDE.md pre-migration) over the line budget."""
|
|
102
127
|
dest = Path(dest)
|
|
@@ -287,6 +312,36 @@ def nudge_line(dest: Path) -> str | None:
|
|
|
287
312
|
return f"[caddis] {head}{more}"
|
|
288
313
|
|
|
289
314
|
|
|
315
|
+
def hook_errors(dest: Path) -> str:
|
|
316
|
+
"""Recent hook failures, counted by hook. Empty when the ledger is clean."""
|
|
317
|
+
scripts = Path(__file__).resolve().parent
|
|
318
|
+
hook_log_path = scripts / "hook_log.py"
|
|
319
|
+
if not hook_log_path.is_file(): # source checkout; installed copies are siblings
|
|
320
|
+
hook_log_path = scripts.parent / "claude-harness" / "scripts" / "hook_log.py"
|
|
321
|
+
try:
|
|
322
|
+
spec = importlib.util.spec_from_file_location("caddis_hook_log", hook_log_path)
|
|
323
|
+
if spec is None or spec.loader is None:
|
|
324
|
+
return ""
|
|
325
|
+
hook_log = importlib.util.module_from_spec(spec)
|
|
326
|
+
spec.loader.exec_module(hook_log)
|
|
327
|
+
if not hook_log.summarise(str(dest), days=7):
|
|
328
|
+
return ""
|
|
329
|
+
cutoff = time.strftime("%Y-%m-%d", time.localtime(time.time() - 7 * 86400))
|
|
330
|
+
counts: dict[str, int] = {}
|
|
331
|
+
with (dest / ".caddis" / "hook-errors.jsonl").open(encoding="utf-8", errors="replace") as fh:
|
|
332
|
+
for line in fh:
|
|
333
|
+
try:
|
|
334
|
+
row = json.loads(line)
|
|
335
|
+
except (ValueError, TypeError):
|
|
336
|
+
continue
|
|
337
|
+
if isinstance(row, dict) and str(row.get("ts", ""))[:10] >= cutoff:
|
|
338
|
+
hook = str(row.get("hook", "?"))
|
|
339
|
+
counts[hook] = counts.get(hook, 0) + 1
|
|
340
|
+
return ", ".join(f"{hook} x{count}" for hook, count in sorted(counts.items()))
|
|
341
|
+
except Exception:
|
|
342
|
+
return "" # a damaged ledger must not break the doctor
|
|
343
|
+
|
|
344
|
+
|
|
290
345
|
# ── report ───────────────────────────────────────────────────────────────────
|
|
291
346
|
def run(dest: Path, quiet: bool) -> int:
|
|
292
347
|
if quiet: # SessionStart mode: only the nudge, never non-zero
|
|
@@ -316,6 +371,11 @@ def run(dest: Path, quiet: bool) -> int:
|
|
|
316
371
|
root = dest / "AGENTS.md"
|
|
317
372
|
print(f" OK root AGENTS.md {'present' if root.is_file() else 'ABSENT (not migrated yet?)'}; shims are shims")
|
|
318
373
|
|
|
374
|
+
if has_document_frontmatter_rule(dest):
|
|
375
|
+
print(" OK AGENTS.md carries the document-header rule")
|
|
376
|
+
else:
|
|
377
|
+
print(" WARN AGENTS.md carries the document-header rule: missing Document frontmatter section with type:")
|
|
378
|
+
|
|
319
379
|
print("-- maintenance signals (deterministic — read-only)")
|
|
320
380
|
sig = maintenance_signals(dest)
|
|
321
381
|
if sig:
|
|
@@ -324,6 +384,12 @@ def run(dest: Path, quiet: bool) -> int:
|
|
|
324
384
|
else:
|
|
325
385
|
print(" none")
|
|
326
386
|
|
|
387
|
+
errors = hook_errors(dest)
|
|
388
|
+
if errors:
|
|
389
|
+
print(f" WARN hook errors (7 days): {errors}")
|
|
390
|
+
else:
|
|
391
|
+
print(" OK hook errors (7 days): 0")
|
|
392
|
+
|
|
327
393
|
print("-- install registry")
|
|
328
394
|
reg = _user_scope_path("installs.json")
|
|
329
395
|
if reg.is_file():
|
|
@@ -129,7 +129,7 @@ if (Test-Path $statusline) {
|
|
|
129
129
|
if ($PSCmdlet.ShouldProcess($statusline, 'install')) { python $statusline --install }
|
|
130
130
|
} else {
|
|
131
131
|
Missing "statusline.py not found at $statusline"
|
|
132
|
-
Note 'It ships with the plugin. Run `/caddis:statusline` in a Claude Code session, or'
|
|
132
|
+
Note 'It ships with the plugin. Run `/caddis:install-statusline` in a Claude Code session, or'
|
|
133
133
|
Note 'python PLUGIN_ROOT/scripts/caddis_statusline.py --install'
|
|
134
134
|
}
|
|
135
135
|
|
|
@@ -10,7 +10,7 @@ in ~/.caddis/keys.env lights up every lane.
|
|
|
10
10
|
Usage:
|
|
11
11
|
python oss_ask.py <provider> <prompt...> [--model M] [--base-url U] [--system S]
|
|
12
12
|
|
|
13
|
-
Exit codes: 0 answer printed;
|
|
13
|
+
Exit codes: 0 answer printed; 3 could not run due to config; 4 endpoint/parse error.
|
|
14
14
|
"""
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
@@ -23,18 +23,19 @@ from pathlib import Path
|
|
|
23
23
|
|
|
24
24
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
25
25
|
import oss_model # noqa: E402 (same-directory import: shared key resolution)
|
|
26
|
+
import caddis_exit # noqa: E402
|
|
26
27
|
|
|
27
28
|
# OpenAI-compatible chat endpoints — intentionally different from oss_model.PROVIDERS,
|
|
28
29
|
# which holds the Anthropic-protocol endpoints for Claude Code.
|
|
29
30
|
CHAT_PROVIDERS: dict[str, dict[str, str]] = {
|
|
30
|
-
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-
|
|
31
|
+
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-flash", "key_env": "DEEPSEEK_API_KEY"},
|
|
31
32
|
"glm": {"base_url": "https://api.z.ai/api/coding/paas/v4", "model": "glm-5.3", "key_env": "GLM_API_KEY"},
|
|
32
33
|
"openrouter": {"base_url": "https://openrouter.ai/api/v1", "model": "deepseek/deepseek-v4-flash", "key_env": "OPENROUTER_API_KEY"},
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
EXIT_OK =
|
|
36
|
-
EXIT_ERROR =
|
|
37
|
-
EXIT_CONFIG =
|
|
36
|
+
EXIT_OK = caddis_exit.CLEAN
|
|
37
|
+
EXIT_ERROR = caddis_exit.ERROR
|
|
38
|
+
EXIT_CONFIG = caddis_exit.NOT_RUN
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
def resolve_chat_config(provider: str, env: dict[str, str],
|
|
@@ -43,7 +43,7 @@ from pathlib import Path
|
|
|
43
43
|
# NEVER put this suffix in the OpenAI-dialect tables (oss_review.py / oss_ask.py): those
|
|
44
44
|
# send the id raw to /chat/completions, which would reject it.
|
|
45
45
|
PROVIDERS: dict[str, dict[str, str]] = {
|
|
46
|
-
"deepseek": {"base_url": "https://api.deepseek.com/anthropic", "model": "deepseek-
|
|
46
|
+
"deepseek": {"base_url": "https://api.deepseek.com/anthropic", "model": "deepseek-flash[1m]", "key_env": "DEEPSEEK_API_KEY"},
|
|
47
47
|
"glm": {"base_url": "https://api.z.ai/api/anthropic", "model": "glm-5.3[1m]", "key_env": "GLM_API_KEY"},
|
|
48
48
|
"openrouter": {"base_url": "", "model": "deepseek/deepseek-v4-flash", "key_env": "OPENROUTER_API_KEY"},
|
|
49
49
|
}
|
|
@@ -18,23 +18,30 @@ vars always win, so you can point at any new id without touching code. Nothing h
|
|
|
18
18
|
|
|
19
19
|
Usage:
|
|
20
20
|
python oss_review.py [--range <git range>] [--cwd <repo>] [--provider P] [--base-url U] [--model M]
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
[--allow-path GLOB ...]
|
|
22
|
+
--range e.g. origin/main..HEAD (default: the working tree — staged, unstaged AND
|
|
23
|
+
untracked non-ignored files; a range excludes untracked by design)
|
|
24
|
+
--allow-path repeatable; re-admits a file the pre-send secret filter's deny list would
|
|
25
|
+
otherwise drop (see secret_filter.py). `.caddis/config.toml`'s
|
|
26
|
+
`[review] deny_paths` / `allow_paths` are layered on the same way.
|
|
23
27
|
|
|
24
28
|
Exit codes (fail-closed):
|
|
25
29
|
0 REVIEW: CLEAN — no blocking issues
|
|
26
30
|
1 REVIEW: BLOCKING — one or more blocking issues
|
|
27
|
-
2
|
|
31
|
+
2 unused — advisory is not a review verdict
|
|
32
|
+
3 misconfigured — REVIEW_API_KEY missing, OR the pre-send secret filter refused (a
|
|
33
|
+
denylisted path / possible secret; see secret_filter.py) — actionable
|
|
34
|
+
message on stderr/stdout either way
|
|
35
|
+
4 error — no diff verdict parsed, git failure, endpoint/parse failure, or the diff
|
|
28
36
|
exceeds REVIEW_MAX_DIFF_CHARS (see below — never silently downgraded to CLEAN)
|
|
29
|
-
3 misconfigured — REVIEW_API_KEY missing (actionable message on stderr)
|
|
30
37
|
|
|
31
38
|
Diff-size ceiling: an oversized diff sent to a chat-completions endpoint has been observed, live, to
|
|
32
39
|
come back two different unsafe ways — an empty `content` field on an HTTP 200 (at least fails closed:
|
|
33
|
-
no verdict line ⇒ exit
|
|
40
|
+
no verdict line ⇒ exit 4), and, worse, a `REVIEW: CLEAN` verdict with no substantive engagement (a
|
|
34
41
|
silent false negative — the model was overwhelmed, not actually reviewing). Rather than risk the second
|
|
35
42
|
case, a diff over REVIEW_MAX_DIFF_CHARS (default 60,000 chars; override via the env var or
|
|
36
43
|
--max-diff-chars) is SPLIT INTO BATCHES on whole-file boundaries and each batch reviewed separately;
|
|
37
|
-
the verdict is CLEAN only if every batch is clean (aggregation is fail-closed). Exit
|
|
44
|
+
the verdict is CLEAN only if every batch is clean (aggregation is fail-closed). Exit 4 is the
|
|
38
45
|
narrower case: a SINGLE file larger than the ceiling, which cannot be split, or more batches than
|
|
39
46
|
MAX_REVIEW_BATCHES. Refusal was the original behaviour and is kept for those two - a silent false
|
|
40
47
|
CLEAN is the worst outcome this tool has - but it no longer fires on an ordinary large phase diff.
|
|
@@ -53,6 +60,12 @@ import urllib.error
|
|
|
53
60
|
import urllib.request
|
|
54
61
|
from pathlib import Path
|
|
55
62
|
|
|
63
|
+
# secret_filter.py ships beside this file in every export target (see
|
|
64
|
+
# .github/runtime-targets.json) — import it from THIS script's own folder, not the caller's
|
|
65
|
+
# cwd, so it resolves the same way whether this runs from the source repo or a plugin install.
|
|
66
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
67
|
+
import secret_filter # noqa: E402
|
|
68
|
+
|
|
56
69
|
# ONE SCALE ACROSS caddis (see claude-harness/README.md):
|
|
57
70
|
# 0 clean · 1 blocked · 2 advisory note · 3 could not run · 4 malformed
|
|
58
71
|
# ERROR moved 2 -> 4 on 2026-09-09. It sat on 2, which caddis_gate uses for "proceed, this is
|
|
@@ -69,7 +82,7 @@ EXIT_ERROR = 4 # ran, produced nothing usable: no verdict line, transport f
|
|
|
69
82
|
# Provider presets — the SINGLE place a renamed model id or moved endpoint is edited. Adding a new
|
|
70
83
|
# provider (Qwen, a local vLLM, …) is one new row. Callers can always bypass this via env/flags.
|
|
71
84
|
PROVIDERS: dict[str, dict[str, str]] = {
|
|
72
|
-
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-
|
|
85
|
+
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-flash"},
|
|
73
86
|
"glm": {"base_url": "https://api.z.ai/api/coding/paas/v4", "model": "glm-5.3"},
|
|
74
87
|
"openrouter": {"base_url": "https://openrouter.ai/api/v1", "model": "deepseek/deepseek-v4-flash"},
|
|
75
88
|
}
|
|
@@ -272,7 +285,8 @@ def _untracked_diff(path: str, cwd: str) -> str:
|
|
|
272
285
|
"""
|
|
273
286
|
null = "/dev/null" if os.name != "nt" else "NUL"
|
|
274
287
|
out = subprocess.run(
|
|
275
|
-
["git", "diff", "--no-
|
|
288
|
+
["git", "diff", "--no-color", "--no-ext-diff", "--src-prefix=a/",
|
|
289
|
+
"--dst-prefix=b/", "--no-index", "--", null, path],
|
|
276
290
|
cwd=cwd, capture_output=True, text=True,
|
|
277
291
|
encoding="utf-8", errors="replace", timeout=30,
|
|
278
292
|
)
|
|
@@ -295,7 +309,8 @@ def get_diff(rng: str | None, cwd: str, include_untracked: bool = True) -> str:
|
|
|
295
309
|
Untracked files are NOT included for an explicit `rng` — a commit range is a span of
|
|
296
310
|
history, and files that were never committed are correctly outside it.
|
|
297
311
|
"""
|
|
298
|
-
cmd = ["git", "diff",
|
|
312
|
+
cmd = ["git", "diff", "--no-color", "--no-ext-diff", "--src-prefix=a/",
|
|
313
|
+
"--dst-prefix=b/", rng or "HEAD"]
|
|
299
314
|
# encoding pinned to UTF-8: git emits UTF-8, but text=True would decode with the locale
|
|
300
315
|
# codepage (cp1252 on Windows) — a non-ASCII diff then kills the reader thread and
|
|
301
316
|
# subprocess hands back stdout=None with returncode 0.
|
|
@@ -483,6 +498,29 @@ def _force_utf8_stdio() -> None:
|
|
|
483
498
|
pass
|
|
484
499
|
|
|
485
500
|
|
|
501
|
+
def _config_is_changed(diff_text: str) -> bool:
|
|
502
|
+
"""True when either side of a changed file block names the review config."""
|
|
503
|
+
target = ".caddis/config.toml"
|
|
504
|
+
for block in secret_filter._split_into_file_blocks(diff_text):
|
|
505
|
+
for line in block.splitlines():
|
|
506
|
+
if line.startswith("@@"):
|
|
507
|
+
break
|
|
508
|
+
if line in (f"--- a/{target}", f"+++ b/{target}",
|
|
509
|
+
f"rename from {target}", f"rename to {target}"):
|
|
510
|
+
return True
|
|
511
|
+
if line.startswith("diff --git "):
|
|
512
|
+
rest = line[len("diff --git "):]
|
|
513
|
+
quoted = secret_filter._QUOTED_HEADER_RE.match(rest)
|
|
514
|
+
if quoted:
|
|
515
|
+
sides = (secret_filter._unescape_git_quoted(part)
|
|
516
|
+
for part in quoted.groups())
|
|
517
|
+
if any(side in (f"a/{target}", f"b/{target}") for side in sides):
|
|
518
|
+
return True
|
|
519
|
+
elif rest.startswith(f"a/{target} b/") or rest.endswith(f" b/{target}"):
|
|
520
|
+
return True
|
|
521
|
+
return False
|
|
522
|
+
|
|
523
|
+
|
|
486
524
|
def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> int:
|
|
487
525
|
_force_utf8_stdio()
|
|
488
526
|
env = os.environ if env is None else env
|
|
@@ -499,6 +537,10 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
|
|
|
499
537
|
parser.add_argument("--check-config", action="store_true",
|
|
500
538
|
help="report whether a usable key exists and exit — no diff, no LLM call. "
|
|
501
539
|
"Exit 0 = ready, 3 = nothing configured.")
|
|
540
|
+
parser.add_argument("--allow-path", dest="allow_path", action="append", default=[],
|
|
541
|
+
metavar="GLOB",
|
|
542
|
+
help="re-admit a file the secret-filter deny list would otherwise drop "
|
|
543
|
+
"(repeatable)")
|
|
502
544
|
args = parser.parse_args(argv)
|
|
503
545
|
|
|
504
546
|
# --check-config is deliberately NON-INTERACTIVE. This script runs headless — from CI, from a
|
|
@@ -519,6 +561,39 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
|
|
|
519
561
|
print(f"cross-review is ready. Providers with a key: {', '.join(available)}")
|
|
520
562
|
return EXIT_CLEAN
|
|
521
563
|
|
|
564
|
+
try:
|
|
565
|
+
diff_text = get_diff(args.range, args.cwd)
|
|
566
|
+
except Exception as exc:
|
|
567
|
+
sys.stderr.write(f"could not read diff: {exc}\n")
|
|
568
|
+
return EXIT_ERROR
|
|
569
|
+
|
|
570
|
+
# Pre-send secret filter — runs on the FULL diff, before it is ever split into batches and
|
|
571
|
+
# before any provider is contacted, and BEFORE provider/key resolution below: a missing or
|
|
572
|
+
# misconfigured REVIEW_API_KEY must never race a secret refusal and print the wrong reason.
|
|
573
|
+
# A run with no key configured at all still refuses here first, on the merits, not because
|
|
574
|
+
# it couldn't have called anyone anyway.
|
|
575
|
+
cfg_deny, cfg_allow = secret_filter.load_config(Path(args.cwd))
|
|
576
|
+
if _config_is_changed(diff_text):
|
|
577
|
+
cfg_deny = list(dict.fromkeys(cfg_deny + secret_filter.load_head_deny_paths(Path(args.cwd))))
|
|
578
|
+
cfg_allow = []
|
|
579
|
+
print("note: .caddis/config.toml is part of this change; its allow_paths are ignored")
|
|
580
|
+
filter_result = secret_filter.filter_diff(
|
|
581
|
+
diff_text,
|
|
582
|
+
secret_filter.DEFAULT_DENY_GLOBS + cfg_deny,
|
|
583
|
+
cfg_allow + list(args.allow_path),
|
|
584
|
+
)
|
|
585
|
+
if filter_result.hits:
|
|
586
|
+
print("REVIEW: REFUSED — possible secrets:")
|
|
587
|
+
for hit_path, hit_line, hit_rule in filter_result.hits:
|
|
588
|
+
print(f" {hit_path}:{hit_line} ({hit_rule})")
|
|
589
|
+
return EXIT_CONFIG
|
|
590
|
+
if filter_result.dropped_paths:
|
|
591
|
+
print(f"excluded from review: {', '.join(filter_result.dropped_paths)}")
|
|
592
|
+
if not filter_result.kept_diff.strip() and filter_result.dropped_paths:
|
|
593
|
+
print("REVIEW: REFUSED — every changed file is excluded")
|
|
594
|
+
return EXIT_CONFIG
|
|
595
|
+
diff_text = filter_result.kept_diff
|
|
596
|
+
|
|
522
597
|
try:
|
|
523
598
|
base_url, api_key, model = resolve_config(args, env)
|
|
524
599
|
primary_provider = (
|
|
@@ -528,12 +603,6 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
|
|
|
528
603
|
sys.stderr.write(f"{exc}\n")
|
|
529
604
|
return EXIT_CONFIG
|
|
530
605
|
|
|
531
|
-
try:
|
|
532
|
-
diff_text = get_diff(args.range, args.cwd)
|
|
533
|
-
except Exception as exc:
|
|
534
|
-
sys.stderr.write(f"could not read diff: {exc}\n")
|
|
535
|
-
return EXIT_ERROR
|
|
536
|
-
|
|
537
606
|
if not diff_text.strip():
|
|
538
607
|
# Says what was scanned, not just that nothing was found. This branch used to fire
|
|
539
608
|
# on a tree full of NEW files (they are invisible to `git diff HEAD`) and report
|