@caddis/cli 0.4.55 → 0.4.57

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.
Files changed (58) hide show
  1. package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
  2. package/bundles/antigravity-plugin/guard_agy.py +1056 -83
  3. package/bundles/antigravity-plugin/hooks.json +2 -1
  4. package/bundles/antigravity-plugin/plugin.json +1 -1
  5. package/bundles/antigravity-plugin/repo_freshness.py +182 -4
  6. package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
  7. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
  8. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
  9. package/bundles/antigravity-plugin/scripts/caddis_gate.py +27 -1
  10. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +93 -6
  11. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
  12. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
  13. package/bundles/antigravity-plugin/scripts/caddis_review.py +290 -176
  14. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +346 -50
  15. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +82 -8
  16. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
  17. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
  18. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
  19. package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
  20. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
  21. package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
  22. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
  23. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
  24. package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
  25. package/bundles/antigravity-plugin/scripts/oss_review.py +303 -79
  26. package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
  27. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +6 -1
  28. package/bundles/antigravity-plugin/session_end_agy.py +27 -11
  29. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +55 -9
  30. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
  31. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
  32. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +10 -6
  33. package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
  34. package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -17
  35. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +45 -5
  37. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
  38. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
  39. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
  40. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
  41. package/bundles/antigravity-plugin/skills/ship/SKILL.md +32 -10
  42. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +32 -1
  43. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +44 -6
  44. package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
  45. package/bundles/antigravity-plugin/warm_start_agy.py +40 -16
  46. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  47. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  48. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
  50. package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
  51. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
  52. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +10 -6
  53. package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
  54. package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
  55. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
  56. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
  57. package/bundles/manifest.json +3 -3
  58. package/package.json +1 -1
@@ -9,7 +9,8 @@
9
9
  "command": "python guard_agy.py",
10
10
  "windows": "python guard_agy.py",
11
11
  "linux": "python3 guard_agy.py",
12
- "osx": "python3 guard_agy.py"
12
+ "osx": "python3 guard_agy.py",
13
+ "timeout": 30
13
14
  }
14
15
  ]
15
16
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.141",
3
+ "version": "1.3.143",
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
  }
@@ -10,6 +10,14 @@ FAIL OPEN, ALWAYS. Every path exits 0 and every git call is wrapped. A hook that
10
10
  or fail a session start is worse than the staleness it prevents, so a failed or slow fetch
11
11
  reports and continues.
12
12
 
13
+ NO LOCKS, NO PROMPTS, A TIME BUDGET (robustness audit #6, 18 Oct 2026). Every git call
14
+ runs with GIT_OPTIONAL_LOCKS=0 (and `git status` with --no-optional-locks), so this hook never takes
15
+ index.lock from a peer session mid-commit. Every call runs with GIT_TERMINAL_PROMPT=0 and
16
+ GCM_INTERACTIVE=never, so a credential helper can never pop a sign-in window. The fetch runs in a
17
+ job object (Windows) or process group (POSIX), so `git-remote-https` dies with `git` on a timeout
18
+ instead of surviving it holding a ref lock. All git calls share one 18 s budget under the 25 s hook
19
+ timeout; with less than the fetch timeout left, the fetch is skipped and the output says so.
20
+
13
21
  IT ONLY PRINTS. It never switches, stashes, resets, commits or creates anything except one
14
22
  throttle stamp inside .git/. A SessionStart hook cannot ask a question, and these checkouts
15
23
  are shared with other live sessions — acting without an answer could take work with it.
@@ -25,6 +33,7 @@ from __future__ import annotations
25
33
  import json
26
34
  import os
27
35
  import re
36
+ import signal
28
37
  import subprocess
29
38
  import sys
30
39
  import time
@@ -49,21 +58,158 @@ if _reconfig:
49
58
  pass
50
59
 
51
60
  FETCH_TIMEOUT_S = 15 # ceiling: a slow or unreachable remote must not stall a session
61
+ TOTAL_BUDGET_S = 18 # all git calls together; the hook timeout in hooks.json is 25 s
52
62
  FETCH_MAX_AGE_S = 600 # a restart within 10 minutes reuses the last fetch
53
63
  PEER_FRESH_S = 1800 # live peer session-state threshold (30 minutes, matches inject_relay)
54
64
  STAMP_NAME = "caddis-freshness-stamp"
55
65
 
66
+ # No git child opens a console window on Windows (a hook is not a place for flashing windows).
67
+ _NO_WINDOW = 0x08000000 if os.name == "nt" else 0
68
+ _NEW_PROCESS_GROUP = 0x00000200
69
+ # Never take an optional lock, never ask for a credential, never open a credential-manager window.
70
+ _GIT_ENV_OVERRIDES = {"GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never"}
71
+
72
+ _deadline: float | None = None # time.monotonic() value that ends this run's git budget; None = no budget
73
+
74
+
75
+ def _git_env() -> dict:
76
+ """os.environ plus the no-lock, no-prompt overrides. A fresh dict each call."""
77
+ return {**os.environ, **_GIT_ENV_OVERRIDES}
78
+
79
+
80
+ def _remaining() -> float:
81
+ """Seconds left in the git budget. Effectively unbounded outside report()."""
82
+ return float("inf") if _deadline is None else _deadline - time.monotonic()
83
+
56
84
 
57
85
  def _git(cwd: str, *args: str, timeout: int = 15) -> tuple[int, str]:
58
- """(exit code, stdout). Never raises; a timeout is just a non-zero code."""
86
+ """(exit code, stdout). Never raises; a timeout is just a non-zero code. No call starts once the budget is
87
+ gone, and no call may run past it."""
88
+ left = _remaining()
89
+ if left <= 0:
90
+ return 1, ""
59
91
  try:
60
92
  r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True,
61
- encoding="utf-8", errors="replace", timeout=timeout)
93
+ encoding="utf-8", errors="replace", timeout=min(timeout, left),
94
+ stdin=subprocess.DEVNULL, env=_git_env(), creationflags=_NO_WINDOW)
62
95
  except (OSError, subprocess.SubprocessError):
63
96
  return 1, ""
64
97
  return r.returncode, (r.stdout or "").strip()
65
98
 
66
99
 
100
+ # -- the bounded runner for the fetch ---------------------------------------------------------------------------
101
+ # Equivalent to caddis_roadmap.run_bounded (job object, tree kill), kept local on purpose: that module cannot take a
102
+ # cwd or creation flags, costs ~120 ms to import, and agy ships this file in a bundle where scripts/ may not sit
103
+ # beside it. A hook that cannot import its helper must still work.
104
+
105
+
106
+ class _Job:
107
+ """A Windows job object holding one process and its children. KILL_ON_JOB_CLOSE: closing the handle (or the
108
+ death of this process) ends the whole tree, even after the first process has exited."""
109
+
110
+ def __init__(self, proc: "subprocess.Popen"):
111
+ import ctypes
112
+ from ctypes import wintypes
113
+
114
+ class _Basic(ctypes.Structure):
115
+ _fields_ = [("PerProcessUserTimeLimit", ctypes.c_int64), ("PerJobUserTimeLimit", ctypes.c_int64),
116
+ ("LimitFlags", wintypes.DWORD), ("MinimumWorkingSetSize", ctypes.c_size_t),
117
+ ("MaximumWorkingSetSize", ctypes.c_size_t), ("ActiveProcessLimit", wintypes.DWORD),
118
+ ("Affinity", ctypes.c_size_t), ("PriorityClass", wintypes.DWORD),
119
+ ("SchedulingClass", wintypes.DWORD)]
120
+
121
+ class _Io(ctypes.Structure):
122
+ _fields_ = [(n, ctypes.c_uint64) for n in (
123
+ "ReadOperationCount", "WriteOperationCount", "OtherOperationCount",
124
+ "ReadTransferCount", "WriteTransferCount", "OtherTransferCount")]
125
+
126
+ class _Extended(ctypes.Structure):
127
+ _fields_ = [("BasicLimitInformation", _Basic), ("IoInfo", _Io), ("ProcessMemoryLimit", ctypes.c_size_t),
128
+ ("JobMemoryLimit", ctypes.c_size_t), ("PeakProcessMemoryUsed", ctypes.c_size_t),
129
+ ("PeakJobMemoryUsed", ctypes.c_size_t)]
130
+
131
+ k = ctypes.WinDLL("kernel32", use_last_error=True)
132
+ k.CreateJobObjectW.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR)
133
+ k.CreateJobObjectW.restype = wintypes.HANDLE
134
+ k.SetInformationJobObject.argtypes = (wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD)
135
+ k.SetInformationJobObject.restype = wintypes.BOOL
136
+ k.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
137
+ k.AssignProcessToJobObject.restype = wintypes.BOOL
138
+ k.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
139
+ k.TerminateJobObject.restype = wintypes.BOOL
140
+ k.CloseHandle.argtypes = (wintypes.HANDLE,)
141
+ k.CloseHandle.restype = wintypes.BOOL
142
+ self._k = k
143
+ self.handle = k.CreateJobObjectW(None, None)
144
+ if not self.handle:
145
+ raise OSError(ctypes.get_last_error(), "CreateJobObjectW failed")
146
+ info = _Extended()
147
+ info.BasicLimitInformation.LimitFlags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
148
+ if not k.SetInformationJobObject(self.handle, 9, ctypes.byref(info), ctypes.sizeof(info)):
149
+ err = ctypes.get_last_error()
150
+ k.CloseHandle(self.handle)
151
+ raise OSError(err, "SetInformationJobObject failed")
152
+ if not k.AssignProcessToJobObject(self.handle, int(proc._handle)): # type: ignore[attr-defined]
153
+ err = ctypes.get_last_error()
154
+ k.CloseHandle(self.handle)
155
+ raise OSError(err, "AssignProcessToJobObject failed")
156
+
157
+ def terminate(self) -> None:
158
+ self._k.TerminateJobObject(self.handle, 1)
159
+
160
+ def close(self) -> None:
161
+ self._k.CloseHandle(self.handle)
162
+
163
+
164
+ def _kill_tree(proc: "subprocess.Popen", job: "_Job | None") -> None:
165
+ try:
166
+ if job is not None:
167
+ job.terminate()
168
+ elif os.name == "nt":
169
+ subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True,
170
+ stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW, timeout=5)
171
+ else:
172
+ os.killpg(proc.pid, signal.SIGKILL)
173
+ except Exception:
174
+ pass # the kill is best effort; the caller still reports the timeout
175
+
176
+
177
+ def _run_bounded(argv: list[str], cwd: str, timeout: float, env: dict) -> "int | None":
178
+ """Run `argv` with no stdin and no output. Returns its exit code, 1 when it cannot start, or None on timeout.
179
+ On a timeout the whole process tree is killed (job object on Windows, process group elsewhere). The tree is
180
+ also killed on any other way out, Ctrl+C included, and when this process dies (Windows job)."""
181
+ kwargs: dict = {"cwd": cwd, "env": env, "stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL,
182
+ "stderr": subprocess.DEVNULL}
183
+ if os.name != "nt":
184
+ kwargs["start_new_session"] = True
185
+ try:
186
+ proc = subprocess.Popen(argv, creationflags=(_NO_WINDOW | _NEW_PROCESS_GROUP) if os.name == "nt" else 0, **kwargs)
187
+ except (OSError, subprocess.SubprocessError):
188
+ return 1
189
+ job = None
190
+ if os.name == "nt":
191
+ try:
192
+ job = _Job(proc)
193
+ except Exception:
194
+ job = None # no job object: taskkill /T is the fallback
195
+ try:
196
+ try:
197
+ return proc.wait(timeout=max(timeout, 0))
198
+ except subprocess.TimeoutExpired:
199
+ _kill_tree(proc, job)
200
+ try:
201
+ proc.wait(timeout=5)
202
+ except subprocess.SubprocessError:
203
+ pass
204
+ return None
205
+ except BaseException:
206
+ _kill_tree(proc, job)
207
+ raise
208
+ finally:
209
+ if job is not None:
210
+ job.close()
211
+
212
+
67
213
  def _default_ref(cwd: str) -> str:
68
214
  """The remote's default branch, e.g. origin/main. '' when there is no usable one."""
69
215
  code, out = _git(cwd, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
@@ -108,7 +254,12 @@ def _status_entries(top: str) -> list[list[str]]:
108
254
  """`git status --porcelain -z` at the repo top: one list of paths per entry (a rename gives two).
109
255
  NUL-separated, so a path is never quoted. [] when git fails."""
110
256
  try:
111
- r = subprocess.run(["git", "status", "--porcelain", "-z"], cwd=top, capture_output=True, timeout=15)
257
+ left = _remaining()
258
+ if left <= 0:
259
+ return []
260
+ r = subprocess.run(["git", "--no-optional-locks", "status", "--porcelain", "-z"], cwd=top,
261
+ capture_output=True, timeout=min(15, left), stdin=subprocess.DEVNULL,
262
+ env=_git_env(), creationflags=_NO_WINDOW)
112
263
  except (OSError, subprocess.SubprocessError):
113
264
  return []
114
265
  if r.returncode != 0:
@@ -201,8 +352,31 @@ def _dirty_owners(top: str, dirty_paths: set[str], own_session_id: str = "") ->
201
352
  return []
202
353
 
203
354
 
355
+ def _fetch(cwd: str) -> "bool | None":
356
+ """`git fetch origin` through the bounded runner. True = fetched, False = failed or timed out, None = skipped
357
+ because less than FETCH_TIMEOUT_S of the budget is left. Never raises. Maintenance is off for this one call:
358
+ a background `gc --auto` would be killed with the job and could leave its lock behind."""
359
+ left = _remaining()
360
+ if left < FETCH_TIMEOUT_S:
361
+ return None
362
+ argv = ["git", "-c", "gc.auto=0", "-c", "maintenance.auto=false", "fetch", "origin", "--quiet"]
363
+ try:
364
+ return _run_bounded(argv, cwd, min(FETCH_TIMEOUT_S, left), _git_env()) == 0
365
+ except Exception:
366
+ return False
367
+
368
+
204
369
  def report(cwd: str, own_session_id: str = "") -> list[str]:
205
370
  """The lines to print. Empty when there is nothing worth saying."""
371
+ global _deadline
372
+ _deadline = time.monotonic() + TOTAL_BUDGET_S
373
+ try:
374
+ return _report(cwd, own_session_id)
375
+ finally:
376
+ _deadline = None
377
+
378
+
379
+ def _report(cwd: str, own_session_id: str = "") -> list[str]:
206
380
  if _git(cwd, "rev-parse", "--is-inside-work-tree")[0] != 0:
207
381
  return [] # not a git repository
208
382
  branch = _git(cwd, "branch", "--show-current")[1]
@@ -214,8 +388,12 @@ def report(cwd: str, own_session_id: str = "") -> list[str]:
214
388
  has_origin = "origin" in _git(cwd, "remote")[1].split()
215
389
  git_dir = _git(cwd, "rev-parse", "--absolute-git-dir")[1] or os.path.join(cwd, ".git")
216
390
  if has_origin and _fetch_is_due(git_dir):
217
- if _git(cwd, "fetch", "origin", "--quiet", timeout=FETCH_TIMEOUT_S)[0] == 0:
391
+ fetched = _fetch(cwd)
392
+ if fetched:
218
393
  _mark_fetched(git_dir)
394
+ elif fetched is None:
395
+ lines.append("[HARNESS] skipped the fetch - the session-start time budget was nearly spent. "
396
+ "The figures below may be stale.")
219
397
  else:
220
398
  lines.append("[HARNESS] could not fetch origin - the figures below may be stale.")
221
399
 
@@ -26,6 +26,14 @@ except ModuleNotFoundError as exc:
26
26
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
27
27
  import caddis_exit
28
28
 
29
+ try:
30
+ import caddis_worktree_safety
31
+ except ModuleNotFoundError as exc:
32
+ if exc.name != "caddis_worktree_safety":
33
+ raise
34
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
35
+ import caddis_worktree_safety
36
+
29
37
  if hasattr(sys.stdout, "reconfigure"):
30
38
  sys.stdout.reconfigure(encoding="utf-8", errors="replace")
31
39
  sys.stderr.reconfigure(encoding="utf-8", errors="replace")
@@ -467,12 +475,10 @@ def run_adhoc(
467
475
  if branch_res.returncode != 0:
468
476
  reason = f"branch update failed: {branch_res.stderr.strip()}"
469
477
  else:
470
- remove_res = subprocess.run(
471
- ["git", "-C", str(repo), "worktree", "remove", "--force", str(wt_path)],
472
- capture_output=True, text=True, encoding="utf-8", errors="replace",
473
- )
474
- if remove_res.returncode != 0 or wt_path.exists():
475
- reason = f"worktree remove failed: {remove_res.stderr.strip() or wt_path}"
478
+ # An OSError from the helper (git not runnable) lands in the `except OSError` below.
479
+ problem = caddis_worktree_safety.safe_remove_worktree(repo, wt_path)
480
+ if problem or wt_path.exists():
481
+ reason = problem or f"worktree remove failed: {wt_path}"
476
482
  else:
477
483
  kept_worktree = None
478
484
  subprocess.run(["git", "-C", str(repo), "worktree", "prune"], capture_output=True)
@@ -603,12 +609,14 @@ def drop_adhoc(repo: Path) -> int:
603
609
  print(f"[mmr] Refusing to remove {kept_worktree}: not a registered lane/* worktree")
604
610
  return EXIT_FAIL
605
611
  if kept_worktree:
606
- removed = subprocess.run(
607
- ["git", "-C", str(repo), "worktree", "remove", "--force", kept_worktree],
608
- capture_output=True, text=True, encoding="utf-8", errors="replace",
609
- )
610
- if removed.returncode != 0:
611
- print(f"[mmr] Could not remove kept worktree: {removed.stderr.strip()}")
612
+ try:
613
+ problem = caddis_worktree_safety.safe_remove_worktree(repo, Path(kept_worktree))
614
+ except OSError as exc:
615
+ problem = f"{type(exc).__name__}: {exc}"
616
+ if not problem and os.path.lexists(kept_worktree):
617
+ problem = f"{kept_worktree} still exists after git worktree remove"
618
+ if problem:
619
+ print(f"[mmr] Could not remove kept worktree: {problem}")
612
620
  return EXIT_FAIL
613
621
  pruned = subprocess.run(
614
622
  ["git", "-C", str(repo), "worktree", "prune"],
@@ -3,6 +3,11 @@
3
3
  `shared` (handoff-integrity Phase 5, the design of fleet-handoff Phase 2): a checkout is shared when its root
4
4
  `AGENTS.md` says `Shared checkout: yes`, or when `git status` lists a path this session did not change. In a
5
5
  shared checkout handoff writes no tracked file there; it uses `caddis_docs_pr.py` instead.
6
+
7
+ `checkout_is_shared` and `foreign_changes` (robustness #11) are the two halves of that test. `caddis_lanes.py accept`
8
+ calls them before it fast-forwards the session checkout, and the commands that switch, rebase or pull run
9
+ `shared` first (pr-merge, ship-pr, ship-merge, cloud-phases). A linked worktree is never shared: AGENTS.md is
10
+ tracked, so a worktree carries the line too, but the worktree is the safe place the rule sends the work to.
6
11
  """
7
12
 
8
13
  from __future__ import annotations
@@ -79,28 +84,74 @@ def _norm(path: str) -> str:
79
84
  return path.strip().replace("\\", "/")
80
85
 
81
86
 
82
- def is_shared_checkout(root: Path, own_files: set[str]) -> tuple[bool, str]:
83
- """(True, reason) when handoff must not write tracked files in `root`; else (False, "").
87
+ class CheckoutStatusError(OSError):
88
+ """`git status` could not run in the checkout, so foreign changes cannot be ruled out."""
84
89
 
85
- `Shared checkout: no` in AGENTS.md never hides a change this session did not make. When git status cannot
86
- run, the checkout counts as shared: a wrong "no" could commit another session's files.
87
- """
88
- agents = Path(root) / "AGENTS.md"
90
+
91
+ def _repo_top(start: Path) -> Path:
92
+ """The nearest folder at or above `start` that holds `.git` (a folder or a gitfile); `start` when none does."""
93
+ here = Path(start).resolve()
94
+ for folder in (here, *here.parents):
95
+ if (folder / ".git").exists():
96
+ return folder
97
+ return here
98
+
99
+
100
+ def is_linked_worktree(top: Path) -> bool:
101
+ """True when `top/.git` is a gitfile that points into `<repo>/.git/worktrees/` (a `git worktree add` tree)."""
102
+ gitfile = Path(top) / ".git"
103
+ if not gitfile.is_file():
104
+ return False
89
105
  try:
90
- text = agents.read_text(encoding="utf-8", errors="replace") if agents.is_file() else ""
106
+ line = gitfile.read_text(encoding="utf-8", errors="replace").strip()
91
107
  except OSError:
92
- text = ""
93
- if SHARED_RE.search(text):
94
- return True, "AGENTS.md says Shared checkout: yes"
108
+ return False
109
+ return line.lower().startswith("gitdir:") and "/worktrees/" in line.replace("\\", "/").lower()
110
+
111
+
112
+ def checkout_is_shared(root: Path) -> bool:
113
+ """True when the repo that holds `root` says `Shared checkout: yes` in its top `AGENTS.md` (any case).
114
+
115
+ A linked worktree is never shared (see the module note). A missing or unreadable AGENTS.md means no."""
116
+ top = _repo_top(Path(root))
117
+ if is_linked_worktree(top):
118
+ return False
119
+ try:
120
+ text = (top / "AGENTS.md").read_text(encoding="utf-8", errors="replace")
121
+ except OSError:
122
+ return False
123
+ return bool(SHARED_RE.search(text))
124
+
125
+
126
+ def foreign_changes(root: Path, own_files: set[str] | None = None) -> list[str]:
127
+ """The dirty paths in `root` that are not in `own_files`, in `git status` order. A rename counts both paths.
128
+
129
+ Without session tracking (`own_files` None or empty), every dirty path counts. Raises CheckoutStatusError
130
+ when `git status` fails: the caller must then treat the checkout as unsafe."""
95
131
  ret, lines = get_porcelain_lines(Path(root))
96
132
  if ret != 0:
97
- return True, "git status failed, so changes by other sessions cannot be ruled out"
98
- own = {_norm(p) for p in own_files}
133
+ raise CheckoutStatusError(f"git status failed in {root}")
134
+ own = {_norm(p) for p in (own_files or set())}
99
135
  foreign: list[str] = []
100
136
  for entry in lines:
101
137
  for path in entry_paths(entry):
102
138
  if _norm(path) not in own and path not in foreign:
103
139
  foreign.append(path)
140
+ return foreign
141
+
142
+
143
+ def is_shared_checkout(root: Path, own_files: set[str]) -> tuple[bool, str]:
144
+ """(True, reason) when handoff must not write tracked files in `root`; else (False, "").
145
+
146
+ `Shared checkout: no` in AGENTS.md never hides a change this session did not make. When git status cannot
147
+ run, the checkout counts as shared: a wrong "no" could commit another session's files.
148
+ """
149
+ if checkout_is_shared(Path(root)):
150
+ return True, "AGENTS.md says Shared checkout: yes"
151
+ try:
152
+ foreign = foreign_changes(Path(root), own_files)
153
+ except CheckoutStatusError:
154
+ return True, "git status failed, so changes by other sessions cannot be ruled out"
104
155
  if foreign:
105
156
  return True, "changes not made by this session: " + ", ".join(foreign[:3])
106
157
  return False, ""
@@ -31,7 +31,6 @@ import datetime
31
31
  import json
32
32
  import os
33
33
  import re
34
- import shutil
35
34
  import subprocess
36
35
  import sys
37
36
  import tempfile
@@ -41,14 +40,16 @@ try:
41
40
  import caddis_exit
42
41
  import caddis_checkout_guard
43
42
  import caddis_roadmap as roadmap
43
+ import caddis_worktree_safety
44
44
  except ModuleNotFoundError as exc:
45
- if exc.name not in {"caddis_exit", "caddis_checkout_guard", "caddis_roadmap"}:
45
+ if exc.name not in {"caddis_exit", "caddis_checkout_guard", "caddis_roadmap", "caddis_worktree_safety"}:
46
46
  raise
47
47
  sys.path.insert(0, str(Path(__file__).resolve().parent))
48
48
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
49
49
  import caddis_exit
50
50
  import caddis_checkout_guard
51
51
  import caddis_roadmap as roadmap
52
+ import caddis_worktree_safety
52
53
 
53
54
  BRANCH_PREFIX = "docs/handoff-"
54
55
  DIR_PREFIX = "caddis-docs-pr-"
@@ -199,14 +200,22 @@ def _save_patch(worktree: Path) -> Path:
199
200
 
200
201
 
201
202
  def _remove(main: Path, worktree: Path, branch: str) -> None:
202
- for args in (("worktree", "remove", "--force", "--force", str(worktree)), ("worktree", "prune"),
203
- ("branch", "-D", branch)):
203
+ """Remove the worktree (links first, so a junction's target survives), prune, delete the branch.
204
+
205
+ Best effort, as before: the result of each step is not checked. Every caller has already saved the session's
206
+ work as a patch or has none, so a step that fails leaves only a temp folder. A folder that git leaves is
207
+ removed below through remove_tree, which also removes links first."""
208
+ try:
209
+ caddis_worktree_safety.safe_remove_worktree(main, worktree, force_locked=True, env=roadmap._quiet_env())
210
+ except OSError:
211
+ pass
212
+ for args in (("worktree", "prune"), ("branch", "-D", branch)):
204
213
  try:
205
214
  _git(main, *args, check=False)
206
215
  except (roadmap.RoadmapError, OSError):
207
216
  pass
208
217
  if worktree.exists():
209
- shutil.rmtree(worktree, ignore_errors=True)
218
+ caddis_worktree_safety.remove_tree(worktree)
210
219
 
211
220
 
212
221
  def finish(worktree: Path, title: str, body: str, out=print) -> int:
@@ -602,7 +602,26 @@ def is_test_file(path: str) -> bool:
602
602
  return False
603
603
 
604
604
 
605
+ _STEERING_FILENAMES = {"conftest.py", "pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"}
606
+
607
+
608
+ def is_review_steering_file(path: str) -> bool:
609
+ """True for config that steers reviews, merges or tests: never tier 0, and never a 0-reviewer "1-small".
610
+
611
+ `.caddis/*.toml` (review/implement routing, merge-chain checks, spend cap), any `conftest.py`, the pytest and
612
+ packaging config, and `.github/workflows/**`. A fix diff that changes these can turn a gate off, so it is reviewed.
613
+ """
614
+ norm = path.replace("\\", "/").lower().removeprefix("./")
615
+ if norm.startswith(".caddis/") and norm.endswith(".toml"):
616
+ return True
617
+ if norm.startswith(".github/workflows/"):
618
+ return True
619
+ return norm.rsplit("/", 1)[-1] in _STEERING_FILENAMES
620
+
621
+
605
622
  def is_tier0_file(path: str) -> bool:
623
+ if is_review_steering_file(path):
624
+ return False
606
625
  norm = path.replace("\\", "/").lower()
607
626
  if norm.startswith("./"):
608
627
  norm = norm[2:]
@@ -647,8 +666,13 @@ def _numstat(rng: str | None = None, repo_root: Path = Path(".")) -> list[tuple[
647
666
  if res.returncode != 0:
648
667
  reason = (res.stderr or "").strip().splitlines()
649
668
  raise RuntimeError(f"git diff --numstat exited {res.returncode}: {reason[0] if reason else 'no message'}")
669
+ return _parse_numstat(res.stdout)
670
+
671
+
672
+ def _parse_numstat(stdout: str) -> list[tuple[str, int]]:
673
+ """(path, changed-lines) per file from `git diff --numstat` output; see `_numstat`."""
650
674
  files: list[tuple[str, int]] = []
651
- for row in res.stdout.splitlines():
675
+ for row in stdout.splitlines():
652
676
  parts = row.split("\t")
653
677
  if len(parts) != 3:
654
678
  continue
@@ -680,6 +704,8 @@ def review_tier(files: list[tuple[str, int]], plan_risk: str = "normal") -> str:
680
704
  non_tier0_lines = sum(lines for _, lines in non_tier0)
681
705
  if review_triggers(non_tier0_paths, non_tier0_lines):
682
706
  return "2"
707
+ if any(is_review_steering_file(p) for p in non_tier0_paths):
708
+ return "1"
683
709
  has_test = any(is_test_file(p) for p, _ in files)
684
710
  if non_tier0_lines <= 40 and has_test:
685
711
  return "1-small"