@caddis/cli 0.4.54 → 0.4.56
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 +378 -37
- 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_docs_pr.py +14 -5
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +6 -1
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +58 -6
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +231 -167
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +226 -26
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +2 -1
- 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 +3 -0
- package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +2 -1
- package/bundles/antigravity-plugin/session_end_agy.py +14 -1
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +13 -6
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
- 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 +6 -3
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +2 -2
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +14 -1
- package/bundles/antigravity-plugin-extras/plugin.json +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 +1 -1
- 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"],
|
|
@@ -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:
|
|
@@ -647,8 +647,13 @@ def _numstat(rng: str | None = None, repo_root: Path = Path(".")) -> list[tuple[
|
|
|
647
647
|
if res.returncode != 0:
|
|
648
648
|
reason = (res.stderr or "").strip().splitlines()
|
|
649
649
|
raise RuntimeError(f"git diff --numstat exited {res.returncode}: {reason[0] if reason else 'no message'}")
|
|
650
|
+
return _parse_numstat(res.stdout)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _parse_numstat(stdout: str) -> list[tuple[str, int]]:
|
|
654
|
+
"""(path, changed-lines) per file from `git diff --numstat` output; see `_numstat`."""
|
|
650
655
|
files: list[tuple[str, int]] = []
|
|
651
|
-
for row in
|
|
656
|
+
for row in stdout.splitlines():
|
|
652
657
|
parts = row.split("\t")
|
|
653
658
|
if len(parts) != 3:
|
|
654
659
|
continue
|
|
@@ -36,6 +36,7 @@ except ModuleNotFoundError as exc:
|
|
|
36
36
|
import caddis_routing
|
|
37
37
|
import caddis_redact
|
|
38
38
|
import caddis_spend
|
|
39
|
+
import caddis_worktree_safety
|
|
39
40
|
|
|
40
41
|
if hasattr(sys.stdout, "reconfigure"):
|
|
41
42
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
@@ -265,7 +266,14 @@ def remove_phase_worktree(
|
|
|
265
266
|
if delete_branch and not wt.branch.startswith("lane/"):
|
|
266
267
|
raise LaneConfigError(f"refusing to delete non-lane branch: {wt.branch}")
|
|
267
268
|
|
|
268
|
-
|
|
269
|
+
try:
|
|
270
|
+
problem = caddis_worktree_safety.safe_remove_worktree(repo_root, resolved)
|
|
271
|
+
except OSError as exc:
|
|
272
|
+
raise LaneConfigError(f"worktree remove failed: {exc}") from exc
|
|
273
|
+
if not problem and os.path.lexists(resolved):
|
|
274
|
+
problem = f"worktree remove left {resolved} on disk"
|
|
275
|
+
if problem:
|
|
276
|
+
raise LaneConfigError(problem)
|
|
269
277
|
_git_output(repo_root, "worktree", "prune")
|
|
270
278
|
if delete_branch:
|
|
271
279
|
_git_output(repo_root, "branch", "-D", "--", wt.branch)
|
|
@@ -1244,8 +1252,19 @@ def validate_codex_argv(argv: list[str]) -> None:
|
|
|
1244
1252
|
)
|
|
1245
1253
|
|
|
1246
1254
|
|
|
1255
|
+
# Robustness #4: a lane agent can be prompt-injected through a plan or a diff, so it gets no publish token and no
|
|
1256
|
+
# credential prompt, and cannot switch the guard off. The same list as caddis_roadmap_run.SESSION_SECRET_VARS. The
|
|
1257
|
+
# gh keyring login stays on the machine; the guard denies push, merge and publish when CADDIS_HEADLESS=1.
|
|
1258
|
+
LANE_SECRET_VARS = ("GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "NPM_TOKEN", "NODE_AUTH_TOKEN",
|
|
1259
|
+
"CADDIS_ALLOW_PUBLISH", "CADDIS_GUARD_DISABLED")
|
|
1260
|
+
LANE_NO_PROMPT_ENV = {"GH_PROMPT_DISABLED": "1", "GIT_TERMINAL_PROMPT": "0"}
|
|
1261
|
+
|
|
1262
|
+
|
|
1247
1263
|
def child_env(base: dict, lane: str, model: str) -> dict:
|
|
1248
1264
|
env = base.copy()
|
|
1265
|
+
for name in LANE_SECRET_VARS:
|
|
1266
|
+
env.pop(name, None)
|
|
1267
|
+
env.update(LANE_NO_PROMPT_ENV)
|
|
1249
1268
|
env["CADDIS_HEADLESS"] = "1"
|
|
1250
1269
|
if lane == "glm":
|
|
1251
1270
|
env["OSS_MODEL"] = model
|
|
@@ -2132,6 +2151,20 @@ def _check_command(
|
|
|
2132
2151
|
return exit_code
|
|
2133
2152
|
|
|
2134
2153
|
|
|
2154
|
+
_TERMINAL_ESCAPES = re.compile(
|
|
2155
|
+
r"\x1b\[[0-?]*[ -/]*[@-~]" # CSI (colours, cursor)
|
|
2156
|
+
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?" # OSC (titles, links)
|
|
2157
|
+
r"|\x1b[@-Z\\-_]" # other two-byte escapes
|
|
2158
|
+
)
|
|
2159
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x09\x0b-\x1f\x7f]")
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
def _printable(value: object) -> str:
|
|
2163
|
+
"""Text safe to print: no ANSI escapes and no C0 control characters except newline."""
|
|
2164
|
+
text = _TERMINAL_ESCAPES.sub("", str(value if value is not None else ""))
|
|
2165
|
+
return _CONTROL_CHARS.sub("", text)
|
|
2166
|
+
|
|
2167
|
+
|
|
2135
2168
|
def _accept_command(
|
|
2136
2169
|
args,
|
|
2137
2170
|
repo: Path,
|
|
@@ -2151,12 +2184,26 @@ def _accept_command(
|
|
|
2151
2184
|
if packet.get("next") != "review":
|
|
2152
2185
|
print(f"refusing: packet next={packet.get('next')}")
|
|
2153
2186
|
return EXIT_USAGE
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2187
|
+
# Fail closed: only an exact CLEAN verdict merges without an owner override.
|
|
2188
|
+
review = packet.get("review") or {}
|
|
2189
|
+
if not isinstance(review, dict):
|
|
2190
|
+
review = {}
|
|
2191
|
+
review_status = review.get("status")
|
|
2192
|
+
override_reason = (getattr(args, "override_review", None) or "").strip()
|
|
2193
|
+
if review_status != "CLEAN" and not override_reason:
|
|
2194
|
+
if review_status == "BLOCKING":
|
|
2195
|
+
print("refusing: review BLOCKING; pass --override-review with a reason")
|
|
2196
|
+
else:
|
|
2197
|
+
# MISSING = no verdict (provider down, 429, secret-filter refusal). Anything else
|
|
2198
|
+
# that is not CLEAN (absent, null, unknown, lowercase) is also no valid verdict.
|
|
2199
|
+
cause = " ".join(_printable(review.get("tail")).split())[-300:] or "no reason recorded"
|
|
2200
|
+
label = (
|
|
2201
|
+
"MISSING" if review_status == "MISSING"
|
|
2202
|
+
else f"status {_printable(review_status)!r}"
|
|
2203
|
+
)
|
|
2204
|
+
print(f"refusing: review {label} (no verdict: {cause}); re-run the review, "
|
|
2205
|
+
"or pass --override-review with a reason")
|
|
2157
2206
|
return EXIT_USAGE
|
|
2158
|
-
if review_status == "MISSING":
|
|
2159
|
-
print("WARNING: review MISSING")
|
|
2160
2207
|
if _git_output(repo, "rev-parse", "HEAD") != packet.get("base_sha"):
|
|
2161
2208
|
print("refusing: programme branch moved since the attempt started")
|
|
2162
2209
|
return EXIT_FAIL
|
|
@@ -2178,6 +2225,11 @@ def _accept_command(
|
|
|
2178
2225
|
packet["override_review"] = args.override_review
|
|
2179
2226
|
write_packet(packet_path, packet)
|
|
2180
2227
|
print(f"OVERRIDE REVIEW: {args.override_review}")
|
|
2228
|
+
if review_status != "CLEAN":
|
|
2229
|
+
print(
|
|
2230
|
+
f"WARNING: merged without a vendor review (status {_printable(review_status)}): "
|
|
2231
|
+
f"{args.override_review}"
|
|
2232
|
+
)
|
|
2181
2233
|
_git_output(wt.path, "add", "--", *(str(path) for path in files))
|
|
2182
2234
|
_git_output(wt.path, "commit", "-m", args.message)
|
|
2183
2235
|
accepted_sha = _git_output(wt.path, "rev-parse", "HEAD")
|
|
@@ -20,13 +20,18 @@ except ModuleNotFoundError as exc:
|
|
|
20
20
|
import secret_filter
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
# No child process opens a console window on Windows (this runs under hooks and status lines that
|
|
24
|
+
# have no console). Inline, not scripts/no_window.py: in the source tree this file does not sit beside it.
|
|
25
|
+
_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
26
|
+
|
|
23
27
|
MAX_NOTE_CHARS = 4000
|
|
24
28
|
|
|
25
29
|
|
|
26
30
|
def _git(repo: Path, *args: str, input_data: bytes | None = None,
|
|
27
31
|
env: dict[str, str] | None = None) -> bytes:
|
|
28
32
|
result = subprocess.run(["git", *args], cwd=repo, env=env, input=input_data,
|
|
29
|
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False
|
|
33
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False,
|
|
34
|
+
creationflags=_NO_WINDOW)
|
|
30
35
|
if result.returncode:
|
|
31
36
|
# Name the subcommand, not a leading "-c key=value" option.
|
|
32
37
|
command = next((arg for i, arg in enumerate(args) if not arg.startswith("-") and (i == 0 or args[i - 1] != "-c")), "?")
|
|
@@ -53,7 +58,8 @@ def _pre_send(note: str) -> None:
|
|
|
53
58
|
"--- /dev/null\n+++ b/relay.md\n"
|
|
54
59
|
f"@@ -0,0 +1,{len(lines)} @@\n" +
|
|
55
60
|
"".join("+" + line + "\n" for line in lines))
|
|
56
|
-
result = secret_filter.filter_diff(diff, list(secret_filter.DEFAULT_DENY_GLOBS), []
|
|
61
|
+
result = secret_filter.filter_diff(diff, list(secret_filter.DEFAULT_DENY_GLOBS), [],
|
|
62
|
+
env_assignment_everywhere=True)
|
|
57
63
|
if result.hits or result.dropped_paths or not result.kept_diff:
|
|
58
64
|
raise ValueError("relay refused by pre-send filter")
|
|
59
65
|
|