@caddis/cli 0.4.66 → 0.4.67

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.152",
3
+ "version": "1.3.153",
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
  }
@@ -0,0 +1,191 @@
1
+ """What a session CREATED and must clean up: worktrees and scratch folders, recorded per session.
2
+
3
+ Why a file of its own. The Stop hook rewrites `.caddis/session-state/<id>.md` every turn, so a record kept there is
4
+ lost. A session also often makes worktrees in OTHER repos. So the record lives outside every repo:
5
+
6
+ ~/.caddis/sessions/<session-id>.created.jsonl (append-only; one JSON object per line)
7
+
8
+ (`~` is `$CADDIS_HOME` when set, else the user's home folder; the `home` argument beats both. The folder also holds
9
+ other code's `<session-id>.json` files; this module never touches those.)
10
+
11
+ A line is one fact about one path:
12
+
13
+ {"path", "kind": "worktree"|"scratch", "created": <ISO UTC>, "purpose", "pr": str|null, "repo": str|null,
14
+ "branch": str|null, "status": "created"|"removed", "by": "helper"|"lanes"|"roadmap-run"|"docs-pr"|
15
+ "transcript"|"cli"}
16
+
17
+ `read` FOLDS the lines by normalised path (case-insensitive on Windows): `created` keeps the first value and every
18
+ other field takes the latest non-null value. So "record again with the PR number" and "mark removed" are just new
19
+ lines. One exception, which is a safety rule: a `created` line for a path whose last status was `removed` starts a
20
+ NEW entry. A path that is used again (a roadmap worktree has a fixed name) must not inherit the old PR.
21
+
22
+ This module only RECORDS. It deletes nothing; `cleanup` hands off to `caddis_own_cleanup.py`, which does.
23
+
24
+ session_id(explicit=None) -> a safe session id, or None
25
+ record(path, kind, purpose, *, pr, repo, branch, status, by, session_id, home) -> bool (never raises)
26
+ read(session_id, home=None) -> list[dict] (folded; oldest first; bad lines skipped)
27
+
28
+ CLI (exit 0 on success; `record` with no session id warns on stderr and still exits 0, so a caller never breaks;
29
+ exit 2 on bad arguments):
30
+ python caddis_created.py record --path P --kind worktree --purpose "why" [--pr N] [--repo R] [--branch B]
31
+ [--status removed] [--by cli] [--session-id S]
32
+ python caddis_created.py list [--session-id S] [--json]
33
+ python caddis_created.py cleanup [--session-id S] [--apply] [--json] (dry run by default; always exit 0)
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import json
40
+ import os
41
+ import re
42
+ import sys
43
+ from datetime import datetime, timezone
44
+ from pathlib import Path
45
+
46
+ SESSION_ENV_VARS = ("CADDIS_SESSION_ID", "CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID") # as caddis_adhoc.session_key
47
+ KINDS = ("worktree", "scratch")
48
+ STATUSES = ("created", "removed")
49
+ BYS = ("helper", "lanes", "roadmap-run", "docs-pr", "transcript", "cli")
50
+ _SAFE_ID = re.compile(r"[A-Za-z0-9_-]{1,100}") # no separator, drive colon or dots: a plain file-name stem
51
+ _FIELDS = ("purpose", "pr", "repo", "branch", "by")
52
+
53
+
54
+ def _sid(explicit: str | None = None) -> str | None:
55
+ """`explicit` (even "", which gives None), else the first non-blank session env var. None when there is none or not a safe file
56
+ name (a bad explicit id does NOT fall back to the environment: that would record under the wrong session)."""
57
+ candidates = [explicit] if explicit is not None else [os.environ.get(name) for name in SESSION_ENV_VARS]
58
+ for raw in candidates:
59
+ value = (raw or "").strip()
60
+ if value:
61
+ return value if _SAFE_ID.fullmatch(value) else None
62
+ return None
63
+
64
+
65
+ def session_id(explicit: str | None = None) -> str | None:
66
+ """The session id to record under: see `_sid`."""
67
+ return _sid(explicit)
68
+
69
+
70
+ def _file(sid: str, home: Path | str | None) -> Path:
71
+ root = Path(home) if home else Path(os.environ.get("CADDIS_HOME") or Path.home())
72
+ return root / ".caddis" / "sessions" / f"{sid}.created.jsonl"
73
+
74
+
75
+ def _normalise(path: str | os.PathLike) -> str:
76
+ """Absolute, `..` collapsed. Not resolved: resolving would follow a link, and the record names the path as made."""
77
+ return os.path.abspath(os.fspath(path))
78
+
79
+
80
+ def _key(path: str) -> str:
81
+ return os.path.normcase(path) # lower-case and backslashes on Windows; unchanged elsewhere
82
+
83
+
84
+ def record(path: str | os.PathLike, kind: str, purpose: str = "", *, pr: str | int | None = None,
85
+ repo: str | os.PathLike | None = None, branch: str | None = None, status: str = "created",
86
+ by: str = "cli", session_id: str | None = None, home: Path | str | None = None) -> bool:
87
+ """Append one line. False, and nothing written, with no usable session id, an unknown kind or status, or any I/O
88
+ error. Never raises: callers are hooks and runners."""
89
+ try:
90
+ sid = _sid(session_id)
91
+ if not sid or kind not in KINDS or status not in STATUSES or not str(path).strip():
92
+ return False
93
+ line = {
94
+ "path": _normalise(path), "kind": kind,
95
+ "created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
96
+ "purpose": str(purpose or ""), "pr": None if pr is None or str(pr) == "" else str(pr),
97
+ "repo": _normalise(repo) if repo else None, "branch": branch or None, "status": status, "by": by,
98
+ }
99
+ target = _file(sid, home)
100
+ target.parent.mkdir(parents=True, exist_ok=True)
101
+ with open(target, "a", encoding="utf-8", newline="\n") as handle:
102
+ handle.write(json.dumps(line, ensure_ascii=False) + "\n") # one write call: appends do not interleave
103
+ return True
104
+ except Exception: # noqa: BLE001 - a recording failure must never break the caller
105
+ return False
106
+
107
+
108
+ def read(session_id: str, home: Path | str | None = None) -> list[dict]:
109
+ """The session's entries, folded by path, oldest `created` first. [] when there is no file; bad lines are skipped."""
110
+ try:
111
+ sid = session_id if _SAFE_ID.fullmatch(session_id or "") else None # strict: no env fallback when reading
112
+ if not sid:
113
+ return []
114
+ text = _file(sid, home).read_text(encoding="utf-8", errors="replace")
115
+ except Exception: # noqa: BLE001
116
+ return []
117
+ folded: dict[str, dict] = {}
118
+ for raw in text.splitlines():
119
+ try:
120
+ line = json.loads(raw)
121
+ except ValueError:
122
+ continue
123
+ if not (isinstance(line, dict) and isinstance(line.get("path"), str) and line["path"]
124
+ and line.get("kind") in KINDS and line.get("status") in STATUSES):
125
+ continue
126
+ key = _key(line["path"])
127
+ entry = folded.get(key)
128
+ if entry is None or (entry["status"] == "removed" and line["status"] == "created"):
129
+ folded[key] = {"path": line["path"], "kind": line["kind"], "created": str(line.get("created") or ""),
130
+ "status": line["status"], **{name: line.get(name) for name in _FIELDS}}
131
+ continue
132
+ entry["status"] = line["status"]
133
+ entry["kind"] = line["kind"]
134
+ for name in _FIELDS:
135
+ if line.get(name) is not None and not (name == "purpose" and line[name] == ""):
136
+ entry[name] = line[name]
137
+ return sorted(folded.values(), key=lambda e: e["created"]) # stable: equal stamps keep file order
138
+
139
+
140
+ def _summary(entry: dict) -> str:
141
+ text = f"{entry['path']} | {entry['kind']} | {entry['status']} | {entry.get('purpose') or ''}"
142
+ return text + (f" | PR {entry['pr']}" if entry.get("pr") else "")
143
+
144
+
145
+ def main(argv: list[str] | None = None) -> int:
146
+ ap = argparse.ArgumentParser(description="Record or list what this session created (worktrees, scratch folders).")
147
+ sub = ap.add_subparsers(dest="cmd", required=True)
148
+ rec = sub.add_parser("record", help="append one line for a path")
149
+ rec.add_argument("--path", required=True)
150
+ rec.add_argument("--kind", required=True, choices=KINDS)
151
+ rec.add_argument("--purpose", default="")
152
+ rec.add_argument("--pr", default=None)
153
+ rec.add_argument("--repo", default=None)
154
+ rec.add_argument("--branch", default=None)
155
+ rec.add_argument("--status", default="created", choices=STATUSES)
156
+ rec.add_argument("--by", default="cli", choices=BYS)
157
+ rec.add_argument("--session-id", default=None)
158
+ lst = sub.add_parser("list", help="print the folded entries")
159
+ lst.add_argument("--session-id", default=None)
160
+ lst.add_argument("--json", action="store_true")
161
+ cln = sub.add_parser("cleanup", help="remove this session's merged, clean worktrees and scratch folders")
162
+ cln.add_argument("--session-id", default=None)
163
+ cln.add_argument("--apply", action="store_true", help="act; without it, only list (the default)")
164
+ cln.add_argument("--json", action="store_true")
165
+ try:
166
+ args = ap.parse_args(argv)
167
+ except SystemExit as exc:
168
+ return int(exc.code) if isinstance(exc.code, int) else 2
169
+ sid = session_id(args.session_id)
170
+ if args.cmd == "record":
171
+ if not record(args.path, args.kind, args.purpose, pr=args.pr, repo=args.repo, branch=args.branch,
172
+ status=args.status, by=args.by, session_id=args.session_id):
173
+ why = "no usable session id" if not sid else "the record could not be written"
174
+ print(f"caddis_created: not recorded ({why}): {args.path}", file=sys.stderr)
175
+ return 0
176
+ if args.cmd == "cleanup":
177
+ import caddis_own_cleanup # it deletes, so it lives apart from this record-only module (and imports it)
178
+ return caddis_own_cleanup.run(sid, args.apply, args.json)
179
+ if not sid:
180
+ print("caddis_created: no usable session id; nothing to list", file=sys.stderr)
181
+ entries = read(sid) if sid else []
182
+ if args.json:
183
+ print(json.dumps(entries, indent=2, ensure_ascii=False))
184
+ else:
185
+ for entry in entries:
186
+ print(_summary(entry))
187
+ return 0
188
+
189
+
190
+ if __name__ == "__main__":
191
+ sys.exit(main())
@@ -37,15 +37,18 @@ import tempfile
37
37
  from pathlib import Path
38
38
 
39
39
  try:
40
+ import caddis_created
40
41
  import caddis_exit
41
42
  import caddis_checkout_guard
42
43
  import caddis_roadmap as roadmap
43
44
  import caddis_worktree_safety
44
45
  except ModuleNotFoundError as exc:
45
- if exc.name not in {"caddis_exit", "caddis_checkout_guard", "caddis_roadmap", "caddis_worktree_safety"}:
46
+ if exc.name not in {"caddis_created", "caddis_exit", "caddis_checkout_guard", "caddis_roadmap",
47
+ "caddis_worktree_safety"}:
46
48
  raise
47
49
  sys.path.insert(0, str(Path(__file__).resolve().parent))
48
50
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
51
+ import caddis_created
49
52
  import caddis_exit
50
53
  import caddis_checkout_guard
51
54
  import caddis_roadmap as roadmap
@@ -87,6 +90,7 @@ def start(repo: Path, out=print) -> int:
87
90
  worktree = tmp_root / f"{DIR_PREFIX}{stamp}{suffix}"
88
91
  branch = f"{BRANCH_PREFIX}{stamp}{suffix}"
89
92
  _git(repo, "worktree", "add", "--quiet", "-b", branch, str(worktree), f"origin/{base}")
93
+ caddis_created.record(worktree, "worktree", "handoff docs PR", repo=repo, branch=branch, by="docs-pr")
90
94
  out(f"WORKTREE: {worktree}")
91
95
  return caddis_exit.CLEAN
92
96
 
@@ -199,7 +203,7 @@ def _save_patch(worktree: Path) -> Path:
199
203
  return Path(name)
200
204
 
201
205
 
202
- def _remove(main: Path, worktree: Path, branch: str) -> None:
206
+ def _remove(main: Path, worktree: Path, branch: str, pr: str | None = None) -> None:
203
207
  """Remove the worktree (links first, so a junction's target survives), prune, delete the branch.
204
208
 
205
209
  Best effort, as before: the result of each step is not checked. Every caller has already saved the session's
@@ -215,6 +219,9 @@ def _remove(main: Path, worktree: Path, branch: str) -> None:
215
219
  pass
216
220
  if worktree.exists():
217
221
  caddis_worktree_safety.remove_tree(worktree)
222
+ if not worktree.exists(): # only a folder that is really gone is recorded as removed
223
+ caddis_created.record(worktree, "worktree", "", pr=pr, repo=main, branch=branch, status="removed",
224
+ by="docs-pr")
218
225
 
219
226
 
220
227
  def finish(worktree: Path, title: str, body: str, out=print) -> int:
@@ -257,7 +264,8 @@ def finish(worktree: Path, title: str, body: str, out=print) -> int:
257
264
  out(f"PR: {url}")
258
265
  for path in files:
259
266
  out(f"FILE: {path}")
260
- _remove(main, worktree, branch)
267
+ pr_number = re.search(r"/pull/(\d+)\s*$", url)
268
+ _remove(main, worktree, branch, pr=pr_number.group(1) if pr_number else None)
261
269
  try:
262
270
  patch.unlink()
263
271
  except OSError:
@@ -34,6 +34,7 @@ except ModuleNotFoundError as exc:
34
34
  from caddis_heavy_slot import HeavySlotBusy, heavy_slot
35
35
 
36
36
  import caddis_checkout_guard
37
+ import caddis_created
37
38
  import caddis_routing
38
39
  import caddis_redact
39
40
  import caddis_spend
@@ -284,6 +285,8 @@ def create_phase_worktree(
284
285
  str(path),
285
286
  base_sha,
286
287
  )
288
+ caddis_created.record(path, "worktree", f"lane {plan_stem} phase {phase:02d} attempt {attempt}", repo=repo_root,
289
+ branch=branch, by="lanes")
287
290
  print(f"[caddis-lanes] worktree: {path}", file=sys.stderr)
288
291
  return Worktree(path=path, branch=branch, base_sha=base_sha)
289
292
 
@@ -307,6 +310,7 @@ def remove_phase_worktree(
307
310
  problem = f"worktree remove left {resolved} on disk"
308
311
  if problem:
309
312
  raise LaneConfigError(problem)
313
+ caddis_created.record(resolved, "worktree", "", repo=repo_root, branch=wt.branch, status="removed", by="lanes")
310
314
  if delete_branch:
311
315
  _git_output(repo_root, "branch", "-D", "--", wt.branch)
312
316
 
@@ -0,0 +1,424 @@
1
+ """Remove THIS session's own leftovers at handoff: merged, clean worktrees and scratch folders (tidy v2, Phase 2).
2
+
3
+ Input is the session's record (`caddis_created.read`): only the paths this session made. Each entry with status
4
+ `created` gets one decision; the first rule that matches wins. Anything in doubt is KEPT, with the reason.
5
+
6
+ Worktree (in order):
7
+ by roadmap-run -> KEPT (the runner reuses and removes its own) | missing -> GONE | a link, the main checkout, or
8
+ not a registered linked worktree -> KEPT | the process cwd is in it -> KEPT | `git status` not clean (untracked
9
+ counts) -> KEPT | locked -> KEPT | on the default branch -> KEPT | no merged PR (no gh, gh error, no PR, PR not
10
+ MERGED, PR head branch differs) -> KEPT | PR head commit unknown here, or commits after it -> KEPT | a file the branch changed differs
11
+ from `origin/<default>` (fetched first; this is the squash-merge proof that the work landed) -> KEPT |
12
+ else REMOVE: `safe_remove_worktree` (links first, never --force), then the local branch (only while its tip is
13
+ HEAD or the PR head and no other worktree has it), then the remote branch (only while its tip is the PR head or
14
+ the local tip, as one compare-and-delete push).
15
+ Scratch: missing -> GONE | a link, a root or home folder, or holds the cwd -> KEPT | inside a git checkout and
16
+ holds tracked files -> KEPT | a full scan (links never followed) that hits an unreadable folder -> KEPT | holds a git repo that is dirty, has commits on no remote, or is
17
+ a linked worktree -> KEPT | else REMOVE via `remove_tree` (links first).
18
+
19
+ Dry run is the default; `apply=True` acts and records `status=removed` (GONE too). Nothing raises to the caller.
20
+
21
+ cleanup(session_id, *, apply, home, pr_lookup, cwd) -> list[{path, kind, action, reason}]
22
+ action: REMOVE (dry run) | REMOVED | KEPT | GONE | FAILED
23
+ summary(results, apply) -> "[OWN-CLEANUP] removed <n>, kept <n>, gone <n>, failed <n>"
24
+ run(session_id, apply, as_json) -> 0 (the `caddis_created.py cleanup` CLI)
25
+
26
+ `pr_lookup(main_repo, branch, pr) -> list[{number, state, headRefOid, headRefName}]` (newest first) is the gh
27
+ seam; it raises PrLookupError. The default calls plain `gh` with the inherited environment (never `gh auth switch`).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ import re
35
+ import subprocess
36
+ import sys
37
+ from collections.abc import Callable
38
+ from pathlib import Path
39
+
40
+ import caddis_created
41
+ import caddis_worktree_safety as safety
42
+
43
+ GIT_TIMEOUT = 60
44
+ NET_TIMEOUT = 120
45
+ _GH_FIELDS = "number,state,headRefOid,mergedAt,headRefName"
46
+
47
+
48
+ class PrLookupError(Exception):
49
+ """gh is missing, failed, or the repo cannot be named."""
50
+
51
+
52
+ def _env() -> dict[str, str]:
53
+ env = dict(os.environ)
54
+ env["GIT_TERMINAL_PROMPT"] = "0" # a credential prompt must fail, not hang the handoff
55
+ return env
56
+
57
+
58
+ def _run(cmd: list[str], cwd: Path | None = None, timeout: int = GIT_TIMEOUT) -> subprocess.CompletedProcess:
59
+ """Never raises: a missing program or a timeout comes back as returncode -1 with the problem in stderr."""
60
+ try:
61
+ return subprocess.run(cmd, cwd=str(cwd) if cwd else None, capture_output=True, text=True, encoding="utf-8",
62
+ errors="replace", stdin=subprocess.DEVNULL, timeout=timeout, check=False, env=_env())
63
+ except FileNotFoundError:
64
+ return subprocess.CompletedProcess(cmd, -1, "", f"{cmd[0]} is not installed")
65
+ except subprocess.TimeoutExpired:
66
+ return subprocess.CompletedProcess(cmd, -1, "", f"{cmd[0]} timed out after {timeout} s")
67
+ except OSError as exc:
68
+ return subprocess.CompletedProcess(cmd, -1, "", str(exc))
69
+
70
+
71
+ def _git(path: Path, *args: str, timeout: int = GIT_TIMEOUT) -> subprocess.CompletedProcess:
72
+ return _run(["git", "-c", "core.quotepath=off", "-C", str(path), *args], timeout=timeout)
73
+
74
+
75
+ def _err(proc: subprocess.CompletedProcess) -> str:
76
+ text = (proc.stderr or proc.stdout or "").strip()
77
+ return text.splitlines()[-1] if text else f"exit {proc.returncode}"
78
+
79
+
80
+ def _same(a: str | Path, b: str | Path) -> bool:
81
+ return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b))
82
+
83
+
84
+ def _inside(child: str | Path, parent: str | Path) -> bool:
85
+ """`child` is `parent` or under it (both resolved, case-folded on Windows)."""
86
+ c = os.path.normcase(os.path.realpath(child))
87
+ p = os.path.normcase(os.path.realpath(parent))
88
+ return c == p or c.startswith(p.rstrip("\\/") + os.sep)
89
+
90
+
91
+ # -- gh ---------------------------------------------------------------------------------------------------------
92
+
93
+ def _slug(main_repo: Path) -> str | None:
94
+ """`owner/name` from the origin URL (https, ssh or scp form), else None."""
95
+ proc = _git(main_repo, "remote", "get-url", "origin")
96
+ url = proc.stdout.strip() if proc.returncode == 0 else ""
97
+ if "://" not in url and "@" not in url:
98
+ return None # a local path origin has no GitHub name
99
+ match = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?/?$", url)
100
+ return f"{match.group(1)}/{match.group(2)}" if match else None
101
+
102
+
103
+ def gh_pr_lookup(main_repo: Path, branch: str | None, pr: str | None) -> list[dict]:
104
+ slug = _slug(main_repo)
105
+ if not slug:
106
+ raise PrLookupError("cannot read owner/name from the origin URL")
107
+ if pr:
108
+ proc = _run(["gh", "pr", "view", pr, "--repo", slug, "--json", _GH_FIELDS], timeout=NET_TIMEOUT)
109
+ elif branch:
110
+ proc = _run(["gh", "pr", "list", "--repo", slug, "--head", branch, "--state", "all", "--limit", "20",
111
+ "--json", _GH_FIELDS], timeout=NET_TIMEOUT)
112
+ else:
113
+ return []
114
+ if proc.returncode != 0:
115
+ raise PrLookupError(f"gh failed: {_err(proc)}")
116
+ try:
117
+ data = json.loads(proc.stdout or "null")
118
+ except ValueError as exc:
119
+ raise PrLookupError(f"gh printed bad JSON: {exc}") from exc
120
+ data = [data] if isinstance(data, dict) else data
121
+ if not isinstance(data, list):
122
+ raise PrLookupError("gh printed an unexpected shape")
123
+ return [d for d in data if isinstance(d, dict)]
124
+
125
+
126
+ def _pr_number(value: str | None) -> str | None:
127
+ match = re.search(r"(\d+)\s*$", str(value or ""))
128
+ return match.group(1) if match else None
129
+
130
+
131
+ def _pick_pr(prs: list[dict], branch: str | None, by_number: bool) -> tuple[dict | None, str]:
132
+ """(the merged PR, "") or (None, why the worktree is kept)."""
133
+ if not prs:
134
+ return None, f"no merged PR found: no PR for branch {branch}" if branch else "no merged PR found: no PR"
135
+ open_pr = next((p for p in prs if str(p.get("state", "")).upper() == "OPEN"), None)
136
+ pick = open_pr or prs[0]
137
+ state = str(pick.get("state", "")).upper() or "UNKNOWN"
138
+ if state != "MERGED":
139
+ return None, f"PR #{pick.get('number')} is {state}"
140
+ head_name = pick.get("headRefName")
141
+ if by_number and branch and head_name and head_name != branch:
142
+ return None, f"PR #{pick.get('number')} is for branch {head_name}, not {branch}"
143
+ return pick, ""
144
+
145
+
146
+ # -- worktree ---------------------------------------------------------------------------------------------------
147
+
148
+ def _main_repo(entry: dict, path: Path) -> Path | None:
149
+ """The repo to list worktrees from: the entry's `repo` when it is a git checkout, else from the path's common dir."""
150
+ for start in (entry.get("repo"), str(path)):
151
+ if not start or not os.path.isdir(start):
152
+ continue
153
+ proc = _git(Path(start), "rev-parse", "--path-format=absolute", "--git-common-dir")
154
+ if proc.returncode == 0 and proc.stdout.strip():
155
+ common = Path(proc.stdout.strip())
156
+ return common.parent if common.name == ".git" else Path(start)
157
+ return None
158
+
159
+
160
+ def _default_branch(wt: Path) -> str:
161
+ proc = _git(wt, "symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD")
162
+ name = proc.stdout.strip() if proc.returncode == 0 else ""
163
+ return name.split("/", 1)[1] if name.startswith("origin/") else "main"
164
+
165
+
166
+ def _ref(wt: Path, rev: str) -> str | None:
167
+ proc = _git(wt, "rev-parse", "-q", "--verify", f"{rev}^{{commit}}")
168
+ return proc.stdout.strip() if proc.returncode == 0 and proc.stdout.strip() else None
169
+
170
+
171
+ def _names(wt: Path, a: str, b: str) -> set[str] | None:
172
+ proc = _git(wt, "diff", "--name-only", "-z", "--no-renames", "--no-ext-diff", a, b)
173
+ return None if proc.returncode != 0 else {n for n in proc.stdout.split("\0") if n}
174
+
175
+
176
+ def _check_worktree(entry: dict, path: Path, cwd: str, pr_lookup: Callable) -> tuple[str, str, dict]:
177
+ """("REMOVE", reason, facts) or ("KEPT"/"GONE", reason, {})."""
178
+ if entry.get("by") == "roadmap-run":
179
+ return "KEPT", "owned by the roadmap runner", {}
180
+ if not os.path.lexists(path):
181
+ return "GONE", "", {}
182
+ try:
183
+ if safety.is_link(os.lstat(path)):
184
+ return "KEPT", "the folder is a junction or symlink", {}
185
+ except OSError as exc:
186
+ return "KEPT", f"cannot inspect it: {exc}", {}
187
+ repo = _main_repo(entry, path)
188
+ if repo is None:
189
+ return "KEPT", "not a registered linked worktree (no git repo found)", {}
190
+ listing = _git(repo, "worktree", "list", "--porcelain")
191
+ if listing.returncode != 0:
192
+ return "KEPT", f"git worktree list failed: {_err(listing)}", {}
193
+ blocks = safety._worktree_blocks(listing.stdout)
194
+ if not blocks:
195
+ return "KEPT", "git listed no worktrees", {}
196
+ main = Path(blocks[0][0][len("worktree "):])
197
+ if _same(path, main):
198
+ return "KEPT", "it is the main checkout", {}
199
+ block = next((b for b in blocks[1:] if _same(path, b[0][len("worktree "):])), None)
200
+ if block is None:
201
+ return "KEPT", f"not a registered linked worktree of {main}", {}
202
+ if _inside(cwd, path):
203
+ return "KEPT", "this session is working in it", {}
204
+ status = _git(path, "status", "--porcelain", "--untracked-files=normal")
205
+ if status.returncode != 0:
206
+ return "KEPT", f"git status failed: {_err(status)}", {}
207
+ if status.stdout.strip():
208
+ return "KEPT", "uncommitted changes", {}
209
+ if any(line == "locked" or line.startswith("locked ") for line in block):
210
+ return "KEPT", "it is locked", {}
211
+ sym = _git(path, "symbolic-ref", "--short", "-q", "HEAD")
212
+ branch = sym.stdout.strip() if sym.returncode == 0 and sym.stdout.strip() else (entry.get("branch") or None)
213
+ default = _default_branch(path)
214
+ if branch and branch == default:
215
+ return "KEPT", f"it is on the default branch {default}", {}
216
+ pr = _pr_number(entry.get("pr"))
217
+ if not branch and not pr:
218
+ return "KEPT", "no merged PR found: no branch and no PR in the record", {}
219
+ try:
220
+ prs = pr_lookup(main, branch, pr)
221
+ except PrLookupError as exc:
222
+ return "KEPT", f"no merged PR found: {exc}", {}
223
+ except Exception as exc: # noqa: BLE001 - any lookup failure keeps the worktree
224
+ return "KEPT", f"no merged PR found: lookup failed: {exc}", {}
225
+ merged, why = _pick_pr(prs, branch, by_number=bool(pr))
226
+ if merged is None:
227
+ return "KEPT", why, {}
228
+ number = str(merged.get("number") or pr or "?")
229
+ head = _ref(path, "HEAD")
230
+ if not head:
231
+ return "KEPT", "cannot read HEAD", {}
232
+ pr_head = str(merged.get("headRefOid") or "").strip().lower()
233
+ if not pr_head or not _ref(path, pr_head): # a reused branch name: without the PR head, nothing proves HEAD landed
234
+ return "KEPT", f"PR #{number} head commit is not in the local repo", {}
235
+ after = _git(path, "rev-list", "--max-count=1", f"{pr_head}..HEAD")
236
+ if after.returncode != 0:
237
+ return "KEPT", f"git rev-list failed: {_err(after)}", {}
238
+ if after.stdout.strip():
239
+ return "KEPT", f"commits after the merged PR #{number}", {}
240
+ fetch = _git(path, "fetch", "--quiet", "--no-tags", "origin",
241
+ f"+refs/heads/{default}:refs/remotes/origin/{default}", timeout=NET_TIMEOUT)
242
+ if fetch.returncode != 0:
243
+ return "KEPT", f"cannot fetch origin/{default}: {_err(fetch)}", {}
244
+ target = f"refs/remotes/origin/{default}"
245
+ base = _git(path, "merge-base", target, "HEAD")
246
+ if base.returncode != 0 or not base.stdout.strip():
247
+ return "KEPT", f"no merge base with origin/{default}", {}
248
+ changed = _names(path, base.stdout.strip(), "HEAD")
249
+ differ_all = _names(path, target, "HEAD")
250
+ if changed is None or differ_all is None:
251
+ return "KEPT", f"git diff against origin/{default} failed", {}
252
+ differ = sorted(changed & differ_all)
253
+ if differ:
254
+ return "KEPT", f"{len(differ)} file(s) differ from origin/{default}: {', '.join(differ[:3])}", {}
255
+ facts = {"repo": main, "branch": branch, "pr": number, "pr_head": pr_head, "head": head}
256
+ return "REMOVE", f"PR #{number} merged, clean, tree equals origin/{default}", facts
257
+
258
+
259
+ def _branch_tip(repo: Path, branch: str) -> str | None:
260
+ proc = _git(repo, "rev-parse", "-q", "--verify", f"refs/heads/{branch}")
261
+ return proc.stdout.strip() if proc.returncode == 0 and proc.stdout.strip() else None
262
+
263
+
264
+ def _remove_worktree(path: Path, facts: dict) -> tuple[str, str]:
265
+ repo, branch, head, pr_head = facts["repo"], facts["branch"], facts["head"], facts["pr_head"]
266
+ local_tip = _branch_tip(repo, branch) if branch else None
267
+ problem = safety.safe_remove_worktree(repo, path, force=False, env=_env())
268
+ if problem:
269
+ return "FAILED", problem
270
+ notes: list[str] = []
271
+ if branch and local_tip:
272
+ listing = _git(repo, "worktree", "list", "--porcelain")
273
+ if listing.returncode != 0 or f"branch refs/heads/{branch}" in listing.stdout.splitlines():
274
+ notes.append("local branch kept: another worktree has it checked out")
275
+ elif local_tip not in {head, pr_head}:
276
+ notes.append("local branch kept: its tip is not the removed HEAD or the PR head")
277
+ else:
278
+ gone = _git(repo, "branch", "-D", "--", branch)
279
+ notes.append("local branch deleted" if gone.returncode == 0 else f"local branch kept: {_err(gone)}")
280
+ if branch:
281
+ ls = _git(repo, "ls-remote", "--heads", "origin", f"refs/heads/{branch}", timeout=NET_TIMEOUT)
282
+ tips = [ln.split()[0] for ln in ls.stdout.splitlines() if ln.strip()] if ls.returncode == 0 else []
283
+ if ls.returncode != 0:
284
+ notes.append(f"remote branch kept: ls-remote failed: {_err(ls)}")
285
+ elif tips:
286
+ tip = tips[0].lower()
287
+ if tip in {pr_head, head.lower()} - {""}: # only a tip this check proved landed
288
+ try:
289
+ push = _git(repo, *safety.remote_delete_args(branch, tip), timeout=NET_TIMEOUT)
290
+ notes.append("remote branch deleted" if push.returncode == 0
291
+ else f"remote branch kept: {_err(push)}")
292
+ except ValueError as exc:
293
+ notes.append(f"remote branch kept: {exc}")
294
+ else:
295
+ notes.append("remote branch kept: its tip moved past the PR head")
296
+ return "REMOVED", "; ".join(notes)
297
+
298
+
299
+ # -- scratch ----------------------------------------------------------------------------------------------------
300
+
301
+ def _scan_scratch(path: Path) -> str | None:
302
+ """None when the folder may go, else why not. Walks every folder (links never followed); errors raise."""
303
+ def fail(exc: OSError) -> None:
304
+ raise exc
305
+
306
+ try:
307
+ for top, dirs, files in os.walk(path, topdown=True, onerror=fail, followlinks=False):
308
+ # os.walk does not see a Windows junction as a link: prune every link by lstat, so none is walked
309
+ dirs[:] = [d for d in dirs if not safety.is_link(os.lstat(os.path.join(top, d)))]
310
+ if ".git" not in dirs and ".git" not in files:
311
+ continue
312
+ repo = Path(top)
313
+ if os.path.isfile(repo / ".git"):
314
+ return f"holds a linked git worktree at {repo}; remove it as a worktree"
315
+ status = _git(repo, "status", "--porcelain")
316
+ if status.returncode != 0:
317
+ return f"holds a git repo at {repo} whose status failed: {_err(status)}"
318
+ if status.stdout.strip():
319
+ return f"holds a git repo with uncommitted changes at {repo}"
320
+ local = _git(repo, "rev-list", "--max-count=1", "--all", "--not", "--remotes")
321
+ if local.returncode != 0 or local.stdout.strip():
322
+ return f"holds a git repo with commits or a stash on no remote at {repo}"
323
+ dirs[:] = [d for d in dirs if d != ".git"]
324
+ except OSError as exc:
325
+ return f"cannot scan: {exc}"
326
+ return None
327
+
328
+
329
+ def _check_scratch(path: Path, cwd: str) -> tuple[str, str]:
330
+ if not os.path.lexists(path):
331
+ return "GONE", ""
332
+ try:
333
+ if safety.is_link(os.lstat(path)):
334
+ return "KEPT", "the folder is a junction or symlink"
335
+ except OSError as exc:
336
+ return "KEPT", f"cannot inspect it: {exc}"
337
+ if not path.is_dir():
338
+ return "KEPT", "not a folder"
339
+ resolved = Path(os.path.realpath(path))
340
+ if resolved.parent == resolved or _same(resolved, Path.home()) or _inside(Path.home(), resolved):
341
+ return "KEPT", "a root or home folder is never scratch"
342
+ if _inside(cwd, path):
343
+ return "KEPT", "this session is working in it"
344
+ if not os.path.lexists(path / ".git"): # a folder inside a checkout must not take tracked files with it
345
+ inside = _git(path, "rev-parse", "--is-inside-work-tree")
346
+ if inside.returncode == 0 and inside.stdout.strip() == "true":
347
+ tracked = _git(path, "ls-files", "-z", "--", ".")
348
+ if tracked.returncode != 0 or tracked.stdout.strip("\0"):
349
+ return "KEPT", "inside a git checkout and holds tracked files"
350
+ problem = _scan_scratch(path)
351
+ return ("KEPT", problem) if problem else ("REMOVE", "scratch folder of this session")
352
+
353
+
354
+ # -- driver -----------------------------------------------------------------------------------------------------
355
+
356
+ def _mark_removed(entry: dict, sid: str, home, pr: str | None = None) -> None:
357
+ by = entry.get("by") if entry.get("by") in caddis_created.BYS else "cli"
358
+ caddis_created.record(entry["path"], entry["kind"], "", pr=pr, status="removed", by=by, session_id=sid,
359
+ home=home)
360
+
361
+
362
+ def cleanup(session_id: str, *, apply: bool = False, home: Path | str | None = None,
363
+ pr_lookup: Callable | None = None, cwd: str | Path | None = None) -> list[dict]:
364
+ lookup = pr_lookup or gh_pr_lookup
365
+ here = str(cwd) if cwd is not None else os.getcwd()
366
+ results: list[dict] = []
367
+ for entry in caddis_created.read(session_id, home=home):
368
+ if entry.get("status") != "created":
369
+ continue
370
+ path = Path(entry["path"])
371
+ try:
372
+ if entry["kind"] == "worktree":
373
+ action, reason, facts = _check_worktree(entry, path, here, lookup)
374
+ else:
375
+ (action, reason), facts = _check_scratch(path, here), {}
376
+ if apply and action == "GONE":
377
+ _mark_removed(entry, session_id, home)
378
+ elif apply and action == "REMOVE":
379
+ if entry["kind"] == "worktree":
380
+ action, notes = _remove_worktree(path, facts)
381
+ if action != "REMOVED":
382
+ reason = notes
383
+ elif notes:
384
+ reason = f"{reason}; {notes}"
385
+ else:
386
+ problem = safety.remove_tree(path)
387
+ action, reason = ("FAILED", problem) if problem else ("REMOVED", reason)
388
+ if action == "REMOVED":
389
+ _mark_removed(entry, session_id, home, facts.get("pr"))
390
+ except Exception as exc: # noqa: BLE001 - one bad entry must not stop the others
391
+ action, reason = "FAILED", f"unexpected error: {exc}"
392
+ results.append({"path": str(path), "kind": entry["kind"], "action": action, "reason": reason})
393
+ return results
394
+
395
+
396
+ def line(result: dict) -> str:
397
+ action, path, reason = result["action"], result["path"], result["reason"]
398
+ if action == "GONE":
399
+ return f"GONE {path}"
400
+ if action in ("REMOVE", "REMOVED"):
401
+ return f"{action} {path}" + (f" ({reason})" if reason else "")
402
+ return f"{action} {path}: {reason}"
403
+
404
+
405
+ def summary(results: list[dict], apply: bool) -> str:
406
+ count = {a: sum(1 for r in results if r["action"] == a) for a in ("REMOVE", "REMOVED", "KEPT", "GONE", "FAILED")}
407
+ head = f"removed {count['REMOVED']}" if apply else f"would remove {count['REMOVE']}"
408
+ return f"[OWN-CLEANUP] {head}, kept {count['KEPT']}, gone {count['GONE']}, failed {count['FAILED']}"
409
+
410
+
411
+ def run(session_id: str | None, apply: bool, as_json: bool) -> int:
412
+ """The CLI body. Always 0: a handoff must never fail on its cleanup row."""
413
+ if not session_id:
414
+ print("caddis_created: no usable session id; nothing to clean up", file=sys.stderr)
415
+ results: list[dict] = []
416
+ else:
417
+ results = cleanup(session_id, apply=apply)
418
+ if as_json:
419
+ print(json.dumps(results, indent=2, ensure_ascii=False))
420
+ return 0
421
+ for result in results:
422
+ print(line(result))
423
+ print(summary(results, apply))
424
+ return 0
@@ -130,6 +130,7 @@ except ModuleNotFoundError as exc:
130
130
  sys.path.insert(0, str(_HERE.parent / "claude-harness" / "scripts"))
131
131
  import caddis_exit
132
132
 
133
+ import caddis_created # noqa: E402
133
134
  import caddis_notify # noqa: E402
134
135
  import caddis_redact # noqa: E402
135
136
  import caddis_review # noqa: E402
@@ -1577,6 +1578,7 @@ def ensure_worktree(repo: Path, slug: str, default: str, root: Path | None = Non
1577
1578
  path.parent.mkdir(parents=True, exist_ok=True)
1578
1579
  if _git_out(repo, "worktree", "add", "--quiet", "--detach", str(path), f"origin/{default}") is None:
1579
1580
  raise _Stop(f"FAILED cannot make the roadmap worktree {path}", caddis_exit.BLOCKED)
1581
+ caddis_created.record(path, "worktree", f"roadmap {slug} runner worktree", repo=repo, by="roadmap-run")
1580
1582
  _write_record(_state_file(repo, slug, "worktree"), {"path": str(path), "utc": _iso(datetime.now(timezone.utc))})
1581
1583
  return path
1582
1584
 
@@ -1606,6 +1608,7 @@ def remove_worktree_if_clean(repo: Path, slug: str, root: Path | None = None) ->
1606
1608
  if caddis_worktree_safety.safe_remove_worktree(repo, path) is not None:
1607
1609
  return f"worktree kept (remove failed): {path}"
1608
1610
  _state_file(repo, slug, "worktree").unlink(missing_ok=True)
1611
+ caddis_created.record(path, "worktree", "", repo=repo, status="removed", by="roadmap-run")
1609
1612
  return f"worktree removed: {path}"
1610
1613
 
1611
1614
 
@@ -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. Exit 2 on a bad name
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
- print(worktree_path(args.repo, args.short_name, args.root))
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. The caddis guard checks only the `Shared checkout: yes` line in
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 13 in this order. Never drop a row from the report, even when its step did not apply.
55
- Rows 3 and 8 are retired (the 3-store model, 21 Oct 2026): they stay in the list so the row numbers and the
56
- 13-row report do not move for a fleet that mixes caddis versions, and each always reports
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) |
@@ -71,6 +73,7 @@ Rows 3 and 8 are retired (the 3-store model, 21 Oct 2026): they stay in the list
71
73
  | 11 | `state-prune` | Step 6b: `session_state.py prune` | `skipped: shared checkout` |
72
74
  | 12 | `prune-list` | Step 6b: `caddis_tidy.py --prune`, 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
79
  `caddis_tidy.py --prune --apply` on its own, in any checkout; only the user can ask for it (Step 6b). In a
@@ -346,6 +349,27 @@ Then, when the repo has a roadmap (`.caddis/roadmaps/`), re-render its page so p
346
349
  python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_roadmap.py" render --all
347
350
  ```
348
351
 
352
+ ## Step 6d — remove this session's own worktrees and scratch folders
353
+ Checklist row 14, `own-cleanup`. It runs in every checkout, a shared one too, after the Step 0 `finish`
354
+ (falls back to `scripts/caddis_created.py` in a source checkout; if the script is missing, or exits 2 because
355
+ it has no `cleanup`, the row is `skipped: not installed`):
356
+ ```
357
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_created.py" cleanup --session-id <session-id> --apply
358
+ ```
359
+ It reads only this session's record (`~/.caddis/sessions/<session-id>.created.jsonl`). A worktree goes only
360
+ when its PR is merged, `git status` is clean, it has no commits after the PR head, and every file its branch
361
+ changed equals `origin/<default>` (so a squash merge counts). Then its local and remote branch go too: each
362
+ only while its tip is still the merged work. A scratch folder goes after a full scan, links first.
363
+
364
+ It never removes: another session's worktree (it is not in this record), a roadmap runner's worktree, the
365
+ worktree this session is working in, one with uncommitted changes (untracked files count), one whose PR is
366
+ not merged or not found, one whose tree differs from `origin/<default>`, or anything through a
367
+ junction or symlink (a link is removed as a link; its target stays). It prints one line per entry (`REMOVED`, `KEPT
368
+ <path>: <reason>`, `GONE` or `FAILED <path>: <problem>`) and ends with
369
+ `[OWN-CLEANUP] removed <n>, kept <n>, gone <n>, failed <n>`. Include that summary line and each `KEPT` and
370
+ `FAILED` line in your report. It always exits 0: a kept or failed entry is not a failure of the row, which is
371
+ `done` when the script ran.
372
+
349
373
  ## Step 7 — push prompt (interactive only)
350
374
  If this is an interactive session, ask the user every time — never reuse an earlier answer:
351
375
  `Push this note for another machine? (y/N)`
@@ -376,6 +400,7 @@ Print this block, every row filled with `done`, `skipped: <reason>` or `failed:
376
400
  11 state-prune <same three forms>
377
401
  12 prune-list <same three forms>
378
402
  13 handover-check <same three forms>
403
+ 14 own-cleanup <same three forms>
379
404
  Shared checkout: yes|no <reason>
380
405
  ```
381
406
 
@@ -43,10 +43,10 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
43
43
 
44
44
  - **On `SHARED: no`,** do the steps as written, in this checkout.
45
45
  - **On `SHARED: yes`,** never move this checkout. Leave its changes as they are, and report them. Instead:
46
- - **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name>)" <branch>`, then
46
+ - **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name> --record --purpose "rebase <name>")" <branch>`, then
47
47
  `git -C <worktree> rebase origin/<default>` and `git -C <worktree> push --force-with-lease origin HEAD:<branch>`.
48
48
  - **Pull a branch to test it:** `git fetch origin`, then
49
- `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name>)" origin/<branch>` (for the default branch,
49
+ `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,
50
50
  `origin/<default>`), and run the gates there. Install dependencies fresh; never link `node_modules` or a venv.
51
51
  - **Clean up after a merge:** do not switch or pull. Run `git fetch --prune`, then
52
52
  `git fetch origin <default>:<default>`: it fast-forwards the default branch with no checkout. Git refuses when a
@@ -56,7 +56,8 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
56
56
 
57
57
  `<name>` is the branch name with each `/` written as `-`. `caddis_worktree_root.py` prints the path of a new worktree:
58
58
  `$CADDIS_WORKTREE_ROOT/<repo>/<short-name>`, else `<parent of the main checkout>/.worktrees/<repo>/<short-name>`. Never `%TEMP%`: Windows
59
- cleanup can empty it. The caddis guard checks only the `Shared checkout: yes` line in
59
+ cleanup can empty it. `--record --purpose "<why>"` also writes the path to this
60
+ 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
60
61
  `AGENTS.md`, not a dirty tree: with that line, it asks before a tree move, and denies it in a headless session.
61
62
  <!-- /checkout-safety -->
62
63
 
@@ -42,10 +42,10 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
42
42
 
43
43
  - **On `SHARED: no`,** do the steps as written, in this checkout.
44
44
  - **On `SHARED: yes`,** never move this checkout. Leave its changes as they are, and report them. Instead:
45
- - **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name>)" <branch>`, then
45
+ - **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name> --record --purpose "rebase <name>")" <branch>`, then
46
46
  `git -C <worktree> rebase origin/<default>` and `git -C <worktree> push --force-with-lease origin HEAD:<branch>`.
47
47
  - **Pull a branch to test it:** `git fetch origin`, then
48
- `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name>)" origin/<branch>` (for the default branch,
48
+ `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,
49
49
  `origin/<default>`), and run the gates there. Install dependencies fresh; never link `node_modules` or a venv.
50
50
  - **Clean up after a merge:** do not switch or pull. Run `git fetch --prune`, then
51
51
  `git fetch origin <default>:<default>`: it fast-forwards the default branch with no checkout. Git refuses when a
@@ -55,7 +55,8 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
55
55
 
56
56
  `<name>` is the branch name with each `/` written as `-`. `caddis_worktree_root.py` prints the path of a new worktree:
57
57
  `$CADDIS_WORKTREE_ROOT/<repo>/<short-name>`, else `<parent of the main checkout>/.worktrees/<repo>/<short-name>`. Never `%TEMP%`: Windows
58
- cleanup can empty it. The caddis guard checks only the `Shared checkout: yes` line in
58
+ cleanup can empty it. `--record --purpose "<why>"` also writes the path to this
59
+ 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
59
60
  `AGENTS.md`, not a dirty tree: with that line, it asks before a tree move, and denies it in a headless session.
60
61
  <!-- /checkout-safety -->
61
62
 
@@ -41,10 +41,10 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
41
41
 
42
42
  - **On `SHARED: no`,** do the steps as written, in this checkout.
43
43
  - **On `SHARED: yes`,** never move this checkout. Leave its changes as they are, and report them. Instead:
44
- - **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name>)" <branch>`, then
44
+ - **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name> --record --purpose "rebase <name>")" <branch>`, then
45
45
  `git -C <worktree> rebase origin/<default>` and `git -C <worktree> push --force-with-lease origin HEAD:<branch>`.
46
46
  - **Pull a branch to test it:** `git fetch origin`, then
47
- `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name>)" origin/<branch>` (for the default branch,
47
+ `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,
48
48
  `origin/<default>`), and run the gates there. Install dependencies fresh; never link `node_modules` or a venv.
49
49
  - **Clean up after a merge:** do not switch or pull. Run `git fetch --prune`, then
50
50
  `git fetch origin <default>:<default>`: it fast-forwards the default branch with no checkout. Git refuses when a
@@ -54,7 +54,8 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
54
54
 
55
55
  `<name>` is the branch name with each `/` written as `-`. `caddis_worktree_root.py` prints the path of a new worktree:
56
56
  `$CADDIS_WORKTREE_ROOT/<repo>/<short-name>`, else `<parent of the main checkout>/.worktrees/<repo>/<short-name>`. Never `%TEMP%`: Windows
57
- cleanup can empty it. The caddis guard checks only the `Shared checkout: yes` line in
57
+ cleanup can empty it. `--record --purpose "<why>"` also writes the path to this
58
+ 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
58
59
  `AGENTS.md`, not a dirty tree: with that line, it asks before a tree move, and denies it in a headless session.
59
60
  <!-- /checkout-safety -->
60
61
 
@@ -39,10 +39,16 @@ If none is true, stop here and use `git switch -c`.
39
39
  script `caddis_worktree_root.py` prints the path; use it so the rule lives in one place:
40
40
 
41
41
  ```powershell
42
- $wt = python "<caddis scripts>\caddis_worktree_root.py" <short-name> # run in the repo; prints the path
42
+ $wt = python "<caddis scripts>\caddis_worktree_root.py" <short-name> --record --purpose "<why>" # run in the repo; prints the path
43
43
  git worktree add -b <branch> $wt origin/<default>
44
44
  ```
45
45
 
46
+ `--record` also writes the path to this session's created list (`caddis_created.py`), so `/handoff` can remove the
47
+ worktree later. Always record it, subagents most of all: their tool calls are in a separate transcript that the
48
+ Stop hook does not read. If you create a worktree without the helper, run
49
+ `python "<caddis scripts>\caddis_created.py" record --path <path> --kind worktree --purpose "<why>"` right after
50
+ `git worktree add`.
51
+
46
52
  `<caddis scripts>` is the `scripts` folder of the installed caddis plugin. Do not create worktrees inside the
47
53
  repo. An existing worktree stays where it is: moving it breaks a venv, which keeps absolute paths.
48
54
  4. State the full path of the worktree. Work only there, and do not switch branches in it.
@@ -39,10 +39,16 @@ If none is true, stop here and use `git switch -c`.
39
39
  script `caddis_worktree_root.py` prints the path; use it so the rule lives in one place:
40
40
 
41
41
  ```powershell
42
- $wt = python "<caddis scripts>\caddis_worktree_root.py" <short-name> # run in the repo; prints the path
42
+ $wt = python "<caddis scripts>\caddis_worktree_root.py" <short-name> --record --purpose "<why>" # run in the repo; prints the path
43
43
  git worktree add -b <branch> $wt origin/<default>
44
44
  ```
45
45
 
46
+ `--record` also writes the path to this session's created list (`caddis_created.py`), so `/handoff` can remove the
47
+ worktree later. Always record it, subagents most of all: their tool calls are in a separate transcript that the
48
+ Stop hook does not read. If you create a worktree without the helper, run
49
+ `python "<caddis scripts>\caddis_created.py" record --path <path> --kind worktree --purpose "<why>"` right after
50
+ `git worktree add`.
51
+
46
52
  `<caddis scripts>` is the `scripts` folder of the installed caddis plugin. Do not create worktrees inside the
47
53
  repo. An existing worktree stays where it is: moving it breaks a venv, which keeps absolute paths.
48
54
  4. State the full path of the worktree. Work only there, and do not switch branches in it.
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.152",
2
+ "poolVersion": "1.3.153",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.152",
4
+ "antigravity-plugin": "1.3.153",
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.66",
3
+ "version": "0.4.67",
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",