@caddis/cli 0.4.67 → 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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.153",
3
+ "version": "1.3.154",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -11,6 +11,7 @@ import os
11
11
  import re
12
12
  import subprocess
13
13
  import sys
14
+ import time
14
15
  from dataclasses import dataclass
15
16
  from datetime import datetime
16
17
  from pathlib import Path
@@ -332,6 +333,78 @@ def whoami(session_id: str, name: str, root: Path) -> Path:
332
333
  return out
333
334
 
334
335
 
336
+ # ---- live peers -------------------------------------------------------------------------------
337
+
338
+ # The same rule as `inject_relay._live_peers` (the SessionStart hook cannot be imported: it runs at import).
339
+ # test_peers_apply_the_same_liveness_rule_as_the_session_start_hook runs the hook's own source to keep them equal.
340
+ PEER_FRESH_S = 1800
341
+
342
+
343
+ def _slug(session_id: str) -> str:
344
+ return "".join(c for c in session_id if c.isalnum() or c in "-_")[:64]
345
+
346
+
347
+ def _worktree_roots(root: Path) -> list[Path]:
348
+ """`root` plus every worktree of its repo: a session in a linked worktree writes its state there, not in the main
349
+ checkout, so a repo-wide scan reads each one. Not a git repo (or no git): just `root`."""
350
+ roots = [root]
351
+ proc = subprocess.run(["git", "-C", str(root), "worktree", "list", "--porcelain"], capture_output=True,
352
+ text=True, encoding="utf-8", errors="replace")
353
+ if proc.returncode == 0:
354
+ for line in proc.stdout.splitlines():
355
+ if line.startswith("worktree "):
356
+ path = Path(line[len("worktree "):].strip())
357
+ if path not in roots and path.resolve() != root:
358
+ roots.append(path)
359
+ return roots
360
+
361
+
362
+ def _peer_name(base: Path, sid: str) -> str:
363
+ try:
364
+ return (base / f"{sid}.name").read_text(encoding="utf-8").split("\n", 1)[0].split("\t")[0].strip() or "-"
365
+ except (OSError, UnicodeDecodeError):
366
+ return "-"
367
+
368
+
369
+ def live_peers(session_id: str, root: Path, now: float | None = None) -> list[tuple[str, str, int]]:
370
+ """[(name, session id, seconds since its state file was written)] for the OTHER live local sessions in this repo
371
+ (the checkout and its worktrees), freshest first. The name is `-` when that session never ran `whoami`.
372
+
373
+ Live: a `<sid>.md` state file written within PEER_FRESH_S, with no newer `<sid>.ended` marker (a resumed session
374
+ rewrites its state, which is newer than the marker). A cloud or offline session writes no state here.
375
+ """
376
+ now = time.time() if now is None else now
377
+ own = _slug(session_id)
378
+ found: dict[str, tuple[str, int]] = {}
379
+ for wt in _worktree_roots(root):
380
+ try:
381
+ from claudster_config import artifact_root
382
+ base = Path(artifact_root(str(wt))) / "session-state"
383
+ except Exception:
384
+ base = wt / ".caddis" / "session-state"
385
+ try:
386
+ names = os.listdir(base)
387
+ except OSError:
388
+ continue
389
+ for fname in names:
390
+ sid = fname[:-3]
391
+ if not fname.endswith(".md") or (own and sid == own):
392
+ continue
393
+ try:
394
+ mtime = (base / fname).stat().st_mtime
395
+ except OSError:
396
+ continue
397
+ age = int(now - mtime)
398
+ try:
399
+ if (base / f"{sid}.ended").stat().st_mtime >= mtime:
400
+ continue
401
+ except OSError:
402
+ pass
403
+ if 0 <= age <= PEER_FRESH_S and (sid not in found or age < found[sid][1]):
404
+ found[sid] = (_peer_name(base, sid), age)
405
+ return sorted(((name, sid, age) for sid, (name, age) in found.items()), key=lambda p: p[2])
406
+
407
+
335
408
  # ---- resume block -----------------------------------------------------------------------------
336
409
 
337
410
  class ResumeError(ValueError):
@@ -375,15 +448,39 @@ def shell_arg(text: str) -> str:
375
448
  return "'" + one_line.replace("'", "’").replace('"', "”") + "'"
376
449
 
377
450
 
451
+ ANNOUNCE_ID = "<your session id>"
452
+
453
+
454
+ def announce_text(script: Path, name: str, folder: str) -> str:
455
+ """The sentence the start line adds, so the resumed session names itself and tells its live peers once.
456
+
457
+ A new session cannot expand `${CLAUDE_PLUGIN_ROOT}`, so the script path is this script's own, written out. It
458
+ holds no quote or double quote (`shell_arg` would change them). A peer without a name (`-`) is not messaged."""
459
+ py = f"python {script.as_posix()}"
460
+ return (f"Then announce yourself once. Run `{py} whoami --session-id {ANNOUNCE_ID} --name {name} --root .` and "
461
+ f"`{py} peers --session-id {ANNOUNCE_ID} --root .` (your session id is printed at session start). "
462
+ f"Send each peer that has a name (not `-`) one SendMessage: "
463
+ f"`{name} started in {folder}: <next action>. No reply needed.` Send nothing else, and do not answer "
464
+ f"a reply. If the script is missing or lists no peer, skip this.")
465
+
466
+
378
467
  def render_resume(session: str, session_id: str, workstream: str, prompt: str, role: str, stamp: datetime,
379
- fleet_mode: bool = False) -> str:
380
- """The last output of `/caddis:handoff`. In a fleet close the restart sheet holds the only start line."""
468
+ fleet_mode: bool = False, announce_script: Path | None = None, folder: str = "") -> str:
469
+ """The last output of `/caddis:handoff`. In a fleet close the restart sheet holds the only start line.
470
+
471
+ With `announce_script`, the start line (not the fenced block, which stays the relay's text) ends with the
472
+ whoami + announce sentence: the start line is what the new session receives with its name."""
473
+ shown = prompt # the fenced block stays the relay text
381
474
  if fleet_mode:
382
475
  start = "Start line: from the restart sheet"
383
476
  else:
384
- start = f"Start line:\nclaude --name {new_name(role, stamp)} {shell_arg(prompt)}"
477
+ name = new_name(role, stamp)
478
+ if announce_script is not None:
479
+ prompt = f"{prompt}\n{announce_text(announce_script, name, folder)}"
480
+ start = f"Start line:\nclaude --name {name} {shell_arg(prompt)}"
385
481
  return (f"=== RESUME ===\nSession: {session} ({session_id})\nWorkstream: {workstream}\n"
386
- f"{_fenced(prompt)}\n{start}\n")
482
+ f"{_fenced(shown)}\n{start}\n")
483
+
387
484
 
388
485
  # ---- CLI --------------------------------------------------------------------------------------
389
486
 
@@ -416,7 +513,8 @@ def _resume_block_cli(args: argparse.Namespace) -> int:
416
513
  if not _SAFE_NAME.match(role): # the role goes unquoted into the start line
417
514
  sys.stderr.write(f"resume-block failed: bad role {role!r}\n")
418
515
  return caddis_exit.BLOCKED
419
- print(render_resume(session, args.session_id, args.name, prompt, role, datetime.now(), args.fleet), end="")
516
+ print(render_resume(session, args.session_id, args.name, prompt, role, datetime.now(), args.fleet,
517
+ Path(__file__).resolve(), root.name), end="")
420
518
  return caddis_exit.CLEAN
421
519
 
422
520
 
@@ -446,6 +544,9 @@ def main(argv: list[str] | None = None) -> int:
446
544
  p.add_argument("--session-id", required=True)
447
545
  p.add_argument("--name", required=True)
448
546
  p.add_argument("--root", default=".", type=Path)
547
+ p = sub.add_parser("peers", help="the other live local sessions in this repo: <name> TAB <session id> TAB <age s>")
548
+ p.add_argument("--session-id", required=True, help="this session's id (never listed)")
549
+ p.add_argument("--root", default=".", type=Path)
449
550
  p = sub.add_parser("resume-block", help="print the session name and the relay's resume prompt (last step of handoff)")
450
551
  p.add_argument("--name", required=True, help="the relay's workstream name")
451
552
  p.add_argument("--session-id", required=True)
@@ -520,6 +621,10 @@ def main(argv: list[str] | None = None) -> int:
520
621
  return caddis_exit.CLEAN
521
622
  if args.cmd == "resume-block":
522
623
  return _resume_block_cli(args)
624
+ if args.cmd == "peers":
625
+ for peer_name, sid, age in live_peers(args.session_id, args.root.resolve()):
626
+ print(f"{peer_name} {sid} {age}")
627
+ return caddis_exit.CLEAN
523
628
  # whoami
524
629
  try:
525
630
  out = whoami(args.session_id, args.name, args.root.resolve())
@@ -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
- print(f" REFUSED: {report.refused} Nothing was listed or deleted.")
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 --prune --apply` to delete these. That is the user's choice.")
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
- return _print_prune(repo_root, prune(repo_root, apply=args.apply), args.apply)
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,
@@ -71,12 +71,12 @@ move for a fleet that mixes caddis versions, and each always reports
71
71
  | 9 | `usage-record` | Step 6b: `session_state.py usage` | runs |
72
72
  | 10 | `tidy-apply` | Step 4: `caddis_tidy.py --apply` | `skipped: shared checkout` |
73
73
  | 11 | `state-prune` | Step 6b: `session_state.py prune` | `skipped: shared checkout` |
74
- | 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) |
75
75
  | 13 | `handover-check` | Step 5 | runs |
76
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) |
77
77
 
78
78
  `tidy-apply` and `state-prune` run only on `SHARED: no`. Handoff never runs
79
- `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
80
80
  shared checkout the report also lists every tracked file Step 1 changed, under `Docs PR needed:`.
81
81
 
82
82
  ## Step 1 — capture learnings FIRST (knowledge-transfer)
@@ -315,11 +315,14 @@ shared checkout (Step 0). Include the
315
315
 
316
316
  Then list old local state (falls back to `scripts/caddis_tidy.py`; skip if missing):
317
317
  ```
318
- python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_tidy.py" --prune
318
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_tidy.py" --prune --temp
319
319
  ```
320
320
  It only lists: session-state files older than 7 days, logs over 5 MB to rotate, and (report
321
- only, never deleted) orphan `.lock` files and lane worktrees older than 7 days. Include its report. **Never
322
- add `--apply` yourself**; deleting is the user's choice. If they ask, run `--prune --apply`.
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`.
323
326
 
324
327
  ## Step 6c — KB index and standing violations
325
328
  Both scripts fall back to `claude-harness/scripts/check_doc_coverage.py` and `scripts/caddis_tidy.py` in a
@@ -422,11 +425,18 @@ Session: <session name> (<session id>)
422
425
  Workstream: <name>
423
426
  <the resume prompt, in a fenced block>
424
427
  Start line:
425
- claude --name <role>-<MMDD>-<HH> '<the resume prompt on one line>'
426
- ```
428
+ claude --name <role>-<MMDD>-<HH> '<the resume prompt on one line> <the announce sentence>'
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}`.
427
436
  - **Fleet close.** When this handoff runs because a peer sent `FLEET CLOSE from <coordinator>`, add
428
437
  `--fleet`. The Start line is then `Start line: from the restart sheet`: the restart sheet is the only
429
- start line in a fleet close. The `FLEET-ACK` reply is a separate message and does not change.
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.
430
440
  - **Exit 1** (no `## Resume prompt` block, or no relay) prints nothing on stdout. The handoff is not
431
441
  complete: fix the relay (Step 3, point 5) and run the command again. Never write the block by hand.
432
442
  - Workstream with no `whoami` record: the new name uses the workstream name as its role. Pass
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.153",
2
+ "poolVersion": "1.3.154",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.153",
4
+ "antigravity-plugin": "1.3.154",
5
5
  "antigravity-plugin-extras": "1.3.46",
6
6
  "codex": "unknown"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.67",
3
+ "version": "0.4.68",
4
4
  "description": "Install, update and diagnose the caddis dev harness across every coding agent you use (Claude Code, agy/Antigravity, and more).",
5
5
  "keywords": [
6
6
  "caddis",