@caddis/cli 0.4.66 → 0.4.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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