@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
@@ -23,6 +23,13 @@ Rules the runner trusts unattended:
23
23
  environment (any value, even empty), stdin not a terminal, or (Windows) stdin not a console (a NUL stdin is not);
24
24
  - Exit check and Check commands run in a temporary worktree of `origin/<default>` (the merged code), with no stdin,
25
25
  in their own process group (a job object on Windows); the tree is killed on every exit;
26
+ - that worktree has no venv, no node_modules and no build. An optional roadmap line `**Exit setup:** <command>` runs
27
+ first in it (900 s) and must install fresh into it; a failing setup is exit 3 and writes nothing. An exit check
28
+ that fails because the environment is missing (ENV_FAILURE_PATTERNS, exit 9009/127/126) is exit 3 and writes
29
+ nothing; a real failure writes an `external` blocker whose reason ends with the masked tail of the output (one
30
+ line, the whole cell at most 200 characters; deduped and unblocked by its stable `exit check failing (exit N)`
31
+ part). Both keep the last 64 KB of output (masked) in `.caddis/roadmaps/<slug>/.local/exit-check-<id>-<utc>.log`
32
+ (the newest 10 per roadmap);
26
33
  - every network call has a timeout (git fetch/ls-remote/push 120 s, gh 60 s, the merge chain 1800 s) and runs with
27
34
  GIT_TERMINAL_PROMPT=0 and GCM_INTERACTIVE=never; a timeout is exit 3 (a merge-chain timeout is logged as
28
35
  "merge_exit": "timeout"); Ctrl+C kills the tree too;
@@ -39,7 +46,8 @@ commit on a `roadmap/<slug>-<id>-<utc>` branch made from `origin/<default>` in a
39
46
  prints the diff it would write and the commands it would run, and runs and writes nothing.
40
47
 
41
48
  Exit codes (caddis_exit): 0 clean, 1 blocked (nothing ready, check failing, PR not merged), 3 could not run
42
- (no origin, no roadmap, lock busy), 4 error (malformed roadmap, unknown id, column or value, refused change).
49
+ (no origin, no roadmap, lock busy, an exit check or Exit setup that cannot run in the merged checkout), 4 error
50
+ (malformed roadmap, unknown id, column or value, refused change).
43
51
 
44
52
  Usage: caddis_roadmap.py status --roadmap SLUG [--root DIR] [--json]
45
53
  caddis_roadmap.py next --roadmap SLUG [--root DIR] [--json]
@@ -63,7 +71,6 @@ import difflib
63
71
  import json
64
72
  import os
65
73
  import re
66
- import shutil
67
74
  import signal
68
75
  import socket
69
76
  import subprocess
@@ -78,14 +85,22 @@ from pathlib import Path
78
85
 
79
86
  try:
80
87
  import caddis_exit
88
+ import caddis_redact
81
89
  import caddis_rows
90
+ import caddis_worktree_safety
82
91
  except ModuleNotFoundError as exc:
83
- if exc.name not in {"caddis_exit", "caddis_rows"}:
92
+ if exc.name not in {"caddis_exit", "caddis_redact", "caddis_rows", "caddis_worktree_safety"}:
84
93
  raise
85
94
  sys.path.insert(0, str(Path(__file__).resolve().parent))
86
95
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
87
96
  import caddis_exit
97
+ import caddis_redact
88
98
  import caddis_rows
99
+ import caddis_worktree_safety
100
+
101
+ # No child process opens a console window on Windows (this runs under hooks and status lines that
102
+ # have no console). Inline, not scripts/no_window.py: in the source tree this file does not sit beside it.
103
+ _NO_WINDOW = 0x08000000 if os.name == "nt" else 0
89
104
 
90
105
  SCRIPTS_DIR = Path(__file__).resolve().parent
91
106
  STATUSES = ("todo", "in-progress", "code-done/data-pending", "done", "not-needed")
@@ -97,7 +112,55 @@ MILESTONE_COLS = ["ID", "Milestone", "Phases", "Depends on", "Exit check", "Stat
97
112
  BLOCKER_COLS = ["Milestone", "Type", "Reason", "Check"]
98
113
  HEADLESS_VARS = ("CADDIS_HEADLESS", "CLAUDECODE")
99
114
  EXIT_CHECK_TIMEOUT = 300
115
+ EXIT_SETUP_TIMEOUT = 900 # a fresh venv + pip install, or npm ci + a build, is slower than a check
100
116
  RECHECK_TIMEOUT = 120
117
+ # Exit codes of a shell that could not start the command: 9009 cmd.exe "not recognized", 127 sh "command not
118
+ # found", 126 sh "found but not executable".
119
+ SHELL_CANNOT_RUN_CODES = (9009, 127, 126)
120
+ # Output that says the merged checkout lacks the project's environment (no venv, no node_modules, no build), not that
121
+ # the product fails (serve-sight #301). Matched case-insensitively, only on a check that exits non-zero; a match is
122
+ # NOT_RUN (exit 3): no blocker, no PR. Each pattern has a sample in test_caddis_roadmap.py ENV_SAMPLES.
123
+ ENV_FAILURE_PATTERNS = (
124
+ "not recognized as an internal or external command", # cmd.exe: the tool is not on PATH
125
+ "command not found", # sh / bash: the tool is not on PATH
126
+ "No module named", # Python: no venv, or a package not installed
127
+ "ModuleNotFoundError", # Python: the same, as a traceback names it
128
+ "Cannot find module", # Node (CommonJS): no node_modules
129
+ "ERR_MODULE_NOT_FOUND", # Node (ES modules): no node_modules
130
+ "ENOENT", # Node: a file or binary that a build or an install makes is missing
131
+ "run npm run build first", # a check that needs the front-end bundle
132
+ "is not installed", # a tool or a browser (Playwright) that an install step adds
133
+ "could not find a version", # pip: a package index the setup cannot reach
134
+ )
135
+ ENV_FAILURE_RE = re.compile("|".join(re.escape(p) for p in ENV_FAILURE_PATTERNS), re.IGNORECASE)
136
+ ENV_SCAN_LINES = 40 # the patterns are searched only in this many last non-empty output lines
137
+ REASON_TAIL_LINES = 3 # a real failure's blocker reason ends with this many last non-empty output lines
138
+ REASON_CELL_CHARS = 200 # the whole stored Reason cell of a rollup blocker, after every escape
139
+ ROLLUP_PREFIX_RE = re.compile(r"exit check failing \((?:exit -?\d+|timed out after \d+ s)\)") # its stable part
140
+ KEEP_CHECK_LOGS = 10 # exit-check-*.log files kept per roadmap; older ones are removed
141
+ # Masks for the output tail that goes to origin and for the local log, after caddis_redact (which masks only the
142
+ # values of secret-named variables). The token rules are copied from the high-confidence SECRET_RULES in
143
+ # .github/tools/secret_filter.py (that folder is not on this script's import path in an installed plugin).
144
+ OUTPUT_MASKS: list[tuple[re.Pattern, str]] = [
145
+ # secret_filter private-key, widened to the WHOLE block: BEGIN to END, or to the end of the output with no END.
146
+ (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\Z)", re.DOTALL), "***"),
147
+ (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "***"), # secret_filter aws-key
148
+ (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), "***"), # secret_filter github-token
149
+ (re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"), "***"), # secret_filter slack-token
150
+ (re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"), "***"), # secret_filter jwt
151
+ # secret_filter assignment, widened: a quoted value may hold spaces (a passphrase of several words).
152
+ (re.compile(r"(?i)([A-Za-z0-9_]*(?:password|passwd|secret|api[_-]?key|token)[A-Za-z0-9_]*\s*[:=]\s*)"
153
+ r"(['\"])[^'\"\n]{8,}\2"), r"\1\2***\2"),
154
+ (re.compile(r"(?im)^(\s*(?:export\s+)?[A-Za-z0-9_]*(?:PASSWORD|PASSWD|SECRET|API_KEY|APIKEY|TOKEN|PRIVATE_KEY)"
155
+ r"[A-Za-z0-9_]*\s*=\s*)[^\s'\"#]{8,}"), r"\1***"), # secret_filter env-assignment
156
+ (re.compile(r"\b([A-Za-z][A-Za-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@"), r"\1***@"), # URL credentials
157
+ # Absolute paths, which can name a user or a host. A path may hold spaces, so each one runs to the end of the
158
+ # line or a quote; a Windows drive or UNC path also stops at `<`, `>` or `|` (never part of a Windows path).
159
+ (re.compile(r"(?<![A-Za-z0-9])[A-Za-z]:[\\/][^\r\n'\"<>|]*"), "<path>"), # a Windows drive path
160
+ (re.compile(r"(?<![\w\\])\\\\[^\s\\'\"<>|]+\\[^\r\n'\"<>|]*"), "<path>"), # a UNC path
161
+ (re.compile(r"(?<![\w:/.~-])/(?:home|Users|tmp|root|var|etc|opt)/[^\r\n'\"]*"), "<path>"), # a POSIX path
162
+ ]
163
+ EXIT_SETUP_RE = re.compile(r"^\*\*Exit setup:\*\*[ \t]*(.*?)[ \t]*$")
101
164
  LOCK_TIMEOUT = 30
102
165
  LOCK_MALFORMED_STALE_S = 60 # an empty or non-JSON lock file older than this is stale
103
166
  _WINDOWS = os.name == "nt" # a create can fail with PermissionError while a deleted lock's delete is pending
@@ -136,10 +199,17 @@ class RoadmapError(Exception):
136
199
  # -- git -------------------------------------------------------------------------------------------------------
137
200
 
138
201
 
202
+ # The login this process's own git, gh and merge-chain calls use (GH_TOKEN from the runner's --gh-account). It is
203
+ # never put in os.environ, so a session or lane started from os.environ cannot inherit it (robustness #4).
204
+ AUTH_ENV: dict[str, str] = {}
205
+
206
+
139
207
  def _quiet_env() -> dict:
140
- """The environment for git, gh and the merge chain: never wait for a credential prompt."""
208
+ """The environment for git, gh and the merge chain: never wait for a credential prompt. A fresh dict each call,
209
+ with AUTH_ENV on top."""
141
210
  env = dict(os.environ)
142
211
  env.update(NO_PROMPT_ENV)
212
+ env.update(AUTH_ENV)
143
213
  return env
144
214
 
145
215
 
@@ -149,7 +219,7 @@ def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProc
149
219
  proc = run_bounded(argv, GIT_NET_TIMEOUT, f"git {args[0]}")
150
220
  else:
151
221
  proc = subprocess.run(argv, capture_output=True, text=True, encoding="utf-8", errors="replace",
152
- stdin=subprocess.DEVNULL, env=_quiet_env())
222
+ stdin=subprocess.DEVNULL, env=_quiet_env(), creationflags=_NO_WINDOW)
153
223
  if check and proc.returncode != 0:
154
224
  raise RoadmapError(f"git {' '.join(args)} failed: {(proc.stderr or proc.stdout).strip()[:300]}",
155
225
  caddis_exit.NOT_RUN)
@@ -179,7 +249,7 @@ def fetch(root: Path) -> str:
179
249
 
180
250
  def show(root: Path, branch: str, path: str) -> str | None:
181
251
  proc = subprocess.run(["git", "-C", str(root), "show", f"origin/{branch}:{path}"], capture_output=True,
182
- stdin=subprocess.DEVNULL)
252
+ stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
183
253
  if proc.returncode != 0:
184
254
  return None
185
255
  # Bytes, not text mode: text mode would turn CRLF into LF and a write would change every line ending.
@@ -387,6 +457,7 @@ class Blocker:
387
457
  @dataclass
388
458
  class Roadmap:
389
459
  runner_host: str = ""
460
+ exit_setup: str = "" # the `**Exit setup:**` command run before an Exit check; empty when there is none
390
461
  milestones: list[Milestone] = field(default_factory=list)
391
462
  blockers: list[Blocker] = field(default_factory=list)
392
463
  errors: list[str] = field(default_factory=list)
@@ -518,6 +589,12 @@ def parse_roadmap(text: str) -> Roadmap:
518
589
  rm.runner_host = next((m.group(1) for m in map(HOST_RE.match, view) if m), "")
519
590
  if not rm.runner_host:
520
591
  rm.errors.append("no `Runner host: <host>` line")
592
+ setups = [(i, m.group(1)) for i, m in enumerate(map(EXIT_SETUP_RE.match, view)) if m]
593
+ if len(setups) > 1:
594
+ rm.errors.append("more than one `**Exit setup:**` line (lines "
595
+ + ", ".join(str(i + 1) for i, _ in setups) + "); a roadmap has at most one")
596
+ elif setups:
597
+ rm.exit_setup = _cell_text(setups[0][1])
521
598
 
522
599
  table = _section(tables, "Milestones", MILESTONE_COLS, rm.errors)
523
600
  seen: set[str] = set()
@@ -865,7 +942,10 @@ _CALENDAR_NEEDS_DATE = ("a calendar blocker needs --check with an ISO date YYYY-
865
942
  "date has passed, UTC)")
866
943
 
867
944
 
868
- def add_blocker(text: str, mid: str, btype: str, reason: str, check_cmd: str = "") -> str:
945
+ def add_blocker(text: str, mid: str, btype: str, reason: str, check_cmd: str = "", same_prefix: str = "") -> str:
946
+ """The text with one more Blockers row. No change when the same milestone, type and reason exist, or (with
947
+ `same_prefix`, rollup's stable `exit check failing (...)`) a row whose reason is that prefix or starts with it
948
+ and `: `."""
869
949
  rm = parse_roadmap(text)
870
950
  if rm.get(mid) is None:
871
951
  raise RoadmapError(f"unknown milestone id {mid}")
@@ -879,7 +959,9 @@ def add_blocker(text: str, mid: str, btype: str, reason: str, check_cmd: str = "
879
959
  if when is None:
880
960
  raise RoadmapError(_CALENDAR_NEEDS_DATE)
881
961
  check_cmd = when.isoformat()
882
- if any(b.milestone == mid and b.type == btype and b.reason == reason.strip() for b in rm.blockers):
962
+ if any(b.milestone == mid and b.type == btype and (b.reason == reason.strip() or (
963
+ same_prefix and (b.reason == same_prefix or b.reason.startswith(same_prefix + ": "))))
964
+ for b in rm.blockers):
883
965
  return text
884
966
  check_cell = _clean_value(check_cmd, "--check")
885
967
  if check_cell and "`" not in check_cell:
@@ -901,7 +983,18 @@ def remove_blockers(text: str, rows: list[Blocker]) -> str:
901
983
 
902
984
 
903
985
  def _find_blockers(rm: Roadmap, mid: str, btype: str, reason: str, check_cmd: str | None = None) -> list[Blocker]:
904
- """The rows to remove. More than one row with this milestone, type and reason needs --check to pick one."""
986
+ """The rows to remove. More than one row with this milestone, type and reason needs --check to pick one.
987
+ A reason that is only rollup's stable prefix (`exit check failing (exit N)`) removes EVERY row of this milestone
988
+ and type whose reason is that prefix or starts with it and `: ` (each is the same blocker with another tail)."""
989
+ if ROLLUP_PREFIX_RE.fullmatch(reason.strip()):
990
+ prefix = reason.strip()
991
+ rows = [b for b in rm.blockers if b.milestone == mid and b.type == btype
992
+ and (b.reason == prefix or b.reason.startswith(prefix + ": "))]
993
+ if not rows:
994
+ raise RoadmapError(f"no `{btype}` blocker on {mid} with reason `{prefix}` (or `{prefix}: ...`)")
995
+ if check_cmd is not None:
996
+ rows = [b for b in rows if b.check == _cell_text(check_cmd)]
997
+ return rows
905
998
  rows = [b for b in rm.blockers if b.milestone == mid and b.type == btype and b.reason == reason.strip()]
906
999
  if not rows:
907
1000
  raise RoadmapError(f"no `{btype}` blocker on {mid} with reason `{reason.strip()}`")
@@ -974,15 +1067,15 @@ def merged_checkout(root: Path, branch: str):
974
1067
  _git(root, "worktree", "add", "--quiet", "--detach", str(worktree), f"origin/{branch}")
975
1068
  yield worktree
976
1069
  finally:
977
- _git(root, "worktree", "remove", "--force", "--force", str(worktree), check=False)
1070
+ caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
978
1071
  _git(root, "worktree", "prune", check=False)
979
- shutil.rmtree(tmp, ignore_errors=True)
1072
+ caddis_worktree_safety.remove_tree(tmp) # links first: a junction's target survives
980
1073
 
981
1074
 
982
1075
  def _kill_tree(proc: subprocess.Popen) -> None:
983
1076
  if WINDOWS:
984
1077
  subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True,
985
- stdin=subprocess.DEVNULL)
1078
+ stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
986
1079
  else:
987
1080
  try:
988
1081
  os.killpg(proc.pid, signal.SIGKILL)
@@ -1078,11 +1171,11 @@ class _WinJob:
1078
1171
 
1079
1172
  def _start(argv, **kwargs) -> tuple[subprocess.Popen, "_WinJob | None"]:
1080
1173
  """Popen with no stdin, in its own process group (and, on Windows, its own job object)."""
1081
- if WINDOWS:
1082
- kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
1083
- else:
1174
+ if not WINDOWS:
1084
1175
  kwargs["start_new_session"] = True
1085
- proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, **kwargs)
1176
+ # CREATE_NO_WINDOW: the child (git, gh, a gate) must not flash a window when this runs from a hook.
1177
+ flags = (subprocess.CREATE_NEW_PROCESS_GROUP | _NO_WINDOW) if WINDOWS else 0
1178
+ proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, creationflags=flags, **kwargs)
1086
1179
  job = None
1087
1180
  if WINDOWS:
1088
1181
  try:
@@ -1206,6 +1299,81 @@ def _failure(rc: int | None, timeout: float) -> str:
1206
1299
  return f"timed out after {timeout:.0f} s" if rc is None else f"exit {rc}"
1207
1300
 
1208
1301
 
1302
+ def env_failure(rc: int | None, output: str) -> str | None:
1303
+ """Why a failed check could not run in the merged checkout (the environment is missing); None for a real failure
1304
+ (also for a timeout: it ran)."""
1305
+ if rc is None or rc == 0:
1306
+ return None
1307
+ if rc in SHELL_CANNOT_RUN_CODES:
1308
+ return f"exit {rc}: the shell could not start the command"
1309
+ lines = [ln for ln in (output or "").splitlines() if ln.strip()][-ENV_SCAN_LINES:]
1310
+ match = ENV_FAILURE_RE.search("\n".join(lines))
1311
+ return f"the output says `{match.group(0)}`" if match else None
1312
+
1313
+
1314
+ def mask_output(text: str, secrets: list[str]) -> str:
1315
+ """`text` with secret values (caddis_redact), token-shaped strings, URL credentials and absolute paths masked."""
1316
+ text = caddis_redact.redact(text or "", secrets)
1317
+ for pattern, repl in OUTPUT_MASKS:
1318
+ text = pattern.sub(repl, text)
1319
+ return text
1320
+
1321
+
1322
+ def _plain_line(text: str) -> str:
1323
+ """Text a roadmap cell holds as is: printable ASCII on one line, no HTML comment marker, and no `|` or backslash,
1324
+ so `_clean_value` escapes nothing and the stored cell is exactly this text."""
1325
+ text = "".join(ch if " " <= ch <= "~" else " " for ch in text)
1326
+ text = text.replace("\\", "/").replace("|", "/").replace("<!--", "<! --").replace("-->", "-- >")
1327
+ return " ".join(text.split())
1328
+
1329
+
1330
+ def blocker_reason(failure: str, output: str, secrets: list[str]) -> str:
1331
+ """`exit check failing (<failure>)`, then `: ` and the last REASON_TAIL_LINES non-empty output lines on one line,
1332
+ masked (mask_output), with nothing left to escape, cut from the front so the whole cell is at most
1333
+ REASON_CELL_CHARS: the merged blocker says why. The part before `: ` is the stable identity (ROLLUP_PREFIX_RE)."""
1334
+ base = f"exit check failing ({failure})"
1335
+ lines = [ln.strip() for ln in mask_output(output, secrets).splitlines() if ln.strip()]
1336
+ tail = _plain_line(" / ".join(lines[-REASON_TAIL_LINES:]))
1337
+ room = REASON_CELL_CHARS - len(base) - len(": ")
1338
+ if len(tail) > room:
1339
+ tail = ("..." + tail[-(room - 3):].lstrip())[:room]
1340
+ return f"{base}: {tail}" if tail else base
1341
+
1342
+
1343
+ def _log_stamp() -> str:
1344
+ """The UTC stamp in an exit-check log name; it sorts in time order (a seam for the tests)."""
1345
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
1346
+
1347
+
1348
+ def keep_check_output(root: Path, slug: str, mid: str, runs: list[tuple[str, str, int | None, str]],
1349
+ secrets: list[str]) -> Path | None:
1350
+ """Write the last OUTPUT_TAIL_BYTES of what the Exit setup and Exit check printed, masked (mask_output), to
1351
+ `<.local>/exit-check-<id>-<utc>.log` and return its path (None when it cannot be written; the caller says so).
1352
+ Only the newest KEEP_CHECK_LOGS such logs of the roadmap stay. `runs` holds (what, command, exit code or None,
1353
+ output)."""
1354
+ parts = []
1355
+ for what, cmd, rc, output in runs:
1356
+ body = output if not output or output.endswith("\n") else output + "\n"
1357
+ result = "timed out" if rc is None else f"exit {rc}"
1358
+ parts.append(f"== {what}: {cmd}\n{body}== {what} result: {result}\n")
1359
+ data = mask_output("".join(parts), secrets).encode("utf-8")[-OUTPUT_TAIL_BYTES:]
1360
+ path = local_dir(root, slug) / f"exit-check-{mid}-{_log_stamp()}.log"
1361
+ try:
1362
+ path.parent.mkdir(parents=True, exist_ok=True)
1363
+ path.write_bytes(data.decode("utf-8", errors="ignore").encode("utf-8"))
1364
+ except OSError:
1365
+ return None
1366
+ logs = sorted(path.parent.glob("exit-check-*.log"), key=lambda f: (f.stem.rsplit("-", 1)[-1], f.name))
1367
+ for old in logs[:-KEEP_CHECK_LOGS]:
1368
+ with contextlib.suppress(OSError):
1369
+ old.unlink()
1370
+ return path
1371
+
1372
+
1373
+ def _log_note(log: Path | None) -> str:
1374
+ return f"Output: {log}" if log is not None else "Output not kept (the .local folder cannot be written)."
1375
+
1376
+
1209
1377
  # -- the write path ---------------------------------------------------------------------------------------------
1210
1378
 
1211
1379
 
@@ -1373,7 +1541,7 @@ BATCH_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)#(\d+|\*)$") # `<plan>#<ph
1373
1541
  def runner_state_dir(root: Path) -> Path | None:
1374
1542
  """`<git common dir>/caddis-runner`, or None when `root` is not inside a git repo."""
1375
1543
  proc = subprocess.run(["git", "-C", str(root), "rev-parse", "--git-common-dir"], capture_output=True, text=True,
1376
- encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL)
1544
+ encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
1377
1545
  if proc.returncode != 0 or not proc.stdout.strip():
1378
1546
  return None
1379
1547
  common = Path(proc.stdout.strip())
@@ -1667,10 +1835,10 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
1667
1835
  else:
1668
1836
  rc = caddis_exit.NOT_RUN if rc == caddis_exit.NOT_RUN else caddis_exit.BLOCKED
1669
1837
  finally:
1670
- _git(root, "worktree", "remove", "--force", "--force", str(worktree), check=False)
1838
+ caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
1671
1839
  _git(root, "worktree", "prune", check=False)
1672
1840
  _git(root, "branch", "-D", branch, check=False)
1673
- shutil.rmtree(tmp, ignore_errors=True)
1841
+ caddis_worktree_safety.remove_tree(tmp)
1674
1842
  _audit(root, slug, {"utc": _utc(), "roadmap": slug, "id": mid, "change": change, "branch": branch,
1675
1843
  "pr": pr, "merge_exit": merge_exit if merge_exit is not None
1676
1844
  else (rc if pr is not None else None)})
@@ -1843,28 +2011,60 @@ def rollup(root: Path, slug: str, mid: str, dry_run: bool = False, out=print) ->
1843
2011
  return edit_cell(fresh.text, mid, "Status", value)
1844
2012
  return edit
1845
2013
 
2014
+ setup = snap.roadmap.exit_setup
1846
2015
  if dry_run:
1847
2016
  if ms.exit_check:
2017
+ if setup:
2018
+ out(f"would run setup: {setup}")
1848
2019
  out(f"would run: {ms.exit_check}")
1849
2020
  return write_change(root, slug, mid, "Status done", to_status("done"), True, out)
1850
2021
  rc: int | None = 0
2022
+ output = ""
2023
+ log: Path | None = None
2024
+ secrets: list[str] = []
1851
2025
  if ms.exit_check:
2026
+ secrets = caddis_redact.load_secrets(Path.home())
2027
+ runs: list[tuple[str, str, int | None, str]] = []
2028
+ setup_rc: int | None = 0
1852
2029
  with merged_checkout(root, snap.branch) as cwd:
1853
- rc, _output = run_check(cwd, ms.exit_check, EXIT_CHECK_TIMEOUT)
2030
+ if setup: # installs fresh into this worktree; never a link to a shared venv or node_modules
2031
+ setup_rc, setup_output = run_check(cwd, setup, EXIT_SETUP_TIMEOUT)
2032
+ runs.append(("Exit setup", setup, setup_rc, setup_output))
2033
+ if setup_rc == 0:
2034
+ rc, output = run_check(cwd, ms.exit_check, EXIT_CHECK_TIMEOUT)
2035
+ runs.append(("Exit check", ms.exit_check, rc, output))
2036
+ if setup_rc != 0: # the check never ran: an environment problem, never a blocker
2037
+ log = keep_check_output(root, slug, mid, runs, secrets)
2038
+ out(f"{mid}: exit check not run: the Exit setup failed "
2039
+ f"({_failure(setup_rc, EXIT_SETUP_TIMEOUT)}); nothing written. {_log_note(log)}")
2040
+ return caddis_exit.NOT_RUN
2041
+ if rc != 0:
2042
+ log = keep_check_output(root, slug, mid, runs, secrets)
2043
+ why = env_failure(rc, output)
2044
+ if why is not None:
2045
+ hint = ("check the `**Exit setup:**` line (it ran and passed, but did not install what the check "
2046
+ "needs, or the check does not call the venv it made)" if setup else
2047
+ "give the roadmap an `**Exit setup:**` line that installs it fresh")
2048
+ out(f"{mid}: exit check not run: the merged checkout lacks the project environment ({why}); "
2049
+ f"nothing written. Fix: {hint}, or fix the check. {_log_note(log)}")
2050
+ return caddis_exit.NOT_RUN
1854
2051
  if rc == 0:
1855
2052
  out(f"{mid}: exit check passed" if ms.exit_check else f"{mid}: every phase merged, no exit check")
1856
2053
  return write_change(root, slug, mid, "Status done", to_status("done"), False, out)
1857
2054
  failure = _failure(rc, EXIT_CHECK_TIMEOUT)
1858
2055
  if any(b.type == "data-condition" for b in snap.roadmap.blockers_of(mid)):
1859
- out(f"{mid}: exit check failing ({failure}) and a data-condition blocker is open: code-done/data-pending")
2056
+ out(f"{mid}: exit check failing ({failure}) and a data-condition blocker is open: code-done/data-pending. "
2057
+ f"{_log_note(log)}")
1860
2058
  write_rc = write_change(root, slug, mid, "Status code-done/data-pending",
1861
2059
  to_status("code-done/data-pending"), False, out)
1862
2060
  return write_rc if write_rc != caddis_exit.CLEAN else caddis_exit.BLOCKED
1863
- # The command's output is never written to the roadmap: it can hold paths or secrets.
1864
- reason = f"exit check failing ({failure})"
1865
- out(f"{mid}: {reason}")
2061
+ # Only a short tail of the output reaches the roadmap: redacted, one line, plain ASCII. The rest stays local.
2062
+ reason = blocker_reason(failure, output, secrets)
2063
+ out(f"{mid}: {reason}. {_log_note(log)}")
2064
+ stable = f"exit check failing ({failure})" # a rollup blocker's identity: the tail varies from run to run
1866
2065
  write_rc = write_change(root, slug, mid, "block external",
1867
- lambda fresh: add_blocker(fresh.text, mid, "external", reason), False, out)
2066
+ lambda fresh: add_blocker(fresh.text, mid, "external", reason, same_prefix=stable), False,
2067
+ out)
1868
2068
  return write_rc if write_rc != caddis_exit.CLEAN else caddis_exit.BLOCKED
1869
2069
 
1870
2070
 
@@ -1944,7 +2144,7 @@ class _Parser(argparse.ArgumentParser):
1944
2144
 
1945
2145
  def _repo_root(raw: str) -> Path:
1946
2146
  proc = subprocess.run(["git", "-C", raw, "rev-parse", "--show-toplevel"], capture_output=True, text=True,
1947
- stdin=subprocess.DEVNULL)
2147
+ stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
1948
2148
  if proc.returncode != 0:
1949
2149
  raise RoadmapError(f"{raw} is not inside a git repo", caddis_exit.NOT_RUN)
1950
2150
  return Path(proc.stdout.strip())
@@ -137,7 +137,8 @@ def main_checkout(root: Path) -> Path:
137
137
  session-state folder; the waiting hook writes its marks there too."""
138
138
  try:
139
139
  out = subprocess.run(["git", "-C", str(root), "rev-parse", "--path-format=absolute", "--git-common-dir"],
140
- capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30)
140
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30,
141
+ creationflags=0x08000000 if os.name == "nt" else 0)
141
142
  common = Path(out.stdout.strip()) if out.returncode == 0 and out.stdout.strip() else None
142
143
  except (OSError, subprocess.SubprocessError):
143
144
  common = None