@caddis/cli 0.4.56 → 0.4.58

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 (45) hide show
  1. package/bundles/antigravity-plugin/caddis_statusline.py +45 -11
  2. package/bundles/antigravity-plugin/guard_agy.py +700 -68
  3. package/bundles/antigravity-plugin/hooks.json +2 -1
  4. package/bundles/antigravity-plugin/plugin.json +1 -1
  5. package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +69 -23
  6. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
  7. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +9 -8
  8. package/bundles/antigravity-plugin/scripts/caddis_gate.py +21 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +39 -2
  10. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
  11. package/bundles/antigravity-plugin/scripts/caddis_review.py +81 -11
  12. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +124 -29
  13. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +80 -7
  14. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +26 -9
  15. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +24 -4
  16. package/bundles/antigravity-plugin/scripts/caddis_spend.py +120 -10
  17. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +45 -11
  18. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +14 -9
  19. package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +12 -0
  20. package/bundles/antigravity-plugin/scripts/hook_log.py +13 -2
  21. package/bundles/antigravity-plugin/scripts/oss_review.py +300 -79
  22. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +20 -6
  23. package/bundles/antigravity-plugin/session_end_agy.py +13 -10
  24. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +53 -8
  25. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
  26. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
  27. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +9 -5
  28. package/bundles/antigravity-plugin/skills/implement/SKILL.md +18 -11
  29. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +4 -1
  30. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +44 -4
  31. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
  32. package/bundles/antigravity-plugin/skills/ship/SKILL.md +26 -7
  33. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +31 -0
  34. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +42 -4
  35. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +3 -2
  36. package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +52 -175
  37. package/bundles/antigravity-plugin/warm_start_agy.py +26 -15
  38. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  39. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  40. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
  41. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +9 -5
  42. package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +52 -175
  43. package/bundles/manifest.json +3 -3
  44. package/dist/cli.js +310 -46
  45. package/package.json +1 -1
@@ -77,6 +77,33 @@ USAGE_LIMIT_RE = re.compile(
77
77
  r"Usage limit reached for|Limit Exhausted|Insufficient balance|You.ve hit your .*limit|RESOURCE_EXHAUSTED",
78
78
  re.IGNORECASE,
79
79
  )
80
+ # A provider failure, not a review: HTTP 429, any 5xx, a rate-limit or overload error. Counts only when the run gave no
81
+ # verdict line (a review may quote such text from the code). 18-19 Oct 2026: a GLM 429 was reported as
82
+ # "FAILED no verdict line" = BLOCKED, which can start a fix session for nothing.
83
+ PROVIDER_FAILURE_RE = re.compile(
84
+ r"API Error: (?:429|5\d\d)\b|\bHTTP (?:429|5\d\d)\b|Too Many Requests|rate_limit_error|overloaded_error"
85
+ r"|Service Unavailable|Bad Gateway|Gateway Time-?out|Internal Server Error",
86
+ re.IGNORECASE,
87
+ )
88
+ CARD_TIMEOUT_SECS = 30 * 60 # one card review; at the limit the launcher tree is killed and the run is NOT_RUN
89
+
90
+
91
+ def _provider_failure(output: str, review_text: str) -> str | None:
92
+ """Why a run with no verdict line is a provider failure (NOT_RUN), or None when it is a real review."""
93
+ if _verdicts(review_text):
94
+ return None # any verdict line, even two that conflict, means a model wrote a review: BLOCKED, never NOT_RUN
95
+ if not review_text.strip():
96
+ return "empty reply"
97
+ match = PROVIDER_FAILURE_RE.search(output)
98
+ if match:
99
+ return f"provider error ({match.group(0)})"
100
+ try:
101
+ data = _leading_json(output)
102
+ except ValueError: # not JSON (JSONDecodeError is a ValueError)
103
+ data = None
104
+ if isinstance(data, dict) and data.get("is_error") is True:
105
+ return "the run reported is_error"
106
+ return None
80
107
 
81
108
 
82
109
  def _review_role(repo: Path) -> list[str]:
@@ -120,12 +147,30 @@ def launch(provider: str, prompt: str, cwd: Path) -> tuple[int, str]:
120
147
  # and the verdict is lost (seen with DeepSeek on 10 Oct 2026). claude-oss.ps1 sets this for GLM only.
121
148
  env = {**os.environ, "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1"}
122
149
  # stdin closed: started from a background shell with an open stdin, the launcher hung 40 minutes (10 Oct 2026).
123
- res = subprocess.run(cmd, cwd=cwd, stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8",
124
- errors="replace", env=env)
125
- if res.returncode != 0 and res.stderr:
150
+ # Its own process group (Windows) or session (POSIX), so the timeout kills the whole tree (pwsh, claude), not only
151
+ # the top process. A timeout is NOT_RUN (3): the run switches provider, never BLOCKED.
152
+ group = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt"
153
+ else {"start_new_session": True})
154
+ proc = subprocess.Popen(cmd, cwd=cwd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
155
+ text=True, encoding="utf-8", errors="replace", env=env, **group)
156
+ try:
157
+ stdout, stderr = proc.communicate(timeout=CARD_TIMEOUT_SECS)
158
+ except subprocess.TimeoutExpired:
159
+ _kill_tree(proc)
160
+ try:
161
+ stdout, _stderr = proc.communicate(timeout=60)
162
+ except subprocess.TimeoutExpired:
163
+ stdout = ""
164
+ return caddis_exit.NOT_RUN, (f"caddis_review: card review timed out after {CARD_TIMEOUT_SECS} s; process tree "
165
+ f"killed\n{stdout or ''}")
166
+ except BaseException:
167
+ _kill_tree(proc)
168
+ raise
169
+ stdout, stderr = stdout or "", stderr or ""
170
+ if proc.returncode != 0 and stderr:
126
171
  # A limit or key message often comes on stderr; keep it for the switch decision.
127
- return res.returncode, (res.stdout + "\n" + res.stderr).strip() + "\n"
128
- return res.returncode, res.stdout
172
+ return proc.returncode, (stdout + "\n" + stderr).strip() + "\n"
173
+ return proc.returncode, stdout
129
174
 
130
175
 
131
176
  def _leading_json(output: str) -> object:
@@ -261,12 +306,15 @@ def run_review(
261
306
  break
262
307
 
263
308
  # Limit text counts only when the run gave no verdict: a review may quote such text from the code.
264
- has_usage_limit = bool(USAGE_LIMIT_RE.search(output)) and not (
265
- code == 0 and _verdicts(_extract_review_text(output)))
266
- needs_switch = (code == 3) or (code == 2) or has_usage_limit
309
+ extracted = _extract_review_text(output)
310
+ has_usage_limit = bool(USAGE_LIMIT_RE.search(output)) and not (code == 0 and _verdicts(extracted))
311
+ # A 429, a 5xx or an empty reply is the provider failing, not the reviewer finding nothing: switch, and
312
+ # NOT_RUN when no provider is left. Never "FAILED no verdict line" (BLOCKED).
313
+ provider_failed = _provider_failure(output, extracted)
314
+ needs_switch = (code == 3) or (code == 2) or has_usage_limit or provider_failed is not None
267
315
 
268
316
  # Again after the JSON decode: an escaped key form that survived in the raw JSON is plain text now.
269
- review_text = caddis_redact.redact(_extract_review_text(output), masks)
317
+ review_text = caddis_redact.redact(extracted, masks)
270
318
  review_file = out_dir / f"{card.stem}-{provider}.txt"
271
319
  content = review_text.rstrip("\r\n") + f"\nEXIT {code} secs {duration}\n"
272
320
 
@@ -277,6 +325,8 @@ def run_review(
277
325
  reason = "exit 2"
278
326
  elif has_usage_limit:
279
327
  reason = "usage limit reached"
328
+ elif provider_failed is not None:
329
+ reason = provider_failed
280
330
  else:
281
331
  reason = f"exit {code}"
282
332
 
@@ -634,6 +684,16 @@ def launch_claude(prompt: str, worktree: Path, out: Path) -> str:
634
684
  # there is no sandbox on native Windows. The diff comes in the prompt (`_diff_material`).
635
685
  # No `--allowedTools`: unscoped, it approves files outside the worktree (smoke run 18 Oct 2026: without it a
636
686
  # Read in the worktree ran, a Read of ~/.caddis/spend-cap.toml was refused).
687
+ try:
688
+ import caddis_spend # the recursion guard lives beside the spend cap (robustness #19)
689
+ env_depth = caddis_spend.bump_headless_depth(os.environ)
690
+ except ImportError:
691
+ env_depth = None
692
+ except RuntimeError as exc: # HeadlessDepthExceeded
693
+ output = f"{_PREFIX}{exc}\n"
694
+ out.parent.mkdir(parents=True, exist_ok=True)
695
+ out.write_text(output, encoding="utf-8")
696
+ return output
637
697
  exe = _claude_exe()
638
698
  if exe is None:
639
699
  output = f"{_PREFIX}claude could not start: not found on PATH (the current folder is not searched)\n"
@@ -647,11 +707,21 @@ def launch_claude(prompt: str, worktree: Path, out: Path) -> str:
647
707
  governor = default_governor()
648
708
  if governor:
649
709
  cmd = governor + cmd
710
+ elif os.environ.get("CADDIS_ALLOW_UNGOVERNED") == "1": # the owner's explicit opt-out
711
+ print("WARNING: caddis_governor.py not found; the review runs without a CPU and memory cap "
712
+ "(CADDIS_ALLOW_UNGOVERNED=1)", file=sys.stderr)
650
713
  else:
651
- print("WARNING: caddis_governor.py not found; the review runs without a CPU and memory cap", file=sys.stderr)
714
+ # The plan reviewer needs the governor, as the roadmap runner does (robustness #19): an ungoverned
715
+ # `claude -p` can fill the machine's CPU and memory, and a review that cannot run is only NOT_RUN.
716
+ output = (f"{_PREFIX}caddis_governor.py not found, so the review is not started without a CPU and memory "
717
+ "cap (set CADDIS_ALLOW_UNGOVERNED=1 to run it ungoverned)\n")
718
+ out.parent.mkdir(parents=True, exist_ok=True)
719
+ out.write_text(output, encoding="utf-8")
720
+ return output
652
721
  # CADDIS_HEADLESS keeps relay and session-end hooks quiet (claude-oss.ps1 sets it too).
653
722
  # No program from the current folder (Windows searches it first), no git index rewrite by the reviewer's tools.
654
- env = {**os.environ, "CADDIS_HEADLESS": "1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1",
723
+ env = {**(env_depth if env_depth is not None else os.environ), "CADDIS_HEADLESS": "1",
724
+ "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1",
655
725
  "NoDefaultCurrentDirectoryInExePath": "1", "GIT_OPTIONAL_LOCKS": "0"}
656
726
  # Its own process group (Windows) or session (POSIX), so a timeout kills the whole tree, not only the top process.
657
727
  group = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt"
@@ -241,9 +241,13 @@ def default_branch(root: Path) -> str:
241
241
  caddis_exit.NOT_RUN)
242
242
 
243
243
 
244
+ FETCH = True # `render --no-fetch` (the waiting hook) turns it off for one command: read the origin refs it has
245
+
246
+
244
247
  def fetch(root: Path) -> str:
245
248
  """Fetch origin and return the default branch name."""
246
- _git(root, "fetch", "--quiet", "origin")
249
+ if FETCH:
250
+ _git(root, "fetch", "--quiet", "origin")
247
251
  return default_branch(root)
248
252
 
249
253
 
@@ -1068,7 +1072,6 @@ def merged_checkout(root: Path, branch: str):
1068
1072
  yield worktree
1069
1073
  finally:
1070
1074
  caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
1071
- _git(root, "worktree", "prune", check=False)
1072
1075
  caddis_worktree_safety.remove_tree(tmp) # links first: a junction's target survives
1073
1076
 
1074
1077
 
@@ -1818,11 +1821,12 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
1818
1821
  _git(worktree, "add", "--", path)
1819
1822
  _git(worktree, "commit", "--quiet", "-m", title)
1820
1823
  _git(worktree, "push", "--quiet", "-u", "origin", branch)
1824
+ pushed_tip = _git(worktree, "rev-parse", "HEAD").stdout.strip() # the tip we pushed: the only one we delete
1821
1825
  try:
1822
1826
  pr = open_pr(root, gh_repo, branch, snap.branch, title,
1823
1827
  f"Roadmap change by caddis_roadmap.py: {slug} {mid} {change}.")
1824
1828
  except Exception:
1825
- _git(root, "push", "--quiet", "origin", "--delete", branch, check=False)
1829
+ _git(root, *caddis_worktree_safety.remote_delete_args(branch, pushed_tip), check=False)
1826
1830
  raise
1827
1831
  try:
1828
1832
  rc = merge_pr(root, pr, gh_repo)
@@ -1830,13 +1834,12 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
1830
1834
  merge_exit = "timeout"
1831
1835
  raise
1832
1836
  out(f"#{pr} {title}: " + ("merged" if rc == caddis_exit.CLEAN else f"not merged (merge chain exit {rc})"))
1833
- if rc == caddis_exit.CLEAN:
1834
- _git(root, "push", "--quiet", "origin", "--delete", branch, check=False)
1837
+ if rc == caddis_exit.CLEAN: # only while the remote tip is still the commit we pushed
1838
+ _git(root, *caddis_worktree_safety.remote_delete_args(branch, pushed_tip), check=False)
1835
1839
  else:
1836
1840
  rc = caddis_exit.NOT_RUN if rc == caddis_exit.NOT_RUN else caddis_exit.BLOCKED
1837
1841
  finally:
1838
1842
  caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
1839
- _git(root, "worktree", "prune", check=False)
1840
1843
  _git(root, "branch", "-D", branch, check=False)
1841
1844
  caddis_worktree_safety.remove_tree(tmp)
1842
1845
  _audit(root, slug, {"utc": _utc(), "roadmap": slug, "id": mid, "change": change, "branch": branch,
@@ -2218,9 +2221,93 @@ def _build_parser() -> argparse.ArgumentParser:
2218
2221
  which.add_argument("--all", action="store_true", help="every roadmap on origin (used by hooks and commands)")
2219
2222
  p.add_argument("--root", default=".")
2220
2223
  p.add_argument("--out", default=None, help="the HTML file (default ~/.caddis/roadmap-pages/<repo>/<slug>.html)")
2224
+ p.add_argument("--no-fetch", action="store_true",
2225
+ help="use the origin refs this checkout has; no git fetch (the waiting hook's background render)")
2226
+ p.add_argument("--delay", type=int, default=0,
2227
+ help="wait this many seconds (at most 60) first: the waiting hook's follow-up render")
2221
2228
  return parser
2222
2229
 
2223
2230
 
2231
+ RENDER_LOCK_STALE_S = 600 # a render lock older than this is left by a killed render
2232
+ PENDING_NAME = ".roadmap-render.pending" # session_waiting.py writes it beside its debounce stamp
2233
+
2234
+
2235
+ def _clear(path: Path | None) -> bool:
2236
+ """Delete `path`; True when it was there."""
2237
+ if path is None:
2238
+ return False
2239
+ try:
2240
+ path.unlink()
2241
+ return True
2242
+ except FileNotFoundError:
2243
+ return False
2244
+ except OSError:
2245
+ return path.exists()
2246
+
2247
+
2248
+ def _take_render_lock(lock: Path) -> bool:
2249
+ """One render per repo at a time (robustness #8): create `lock` with O_EXCL, never wait. A lock older than
2250
+ RENDER_LOCK_STALE_S is taken over once. False: another render holds it."""
2251
+ lock.parent.mkdir(parents=True, exist_ok=True)
2252
+ for attempt in range(2):
2253
+ try:
2254
+ fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
2255
+ except FileExistsError:
2256
+ pass
2257
+ except PermissionError: # Windows: a deleted lock whose delete is still pending
2258
+ return False
2259
+ else:
2260
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
2261
+ fh.write(str(os.getpid()))
2262
+ return True
2263
+ try:
2264
+ stale = time.time() - lock.stat().st_mtime > RENDER_LOCK_STALE_S
2265
+ except FileNotFoundError:
2266
+ stale = True # released between the create and the stat: try again
2267
+ except OSError: # Windows: a lock being deleted cannot be read either
2268
+ return False
2269
+ if attempt or not stale:
2270
+ return False
2271
+ try:
2272
+ lock.unlink()
2273
+ except FileNotFoundError:
2274
+ pass
2275
+ except OSError:
2276
+ return False
2277
+ return False
2278
+
2279
+
2280
+ def _render(root: Path, args: argparse.Namespace) -> int:
2281
+ """`render`: the pages, the prune and the hub. The caller holds the repo's render lock."""
2282
+ import caddis_roadmap_page
2283
+ slugs, on_origin = list_roadmaps_checked(root) if args.all else ([args.roadmap], None)
2284
+ failed = written = pruned = 0
2285
+ for slug in slugs: # --all: one bad roadmap must not stop the others
2286
+ try:
2287
+ path = caddis_roadmap_page.write_page(root, slug, Path(args.out) if args.out else None,
2288
+ sys.modules[__name__])
2289
+ if path is None: # a newer caddis owns this page: kept, and neither written nor failed
2290
+ continue
2291
+ written += 1
2292
+ print(f"[ROADMAP] page written: {path.resolve()}")
2293
+ except Exception as exc: # --all: one roadmap must never stop the others
2294
+ if not args.all:
2295
+ raise
2296
+ failed += 1
2297
+ print(f"[ROADMAP] {slug}: page not written: {exc}", file=sys.stderr)
2298
+ try: # the hub is a convenience: nothing below ever fails a render or changes its exit code
2299
+ if args.all and on_origin is not None: # a roadmap removed from origin leaves the hub too; a listing
2300
+ # that failed removes nothing, and a folder still on origin keeps its page even if its render failed
2301
+ pruned = caddis_roadmap_page.prune_folder(
2302
+ caddis_roadmap_page.default_page_path(root, "_").parent, on_origin | set(slugs))
2303
+ hub = caddis_roadmap_page.write_hub() if not args.out and (written or pruned) else None
2304
+ if hub is not None: # None: a newer caddis owns the hub (write_hub said so)
2305
+ print(f"[ROADMAP] hub written: {hub.resolve()}")
2306
+ except Exception as exc:
2307
+ print(f"[ROADMAP] hub not written: {exc}", file=sys.stderr)
2308
+ return caddis_exit.BLOCKED if failed else caddis_exit.CLEAN
2309
+
2310
+
2224
2311
  def _dispatch(args: argparse.Namespace) -> int:
2225
2312
  root = _repo_root(args.root)
2226
2313
  if args.cmd == "list":
@@ -2266,29 +2353,37 @@ def _dispatch(args: argparse.Namespace) -> int:
2266
2353
  import caddis_roadmap_page # the page is separate; only `render` needs it
2267
2354
  if args.all and args.out:
2268
2355
  raise RoadmapError("--out works with one --roadmap, not with --all", caddis_exit.ERROR)
2269
- slugs, on_origin = list_roadmaps_checked(root) if args.all else ([args.roadmap], None)
2270
- failed = written = pruned = 0
2271
- for slug in slugs: # --all: one bad roadmap must not stop the others
2272
- try:
2273
- path = caddis_roadmap_page.write_page(root, slug, Path(args.out) if args.out else None,
2274
- sys.modules[__name__])
2275
- written += 1
2276
- print(f"[ROADMAP] page written: {path.resolve()}")
2277
- except Exception as exc: # --all: one roadmap must never stop the others
2278
- if not args.all:
2279
- raise
2280
- failed += 1
2281
- print(f"[ROADMAP] {slug}: page not written: {exc}", file=sys.stderr)
2282
- try: # the hub is a convenience: nothing below ever fails a render or changes its exit code
2283
- if args.all and on_origin is not None: # a roadmap removed from origin leaves the hub too; a listing
2284
- # that failed removes nothing, and a folder still on origin keeps its page even if its render failed
2285
- pruned = caddis_roadmap_page.prune_folder(
2286
- caddis_roadmap_page.default_page_path(root, "_").parent, on_origin | set(slugs))
2287
- if not args.out and (written or pruned):
2288
- print(f"[ROADMAP] hub written: {caddis_roadmap_page.write_hub().resolve()}")
2289
- except Exception as exc:
2290
- print(f"[ROADMAP] hub not written: {exc}", file=sys.stderr)
2291
- return caddis_exit.BLOCKED if failed else caddis_exit.CLEAN
2356
+ if args.delay > 0:
2357
+ time.sleep(min(args.delay, 60))
2358
+ folder = caddis_roadmap_page.default_page_path(root, "_").parent.name
2359
+ lock = caddis_roadmap_page.pages_root() / ".locks" / f"{folder}.lock"
2360
+ # The waiting hook's debounce (and a render that finds the lock held) leaves PENDING_NAME: the render that
2361
+ # holds the lock renders once more at its end, so the last mark change always reaches the page.
2362
+ state = caddis_roadmap_page.main_checkout(root) / ".caddis" / "session-state"
2363
+ pending = state / PENDING_NAME if args.all and not args.out else None
2364
+ if not _take_render_lock(lock):
2365
+ if pending is not None and state.is_dir():
2366
+ try:
2367
+ pending.write_text(str(os.getpid()), encoding="utf-8")
2368
+ except OSError:
2369
+ pass
2370
+ print("[ROADMAP] another render of this repo is running; skipped")
2371
+ return caddis_exit.CLEAN
2372
+ global FETCH
2373
+ FETCH = not args.no_fetch
2374
+ try:
2375
+ _clear(pending) # this render reads the marks as they are now
2376
+ code = _render(root, args)
2377
+ if _clear(pending): # a mark changed while it ran: once more, under the same lock
2378
+ code = _render(root, args)
2379
+ return code
2380
+ finally:
2381
+ FETCH = True
2382
+ try: # only our own lock: a render that ran past the stale age may have been taken over
2383
+ if lock.read_text(encoding="utf-8").strip() == str(os.getpid()):
2384
+ lock.unlink()
2385
+ except (OSError, UnicodeDecodeError):
2386
+ pass
2292
2387
  return caddis_exit.ERROR
2293
2388
 
2294
2389
 
@@ -19,6 +19,7 @@ from the files is escaped: a plan, a relay or a hook message can hold any text.
19
19
  from __future__ import annotations
20
20
 
21
21
  import datetime
22
+ import functools
22
23
  import hashlib
23
24
  import html
24
25
  import json
@@ -452,11 +453,13 @@ def default_page_path(root: Path, slug: str, home: Path | None = None) -> Path:
452
453
  return pages_root(home) / f"{main.name}-{tag}" / f"{slug}.html"
453
454
 
454
455
 
455
- def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path:
456
+ def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path | None:
456
457
  """The page. At the default path it also writes `<slug>.json`, the one-row summary the hub is built from; an
457
- `--out` page is a one-off copy and stays out of the hub."""
458
- model = build(root, slug, roadmap_mod)
458
+ `--out` page is a one-off copy and stays out of the hub. None: a newer caddis wrote this page, so it is kept."""
459
459
  path = Path(out) if out else default_page_path(root, slug)
460
+ if out is None and _owned_by_newer(slug, path.with_suffix(".json")): # before build: a skip does no work
461
+ return None
462
+ model = build(root, slug, roadmap_mod)
460
463
  path.parent.mkdir(parents=True, exist_ok=True)
461
464
  _write_atomic(path, render_html(model))
462
465
  if out is None:
@@ -468,6 +471,66 @@ def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path:
468
471
  return path
469
472
 
470
473
 
474
+ # ---- the version gate: a session on an old plugin never overwrites what a newer caddis wrote (robustness #8) -------
475
+
476
+ @functools.lru_cache(maxsize=8)
477
+ def generator_version(here: Path | None = None) -> str:
478
+ """This caddis's version: the Claude plugin's `.claude-plugin/plugin.json` or the agy plugin's root
479
+ `plugin.json` (both beside `scripts/`), else the source checkout's `caddis` entry in
480
+ `.github/runtime-targets.json`, else "dev"."""
481
+ base = Path(here or Path(__file__).resolve().parent).parent
482
+ for manifest in (base / ".claude-plugin" / "plugin.json", base / "plugin.json"):
483
+ data, _ = _read_json(manifest)
484
+ if data and data.get("version"):
485
+ return str(data["version"])
486
+ data, _ = _read_json(base / ".github" / "runtime-targets.json")
487
+ for target in (data or {}).get("targets") or []:
488
+ plugin = target.get("plugin") if isinstance(target, dict) else None
489
+ if isinstance(plugin, dict) and plugin.get("name") == "caddis" and plugin.get("version"):
490
+ return str(plugin["version"])
491
+ return "dev"
492
+
493
+
494
+ def _version_key(text) -> tuple[int, ...] | None:
495
+ parts = str(text or "").strip().split(".")
496
+ return tuple(int(p) for p in parts) if all(p.isdigit() for p in parts) else None
497
+
498
+
499
+ def is_newer(theirs, mine) -> bool:
500
+ """True only when both are numeric versions and `theirs` is the higher one; "dev" or "" never blocks."""
501
+ a, b = _version_key(theirs), _version_key(mine)
502
+ return a is not None and b is not None and a > b
503
+
504
+
505
+ GENERATOR_OWNERSHIP_S = 1800 # a newer caddis owns a page only while it keeps writing it (review of #71)
506
+
507
+
508
+ def _newer_owner(summary: Path) -> tuple[str, bool]:
509
+ """(version, live): the newer caddis that wrote `summary` ("" when it is not newer), and whether it wrote it in
510
+ the last GENERATOR_OWNERSHIP_S. An unreadable time is not live: a downgrade must never freeze a page."""
511
+ data, _ = _read_json(summary)
512
+ theirs = str((data or {}).get("generator") or "")
513
+ if not is_newer(theirs, generator_version()):
514
+ return "", False
515
+ age_h = _age_h(str((data or {}).get("generated_utc") or ""), datetime.datetime.now(datetime.timezone.utc))
516
+ return theirs, age_h is not None and age_h * 3600 <= GENERATOR_OWNERSHIP_S
517
+
518
+
519
+ def _gate(label: str, theirs: str, live: bool) -> bool:
520
+ """True: skip the write. Prints one line when a newer caddis owns it, or when this one takes it over."""
521
+ if theirs and live:
522
+ print(f"[ROADMAP] {label}: a newer caddis ({theirs}) owns this page; skipped")
523
+ return True
524
+ if theirs:
525
+ print(f"[ROADMAP] {label}: taking over from caddis {theirs} (it has not written for "
526
+ f"{GENERATOR_OWNERSHIP_S // 60} min)")
527
+ return False
528
+
529
+
530
+ def _owned_by_newer(slug: str, summary: Path) -> bool:
531
+ return _gate(slug, *_newer_owner(summary))
532
+
533
+
471
534
  # ---- the hub: one page over every roadmap page on this machine, grouped per repo ---------------------------------
472
535
 
473
536
  HUB_STALE_H = 24 # a summary older than this is marked "not refreshed"
@@ -507,7 +570,7 @@ def page_summary(model: dict, main: Path, path: Path) -> dict:
507
570
  else f"stopped: {stop.get('reason')}" if stop.get("reason") else "")
508
571
  nxt = model.get("next") or {}
509
572
  return {
510
- "version": 1, "repo": main.name, "checkout": str(main), "folder": path.parent.name, "slug": model["roadmap"],
573
+ "version": 1, "generator": generator_version(), "repo": main.name, "checkout": str(main), "folder": path.parent.name, "slug": model["roadmap"],
511
574
  "branch": model["branch"], "origin_commit": model.get("origin_commit", ""),
512
575
  "generated_utc": model["generated"],
513
576
  "milestones_done": model["done"], "milestones_total": model["total"],
@@ -530,7 +593,7 @@ def prune_folder(folder: Path, keep: set[str]) -> int:
530
593
  if not folder.is_dir():
531
594
  return removed
532
595
  for js in folder.glob("*.json"):
533
- if js.stem in keep:
596
+ if js.stem in keep or _newer_owner(js)[1]: # a live newer caddis may know a roadmap this one cannot list
534
597
  continue
535
598
  for f in (js, js.with_suffix(".html")):
536
599
  try:
@@ -667,11 +730,21 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime) -> s
667
730
  return "".join(out)
668
731
 
669
732
 
670
- def write_hub(home: Path | None = None, now: datetime.datetime | None = None) -> Path:
671
- """`~/.caddis/roadmap-pages/index.html`: every roadmap page on this machine, grouped per repo."""
733
+ def write_hub(home: Path | None = None, now: datetime.datetime | None = None) -> Path | None:
734
+ """`~/.caddis/roadmap-pages/index.html`: every roadmap page on this machine, grouped per repo. None: a newer
735
+ caddis wrote the hub (its version is in `.hub-generator`), so it is kept."""
672
736
  root_dir = pages_root(home)
673
737
  root_dir.mkdir(parents=True, exist_ok=True)
738
+ stamp = root_dir / ".hub-generator"
739
+ try:
740
+ theirs = stamp.read_text(encoding="utf-8").strip()
741
+ live = time.time() - stamp.stat().st_mtime <= GENERATOR_OWNERSHIP_S
742
+ except (OSError, UnicodeDecodeError):
743
+ theirs, live = "", False
744
+ if _gate("hub", theirs if is_newer(theirs, generator_version()) else "", live):
745
+ return None
674
746
  rows, errors = read_summaries(root_dir)
675
747
  path = root_dir / "index.html"
676
748
  _write_atomic(path, render_hub(rows, errors, now or datetime.datetime.now(datetime.timezone.utc)))
749
+ _write_atomic(stamp, generator_version() + "\n")
677
750
  return path
@@ -200,6 +200,8 @@ def headless_env(base: dict | None = None) -> dict:
200
200
  console, so every session and every caddis_roadmap.py call the runner starts carries the marker."""
201
201
  env = dict(os.environ if base is None else base)
202
202
  env.update(HEADLESS_ENV)
203
+ # One level deeper for every session it starts. main() refuses to start past the limit (robustness #19).
204
+ env[caddis_spend.HEADLESS_DEPTH_VAR] = str(caddis_spend.headless_depth(env) + 1)
203
205
  return env
204
206
 
205
207
 
@@ -223,12 +225,19 @@ def runner_env() -> dict:
223
225
 
224
226
 
225
227
  def build_command(engine: str, model: str, prompt: str, *, usd: float, timeout_min: float,
226
- governor: Sequence[str], engine_cmd: Sequence[str] | None = None) -> list[str]:
228
+ governor: Sequence[str], engine_cmd: Sequence[str] | None = None,
229
+ worktree: Path | str | None = None) -> list[str]:
227
230
  """The full command line for one session: the governor prefix, then the engine's print-mode call.
228
231
 
229
232
  `claude` gets its prompt on stdin, not as an argument (PROMPT_ON_STDIN): on Windows `claude` is an npm `.cmd`
230
233
  shim, and cmd.exe cuts a multi-line argument at its first newline, which drops the rest of the prompt and every
231
- argument after it."""
234
+ argument after it.
235
+
236
+ Every engine gets its permission mode on the command line (robustness #19), none is left to a settings file or
237
+ a default that a new version may change: `claude` bypassPermissions (with the deny rules), `agy` accept-edits
238
+ plus --dangerously-skip-permissions (as the lanes do; a print session cannot answer a prompt), `codex` the
239
+ workspace-write sandbox confined to `worktree` with `-C` (as the lanes do; the session's own folder, default
240
+ the current one). The boundary is the same for all three: no publish token (session_env) and the guard."""
232
241
  if engine not in ENGINES:
233
242
  raise ValueError(f"unknown engine {engine!r}; known: {', '.join(ENGINES)}")
234
243
  exe = list(engine_cmd) if engine_cmd else [engine]
@@ -240,10 +249,10 @@ def build_command(engine: str, model: str, prompt: str, *, usd: float, timeout_m
240
249
  tail = [*exe, "-p", "--disallowedTools", *SESSION_DENY_RULES, "--permission-mode", "bypassPermissions",
241
250
  "--model", model, "--output-format", "json", "--max-budget-usd", _usd(usd)]
242
251
  elif engine == "agy": # probe 5: --model must come before --print
243
- tail = [*exe, "--model", model, "--print", prompt, "--output-format", "json",
244
- "--print-timeout", f"{max(1, int(timeout_min))}m"]
252
+ tail = [*exe, "--model", model, "--mode", "accept-edits", "--dangerously-skip-permissions",
253
+ "--print", prompt, "--output-format", "json", "--print-timeout", f"{max(1, int(timeout_min))}m"]
245
254
  else:
246
- tail = [*exe, "exec", "--model", model, "--json", prompt]
255
+ tail = [*exe, "exec", "--model", model, "-s", "workspace-write", "-C", str(worktree or "."), "--json", prompt]
247
256
  return [*governor, *tail]
248
257
 
249
258
 
@@ -888,7 +897,7 @@ def _one_session(ctx: _Ctx, prompt: str, relay_slug: str, n: int, spent: float,
888
897
  remaining_s = max(1.0, min(caps.session_timeout_min * 60, caps.max_hours * 3600 - elapsed))
889
898
  usd = min(caps.max_usd_per_session, caps.max_usd_total - spent)
890
899
  cmd = build_command(ctx.engine, ctx.model, prompt, usd=usd, timeout_min=remaining_s / 60, governor=ctx.governor,
891
- engine_cmd=ctx.engine_cmd)
900
+ engine_cmd=ctx.engine_cmd, worktree=cwd)
892
901
  rid = _spend_reserve(ctx.home, spend_slug, ctx.now(), n > 1) if ctx.engine == "agy" else None
893
902
  launched, outcome = ctx.clock(), None
894
903
  try:
@@ -1273,7 +1282,8 @@ def _render_page(ctx: "_Ctx", roadmap_cmd: Sequence[str]) -> None:
1273
1282
  def _dry_run_tail(ctx: _Ctx, template: Path, prompt: str, cwd: Path | None = None) -> None:
1274
1283
  caps = ctx.caps
1275
1284
  cmd = build_command(ctx.engine, ctx.model, "<resume prompt>", usd=min(caps.max_usd_per_session, caps.max_usd_total),
1276
- timeout_min=caps.session_timeout_min, governor=ctx.governor, engine_cmd=ctx.engine_cmd)
1285
+ timeout_min=caps.session_timeout_min, governor=ctx.governor, engine_cmd=ctx.engine_cmd,
1286
+ worktree=cwd or ctx.repo)
1277
1287
  print(f"would launch (cwd {cwd or ctx.repo}, CADDIS_HEADLESS=1): {subprocess.list2cmdline(cmd)}"
1278
1288
  + (" (resume prompt on stdin)" if ctx.engine in PROMPT_ON_STDIN else ""))
1279
1289
  print(f"resume prompt from {template}:\n{prompt}")
@@ -1552,7 +1562,10 @@ def ensure_worktree(repo: Path, slug: str, default: str, root: Path | None = Non
1552
1562
  if path.resolve() in _registered_worktrees(repo):
1553
1563
  if path.is_dir():
1554
1564
  return path
1555
- _git_out(repo, "worktree", "prune") # listed, but its folder is gone (temp cleanup): prune, make it again
1565
+ # Listed, but its folder is gone (temp cleanup): drop this one registration and make it again. Not
1566
+ # `git worktree prune`: that would drop every other tool's registration with a missing folder too.
1567
+ if caddis_worktree_safety.safe_remove_worktree(repo, path) is not None:
1568
+ raise _Stop(f"FAILED cannot drop the registration of the missing worktree {path}", caddis_exit.BLOCKED)
1556
1569
  if path.exists():
1557
1570
  raise _Stop(f"FAILED worktree path {path} exists and is not a worktree of this repo", caddis_exit.BLOCKED)
1558
1571
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -1586,7 +1599,6 @@ def remove_worktree_if_clean(repo: Path, slug: str, root: Path | None = None) ->
1586
1599
  return f"worktree kept (not clean, {len(work)} change(s)): {path}"
1587
1600
  if caddis_worktree_safety.safe_remove_worktree(repo, path) is not None:
1588
1601
  return f"worktree kept (remove failed): {path}"
1589
- _git_out(repo, "worktree", "prune")
1590
1602
  _state_file(repo, slug, "worktree").unlink(missing_ok=True)
1591
1603
  return f"worktree removed: {path}"
1592
1604
 
@@ -2660,6 +2672,11 @@ def main(argv: list[str] | None = None) -> int:
2660
2672
  ok, why = last_stop_restartable(args.repo, args.roadmap)
2661
2673
  print(why)
2662
2674
  return caddis_exit.CLEAN if ok else caddis_exit.BLOCKED
2675
+ try:
2676
+ caddis_spend.bump_headless_depth(os.environ) # a check only: the sessions get the bump in headless_env
2677
+ except caddis_spend.HeadlessDepthExceeded as exc:
2678
+ print(f"caddis_roadmap_run: {exc}", file=sys.stderr)
2679
+ return caddis_exit.BLOCKED
2663
2680
  caps = Caps(args.max_hours, args.max_sessions, args.session_timeout_min, args.max_usd_per_session,
2664
2681
  args.max_usd_total)
2665
2682
  return run(repo=args.repo, plan=args.plan, roadmap=args.roadmap, caps=caps, engine=args.engine, model=args.model,
@@ -258,14 +258,32 @@ CONTEXT_NOTE_ABOVE = 85.0
258
258
  CONTEXT_MAX_AGE_S = 900
259
259
 
260
260
 
261
- def read_context_pct(repo_root: Path) -> tuple[float | None, str]:
261
+ SESSION_ID_ENV = ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CADDIS_SESSION_ID")
262
+
263
+
264
+ def _session_id(explicit: str | None = None) -> str:
265
+ """This session's id: the explicit one, else the first of SESSION_ID_ENV that is set. Made file-name safe."""
266
+ raw = explicit or next((os.environ[v] for v in SESSION_ID_ENV if os.environ.get(v)), "")
267
+ return "".join(ch if (ch.isalnum() and ch.isascii()) or ch in "-_" else "_" for ch in raw)[:64].strip(".")
268
+
269
+
270
+ def read_context_pct(repo_root: Path, session_id: str | None = None) -> tuple[float | None, str]:
262
271
  """(percentage, provenance) from the statusline's cache, or (None, why not).
263
272
 
273
+ With a session id (the argument, or CLAUDE_SESSION_ID and the like) it reads that session's own file,
274
+ `.caddis/context-window/<id>.json`, so a second session in the same folder cannot change the figure.
275
+ Without one it reads the shared `context-window.json`, which any session may have written last; the
276
+ provenance then says "shared".
277
+
264
278
  Written by `caddis_statusline.record_context`. Claude Code gives the figure to the status
265
279
  line and to nothing else, so this file is the only runtime-sourced number a plain command
266
280
  can reach.
267
281
  """
268
- target = repo_root / ART / "context-window.json"
282
+ sid = _session_id(session_id)
283
+ shared = repo_root / ART / "context-window.json"
284
+ target = repo_root / ART / "context-window" / f"{sid}.json" if sid else shared
285
+ if sid and not target.is_file():
286
+ target, sid = shared, "" # the status line has not written this session's file yet (or is old)
269
287
  try:
270
288
  raw = json.loads(target.read_text(encoding="utf-8"))
271
289
  pct = float(raw["used_percentage"])
@@ -275,8 +293,10 @@ def read_context_pct(repo_root: Path) -> tuple[float | None, str]:
275
293
  + f"{ART}/context-window.json — run `/caddis:install-statusline` to install the "
276
294
  "status line that writes it")
277
295
  if age > CONTEXT_MAX_AGE_S:
278
- return pct, f"runtime figure, STALE by {age}s (a lower bound — the real number is >= this)"
279
- return pct, f"runtime figure, {age}s old"
296
+ scope = "this session's" if sid else "shared (session unknown)"
297
+ return pct, f"{scope} runtime figure, STALE by {age}s (a lower bound — the real number is >= this)"
298
+ scope = "this session's" if sid else "shared (session unknown)"
299
+ return pct, f"runtime figure, {scope}, {age}s old"
280
300
 
281
301
 
282
302
  @dataclass