@caddis/cli 0.4.29 → 0.4.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/caddis_statusline.py +0 -1
- 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_statusline.py +0 -1
- 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/convert_doc_to_confluence.py +1430 -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/data-lineage/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/hld/SKILL.md +1 -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/lld/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +23 -14
- 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
|
@@ -129,7 +129,7 @@ if (Test-Path $statusline) {
|
|
|
129
129
|
if ($PSCmdlet.ShouldProcess($statusline, 'install')) { python $statusline --install }
|
|
130
130
|
} else {
|
|
131
131
|
Missing "statusline.py not found at $statusline"
|
|
132
|
-
Note 'It ships with the plugin. Run `/caddis:statusline` in a Claude Code session, or'
|
|
132
|
+
Note 'It ships with the plugin. Run `/caddis:install-statusline` in a Claude Code session, or'
|
|
133
133
|
Note 'python PLUGIN_ROOT/scripts/caddis_statusline.py --install'
|
|
134
134
|
}
|
|
135
135
|
|
|
@@ -10,7 +10,7 @@ in ~/.caddis/keys.env lights up every lane.
|
|
|
10
10
|
Usage:
|
|
11
11
|
python oss_ask.py <provider> <prompt...> [--model M] [--base-url U] [--system S]
|
|
12
12
|
|
|
13
|
-
Exit codes: 0 answer printed;
|
|
13
|
+
Exit codes: 0 answer printed; 3 could not run due to config; 4 endpoint/parse error.
|
|
14
14
|
"""
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
@@ -23,18 +23,19 @@ from pathlib import Path
|
|
|
23
23
|
|
|
24
24
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
25
25
|
import oss_model # noqa: E402 (same-directory import: shared key resolution)
|
|
26
|
+
import caddis_exit # noqa: E402
|
|
26
27
|
|
|
27
28
|
# OpenAI-compatible chat endpoints — intentionally different from oss_model.PROVIDERS,
|
|
28
29
|
# which holds the Anthropic-protocol endpoints for Claude Code.
|
|
29
30
|
CHAT_PROVIDERS: dict[str, dict[str, str]] = {
|
|
30
|
-
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-
|
|
31
|
+
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-flash", "key_env": "DEEPSEEK_API_KEY"},
|
|
31
32
|
"glm": {"base_url": "https://api.z.ai/api/coding/paas/v4", "model": "glm-5.3", "key_env": "GLM_API_KEY"},
|
|
32
33
|
"openrouter": {"base_url": "https://openrouter.ai/api/v1", "model": "deepseek/deepseek-v4-flash", "key_env": "OPENROUTER_API_KEY"},
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
EXIT_OK =
|
|
36
|
-
EXIT_ERROR =
|
|
37
|
-
EXIT_CONFIG =
|
|
36
|
+
EXIT_OK = caddis_exit.CLEAN
|
|
37
|
+
EXIT_ERROR = caddis_exit.ERROR
|
|
38
|
+
EXIT_CONFIG = caddis_exit.NOT_RUN
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
def resolve_chat_config(provider: str, env: dict[str, str],
|
|
@@ -43,7 +43,7 @@ from pathlib import Path
|
|
|
43
43
|
# NEVER put this suffix in the OpenAI-dialect tables (oss_review.py / oss_ask.py): those
|
|
44
44
|
# send the id raw to /chat/completions, which would reject it.
|
|
45
45
|
PROVIDERS: dict[str, dict[str, str]] = {
|
|
46
|
-
"deepseek": {"base_url": "https://api.deepseek.com/anthropic", "model": "deepseek-
|
|
46
|
+
"deepseek": {"base_url": "https://api.deepseek.com/anthropic", "model": "deepseek-flash[1m]", "key_env": "DEEPSEEK_API_KEY"},
|
|
47
47
|
"glm": {"base_url": "https://api.z.ai/api/anthropic", "model": "glm-5.3[1m]", "key_env": "GLM_API_KEY"},
|
|
48
48
|
"openrouter": {"base_url": "", "model": "deepseek/deepseek-v4-flash", "key_env": "OPENROUTER_API_KEY"},
|
|
49
49
|
}
|
|
@@ -18,23 +18,30 @@ vars always win, so you can point at any new id without touching code. Nothing h
|
|
|
18
18
|
|
|
19
19
|
Usage:
|
|
20
20
|
python oss_review.py [--range <git range>] [--cwd <repo>] [--provider P] [--base-url U] [--model M]
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
[--allow-path GLOB ...]
|
|
22
|
+
--range e.g. origin/main..HEAD (default: the working tree — staged, unstaged AND
|
|
23
|
+
untracked non-ignored files; a range excludes untracked by design)
|
|
24
|
+
--allow-path repeatable; re-admits a file the pre-send secret filter's deny list would
|
|
25
|
+
otherwise drop (see secret_filter.py). `.caddis/config.toml`'s
|
|
26
|
+
`[review] deny_paths` / `allow_paths` are layered on the same way.
|
|
23
27
|
|
|
24
28
|
Exit codes (fail-closed):
|
|
25
29
|
0 REVIEW: CLEAN — no blocking issues
|
|
26
30
|
1 REVIEW: BLOCKING — one or more blocking issues
|
|
27
|
-
2
|
|
31
|
+
2 unused — advisory is not a review verdict
|
|
32
|
+
3 misconfigured — REVIEW_API_KEY missing, OR the pre-send secret filter refused (a
|
|
33
|
+
denylisted path / possible secret; see secret_filter.py) — actionable
|
|
34
|
+
message on stderr/stdout either way
|
|
35
|
+
4 error — no diff verdict parsed, git failure, endpoint/parse failure, or the diff
|
|
28
36
|
exceeds REVIEW_MAX_DIFF_CHARS (see below — never silently downgraded to CLEAN)
|
|
29
|
-
3 misconfigured — REVIEW_API_KEY missing (actionable message on stderr)
|
|
30
37
|
|
|
31
38
|
Diff-size ceiling: an oversized diff sent to a chat-completions endpoint has been observed, live, to
|
|
32
39
|
come back two different unsafe ways — an empty `content` field on an HTTP 200 (at least fails closed:
|
|
33
|
-
no verdict line ⇒ exit
|
|
40
|
+
no verdict line ⇒ exit 4), and, worse, a `REVIEW: CLEAN` verdict with no substantive engagement (a
|
|
34
41
|
silent false negative — the model was overwhelmed, not actually reviewing). Rather than risk the second
|
|
35
42
|
case, a diff over REVIEW_MAX_DIFF_CHARS (default 60,000 chars; override via the env var or
|
|
36
43
|
--max-diff-chars) is SPLIT INTO BATCHES on whole-file boundaries and each batch reviewed separately;
|
|
37
|
-
the verdict is CLEAN only if every batch is clean (aggregation is fail-closed). Exit
|
|
44
|
+
the verdict is CLEAN only if every batch is clean (aggregation is fail-closed). Exit 4 is the
|
|
38
45
|
narrower case: a SINGLE file larger than the ceiling, which cannot be split, or more batches than
|
|
39
46
|
MAX_REVIEW_BATCHES. Refusal was the original behaviour and is kept for those two - a silent false
|
|
40
47
|
CLEAN is the worst outcome this tool has - but it no longer fires on an ordinary large phase diff.
|
|
@@ -53,6 +60,12 @@ import urllib.error
|
|
|
53
60
|
import urllib.request
|
|
54
61
|
from pathlib import Path
|
|
55
62
|
|
|
63
|
+
# secret_filter.py ships beside this file in every export target (see
|
|
64
|
+
# .github/runtime-targets.json) — import it from THIS script's own folder, not the caller's
|
|
65
|
+
# cwd, so it resolves the same way whether this runs from the source repo or a plugin install.
|
|
66
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
67
|
+
import secret_filter # noqa: E402
|
|
68
|
+
|
|
56
69
|
# ONE SCALE ACROSS caddis (see claude-harness/README.md):
|
|
57
70
|
# 0 clean · 1 blocked · 2 advisory note · 3 could not run · 4 malformed
|
|
58
71
|
# ERROR moved 2 -> 4 on 2026-09-09. It sat on 2, which caddis_gate uses for "proceed, this is
|
|
@@ -69,7 +82,7 @@ EXIT_ERROR = 4 # ran, produced nothing usable: no verdict line, transport f
|
|
|
69
82
|
# Provider presets — the SINGLE place a renamed model id or moved endpoint is edited. Adding a new
|
|
70
83
|
# provider (Qwen, a local vLLM, …) is one new row. Callers can always bypass this via env/flags.
|
|
71
84
|
PROVIDERS: dict[str, dict[str, str]] = {
|
|
72
|
-
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-
|
|
85
|
+
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-flash"},
|
|
73
86
|
"glm": {"base_url": "https://api.z.ai/api/coding/paas/v4", "model": "glm-5.3"},
|
|
74
87
|
"openrouter": {"base_url": "https://openrouter.ai/api/v1", "model": "deepseek/deepseek-v4-flash"},
|
|
75
88
|
}
|
|
@@ -272,7 +285,8 @@ def _untracked_diff(path: str, cwd: str) -> str:
|
|
|
272
285
|
"""
|
|
273
286
|
null = "/dev/null" if os.name != "nt" else "NUL"
|
|
274
287
|
out = subprocess.run(
|
|
275
|
-
["git", "diff", "--no-
|
|
288
|
+
["git", "diff", "--no-color", "--no-ext-diff", "--src-prefix=a/",
|
|
289
|
+
"--dst-prefix=b/", "--no-index", "--", null, path],
|
|
276
290
|
cwd=cwd, capture_output=True, text=True,
|
|
277
291
|
encoding="utf-8", errors="replace", timeout=30,
|
|
278
292
|
)
|
|
@@ -295,7 +309,8 @@ def get_diff(rng: str | None, cwd: str, include_untracked: bool = True) -> str:
|
|
|
295
309
|
Untracked files are NOT included for an explicit `rng` — a commit range is a span of
|
|
296
310
|
history, and files that were never committed are correctly outside it.
|
|
297
311
|
"""
|
|
298
|
-
cmd = ["git", "diff",
|
|
312
|
+
cmd = ["git", "diff", "--no-color", "--no-ext-diff", "--src-prefix=a/",
|
|
313
|
+
"--dst-prefix=b/", rng or "HEAD"]
|
|
299
314
|
# encoding pinned to UTF-8: git emits UTF-8, but text=True would decode with the locale
|
|
300
315
|
# codepage (cp1252 on Windows) — a non-ASCII diff then kills the reader thread and
|
|
301
316
|
# subprocess hands back stdout=None with returncode 0.
|
|
@@ -483,6 +498,29 @@ def _force_utf8_stdio() -> None:
|
|
|
483
498
|
pass
|
|
484
499
|
|
|
485
500
|
|
|
501
|
+
def _config_is_changed(diff_text: str) -> bool:
|
|
502
|
+
"""True when either side of a changed file block names the review config."""
|
|
503
|
+
target = ".caddis/config.toml"
|
|
504
|
+
for block in secret_filter._split_into_file_blocks(diff_text):
|
|
505
|
+
for line in block.splitlines():
|
|
506
|
+
if line.startswith("@@"):
|
|
507
|
+
break
|
|
508
|
+
if line in (f"--- a/{target}", f"+++ b/{target}",
|
|
509
|
+
f"rename from {target}", f"rename to {target}"):
|
|
510
|
+
return True
|
|
511
|
+
if line.startswith("diff --git "):
|
|
512
|
+
rest = line[len("diff --git "):]
|
|
513
|
+
quoted = secret_filter._QUOTED_HEADER_RE.match(rest)
|
|
514
|
+
if quoted:
|
|
515
|
+
sides = (secret_filter._unescape_git_quoted(part)
|
|
516
|
+
for part in quoted.groups())
|
|
517
|
+
if any(side in (f"a/{target}", f"b/{target}") for side in sides):
|
|
518
|
+
return True
|
|
519
|
+
elif rest.startswith(f"a/{target} b/") or rest.endswith(f" b/{target}"):
|
|
520
|
+
return True
|
|
521
|
+
return False
|
|
522
|
+
|
|
523
|
+
|
|
486
524
|
def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> int:
|
|
487
525
|
_force_utf8_stdio()
|
|
488
526
|
env = os.environ if env is None else env
|
|
@@ -499,6 +537,10 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
|
|
|
499
537
|
parser.add_argument("--check-config", action="store_true",
|
|
500
538
|
help="report whether a usable key exists and exit — no diff, no LLM call. "
|
|
501
539
|
"Exit 0 = ready, 3 = nothing configured.")
|
|
540
|
+
parser.add_argument("--allow-path", dest="allow_path", action="append", default=[],
|
|
541
|
+
metavar="GLOB",
|
|
542
|
+
help="re-admit a file the secret-filter deny list would otherwise drop "
|
|
543
|
+
"(repeatable)")
|
|
502
544
|
args = parser.parse_args(argv)
|
|
503
545
|
|
|
504
546
|
# --check-config is deliberately NON-INTERACTIVE. This script runs headless — from CI, from a
|
|
@@ -519,6 +561,39 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
|
|
|
519
561
|
print(f"cross-review is ready. Providers with a key: {', '.join(available)}")
|
|
520
562
|
return EXIT_CLEAN
|
|
521
563
|
|
|
564
|
+
try:
|
|
565
|
+
diff_text = get_diff(args.range, args.cwd)
|
|
566
|
+
except Exception as exc:
|
|
567
|
+
sys.stderr.write(f"could not read diff: {exc}\n")
|
|
568
|
+
return EXIT_ERROR
|
|
569
|
+
|
|
570
|
+
# Pre-send secret filter — runs on the FULL diff, before it is ever split into batches and
|
|
571
|
+
# before any provider is contacted, and BEFORE provider/key resolution below: a missing or
|
|
572
|
+
# misconfigured REVIEW_API_KEY must never race a secret refusal and print the wrong reason.
|
|
573
|
+
# A run with no key configured at all still refuses here first, on the merits, not because
|
|
574
|
+
# it couldn't have called anyone anyway.
|
|
575
|
+
cfg_deny, cfg_allow = secret_filter.load_config(Path(args.cwd))
|
|
576
|
+
if _config_is_changed(diff_text):
|
|
577
|
+
cfg_deny = list(dict.fromkeys(cfg_deny + secret_filter.load_head_deny_paths(Path(args.cwd))))
|
|
578
|
+
cfg_allow = []
|
|
579
|
+
print("note: .caddis/config.toml is part of this change; its allow_paths are ignored")
|
|
580
|
+
filter_result = secret_filter.filter_diff(
|
|
581
|
+
diff_text,
|
|
582
|
+
secret_filter.DEFAULT_DENY_GLOBS + cfg_deny,
|
|
583
|
+
cfg_allow + list(args.allow_path),
|
|
584
|
+
)
|
|
585
|
+
if filter_result.hits:
|
|
586
|
+
print("REVIEW: REFUSED — possible secrets:")
|
|
587
|
+
for hit_path, hit_line, hit_rule in filter_result.hits:
|
|
588
|
+
print(f" {hit_path}:{hit_line} ({hit_rule})")
|
|
589
|
+
return EXIT_CONFIG
|
|
590
|
+
if filter_result.dropped_paths:
|
|
591
|
+
print(f"excluded from review: {', '.join(filter_result.dropped_paths)}")
|
|
592
|
+
if not filter_result.kept_diff.strip() and filter_result.dropped_paths:
|
|
593
|
+
print("REVIEW: REFUSED — every changed file is excluded")
|
|
594
|
+
return EXIT_CONFIG
|
|
595
|
+
diff_text = filter_result.kept_diff
|
|
596
|
+
|
|
522
597
|
try:
|
|
523
598
|
base_url, api_key, model = resolve_config(args, env)
|
|
524
599
|
primary_provider = (
|
|
@@ -528,12 +603,6 @@ def main(argv: list[str] | None = None, env: dict[str, str] | None = None) -> in
|
|
|
528
603
|
sys.stderr.write(f"{exc}\n")
|
|
529
604
|
return EXIT_CONFIG
|
|
530
605
|
|
|
531
|
-
try:
|
|
532
|
-
diff_text = get_diff(args.range, args.cwd)
|
|
533
|
-
except Exception as exc:
|
|
534
|
-
sys.stderr.write(f"could not read diff: {exc}\n")
|
|
535
|
-
return EXIT_ERROR
|
|
536
|
-
|
|
537
606
|
if not diff_text.strip():
|
|
538
607
|
# Says what was scanned, not just that nothing was found. This branch used to fire
|
|
539
608
|
# on a tree full of NEW files (they are invisible to `git diff HEAD`) and report
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pre-send secret filter for `oss_review.py`.
|
|
3
|
+
|
|
4
|
+
Everything `oss_review.py` sends to an external review vendor (DeepSeek/GLM/OpenRouter/etc.)
|
|
5
|
+
passes through `filter_diff()` first. It does two independent things, both fail-closed:
|
|
6
|
+
|
|
7
|
+
1. **Path denylist** — a whole file's diff block is dropped when its path matches a deny glob
|
|
8
|
+
(`.env`, `secrets.yaml`, `id_rsa`, a private key, ...) and no allow glob re-admits it. This
|
|
9
|
+
is silent-by-default: the file simply never reaches the model, and the caller is told which
|
|
10
|
+
paths were excluded so the drop is visible, not invisible.
|
|
11
|
+
2. **Content rules** — every hunk line of a file that was NOT dropped is scanned against
|
|
12
|
+
`SECRET_RULES` (AWS keys, GitHub/Slack tokens, JWTs, PEM private keys, `key = "..."`-shaped
|
|
13
|
+
assignments). A hit refuses the ENTIRE review (not just that file) — a secret slipping past
|
|
14
|
+
the path denylist is exactly the case this second layer exists for, and the safe response to
|
|
15
|
+
"something here looks like a credential" is "send nothing", not "guess which part is safe".
|
|
16
|
+
|
|
17
|
+
Deliberately out of scope, by design (see the plan's "Design (decided)"):
|
|
18
|
+
- Removed-line hits use old-file line numbers and are marked `removed line`.
|
|
19
|
+
- `+++`/`---` file headers never fire because they precede the first hunk.
|
|
20
|
+
- Content rules run only on files the path denylist did NOT already drop; a dropped file's
|
|
21
|
+
block is never scanned (nothing in it reaches the model either way).
|
|
22
|
+
|
|
23
|
+
Nothing here ever prints a matched secret value: hits are reported as (path, line, rule name)
|
|
24
|
+
only. Stdlib-only (`tomllib` needs Python 3.11+, matching the rest of caddis's `.github/tools/`).
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import fnmatch
|
|
29
|
+
import re
|
|
30
|
+
import subprocess
|
|
31
|
+
import tomllib
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
# ── path denylist ─────────────────────────────────────────────────────────────
|
|
36
|
+
#
|
|
37
|
+
# fnmatch's `*` matches `/` too (it is not a directory-aware glob), which cuts both ways and
|
|
38
|
+
# is the reason for the paired entries below:
|
|
39
|
+
# - "*.pem" / "*.key" / "*.pfx" / "*.p12" already match at ANY depth (root or nested) because
|
|
40
|
+
# the leading `*` freely absorbs any prefix, slashes included.
|
|
41
|
+
# - ".env" (a literal, no wildcard) matches ONLY a root ".env" — fnmatch requires an exact
|
|
42
|
+
# whole-string match when there is no wildcard, and "config/.env" is a different string.
|
|
43
|
+
# - "**/.env" matches ONLY a nested path — translated to a regex it requires a literal "/"
|
|
44
|
+
# immediately before ".env", so a root ".env" (no slash anywhere) does NOT match it.
|
|
45
|
+
# - "id_rsa*" / "secrets.*" / "keys.env" are anchored at the start of the string (no leading
|
|
46
|
+
# wildcard), so — like ".env" — they match only at the repo root; "**/id_rsa*" etc. cover
|
|
47
|
+
# the nested case.
|
|
48
|
+
# Every root-only pattern therefore needs an explicit "**/" sibling, or a nested secret file
|
|
49
|
+
# of that shape is invisible to this filter. Verified live, not assumed — see
|
|
50
|
+
# scripts/tests/test_secret_filter.py.
|
|
51
|
+
DEFAULT_DENY_GLOBS: list[str] = [
|
|
52
|
+
".env", ".env.*", "**/.env", "**/.env.*",
|
|
53
|
+
"*.pem", "*.key", "*.pfx", "*.p12",
|
|
54
|
+
"id_rsa*", "**/id_rsa*",
|
|
55
|
+
"secrets.*", "**/secrets.*",
|
|
56
|
+
"keys.env", "**/keys.env",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# `.env.example` / `.env.sample` / `.env.template` / `.env.dist` are template files with no
|
|
60
|
+
# real values in them and are conventionally committed and reviewed like any other source file.
|
|
61
|
+
# Checked BEFORE the deny glob match, so it wins even though ".env.*" would otherwise catch it.
|
|
62
|
+
ENV_EXAMPLE = re.compile(r"\.env\.(example|sample|template|dist)$", re.I)
|
|
63
|
+
|
|
64
|
+
# ── content rules ───────────────────────────────────────────────────────────
|
|
65
|
+
#
|
|
66
|
+
# Each pattern is checked against the content of each changed line in a kept file.
|
|
67
|
+
SECRET_RULES: list[tuple[str, re.Pattern]] = [
|
|
68
|
+
("private-key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
|
|
69
|
+
("aws-key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
|
|
70
|
+
("github-token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
|
|
71
|
+
("slack-token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b")),
|
|
72
|
+
("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")),
|
|
73
|
+
("assignment", re.compile(
|
|
74
|
+
r"(?i)[A-Za-z0-9_]*(password|passwd|secret|api[_-]?key|token)[A-Za-z0-9_]*\s*[:=]\s*['\"](?P<value>[^'\"\s]{8,})['\"]"
|
|
75
|
+
)),
|
|
76
|
+
("env-assignment", re.compile(
|
|
77
|
+
r"^\s*(?:export\s+)?[A-Za-z0-9_]*(?:PASSWORD|PASSWD|SECRET|API_KEY|APIKEY|TOKEN|PRIVATE_KEY)"
|
|
78
|
+
r"[A-Za-z0-9_]*\s*=\s*(?P<value>[^\s'\"#]{8,})", re.I
|
|
79
|
+
)),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_placeholder(value: str) -> bool:
|
|
84
|
+
"""Exclude obvious template values from both assignment rules."""
|
|
85
|
+
lowered = value.lower()
|
|
86
|
+
return (
|
|
87
|
+
lowered in {"your_key_here", "your-api-key", "changeme", "placeholder", "example"}
|
|
88
|
+
or bool(re.fullmatch(r"x+", lowered))
|
|
89
|
+
or bool(re.fullmatch(r"<[^>]*>", value))
|
|
90
|
+
or bool(re.fullmatch(r"\$\{[^}]*\}", value))
|
|
91
|
+
or bool(re.fullmatch(r"\$[A-Za-z_][A-Za-z0-9_]*", value))
|
|
92
|
+
or value.startswith("***")
|
|
93
|
+
or lowered.startswith(("your_", "your-"))
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class FilterResult:
|
|
99
|
+
kept_diff: str
|
|
100
|
+
dropped_paths: list[str] = field(default_factory=list)
|
|
101
|
+
hits: list[tuple[str, int, str]] = field(default_factory=list) # (path, side's line, rule/removed marker)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# A new file block starts at a line beginning "diff --git a/..." (the common, unquoted case)
|
|
105
|
+
# or "diff --git "a/..." (git double-quotes a path that needs escaping — spaces alone do NOT
|
|
106
|
+
# trigger quoting, but non-ASCII characters, embedded quotes/backslashes, and control
|
|
107
|
+
# characters do). Matched at line start only, never mid-line.
|
|
108
|
+
_BLOCK_START_RE = re.compile(r'^diff --git (?:"|a/)')
|
|
109
|
+
|
|
110
|
+
# The common (non-rename) case: "diff --git a/<P> b/<P>" with the OLD and NEW path identical.
|
|
111
|
+
# A backreference handles a path containing spaces correctly (the naive "split on ' b/'"
|
|
112
|
+
# approach is ambiguous when the path itself contains " b/"-shaped substrings and can't tell
|
|
113
|
+
# unquoted-with-a-space apart from a genuine rename without this).
|
|
114
|
+
_IDENTICAL_HEADER_RE = re.compile(r'^a/(.+) b/\1$')
|
|
115
|
+
|
|
116
|
+
# Both sides double-quoted (git always quotes both together, never just one).
|
|
117
|
+
_QUOTED_HEADER_RE = re.compile(r'^"((?:[^"\\]|\\.)*)" "((?:[^"\\]|\\.)*)"$')
|
|
118
|
+
|
|
119
|
+
_HUNK_RE = re.compile(r'^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@')
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _unescape_git_quoted(s: str) -> str:
|
|
123
|
+
"""Undo git's C-style quoting: \\", \\\\, \\t, \\n, and \\NNN octal byte escapes.
|
|
124
|
+
|
|
125
|
+
git quotes a path by escaping each byte that needs it; multi-byte (UTF-8) characters come
|
|
126
|
+
out as a run of \\NNN octal escapes, one per byte, so the escapes are collected as raw
|
|
127
|
+
bytes and decoded as UTF-8 at the end rather than character-by-character.
|
|
128
|
+
"""
|
|
129
|
+
out = bytearray()
|
|
130
|
+
i, n = 0, len(s)
|
|
131
|
+
while i < n:
|
|
132
|
+
c = s[i]
|
|
133
|
+
if c == "\\" and i + 1 < n:
|
|
134
|
+
nxt = s[i + 1]
|
|
135
|
+
if nxt in ('"', "\\"):
|
|
136
|
+
out.append(ord(nxt))
|
|
137
|
+
i += 2
|
|
138
|
+
elif nxt == "n":
|
|
139
|
+
out.append(10)
|
|
140
|
+
i += 2
|
|
141
|
+
elif nxt == "t":
|
|
142
|
+
out.append(9)
|
|
143
|
+
i += 2
|
|
144
|
+
elif "0" <= nxt <= "7":
|
|
145
|
+
j, digits = i + 1, 0
|
|
146
|
+
val = 0
|
|
147
|
+
while j < n and digits < 3 and "0" <= s[j] <= "7":
|
|
148
|
+
val = val * 8 + int(s[j])
|
|
149
|
+
j += 1
|
|
150
|
+
digits += 1
|
|
151
|
+
out.append(val & 0xFF)
|
|
152
|
+
i = j
|
|
153
|
+
else:
|
|
154
|
+
out.append(ord(nxt))
|
|
155
|
+
i += 2
|
|
156
|
+
else:
|
|
157
|
+
out.extend(c.encode("utf-8", "surrogatepass"))
|
|
158
|
+
i += 1
|
|
159
|
+
return out.decode("utf-8", "replace")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _extract_paths(header_line: str) -> tuple[str | None, str | None]:
|
|
163
|
+
"""Return old and new paths from a git diff header."""
|
|
164
|
+
line = header_line.rstrip("\r\n")
|
|
165
|
+
if not line.startswith("diff --git "):
|
|
166
|
+
return (None, None)
|
|
167
|
+
rest = line[len("diff --git "):]
|
|
168
|
+
m = _QUOTED_HEADER_RE.match(rest)
|
|
169
|
+
if m:
|
|
170
|
+
a_path, b_path = (_unescape_git_quoted(part) for part in m.groups())
|
|
171
|
+
return (a_path[2:] if a_path.startswith("a/") else a_path,
|
|
172
|
+
b_path[2:] if b_path.startswith("b/") else b_path)
|
|
173
|
+
m = _IDENTICAL_HEADER_RE.match(rest)
|
|
174
|
+
if m:
|
|
175
|
+
return (m.group(1), m.group(1))
|
|
176
|
+
# Rename/copy to a genuinely different, unquoted path: paths with spaces are ambiguous
|
|
177
|
+
# here in principle, but git does not quote for a space alone, so this is best-effort for
|
|
178
|
+
# the common no-space rename case (e.g. "diff --git a/x b/.env").
|
|
179
|
+
idx = rest.rfind(" b/")
|
|
180
|
+
if idx != -1:
|
|
181
|
+
return (rest[2:idx], rest[idx + 3:])
|
|
182
|
+
return (None, None)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _extract_new_path(header_line: str) -> str | None:
|
|
186
|
+
return _extract_paths(header_line)[1]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _split_into_file_blocks(diff_text: str) -> list[str]:
|
|
190
|
+
if not diff_text:
|
|
191
|
+
return []
|
|
192
|
+
blocks: list[str] = []
|
|
193
|
+
current: list[str] = []
|
|
194
|
+
for line in diff_text.splitlines(keepends=True):
|
|
195
|
+
if _BLOCK_START_RE.match(line) and current:
|
|
196
|
+
blocks.append("".join(current))
|
|
197
|
+
current = [line]
|
|
198
|
+
else:
|
|
199
|
+
current.append(line)
|
|
200
|
+
if current:
|
|
201
|
+
blocks.append("".join(current))
|
|
202
|
+
return blocks
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _is_denied(path: str, deny_globs: list[str], allow_globs: list[str]) -> bool:
|
|
206
|
+
if ENV_EXAMPLE.search(path):
|
|
207
|
+
return False
|
|
208
|
+
# fnmatchcase, not fnmatch: fnmatch() runs os.path.normcase() first, which on Windows
|
|
209
|
+
# lowercases AND rewrites "/" to "\\" — silently breaking every POSIX-style glob above.
|
|
210
|
+
# fnmatchcase never touches the string, so matching is identical on every OS.
|
|
211
|
+
if not any(fnmatch.fnmatchcase(path, glob) for glob in deny_globs):
|
|
212
|
+
return False
|
|
213
|
+
return not any(fnmatch.fnmatchcase(path, glob) for glob in allow_globs)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _scan_added_lines(block: str, path: str) -> list[tuple[str, int, str]]:
|
|
217
|
+
"""Scan all hunk lines; removed hits use old-file line numbers."""
|
|
218
|
+
hits: list[tuple[str, int, str]] = []
|
|
219
|
+
new_line: int | None = None
|
|
220
|
+
old_line: int | None = None
|
|
221
|
+
for line in block.splitlines():
|
|
222
|
+
m = _HUNK_RE.match(line)
|
|
223
|
+
if m:
|
|
224
|
+
old_line, new_line = int(m.group(1)), int(m.group(2))
|
|
225
|
+
continue
|
|
226
|
+
if new_line is None:
|
|
227
|
+
continue # still in the file header (index/mode/---/+++ lines), before any hunk
|
|
228
|
+
if line.startswith("\\"):
|
|
229
|
+
continue # "" — not a line, does not advance the count
|
|
230
|
+
if line.startswith(("+", "-", " ")):
|
|
231
|
+
content = line[1:]
|
|
232
|
+
removed = line.startswith("-")
|
|
233
|
+
context = line.startswith(" ")
|
|
234
|
+
for rule_name, pattern in SECRET_RULES:
|
|
235
|
+
match = pattern.search(content)
|
|
236
|
+
if match and not (rule_name in {"assignment", "env-assignment"}
|
|
237
|
+
and _is_placeholder(match.group("value"))) and not (
|
|
238
|
+
rule_name == "env-assignment" and
|
|
239
|
+
re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+",
|
|
240
|
+
match.group("value"))):
|
|
241
|
+
marker = (f"{rule_name}, removed line" if removed else
|
|
242
|
+
f"{rule_name}, context line" if context else rule_name)
|
|
243
|
+
hits.append((path, old_line if removed else new_line, marker))
|
|
244
|
+
if removed:
|
|
245
|
+
old_line += 1
|
|
246
|
+
elif not context:
|
|
247
|
+
new_line += 1
|
|
248
|
+
else:
|
|
249
|
+
old_line += 1
|
|
250
|
+
new_line += 1
|
|
251
|
+
else:
|
|
252
|
+
old_line += 1
|
|
253
|
+
new_line += 1 # context line exists in both files
|
|
254
|
+
return hits
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def filter_diff(diff_text: str, deny_globs: list[str], allow_globs: list[str]) -> FilterResult:
|
|
258
|
+
"""Drop denylisted-path file blocks, then scan changed lines in kept blocks."""
|
|
259
|
+
dropped_paths: list[str] = []
|
|
260
|
+
hits: list[tuple[str, int, str]] = []
|
|
261
|
+
kept_parts: list[str] = []
|
|
262
|
+
for block in _split_into_file_blocks(diff_text):
|
|
263
|
+
first_line = block.splitlines()[0] if block else ""
|
|
264
|
+
old_path, new_path = _extract_paths(first_line)
|
|
265
|
+
path = new_path or old_path or "(unparseable path)"
|
|
266
|
+
denied = next((candidate for candidate in (old_path, new_path)
|
|
267
|
+
if candidate and _is_denied(candidate, deny_globs, allow_globs)), None)
|
|
268
|
+
if denied:
|
|
269
|
+
dropped_paths.append(denied)
|
|
270
|
+
continue
|
|
271
|
+
hits.extend(_scan_added_lines(block, path))
|
|
272
|
+
kept_parts.append(block)
|
|
273
|
+
return FilterResult(kept_diff="".join(kept_parts), dropped_paths=dropped_paths, hits=hits)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def load_config(repo: Path) -> tuple[list[str], list[str]]:
|
|
277
|
+
"""`[review] deny_paths` / `allow_paths` from `<repo>/.caddis/config.toml`.
|
|
278
|
+
|
|
279
|
+
A missing file, a missing `[review]` table, or a malformed TOML file all yield `([], [])`
|
|
280
|
+
— this is additive configuration layered onto DEFAULT_DENY_GLOBS, never the sole source of
|
|
281
|
+
truth, so failing to read it must never silently widen what gets sent.
|
|
282
|
+
"""
|
|
283
|
+
cfg_path = Path(repo) / ".caddis" / "config.toml"
|
|
284
|
+
if not cfg_path.is_file():
|
|
285
|
+
return ([], [])
|
|
286
|
+
try:
|
|
287
|
+
with cfg_path.open("rb") as f:
|
|
288
|
+
return _parse_config(f.read())
|
|
289
|
+
except (tomllib.TOMLDecodeError, OSError, UnicodeError):
|
|
290
|
+
return ([], [])
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _parse_config(contents: bytes) -> tuple[list[str], list[str]]:
|
|
294
|
+
data = tomllib.loads(contents.decode("utf-8"))
|
|
295
|
+
review = data.get("review") if isinstance(data, dict) else None
|
|
296
|
+
if not isinstance(review, dict):
|
|
297
|
+
return ([], [])
|
|
298
|
+
deny = review.get("deny_paths", [])
|
|
299
|
+
allow = review.get("allow_paths", [])
|
|
300
|
+
deny_out = [str(p) for p in deny] if isinstance(deny, list) else []
|
|
301
|
+
allow_out = [str(p) for p in allow] if isinstance(allow, list) else []
|
|
302
|
+
return (deny_out, allow_out)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def load_head_deny_paths(repo: Path) -> list[str]:
|
|
306
|
+
"""Read the committed deny list; a missing or unreadable HEAD config contributes nothing."""
|
|
307
|
+
try:
|
|
308
|
+
out = subprocess.run(
|
|
309
|
+
["git", "show", "HEAD:.caddis/config.toml"], cwd=repo,
|
|
310
|
+
capture_output=True, timeout=30,
|
|
311
|
+
)
|
|
312
|
+
if out.returncode == 0:
|
|
313
|
+
return _parse_config(out.stdout)[0]
|
|
314
|
+
except (OSError, subprocess.TimeoutExpired, UnicodeError, tomllib.TOMLDecodeError):
|
|
315
|
+
pass
|
|
316
|
+
return []
|
|
@@ -291,6 +291,10 @@ def merge_settings(target: Path, stack: dict, dry: bool) -> str:
|
|
|
291
291
|
if a not in ex_allow:
|
|
292
292
|
ex_allow.append(a)
|
|
293
293
|
existing.setdefault("permissions", {})["allow"] = ex_allow
|
|
294
|
+
ex_deny = existing["permissions"].setdefault("deny", [])
|
|
295
|
+
for rule in base["permissions"]["deny"]:
|
|
296
|
+
if rule not in ex_deny:
|
|
297
|
+
ex_deny.append(rule)
|
|
294
298
|
# Self-heal: prune legacy harness-injected `ask` rules that override bypassPermissions.
|
|
295
299
|
ex_ask = existing["permissions"].get("ask", [])
|
|
296
300
|
pruned_ask = [a for a in ex_ask if a not in _LEGACY_HARNESS_ASK]
|
|
@@ -308,7 +312,7 @@ def merge_settings(target: Path, stack: dict, dry: bool) -> str:
|
|
|
308
312
|
else:
|
|
309
313
|
base["permissions"]["allow"] = allow
|
|
310
314
|
# No statusLine here on purpose: it is user-scope now, installed once per machine
|
|
311
|
-
# by `/caddis:statusline`. A per-project copy is exactly how four status-line
|
|
315
|
+
# by `/caddis:install-statusline`. A per-project copy is exactly how four status-line
|
|
312
316
|
# scripts came to exist and drift apart.
|
|
313
317
|
out = base
|
|
314
318
|
verb = "write"
|
|
@@ -799,7 +803,7 @@ items?" has no answer when one state has three spellings.
|
|
|
799
803
|
| Not this | Where it goes | Why |
|
|
800
804
|
|---|---|---|
|
|
801
805
|
| Work in flight right now | `.caddis/plans/<feature>.md` | A plan is being executed. A parking-lot item is not. |
|
|
802
|
-
| A task interrupted mid-session | `/caddis:digress` stack | That is a pause, not a backlog item. `/caddis:
|
|
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 |
|