@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.
- package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/guard_agy.py +1056 -83
- package/bundles/antigravity-plugin/hooks.json +2 -1
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +182 -4
- package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +27 -1
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +93 -6
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +290 -176
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +346 -50
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +82 -8
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
- package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
- package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +303 -79
- package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +6 -1
- package/bundles/antigravity-plugin/session_end_agy.py +27 -11
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +55 -9
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +10 -6
- package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -17
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +45 -5
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +32 -10
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +32 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +44 -6
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +40 -16
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
- package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +10 -6
- package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
- package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
- package/bundles/manifest.json +3 -3
- 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
|
|
14
|
-
the path denylist is exactly the case this second layer exists for, and
|
|
15
|
-
"something here looks like a credential" is "send nothing", not "guess
|
|
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_]
|
|
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'\"
|
|
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
|
|
217
|
-
"""
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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]
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
`
|
|
160
|
-
|
|
161
|
-
|
|
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.**
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 —
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
gh
|
|
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
|
-
#
|
|
91
|
-
|
|
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).
|