@caddis/cli 0.4.55 → 0.4.57

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 (58) hide show
  1. package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
  2. package/bundles/antigravity-plugin/guard_agy.py +1056 -83
  3. package/bundles/antigravity-plugin/hooks.json +2 -1
  4. package/bundles/antigravity-plugin/plugin.json +1 -1
  5. package/bundles/antigravity-plugin/repo_freshness.py +182 -4
  6. package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
  7. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
  8. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
  9. package/bundles/antigravity-plugin/scripts/caddis_gate.py +27 -1
  10. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +93 -6
  11. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
  12. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
  13. package/bundles/antigravity-plugin/scripts/caddis_review.py +290 -176
  14. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +346 -50
  15. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +82 -8
  16. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
  17. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
  18. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
  19. package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
  20. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
  21. package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
  22. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
  23. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
  24. package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
  25. package/bundles/antigravity-plugin/scripts/oss_review.py +303 -79
  26. package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
  27. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +6 -1
  28. package/bundles/antigravity-plugin/session_end_agy.py +27 -11
  29. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +55 -9
  30. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
  31. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
  32. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +10 -6
  33. package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
  34. package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -17
  35. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +45 -5
  37. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
  38. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
  39. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
  40. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
  41. package/bundles/antigravity-plugin/skills/ship/SKILL.md +32 -10
  42. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +32 -1
  43. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +44 -6
  44. package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
  45. package/bundles/antigravity-plugin/warm_start_agy.py +40 -16
  46. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  47. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  48. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
  50. package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
  51. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
  52. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +10 -6
  53. package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
  54. package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
  55. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
  56. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
  57. package/bundles/manifest.json +3 -3
  58. package/package.json +1 -1
@@ -10,9 +10,31 @@ passes through `filter_diff()` first. It does two independent things, both fail-
10
10
  paths were excluded so the drop is visible, not invisible.
11
11
  2. **Content rules** — every hunk line of a file that was NOT dropped is scanned against
12
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".
13
+ assignments). A hit on an ADDED line refuses the ENTIRE review (not just that file) — a
14
+ secret slipping past the path denylist is exactly the case this second layer exists for, and
15
+ the safe response to "something here looks like a credential" is "send nothing", not "guess
16
+ which part is safe".
17
+
18
+ What counts as a refusal (the rest is a WARN, or nothing):
19
+ - Added line: any rule refuses.
20
+ - Removed line: only the high-confidence rules refuse (`HIGH_CONFIDENCE_RULES`). The two
21
+ assignment heuristics do not: the text is leaving the code, and they guess.
22
+ - Context line: a high-confidence hit refuses too (the line would be SENT to the provider,
23
+ and a real key must not leave the machine). A hit of an assignment heuristic only goes to
24
+ `FilterResult.warnings`; the caller prints a WARN line, because the line is already in the
25
+ repo and the heuristic guesses.
26
+ - `env-assignment` (an unquoted `NAME_TOKEN=value`) runs only on env-like files (`.env*`,
27
+ `*.env`, shell, ini, yaml, tfvars, service, Dockerfile, files with no extension, ...). In
28
+ code, that shape is an ordinary assignment (`tokens = _ga_tokens(text)`). The quoted
29
+ `assignment` rule still runs on every file.
30
+ - An unquoted value in an env-like file is exempt only as a shell variable or substitution
31
+ (`$VAR` as the whole value, `${VAR}`, `$(cmd)`, a backtick command, or `$(...)` / `${...}`
32
+ anywhere in the value) or as a placeholder. Code shape proves nothing there: `SECRET=YWJj==.sig`,
33
+ `SAS=sv=2022-11-02&sig=abc123` and `PASS=ab#cdefgh12` are secrets. A quoted value is exempt
34
+ only as a placeholder, or as a CSS custom-property block (`--bg:#282a36;--fg:#f8f8f2`).
35
+ - A trailing `# caddis: not-secret` or `// caddis: not-secret` comment exempts that line from
36
+ the two assignment rules only. It is ignored inside a string and never exempts a
37
+ high-confidence rule.
16
38
 
17
39
  Deliberately out of scope, by design (see the plan's "Design (decided)"):
18
40
  - Removed-line hits use old-file line numbers and are marked `removed line`.
@@ -54,6 +76,7 @@ DEFAULT_DENY_GLOBS: list[str] = [
54
76
  "id_rsa*", "**/id_rsa*",
55
77
  "secrets.*", "**/secrets.*",
56
78
  "keys.env", "**/keys.env",
79
+ "*.env", # prod.env, config/app.env: a leading `*` matches at any depth
57
80
  ]
58
81
 
59
82
  # `.env.example` / `.env.sample` / `.env.template` / `.env.dist` are template files with no
@@ -70,15 +93,111 @@ SECRET_RULES: list[tuple[str, re.Pattern]] = [
70
93
  ("github-token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
71
94
  ("slack-token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b")),
72
95
  ("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")),
96
+ ("github-pat", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{22,}")),
97
+ ("aws-session-key", re.compile(r"\bASIA[0-9A-Z]{16}\b")),
98
+ ("openai-key", re.compile(r"\bsk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}")),
99
+ ("anthropic-key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}")),
100
+ ("google-api-key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}")),
101
+ ("npm-token", re.compile(r"\bnpm_[A-Za-z0-9]{36}\b")),
102
+ ("slack-webhook", re.compile(r"https://hooks\.slack\.com/services/[A-Za-z0-9_/]+")),
103
+ # user:password@host in a URL. `value` is the password; placeholders are exempted in code.
104
+ ("url-credentials", re.compile(
105
+ r"[a-z][a-z0-9+.-]*://[^/\s:@]*:(?P<value>[^/\s@]{3,})@", re.I)),
106
+ # `name = "v"`, `name: "v"`, and a quoted key: `"name": "v"`, `'name': 'v'`.
73
107
  ("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,})['\"]"
108
+ r"(?i)[A-Za-z0-9_]*(password|passwd|secret|api[_-]?key|token)[A-Za-z0-9_]*['\"]?\s*[:=]\s*['\"](?P<value>[^'\"\s]{8,})['\"]"
75
109
  )),
76
110
  ("env-assignment", re.compile(
77
111
  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
112
+ r"[A-Za-z0-9_]*\s*=\s*(?P<value>[^\s'\"]{8,})", re.I
79
113
  )),
80
114
  ]
81
115
 
116
+ # Rules that match a fixed credential format. These refuse on added, removed AND context lines
117
+ # and ignore the `caddis: not-secret` marker. The two assignment rules are heuristics and get none
118
+ # of that.
119
+ ASSIGNMENT_RULES = frozenset({"assignment", "env-assignment"})
120
+ HIGH_CONFIDENCE_RULES = frozenset({
121
+ "private-key", "aws-key", "github-token", "slack-token", "jwt",
122
+ "github-pat", "aws-session-key", "openai-key", "anthropic-key", "google-api-key",
123
+ "npm-token", "slack-webhook", "url-credentials",
124
+ })
125
+
126
+ # A trailing comment only: `# caddis: not-secret` or `// caddis: not-secret` at the end of the
127
+ # line, and not inside a string (see `_has_not_secret_marker`).
128
+ _TRAILING_MARKER = re.compile(r"(?:#|//)\s*caddis:\s*not-secret\s*$", re.I)
129
+
130
+ # `env-assignment` runs only on these files (matched on the file name, not the directory).
131
+ _ENV_LIKE_SUFFIXES = frozenset({
132
+ ".sh", ".bash", ".zsh", ".ps1", ".psm1", ".bat", ".cmd", ".ini", ".cfg", ".conf", ".toml",
133
+ ".yaml", ".yml", ".properties", ".env", ".tfvars", ".service",
134
+ })
135
+
136
+
137
+ def _is_env_like(path: str) -> bool:
138
+ name = path.replace("\\", "/").rsplit("/", 1)[-1].lower()
139
+ if name.startswith((".env", "dockerfile")):
140
+ return True
141
+ suffix = Path(name).suffix
142
+ return suffix == "" or suffix in _ENV_LIKE_SUFFIXES
143
+
144
+
145
+ def _inside_string(prefix: str) -> bool:
146
+ """True when `prefix` ends inside an open '...' or "..." string."""
147
+ quote = None
148
+ i = 0
149
+ while i < len(prefix):
150
+ c = prefix[i]
151
+ if quote:
152
+ if c == "\\":
153
+ i += 2
154
+ continue
155
+ if c == quote:
156
+ quote = None
157
+ elif c in "'\"":
158
+ quote = c
159
+ i += 1
160
+ return quote is not None
161
+
162
+
163
+ def _has_not_secret_marker(content: str) -> bool:
164
+ match = _TRAILING_MARKER.search(content)
165
+ return bool(match) and not _inside_string(content[:match.start()])
166
+
167
+
168
+ # `--bg:#282a36;--fg:#f8f8f2`: a CSS custom-property block held in a quoted string.
169
+ _CSS_DECLARATIONS = re.compile(r"(?:--[A-Za-z0-9_-]+:[^;\s]+;?)+")
170
+ # $(cmd) `cmd` ${VAR}. A bare `$VAR` is exempt only as the WHOLE value, through
171
+ # `_is_placeholder`: a value that merely starts with `$` (`$ecretP@ss`, `$argon2id$...`) is a secret.
172
+ _SHELL_START = re.compile(r"\$\(|`|\$\{")
173
+ _SHELL_SUBSTITUTION = re.compile(r"\$\([^)]*\)|\$\{[^}]*\}") # $(...) or ${...} anywhere
174
+
175
+
176
+ def _is_not_secret_value(value: str, *, quoted: bool) -> bool:
177
+ """True when the value is a CSS block (quoted) or a shell variable or substitution (unquoted).
178
+
179
+ A QUOTED value is a string literal, so code shape proves nothing: `"Sec;ret(1)"` and
180
+ `"P@ssw0rd#2026!"` are passwords. Only a CSS custom-property block is exempt (the
181
+ placeholder check happens in the caller).
182
+
183
+ An UNQUOTED value reaches this rule only in an env-like file, where values are not code:
184
+ `SECRET=YWJj==.sig`, `SAS=sv=2022-11-02&sig=abc123`, `PASS=ab#cdefgh12` and
185
+ `KEY=correct_horse` are secrets. Only a shell reference is exempt: a value that starts with
186
+ `$(`, a backtick or `${`, or holds `$(...)` / `${...}` in any position. A bare `$name` is
187
+ exempt only as the whole value (a placeholder); `$argon2id$v=19$...` is a hash.
188
+ """
189
+ if quoted:
190
+ return bool(_CSS_DECLARATIONS.fullmatch(value))
191
+ return bool(_SHELL_START.match(value) or _SHELL_SUBSTITUTION.search(value))
192
+
193
+
194
+ _URL_PASSWORD_PLACEHOLDERS = frozenset({"password", "pass", "passwd", "pwd", "secret", "token"})
195
+
196
+
197
+ def _is_url_password_placeholder(value: str) -> bool:
198
+ return (_is_placeholder(value) or value.lower() in _URL_PASSWORD_PLACEHOLDERS
199
+ or bool(re.fullmatch(r"\{[^}]*\}|%s|%\([^)]*\)s", value)))
200
+
82
201
 
83
202
  def _is_placeholder(value: str) -> bool:
84
203
  """Exclude obvious template values from both assignment rules."""
@@ -99,6 +218,8 @@ class FilterResult:
99
218
  kept_diff: str
100
219
  dropped_paths: list[str] = field(default_factory=list)
101
220
  hits: list[tuple[str, int, str]] = field(default_factory=list) # (path, side's line, rule/removed marker)
221
+ # Context-line matches: already in the repo, so reported (WARN) but never a refusal.
222
+ warnings: list[tuple[str, int, str]] = field(default_factory=list)
102
223
 
103
224
 
104
225
  # A new file block starts at a line beginning "diff --git a/..." (the common, unquoted case)
@@ -213,9 +334,38 @@ def _is_denied(path: str, deny_globs: list[str], allow_globs: list[str]) -> bool
213
334
  return not any(fnmatch.fnmatchcase(path, glob) for glob in allow_globs)
214
335
 
215
336
 
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."""
337
+ def _line_matches(content: str, env_like: bool) -> list[str]:
338
+ """Names of the rules that match one line of content, after every exemption.
339
+
340
+ Every match of a rule is checked, so a real secret that follows a placeholder on the same
341
+ line (a JSON object with a placeholder key first and a real one second) is still caught.
342
+ """
343
+ marked = _has_not_secret_marker(content)
344
+ names: list[str] = []
345
+ for rule_name, pattern in SECRET_RULES:
346
+ if rule_name in ASSIGNMENT_RULES and marked:
347
+ continue
348
+ if rule_name == "env-assignment" and not env_like:
349
+ continue
350
+ for match in pattern.finditer(content):
351
+ if rule_name in ASSIGNMENT_RULES:
352
+ value = match.group("value")
353
+ if _is_placeholder(value) or _is_not_secret_value(
354
+ value, quoted=rule_name == "assignment"):
355
+ continue
356
+ elif rule_name == "url-credentials" and _is_url_password_placeholder(
357
+ match.group("value")):
358
+ continue
359
+ names.append(rule_name)
360
+ break
361
+ return names
362
+
363
+
364
+ def _scan_block(block: str, path: str, env_like: bool) -> tuple[list[tuple[str, int, str]],
365
+ list[tuple[str, int, str]]]:
366
+ """Scan all hunk lines; return (hits, warnings). Removed hits use old-file line numbers."""
218
367
  hits: list[tuple[str, int, str]] = []
368
+ warnings: list[tuple[str, int, str]] = []
219
369
  new_line: int | None = None
220
370
  old_line: int | None = None
221
371
  for line in block.splitlines():
@@ -231,16 +381,18 @@ def _scan_added_lines(block: str, path: str) -> list[tuple[str, int, str]]:
231
381
  content = line[1:]
232
382
  removed = line.startswith("-")
233
383
  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))
384
+ for rule_name in _line_matches(content, env_like):
385
+ if context:
386
+ # A real key on a context line is still SENT to the provider.
387
+ if rule_name in HIGH_CONFIDENCE_RULES:
388
+ hits.append((path, new_line, f"{rule_name}, context line"))
389
+ else:
390
+ warnings.append((path, new_line, f"{rule_name}, context line"))
391
+ elif removed:
392
+ if rule_name in HIGH_CONFIDENCE_RULES:
393
+ hits.append((path, old_line, f"{rule_name}, removed line"))
394
+ else:
395
+ hits.append((path, new_line, rule_name))
244
396
  if removed:
245
397
  old_line += 1
246
398
  elif not context:
@@ -251,13 +403,25 @@ def _scan_added_lines(block: str, path: str) -> list[tuple[str, int, str]]:
251
403
  else:
252
404
  old_line += 1
253
405
  new_line += 1 # context line exists in both files
254
- return hits
406
+ return hits, warnings
407
+
408
+
409
+ def _scan_added_lines(block: str, path: str,
410
+ env_assignment_everywhere: bool = False) -> list[tuple[str, int, str]]:
411
+ """The refusing hits of `_scan_block` (kept for callers that want only those)."""
412
+ return _scan_block(block, path, env_assignment_everywhere or _is_env_like(path))[0]
255
413
 
256
414
 
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."""
415
+ def filter_diff(diff_text: str, deny_globs: list[str], allow_globs: list[str],
416
+ env_assignment_everywhere: bool = False) -> FilterResult:
417
+ """Drop denylisted-path file blocks, then scan the hunk lines in kept blocks.
418
+
419
+ `env_assignment_everywhere` runs `env-assignment` on every file type. Use it for text that
420
+ is not a diff of real files (the relay note is prose in a synthetic `.md` file).
421
+ """
259
422
  dropped_paths: list[str] = []
260
423
  hits: list[tuple[str, int, str]] = []
424
+ warnings: list[tuple[str, int, str]] = []
261
425
  kept_parts: list[str] = []
262
426
  for block in _split_into_file_blocks(diff_text):
263
427
  first_line = block.splitlines()[0] if block else ""
@@ -268,9 +432,13 @@ def filter_diff(diff_text: str, deny_globs: list[str], allow_globs: list[str]) -
268
432
  if denied:
269
433
  dropped_paths.append(denied)
270
434
  continue
271
- hits.extend(_scan_added_lines(block, path))
435
+ block_hits, block_warnings = _scan_block(
436
+ block, path, env_assignment_everywhere or _is_env_like(path))
437
+ hits.extend(block_hits)
438
+ warnings.extend(block_warnings)
272
439
  kept_parts.append(block)
273
- return FilterResult(kept_diff="".join(kept_parts), dropped_paths=dropped_paths, hits=hits)
440
+ return FilterResult(kept_diff="".join(kept_parts), dropped_paths=dropped_paths, hits=hits,
441
+ warnings=warnings)
274
442
 
275
443
 
276
444
  def load_config(repo: Path) -> tuple[list[str], list[str]]:
@@ -369,7 +369,8 @@ def check_venv(target: Path, install: bool, dry: bool) -> list[str]:
369
369
  notes.append("venv: .venv present — ok")
370
370
  elif install and not dry:
371
371
  import subprocess
372
- subprocess.run([sys.executable, "-m", "venv", str(venv)], check=False)
372
+ subprocess.run([sys.executable, "-m", "venv", str(venv)], check=False,
373
+ creationflags=0x08000000 if os.name == "nt" else 0)
373
374
  notes.append("venv: created .venv (install dev deps: .venv/Scripts/pip install -e .[dev])")
374
375
  else:
375
376
  notes.append("venv: MISSING — create with `python -m venv .venv` then "
@@ -1205,6 +1206,10 @@ relay/
1205
1206
  session-state.md
1206
1207
  session-state/
1207
1208
  context-window.json
1209
+ context-window.json.tmp
1210
+ .session-state-*.tmp
1211
+ hook-errors.jsonl
1212
+ .relay-injected/
1208
1213
  PROJECT-FACTS.md
1209
1214
  # lock files beside locked documents, and migration backups (never commit these)
1210
1215
  *.lock
@@ -23,6 +23,19 @@ import subprocess
23
23
  import sys
24
24
  from datetime import datetime, timezone
25
25
 
26
+ # No child process of this hook opens a console window on Windows (scripts/no_window.py). agy hooks ship
27
+ # FLAT at the plugin root with scripts/ beside them; in the source repo scripts/ is one level up.
28
+ try:
29
+ _nw_here = os.path.dirname(os.path.abspath(__file__))
30
+ for _nw_cand in (os.path.join(_nw_here, "scripts"), os.path.join(os.path.dirname(_nw_here), "scripts")):
31
+ if os.path.isfile(os.path.join(_nw_cand, "no_window.py")):
32
+ if _nw_cand not in sys.path:
33
+ sys.path.insert(0, _nw_cand)
34
+ break
35
+ from no_window import NO_WINDOW_KW # noqa: E402
36
+ except Exception: # pragma: no cover - defensive
37
+ NO_WINDOW_KW = {"creationflags": 0x08000000} if os.name == "nt" else {}
38
+
26
39
 
27
40
  def _artifact_root(root: str) -> str:
28
41
  return os.path.join(str(root), ".caddis")
@@ -39,7 +52,7 @@ def _repo_root(path: str):
39
52
  try:
40
53
  done = subprocess.run(
41
54
  ["git", "-C", str(path), "rev-parse", "--show-toplevel"],
42
- capture_output=True, text=True, timeout=5,
55
+ capture_output=True, text=True, timeout=5, **NO_WINDOW_KW,
43
56
  )
44
57
  top = done.stdout.strip()
45
58
  return os.path.normpath(top) if done.returncode == 0 and top else None
@@ -48,7 +61,8 @@ def _repo_root(path: str):
48
61
 
49
62
 
50
63
  def _user_artifact_root() -> str:
51
- """Where usage goes when the workspace is in no repository: the user's own folder, never the cwd."""
64
+ """Where usage goes when the workspace is in no repository: the user's own folder, never the cwd.
65
+ The caller writes there only when it already exists."""
52
66
  return _artifact_root(os.path.expanduser("~"))
53
67
 
54
68
 
@@ -108,21 +122,24 @@ def main() -> None:
108
122
  try:
109
123
  roots = _workspace_roots(data) or [os.getcwd()]
110
124
  # A workspace folder may sit inside a repo: the artifact dir is the REPO ROOT's, not the sub-folder's.
111
- resolved = [(_repo_root(r) or str(r)) for r in roots]
112
- # Write the record to every root that ALREADY has a .caddis dir, rather than to
113
- # whichever root happened to be first. A multi-root session's usage belongs to each
114
- # repo that participates in it, and creating .caddis in a repo that never opted in
125
+ # A folder in no repository is skipped: it has no project, and its cwd is never a fallback.
126
+ resolved = [top for top in (_repo_root(r) for r in roots) if top]
127
+ # Write the record only to roots that ALREADY have a .caddis dir. A multi-root session's usage
128
+ # belongs to each repo that participates in it, and creating .caddis in a repo that never opted in
115
129
  # would be caddis littering someone else's tree.
116
130
  targets = []
117
131
  for root in resolved:
118
132
  art = _artifact_root(root)
119
133
  if os.path.isdir(art) and art not in targets:
120
134
  targets.append(art)
135
+ if not resolved:
136
+ # No workspace is in a repository. Usage goes to the user's own folder, and only when
137
+ # that folder already exists. It is never created and never the cwd.
138
+ user_art = _user_artifact_root()
139
+ if os.path.isdir(user_art):
140
+ targets = [user_art]
121
141
  if not targets:
122
- # Nothing has opted in. In a repo, the first root's repo root is the default. Outside any
123
- # repo there is no project to attach usage to, so it goes to the user's folder, not the cwd.
124
- first_repo = _repo_root(roots[0])
125
- targets = [_artifact_root(first_repo) if first_repo else _user_artifact_root()]
142
+ return # nothing has opted in: write nothing
126
143
  record = {
127
144
  "ts": datetime.now(timezone.utc).isoformat(),
128
145
  "event": "session_end",
@@ -133,7 +150,6 @@ def main() -> None:
133
150
  "model": data.get("modelName"),
134
151
  }
135
152
  for art in targets:
136
- os.makedirs(art, exist_ok=True)
137
153
  with open(os.path.join(art, "usage-log.jsonl"), "a", encoding="utf-8") as fh:
138
154
  fh.write(json.dumps(record) + "\n")
139
155
  except Exception as _exc:
@@ -23,6 +23,35 @@ Two modes:
23
23
  With no plan argument, use the active plan (`python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_todo.py" active-plan`,
24
24
  or the one `status: current` plan in `.caddis/plans/`). If there is none, or more than one, ask.
25
25
 
26
+ ## Step 0 — Is this checkout safe to move?
27
+
28
+ <!-- checkout-safety: keep byte-identical in pr-merge, ship-pr, ship-merge and cloud-phases; a test pins it -->
29
+ Some steps below move the files of this checkout (`git rebase`, `switch`, `checkout <branch>`, `pull`, `merge`,
30
+ `stash`, `reset --hard`). Before the first of them, run:
31
+ ```
32
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_checkout_guard.py" shared --root .
33
+ ```
34
+ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when the repo's `AGENTS.md` has a line
35
+ `Shared checkout: yes` (a dev service serves from this checkout), or when `git status` lists a change. With no
36
+ `--own-files`, every change counts as another session's. A linked worktree is never shared.
37
+
38
+ - **On `SHARED: no`,** do the steps as written, in this checkout.
39
+ - **On `SHARED: yes`,** never move this checkout. Leave its changes as they are, and report them. Instead:
40
+ - **Rebase:** `git worktree add --detach "$TEMP/<repo>-rebase" <branch>`, then
41
+ `git -C <worktree> rebase origin/<default>` and `git -C <worktree> push --force-with-lease origin HEAD:<branch>`.
42
+ - **Pull a branch to test it:** `git fetch origin`, then
43
+ `git worktree add --detach "$TEMP/<repo>-<branch>" origin/<branch>` (for the default branch,
44
+ `origin/<default>`), and run the gates there. Install dependencies fresh; never link `node_modules` or a venv.
45
+ - **Clean up after a merge:** do not switch or pull. Run `git fetch --prune`, then
46
+ `git fetch origin <default>:<default>`: it fast-forwards the default branch with no checkout. Git refuses when a
47
+ checkout has the default branch out; then leave it and report it. Delete the local feature branch only when no
48
+ checkout has it out.
49
+ - Remove each worktree you made when its step ends: `git worktree remove <worktree>`.
50
+
51
+ `$TEMP` is `%TEMP%` (`$env:TEMP` in PowerShell). The caddis guard checks only the `Shared checkout: yes` line in
52
+ `AGENTS.md`, not a dirty tree: with that line, it asks before a tree move, and denies it in a headless session.
53
+ <!-- /checkout-safety -->
54
+
26
55
  ## Step 1 — check the preconditions (stop on any failure)
27
56
 
28
57
  ```
@@ -35,7 +64,11 @@ gh auth status
35
64
  - `origin` must be a GitHub repository. Cloud sessions clone from GitHub; a repo with no GitHub
36
65
  remote cannot use this command. Say so and stop.
37
66
  - The current branch must be the plan's feature branch, never the default branch. The cloud PRs
38
- target this branch, and it goes to `main` later through the normal `/caddis:ship-pr`.
67
+ target this branch, and it goes to the default branch later through the normal `/caddis:ship-pr`.
68
+ Read the default branch as a bare name: `git rev-parse --abbrev-ref origin/HEAD` prints
69
+ `origin/<default>`, so strip the `origin/` prefix before you compare
70
+ (`d=$(git rev-parse --abbrev-ref origin/HEAD) && d=${d#origin/}`; when origin/HEAD is not set,
71
+ `gh repo view --json defaultBranchRef -q .defaultBranchRef.name`).
39
72
  - Tracked changes must be committed. A cloud session cannot see uncommitted work.
40
73
  - `gh` must be signed in as an account that can push to the repository and open PRs.
41
74
 
@@ -117,7 +150,8 @@ internal host names, no machine paths and no credentials.
117
150
 
118
151
  ```
119
152
  git add <each prompt file by name>
120
- git commit -m "docs(prompts): cloud prompts for <feature> phases <N, M>"
153
+ git diff --cached --name-only # must list only the prompt files; else stop and report
154
+ git commit -m "docs(prompts): cloud prompts for <feature> phases <N, M>" -- <each prompt file>
121
155
  git push -u origin <feature branch>
122
156
  git rev-parse HEAD
123
157
  git ls-remote origin refs/heads/<feature branch>
@@ -151,14 +185,26 @@ gh pr list --base <feature branch> --state open --json number,title,headRefName,
151
185
  For each PR from a `<prefix>/phase-<NN>` branch, one at a time:
152
186
  1. Read the whole diff (`gh pr diff <N>`). Check it against the phase's Design and Touches. A file
153
187
  outside Touches is a finding.
154
- 2. Wait for CI: `gh pr checks <N> --watch` (or the ci-watch lane, if this caddis has it).
188
+ 2. Wait for CI outside the session:
189
+ `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --pr <N>`. Only `RESULT: pass`
190
+ counts; `HEAD:` names the watched head. A `gh pr checks` table is not proof: it can show an older
191
+ head's checks.
155
192
  3. Review under the three rules (rule 3, review by tier): stop at the first CLEAN; after 3 rounds,
156
- ask the owner. A fix goes back to the same cloud session or is made locally on the PR branch.
157
- 4. Just before merging, re-read `gh pr view <N> --json headRefOid,mergeStateStatus`. Merge only if
158
- the head SHA is the one you reviewed and CI passed on it:
159
- `gh pr merge <N> --squash --delete-branch`.
160
- 5. Pull: `git pull --ff-only origin <feature branch>`.
161
- 6. Run the phase's exit gate locally, and after the last PR, the full test suite.
193
+ ask the owner. A fix goes back to the same cloud session or is made locally on the PR branch. A
194
+ fix pushes a new head: run ci-watch again.
195
+ 4. Just before merging, check all three, then merge that head only:
196
+ - the last ci-watch printed `RESULT: pass`, and its `HEAD:` is the head you reviewed;
197
+ - `gh pr view <N> --json headRefOid,mergeStateStatus` still shows that head;
198
+ - no CI run on the default branch is in progress:
199
+ `gh run list --branch <default branch> --json status` lists no `in_progress` or `queued` run
200
+ (wait, then check again).
201
+
202
+ `gh pr merge <N> --squash --delete-branch --match-head-commit <watched head>` (the full SHA).
203
+ GitHub refuses the merge if the head moved. A merge on a head match alone merged a red PR (#34).
204
+ 5. Pull: `git pull --ff-only origin <feature branch>`. In a shared checkout (Step 0 says `SHARED: yes`), never
205
+ pull here: make a detached worktree of `origin/<feature branch>`, as Step 0 says, and run point 6 there.
206
+ 6. Run the phase's exit gate locally: the tests of the touched files, in the foreground, each command under
207
+ 10 minutes. Do not start the full suite in the background. CI runs it on every PR.
162
208
  7. Update the phase's Tracker row: status, PR number, merge commit, review result. Set the prompt's
163
209
  frontmatter `status: done` so `/caddis:handoff` tidies it.
164
210
 
@@ -86,23 +86,26 @@ now means the narrower case — a **single file** too big to split, or more batc
86
86
  see it, the fix is usually to review that one file separately, not to narrow `--range`; don't raise
87
87
  `--max-diff-chars` blind.
88
88
 
89
- **Do not alternate providers on a schedule.** DeepSeek is the default because it has actually found real
90
- bugs that same-vendor passes missed; GLM is the documented fallback, used automatically when DeepSeek is
91
- unavailable and you did not name a provider. The policy lives in `oss_review.py`'s `FALLBACK_PROVIDERS`
92
- comment — a rota that sends half your phases to a provider observed timing out three phases running buys
93
- variety it never actually collects, and a review that silently does not happen is the same failure class
94
- as a diff that silently is not read. Name a provider explicitly only when you have a reason.
89
+ **Do not alternate providers on a schedule.** The order is the owner policy: **DeepSeek, then agy Argon,
90
+ then GLM** (`REVIEW_ORDER` in `oss_review.py`). A named `--provider` goes first; on a provider failure
91
+ (HTTP 429, any 5xx, a timeout, a connection error, an empty reply) the tool moves to the next one in
92
+ that order and prints which provider gave the verdict. A provider with no key is skipped. agy runs
93
+ through `caddis_spend.py run` (the spend cap); if the cap refuses, agy is skipped. On a 429 the tool
94
+ waits for `Retry-After` once (at most 60 s); it never splits the diff because of a 429 or an empty
95
+ reply. A custom endpoint (`--base-url`/`--model`) never falls back. Name a provider only when you have
96
+ a reason.
95
97
 
96
98
  ## Interpret the exit code
97
99
  - **0 — REVIEW: CLEAN** → no blocking issues. Proceed.
98
100
  - **1 — REVIEW: BLOCKING** → read the printed findings, then **FIX each blocking item** (or explicitly
99
101
  justify why one is a false positive). Fix it, then re-review only the fix diff. After 3 rounds, ask the owner (rule 3).
100
102
  - **4 — error** → no verdict parsed, or a git/endpoint failure. Read stderr; do NOT treat as clean.
101
- - **3 — misconfigured** → `REVIEW_API_KEY` is unset. Set it (see Prerequisite) and re-run.
103
+ - **3 — could not run** → no key (see Prerequisite), the secret filter refused, or every provider
104
+ failed (429, 5xx, timeout, empty reply). Read stderr. A provider failure is not a verdict: re-run later.
102
105
 
103
106
  ## Three rules: one attempt, one review
104
107
  1. **Think before you send.** Before a task goes to any coder, write its edge cases and check the facts it depends on (one query or one file read).
105
- 2. **Run it before review.** The coder runs the same checks as CI (name the exact commands in the task). Then run the change once on real data.
108
+ 2. **Run it before review.** The coder runs only the tests of the files it touched, in the foreground, each command under 10 minutes (split into parts if needed). Name the exact commands in the task. Never start the full suite in the background and never wait on a monitor for it: CI runs the full suite on every PR. Then run the change once on real data.
106
109
  3. **Review by tier.** Run `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" review-tier --range <base>...HEAD` (add `--plan <plan> --phase <N>` inside a plan; no `TIER:` line means tier 2).
107
110
  Tier 0: gates only. Tier 1-small: your self-check only. Tier 1: one review by another vendor; stop at
108
111
  the first CLEAN. Tier 2: two reviewers of two other vendors, in one round. Fix blocking findings only;
@@ -270,7 +270,7 @@ yourself (relay + Tracker) before stopping — do not hand back "run `/caddis:ha
270
270
  ## Execution protocol (standing rules — this plan is the memory, not anyone's head)
271
271
  Three rules: one attempt, one review.
272
272
  1. **Think before you send.** Before a task goes to any coder, write its edge cases and check the facts it depends on (one query or one file read).
273
- 2. **Run it before review.** The coder runs the same checks as CI (name the exact commands in the task). Then run the change once on real data.
273
+ 2. **Run it before review.** The coder runs only the tests of the files it touched, in the foreground, each command under 10 minutes (split into parts if needed). Name the exact commands in the task. Never start the full suite in the background and never wait on a monitor for it: CI runs the full suite on every PR. Then run the change once on real data.
274
274
  3. **Review by tier.** Run `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" review-tier --range <base>...HEAD` (add `--plan <plan> --phase <N>` inside a plan; no `TIER:` line means tier 2).
275
275
  Tier 0: gates only. Tier 1-small: your self-check only. Tier 1: one review by another vendor; stop at
276
276
  the first CLEAN. Tier 2: two reviewers of two other vendors, in one round. Fix blocking findings only;
@@ -73,7 +73,10 @@ gh pr diff 55
73
73
  gh pr checkout 55
74
74
  gh pr review 55 --approve --body "LGTM!"
75
75
  gh pr review 55 --request-changes --body "Please fix error handling"
76
- gh pr merge 55 --squash --delete-branch
76
+ # merge only on ci-watch RESULT: pass, only its HEAD:, with no default-branch run in progress
77
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --pr 55
78
+ gh run list --branch main --json status # no in_progress or queued run (main = the default branch)
79
+ gh pr merge 55 --squash --delete-branch --match-head-commit <watched head>
77
80
 
78
81
  # Manage
79
82
  gh pr ready 55 # Mark draft as ready
@@ -168,10 +171,11 @@ gh alias set review 'pr list --search "review-requested:@me"'
168
171
  ```bash
169
172
  gh issue develop 42 --branch fix/issue-42
170
173
  git add src/auth/login.py tests/test_login.py # named files only, never `git add .`
171
- git commit -m "fix: resolve login bug" && git push -u origin HEAD
174
+ git commit -m "fix: resolve login bug" -- src/auth/login.py tests/test_login.py && git push -u origin HEAD
172
175
  gh pr create --title "fix: resolve login bug" --body "Closes #42"
173
- gh pr checks --watch
174
- gh pr merge --squash --delete-branch
176
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --pr <n> # RESULT: pass, HEAD: <watched head>
177
+ gh run list --branch main --json status # wait while a default-branch run is in progress
178
+ gh pr merge <n> --squash --delete-branch --match-head-commit <watched head>
175
179
  ```
176
180
 
177
181
  ### Release Workflow
@@ -189,8 +193,8 @@ gh release upload v1.3.0 ./dist/*.tar.gz
189
193
  | Create repo | `gh repo create name --public` |
190
194
  | Create issue | `gh issue create --title "..." --labels bug` |
191
195
  | Create PR | `gh pr create --title "..." --reviewer user` |
192
- | Merge PR | `gh pr merge 55 --squash --delete-branch` |
196
+ | Merge PR | `gh pr merge 55 --squash --delete-branch --match-head-commit <watched head>`, only after ci-watch `RESULT: pass` |
193
197
  | Create release | `gh release create v1.0.0 --generate-notes` |
194
- | Watch CI | `gh run watch` |
198
+ | Watch CI | `caddis_lanes.py ci-watch --pr 55` (fallback: `gh run watch`) |
195
199
  | Set secret | `echo "val" \| gh secret set KEY` |
196
200
  | API call | `gh api /repos/o/r --jq '.description'` |
@@ -87,9 +87,8 @@ Optional area of change:
87
87
  # Stage specific files
88
88
  git add path/to/file1 path/to/file2
89
89
 
90
- # Stage by pattern
91
- git add *.test.*
92
- git add src/components/*
90
+ # Never stage by pattern or folder (`*.test.*`, `src/components/`): a pattern also takes
91
+ # files you did not change, and the caddis guard denies it. Name each file.
93
92
  ```
94
93
 
95
94
  **Never stage secrets** (`.env`, `credentials.json`, private keys).