@caddis/cli 0.4.55 → 0.4.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
  2. package/bundles/antigravity-plugin/guard_agy.py +1056 -83
  3. package/bundles/antigravity-plugin/hooks.json +2 -1
  4. package/bundles/antigravity-plugin/plugin.json +1 -1
  5. package/bundles/antigravity-plugin/repo_freshness.py +182 -4
  6. package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
  7. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
  8. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
  9. package/bundles/antigravity-plugin/scripts/caddis_gate.py +27 -1
  10. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +93 -6
  11. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
  12. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
  13. package/bundles/antigravity-plugin/scripts/caddis_review.py +290 -176
  14. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +346 -50
  15. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +82 -8
  16. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
  17. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
  18. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
  19. package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
  20. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
  21. package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
  22. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
  23. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
  24. package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
  25. package/bundles/antigravity-plugin/scripts/oss_review.py +303 -79
  26. package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
  27. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +6 -1
  28. package/bundles/antigravity-plugin/session_end_agy.py +27 -11
  29. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +55 -9
  30. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
  31. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
  32. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +10 -6
  33. package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
  34. package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -17
  35. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +45 -5
  37. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
  38. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
  39. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
  40. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
  41. package/bundles/antigravity-plugin/skills/ship/SKILL.md +32 -10
  42. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +32 -1
  43. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +44 -6
  44. package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
  45. package/bundles/antigravity-plugin/warm_start_agy.py +40 -16
  46. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  47. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  48. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
  50. package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
  51. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
  52. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +10 -6
  53. package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
  54. package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
  55. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
  56. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
  57. package/bundles/manifest.json +3 -3
  58. 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)
@@ -171,15 +241,19 @@ def default_branch(root: Path) -> str:
171
241
  caddis_exit.NOT_RUN)
172
242
 
173
243
 
244
+ FETCH = True # `render --no-fetch` (the waiting hook) turns it off for one command: read the origin refs it has
245
+
246
+
174
247
  def fetch(root: Path) -> str:
175
248
  """Fetch origin and return the default branch name."""
176
- _git(root, "fetch", "--quiet", "origin")
249
+ if FETCH:
250
+ _git(root, "fetch", "--quiet", "origin")
177
251
  return default_branch(root)
178
252
 
179
253
 
180
254
  def show(root: Path, branch: str, path: str) -> str | None:
181
255
  proc = subprocess.run(["git", "-C", str(root), "show", f"origin/{branch}:{path}"], capture_output=True,
182
- stdin=subprocess.DEVNULL)
256
+ stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
183
257
  if proc.returncode != 0:
184
258
  return None
185
259
  # Bytes, not text mode: text mode would turn CRLF into LF and a write would change every line ending.
@@ -387,6 +461,7 @@ class Blocker:
387
461
  @dataclass
388
462
  class Roadmap:
389
463
  runner_host: str = ""
464
+ exit_setup: str = "" # the `**Exit setup:**` command run before an Exit check; empty when there is none
390
465
  milestones: list[Milestone] = field(default_factory=list)
391
466
  blockers: list[Blocker] = field(default_factory=list)
392
467
  errors: list[str] = field(default_factory=list)
@@ -518,6 +593,12 @@ def parse_roadmap(text: str) -> Roadmap:
518
593
  rm.runner_host = next((m.group(1) for m in map(HOST_RE.match, view) if m), "")
519
594
  if not rm.runner_host:
520
595
  rm.errors.append("no `Runner host: <host>` line")
596
+ setups = [(i, m.group(1)) for i, m in enumerate(map(EXIT_SETUP_RE.match, view)) if m]
597
+ if len(setups) > 1:
598
+ rm.errors.append("more than one `**Exit setup:**` line (lines "
599
+ + ", ".join(str(i + 1) for i, _ in setups) + "); a roadmap has at most one")
600
+ elif setups:
601
+ rm.exit_setup = _cell_text(setups[0][1])
521
602
 
522
603
  table = _section(tables, "Milestones", MILESTONE_COLS, rm.errors)
523
604
  seen: set[str] = set()
@@ -865,7 +946,10 @@ _CALENDAR_NEEDS_DATE = ("a calendar blocker needs --check with an ISO date YYYY-
865
946
  "date has passed, UTC)")
866
947
 
867
948
 
868
- def add_blocker(text: str, mid: str, btype: str, reason: str, check_cmd: str = "") -> str:
949
+ def add_blocker(text: str, mid: str, btype: str, reason: str, check_cmd: str = "", same_prefix: str = "") -> str:
950
+ """The text with one more Blockers row. No change when the same milestone, type and reason exist, or (with
951
+ `same_prefix`, rollup's stable `exit check failing (...)`) a row whose reason is that prefix or starts with it
952
+ and `: `."""
869
953
  rm = parse_roadmap(text)
870
954
  if rm.get(mid) is None:
871
955
  raise RoadmapError(f"unknown milestone id {mid}")
@@ -879,7 +963,9 @@ def add_blocker(text: str, mid: str, btype: str, reason: str, check_cmd: str = "
879
963
  if when is None:
880
964
  raise RoadmapError(_CALENDAR_NEEDS_DATE)
881
965
  check_cmd = when.isoformat()
882
- if any(b.milestone == mid and b.type == btype and b.reason == reason.strip() for b in rm.blockers):
966
+ if any(b.milestone == mid and b.type == btype and (b.reason == reason.strip() or (
967
+ same_prefix and (b.reason == same_prefix or b.reason.startswith(same_prefix + ": "))))
968
+ for b in rm.blockers):
883
969
  return text
884
970
  check_cell = _clean_value(check_cmd, "--check")
885
971
  if check_cell and "`" not in check_cell:
@@ -901,7 +987,18 @@ def remove_blockers(text: str, rows: list[Blocker]) -> str:
901
987
 
902
988
 
903
989
  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."""
990
+ """The rows to remove. More than one row with this milestone, type and reason needs --check to pick one.
991
+ A reason that is only rollup's stable prefix (`exit check failing (exit N)`) removes EVERY row of this milestone
992
+ and type whose reason is that prefix or starts with it and `: ` (each is the same blocker with another tail)."""
993
+ if ROLLUP_PREFIX_RE.fullmatch(reason.strip()):
994
+ prefix = reason.strip()
995
+ rows = [b for b in rm.blockers if b.milestone == mid and b.type == btype
996
+ and (b.reason == prefix or b.reason.startswith(prefix + ": "))]
997
+ if not rows:
998
+ raise RoadmapError(f"no `{btype}` blocker on {mid} with reason `{prefix}` (or `{prefix}: ...`)")
999
+ if check_cmd is not None:
1000
+ rows = [b for b in rows if b.check == _cell_text(check_cmd)]
1001
+ return rows
905
1002
  rows = [b for b in rm.blockers if b.milestone == mid and b.type == btype and b.reason == reason.strip()]
906
1003
  if not rows:
907
1004
  raise RoadmapError(f"no `{btype}` blocker on {mid} with reason `{reason.strip()}`")
@@ -974,15 +1071,15 @@ def merged_checkout(root: Path, branch: str):
974
1071
  _git(root, "worktree", "add", "--quiet", "--detach", str(worktree), f"origin/{branch}")
975
1072
  yield worktree
976
1073
  finally:
977
- _git(root, "worktree", "remove", "--force", "--force", str(worktree), check=False)
1074
+ caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
978
1075
  _git(root, "worktree", "prune", check=False)
979
- shutil.rmtree(tmp, ignore_errors=True)
1076
+ caddis_worktree_safety.remove_tree(tmp) # links first: a junction's target survives
980
1077
 
981
1078
 
982
1079
  def _kill_tree(proc: subprocess.Popen) -> None:
983
1080
  if WINDOWS:
984
1081
  subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True,
985
- stdin=subprocess.DEVNULL)
1082
+ stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
986
1083
  else:
987
1084
  try:
988
1085
  os.killpg(proc.pid, signal.SIGKILL)
@@ -1078,11 +1175,11 @@ class _WinJob:
1078
1175
 
1079
1176
  def _start(argv, **kwargs) -> tuple[subprocess.Popen, "_WinJob | None"]:
1080
1177
  """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:
1178
+ if not WINDOWS:
1084
1179
  kwargs["start_new_session"] = True
1085
- proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, **kwargs)
1180
+ # CREATE_NO_WINDOW: the child (git, gh, a gate) must not flash a window when this runs from a hook.
1181
+ flags = (subprocess.CREATE_NEW_PROCESS_GROUP | _NO_WINDOW) if WINDOWS else 0
1182
+ proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, creationflags=flags, **kwargs)
1086
1183
  job = None
1087
1184
  if WINDOWS:
1088
1185
  try:
@@ -1206,6 +1303,81 @@ def _failure(rc: int | None, timeout: float) -> str:
1206
1303
  return f"timed out after {timeout:.0f} s" if rc is None else f"exit {rc}"
1207
1304
 
1208
1305
 
1306
+ def env_failure(rc: int | None, output: str) -> str | None:
1307
+ """Why a failed check could not run in the merged checkout (the environment is missing); None for a real failure
1308
+ (also for a timeout: it ran)."""
1309
+ if rc is None or rc == 0:
1310
+ return None
1311
+ if rc in SHELL_CANNOT_RUN_CODES:
1312
+ return f"exit {rc}: the shell could not start the command"
1313
+ lines = [ln for ln in (output or "").splitlines() if ln.strip()][-ENV_SCAN_LINES:]
1314
+ match = ENV_FAILURE_RE.search("\n".join(lines))
1315
+ return f"the output says `{match.group(0)}`" if match else None
1316
+
1317
+
1318
+ def mask_output(text: str, secrets: list[str]) -> str:
1319
+ """`text` with secret values (caddis_redact), token-shaped strings, URL credentials and absolute paths masked."""
1320
+ text = caddis_redact.redact(text or "", secrets)
1321
+ for pattern, repl in OUTPUT_MASKS:
1322
+ text = pattern.sub(repl, text)
1323
+ return text
1324
+
1325
+
1326
+ def _plain_line(text: str) -> str:
1327
+ """Text a roadmap cell holds as is: printable ASCII on one line, no HTML comment marker, and no `|` or backslash,
1328
+ so `_clean_value` escapes nothing and the stored cell is exactly this text."""
1329
+ text = "".join(ch if " " <= ch <= "~" else " " for ch in text)
1330
+ text = text.replace("\\", "/").replace("|", "/").replace("<!--", "<! --").replace("-->", "-- >")
1331
+ return " ".join(text.split())
1332
+
1333
+
1334
+ def blocker_reason(failure: str, output: str, secrets: list[str]) -> str:
1335
+ """`exit check failing (<failure>)`, then `: ` and the last REASON_TAIL_LINES non-empty output lines on one line,
1336
+ masked (mask_output), with nothing left to escape, cut from the front so the whole cell is at most
1337
+ REASON_CELL_CHARS: the merged blocker says why. The part before `: ` is the stable identity (ROLLUP_PREFIX_RE)."""
1338
+ base = f"exit check failing ({failure})"
1339
+ lines = [ln.strip() for ln in mask_output(output, secrets).splitlines() if ln.strip()]
1340
+ tail = _plain_line(" / ".join(lines[-REASON_TAIL_LINES:]))
1341
+ room = REASON_CELL_CHARS - len(base) - len(": ")
1342
+ if len(tail) > room:
1343
+ tail = ("..." + tail[-(room - 3):].lstrip())[:room]
1344
+ return f"{base}: {tail}" if tail else base
1345
+
1346
+
1347
+ def _log_stamp() -> str:
1348
+ """The UTC stamp in an exit-check log name; it sorts in time order (a seam for the tests)."""
1349
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
1350
+
1351
+
1352
+ def keep_check_output(root: Path, slug: str, mid: str, runs: list[tuple[str, str, int | None, str]],
1353
+ secrets: list[str]) -> Path | None:
1354
+ """Write the last OUTPUT_TAIL_BYTES of what the Exit setup and Exit check printed, masked (mask_output), to
1355
+ `<.local>/exit-check-<id>-<utc>.log` and return its path (None when it cannot be written; the caller says so).
1356
+ Only the newest KEEP_CHECK_LOGS such logs of the roadmap stay. `runs` holds (what, command, exit code or None,
1357
+ output)."""
1358
+ parts = []
1359
+ for what, cmd, rc, output in runs:
1360
+ body = output if not output or output.endswith("\n") else output + "\n"
1361
+ result = "timed out" if rc is None else f"exit {rc}"
1362
+ parts.append(f"== {what}: {cmd}\n{body}== {what} result: {result}\n")
1363
+ data = mask_output("".join(parts), secrets).encode("utf-8")[-OUTPUT_TAIL_BYTES:]
1364
+ path = local_dir(root, slug) / f"exit-check-{mid}-{_log_stamp()}.log"
1365
+ try:
1366
+ path.parent.mkdir(parents=True, exist_ok=True)
1367
+ path.write_bytes(data.decode("utf-8", errors="ignore").encode("utf-8"))
1368
+ except OSError:
1369
+ return None
1370
+ logs = sorted(path.parent.glob("exit-check-*.log"), key=lambda f: (f.stem.rsplit("-", 1)[-1], f.name))
1371
+ for old in logs[:-KEEP_CHECK_LOGS]:
1372
+ with contextlib.suppress(OSError):
1373
+ old.unlink()
1374
+ return path
1375
+
1376
+
1377
+ def _log_note(log: Path | None) -> str:
1378
+ return f"Output: {log}" if log is not None else "Output not kept (the .local folder cannot be written)."
1379
+
1380
+
1209
1381
  # -- the write path ---------------------------------------------------------------------------------------------
1210
1382
 
1211
1383
 
@@ -1373,7 +1545,7 @@ BATCH_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)#(\d+|\*)$") # `<plan>#<ph
1373
1545
  def runner_state_dir(root: Path) -> Path | None:
1374
1546
  """`<git common dir>/caddis-runner`, or None when `root` is not inside a git repo."""
1375
1547
  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)
1548
+ encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
1377
1549
  if proc.returncode != 0 or not proc.stdout.strip():
1378
1550
  return None
1379
1551
  common = Path(proc.stdout.strip())
@@ -1667,10 +1839,10 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
1667
1839
  else:
1668
1840
  rc = caddis_exit.NOT_RUN if rc == caddis_exit.NOT_RUN else caddis_exit.BLOCKED
1669
1841
  finally:
1670
- _git(root, "worktree", "remove", "--force", "--force", str(worktree), check=False)
1842
+ caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
1671
1843
  _git(root, "worktree", "prune", check=False)
1672
1844
  _git(root, "branch", "-D", branch, check=False)
1673
- shutil.rmtree(tmp, ignore_errors=True)
1845
+ caddis_worktree_safety.remove_tree(tmp)
1674
1846
  _audit(root, slug, {"utc": _utc(), "roadmap": slug, "id": mid, "change": change, "branch": branch,
1675
1847
  "pr": pr, "merge_exit": merge_exit if merge_exit is not None
1676
1848
  else (rc if pr is not None else None)})
@@ -1843,28 +2015,60 @@ def rollup(root: Path, slug: str, mid: str, dry_run: bool = False, out=print) ->
1843
2015
  return edit_cell(fresh.text, mid, "Status", value)
1844
2016
  return edit
1845
2017
 
2018
+ setup = snap.roadmap.exit_setup
1846
2019
  if dry_run:
1847
2020
  if ms.exit_check:
2021
+ if setup:
2022
+ out(f"would run setup: {setup}")
1848
2023
  out(f"would run: {ms.exit_check}")
1849
2024
  return write_change(root, slug, mid, "Status done", to_status("done"), True, out)
1850
2025
  rc: int | None = 0
2026
+ output = ""
2027
+ log: Path | None = None
2028
+ secrets: list[str] = []
1851
2029
  if ms.exit_check:
2030
+ secrets = caddis_redact.load_secrets(Path.home())
2031
+ runs: list[tuple[str, str, int | None, str]] = []
2032
+ setup_rc: int | None = 0
1852
2033
  with merged_checkout(root, snap.branch) as cwd:
1853
- rc, _output = run_check(cwd, ms.exit_check, EXIT_CHECK_TIMEOUT)
2034
+ if setup: # installs fresh into this worktree; never a link to a shared venv or node_modules
2035
+ setup_rc, setup_output = run_check(cwd, setup, EXIT_SETUP_TIMEOUT)
2036
+ runs.append(("Exit setup", setup, setup_rc, setup_output))
2037
+ if setup_rc == 0:
2038
+ rc, output = run_check(cwd, ms.exit_check, EXIT_CHECK_TIMEOUT)
2039
+ runs.append(("Exit check", ms.exit_check, rc, output))
2040
+ if setup_rc != 0: # the check never ran: an environment problem, never a blocker
2041
+ log = keep_check_output(root, slug, mid, runs, secrets)
2042
+ out(f"{mid}: exit check not run: the Exit setup failed "
2043
+ f"({_failure(setup_rc, EXIT_SETUP_TIMEOUT)}); nothing written. {_log_note(log)}")
2044
+ return caddis_exit.NOT_RUN
2045
+ if rc != 0:
2046
+ log = keep_check_output(root, slug, mid, runs, secrets)
2047
+ why = env_failure(rc, output)
2048
+ if why is not None:
2049
+ hint = ("check the `**Exit setup:**` line (it ran and passed, but did not install what the check "
2050
+ "needs, or the check does not call the venv it made)" if setup else
2051
+ "give the roadmap an `**Exit setup:**` line that installs it fresh")
2052
+ out(f"{mid}: exit check not run: the merged checkout lacks the project environment ({why}); "
2053
+ f"nothing written. Fix: {hint}, or fix the check. {_log_note(log)}")
2054
+ return caddis_exit.NOT_RUN
1854
2055
  if rc == 0:
1855
2056
  out(f"{mid}: exit check passed" if ms.exit_check else f"{mid}: every phase merged, no exit check")
1856
2057
  return write_change(root, slug, mid, "Status done", to_status("done"), False, out)
1857
2058
  failure = _failure(rc, EXIT_CHECK_TIMEOUT)
1858
2059
  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")
2060
+ out(f"{mid}: exit check failing ({failure}) and a data-condition blocker is open: code-done/data-pending. "
2061
+ f"{_log_note(log)}")
1860
2062
  write_rc = write_change(root, slug, mid, "Status code-done/data-pending",
1861
2063
  to_status("code-done/data-pending"), False, out)
1862
2064
  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}")
2065
+ # Only a short tail of the output reaches the roadmap: redacted, one line, plain ASCII. The rest stays local.
2066
+ reason = blocker_reason(failure, output, secrets)
2067
+ out(f"{mid}: {reason}. {_log_note(log)}")
2068
+ stable = f"exit check failing ({failure})" # a rollup blocker's identity: the tail varies from run to run
1866
2069
  write_rc = write_change(root, slug, mid, "block external",
1867
- lambda fresh: add_blocker(fresh.text, mid, "external", reason), False, out)
2070
+ lambda fresh: add_blocker(fresh.text, mid, "external", reason, same_prefix=stable), False,
2071
+ out)
1868
2072
  return write_rc if write_rc != caddis_exit.CLEAN else caddis_exit.BLOCKED
1869
2073
 
1870
2074
 
@@ -1944,7 +2148,7 @@ class _Parser(argparse.ArgumentParser):
1944
2148
 
1945
2149
  def _repo_root(raw: str) -> Path:
1946
2150
  proc = subprocess.run(["git", "-C", raw, "rev-parse", "--show-toplevel"], capture_output=True, text=True,
1947
- stdin=subprocess.DEVNULL)
2151
+ stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW)
1948
2152
  if proc.returncode != 0:
1949
2153
  raise RoadmapError(f"{raw} is not inside a git repo", caddis_exit.NOT_RUN)
1950
2154
  return Path(proc.stdout.strip())
@@ -2018,9 +2222,93 @@ def _build_parser() -> argparse.ArgumentParser:
2018
2222
  which.add_argument("--all", action="store_true", help="every roadmap on origin (used by hooks and commands)")
2019
2223
  p.add_argument("--root", default=".")
2020
2224
  p.add_argument("--out", default=None, help="the HTML file (default ~/.caddis/roadmap-pages/<repo>/<slug>.html)")
2225
+ p.add_argument("--no-fetch", action="store_true",
2226
+ help="use the origin refs this checkout has; no git fetch (the waiting hook's background render)")
2227
+ p.add_argument("--delay", type=int, default=0,
2228
+ help="wait this many seconds (at most 60) first: the waiting hook's follow-up render")
2021
2229
  return parser
2022
2230
 
2023
2231
 
2232
+ RENDER_LOCK_STALE_S = 600 # a render lock older than this is left by a killed render
2233
+ PENDING_NAME = ".roadmap-render.pending" # session_waiting.py writes it beside its debounce stamp
2234
+
2235
+
2236
+ def _clear(path: Path | None) -> bool:
2237
+ """Delete `path`; True when it was there."""
2238
+ if path is None:
2239
+ return False
2240
+ try:
2241
+ path.unlink()
2242
+ return True
2243
+ except FileNotFoundError:
2244
+ return False
2245
+ except OSError:
2246
+ return path.exists()
2247
+
2248
+
2249
+ def _take_render_lock(lock: Path) -> bool:
2250
+ """One render per repo at a time (robustness #8): create `lock` with O_EXCL, never wait. A lock older than
2251
+ RENDER_LOCK_STALE_S is taken over once. False: another render holds it."""
2252
+ lock.parent.mkdir(parents=True, exist_ok=True)
2253
+ for attempt in range(2):
2254
+ try:
2255
+ fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
2256
+ except FileExistsError:
2257
+ pass
2258
+ except PermissionError: # Windows: a deleted lock whose delete is still pending
2259
+ return False
2260
+ else:
2261
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
2262
+ fh.write(str(os.getpid()))
2263
+ return True
2264
+ try:
2265
+ stale = time.time() - lock.stat().st_mtime > RENDER_LOCK_STALE_S
2266
+ except FileNotFoundError:
2267
+ stale = True # released between the create and the stat: try again
2268
+ except OSError: # Windows: a lock being deleted cannot be read either
2269
+ return False
2270
+ if attempt or not stale:
2271
+ return False
2272
+ try:
2273
+ lock.unlink()
2274
+ except FileNotFoundError:
2275
+ pass
2276
+ except OSError:
2277
+ return False
2278
+ return False
2279
+
2280
+
2281
+ def _render(root: Path, args: argparse.Namespace) -> int:
2282
+ """`render`: the pages, the prune and the hub. The caller holds the repo's render lock."""
2283
+ import caddis_roadmap_page
2284
+ slugs, on_origin = list_roadmaps_checked(root) if args.all else ([args.roadmap], None)
2285
+ failed = written = pruned = 0
2286
+ for slug in slugs: # --all: one bad roadmap must not stop the others
2287
+ try:
2288
+ path = caddis_roadmap_page.write_page(root, slug, Path(args.out) if args.out else None,
2289
+ sys.modules[__name__])
2290
+ if path is None: # a newer caddis owns this page: kept, and neither written nor failed
2291
+ continue
2292
+ written += 1
2293
+ print(f"[ROADMAP] page written: {path.resolve()}")
2294
+ except Exception as exc: # --all: one roadmap must never stop the others
2295
+ if not args.all:
2296
+ raise
2297
+ failed += 1
2298
+ print(f"[ROADMAP] {slug}: page not written: {exc}", file=sys.stderr)
2299
+ try: # the hub is a convenience: nothing below ever fails a render or changes its exit code
2300
+ if args.all and on_origin is not None: # a roadmap removed from origin leaves the hub too; a listing
2301
+ # that failed removes nothing, and a folder still on origin keeps its page even if its render failed
2302
+ pruned = caddis_roadmap_page.prune_folder(
2303
+ caddis_roadmap_page.default_page_path(root, "_").parent, on_origin | set(slugs))
2304
+ hub = caddis_roadmap_page.write_hub() if not args.out and (written or pruned) else None
2305
+ if hub is not None: # None: a newer caddis owns the hub (write_hub said so)
2306
+ print(f"[ROADMAP] hub written: {hub.resolve()}")
2307
+ except Exception as exc:
2308
+ print(f"[ROADMAP] hub not written: {exc}", file=sys.stderr)
2309
+ return caddis_exit.BLOCKED if failed else caddis_exit.CLEAN
2310
+
2311
+
2024
2312
  def _dispatch(args: argparse.Namespace) -> int:
2025
2313
  root = _repo_root(args.root)
2026
2314
  if args.cmd == "list":
@@ -2066,29 +2354,37 @@ def _dispatch(args: argparse.Namespace) -> int:
2066
2354
  import caddis_roadmap_page # the page is separate; only `render` needs it
2067
2355
  if args.all and args.out:
2068
2356
  raise RoadmapError("--out works with one --roadmap, not with --all", caddis_exit.ERROR)
2069
- slugs, on_origin = list_roadmaps_checked(root) if args.all else ([args.roadmap], None)
2070
- failed = written = pruned = 0
2071
- for slug in slugs: # --all: one bad roadmap must not stop the others
2072
- try:
2073
- path = caddis_roadmap_page.write_page(root, slug, Path(args.out) if args.out else None,
2074
- sys.modules[__name__])
2075
- written += 1
2076
- print(f"[ROADMAP] page written: {path.resolve()}")
2077
- except Exception as exc: # --all: one roadmap must never stop the others
2078
- if not args.all:
2079
- raise
2080
- failed += 1
2081
- print(f"[ROADMAP] {slug}: page not written: {exc}", file=sys.stderr)
2082
- try: # the hub is a convenience: nothing below ever fails a render or changes its exit code
2083
- if args.all and on_origin is not None: # a roadmap removed from origin leaves the hub too; a listing
2084
- # that failed removes nothing, and a folder still on origin keeps its page even if its render failed
2085
- pruned = caddis_roadmap_page.prune_folder(
2086
- caddis_roadmap_page.default_page_path(root, "_").parent, on_origin | set(slugs))
2087
- if not args.out and (written or pruned):
2088
- print(f"[ROADMAP] hub written: {caddis_roadmap_page.write_hub().resolve()}")
2089
- except Exception as exc:
2090
- print(f"[ROADMAP] hub not written: {exc}", file=sys.stderr)
2091
- return caddis_exit.BLOCKED if failed else caddis_exit.CLEAN
2357
+ if args.delay > 0:
2358
+ time.sleep(min(args.delay, 60))
2359
+ folder = caddis_roadmap_page.default_page_path(root, "_").parent.name
2360
+ lock = caddis_roadmap_page.pages_root() / ".locks" / f"{folder}.lock"
2361
+ # The waiting hook's debounce (and a render that finds the lock held) leaves PENDING_NAME: the render that
2362
+ # holds the lock renders once more at its end, so the last mark change always reaches the page.
2363
+ state = caddis_roadmap_page.main_checkout(root) / ".caddis" / "session-state"
2364
+ pending = state / PENDING_NAME if args.all and not args.out else None
2365
+ if not _take_render_lock(lock):
2366
+ if pending is not None and state.is_dir():
2367
+ try:
2368
+ pending.write_text(str(os.getpid()), encoding="utf-8")
2369
+ except OSError:
2370
+ pass
2371
+ print("[ROADMAP] another render of this repo is running; skipped")
2372
+ return caddis_exit.CLEAN
2373
+ global FETCH
2374
+ FETCH = not args.no_fetch
2375
+ try:
2376
+ _clear(pending) # this render reads the marks as they are now
2377
+ code = _render(root, args)
2378
+ if _clear(pending): # a mark changed while it ran: once more, under the same lock
2379
+ code = _render(root, args)
2380
+ return code
2381
+ finally:
2382
+ FETCH = True
2383
+ try: # only our own lock: a render that ran past the stale age may have been taken over
2384
+ if lock.read_text(encoding="utf-8").strip() == str(os.getpid()):
2385
+ lock.unlink()
2386
+ except (OSError, UnicodeDecodeError):
2387
+ pass
2092
2388
  return caddis_exit.ERROR
2093
2389
 
2094
2390