@caddis/cli 0.4.67 → 0.4.69
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_fleet.py +110 -5
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +9 -7
- package/bundles/antigravity-plugin/scripts/caddis_review.py +6 -1
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +9 -5
- package/bundles/antigravity-plugin/scripts/caddis_temp_prune.py +376 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +152 -6
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +3 -2
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +18 -8
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +3 -2
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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())
|
|
@@ -56,9 +56,6 @@ except ModuleNotFoundError as exc:
|
|
|
56
56
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
57
57
|
import caddis_frontmatter
|
|
58
58
|
|
|
59
|
-
if hasattr(sys.stdout, "reconfigure"):
|
|
60
|
-
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
61
|
-
|
|
62
59
|
ART = ".caddis"
|
|
63
60
|
|
|
64
61
|
# Artefact kinds worth enumerating, and what a reader needs from each. Order matters: it is the
|
|
@@ -277,9 +274,10 @@ def stale_artifacts(root: Path) -> list[dict]:
|
|
|
277
274
|
return out
|
|
278
275
|
|
|
279
276
|
|
|
280
|
-
def stale_plans(root: Path, days: int = 14
|
|
281
|
-
|
|
282
|
-
|
|
277
|
+
def stale_plans(root: Path, days: int = 14,
|
|
278
|
+
statuses: tuple[str, ...] = ("doing", "current")) -> list[dict]:
|
|
279
|
+
"""Top-level plans whose `status:` is one of `statuses` (default `doing` or `current`) and whose
|
|
280
|
+
last git commit is older than `days` days. `caddis_tidy.py --check` calls it with `current` only."""
|
|
283
281
|
d = root / ART / "plans"
|
|
284
282
|
if not d.is_dir() or not (root / ".git").exists():
|
|
285
283
|
return []
|
|
@@ -291,7 +289,7 @@ def stale_plans(root: Path, days: int = 14) -> list[dict]:
|
|
|
291
289
|
continue
|
|
292
290
|
fm = frontmatter(f)
|
|
293
291
|
status = (fm.get("status") or "").strip()
|
|
294
|
-
if status.lower() not in
|
|
292
|
+
if status.lower() not in statuses:
|
|
295
293
|
continue
|
|
296
294
|
rel = f"{ART}/plans/{f.name}"
|
|
297
295
|
ts = _run(["git", "log", "-1", "--format=%ct", "--", rel], root)
|
|
@@ -464,6 +462,10 @@ def render_md(inv: Inventory) -> str:
|
|
|
464
462
|
|
|
465
463
|
|
|
466
464
|
def main(argv: list[str] | None = None) -> int:
|
|
465
|
+
# Only when run as a command: importing this module (caddis_tidy and caddis_gate do) must not
|
|
466
|
+
# change the importer's stdout encoding.
|
|
467
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
468
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
467
469
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
468
470
|
ap.add_argument("--repo-root", default=".")
|
|
469
471
|
ap.add_argument("--format", choices=("md", "json"), default="md")
|
|
@@ -999,7 +999,12 @@ def _prune_stale_review_worktrees(root: Path) -> None:
|
|
|
999
999
|
|
|
1000
1000
|
def _add_worktree(root: Path, head: str, phase: int | str, run: int) -> Path:
|
|
1001
1001
|
path = Path(tempfile.gettempdir()) / f"caddis-review-{phase}-{run}-{uuid.uuid4().hex[:8]}"
|
|
1002
|
-
|
|
1002
|
+
try:
|
|
1003
|
+
_git_out(root, "worktree", "add", "--detach", str(path), head)
|
|
1004
|
+
except BaseException:
|
|
1005
|
+
# A failed `worktree add` can leave the half-made folder: the caller has no path to remove (tidy v2 Phase 5).
|
|
1006
|
+
caddis_worktree_safety.remove_tree(path)
|
|
1007
|
+
raise
|
|
1003
1008
|
return path
|
|
1004
1009
|
|
|
1005
1010
|
|
|
@@ -1081,8 +1081,10 @@ def merged_checkout(root: Path, branch: str):
|
|
|
1081
1081
|
_git(root, "worktree", "add", "--quiet", "--detach", str(worktree), f"origin/{branch}")
|
|
1082
1082
|
yield worktree
|
|
1083
1083
|
finally:
|
|
1084
|
-
|
|
1085
|
-
|
|
1084
|
+
try:
|
|
1085
|
+
caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
|
|
1086
|
+
finally: # the temp folder goes even when git fails or is missing (tidy v2 Phase 5)
|
|
1087
|
+
caddis_worktree_safety.remove_tree(tmp) # links first: a junction's target survives
|
|
1086
1088
|
|
|
1087
1089
|
|
|
1088
1090
|
def _kill_tree(proc: subprocess.Popen) -> None:
|
|
@@ -1849,9 +1851,11 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
|
|
|
1849
1851
|
else:
|
|
1850
1852
|
rc = caddis_exit.NOT_RUN if rc == caddis_exit.NOT_RUN else caddis_exit.BLOCKED
|
|
1851
1853
|
finally:
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1854
|
+
try:
|
|
1855
|
+
caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
|
|
1856
|
+
_git(root, "branch", "-D", branch, check=False)
|
|
1857
|
+
finally: # the temp folder goes even when git fails or is missing (tidy v2 Phase 5)
|
|
1858
|
+
caddis_worktree_safety.remove_tree(tmp)
|
|
1855
1859
|
_audit(root, slug, {"utc": _utc(), "roadmap": slug, "id": mid, "change": change, "branch": branch,
|
|
1856
1860
|
"pr": pr, "merge_exit": merge_exit if merge_exit is not None
|
|
1857
1861
|
else (rc if pr is not None else None)})
|
|
@@ -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
|
|
@@ -38,6 +38,10 @@ DESIGN (locked, see the plan doc — do not re-litigate here)
|
|
|
38
38
|
already in a repo never block an unrelated PR. It first refuses when any of those files has an
|
|
39
39
|
uncommitted change (the check would read a file that is not what the PR ships), and a range git
|
|
40
40
|
cannot read prints only that error, never an unfiltered list.
|
|
41
|
+
- `--check` also reports three things it never fixes (tidy v2 Phase 4), each with a one-line fix: untracked
|
|
42
|
+
crash/scratch files at the repo root (`*.stackdump`, `run*.ps1`, `nul`, ...); a `.caddis/` file whose
|
|
43
|
+
`type:` belongs to another folder; a plan `status: current` with no commit for 14 days. The first is
|
|
44
|
+
repo-wide and so never appears under `--commit-range`; the other two follow the range like any file.
|
|
41
45
|
- `--only <plan> [<plan> ...]` limits a run to those top-level plans: nothing else is moved or
|
|
42
46
|
stamped. `/implement` closes the plan it finished with it, so another session's finished work
|
|
43
47
|
is never swept into this PR.
|
|
@@ -53,11 +57,14 @@ Usage:
|
|
|
53
57
|
# this range changed (what /ship-pr runs)
|
|
54
58
|
python scripts/caddis_tidy.py --prune # list old local state that could be deleted
|
|
55
59
|
python scripts/caddis_tidy.py --prune --apply # delete it (the user's choice, never automatic)
|
|
60
|
+
python scripts/caddis_tidy.py --prune --temp # also list old scratchpads and caddis test folders in the
|
|
61
|
+
# temp folder (scripts/caddis_temp_prune.py); --apply deletes
|
|
56
62
|
"""
|
|
57
63
|
|
|
58
64
|
from __future__ import annotations
|
|
59
65
|
|
|
60
66
|
import argparse
|
|
67
|
+
import fnmatch
|
|
61
68
|
import os
|
|
62
69
|
import re
|
|
63
70
|
import stat
|
|
@@ -733,6 +740,116 @@ def _check_parking_lot_item(item: Item, report: Report) -> None:
|
|
|
733
740
|
"into its own file and move the resolved history to done/.")
|
|
734
741
|
|
|
735
742
|
|
|
743
|
+
# ── wider --check (tidy v2 Phase 4): report only, nothing here moves or deletes a file ──────────
|
|
744
|
+
# Names that crash tools and one-off scripts leave at a repo root. Each one is a file nobody chose to
|
|
745
|
+
# keep, so an untracked, un-ignored match is a stray. Matched case-insensitively (Windows).
|
|
746
|
+
_STRAY_PATTERNS = (
|
|
747
|
+
("*.stackdump", "a Git Bash/Cygwin crash dump"),
|
|
748
|
+
("*.tmp", "an editor or tool temp file"),
|
|
749
|
+
("*.bak", "a hand-made or tool backup copy"),
|
|
750
|
+
("*.orig", "a merge or patch tool's pre-change copy"),
|
|
751
|
+
("*.rej", "a rejected patch hunk"),
|
|
752
|
+
("run*.ps1", "a one-off launcher script written during a session"),
|
|
753
|
+
("tmp*.py", "a throwaway probe script"),
|
|
754
|
+
("scratch*", "a scratch file or folder note"),
|
|
755
|
+
("nul", "a Windows device name that a bad redirect (`> nul` run from a bash shell) wrote as a real file"),
|
|
756
|
+
)
|
|
757
|
+
STRAY_FIX = "delete it, or add it to .gitignore if it is meant to stay"
|
|
758
|
+
|
|
759
|
+
# Folder-defining `type:` values -> the `.caddis/` folder that holds them (None: no folder, the file is
|
|
760
|
+
# a root-level one such as relay.md). Only these can be misfiled. Generic types (note, reference, design,
|
|
761
|
+
# analysis, handoff, ...) are legitimately spread over plans/, kb/ and reviews/ in real repos, so they
|
|
762
|
+
# are never flagged. parking-lot/ is left to `_check_parking_lot_item`, which already enforces its type.
|
|
763
|
+
_TYPE_HOME = {
|
|
764
|
+
"plan": "plans", "prd": "prd", "prompt": "prompts", "review": "reviews", "rca": "rca",
|
|
765
|
+
"adr": "decisions", "decision": "decisions", "parking-lot": "parking-lot",
|
|
766
|
+
"relay": None, "session-state": None,
|
|
767
|
+
}
|
|
768
|
+
# The types each known folder accepts of those above. `relay/` holds relays; kb/ accepts none of them.
|
|
769
|
+
_FOLDER_TYPES = {
|
|
770
|
+
"plans": {"plan"}, "prd": {"prd"}, "prompts": {"prompt"}, "reviews": {"review"}, "rca": {"rca"},
|
|
771
|
+
"decisions": {"adr", "decision"}, "relay": {"relay"}, "kb": set(),
|
|
772
|
+
}
|
|
773
|
+
STALE_PLAN_DAYS = 14
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def is_stray_name(name: str) -> bool:
|
|
777
|
+
"""True when a root-level file name matches a crash/scratch pattern."""
|
|
778
|
+
low = name.lower()
|
|
779
|
+
return any(fnmatch.fnmatchcase(low, pat) for pat, _ in _STRAY_PATTERNS)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _check_root_strays(repo_root: Path, report: Report) -> None:
|
|
783
|
+
"""Untracked, un-ignored files directly at the repo root whose name looks like crash/scratch debris.
|
|
784
|
+
Repo-wide: a stray is local, so a `--commit-range` filter drops it (no commit can contain it).
|
|
785
|
+
Untracked files in subfolders are out of scope (too noisy). `--directory` collapses an untracked
|
|
786
|
+
folder to one `name/` entry, which has a slash and is skipped."""
|
|
787
|
+
if not _is_git_repo(repo_root):
|
|
788
|
+
return
|
|
789
|
+
done = _git_run(repo_root, "ls-files", "--others", "--exclude-standard", "--directory",
|
|
790
|
+
"--no-empty-directory", "-z")
|
|
791
|
+
if done is None or done.returncode != 0:
|
|
792
|
+
return
|
|
793
|
+
for name in sorted(done.stdout.decode("utf-8", "replace").split("\0")):
|
|
794
|
+
if name and "/" not in name and is_stray_name(name):
|
|
795
|
+
report._flag(repo_root / name, f"untracked stray file at the repo root; fix: {STRAY_FIX}")
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _doc_type(path: Path) -> str:
|
|
799
|
+
try:
|
|
800
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
801
|
+
except OSError:
|
|
802
|
+
return ""
|
|
803
|
+
return str(_parse_frontmatter(text).get("type") or "").split("#")[0].strip().lower()
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _check_wrong_folder(repo_root: Path, report: Report) -> None:
|
|
807
|
+
"""A `.caddis/` file whose `type:` names another folder's kind. Looks at the files directly in
|
|
808
|
+
`.caddis/` and in each known folder; `done/` and other subfolders are history and are skipped, and
|
|
809
|
+
so is a file with no frontmatter (most folders never require it). Under `--commit-range` it
|
|
810
|
+
applies to the files the range changed, like every other per-file check."""
|
|
811
|
+
caddis = repo_root / ".caddis"
|
|
812
|
+
if not caddis.is_dir():
|
|
813
|
+
return
|
|
814
|
+
for folder in ("", *_FOLDER_TYPES):
|
|
815
|
+
d = caddis / folder if folder else caddis
|
|
816
|
+
if not d.is_dir():
|
|
817
|
+
continue
|
|
818
|
+
for f in sorted(d.glob("*.md")):
|
|
819
|
+
kind = _doc_type(f) if f.name.lower() != "readme.md" and f.is_file() else ""
|
|
820
|
+
if kind not in _TYPE_HOME or kind in _FOLDER_TYPES.get(folder, ()):
|
|
821
|
+
continue
|
|
822
|
+
home = _TYPE_HOME[kind]
|
|
823
|
+
if not folder and home is None:
|
|
824
|
+
continue # relay.md and session-state*.md live at the `.caddis/` root
|
|
825
|
+
where = f".caddis/{folder}" if folder else ".caddis"
|
|
826
|
+
fix = f"git mv {f.relative_to(repo_root).as_posix()} .caddis/{home}/" if home else "move it"
|
|
827
|
+
report._flag(f, f"type is `{kind}`, which does not belong in {where}/; fix: {fix}")
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _check_stale_current_plans(repo_root: Path, report: Report) -> None:
|
|
831
|
+
"""Plans `status: current` with no commit in 14 days. Reuses caddis_inventory.stale_plans, which
|
|
832
|
+
ships beside this file in every runtime. A plan a `--commit-range` changed has a fresh commit,
|
|
833
|
+
so a range check drops these naturally."""
|
|
834
|
+
try:
|
|
835
|
+
import caddis_inventory
|
|
836
|
+
except ModuleNotFoundError as exc:
|
|
837
|
+
if exc.name != "caddis_inventory":
|
|
838
|
+
raise
|
|
839
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
840
|
+
import caddis_inventory
|
|
841
|
+
for entry in caddis_inventory.stale_plans(repo_root, days=STALE_PLAN_DAYS, statuses=("current",)):
|
|
842
|
+
report._flag(repo_root / entry["file"],
|
|
843
|
+
f"status current, but no commit for {entry['days']} days; fix: set status: "
|
|
844
|
+
"done/superseded/draft in its frontmatter, or commit progress to it")
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _check_wider(repo_root: Path, report: Report) -> None:
|
|
848
|
+
_check_root_strays(repo_root, report)
|
|
849
|
+
_check_wrong_folder(repo_root, report)
|
|
850
|
+
_check_stale_current_plans(repo_root, report)
|
|
851
|
+
|
|
852
|
+
|
|
736
853
|
def tidy(repo_root: Path, apply: bool = False, check: bool = False,
|
|
737
854
|
kinds: tuple[str, ...] = _KINDS, commit_range: str | None = None,
|
|
738
855
|
only: list[str] | None = None) -> Report:
|
|
@@ -827,6 +944,8 @@ def tidy(repo_root: Path, apply: bool = False, check: bool = False,
|
|
|
827
944
|
report.stale_suspects.append(item.path)
|
|
828
945
|
if commit_range is not None and PARKING_LOT_DIR in kinds:
|
|
829
946
|
_plan_parked(repo_root, commit_range, report, moves, rewrites)
|
|
947
|
+
if check:
|
|
948
|
+
_check_wider(repo_root, report)
|
|
830
949
|
if check and report.in_range is not None:
|
|
831
950
|
_limit_check_to(repo_root, report, report.in_range)
|
|
832
951
|
if only_set is not None:
|
|
@@ -1097,13 +1216,14 @@ def prune(root: Path, *, apply: bool = False, now: float | None = None) -> Prune
|
|
|
1097
1216
|
return report
|
|
1098
1217
|
|
|
1099
1218
|
|
|
1100
|
-
def _print_prune(root: Path, report: PruneReport, apply: bool) -> int:
|
|
1219
|
+
def _print_prune(root: Path, report: PruneReport, apply: bool, temp: bool = False) -> int:
|
|
1101
1220
|
def rel(p: Path) -> str:
|
|
1102
1221
|
return p.relative_to(root).as_posix()
|
|
1103
1222
|
|
|
1104
1223
|
print(f"[caddis_tidy] mode=PRUNE-{'APPLY' if apply else 'LIST'} root={root}")
|
|
1105
1224
|
if report.refused:
|
|
1106
|
-
|
|
1225
|
+
# With --temp the repo part is only one half; the caller still runs the temp part.
|
|
1226
|
+
print(f" REFUSED: {report.refused} Nothing was listed or deleted" + (" under .caddis." if temp else "."))
|
|
1107
1227
|
return 1
|
|
1108
1228
|
for p in (report.deleted if apply else report.delete):
|
|
1109
1229
|
print(f" {'deleted' if apply else 'would delete'} (session state older than 7 days): "
|
|
@@ -1120,11 +1240,12 @@ def _print_prune(root: Path, report: PruneReport, apply: bool) -> int:
|
|
|
1120
1240
|
print(f" NOTE: {note}")
|
|
1121
1241
|
for msg in report.errors:
|
|
1122
1242
|
print(f" ERROR: {msg}")
|
|
1243
|
+
flags = "--prune --temp --apply" if temp else "--prune --apply"
|
|
1123
1244
|
if not apply and (report.delete or report.rotate):
|
|
1124
|
-
print(" Run `caddis_tidy.py
|
|
1245
|
+
print(f" Run `caddis_tidy.py {flags}` to delete these. That is the user's choice.")
|
|
1125
1246
|
elif not (report.delete or report.rotate or report.old_worktrees or report.orphan_locks
|
|
1126
1247
|
or report.notes or report.errors):
|
|
1127
|
-
print(" nothing to prune.")
|
|
1248
|
+
print(" nothing to prune" + (" under .caddis." if temp else "."))
|
|
1128
1249
|
return 1 if report.errors else 0
|
|
1129
1250
|
|
|
1130
1251
|
|
|
@@ -1135,7 +1256,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1135
1256
|
ap.add_argument("--apply", action="store_true", help="actually move files (default: dry-run report)")
|
|
1136
1257
|
ap.add_argument("--check", action="store_true",
|
|
1137
1258
|
help="conformance check - exit 1 if any top-level artifact lacks a known "
|
|
1138
|
-
"status,
|
|
1259
|
+
"status, a plan's Tracker is all done while its status is active, a stray "
|
|
1260
|
+
"crash/scratch file sits at the repo root, a .caddis file is in the wrong "
|
|
1261
|
+
"folder, or a `current` plan has no commit for 14 days (report only)")
|
|
1139
1262
|
ap.add_argument("--commit-range", default=None,
|
|
1140
1263
|
help="also read Resolves-Parked / Partly-Resolves-Parked trailers in these "
|
|
1141
1264
|
"commits only, e.g. origin/main..HEAD (default: read no trailers); "
|
|
@@ -1145,6 +1268,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1145
1268
|
"else is moved or stamped")
|
|
1146
1269
|
ap.add_argument("--prune", action="store_true",
|
|
1147
1270
|
help="list old local state under .caddis/ (with --apply: delete it)")
|
|
1271
|
+
ap.add_argument("--temp", action="store_true",
|
|
1272
|
+
help="with --prune: also list scratchpads of ended sessions and caddis test folders "
|
|
1273
|
+
"in the temp folder, older than 7 days (with --apply: delete them)")
|
|
1274
|
+
ap.add_argument("--verbose", action="store_true",
|
|
1275
|
+
help="with --temp: list every folder and note (default: totals, the 10 largest, "
|
|
1276
|
+
"kept notes grouped by reason)")
|
|
1148
1277
|
args = ap.parse_args(argv)
|
|
1149
1278
|
# A status or path the console cannot encode prints as `?`, never a crash (a cp1252 pipe).
|
|
1150
1279
|
for stream in (sys.stdout, sys.stderr):
|
|
@@ -1154,10 +1283,27 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1154
1283
|
pass
|
|
1155
1284
|
|
|
1156
1285
|
repo_root = Path(args.repo_root).resolve()
|
|
1286
|
+
if args.temp and not args.prune:
|
|
1287
|
+
ap.error("--temp only works together with --prune")
|
|
1288
|
+
if args.verbose and not args.temp:
|
|
1289
|
+
ap.error("--verbose only works together with --temp")
|
|
1157
1290
|
if args.prune:
|
|
1158
1291
|
if args.check or args.commit_range or args.only:
|
|
1159
1292
|
ap.error("--prune runs alone: not with --check, --commit-range or --only")
|
|
1160
|
-
|
|
1293
|
+
repo_part = prune(repo_root, apply=args.apply)
|
|
1294
|
+
code = _print_prune(repo_root, repo_part, args.apply, temp=args.temp)
|
|
1295
|
+
if not args.temp:
|
|
1296
|
+
return code
|
|
1297
|
+
# The temp folder has nothing to do with the repo: a repo that prune refuses (not a git work tree)
|
|
1298
|
+
# or one with no .caddis must not block it, and a refusal here is not a failure of the temp part.
|
|
1299
|
+
if repo_part.refused:
|
|
1300
|
+
code = 0
|
|
1301
|
+
import caddis_temp_prune # ships beside this file
|
|
1302
|
+
temp_part = caddis_temp_prune.prune_temp(apply=args.apply, max_age_s=PRUNE_MAX_AGE_S)
|
|
1303
|
+
print(f"[caddis_tidy] mode=PRUNE-TEMP-{'APPLY' if args.apply else 'LIST'}")
|
|
1304
|
+
for line in caddis_temp_prune.format_report(temp_part, args.apply, args.verbose):
|
|
1305
|
+
print(line)
|
|
1306
|
+
return 1 if (code or temp_part.errors) else 0
|
|
1161
1307
|
if args.only and args.check:
|
|
1162
1308
|
ap.error("--only is for --apply (or its dry run), not --check")
|
|
1163
1309
|
report = tidy(repo_root, apply=args.apply, check=args.check, commit_range=args.commit_range,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { test } from "node:test";
|
|
1
|
+
import { after, test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join, resolve } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
@@ -25,6 +25,7 @@ const good = {
|
|
|
25
25
|
links: [{ id: "a-b", from: "a", to: "b", state: "ok" }],
|
|
26
26
|
};
|
|
27
27
|
const dir = mkdtempSync(join(tmpdir(), "flow-studio-"));
|
|
28
|
+
after(() => rmSync(dir, { recursive: true, force: true })); // a test run leaves nothing in the temp folder (tidy v2 Phase 5)
|
|
28
29
|
const write = (name, value) => { const p = join(dir, name); writeFileSync(p, typeof value === "string" ? value : JSON.stringify(value)); return p; };
|
|
29
30
|
|
|
30
31
|
test("validate: a good model exits 0 and prints OK", () => {
|
|
@@ -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.
|
|
322
|
-
|
|
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
|
|
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
|
-
import { test } from "node:test";
|
|
1
|
+
import { after, test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join, resolve } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
@@ -25,6 +25,7 @@ const good = {
|
|
|
25
25
|
links: [{ id: "a-b", from: "a", to: "b", state: "ok" }],
|
|
26
26
|
};
|
|
27
27
|
const dir = mkdtempSync(join(tmpdir(), "flow-studio-"));
|
|
28
|
+
after(() => rmSync(dir, { recursive: true, force: true })); // a test run leaves nothing in the temp folder (tidy v2 Phase 5)
|
|
28
29
|
const write = (name, value) => { const p = join(dir, name); writeFileSync(p, typeof value === "string" ? value : JSON.stringify(value)); return p; };
|
|
29
30
|
|
|
30
31
|
test("validate: a good model exits 0 and prints OK", () => {
|
package/bundles/manifest.json
CHANGED
package/package.json
CHANGED