@caddis/cli 0.4.29 → 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 (47) hide show
  1. package/bundles/antigravity-plugin/caddis_statusline.py +0 -1
  2. package/bundles/antigravity-plugin/mcp_ping_server.py +1 -1
  3. package/bundles/antigravity-plugin/plugin.json +1 -1
  4. package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +301 -66
  5. package/bundles/antigravity-plugin/scripts/caddis_exit.py +15 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +145 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_gate.py +33 -10
  8. package/bundles/antigravity-plugin/scripts/caddis_governor.py +5 -2
  9. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +13 -1
  10. package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +1 -1
  11. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +2 -2
  12. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +0 -1
  13. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +9 -14
  14. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +66 -0
  15. package/bundles/antigravity-plugin/scripts/convert_doc_to_confluence.py +1430 -0
  16. package/bundles/antigravity-plugin/scripts/new-machine.ps1 +1 -1
  17. package/bundles/antigravity-plugin/scripts/oss_ask.py +6 -5
  18. package/bundles/antigravity-plugin/scripts/oss_model.py +1 -1
  19. package/bundles/antigravity-plugin/scripts/oss_review.py +84 -15
  20. package/bundles/antigravity-plugin/scripts/secret_filter.py +316 -0
  21. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +9 -4
  22. package/bundles/antigravity-plugin/skills/_registry.md +5 -3
  23. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +37 -2
  24. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +1 -1
  25. package/bundles/antigravity-plugin/skills/data-lineage/SKILL.md +1 -1
  26. package/bundles/antigravity-plugin/skills/digress/SKILL.md +1 -1
  27. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +8 -1
  28. package/bundles/antigravity-plugin/skills/hld/SKILL.md +1 -1
  29. package/bundles/antigravity-plugin/skills/implement/SKILL.md +3 -1
  30. package/bundles/antigravity-plugin/skills/install-statusline/SKILL.md +105 -0
  31. package/bundles/antigravity-plugin/skills/lld/SKILL.md +1 -1
  32. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +23 -14
  33. package/bundles/antigravity-plugin/skills/resume/SKILL.md +2 -32
  34. package/bundles/antigravity-plugin/skills/review/SKILL.md +2 -96
  35. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +100 -0
  36. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +2 -101
  37. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  38. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +3 -3
  39. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
  40. package/bundles/codex/.codex/config.toml.example +0 -16
  41. package/bundles/codex/AGENTS.md +2 -2
  42. package/bundles/manifest.json +3 -3
  43. package/dist/claude-deepseek.js +1 -1
  44. package/dist/claude-glm.js +1 -1
  45. package/dist/claude-oss.js +1 -1
  46. package/dist/cli.js +1 -1
  47. package/package.json +1 -1
@@ -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
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env python3
2
+ """Pre-send secret filter for `oss_review.py`.
3
+
4
+ Everything `oss_review.py` sends to an external review vendor (DeepSeek/GLM/OpenRouter/etc.)
5
+ passes through `filter_diff()` first. It does two independent things, both fail-closed:
6
+
7
+ 1. **Path denylist** — a whole file's diff block is dropped when its path matches a deny glob
8
+ (`.env`, `secrets.yaml`, `id_rsa`, a private key, ...) and no allow glob re-admits it. This
9
+ is silent-by-default: the file simply never reaches the model, and the caller is told which
10
+ paths were excluded so the drop is visible, not invisible.
11
+ 2. **Content rules** — every hunk line of a file that was NOT dropped is scanned against
12
+ `SECRET_RULES` (AWS keys, GitHub/Slack tokens, JWTs, PEM private keys, `key = "..."`-shaped
13
+ assignments). A hit refuses the ENTIRE review (not just that file) — a secret slipping past
14
+ the path denylist is exactly the case this second layer exists for, and the safe response to
15
+ "something here looks like a credential" is "send nothing", not "guess which part is safe".
16
+
17
+ Deliberately out of scope, by design (see the plan's "Design (decided)"):
18
+ - Removed-line hits use old-file line numbers and are marked `removed line`.
19
+ - `+++`/`---` file headers never fire because they precede the first hunk.
20
+ - Content rules run only on files the path denylist did NOT already drop; a dropped file's
21
+ block is never scanned (nothing in it reaches the model either way).
22
+
23
+ Nothing here ever prints a matched secret value: hits are reported as (path, line, rule name)
24
+ only. Stdlib-only (`tomllib` needs Python 3.11+, matching the rest of caddis's `.github/tools/`).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import fnmatch
29
+ import re
30
+ import subprocess
31
+ import tomllib
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+
35
+ # ── path denylist ─────────────────────────────────────────────────────────────
36
+ #
37
+ # fnmatch's `*` matches `/` too (it is not a directory-aware glob), which cuts both ways and
38
+ # is the reason for the paired entries below:
39
+ # - "*.pem" / "*.key" / "*.pfx" / "*.p12" already match at ANY depth (root or nested) because
40
+ # the leading `*` freely absorbs any prefix, slashes included.
41
+ # - ".env" (a literal, no wildcard) matches ONLY a root ".env" — fnmatch requires an exact
42
+ # whole-string match when there is no wildcard, and "config/.env" is a different string.
43
+ # - "**/.env" matches ONLY a nested path — translated to a regex it requires a literal "/"
44
+ # immediately before ".env", so a root ".env" (no slash anywhere) does NOT match it.
45
+ # - "id_rsa*" / "secrets.*" / "keys.env" are anchored at the start of the string (no leading
46
+ # wildcard), so — like ".env" — they match only at the repo root; "**/id_rsa*" etc. cover
47
+ # the nested case.
48
+ # Every root-only pattern therefore needs an explicit "**/" sibling, or a nested secret file
49
+ # of that shape is invisible to this filter. Verified live, not assumed — see
50
+ # scripts/tests/test_secret_filter.py.
51
+ DEFAULT_DENY_GLOBS: list[str] = [
52
+ ".env", ".env.*", "**/.env", "**/.env.*",
53
+ "*.pem", "*.key", "*.pfx", "*.p12",
54
+ "id_rsa*", "**/id_rsa*",
55
+ "secrets.*", "**/secrets.*",
56
+ "keys.env", "**/keys.env",
57
+ ]
58
+
59
+ # `.env.example` / `.env.sample` / `.env.template` / `.env.dist` are template files with no
60
+ # real values in them and are conventionally committed and reviewed like any other source file.
61
+ # Checked BEFORE the deny glob match, so it wins even though ".env.*" would otherwise catch it.
62
+ ENV_EXAMPLE = re.compile(r"\.env\.(example|sample|template|dist)$", re.I)
63
+
64
+ # ── content rules ───────────────────────────────────────────────────────────
65
+ #
66
+ # Each pattern is checked against the content of each changed line in a kept file.
67
+ SECRET_RULES: list[tuple[str, re.Pattern]] = [
68
+ ("private-key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
69
+ ("aws-key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
70
+ ("github-token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
71
+ ("slack-token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b")),
72
+ ("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")),
73
+ ("assignment", re.compile(
74
+ r"(?i)[A-Za-z0-9_]*(password|passwd|secret|api[_-]?key|token)[A-Za-z0-9_]*\s*[:=]\s*['\"](?P<value>[^'\"\s]{8,})['\"]"
75
+ )),
76
+ ("env-assignment", re.compile(
77
+ r"^\s*(?:export\s+)?[A-Za-z0-9_]*(?:PASSWORD|PASSWD|SECRET|API_KEY|APIKEY|TOKEN|PRIVATE_KEY)"
78
+ r"[A-Za-z0-9_]*\s*=\s*(?P<value>[^\s'\"#]{8,})", re.I
79
+ )),
80
+ ]
81
+
82
+
83
+ def _is_placeholder(value: str) -> bool:
84
+ """Exclude obvious template values from both assignment rules."""
85
+ lowered = value.lower()
86
+ return (
87
+ lowered in {"your_key_here", "your-api-key", "changeme", "placeholder", "example"}
88
+ or bool(re.fullmatch(r"x+", lowered))
89
+ or bool(re.fullmatch(r"<[^>]*>", value))
90
+ or bool(re.fullmatch(r"\$\{[^}]*\}", value))
91
+ or bool(re.fullmatch(r"\$[A-Za-z_][A-Za-z0-9_]*", value))
92
+ or value.startswith("***")
93
+ or lowered.startswith(("your_", "your-"))
94
+ )
95
+
96
+
97
+ @dataclass
98
+ class FilterResult:
99
+ kept_diff: str
100
+ dropped_paths: list[str] = field(default_factory=list)
101
+ hits: list[tuple[str, int, str]] = field(default_factory=list) # (path, side's line, rule/removed marker)
102
+
103
+
104
+ # A new file block starts at a line beginning "diff --git a/..." (the common, unquoted case)
105
+ # or "diff --git "a/..." (git double-quotes a path that needs escaping — spaces alone do NOT
106
+ # trigger quoting, but non-ASCII characters, embedded quotes/backslashes, and control
107
+ # characters do). Matched at line start only, never mid-line.
108
+ _BLOCK_START_RE = re.compile(r'^diff --git (?:"|a/)')
109
+
110
+ # The common (non-rename) case: "diff --git a/<P> b/<P>" with the OLD and NEW path identical.
111
+ # A backreference handles a path containing spaces correctly (the naive "split on ' b/'"
112
+ # approach is ambiguous when the path itself contains " b/"-shaped substrings and can't tell
113
+ # unquoted-with-a-space apart from a genuine rename without this).
114
+ _IDENTICAL_HEADER_RE = re.compile(r'^a/(.+) b/\1$')
115
+
116
+ # Both sides double-quoted (git always quotes both together, never just one).
117
+ _QUOTED_HEADER_RE = re.compile(r'^"((?:[^"\\]|\\.)*)" "((?:[^"\\]|\\.)*)"$')
118
+
119
+ _HUNK_RE = re.compile(r'^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@')
120
+
121
+
122
+ def _unescape_git_quoted(s: str) -> str:
123
+ """Undo git's C-style quoting: \\", \\\\, \\t, \\n, and \\NNN octal byte escapes.
124
+
125
+ git quotes a path by escaping each byte that needs it; multi-byte (UTF-8) characters come
126
+ out as a run of \\NNN octal escapes, one per byte, so the escapes are collected as raw
127
+ bytes and decoded as UTF-8 at the end rather than character-by-character.
128
+ """
129
+ out = bytearray()
130
+ i, n = 0, len(s)
131
+ while i < n:
132
+ c = s[i]
133
+ if c == "\\" and i + 1 < n:
134
+ nxt = s[i + 1]
135
+ if nxt in ('"', "\\"):
136
+ out.append(ord(nxt))
137
+ i += 2
138
+ elif nxt == "n":
139
+ out.append(10)
140
+ i += 2
141
+ elif nxt == "t":
142
+ out.append(9)
143
+ i += 2
144
+ elif "0" <= nxt <= "7":
145
+ j, digits = i + 1, 0
146
+ val = 0
147
+ while j < n and digits < 3 and "0" <= s[j] <= "7":
148
+ val = val * 8 + int(s[j])
149
+ j += 1
150
+ digits += 1
151
+ out.append(val & 0xFF)
152
+ i = j
153
+ else:
154
+ out.append(ord(nxt))
155
+ i += 2
156
+ else:
157
+ out.extend(c.encode("utf-8", "surrogatepass"))
158
+ i += 1
159
+ return out.decode("utf-8", "replace")
160
+
161
+
162
+ def _extract_paths(header_line: str) -> tuple[str | None, str | None]:
163
+ """Return old and new paths from a git diff header."""
164
+ line = header_line.rstrip("\r\n")
165
+ if not line.startswith("diff --git "):
166
+ return (None, None)
167
+ rest = line[len("diff --git "):]
168
+ m = _QUOTED_HEADER_RE.match(rest)
169
+ if m:
170
+ a_path, b_path = (_unescape_git_quoted(part) for part in m.groups())
171
+ return (a_path[2:] if a_path.startswith("a/") else a_path,
172
+ b_path[2:] if b_path.startswith("b/") else b_path)
173
+ m = _IDENTICAL_HEADER_RE.match(rest)
174
+ if m:
175
+ return (m.group(1), m.group(1))
176
+ # Rename/copy to a genuinely different, unquoted path: paths with spaces are ambiguous
177
+ # here in principle, but git does not quote for a space alone, so this is best-effort for
178
+ # the common no-space rename case (e.g. "diff --git a/x b/.env").
179
+ idx = rest.rfind(" b/")
180
+ if idx != -1:
181
+ return (rest[2:idx], rest[idx + 3:])
182
+ return (None, None)
183
+
184
+
185
+ def _extract_new_path(header_line: str) -> str | None:
186
+ return _extract_paths(header_line)[1]
187
+
188
+
189
+ def _split_into_file_blocks(diff_text: str) -> list[str]:
190
+ if not diff_text:
191
+ return []
192
+ blocks: list[str] = []
193
+ current: list[str] = []
194
+ for line in diff_text.splitlines(keepends=True):
195
+ if _BLOCK_START_RE.match(line) and current:
196
+ blocks.append("".join(current))
197
+ current = [line]
198
+ else:
199
+ current.append(line)
200
+ if current:
201
+ blocks.append("".join(current))
202
+ return blocks
203
+
204
+
205
+ def _is_denied(path: str, deny_globs: list[str], allow_globs: list[str]) -> bool:
206
+ if ENV_EXAMPLE.search(path):
207
+ return False
208
+ # fnmatchcase, not fnmatch: fnmatch() runs os.path.normcase() first, which on Windows
209
+ # lowercases AND rewrites "/" to "\\" — silently breaking every POSIX-style glob above.
210
+ # fnmatchcase never touches the string, so matching is identical on every OS.
211
+ if not any(fnmatch.fnmatchcase(path, glob) for glob in deny_globs):
212
+ return False
213
+ return not any(fnmatch.fnmatchcase(path, glob) for glob in allow_globs)
214
+
215
+
216
+ def _scan_added_lines(block: str, path: str) -> list[tuple[str, int, str]]:
217
+ """Scan all hunk lines; removed hits use old-file line numbers."""
218
+ hits: list[tuple[str, int, str]] = []
219
+ new_line: int | None = None
220
+ old_line: int | None = None
221
+ for line in block.splitlines():
222
+ m = _HUNK_RE.match(line)
223
+ if m:
224
+ old_line, new_line = int(m.group(1)), int(m.group(2))
225
+ continue
226
+ if new_line is None:
227
+ continue # still in the file header (index/mode/---/+++ lines), before any hunk
228
+ if line.startswith("\\"):
229
+ continue # "" — not a line, does not advance the count
230
+ if line.startswith(("+", "-", " ")):
231
+ content = line[1:]
232
+ removed = line.startswith("-")
233
+ context = line.startswith(" ")
234
+ for rule_name, pattern in SECRET_RULES:
235
+ match = pattern.search(content)
236
+ if match and not (rule_name in {"assignment", "env-assignment"}
237
+ and _is_placeholder(match.group("value"))) and not (
238
+ rule_name == "env-assignment" and
239
+ re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+",
240
+ match.group("value"))):
241
+ marker = (f"{rule_name}, removed line" if removed else
242
+ f"{rule_name}, context line" if context else rule_name)
243
+ hits.append((path, old_line if removed else new_line, marker))
244
+ if removed:
245
+ old_line += 1
246
+ elif not context:
247
+ new_line += 1
248
+ else:
249
+ old_line += 1
250
+ new_line += 1
251
+ else:
252
+ old_line += 1
253
+ new_line += 1 # context line exists in both files
254
+ return hits
255
+
256
+
257
+ def filter_diff(diff_text: str, deny_globs: list[str], allow_globs: list[str]) -> FilterResult:
258
+ """Drop denylisted-path file blocks, then scan changed lines in kept blocks."""
259
+ dropped_paths: list[str] = []
260
+ hits: list[tuple[str, int, str]] = []
261
+ kept_parts: list[str] = []
262
+ for block in _split_into_file_blocks(diff_text):
263
+ first_line = block.splitlines()[0] if block else ""
264
+ old_path, new_path = _extract_paths(first_line)
265
+ path = new_path or old_path or "(unparseable path)"
266
+ denied = next((candidate for candidate in (old_path, new_path)
267
+ if candidate and _is_denied(candidate, deny_globs, allow_globs)), None)
268
+ if denied:
269
+ dropped_paths.append(denied)
270
+ continue
271
+ hits.extend(_scan_added_lines(block, path))
272
+ kept_parts.append(block)
273
+ return FilterResult(kept_diff="".join(kept_parts), dropped_paths=dropped_paths, hits=hits)
274
+
275
+
276
+ def load_config(repo: Path) -> tuple[list[str], list[str]]:
277
+ """`[review] deny_paths` / `allow_paths` from `<repo>/.caddis/config.toml`.
278
+
279
+ A missing file, a missing `[review]` table, or a malformed TOML file all yield `([], [])`
280
+ — this is additive configuration layered onto DEFAULT_DENY_GLOBS, never the sole source of
281
+ truth, so failing to read it must never silently widen what gets sent.
282
+ """
283
+ cfg_path = Path(repo) / ".caddis" / "config.toml"
284
+ if not cfg_path.is_file():
285
+ return ([], [])
286
+ try:
287
+ with cfg_path.open("rb") as f:
288
+ return _parse_config(f.read())
289
+ except (tomllib.TOMLDecodeError, OSError, UnicodeError):
290
+ return ([], [])
291
+
292
+
293
+ def _parse_config(contents: bytes) -> tuple[list[str], list[str]]:
294
+ data = tomllib.loads(contents.decode("utf-8"))
295
+ review = data.get("review") if isinstance(data, dict) else None
296
+ if not isinstance(review, dict):
297
+ return ([], [])
298
+ deny = review.get("deny_paths", [])
299
+ allow = review.get("allow_paths", [])
300
+ deny_out = [str(p) for p in deny] if isinstance(deny, list) else []
301
+ allow_out = [str(p) for p in allow] if isinstance(allow, list) else []
302
+ return (deny_out, allow_out)
303
+
304
+
305
+ def load_head_deny_paths(repo: Path) -> list[str]:
306
+ """Read the committed deny list; a missing or unreadable HEAD config contributes nothing."""
307
+ try:
308
+ out = subprocess.run(
309
+ ["git", "show", "HEAD:.caddis/config.toml"], cwd=repo,
310
+ capture_output=True, timeout=30,
311
+ )
312
+ if out.returncode == 0:
313
+ return _parse_config(out.stdout)[0]
314
+ except (OSError, subprocess.TimeoutExpired, UnicodeError, tomllib.TOMLDecodeError):
315
+ pass
316
+ return []
@@ -291,6 +291,10 @@ def merge_settings(target: Path, stack: dict, dry: bool) -> str:
291
291
  if a not in ex_allow:
292
292
  ex_allow.append(a)
293
293
  existing.setdefault("permissions", {})["allow"] = ex_allow
294
+ ex_deny = existing["permissions"].setdefault("deny", [])
295
+ for rule in base["permissions"]["deny"]:
296
+ if rule not in ex_deny:
297
+ ex_deny.append(rule)
294
298
  # Self-heal: prune legacy harness-injected `ask` rules that override bypassPermissions.
295
299
  ex_ask = existing["permissions"].get("ask", [])
296
300
  pruned_ask = [a for a in ex_ask if a not in _LEGACY_HARNESS_ASK]
@@ -308,7 +312,7 @@ def merge_settings(target: Path, stack: dict, dry: bool) -> str:
308
312
  else:
309
313
  base["permissions"]["allow"] = allow
310
314
  # No statusLine here on purpose: it is user-scope now, installed once per machine
311
- # by `/caddis:statusline`. A per-project copy is exactly how four status-line
315
+ # by `/caddis:install-statusline`. A per-project copy is exactly how four status-line
312
316
  # scripts came to exist and drift apart.
313
317
  out = base
314
318
  verb = "write"
@@ -799,7 +803,7 @@ items?" has no answer when one state has three spellings.
799
803
  | Not this | Where it goes | Why |
800
804
  |---|---|---|
801
805
  | Work in flight right now | `.caddis/plans/<feature>.md` | A plan is being executed. A parking-lot item is not. |
802
- | A task interrupted mid-session | `/caddis:digress` stack | That is a pause, not a backlog item. `/caddis:resume` pops it. |
806
+ | A task interrupted mid-session | `/caddis:digress` stack | That is a pause, not a backlog item. `/caddis:catchup` pops it. |
803
807
  | An ask only an outsider can resolve | `.caddis/comms/register.md` | Blocked on a human, not on us. |
804
808
  | Board cards | `.caddis/backlog/` | **docket writes that directory.** It is a projection of the board, not a hand-written register. Never edit it by hand. |
805
809
 
@@ -910,6 +914,7 @@ session-state.md
910
914
  session-state/
911
915
  context-window.json
912
916
  PROJECT-FACTS.md
917
+ # retired writer; ignored so old files stay untracked
913
918
  memory.jsonl
914
919
  """
915
920
 
@@ -1178,7 +1183,7 @@ def scaffold_artifact_dir(target: Path, dry: bool) -> list[str]:
1178
1183
 
1179
1184
  Committed subdirs: plans, handoffs, agent-docs, prd, kb, prompts, comms. Transient state
1180
1185
  (reviews/*.html, usage-log.jsonl, .last-usage-review, relay*, session-state.md,
1181
- PROJECT-FACTS.md, memory.jsonl) is gitignored. .caddis/ is the default home for every working-artifact kind (Track A
1186
+ PROJECT-FACTS.md) is gitignored. .caddis/ is the default home for every working-artifact kind (Track A
1182
1187
  Phase A3) — kb/ and prompts/ round out plans/prd/agent-docs/reviews so nothing has to
1183
1188
  scatter to the repo root or .github/. Also drops a documented `config.toml.example`
1184
1189
  (guard/doc_coverage). Idempotent. An existing .caddis/.gitignore is never clobbered or
@@ -1493,7 +1498,7 @@ def main() -> int:
1493
1498
  print(f" {merge_settings(target, stack, args.dry_run)}")
1494
1499
 
1495
1500
  print("-- status line")
1496
- print(" user-scope now — run /caddis:statusline once per machine (not per project)")
1501
+ print(" user-scope now — run /caddis:install-statusline once per machine (not per project)")
1497
1502
 
1498
1503
  print("-- git hooks")
1499
1504
  for line in install_git_hooks(target, args.force, args.dry_run):
@@ -85,6 +85,7 @@
85
85
  | Glossary | `glossary/` | Define the words this project invented, in one place — .caddis/kb/GLOSSARY.md |
86
86
  | Handoff | `handoff/` | End-of-session handoff — capture exact state so the next session resumes with zero re-discovery |
87
87
  | Implement | `implement/` | Headless plan executor — implement an approved plan phase-by-phase on the current feature branch, TDD-first, committing per phase |
88
+ | Install Statusline | `install-statusline/` | Install the caddis status line into Claude Code and agy — one renderer, once per machine |
88
89
  | Kb | `kb/` | Rebuild the KB index (.caddis/kb/DOC-MAP.md) — create it if missing, index un-indexed notes, report dangling links |
89
90
  | Mermaid Db | `mermaid-db/` | Turn a SQL artifact (proc, view, query, .sql file, or table name) into a Mermaid diagram that explains it — git-diffable, saved as .md |
90
91
  | Migrate Dir | `migrate-dir/` | Rename this repo's legacy `.claudster/` artifact dir to `.caddis/` — dry-run first, `git mv` to keep history, merges a straggler dir, rewrites live refs. Opt-in, never automatic. |
@@ -92,14 +93,15 @@
92
93
  | Park | `park/` | File a future-work item in the one backlog — .caddis/parking-lot/ — or list what is open |
93
94
  | Pr Merge | `pr-merge/` | End-to-end pull request and merge lane — push feature branch, open or update PR, monitor CI to green, merge to default branch, and clean up the feature branch. |
94
95
  | Prd | `prd/` | Capture requirements through structured discovery and write a PRD |
95
- | Resume | `resume/` | Pop the most recently parked workstream off the stack and resume it at its exact resume point |
96
- | Review | `review/` | Review someone else's OPEN pull request end to end — what changed, what CI says, what is risky — then approve or request changes. Never approves on its own. |
96
+ | Resume | `resume/` | Renamed to /caddis:catchup (alias, removed next release) |
97
+ | Review | `review/` | Renamed to /caddis:review-pr (alias, removed next release) |
98
+ | Review Pr | `review-pr/` | Review someone else's OPEN pull request end to end — what changed, what CI says, what is risky — then approve or request changes. Never approves on its own. |
97
99
  | Ship | `ship/` | Commit, push, and monitor the deploy pipeline (auto-detects Gitea, GitHub Actions, or local-only) |
98
100
  | Ship Merge | `ship-merge/` | Merge an already-green, already-reviewed PR behind an explicit deploy-confirm, monitor the deploy, validate prod, then clean up the branch — only on green. |
99
101
  | Ship Pr | `ship-pr/` | Open a reviewed PR for the current feature branch — rebase-safe push, PR create/update, monitor CI, STOP at green. Never merges, never deploys (that's /ship-merge). |
100
102
  | Spawn Hub | `spawn-hub/` | Hand the Advisory Hub role to a fresh session — run the mechanical audit, then generate the succession prompt with a context self-check the new Hub must pass |
101
103
  | Spawn Session | `spawn-session/` | Hand the WORK to a fresh session — generate the handover from the repo, then validate the successor by re-deriving its answers |
102
- | Statusline | `statusline/` | Install the caddis status line into Claude Code and agy — one renderer, once per machine |
104
+ | Statusline | `statusline/` | Renamed to /caddis:install-statusline (alias, removed next release) |
103
105
  | Tdd | `tdd/` | Run a strict red-green-refactor TDD cycle for a unit of behavior |
104
106
  | Ui Brief | `ui-brief/` | Design-first UI brief — lock the aesthetic before writing any code |
105
107
  | Usage Review | `usage-review/` | Review local caddis usage over a window, surface prioritised recommendations, and apply config changes in one step |