@caddis/cli 0.4.30 → 0.4.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/mcp_ping_server.py +1 -1
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +301 -66
- package/bundles/antigravity-plugin/scripts/caddis_exit.py +15 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +145 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +33 -10
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +5 -2
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +13 -1
- package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +2 -2
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +9 -14
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +66 -0
- package/bundles/antigravity-plugin/scripts/new-machine.ps1 +1 -1
- package/bundles/antigravity-plugin/scripts/oss_ask.py +6 -5
- package/bundles/antigravity-plugin/scripts/oss_model.py +1 -1
- package/bundles/antigravity-plugin/scripts/oss_review.py +84 -15
- package/bundles/antigravity-plugin/scripts/secret_filter.py +316 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +9 -4
- package/bundles/antigravity-plugin/skills/_registry.md +5 -3
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +37 -2
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/digress/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +8 -1
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +3 -1
- package/bundles/antigravity-plugin/skills/install-statusline/SKILL.md +105 -0
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +14 -8
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +2 -32
- package/bundles/antigravity-plugin/skills/review/SKILL.md +2 -96
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +100 -0
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +2 -101
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +3 -3
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
- package/bundles/codex/.codex/config.toml.example +0 -16
- package/bundles/codex/AGENTS.md +2 -2
- package/bundles/manifest.json +3 -3
- package/dist/claude-deepseek.js +1 -1
- package/dist/claude-glm.js +1 -1
- package/dist/claude-oss.js +1 -1
- package/dist/cli.js +1 -1
- package/package.json +1 -1
|
@@ -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:
|
|
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
|
|
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/` |
|
|
96
|
-
| Review | `review/` |
|
|
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/` |
|
|
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 |
|
|
@@ -5,6 +5,10 @@ description: Where were we? A quick list of what this session was doing and what
|
|
|
5
5
|
|
|
6
6
|
# /catchup — where are we?
|
|
7
7
|
|
|
8
|
+
If $ARGUMENTS is empty, follow the catchup procedure below. If a name is given, follow
|
|
9
|
+
the pop-and-resume procedure at the end of this file. The name selects this mode; the
|
|
10
|
+
procedure still pops the last parked frame.
|
|
11
|
+
|
|
8
12
|
Answer one question: **what were we doing, and what is still open?**
|
|
9
13
|
|
|
10
14
|
Not named `/recap` on purpose — Claude Code has a built-in `/recap` that produces a one-line
|
|
@@ -58,5 +62,36 @@ on is worse than one sentence admitting there is nothing to recap.
|
|
|
58
62
|
folder, or `claude --resume` to pick an older session. Mention this only if the recap is thin,
|
|
59
63
|
or if they ask for detail the files do not hold.
|
|
60
64
|
- To **write** a durable resume doc rather than read one: `/caddis:handoff`.
|
|
61
|
-
- To **pop a parked workstream** off the digress stack: `/caddis:
|
|
62
|
-
|
|
65
|
+
- To **pop a parked workstream** off the digress stack: `/caddis:catchup [name]`. This restores work deliberately set aside.
|
|
66
|
+
|
|
67
|
+
# /caddis:catchup [name] — pop the parked task and pick it back up
|
|
68
|
+
|
|
69
|
+
A digression is finished; return to the task you parked with `/digress`. This pops the top frame off the
|
|
70
|
+
workstream stack, restates where you were, realigns `relay.md`, and immediately continues the work — the
|
|
71
|
+
user should not have to remember or re-state anything.
|
|
72
|
+
|
|
73
|
+
## Step 1 — read the stack
|
|
74
|
+
Read `.caddis/workstreams.json`. If it is **absent, unparseable, `version != 1`, or `stack` is empty**,
|
|
75
|
+
say exactly `Nothing is parked.` and stop. Do nothing else.
|
|
76
|
+
|
|
77
|
+
## Step 2 — pop the top frame
|
|
78
|
+
The top of stack is the **last** element of `stack` (LIFO — most recently parked). Remove it and write the
|
|
79
|
+
rest of the file back (preserve `version` and any other frames + unknown fields). This is the one write
|
|
80
|
+
this command makes to the state file.
|
|
81
|
+
|
|
82
|
+
## Step 3 — restate + realign relay.md
|
|
83
|
+
Restate the popped frame to the user: its `plan`, `phase`, and `resumePointer` (and `repo` if set — the
|
|
84
|
+
parked task lives in another repo, so say which). Then edit `.caddis/relay.md`'s `## Next step` section
|
|
85
|
+
**in place** so it matches the popped frame's `resumePointer` — preserve every other section of relay.md
|
|
86
|
+
untouched. (If relay.md or that section is absent, skip this edit silently; the restatement above is enough.)
|
|
87
|
+
|
|
88
|
+
## Step 4 — resume the work
|
|
89
|
+
Begin executing the `resumePointer` immediately. Ask nothing — the frame already carries the next action.
|
|
90
|
+
If the parked plan lives in another `repo`, note that the user may need to open that repo first, then
|
|
91
|
+
proceed there.
|
|
92
|
+
|
|
93
|
+
## Rules
|
|
94
|
+
- **Never** run a destructive or history-rewriting git action (no `git checkout`, `git reset`, `git stash`,
|
|
95
|
+
branch switches). Resuming is metadata-only.
|
|
96
|
+
- Pop exactly one frame per invocation (the LIFO top). Run `/caddis:catchup [name]` again to pop the next.
|
|
97
|
+
- Only real paths and verified facts — restate the frame as written; do not embellish the resume point.
|
|
@@ -97,7 +97,7 @@ as a diff that silently is not read. Name a provider explicitly only when you ha
|
|
|
97
97
|
- **0 — REVIEW: CLEAN** → no blocking issues. Proceed.
|
|
98
98
|
- **1 — REVIEW: BLOCKING** → read the printed findings, then **FIX each blocking item** (or explicitly
|
|
99
99
|
justify why one is a false positive). Re-run until CLEAN.
|
|
100
|
-
- **
|
|
100
|
+
- **4 — error** → no verdict parsed, or a git/endpoint failure. Read stderr; do NOT treat as clean.
|
|
101
101
|
- **3 — misconfigured** → `REVIEW_API_KEY` is unset. Set it (see Prerequisite) and re-run.
|
|
102
102
|
|
|
103
103
|
## Rules
|
|
@@ -7,7 +7,7 @@ description: Park the current workstream on the stack and switch to a new task
|
|
|
7
7
|
|
|
8
8
|
You are about to leave the current task for a related-but-different one (a design decision, a sub-feature,
|
|
9
9
|
or a blocker that must be fixed first). This command **records the original task on a workstream stack** so
|
|
10
|
-
neither you nor the user has to remember and re-state it later — `/
|
|
10
|
+
neither you nor the user has to remember and re-state it later — `/catchup [name]` pops it back with its exact
|
|
11
11
|
resume point, and every SessionStart surfaces it (`⛏ Parked workstream: …`).
|
|
12
12
|
|
|
13
13
|
The reason for the detour is **$ARGUMENTS** (if empty, derive a one-line reason yourself from what the
|
|
@@ -51,7 +51,7 @@ clean phase boundary and lose or double-do the work.
|
|
|
51
51
|
|
|
52
52
|
**Digression check.** If this session abandoned a mid-flight plan for a *different* one (relay.md or the
|
|
53
53
|
tracker shows plan A in progress, but the work moved to plan B), suggest the user run `/digress` to park
|
|
54
|
-
plan A on the workstream stack — so its exact resume point survives and `/
|
|
54
|
+
plan A on the workstream stack — so its exact resume point survives and `/catchup [name]` can pop it back later.
|
|
55
55
|
|
|
56
56
|
## Step 2b — Advisory-Hub handoff audit (conditional — skip silently if not applicable)
|
|
57
57
|
|
|
@@ -113,6 +113,13 @@ Nothing to say is the normal answer. Say nothing and move on — this must not b
|
|
|
113
113
|
> `/caddis:migrate-dir` does that.
|
|
114
114
|
|
|
115
115
|
```markdown
|
|
116
|
+
---
|
|
117
|
+
type: relay
|
|
118
|
+
workstream: <name>
|
|
119
|
+
branch: <branch>
|
|
120
|
+
generated: { by: <model>, at: <ISO time> }
|
|
121
|
+
---
|
|
122
|
+
|
|
116
123
|
# Relay — <feature>
|
|
117
124
|
**Updated:** <ISO timestamp>
|
|
118
125
|
|
|
@@ -51,7 +51,9 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" verdict-gate --plan <plan
|
|
|
51
51
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" tracker-vs-git --plan <plan>
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
Use the shared scale in `caddis_exit.py`: 0 clean, 1 blocked, 2 advisory, 3 could not run,
|
|
55
|
+
4 error or bad usage. For `caddis_gate.py`, obey the exit code: **0** proceed · **1** STOP and report
|
|
56
|
+
why · **2** proceed but record the note it
|
|
55
57
|
printed · **3** wrong lane — the launch command is on stdout, spawn it · **4** the launch command is
|
|
56
58
|
malformed, do not run it.
|
|
57
59
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: install-statusline
|
|
3
|
+
description: Install the caddis status line into Claude Code and agy — one renderer, once per machine
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /install-statusline — set up the status bar
|
|
7
|
+
|
|
8
|
+
Wire the caddis status line into every coding agent on this machine. One Python renderer serves
|
|
9
|
+
both hosts. It installs at **user scope**, so every project gets the line and no project holds a
|
|
10
|
+
copy that can drift.
|
|
11
|
+
|
|
12
|
+
## What the user gets
|
|
13
|
+
|
|
14
|
+
Claude Code:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
⌂ claudster-source | saajunaid/caddis ⎇ main +2 ●1 ?3 ↑1 | ◆ Opus 5 (1M context) v2.1.239 |
|
|
18
|
+
◉ 412k/1M [███░░░░░] 41% | ⚡ 386k cached | effort:high | style:Plain | thinking |
|
|
19
|
+
5h:12% 7d:40% | 💻 57% (37 GB) | ⏺ relay | ◷ 07:58
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
agy (Antigravity):
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
▛▜ agy | ● idle | 🧠 Gemini 3.6 Flash (High) (high) | 📁 claudster-source | ⎇ main ?3 |
|
|
26
|
+
◉ 48.5k/1M [░░░░░░░░] 5% | ⚡ 40k cached | q 62% | 💻 57% (37 GB) | ⏺ relay | ◷ 08:01
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Both lines come from `caddis_statusline.py`. It has two profiles, because the two hosts send
|
|
30
|
+
different field names — agy's payload is a sibling of Claude Code's, not a copy.
|
|
31
|
+
|
|
32
|
+
## Run it
|
|
33
|
+
|
|
34
|
+
Find the renderer in this install and run it. Try these paths in order and use the first that exists:
|
|
35
|
+
|
|
36
|
+
- `${CLAUDE_PLUGIN_ROOT}/scripts/caddis_statusline.py`
|
|
37
|
+
- `claude-harness/scripts/caddis_statusline.py` (working inside the caddis source repo)
|
|
38
|
+
|
|
39
|
+
Then:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_statusline.py" --install # both hosts
|
|
43
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_statusline.py" --install --host claude
|
|
44
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_statusline.py" --install --host agy
|
|
45
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_statusline.py" --install --dry-run # print, write nothing
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`--install` does four things:
|
|
49
|
+
|
|
50
|
+
1. Copies the renderer to `~/.caddis/statusline.py` — a **stable** path. Pointing the host
|
|
51
|
+
settings at the plugin cache would break on every caddis upgrade, because the version number
|
|
52
|
+
is in that path.
|
|
53
|
+
2. Creates `~/.caddis/statusline.json` with the default toggles. It never overwrites an existing one.
|
|
54
|
+
3. Backs up each host's `settings.json` to `settings.json.bak-caddis-statusline`.
|
|
55
|
+
4. Sets `statusLine` in `~/.claude/settings.json` and `~/.gemini/antigravity-cli/settings.json`,
|
|
56
|
+
preserving every other key. A host that is not installed is skipped, not failed.
|
|
57
|
+
|
|
58
|
+
Report the tool's own output to the user. Then tell them the line appears on the **next** session —
|
|
59
|
+
neither host re-reads `settings.json` mid-session.
|
|
60
|
+
|
|
61
|
+
## Verify
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
python ~/.caddis/statusline.py --check # what is wired where
|
|
65
|
+
python ~/.caddis/statusline.py --sample --profile claude # preview, no host needed
|
|
66
|
+
python ~/.caddis/statusline.py --sample --profile agy
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`--check` exits non-zero when a host's `statusLine` is missing or points somewhere else.
|
|
70
|
+
|
|
71
|
+
## Change the look
|
|
72
|
+
|
|
73
|
+
Edit `~/.caddis/statusline.json`. Every key is optional; a missing file means defaults.
|
|
74
|
+
|
|
75
|
+
| key | default | what it controls |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `color` | `true` | ANSI colour |
|
|
78
|
+
| `icons` | `true` | Unicode segment icons. These are plain Unicode, **not** Nerd Font glyphs |
|
|
79
|
+
| `progress_bar` | `true` | the `[███░░░░░]` context bar |
|
|
80
|
+
| `bar_width` | `8` | bar cells |
|
|
81
|
+
| `token_counts` | `true` | `◉ 412k/1M` |
|
|
82
|
+
| `cached_tokens` | `true` | `⚡ 386k cached` — bold and bright by design, never width-gated |
|
|
83
|
+
| `ram` | `true` | `💻 57% (37 GB)` |
|
|
84
|
+
| `ram_min_width` | `0` | hide RAM below this terminal width; `0` always shows it |
|
|
85
|
+
| `clock` | `true` | `◷ 07:58` |
|
|
86
|
+
| `relay` | `true` | `⏺ relay` when a parked resume pointer exists |
|
|
87
|
+
| `rate_limits` | `true` | `5h:12% 7d:40%` (Claude Code only) |
|
|
88
|
+
| `session_id` | `false` | `#a1b2c3d4` |
|
|
89
|
+
| `git` | `true` | branch, staged/unstaged/untracked counts, ahead/behind |
|
|
90
|
+
| `separator` | `"\|"` | segment separator |
|
|
91
|
+
|
|
92
|
+
Config changes take effect on the next render. No reinstall needed.
|
|
93
|
+
|
|
94
|
+
## Notes for you, not the user
|
|
95
|
+
|
|
96
|
+
- Do **not** hand-edit either `settings.json` to wire this. `--install` reads, merges and writes
|
|
97
|
+
back, so it cannot drop an unrelated key. Hand edits have dropped `trustedWorkspaces` before.
|
|
98
|
+
- The renderer never crashes the host. A bad payload, a missing field, or an unreadable config
|
|
99
|
+
degrades to fewer segments; a hard failure degrades to the directory name.
|
|
100
|
+
- RAM on Windows uses `ctypes` `GlobalMemoryStatusEx`, an in-process call. Do not replace it with
|
|
101
|
+
`Get-CimInstance Win32_OperatingSystem` — that cost about 300 ms on **every** render in the
|
|
102
|
+
PowerShell line this replaced.
|
|
103
|
+
- If the user asks why their old `~/.claude/statusline-custom.sh` or
|
|
104
|
+
`~/.gemini/statusline/statusline.ps1` no longer runs: nothing points at them any more. They are
|
|
105
|
+
superseded, not deleted. Both are safe to remove once the user is happy with the new line.
|