@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.
- package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/guard_agy.py +1056 -83
- package/bundles/antigravity-plugin/hooks.json +2 -1
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +182 -4
- package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +27 -1
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +93 -6
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +290 -176
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +346 -50
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +82 -8
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
- package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
- package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +303 -79
- package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +6 -1
- package/bundles/antigravity-plugin/session_end_agy.py +27 -11
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +55 -9
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +10 -6
- package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -17
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +45 -5
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +32 -10
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +32 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +44 -6
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +40 -16
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
- package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +10 -6
- package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
- package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
if
|
|
611
|
-
|
|
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
|
-
|
|
83
|
-
"""
|
|
87
|
+
class CheckoutStatusError(OSError):
|
|
88
|
+
"""`git status` could not run in the checkout, so foreign changes cannot be ruled out."""
|
|
84
89
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
"""
|
|
88
|
-
|
|
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
|
-
|
|
106
|
+
line = gitfile.read_text(encoding="utf-8", errors="replace").strip()
|
|
91
107
|
except OSError:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
|
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"
|