@caddis/cli 0.4.30 → 0.4.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/bundles/antigravity-plugin/mcp_ping_server.py +1 -1
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +301 -66
  4. package/bundles/antigravity-plugin/scripts/caddis_exit.py +15 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +145 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_gate.py +33 -10
  7. package/bundles/antigravity-plugin/scripts/caddis_governor.py +5 -2
  8. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +13 -1
  9. package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +1 -1
  10. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +2 -2
  11. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +9 -14
  12. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +66 -0
  13. package/bundles/antigravity-plugin/scripts/new-machine.ps1 +1 -1
  14. package/bundles/antigravity-plugin/scripts/oss_ask.py +6 -5
  15. package/bundles/antigravity-plugin/scripts/oss_model.py +1 -1
  16. package/bundles/antigravity-plugin/scripts/oss_review.py +84 -15
  17. package/bundles/antigravity-plugin/scripts/secret_filter.py +316 -0
  18. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +9 -4
  19. package/bundles/antigravity-plugin/skills/_registry.md +5 -3
  20. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +37 -2
  21. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +1 -1
  22. package/bundles/antigravity-plugin/skills/digress/SKILL.md +1 -1
  23. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +8 -1
  24. package/bundles/antigravity-plugin/skills/implement/SKILL.md +3 -1
  25. package/bundles/antigravity-plugin/skills/install-statusline/SKILL.md +105 -0
  26. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +14 -8
  27. package/bundles/antigravity-plugin/skills/resume/SKILL.md +2 -32
  28. package/bundles/antigravity-plugin/skills/review/SKILL.md +2 -96
  29. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +100 -0
  30. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +2 -101
  31. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  32. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +3 -3
  33. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
  34. package/bundles/codex/.codex/config.toml.example +0 -16
  35. package/bundles/codex/AGENTS.md +2 -2
  36. package/bundles/manifest.json +3 -3
  37. package/dist/claude-deepseek.js +1 -1
  38. package/dist/claude-glm.js +1 -1
  39. package/dist/claude-oss.js +1 -1
  40. package/dist/cli.js +1 -1
  41. 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
- t = p.read_text(encoding="utf-8", errors="ignore")
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
- EXIT_USAGE = 2
59
- EXIT_CONFIG = 3
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, EXIT_FAIL, EXIT_USAGE, EXIT_CONFIG, EXIT_ALL_OUT = 0, 1, 2, 3, 4
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", "memory.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
- text = text.lstrip("")
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; 2 endpoint/parse error; 3 config error (message on stderr).
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-v4-flash", "key_env": "DEEPSEEK_API_KEY"},
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 = 0
36
- EXIT_ERROR = 2
37
- EXIT_CONFIG = 3
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-v4-flash[1m]", "key_env": "DEEPSEEK_API_KEY"},
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
- --range e.g. origin/main..HEAD (default: the working tree — staged, unstaged AND
22
- untracked non-ignored files; a range excludes untracked by design)
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 error — no diff verdict parsed, git failure, endpoint/parse failure, or the diff
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 2), and, worse, a `REVIEW: CLEAN` verdict with no substantive engagement (a
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 2 is now the
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-v4-flash"},
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-index", "--", null, path],
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", rng] if rng else ["git", "diff", "HEAD"]
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