@caddis/cli 0.4.66 → 0.4.68
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/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_created.py +191 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +11 -3
- package/bundles/antigravity-plugin/scripts/caddis_fleet.py +110 -5
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +4 -0
- package/bundles/antigravity-plugin/scripts/caddis_own_cleanup.py +424 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +3 -0
- package/bundles/antigravity-plugin/scripts/caddis_temp_prune.py +376 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +32 -5
- package/bundles/antigravity-plugin/scripts/caddis_worktree_root.py +26 -3
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +45 -10
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +7 -1
- package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +7 -1
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Expire old scratchpads and caddis test leftovers in the temp folder (tidy v2, Phase 3).
|
|
2
|
+
|
|
3
|
+
`caddis_tidy.py --prune --temp` calls `prune_temp`. Dry run is the default: it only lists. With `apply` it deletes.
|
|
4
|
+
Two groups, each listed with its size and age:
|
|
5
|
+
|
|
6
|
+
1. Scratch sessions: `<temp>/claude/<project>/<id>/` (Claude Code's session scratchpads and task logs).
|
|
7
|
+
2. Test leftovers: folders directly in the temp folder whose name starts with a prefix caddis itself makes.
|
|
8
|
+
|
|
9
|
+
A folder is listed only when ALL hold (anything in doubt is KEPT, and said so):
|
|
10
|
+
- the newest mtime of the folder and of everything in it is older than the age limit (7 days);
|
|
11
|
+
- scratch only: no transcript `<claude config>/projects/*/<id>.jsonl` changed within the limit, the id is not one of
|
|
12
|
+
the running session's ids, and the process cwd is not inside it;
|
|
13
|
+
- it is not a link and holds no link at any depth (kept, with the link and its target named; links are never
|
|
14
|
+
followed and never removed here);
|
|
15
|
+
- it holds no `.git` (a repo or a worktree): kept, with a note;
|
|
16
|
+
- the scan could read every folder.
|
|
17
|
+
A folder with something newer than the limit inside is not listed and not mentioned: it is live or recent.
|
|
18
|
+
|
|
19
|
+
Apply, per folder: scan again, rename it to `<name>.caddis-trash-<stamp>` in the same parent (atomic; it fails when a
|
|
20
|
+
process holds a file in it, and the folder is then KEPT as "in use"), then `caddis_worktree_safety.remove_tree` on the
|
|
21
|
+
renamed folder (links first). If that fails part-way the renamed path is reported as an error; there is no retry.
|
|
22
|
+
An emptied `<project>` folder is removed with `os.rmdir` (only an empty one can go).
|
|
23
|
+
|
|
24
|
+
prune_temp(apply=False, temp_root=None, claude_config=None, now=None, env_session_ids=None, cwd=None) -> TempReport
|
|
25
|
+
format_report(report, apply) -> list of printable lines
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import tempfile
|
|
32
|
+
import time
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
import caddis_worktree_safety as safety
|
|
37
|
+
|
|
38
|
+
MAX_AGE_S = 7 * 86_400
|
|
39
|
+
TRASH_MARK = ".caddis-trash-"
|
|
40
|
+
# The running session's id, from the environment (as caddis_created.SESSION_ENV_VARS).
|
|
41
|
+
SESSION_ENV_VARS = ("CADDIS_SESSION_ID", "CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")
|
|
42
|
+
|
|
43
|
+
# Only folders whose name starts with one of these are test leftovers. Each prefix maps to the code that makes it.
|
|
44
|
+
# Never add another tool's prefix (playwright* and the like). A new caddis prefix goes here with its maker.
|
|
45
|
+
CADDIS_TEMP_PREFIXES = {
|
|
46
|
+
"caddis-roadmap-": "scripts/caddis_roadmap.py mkdtemp (also caddis-roadmap-check-)",
|
|
47
|
+
"caddis-docs-pr-": "scripts/caddis_docs_pr.py DIR_PREFIX (temp worktree of a docs PR)",
|
|
48
|
+
"caddis-review-": "scripts/caddis_review.py _add_worktree (temp review worktree)",
|
|
49
|
+
"caddis-relay-index-": "scripts/caddis_relay_git.py TemporaryDirectory",
|
|
50
|
+
"caddis-init-": "scripts/claudster_init.py TemporaryDirectory",
|
|
51
|
+
"caddis-fresh-": "scripts/fresh_install_test.ps1 probe folder",
|
|
52
|
+
"caddis-rev-": "scripts/fresh_install_test.ps1 probe folder",
|
|
53
|
+
"caddis-eval-": "evals/run_evals.py mkdtemp",
|
|
54
|
+
"caddis-agy-": "cli/src/agents/agy.ts stageForAgy, cli/test/agy.test.ts",
|
|
55
|
+
"caddis-codex-": "cli/test/codex.test.ts mkdtempSync",
|
|
56
|
+
"caddis-doctor-home-": "cli/test/doctor.test.ts mkdtempSync",
|
|
57
|
+
"caddis-doctor-sessions-": "cli/test/doctor.test.ts mkdtempSync",
|
|
58
|
+
"caddis-keys-": "cli/test/providers.test.ts mkdtempSync",
|
|
59
|
+
"caddis-sessions-": "cli/test/sessions.test.ts mkdtempSync",
|
|
60
|
+
"caddis-stub-": "cli/test/stub.test.ts mkdtempSync",
|
|
61
|
+
}
|
|
62
|
+
# A prefix above that can also match a folder that must stay: kept, with this reason.
|
|
63
|
+
KEEP_PREFIXES = {
|
|
64
|
+
"caddis-agy-backup-": "cli/src/agents/agy.ts keeps a failed install's backup here: it may be the only whole "
|
|
65
|
+
"copy of the agy plugin",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_walk = os.walk # a seam for tests
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Found:
|
|
73
|
+
path: Path
|
|
74
|
+
size: int
|
|
75
|
+
age_days: int
|
|
76
|
+
kind: str # "scratch" | "leftover"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Kept:
|
|
81
|
+
path: Path
|
|
82
|
+
code: str # link | git | unreadable | in-use | changed | backup | config
|
|
83
|
+
why: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class TempReport:
|
|
88
|
+
temp_root: Path
|
|
89
|
+
scratch: list[Found] = field(default_factory=list)
|
|
90
|
+
leftovers: list[Found] = field(default_factory=list)
|
|
91
|
+
kept: list[Kept] = field(default_factory=list)
|
|
92
|
+
removed: list[Found] = field(default_factory=list)
|
|
93
|
+
errors: list[str] = field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _env_session_ids() -> set[str]:
|
|
97
|
+
return {v.strip() for v in (os.environ.get(name) for name in SESSION_ENV_VARS) if v and v.strip()}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _same_or_inside(inner: Path | str, outer: Path | str) -> bool:
|
|
101
|
+
a = os.path.normcase(os.path.realpath(inner))
|
|
102
|
+
b = os.path.normcase(os.path.realpath(outer))
|
|
103
|
+
return a == b or a.startswith(b.rstrip("\\/") + os.sep)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _link_target(path: str) -> str:
|
|
107
|
+
try:
|
|
108
|
+
return os.readlink(path)
|
|
109
|
+
except OSError:
|
|
110
|
+
return "?"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _inspect(path: Path, cutoff: float) -> tuple[str, float, int, str, str]:
|
|
114
|
+
"""(state, newest mtime, size, code, why). state: "young" (something inside is newer than `cutoff`),
|
|
115
|
+
"kept" (a link, a git folder or an unreadable folder: `code` and `why` say which) or "old". Links are never
|
|
116
|
+
entered: each one is found by lstat, because os.walk does not see a Windows junction as a link."""
|
|
117
|
+
try:
|
|
118
|
+
top = os.lstat(path)
|
|
119
|
+
except OSError as exc:
|
|
120
|
+
return "kept", 0.0, 0, "unreadable", f"cannot inspect {path}: {exc}"
|
|
121
|
+
if safety.is_link(top):
|
|
122
|
+
return "kept", top.st_mtime, 0, "link", f"is a link -> {_link_target(str(path))}"
|
|
123
|
+
newest = top.st_mtime
|
|
124
|
+
size = 0
|
|
125
|
+
problem: tuple[str, str] | None = None
|
|
126
|
+
if newest >= cutoff:
|
|
127
|
+
return "young", newest, size, "", ""
|
|
128
|
+
|
|
129
|
+
def fail(exc: OSError) -> None:
|
|
130
|
+
nonlocal problem
|
|
131
|
+
problem = problem or ("unreadable", f"unreadable folder {getattr(exc, 'filename', None) or path}: {exc}")
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
for here, dirs, files in _walk(path, topdown=True, onerror=fail, followlinks=False):
|
|
135
|
+
descend = []
|
|
136
|
+
for name in dirs:
|
|
137
|
+
full = os.path.join(here, name)
|
|
138
|
+
st = os.lstat(full)
|
|
139
|
+
newest = max(newest, st.st_mtime)
|
|
140
|
+
if safety.is_link(st):
|
|
141
|
+
problem = problem or ("link", f"holds a link {full} -> {_link_target(full)}")
|
|
142
|
+
elif name == ".git":
|
|
143
|
+
problem = problem or ("git", f"holds a git repo at {here}")
|
|
144
|
+
else:
|
|
145
|
+
descend.append(name)
|
|
146
|
+
dirs[:] = descend
|
|
147
|
+
for name in files:
|
|
148
|
+
full = os.path.join(here, name)
|
|
149
|
+
st = os.lstat(full)
|
|
150
|
+
newest = max(newest, st.st_mtime)
|
|
151
|
+
size += st.st_size
|
|
152
|
+
if safety.is_link(st):
|
|
153
|
+
problem = problem or ("link", f"holds a link {full} -> {_link_target(full)}")
|
|
154
|
+
elif name == ".git":
|
|
155
|
+
problem = problem or ("git", f"holds a git worktree at {here} (.git file)")
|
|
156
|
+
if newest >= cutoff:
|
|
157
|
+
return "young", newest, size, "", ""
|
|
158
|
+
except OSError as exc:
|
|
159
|
+
problem = problem or ("unreadable", f"cannot scan {path}: {exc}")
|
|
160
|
+
if problem:
|
|
161
|
+
return "kept", newest, size, problem[0], problem[1]
|
|
162
|
+
return "old", newest, size, "", ""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _transcript_times(config: Path) -> dict[str, float] | None:
|
|
166
|
+
"""The newest mtime of every `<config>/projects/*/<id>.jsonl`, by id. None when any folder cannot be read:
|
|
167
|
+
then nobody can say which sessions are live."""
|
|
168
|
+
newest: dict[str, float] = {}
|
|
169
|
+
try:
|
|
170
|
+
with os.scandir(config / "projects") as projects:
|
|
171
|
+
for project in projects:
|
|
172
|
+
if not project.is_dir():
|
|
173
|
+
continue
|
|
174
|
+
with os.scandir(project.path) as entries:
|
|
175
|
+
for entry in entries:
|
|
176
|
+
if entry.name.endswith(".jsonl") and entry.is_file():
|
|
177
|
+
stem = entry.name[:-len(".jsonl")]
|
|
178
|
+
newest[stem] = max(newest.get(stem, 0.0), entry.stat().st_mtime)
|
|
179
|
+
except OSError:
|
|
180
|
+
return None
|
|
181
|
+
return newest
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _subfolders(folder: Path, cutoff: float, report: TempReport) -> list[Path]:
|
|
185
|
+
"""Real sub-folders of `folder`. An old link is noted and skipped; a young one is silent."""
|
|
186
|
+
found = []
|
|
187
|
+
try:
|
|
188
|
+
with os.scandir(folder) as entries:
|
|
189
|
+
for entry in sorted(entries, key=lambda e: e.name):
|
|
190
|
+
path = Path(entry.path)
|
|
191
|
+
st = os.lstat(path)
|
|
192
|
+
if safety.is_link(st):
|
|
193
|
+
if st.st_mtime < cutoff:
|
|
194
|
+
report.kept.append(Kept(path, "link", f"is a link -> {_link_target(str(path))}; never followed"))
|
|
195
|
+
elif entry.is_dir(follow_symlinks=False):
|
|
196
|
+
found.append(path)
|
|
197
|
+
except OSError as exc:
|
|
198
|
+
report.kept.append(Kept(folder, "unreadable", f"cannot list {folder}: {exc}"))
|
|
199
|
+
return found
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _consider(report: TempReport, path: Path, kind: str, cutoff: float, now: float, cwd: Path | None) -> None:
|
|
203
|
+
if cwd is not None and _same_or_inside(cwd, path):
|
|
204
|
+
return # the process works here
|
|
205
|
+
state, newest, size, code, why = _inspect(path, cutoff)
|
|
206
|
+
if state == "young":
|
|
207
|
+
return
|
|
208
|
+
if state == "kept":
|
|
209
|
+
report.kept.append(Kept(path, code, why))
|
|
210
|
+
return
|
|
211
|
+
lowered = path.name.lower()
|
|
212
|
+
if kind == "leftover" and any(lowered.startswith(p) for p in KEEP_PREFIXES):
|
|
213
|
+
reason = next(r for p, r in KEEP_PREFIXES.items() if lowered.startswith(p))
|
|
214
|
+
report.kept.append(Kept(path, "backup", reason))
|
|
215
|
+
return
|
|
216
|
+
found = Found(path, size, int((now - newest) // 86_400), kind)
|
|
217
|
+
(report.scratch if kind == "scratch" else report.leftovers).append(found)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _scan_scratch(report: TempReport, config: Path, env_ids: set[str], cwd: Path | None, cutoff: float,
|
|
221
|
+
now: float) -> None:
|
|
222
|
+
base = report.temp_root / "claude"
|
|
223
|
+
try:
|
|
224
|
+
st = os.lstat(base)
|
|
225
|
+
except FileNotFoundError:
|
|
226
|
+
return
|
|
227
|
+
except OSError as exc:
|
|
228
|
+
report.kept.append(Kept(base, "unreadable", f"cannot inspect {base}: {exc}"))
|
|
229
|
+
return
|
|
230
|
+
if safety.is_link(st):
|
|
231
|
+
report.kept.append(Kept(base, "link", "is a link; scratch sessions were not scanned"))
|
|
232
|
+
return
|
|
233
|
+
transcripts = _transcript_times(config)
|
|
234
|
+
if transcripts is None:
|
|
235
|
+
report.kept.append(Kept(base, "config", f"cannot read the transcript folders under {config / 'projects'}, "
|
|
236
|
+
"so no scratch session can be called ended; none was scanned"))
|
|
237
|
+
return
|
|
238
|
+
for project in _subfolders(base, cutoff, report):
|
|
239
|
+
for session in _subfolders(project, cutoff, report):
|
|
240
|
+
if session.name in env_ids or transcripts.get(session.name, 0.0) >= cutoff:
|
|
241
|
+
continue # a live session
|
|
242
|
+
_consider(report, session, "scratch", cutoff, now, cwd)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _scan_leftovers(report: TempReport, cwd: Path | None, cutoff: float, now: float) -> None:
|
|
246
|
+
prefixes = tuple(CADDIS_TEMP_PREFIXES)
|
|
247
|
+
try:
|
|
248
|
+
with os.scandir(report.temp_root) as entries:
|
|
249
|
+
names = sorted(e.name for e in entries if e.name.lower().startswith(prefixes))
|
|
250
|
+
except OSError as exc:
|
|
251
|
+
report.errors.append(f"cannot list the temp folder {report.temp_root}: {exc}")
|
|
252
|
+
return
|
|
253
|
+
for name in names:
|
|
254
|
+
path = report.temp_root / name
|
|
255
|
+
try:
|
|
256
|
+
st = os.lstat(path)
|
|
257
|
+
except OSError:
|
|
258
|
+
continue
|
|
259
|
+
if safety.is_link(st):
|
|
260
|
+
if st.st_mtime < cutoff:
|
|
261
|
+
report.kept.append(Kept(path, "link", f"is a link -> {_link_target(str(path))}; never followed"))
|
|
262
|
+
continue
|
|
263
|
+
if os.path.isdir(path):
|
|
264
|
+
_consider(report, path, "leftover", cutoff, now, cwd)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _remove(report: TempReport, found: Found, cutoff: float, stamp: str) -> None:
|
|
268
|
+
path = found.path
|
|
269
|
+
state = _inspect(path, cutoff)[0]
|
|
270
|
+
if state != "old":
|
|
271
|
+
report.kept.append(Kept(path, "changed", "changed since the scan (or is no longer plain old files); kept"))
|
|
272
|
+
return
|
|
273
|
+
trash = path.with_name(f"{path.name}{TRASH_MARK}{stamp}")
|
|
274
|
+
n = 1
|
|
275
|
+
while os.path.lexists(trash):
|
|
276
|
+
n += 1
|
|
277
|
+
trash = path.with_name(f"{path.name}{TRASH_MARK}{stamp}-{n}")
|
|
278
|
+
try:
|
|
279
|
+
os.rename(path, trash) # atomic; fails cleanly when a process holds a file inside
|
|
280
|
+
except OSError as exc:
|
|
281
|
+
report.kept.append(Kept(path, "in-use", f"in use: {exc}"))
|
|
282
|
+
return
|
|
283
|
+
problem = safety.remove_tree(trash)
|
|
284
|
+
if problem:
|
|
285
|
+
report.errors.append(f"partly deleted; what is left is at {trash}: {problem}")
|
|
286
|
+
return
|
|
287
|
+
report.removed.append(found)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def prune_temp(*, apply: bool = False, temp_root: Path | str | None = None, claude_config: Path | str | None = None,
|
|
291
|
+
now: float | None = None, env_session_ids: set[str] | None = None, cwd: Path | str | None = None,
|
|
292
|
+
max_age_s: int = MAX_AGE_S) -> TempReport:
|
|
293
|
+
"""List (or, with `apply`, delete) old scratch sessions and caddis test leftovers. Never raises for a folder
|
|
294
|
+
it cannot read: that folder is kept and noted."""
|
|
295
|
+
now = time.time() if now is None else now
|
|
296
|
+
cutoff = now - max_age_s
|
|
297
|
+
root = Path(temp_root) if temp_root is not None else Path(tempfile.gettempdir())
|
|
298
|
+
if claude_config is not None:
|
|
299
|
+
config = Path(claude_config)
|
|
300
|
+
else:
|
|
301
|
+
config = Path(os.environ.get("CLAUDE_CONFIG_DIR") or (Path.home() / ".claude"))
|
|
302
|
+
env_ids = _env_session_ids() if env_session_ids is None else set(env_session_ids)
|
|
303
|
+
if cwd is not None:
|
|
304
|
+
here: Path | None = Path(cwd)
|
|
305
|
+
else:
|
|
306
|
+
try:
|
|
307
|
+
here = Path(os.getcwd())
|
|
308
|
+
except OSError:
|
|
309
|
+
here = None
|
|
310
|
+
report = TempReport(temp_root=root)
|
|
311
|
+
_scan_scratch(report, config, env_ids, here, cutoff, now)
|
|
312
|
+
_scan_leftovers(report, here, cutoff, now)
|
|
313
|
+
if apply:
|
|
314
|
+
stamp = time.strftime("%Y%m%d%H%M%S", time.gmtime(now))
|
|
315
|
+
for found in report.scratch + report.leftovers:
|
|
316
|
+
_remove(report, found, cutoff, stamp)
|
|
317
|
+
for parent in sorted({f.path.parent for f in report.removed if f.kind == "scratch"}):
|
|
318
|
+
try:
|
|
319
|
+
os.rmdir(parent) # only an empty project folder goes
|
|
320
|
+
except OSError:
|
|
321
|
+
pass
|
|
322
|
+
return report
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _human(n: int) -> str:
|
|
326
|
+
size = float(n)
|
|
327
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
328
|
+
if size < 1024 or unit == "GB":
|
|
329
|
+
return f"{int(size)} B" if unit == "B" else f"{size:.1f} {unit}"
|
|
330
|
+
size /= 1024
|
|
331
|
+
return f"{n} B"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
COMPACT_LISTED = 10 # entries shown per group when not verbose (the largest)
|
|
335
|
+
COMPACT_EXAMPLES = 5 # example paths shown per kept reason when not verbose
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def format_report(report: TempReport, apply: bool, verbose: bool = False) -> list[str]:
|
|
339
|
+
"""The lines `caddis_tidy.py --prune --temp` prints. Compact by default, in list and apply mode alike: per group
|
|
340
|
+
one total line (count, size, age range) and the 10 largest folders; kept folders grouped by reason with a count
|
|
341
|
+
and 5 example paths. `verbose` prints every folder and every kept note."""
|
|
342
|
+
gone = {f.path for f in report.removed}
|
|
343
|
+
lines = [f" temp folder: {report.temp_root}"]
|
|
344
|
+
for title, group in (("scratch sessions", report.scratch), ("test leftovers", report.leftovers)):
|
|
345
|
+
total = f"{len(group)} folder(s), {_human(sum(f.size for f in group))}"
|
|
346
|
+
if group:
|
|
347
|
+
ages = [f.age_days for f in group]
|
|
348
|
+
total += f", {min(ages)}-{max(ages)} days old"
|
|
349
|
+
lines.append(f" {title}: {total}" + (" (ended sessions)" if title == "scratch sessions" else ""))
|
|
350
|
+
shown = sorted(group, key=lambda f: str(f.path)) if verbose else sorted(group, key=lambda f: (-f.size, str(f.path)))[:COMPACT_LISTED]
|
|
351
|
+
for f in shown:
|
|
352
|
+
verb = ("deleted" if f.path in gone else "not deleted") if apply else "would delete"
|
|
353
|
+
lines.append(f" {verb}: {f.path} ({_human(f.size)}, {f.age_days} days)")
|
|
354
|
+
if len(group) > len(shown):
|
|
355
|
+
lines.append(f" ... {len(group) - len(shown)} more (--verbose lists all)")
|
|
356
|
+
if apply:
|
|
357
|
+
lines.append(f" deleted: {len(report.removed)} folder(s), {_human(sum(f.size for f in report.removed))}")
|
|
358
|
+
if report.kept:
|
|
359
|
+
by_code: dict[str, list[Kept]] = {}
|
|
360
|
+
for k in report.kept:
|
|
361
|
+
by_code.setdefault(k.code, []).append(k)
|
|
362
|
+
lines.append(f" kept: {len(report.kept)} folder(s)")
|
|
363
|
+
for code, items in sorted(by_code.items()):
|
|
364
|
+
lines.append(f" kept ({code}): {len(items)} folder(s)")
|
|
365
|
+
shown_kept = items if verbose else items[:COMPACT_EXAMPLES]
|
|
366
|
+
for k in shown_kept:
|
|
367
|
+
lines.append(f" {k.path}: {k.why}")
|
|
368
|
+
if len(items) > len(shown_kept):
|
|
369
|
+
lines.append(f" ... {len(items) - len(shown_kept)} more (--verbose lists all)")
|
|
370
|
+
for message in report.errors:
|
|
371
|
+
lines.append(f" ERROR: {message}")
|
|
372
|
+
if not apply and (report.scratch or report.leftovers):
|
|
373
|
+
lines.append(" Run `caddis_tidy.py --prune --temp --apply` to delete these. That is the user's choice.")
|
|
374
|
+
elif not (report.scratch or report.leftovers or report.kept or report.errors):
|
|
375
|
+
lines.append(" nothing to prune in the temp folder.")
|
|
376
|
+
return lines
|
|
@@ -53,6 +53,8 @@ Usage:
|
|
|
53
53
|
# this range changed (what /ship-pr runs)
|
|
54
54
|
python scripts/caddis_tidy.py --prune # list old local state that could be deleted
|
|
55
55
|
python scripts/caddis_tidy.py --prune --apply # delete it (the user's choice, never automatic)
|
|
56
|
+
python scripts/caddis_tidy.py --prune --temp # also list old scratchpads and caddis test folders in the
|
|
57
|
+
# temp folder (scripts/caddis_temp_prune.py); --apply deletes
|
|
56
58
|
"""
|
|
57
59
|
|
|
58
60
|
from __future__ import annotations
|
|
@@ -1097,13 +1099,14 @@ def prune(root: Path, *, apply: bool = False, now: float | None = None) -> Prune
|
|
|
1097
1099
|
return report
|
|
1098
1100
|
|
|
1099
1101
|
|
|
1100
|
-
def _print_prune(root: Path, report: PruneReport, apply: bool) -> int:
|
|
1102
|
+
def _print_prune(root: Path, report: PruneReport, apply: bool, temp: bool = False) -> int:
|
|
1101
1103
|
def rel(p: Path) -> str:
|
|
1102
1104
|
return p.relative_to(root).as_posix()
|
|
1103
1105
|
|
|
1104
1106
|
print(f"[caddis_tidy] mode=PRUNE-{'APPLY' if apply else 'LIST'} root={root}")
|
|
1105
1107
|
if report.refused:
|
|
1106
|
-
|
|
1108
|
+
# With --temp the repo part is only one half; the caller still runs the temp part.
|
|
1109
|
+
print(f" REFUSED: {report.refused} Nothing was listed or deleted" + (" under .caddis." if temp else "."))
|
|
1107
1110
|
return 1
|
|
1108
1111
|
for p in (report.deleted if apply else report.delete):
|
|
1109
1112
|
print(f" {'deleted' if apply else 'would delete'} (session state older than 7 days): "
|
|
@@ -1120,11 +1123,12 @@ def _print_prune(root: Path, report: PruneReport, apply: bool) -> int:
|
|
|
1120
1123
|
print(f" NOTE: {note}")
|
|
1121
1124
|
for msg in report.errors:
|
|
1122
1125
|
print(f" ERROR: {msg}")
|
|
1126
|
+
flags = "--prune --temp --apply" if temp else "--prune --apply"
|
|
1123
1127
|
if not apply and (report.delete or report.rotate):
|
|
1124
|
-
print(" Run `caddis_tidy.py
|
|
1128
|
+
print(f" Run `caddis_tidy.py {flags}` to delete these. That is the user's choice.")
|
|
1125
1129
|
elif not (report.delete or report.rotate or report.old_worktrees or report.orphan_locks
|
|
1126
1130
|
or report.notes or report.errors):
|
|
1127
|
-
print(" nothing to prune.")
|
|
1131
|
+
print(" nothing to prune" + (" under .caddis." if temp else "."))
|
|
1128
1132
|
return 1 if report.errors else 0
|
|
1129
1133
|
|
|
1130
1134
|
|
|
@@ -1145,6 +1149,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1145
1149
|
"else is moved or stamped")
|
|
1146
1150
|
ap.add_argument("--prune", action="store_true",
|
|
1147
1151
|
help="list old local state under .caddis/ (with --apply: delete it)")
|
|
1152
|
+
ap.add_argument("--temp", action="store_true",
|
|
1153
|
+
help="with --prune: also list scratchpads of ended sessions and caddis test folders "
|
|
1154
|
+
"in the temp folder, older than 7 days (with --apply: delete them)")
|
|
1155
|
+
ap.add_argument("--verbose", action="store_true",
|
|
1156
|
+
help="with --temp: list every folder and note (default: totals, the 10 largest, "
|
|
1157
|
+
"kept notes grouped by reason)")
|
|
1148
1158
|
args = ap.parse_args(argv)
|
|
1149
1159
|
# A status or path the console cannot encode prints as `?`, never a crash (a cp1252 pipe).
|
|
1150
1160
|
for stream in (sys.stdout, sys.stderr):
|
|
@@ -1154,10 +1164,27 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1154
1164
|
pass
|
|
1155
1165
|
|
|
1156
1166
|
repo_root = Path(args.repo_root).resolve()
|
|
1167
|
+
if args.temp and not args.prune:
|
|
1168
|
+
ap.error("--temp only works together with --prune")
|
|
1169
|
+
if args.verbose and not args.temp:
|
|
1170
|
+
ap.error("--verbose only works together with --temp")
|
|
1157
1171
|
if args.prune:
|
|
1158
1172
|
if args.check or args.commit_range or args.only:
|
|
1159
1173
|
ap.error("--prune runs alone: not with --check, --commit-range or --only")
|
|
1160
|
-
|
|
1174
|
+
repo_part = prune(repo_root, apply=args.apply)
|
|
1175
|
+
code = _print_prune(repo_root, repo_part, args.apply, temp=args.temp)
|
|
1176
|
+
if not args.temp:
|
|
1177
|
+
return code
|
|
1178
|
+
# The temp folder has nothing to do with the repo: a repo that prune refuses (not a git work tree)
|
|
1179
|
+
# or one with no .caddis must not block it, and a refusal here is not a failure of the temp part.
|
|
1180
|
+
if repo_part.refused:
|
|
1181
|
+
code = 0
|
|
1182
|
+
import caddis_temp_prune # ships beside this file
|
|
1183
|
+
temp_part = caddis_temp_prune.prune_temp(apply=args.apply, max_age_s=PRUNE_MAX_AGE_S)
|
|
1184
|
+
print(f"[caddis_tidy] mode=PRUNE-TEMP-{'APPLY' if args.apply else 'LIST'}")
|
|
1185
|
+
for line in caddis_temp_prune.format_report(temp_part, args.apply, args.verbose):
|
|
1186
|
+
print(line)
|
|
1187
|
+
return 1 if (code or temp_part.errors) else 0
|
|
1161
1188
|
if args.only and args.check:
|
|
1162
1189
|
ap.error("--only is for --apply (or its dry run), not --check")
|
|
1163
1190
|
report = tidy(repo_root, apply=args.apply, check=args.check, commit_range=args.commit_range,
|
|
@@ -21,8 +21,13 @@ stays where it is: moving it breaks a venv, which keeps absolute paths.
|
|
|
21
21
|
worktree_path(repo, short_name, root=None) -> the path for a new worktree (nothing is created)
|
|
22
22
|
main_checkout(repo) -> the main checkout's folder
|
|
23
23
|
|
|
24
|
-
CLI: `python caddis_worktree_root.py [--repo PATH] [--root PATH] <short-name>` prints the path.
|
|
25
|
-
or a folder that is not in a git repo.
|
|
24
|
+
CLI: `python caddis_worktree_root.py [--repo PATH] [--root PATH] [--record --purpose TEXT] <short-name>` prints the path.
|
|
25
|
+
Exit 2 on a bad name or a folder that is not in a git repo.
|
|
26
|
+
|
|
27
|
+
`--record` also appends the path to this session's created list (`caddis_created.py`, by="helper"), so `/handoff` can
|
|
28
|
+
remove the worktree later. The helper creates nothing, so the path is recorded as INTENDED: run `git worktree add` on the
|
|
29
|
+
path it printed. A session with no session id, or a record that cannot be written, only prints a warning on stderr: the
|
|
30
|
+
path is still printed and the exit code is still 0.
|
|
26
31
|
"""
|
|
27
32
|
|
|
28
33
|
from __future__ import annotations
|
|
@@ -96,14 +101,32 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
96
101
|
ap.add_argument("short_name", help="one folder name, e.g. fix-login")
|
|
97
102
|
ap.add_argument("--repo", default=".", help="any folder of the repo or of one of its worktrees (default: .)")
|
|
98
103
|
ap.add_argument("--root", default=None, help=f"override the root (default: ${ENV_VAR}, then <repo parent>/.worktrees)")
|
|
104
|
+
ap.add_argument("--record", action="store_true", help="also record the path in this session's created list")
|
|
105
|
+
ap.add_argument("--purpose", default="", help="with --record: why the worktree is made")
|
|
99
106
|
args = ap.parse_args(argv)
|
|
100
107
|
try:
|
|
101
|
-
|
|
108
|
+
path = worktree_path(args.repo, args.short_name, args.root)
|
|
109
|
+
repo = main_checkout(args.repo) if args.record else None
|
|
102
110
|
except (ValueError, NotARepoError) as exc:
|
|
103
111
|
print(f"error: {exc}", file=sys.stderr)
|
|
104
112
|
return 2
|
|
113
|
+
print(path)
|
|
114
|
+
if args.record:
|
|
115
|
+
_record(path, repo, args.purpose)
|
|
105
116
|
return 0
|
|
106
117
|
|
|
107
118
|
|
|
119
|
+
def _record(path: Path, repo: Path | None, purpose: str) -> None:
|
|
120
|
+
"""Append `path` to the session's created list. A failure only warns: the caller still has its path."""
|
|
121
|
+
try:
|
|
122
|
+
import caddis_created
|
|
123
|
+
done = caddis_created.record(path, "worktree", purpose, repo=repo, by="helper")
|
|
124
|
+
except Exception: # noqa: BLE001 - recording must never break a command that only wants the path
|
|
125
|
+
done = False
|
|
126
|
+
if not done:
|
|
127
|
+
print(f"caddis_worktree_root: not recorded (no usable session id, or the record could not be written): {path}",
|
|
128
|
+
file=sys.stderr)
|
|
129
|
+
|
|
130
|
+
|
|
108
131
|
if __name__ == "__main__":
|
|
109
132
|
sys.exit(main())
|
|
@@ -37,10 +37,10 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
37
37
|
|
|
38
38
|
- **On `SHARED: no`,** do the steps as written, in this checkout.
|
|
39
39
|
- **On `SHARED: yes`,** never move this checkout. Leave its changes as they are, and report them. Instead:
|
|
40
|
-
- **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name>)" <branch>`, then
|
|
40
|
+
- **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name> --record --purpose "rebase <name>")" <branch>`, then
|
|
41
41
|
`git -C <worktree> rebase origin/<default>` and `git -C <worktree> push --force-with-lease origin HEAD:<branch>`.
|
|
42
42
|
- **Pull a branch to test it:** `git fetch origin`, then
|
|
43
|
-
`git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name>)" origin/<branch>` (for the default branch,
|
|
43
|
+
`git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name> --record --purpose "test <name>")" origin/<branch>` (for the default branch,
|
|
44
44
|
`origin/<default>`), and run the gates there. Install dependencies fresh; never link `node_modules` or a venv.
|
|
45
45
|
- **Clean up after a merge:** do not switch or pull. Run `git fetch --prune`, then
|
|
46
46
|
`git fetch origin <default>:<default>`: it fast-forwards the default branch with no checkout. Git refuses when a
|
|
@@ -50,7 +50,8 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
50
50
|
|
|
51
51
|
`<name>` is the branch name with each `/` written as `-`. `caddis_worktree_root.py` prints the path of a new worktree:
|
|
52
52
|
`$CADDIS_WORKTREE_ROOT/<repo>/<short-name>`, else `<parent of the main checkout>/.worktrees/<repo>/<short-name>`. Never `%TEMP%`: Windows
|
|
53
|
-
cleanup can empty it.
|
|
53
|
+
cleanup can empty it. `--record --purpose "<why>"` also writes the path to this
|
|
54
|
+
session's created list (`caddis_created.py`), so `/handoff` can remove the worktree later. The caddis guard checks only the `Shared checkout: yes` line in
|
|
54
55
|
`AGENTS.md`, not a dirty tree: with that line, it asks before a tree move, and denies it in a headless session.
|
|
55
56
|
<!-- /checkout-safety -->
|
|
56
57
|
|
|
@@ -39,6 +39,8 @@ them into its commit, so check first, before Step 1.
|
|
|
39
39
|
- Step 6: retired; nothing to skip (row 8 reports `skipped: retired (see the 3-store model)`).
|
|
40
40
|
- Step 6b: skip `session_state.py prune` (report `skipped: shared checkout`).
|
|
41
41
|
- Step 6c: run `--reindex` in the worktree (it edits the tracked `DOC-MAP.md`).
|
|
42
|
+
- Step 6d: runs as written. It never writes a file in this checkout; it only removes worktrees and
|
|
43
|
+
scratch folders this session recorded.
|
|
42
44
|
- The relay stays in this checkout: `.caddis/relay/` is local and gitignored.
|
|
43
45
|
After Step 6c, run:
|
|
44
46
|
```
|
|
@@ -51,9 +53,9 @@ them into its commit, so check first, before Step 1.
|
|
|
51
53
|
|
|
52
54
|
## Checklist
|
|
53
55
|
The handoff is a fixed checklist. Every row ends as `done`, `skipped: <reason>` or `failed: <error>`, and
|
|
54
|
-
Step 8 prints all
|
|
55
|
-
Rows 3 and 8 are retired (the 3-store model, 21 Oct 2026): they stay in the list so the row numbers
|
|
56
|
-
|
|
56
|
+
Step 8 prints all 14 in this order. Never drop a row from the report, even when its step did not apply.
|
|
57
|
+
Rows 3 and 8 are retired (the 3-store model, 21 Oct 2026): they stay in the list so the row numbers do not
|
|
58
|
+
move for a fleet that mixes caddis versions, and each always reports
|
|
57
59
|
`skipped: retired (see the 3-store model)`. A later release removes them with their scripts.
|
|
58
60
|
|
|
59
61
|
| # | Row | What it is | Shared checkout (Step 0) |
|
|
@@ -69,11 +71,12 @@ Rows 3 and 8 are retired (the 3-store model, 21 Oct 2026): they stay in the list
|
|
|
69
71
|
| 9 | `usage-record` | Step 6b: `session_state.py usage` | runs |
|
|
70
72
|
| 10 | `tidy-apply` | Step 4: `caddis_tidy.py --apply` | `skipped: shared checkout` |
|
|
71
73
|
| 11 | `state-prune` | Step 6b: `session_state.py prune` | `skipped: shared checkout` |
|
|
72
|
-
| 12 | `prune-list` | Step 6b: `caddis_tidy.py --prune`, list only | runs (lists, never applies) |
|
|
74
|
+
| 12 | `prune-list` | Step 6b: `caddis_tidy.py --prune --temp`, list only | runs (lists, never applies) |
|
|
73
75
|
| 13 | `handover-check` | Step 5 | runs |
|
|
76
|
+
| 14 | `own-cleanup` | Step 6d: `caddis_created.py cleanup --apply` removes this session's merged, clean worktrees and scratch folders; lists the rest with a reason | runs (only this session's recorded worktrees and scratch) |
|
|
74
77
|
|
|
75
78
|
`tidy-apply` and `state-prune` run only on `SHARED: no`. Handoff never runs
|
|
76
|
-
`caddis_tidy.py --prune --apply` on its own, in any checkout; only the user can ask for it (Step 6b). In a
|
|
79
|
+
`caddis_tidy.py --prune --apply` on its own, in any checkout (nor `--prune --temp --apply`); only the user can ask for it (Step 6b). In a
|
|
77
80
|
shared checkout the report also lists every tracked file Step 1 changed, under `Docs PR needed:`.
|
|
78
81
|
|
|
79
82
|
## Step 1 — capture learnings FIRST (knowledge-transfer)
|
|
@@ -312,11 +315,14 @@ shared checkout (Step 0). Include the
|
|
|
312
315
|
|
|
313
316
|
Then list old local state (falls back to `scripts/caddis_tidy.py`; skip if missing):
|
|
314
317
|
```
|
|
315
|
-
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_tidy.py" --prune
|
|
318
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_tidy.py" --prune --temp
|
|
316
319
|
```
|
|
317
320
|
It only lists: session-state files older than 7 days, logs over 5 MB to rotate, and (report
|
|
318
|
-
only, never deleted) orphan `.lock` files and lane worktrees older than 7 days.
|
|
319
|
-
|
|
321
|
+
only, never deleted) orphan `.lock` files and lane worktrees older than 7 days. `--temp` adds the temp
|
|
322
|
+
folder: scratchpads under `<temp>/claude/` of ended sessions older than 7 days, and old folders that start
|
|
323
|
+
with a prefix caddis itself makes (each with its size and age). A folder with a link or a `.git` in it, or one a
|
|
324
|
+
live session may use, is kept and listed with the reason. Include its report. **Never add `--apply`
|
|
325
|
+
yourself**; deleting is the user's choice. If they ask, run `--prune --temp --apply`.
|
|
320
326
|
|
|
321
327
|
## Step 6c — KB index and standing violations
|
|
322
328
|
Both scripts fall back to `claude-harness/scripts/check_doc_coverage.py` and `scripts/caddis_tidy.py` in a
|
|
@@ -346,6 +352,27 @@ Then, when the repo has a roadmap (`.caddis/roadmaps/`), re-render its page so p
|
|
|
346
352
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_roadmap.py" render --all
|
|
347
353
|
```
|
|
348
354
|
|
|
355
|
+
## Step 6d — remove this session's own worktrees and scratch folders
|
|
356
|
+
Checklist row 14, `own-cleanup`. It runs in every checkout, a shared one too, after the Step 0 `finish`
|
|
357
|
+
(falls back to `scripts/caddis_created.py` in a source checkout; if the script is missing, or exits 2 because
|
|
358
|
+
it has no `cleanup`, the row is `skipped: not installed`):
|
|
359
|
+
```
|
|
360
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_created.py" cleanup --session-id <session-id> --apply
|
|
361
|
+
```
|
|
362
|
+
It reads only this session's record (`~/.caddis/sessions/<session-id>.created.jsonl`). A worktree goes only
|
|
363
|
+
when its PR is merged, `git status` is clean, it has no commits after the PR head, and every file its branch
|
|
364
|
+
changed equals `origin/<default>` (so a squash merge counts). Then its local and remote branch go too: each
|
|
365
|
+
only while its tip is still the merged work. A scratch folder goes after a full scan, links first.
|
|
366
|
+
|
|
367
|
+
It never removes: another session's worktree (it is not in this record), a roadmap runner's worktree, the
|
|
368
|
+
worktree this session is working in, one with uncommitted changes (untracked files count), one whose PR is
|
|
369
|
+
not merged or not found, one whose tree differs from `origin/<default>`, or anything through a
|
|
370
|
+
junction or symlink (a link is removed as a link; its target stays). It prints one line per entry (`REMOVED`, `KEPT
|
|
371
|
+
<path>: <reason>`, `GONE` or `FAILED <path>: <problem>`) and ends with
|
|
372
|
+
`[OWN-CLEANUP] removed <n>, kept <n>, gone <n>, failed <n>`. Include that summary line and each `KEPT` and
|
|
373
|
+
`FAILED` line in your report. It always exits 0: a kept or failed entry is not a failure of the row, which is
|
|
374
|
+
`done` when the script ran.
|
|
375
|
+
|
|
349
376
|
## Step 7 — push prompt (interactive only)
|
|
350
377
|
If this is an interactive session, ask the user every time — never reuse an earlier answer:
|
|
351
378
|
`Push this note for another machine? (y/N)`
|
|
@@ -376,6 +403,7 @@ Print this block, every row filled with `done`, `skipped: <reason>` or `failed:
|
|
|
376
403
|
11 state-prune <same three forms>
|
|
377
404
|
12 prune-list <same three forms>
|
|
378
405
|
13 handover-check <same three forms>
|
|
406
|
+
14 own-cleanup <same three forms>
|
|
379
407
|
Shared checkout: yes|no <reason>
|
|
380
408
|
```
|
|
381
409
|
|
|
@@ -397,11 +425,18 @@ Session: <session name> (<session id>)
|
|
|
397
425
|
Workstream: <name>
|
|
398
426
|
<the resume prompt, in a fenced block>
|
|
399
427
|
Start line:
|
|
400
|
-
claude --name <role>-<MMDD>-<HH> '<the resume prompt on one line>'
|
|
428
|
+
claude --name <role>-<MMDD>-<HH> '<the resume prompt on one line> <the announce sentence>'
|
|
401
429
|
```
|
|
430
|
+
- **The announce sentence.** In a normal handoff the one-line start line also ends with a sentence that tells
|
|
431
|
+
the new session to run `caddis_fleet.py whoami` with its new name, then `caddis_fleet.py peers`, and to send
|
|
432
|
+
each named live peer in the same repo (the checkout and its worktrees) one `SendMessage`:
|
|
433
|
+
`<name> started in <folder>: <next action>. No reply needed.` The fenced block stays the relay's text. A
|
|
434
|
+
session that pastes the block, not the start line, announces nothing. The script path is written out in the
|
|
435
|
+
sentence, because a new session cannot expand `${CLAUDE_PLUGIN_ROOT}`.
|
|
402
436
|
- **Fleet close.** When this handoff runs because a peer sent `FLEET CLOSE from <coordinator>`, add
|
|
403
437
|
`--fleet`. The Start line is then `Start line: from the restart sheet`: the restart sheet is the only
|
|
404
|
-
start line in a fleet close
|
|
438
|
+
start line in a fleet close, and `/caddis:fleet-open` already greets the peers, so it has no announce
|
|
439
|
+
sentence. The `FLEET-ACK` reply is a separate message and does not change.
|
|
405
440
|
- **Exit 1** (no `## Resume prompt` block, or no relay) prints nothing on stdout. The handoff is not
|
|
406
441
|
complete: fix the relay (Step 3, point 5) and run the command again. Never write the block by hand.
|
|
407
442
|
- Workstream with no `whoami` record: the new name uses the workstream name as its role. Pass
|