@caddis/cli 0.4.55 → 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
@@ -57,10 +57,10 @@ review child (the reviewer has full Bash) and the fix session are each guarded b
57
57
  session; the runner writes `reviewed/<plan>.json` only between children, before the next child's snapshot.
58
58
 
59
59
  Snapshot guard: one baseline across the whole run. The Milestones and Blockers tables (every cell of every row, rows
60
- added, removed or moved) and the `Runner host` line are frozen. Every origin read (each `next`, and the reads after
61
- the runner's own writes and after each session) is compared with the previous read: the only differences allowed are
62
- the exact edits the runner made and recorded for that read (a Status it set, a blocker row it added, and what a
63
- rollup's audit entry says it actually wrote). Anything else stops the run `FAILED roadmap changed` (exit 1). Every
60
+ added, removed or moved), the `Runner host` line and the `**Exit setup:**` line are frozen. Every origin read (each
61
+ `next`, and the reads after the runner's own writes and after each session) is compared with the previous read: the
62
+ only differences allowed are the exact edits the runner made and recorded for that read (a Status it set, a blocker
63
+ row it added, and what a rollup's audit entry says it actually wrote). Anything else stops the run `FAILED roadmap changed` (exit 1). Every
64
64
  roadmap change goes through `caddis_roadmap.py` as a child process with `CADDIS_HEADLESS=1`; the runner never passes
65
65
  `--by-owner`.
66
66
 
@@ -90,8 +90,9 @@ The runner never passes an owner gate, never raises a cap and never deletes a ST
90
90
 
91
91
  Exit codes: 0 stopped at a status the session wrote (plan mode: OWNER-GATE, BLOCKED), or at a STOP file after a
92
92
  session; 1 a FAILED stop, a cap, the agy spend cap, a usage limit, `BLOCKED stuck`, `BLOCKED nothing ready`,
93
- `BLOCKED waiting for merge`, `BLOCKED review`, `BLOCKED review not run`, or the snapshot guard; 3 never started (lock, STOP file, no governor, agy spend cap not configured,
94
- another host, a plan a roadmap runner holds); 4 bad input (no plan or roadmap, both, a bad slug, no prompt template,
93
+ `BLOCKED waiting for merge`, `BLOCKED review`, `BLOCKED review not run`, `BLOCKED rollup not run` (a rollup exit 3,
94
+ for example an exit check that cannot run in the merged checkout; not restartable by the recheck task), or the
95
+ snapshot guard; 3 never started (lock, STOP file, no governor, agy spend cap not configured, another host, a plan a roadmap runner holds); 4 bad input (no plan or roadmap, both, a bad slug, no prompt template,
95
96
  bad engine).
96
97
 
97
98
  Engines: `claude` is built and live-tested. `agy` and `codex` are command-line adapters with unit tests only (not
@@ -134,6 +135,7 @@ import caddis_redact # noqa: E402
134
135
  import caddis_review # noqa: E402
135
136
  import caddis_roadmap # noqa: E402
136
137
  import caddis_spend # noqa: E402
138
+ import caddis_worktree_safety # noqa: E402
137
139
  import caddis_workstreams # noqa: E402
138
140
 
139
141
  ENGINES: dict[str, dict] = {
@@ -148,12 +150,29 @@ PLAN_PROMPT_NAME = "plan-resume-prompt.md"
148
150
  # background, and a print-mode session can end before it finishes, so its work is lost; the session prompt already
149
151
  # asks for foreground work only. Normal subagents stay available.
150
152
  HEADLESS_ENV = {"CADDIS_HEADLESS": "1", "CLAUDE_CODE_FORK_SUBAGENT": "0"}
151
- ROADMAP_WRITE_TIMEOUT_S = caddis_roadmap.MERGE_CHAIN_TIMEOUT + 600 # a backstop: every call inside is bounded
153
+ # Robustness #4: what a session never gets. The publish tokens (the same list as caddis_lanes.LANE_SECRET_VARS), the
154
+ # runner publish step's marker, and the guard kill switch (the guard denies push, merge and publish headless).
155
+ SESSION_SECRET_VARS = ("GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "NPM_TOKEN", "NODE_AUTH_TOKEN",
156
+ "CADDIS_ALLOW_PUBLISH", "CADDIS_GUARD_DISABLED")
157
+ SESSION_NO_PROMPT_ENV = {"GH_PROMPT_DISABLED": "1", "GIT_TERMINAL_PROMPT": "0"}
158
+ # Layer 3 for claude sessions. Not a boundary: Claude Code matches a rule against the raw command string, so
159
+ # `git "push"` or `bash -c "git push"` passes it (#65, 18 Oct). The boundary is layer 1 (session_env: no token) and
160
+ # layer 2 (the guard denies push, merge and publish when CADDIS_HEADLESS=1). With those, bypassPermissions is
161
+ # acceptable for implementers: they need a shell and the write tools, and on native Windows there is no sandbox.
162
+ SESSION_DENY_RULES = ("Bash(git push:*)", "Bash(gh pr merge:*)", "Bash(gh pr create:*)", "Bash(gh release:*)",
163
+ "Bash(gh api:*)", "Bash(npm publish:*)")
164
+ ROADMAP_WRITE_TIMEOUT_S = (caddis_roadmap.MERGE_CHAIN_TIMEOUT + caddis_roadmap.EXIT_SETUP_TIMEOUT
165
+ + caddis_roadmap.EXIT_CHECK_TIMEOUT + 600) # a backstop: every call inside is bounded
152
166
  RUNNING = "running" # the last-stop record while a runner holds the lock; a killed run leaves it (needs the owner)
153
167
  GH_STOP_PREFIX = "BLOCKED gh cannot see" # a gh error at a restart: waiting.json stays, a later tick may resume
154
168
  RESTARTABLE_STOP = "BLOCKED nothing ready" # restartable once a blocker cleared
155
169
  WAITING_PREFIX = "BLOCKED waiting for merge" # restartable once its PR merged
156
- ROLLUP_BLOCK_RE = r"exit check failing \((exit -?\d+|timed out after \d+ s)\)" # the blocker rollup writes
170
+ # The blocker rollup writes: its stable prefix, then (since caddis-robustness Phase 7) `: ` and the output tail. The
171
+ # lookahead caps the WHOLE reason at caddis_roadmap.REASON_CELL_CHARS, the same cap blocker_reason applies, so the
172
+ # tail may hold REASON_CELL_CHARS - len(prefix) - len(": ") characters at most.
173
+ ROLLUP_BLOCK_RE = (rf"(?=[ -~]{{1,{caddis_roadmap.REASON_CELL_CHARS}}}\Z)" + caddis_roadmap.ROLLUP_PREFIX_RE.pattern
174
+ + r"(: [ -~]+)?")
175
+ ROLLUP_NOT_RUN = "BLOCKED rollup not run" # rollup exit 3 (exit check, Exit setup, git or lock); the owner decides
157
176
  _SLUG_RE = re.compile(r"[a-z0-9][a-z0-9-]*")
158
177
  _CLOSED = {"done", "not-needed"}
159
178
  _LANE_RE = re.compile(r"^\s*\*\*Lane:\*\*\s*(.*?)\s*$")
@@ -184,6 +203,25 @@ def headless_env(base: dict | None = None) -> dict:
184
203
  return env
185
204
 
186
205
 
206
+ def session_env(base: dict | None = None) -> dict:
207
+ """The environment of a session (and of the review child, whose reviewer sessions inherit it): headless_env with
208
+ no publish credential (SESSION_SECRET_VARS) and no credential prompt. A session can be prompt-injected through a
209
+ plan or a diff; the runner pushes, opens the PR and merges by itself, with its own login (runner_env)."""
210
+ env = headless_env(base)
211
+ for name in SESSION_SECRET_VARS:
212
+ env.pop(name, None)
213
+ env.update(SESSION_NO_PROMPT_ENV)
214
+ return env
215
+
216
+
217
+ def runner_env() -> dict:
218
+ """The environment of the runner's own caddis_roadmap.py children (writes, merge chain, render): headless_env
219
+ plus the runner's gh login. Never given to a session."""
220
+ env = headless_env()
221
+ env.update(caddis_roadmap.AUTH_ENV)
222
+ return env
223
+
224
+
187
225
  def build_command(engine: str, model: str, prompt: str, *, usd: float, timeout_min: float,
188
226
  governor: Sequence[str], engine_cmd: Sequence[str] | None = None) -> list[str]:
189
227
  """The full command line for one session: the governor prefix, then the engine's print-mode call.
@@ -198,8 +236,9 @@ def build_command(engine: str, model: str, prompt: str, *, usd: float, timeout_m
198
236
  # The mode is explicit: since Claude Code 2.1.302 a headless run with no mode starts in `default` (third-party
199
237
  # providers) and no longer in auto mode, so a machine without `defaultMode` in its settings would stop at
200
238
  # every permission prompt. The session runs in its own worktree under the governor, and the guard still runs.
201
- tail = [*exe, "-p", "--permission-mode", "bypassPermissions", "--model", model, "--output-format", "json",
202
- "--max-budget-usd", _usd(usd)]
239
+ # The deny rules are an extra only (SESSION_DENY_RULES); the token-free env and the guard are the boundary.
240
+ tail = [*exe, "-p", "--disallowedTools", *SESSION_DENY_RULES, "--permission-mode", "bypassPermissions",
241
+ "--model", model, "--output-format", "json", "--max-budget-usd", _usd(usd)]
203
242
  elif engine == "agy": # probe 5: --model must come before --print
204
243
  tail = [*exe, "--model", model, "--print", prompt, "--output-format", "json",
205
244
  "--print-timeout", f"{max(1, int(timeout_min))}m"]
@@ -405,8 +444,8 @@ def roadmap_guard(before: "caddis_roadmap.Roadmap", after: "caddis_roadmap.Roadm
405
444
  edits: Sequence[tuple] = ()) -> list[str]:
406
445
  """Every difference between two origin reads that the runner's own recorded `edits` do not explain.
407
446
 
408
- The Milestones and Blockers tables and the `Runner host` line are frozen: every cell of every milestone row, the
409
- row order, and the multiset of blocker rows. `edits` are what the runner itself wrote since `before`:
447
+ The Milestones and Blockers tables, the `Runner host` line and the `**Exit setup:**` line are frozen: every cell
448
+ of every milestone row, the row order, and the multiset of blocker rows. `edits` are what the runner itself wrote since `before`:
410
449
  `("status", mid, value)`, `("blocker", mid, type, reason)` (a row with no Check; nothing when the same
411
450
  milestone, type and reason exist, as `caddis_roadmap.add_blocker` does), and `("blocker-like", mid, type,
412
451
  regex)` (one added row whose reason matches, for the blocker a rollup writes)."""
@@ -426,6 +465,8 @@ def roadmap_guard(before: "caddis_roadmap.Roadmap", after: "caddis_roadmap.Roadm
426
465
  changes: list[str] = []
427
466
  if after.runner_host != before.runner_host:
428
467
  changes.append("Runner host changed")
468
+ if after.exit_setup != before.exit_setup: # a command rollup runs unattended, like an Exit check
469
+ changes.append("Exit setup changed")
429
470
  now = {ms.id: _milestone_cells(ms) for ms in after.milestones}
430
471
  changes += [f"{mid} milestone removed" for mid in rows if mid not in now]
431
472
  changes += [f"{mid} milestone added" for mid in now if mid not in rows]
@@ -655,11 +696,13 @@ GH_ACCOUNT = {"name": None} # the account the runner's gh calls use (GH_TOKEN s
655
696
 
656
697
 
657
698
  def use_gh_account(account: str | None) -> str | None:
658
- """Make every gh call of this process (and of the caddis_roadmap.py children, which inherit the environment) use
659
- `account`: GH_TOKEN from `gh auth token -u <account>`. None keeps the environment / active account. Returns the
660
- value set, so it can be masked; GhError when gh cannot give it."""
699
+ """Make every gh call of this process (through caddis_roadmap._quiet_env) and of its caddis_roadmap.py children
700
+ (runner_env) use `account`: GH_TOKEN from `gh auth token -u <account>`, held in caddis_roadmap.AUTH_ENV, never in
701
+ os.environ, so no session inherits it. None keeps the environment / active account. Returns the value set, so
702
+ it can be masked; GhError when gh cannot give it."""
661
703
  GH_ACCOUNT["name"] = account or None
662
704
  if not account:
705
+ caddis_roadmap.AUTH_ENV.pop("GH_TOKEN", None)
663
706
  return None
664
707
  try:
665
708
  proc = subprocess.run(["gh", "auth", "token", "-u", account], capture_output=True, text=True,
@@ -671,7 +714,7 @@ def use_gh_account(account: str | None) -> str | None:
671
714
  gh_auth = (proc.stdout or "").strip()
672
715
  if proc.returncode != 0 or not gh_auth:
673
716
  raise GhError(f"gh has no login for account {account}", caddis_exit.BLOCKED)
674
- os.environ["GH_TOKEN"] = gh_auth
717
+ caddis_roadmap.AUTH_ENV["GH_TOKEN"] = gh_auth
675
718
  return gh_auth
676
719
 
677
720
 
@@ -852,7 +895,7 @@ def _one_session(ctx: _Ctx, prompt: str, relay_slug: str, n: int, spent: float,
852
895
  _, hash_before = _read_relay(cwd, relay_slug)
853
896
  record = {"kind": "session", "n": n, "start": _iso(ctx.now()), "relay_hash_before": hash_before}
854
897
  outcome = _launch(cmd, cwd, remaining_s, prompt if ctx.engine in PROMPT_ON_STDIN else None,
855
- env=headless_env())
898
+ env=session_env())
856
899
  finally: # any exception, KeyboardInterrupt too, still settles the reservation
857
900
  if rid is not None:
858
901
  _spend_settle(ctx.home, rid, cmd, outcome, ctx.now(), ctx.clock() - launched)
@@ -1039,7 +1082,7 @@ def _run_review(ctx: _Ctx, tally: _Tally, plan_file: Path, phase: int, out_dir:
1039
1082
  kind, slug = ctx.target.kind, ctx.target.slug
1040
1083
  state_before = state_snapshot(ctx.repo, kind, slug) # the reviewer has full Bash: it is guarded like a session
1041
1084
  try:
1042
- proc = subprocess.run(argv, cwd=cwd, env=headless_env(), stdin=subprocess.DEVNULL, capture_output=True,
1085
+ proc = subprocess.run(argv, cwd=cwd, env=session_env(), stdin=subprocess.DEVNULL, capture_output=True,
1043
1086
  text=True, encoding="utf-8", errors="replace",
1044
1087
  timeout=max(1.0, min(REVIEW_TIMEOUT_S, left_s)))
1045
1088
  code, output = proc.returncode, (proc.stdout or "") + (proc.stderr or "")
@@ -1198,6 +1241,7 @@ def run(*, repo: Path, plan: str | None = None, roadmap: str | None = None, caps
1198
1241
  return caddis_exit.BLOCKED
1199
1242
  if value:
1200
1243
  ctx.masks.append(value)
1244
+ caddis_roadmap.AUTH_ENV["GH_TOKEN"] = value # the runner's own calls only; never a session's env
1201
1245
  if roadmap is not None:
1202
1246
  rm_cmd = list(roadmap_cmd or default_roadmap_cmd())
1203
1247
  try:
@@ -1218,7 +1262,7 @@ def _render_page(ctx: "_Ctx", roadmap_cmd: Sequence[str]) -> None:
1218
1262
  """Re-render the local roadmap page (roadmap-page Phase 3). Best effort: a failure is one stderr line, never a stop."""
1219
1263
  argv = [*roadmap_cmd, "render", "--roadmap", ctx.target.slug, "--root", str(ctx.repo)]
1220
1264
  try:
1221
- proc = subprocess.run(argv, cwd=ctx.repo, env=headless_env(), stdin=subprocess.DEVNULL, capture_output=True,
1265
+ proc = subprocess.run(argv, cwd=ctx.repo, env=runner_env(), stdin=subprocess.DEVNULL, capture_output=True,
1222
1266
  text=True, encoding="utf-8", errors="replace", timeout=PAGE_RENDER_TIMEOUT_S)
1223
1267
  if proc.returncode != 0:
1224
1268
  print(f"caddis_roadmap_run: page not rendered (exit {proc.returncode})", file=sys.stderr)
@@ -1360,7 +1404,7 @@ def _roadmap_write(ctx: _Ctx, roadmap_cmd: Sequence[str], args: list[str]) -> in
1360
1404
  raise ValueError("the runner never makes an owner-only roadmap change")
1361
1405
  argv = [*roadmap_cmd, *args, "--roadmap", ctx.target.slug, "--root", str(ctx.repo)]
1362
1406
  try:
1363
- proc = subprocess.run(argv, cwd=ctx.repo, env=headless_env(), stdin=subprocess.DEVNULL, capture_output=True,
1407
+ proc = subprocess.run(argv, cwd=ctx.repo, env=runner_env(), stdin=subprocess.DEVNULL, capture_output=True,
1364
1408
  text=True, encoding="utf-8", errors="replace", timeout=ROADMAP_WRITE_TIMEOUT_S)
1365
1409
  except subprocess.TimeoutExpired:
1366
1410
  print(f"caddis_roadmap_run: roadmap {args[0]} timed out", file=sys.stderr)
@@ -1377,8 +1421,9 @@ def _roadmap_write(ctx: _Ctx, roadmap_cmd: Sequence[str], args: list[str]) -> in
1377
1421
 
1378
1422
  class _Baseline:
1379
1423
  """The snapshot guard's baseline, kept across iterations. Every origin read is compared with the previous read
1380
- first: the Milestones and Blockers tables and the Runner host line are frozen, except for the exact edits the
1381
- runner itself made and recorded (`expect`) since that read. Then the new read becomes the baseline."""
1424
+ first: the Milestones and Blockers tables, the Runner host line and the Exit setup line are frozen, except for
1425
+ the exact edits the runner itself made and recorded (`expect`) since that read. Then the new read becomes the
1426
+ baseline."""
1382
1427
 
1383
1428
  def __init__(self, roadmap: "caddis_roadmap.Roadmap"):
1384
1429
  self.roadmap = roadmap
@@ -1539,7 +1584,7 @@ def remove_worktree_if_clean(repo: Path, slug: str, root: Path | None = None) ->
1539
1584
  return f"worktree kept (status not readable): {path}"
1540
1585
  if work:
1541
1586
  return f"worktree kept (not clean, {len(work)} change(s)): {path}"
1542
- if _git_out(repo, "worktree", "remove", "--force", str(path)) is None:
1587
+ if caddis_worktree_safety.safe_remove_worktree(repo, path) is not None:
1543
1588
  return f"worktree kept (remove failed): {path}"
1544
1589
  _git_out(repo, "worktree", "prune")
1545
1590
  _state_file(repo, slug, "worktree").unlink(missing_ok=True)
@@ -1740,6 +1785,26 @@ def _run_roadmap(ctx: _Ctx, template: Path, dry_run: bool, after_session: Callab
1740
1785
  written = 0
1741
1786
  seen = len(_audit_lines(repo, slug))
1742
1787
  rc = _roadmap_write(ctx, roadmap_cmd, ["rollup", "--id", mid])
1788
+ if rc == caddis_exit.NOT_RUN: # a retry would fail the same way: the owner decides
1789
+ opened = []
1790
+ for line in _audit_lines(repo, slug)[seen:]:
1791
+ try:
1792
+ entry = json.loads(line)
1793
+ except ValueError:
1794
+ continue
1795
+ if isinstance(entry, dict) and entry.get("id") == mid and entry.get("pr") is not None:
1796
+ opened.append(f"#{entry['pr']}")
1797
+ if len(opened) > 1:
1798
+ written = (f"its docs PRs {', '.join(opened)} were opened and may still be open (the merge chain did "
1799
+ "not finish): check them")
1800
+ elif opened:
1801
+ written = (f"its docs PR {opened[0]} was opened and may still be open (the merge chain did not "
1802
+ "finish): check it")
1803
+ else:
1804
+ written = "nothing written"
1805
+ raise _Stop(f"{ROLLUP_NOT_RUN}: {mid} (its exit check or Exit setup could not run, or a git, lock or "
1806
+ f"merge step; {written}; output in {caddis_roadmap.local_dir(repo, slug)}); the owner decides",
1807
+ caddis_exit.BLOCKED)
1743
1808
  if rc not in (caddis_exit.CLEAN, caddis_exit.BLOCKED):
1744
1809
  raise _Stop(f"FAILED roadmap rollup {mid} exit {rc}", caddis_exit.BLOCKED)
1745
1810
  for line in _audit_lines(repo, slug)[seen:]:
@@ -2289,7 +2354,7 @@ def _launch(cmd: list[str], cwd: Path, timeout_s: float, stdin_text: str | None
2289
2354
  try:
2290
2355
  proc = subprocess.run(cmd, cwd=cwd, input=stdin_text if stdin_text is not None else "", capture_output=True,
2291
2356
  text=True, encoding="utf-8", errors="replace", timeout=timeout_s,
2292
- env=env if env is not None else headless_env())
2357
+ env=env if env is not None else session_env())
2293
2358
  except subprocess.TimeoutExpired as exc:
2294
2359
  out = exc.stdout.decode("utf-8", "replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
2295
2360
  err = exc.stderr.decode("utf-8", "replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
@@ -42,6 +42,10 @@ import datetime
42
42
  import traceback
43
43
  from pathlib import Path
44
44
 
45
+ # No child process opens a console window on Windows. Inline (not scripts/no_window.py) because this
46
+ # file also runs as a standalone copy with no sibling modules.
47
+ _NO_WINDOW_KW = {"creationflags": 0x08000000} if os.name == "nt" else {}
48
+
45
49
  _DEBUG_LOG = Path.home() / ".caddis" / "statusline_debug.log"
46
50
  def _debug_log(msg: str):
47
51
  if not os.environ.get("CADDIS_STATUSLINE_DEBUG"):
@@ -223,7 +227,7 @@ def git_facts(cwd: str) -> dict:
223
227
  try:
224
228
  proc = subprocess.run(
225
229
  ["git", "-C", cwd, "--no-optional-locks", "status", "--porcelain=v2", "--branch"],
226
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=0.8,
230
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=0.8, **_NO_WINDOW_KW,
227
231
  )
228
232
  text = proc.stdout.decode("utf-8", "replace")
229
233
  except Exception:
@@ -307,8 +311,8 @@ def ram_facts():
307
311
 
308
312
  if sys.platform == "darwin":
309
313
  total = int(subprocess.run(["sysctl", "-n", "hw.memsize"], stdout=subprocess.PIPE,
310
- timeout=1.0).stdout.strip())
311
- vm = subprocess.run(["vm_stat"], stdout=subprocess.PIPE, timeout=1.0)
314
+ timeout=1.0, **_NO_WINDOW_KW).stdout.strip())
315
+ vm = subprocess.run(["vm_stat"], stdout=subprocess.PIPE, timeout=1.0, **_NO_WINDOW_KW)
312
316
  page = 4096
313
317
  free_pages = 0
314
318
  for line in vm.stdout.decode("utf-8", "replace").splitlines():
@@ -85,6 +85,10 @@ except ModuleNotFoundError as exc:
85
85
  # caddis_todo has put its own dependencies on the path by now; this one ships beside it.
86
86
  from caddis_file_lock import atomic_write # noqa: E402
87
87
 
88
+ # No child process opens a console window on Windows (this runs under hooks and status lines that
89
+ # have no console). Inline, not scripts/no_window.py: in the source tree this file does not sit beside it.
90
+ _NO_WINDOW = 0x08000000 if os.name == "nt" else 0
91
+
88
92
  TERMINAL_STATUSES = {"done", "superseded", "shipped", "implemented"}
89
93
 
90
94
  # Legacy prompts written before the frontmatter convention existed (2026-07-23). They were
@@ -398,7 +402,7 @@ def _run_git_mv(repo_root: Path, src: Path, dst: Path) -> bool:
398
402
  try:
399
403
  result = subprocess.run(
400
404
  ["git", "mv", "--", str(src), str(dst)],
401
- cwd=str(repo_root), capture_output=True, text=True, timeout=30,
405
+ cwd=str(repo_root), capture_output=True, text=True, timeout=30, creationflags=_NO_WINDOW,
402
406
  )
403
407
  return result.returncode == 0
404
408
  except Exception:
@@ -509,7 +513,7 @@ def parked_resolutions(root: Path, commit_range: str) -> list[Resolution]:
509
513
  ["git", "log", "--reverse", "--date=short", f"--abbrev={_SHA_ABBREV}",
510
514
  "--format=%h%x1f%cd%x1f%(trailers:only,unfold)%x1e", commit_range, "--"],
511
515
  cwd=str(root), capture_output=True, text=True, encoding="utf-8", errors="replace",
512
- timeout=30,
516
+ timeout=30, creationflags=_NO_WINDOW,
513
517
  )
514
518
  except (OSError, subprocess.SubprocessError) as exc:
515
519
  raise ValueError(f"git log failed: {exc}") from exc
@@ -610,7 +614,7 @@ def changed_paths(root: Path, commit_range: str) -> set[str]:
610
614
  ["git", "diff", "--name-only", "--no-renames", "--relative", "-z",
611
615
  f"{base}...{head}", "--"],
612
616
  cwd=str(root), capture_output=True, text=True, encoding="utf-8", errors="replace",
613
- timeout=30,
617
+ timeout=30, creationflags=_NO_WINDOW,
614
618
  )
615
619
  except (OSError, subprocess.SubprocessError) as exc:
616
620
  raise ValueError(f"cannot read commit range {commit_range}: {exc}") from exc
@@ -624,7 +628,7 @@ def _rev(root: Path, ref: str) -> str | None:
624
628
  """The commit `ref` names, or None when git cannot resolve it."""
625
629
  try:
626
630
  done = subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
627
- cwd=str(root), capture_output=True, text=True, timeout=30)
631
+ cwd=str(root), capture_output=True, text=True, timeout=30, creationflags=_NO_WINDOW)
628
632
  except (OSError, subprocess.SubprocessError):
629
633
  return None
630
634
  return done.stdout.strip() or None if done.returncode == 0 else None
@@ -641,7 +645,7 @@ def dirty_paths(root: Path, paths: set[str]) -> list[str]:
641
645
  ["git", "--literal-pathspecs", "status", "--porcelain", "-z", "--no-renames",
642
646
  "--untracked-files=all", "--", *names[at:at + 200]],
643
647
  cwd=str(root), capture_output=True, text=True, encoding="utf-8",
644
- errors="replace", timeout=60,
648
+ errors="replace", timeout=60, creationflags=_NO_WINDOW,
645
649
  )
646
650
  except (OSError, subprocess.SubprocessError) as exc:
647
651
  raise ValueError(f"cannot read git status: {exc}") from exc
@@ -929,7 +933,7 @@ class PruneReport:
929
933
  def _git_run(root: Path, *args: str, stdin: bytes | None = None):
930
934
  try:
931
935
  return subprocess.run(["git", "-C", str(root), *args], input=stdin,
932
- capture_output=True, timeout=60)
936
+ capture_output=True, timeout=60, creationflags=_NO_WINDOW)
933
937
  except (OSError, subprocess.SubprocessError):
934
938
  return None
935
939
 
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import json
7
+ import os
7
8
  import re
8
9
  import subprocess
9
10
  import sys
@@ -26,6 +27,10 @@ except ModuleNotFoundError as exc:
26
27
  from caddis_workstreams import _artifact, _name
27
28
 
28
29
 
30
+ # No child process opens a console window on Windows (this runs under hooks and status lines that
31
+ # have no console). Inline, not scripts/no_window.py: in the source tree this file does not sit beside it.
32
+ _NO_WINDOW = 0x08000000 if os.name == "nt" else 0
33
+
29
34
  _ITEM = re.compile(r'^- \[([ x])\] (.+?) <!-- caddis: (\{.*\}) -->$')
30
35
  _PHASE = re.compile(r'^\|\s*(\d+)\s*\|', re.M)
31
36
  _WORD = re.compile(r'[a-z0-9]{3,}')
@@ -240,7 +245,8 @@ def _plans_changed_on_branch(root: Path, plans: list[Path]) -> list[Path]:
240
245
  def git(*args: str) -> str | None:
241
246
  try:
242
247
  done = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True,
243
- encoding="utf-8", errors="replace", timeout=5, check=False)
248
+ encoding="utf-8", errors="replace", timeout=5, check=False,
249
+ creationflags=_NO_WINDOW)
244
250
  except (OSError, subprocess.SubprocessError):
245
251
  return None
246
252
  return done.stdout.strip() if done.returncode == 0 else None
@@ -28,6 +28,14 @@ except ModuleNotFoundError as exc:
28
28
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
29
29
  import caddis_exit
30
30
 
31
+ try:
32
+ import caddis_worktree_safety
33
+ except ModuleNotFoundError as exc:
34
+ if exc.name != "caddis_worktree_safety":
35
+ raise
36
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
37
+ import caddis_worktree_safety
38
+
31
39
 
32
40
  def _is_symlink_or_reparse(p: Path | str) -> bool:
33
41
  """Return True if path is a symlink or Windows reparse point."""
@@ -262,11 +270,10 @@ def close_worktree(
262
270
  print(f"would remove {worktree}, branch {branch_name} (local and origin)")
263
271
  return caddis_exit.CLEAN
264
272
 
265
- # 10. Remove
266
- cmd_wt_rm = ["git", "-C", str(repo), "worktree", "remove", "--force", str(worktree)]
267
- proc = subprocess.run(cmd_wt_rm, capture_output=True, text=True, encoding="utf-8", errors="replace")
268
- if proc.returncode != 0:
269
- print(f"failed command: {' '.join(cmd_wt_rm)}\n{proc.stderr.strip() or proc.stdout.strip()}", file=sys.stderr)
273
+ # 10. Remove. Step 8 checks only the top level; the helper unlinks every deeper link before git removes the tree.
274
+ problem = caddis_worktree_safety.safe_remove_worktree(repo, worktree)
275
+ if problem:
276
+ print(f"failed command: git -C {repo} worktree remove --force {worktree}\n{problem}", file=sys.stderr)
270
277
  return caddis_exit.ERROR
271
278
 
272
279
  cmd_br_d = ["git", "-C", str(repo), "branch", "-D", branch_name]
@@ -0,0 +1,182 @@
1
+ """Remove a git worktree, or a folder that held one, without deleting through a link.
2
+
3
+ On Windows, `git worktree remove --force` and `shutil.rmtree` can follow a directory junction and delete its
4
+ TARGET: a shared `.venv`, a `node_modules`, the live repo. So every symlink and junction in the tree goes first,
5
+ removed as a link (never entered), and only then is the tree removed. If a link cannot be removed, nothing is
6
+ deleted: the caller gets the problem back.
7
+
8
+ remove_links(root) -> the links removed (raises LinkRemovalError when one cannot go)
9
+ remove_tree(path) -> None, or the problem (links first, then rmtree)
10
+ safe_remove_worktree(repo, wt) -> None, or the problem (refuses the main checkout, unregistered paths, and
11
+ a locked or dirty worktree that git would keep, before any link goes)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import shutil
18
+ import stat
19
+ import subprocess
20
+ from collections.abc import Mapping, Sequence
21
+ from pathlib import Path
22
+
23
+ _REPARSE = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
24
+ _DIRECTORY = getattr(stat, "FILE_ATTRIBUTE_DIRECTORY", 0x10)
25
+
26
+
27
+ class LinkRemovalError(OSError):
28
+ """A link could not be removed. `removed` holds the links that were already removed before the failure; no
29
+ other file was deleted."""
30
+
31
+ def __init__(self, cause: OSError, removed: list[Path]):
32
+ detail = str(cause)
33
+ if removed:
34
+ detail += (f" (removed {len(removed)} link(s) before the failure: "
35
+ f"{', '.join(str(p) for p in removed)})")
36
+ super().__init__(detail)
37
+ self.cause = cause
38
+ self.removed = removed
39
+
40
+
41
+ def is_link(st: os.stat_result) -> bool:
42
+ """A symlink, or on Windows any reparse point (a junction included), from `os.lstat`."""
43
+ return stat.S_ISLNK(st.st_mode) or bool(getattr(st, "st_file_attributes", 0) & _REPARSE)
44
+
45
+
46
+ def _unlink_link(path: str, st: os.stat_result) -> None:
47
+ """Remove the link itself, never its target: on Windows a junction or a directory symlink with os.rmdir, any
48
+ other link with os.unlink."""
49
+ if os.name == "nt" and getattr(st, "st_file_attributes", 0) & _DIRECTORY:
50
+ os.rmdir(path)
51
+ else:
52
+ os.unlink(path)
53
+
54
+
55
+ def remove_links(root: Path) -> list[Path]:
56
+ """Remove every symlink and junction at or under `root`, bottom-up, never following one. Returns the links
57
+ removed. Raises LinkRemovalError (an OSError) when a link cannot be removed; the links removed before it are in
58
+ its `removed`. The caller must then delete nothing more, because a delete through a link destroys its target.
59
+ A missing `root` removes nothing."""
60
+ root = Path(root)
61
+ try:
62
+ st = os.lstat(root)
63
+ except FileNotFoundError:
64
+ return []
65
+ if is_link(st):
66
+ try:
67
+ _unlink_link(str(root), st)
68
+ except OSError as exc:
69
+ raise LinkRemovalError(exc, []) from exc
70
+ return [root]
71
+ folders = [str(root)]
72
+ order: list[str] = []
73
+ while folders: # top-down collection of real folders only; a link is never entered
74
+ folder = folders.pop()
75
+ order.append(folder)
76
+ with os.scandir(folder) as entries:
77
+ for entry in entries:
78
+ entry_st = os.lstat(entry.path)
79
+ if not is_link(entry_st) and stat.S_ISDIR(entry_st.st_mode):
80
+ folders.append(entry.path)
81
+ removed: list[Path] = []
82
+ try:
83
+ for folder in reversed(order): # bottom-up removal of the links in each folder
84
+ with os.scandir(folder) as entries:
85
+ found = [(entry.path, os.lstat(entry.path)) for entry in entries]
86
+ for path, entry_st in found:
87
+ if is_link(entry_st):
88
+ _unlink_link(path, entry_st)
89
+ removed.append(Path(path))
90
+ except OSError as exc:
91
+ raise LinkRemovalError(exc, removed) from exc
92
+ return removed
93
+
94
+
95
+ def remove_tree(path: Path) -> str | None:
96
+ """Delete the folder `path` (for example the temp folder that held a worktree): its links first, then the
97
+ rest. None when it is gone, else the problem. When a link cannot go, no file is deleted (the links removed
98
+ before the failure are named in the problem)."""
99
+ path = Path(path)
100
+ if not os.path.lexists(path):
101
+ return None
102
+ try:
103
+ remove_links(path)
104
+ except OSError as exc:
105
+ return f"not removed: {path}: a link could not be removed: {exc}; no file was deleted"
106
+ shutil.rmtree(path, ignore_errors=True)
107
+ return f"not removed: {path}" if os.path.lexists(path) else None
108
+
109
+
110
+ def _same(a: Path | str, b: Path | str) -> bool:
111
+ return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b))
112
+
113
+
114
+ def _run_git(repo: Path, args: Sequence[str], git_flags: Sequence[str],
115
+ env: Mapping[str, str] | None) -> subprocess.CompletedProcess:
116
+ return subprocess.run(["git", *git_flags, "-C", str(repo), *args], capture_output=True, text=True, check=False,
117
+ encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL,
118
+ env=dict(env) if env is not None else None)
119
+
120
+
121
+ def _worktree_blocks(porcelain: str) -> list[list[str]]:
122
+ """`git worktree list --porcelain` split into one list of lines per worktree."""
123
+ blocks: list[list[str]] = []
124
+ for line in porcelain.splitlines():
125
+ if line.startswith("worktree "):
126
+ blocks.append([line])
127
+ elif line.strip() and blocks:
128
+ blocks[-1].append(line)
129
+ return blocks
130
+
131
+
132
+ def safe_remove_worktree(repo: Path, worktree: Path, *, force: bool = True, force_locked: bool = False,
133
+ git_flags: Sequence[str] = (), env: Mapping[str, str] | None = None) -> str | None:
134
+ """Remove the linked worktree `worktree` of `repo`: its links first, then `git worktree remove`.
135
+
136
+ None when git removed it, else the problem. Before any link is touched it refuses a path that is a link, the
137
+ main checkout, a path that is not a registered linked worktree of `repo`, a locked worktree unless
138
+ `force_locked`, and a dirty one unless `force`: git would keep those, and a kept worktree must keep its links.
139
+ `force` passes `--force` (a dirty worktree goes); `force_locked` passes it twice (a locked one goes too). A
140
+ relative `worktree` is taken from `repo`, not from the process cwd. `git_flags` go before `-C` (for example
141
+ `-c core.hooksPath=...`); `env` is the git environment."""
142
+ worktree = Path(worktree)
143
+ if not worktree.is_absolute():
144
+ worktree = Path(repo) / worktree
145
+ try:
146
+ if os.path.lexists(worktree) and is_link(os.lstat(worktree)):
147
+ return f"refusing to remove {worktree}: the worktree folder is a junction or symlink"
148
+ except OSError as exc:
149
+ return f"refusing to remove {worktree}: cannot inspect it: {exc}"
150
+ listing = _run_git(Path(repo), ("worktree", "list", "--porcelain"), git_flags, env)
151
+ if listing.returncode != 0:
152
+ return f"refusing to remove {worktree}: git worktree list failed: {(listing.stderr or '').strip()}"
153
+ blocks = _worktree_blocks(listing.stdout)
154
+ if not blocks:
155
+ return f"refusing to remove {worktree}: git listed no worktrees"
156
+ if _same(worktree, blocks[0][0][len("worktree "):]):
157
+ return f"refusing to remove {worktree}: it is the main checkout"
158
+ if _same(worktree, repo):
159
+ return f"refusing to remove {worktree}: it is the checkout git runs in"
160
+ block = next((b for b in blocks[1:] if _same(worktree, b[0][len("worktree "):])), None)
161
+ if block is None:
162
+ return f"refusing to remove {worktree}: not a registered worktree of {repo}"
163
+ if not force_locked and any(line == "locked" or line.startswith("locked ") for line in block):
164
+ return f"refusing to remove {worktree}: it is locked (git worktree unlock it first); its links were kept"
165
+ if not force and os.path.lexists(worktree):
166
+ status = _run_git(worktree, ("status", "--porcelain"), git_flags, env)
167
+ if status.returncode != 0:
168
+ return f"refusing to remove {worktree}: git status failed: {(status.stderr or '').strip()}"
169
+ if status.stdout.strip():
170
+ return f"refusing to remove {worktree}: it has uncommitted changes; its links were kept"
171
+ try:
172
+ removed = remove_links(worktree)
173
+ except OSError as exc:
174
+ return f"not removed: {worktree}: a link could not be removed: {exc}; no file was deleted"
175
+ flags = ["--force", "--force"] if force_locked else (["--force"] if force else [])
176
+ proc = _run_git(Path(repo), ("worktree", "remove", *flags, str(worktree)), git_flags, env)
177
+ if proc.returncode != 0:
178
+ detail = (proc.stderr or proc.stdout or "").strip() or str(worktree)
179
+ if removed:
180
+ detail += f" ({len(removed)} link(s) inside it were removed first)"
181
+ return f"git worktree remove failed: {detail}"
182
+ return None
@@ -448,6 +448,8 @@ def _repo_root() -> Path:
448
448
  out = subprocess.run(
449
449
  ["git", "rev-parse", "--show-toplevel"],
450
450
  capture_output=True, text=True, timeout=5,
451
+ # No console window when the parent has none (a hook or an editor task).
452
+ creationflags=0x08000000 if os.name == "nt" else 0,
451
453
  )
452
454
  if out.returncode == 0 and out.stdout.strip():
453
455
  return Path(out.stdout.strip())
@@ -41,6 +41,10 @@ FIX_HINTS: dict[str, str] = {
41
41
 
42
42
  # Probed contract versions (docs/analysis/*-contract.md). WARN when the installed binary drifts past
43
43
  # these — the recorded schemas may be stale and want a re-probe.
44
+ # No child process opens a console window on Windows (this runs under hooks and status lines that
45
+ # have no console). Inline, not scripts/no_window.py: in the source tree this file does not sit beside it.
46
+ _NO_WINDOW = 0x08000000 if os.name == "nt" else 0
47
+
44
48
  PROBED_VERSIONS = {"codex": "0.137", "agy": "1.1.5"}
45
49
  AGENTS_MD_BUDGET = 200 # always-loaded rules file line budget (mirrors check_doc_coverage)
46
50
 
@@ -100,7 +104,7 @@ def _which(name: str) -> str | None:
100
104
 
101
105
  def _binary_version(path: str) -> str | None:
102
106
  try:
103
- out = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=20)
107
+ out = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=20, creationflags=_NO_WINDOW)
104
108
  return (out.stdout or out.stderr).strip().splitlines()[0] if (out.stdout or out.stderr) else None
105
109
  except Exception:
106
110
  return None
@@ -461,7 +465,7 @@ def host_guard_findings(
461
465
  if sys.platform != "win32":
462
466
  return []
463
467
 
464
- run = run or subprocess.run
468
+ run = run or (lambda *a, **k: subprocess.run(*a, creationflags=_NO_WINDOW, **k))
465
469
  out: list[tuple[str, str]] = []
466
470
 
467
471
  try: