@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.
Files changed (49) hide show
  1. package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
  2. package/bundles/antigravity-plugin/guard_agy.py +378 -37
  3. package/bundles/antigravity-plugin/plugin.json +1 -1
  4. package/bundles/antigravity-plugin/repo_freshness.py +182 -4
  5. package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
  6. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
  7. package/bundles/antigravity-plugin/scripts/caddis_gate.py +6 -1
  8. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +58 -6
  9. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
  10. package/bundles/antigravity-plugin/scripts/caddis_review.py +231 -167
  11. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +226 -26
  12. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +2 -1
  13. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
  14. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
  15. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
  16. package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
  17. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
  18. package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
  19. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
  20. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
  21. package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
  22. package/bundles/antigravity-plugin/scripts/oss_review.py +3 -0
  23. package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
  24. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +2 -1
  25. package/bundles/antigravity-plugin/session_end_agy.py +14 -1
  26. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +2 -1
  27. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +1 -1
  28. package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
  29. package/bundles/antigravity-plugin/skills/implement/SKILL.md +13 -6
  30. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +1 -1
  31. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
  32. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
  33. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
  34. package/bundles/antigravity-plugin/skills/ship/SKILL.md +6 -3
  35. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +2 -2
  37. package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/warm_start_agy.py +14 -1
  39. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  40. package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
  41. package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
  42. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
  43. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +1 -1
  44. package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
  45. package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
  46. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
  47. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
  48. package/bundles/manifest.json +3 -3
  49. 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
- 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"],
@@ -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:
@@ -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 res.stdout.splitlines():
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
- _git_output(repo_root, "worktree", "remove", "--force", str(resolved))
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
- review_status = packet.get("review", {}).get("status")
2155
- if review_status == "BLOCKING" and not getattr(args, "override_review", None):
2156
- print("refusing: review BLOCKING; pass --override-review with a reason")
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