@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
@@ -9,7 +9,8 @@
9
9
  "command": "python guard_agy.py",
10
10
  "windows": "python guard_agy.py",
11
11
  "linux": "python3 guard_agy.py",
12
- "osx": "python3 guard_agy.py"
12
+ "osx": "python3 guard_agy.py",
13
+ "timeout": 30
13
14
  }
14
15
  ]
15
16
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.142",
3
+ "version": "1.3.144",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -12,6 +12,7 @@ import ast
12
12
  import json
13
13
  import os
14
14
  import re
15
+ import secrets
15
16
  import shutil
16
17
  import subprocess
17
18
  import sys
@@ -131,7 +132,8 @@ def slugify_prompt(prompt: str) -> str:
131
132
  if not slug:
132
133
  slug = "task"
133
134
  date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
134
- return f"{date_str}-{slug}"
135
+ # Random suffix: two runs on the same day and topic must never share a branch, plan or run dir.
136
+ return f"{date_str}-{slug}-{secrets.token_hex(3)}"
135
137
 
136
138
 
137
139
  def synthesize_plan(
@@ -168,18 +170,73 @@ Task requirements are met, existing tests pass, and new test assertions verify t
168
170
  return plan_path, slug, title
169
171
 
170
172
 
173
+ def session_key() -> str:
174
+ """Id of this agent session, safe in a file name. One checkout can host several sessions."""
175
+ for var in ("CADDIS_SESSION_ID", "CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID"):
176
+ raw = os.environ.get(var, "").strip()
177
+ key = re.sub(r"[^A-Za-z0-9_-]", "-", raw)[:64].strip("-")
178
+ if key:
179
+ return key
180
+ return "nosession"
181
+
182
+
183
+ def _adhoc_dir(repo: Path) -> Path:
184
+ return repo / ".caddis" / "orchestrator" / "adhoc"
185
+
186
+
171
187
  def adhoc_latest_path(repo: Path) -> Path:
172
- return repo / ".caddis" / "orchestrator" / "adhoc" / "latest.json"
188
+ return _adhoc_dir(repo) / f"latest-{session_key()}.json"
189
+
190
+
191
+ def write_latest(repo: Path, state: dict) -> None:
192
+ path = adhoc_latest_path(repo)
193
+ path.parent.mkdir(parents=True, exist_ok=True)
194
+ path.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
173
195
 
174
196
 
175
197
  def read_latest(repo: Path) -> dict | None:
176
198
  path = adhoc_latest_path(repo)
177
199
  if not path.is_file():
178
- return None
200
+ # Migration: the old single latest.json belongs to whoever reads it first. The rename is
201
+ # atomic, so exactly one session adopts it and no other session ever sees it.
202
+ legacy = _adhoc_dir(repo) / "latest.json"
203
+ if not legacy.is_file():
204
+ return None
205
+ try:
206
+ os.replace(legacy, path)
207
+ except OSError:
208
+ return None
179
209
  try:
180
- return json.loads(path.read_text(encoding="utf-8"))
210
+ data = json.loads(path.read_text(encoding="utf-8"))
181
211
  except (OSError, json.JSONDecodeError):
182
212
  return None
213
+ return data if isinstance(data, dict) else None
214
+
215
+
216
+ def move_branch(repo: Path, branch: str, commit_sha: str, base_sha: str) -> str:
217
+ """Point `branch` at `commit_sha`. Return "" on success, else why it was refused.
218
+
219
+ An existing branch is moved only when its tip is merged into the base, equals the new commit, or
220
+ is the tip this session recorded for it. Otherwise it holds work that moving would orphan.
221
+ """
222
+ tip = git_output(repo, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}")
223
+ if tip and tip != commit_sha:
224
+ merged = subprocess.run(
225
+ ["git", "-C", str(repo), "merge-base", "--is-ancestor", tip, base_sha],
226
+ capture_output=True,
227
+ ).returncode == 0
228
+ state = read_latest(repo) or {}
229
+ own = state.get("branch") == branch and state.get("commit_sha") == tip
230
+ if not merged and not own:
231
+ return (f"branch '{branch}' has commits not merged into {base_sha[:7]} "
232
+ f"(tip {tip[:7]}); refusing to move it. Delete or merge it first.")
233
+ res = subprocess.run(
234
+ ["git", "-C", str(repo), "branch", "-f", branch, commit_sha],
235
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
236
+ )
237
+ if res.returncode != 0:
238
+ return f"branch update failed: {res.stderr.strip()}"
239
+ return ""
183
240
 
184
241
 
185
242
  # Tool caches every gate run regenerates. They are the only ignored files a worktree may be
@@ -355,7 +412,7 @@ def run_adhoc(
355
412
  }
356
413
  if len(kept_paths) > 1:
357
414
  state["kept_worktrees"] = [str(path) for path in kept_paths]
358
- adhoc_latest_path(repo).write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
415
+ write_latest(repo, state)
359
416
  return EXIT_FAIL
360
417
 
361
418
  worktree_str = packet.get("worktree")
@@ -468,20 +525,14 @@ def run_adhoc(
468
525
 
469
526
  if not reason:
470
527
  try:
471
- branch_res = subprocess.run(
472
- ["git", "-C", str(repo), "branch", "-f", adhoc_branch, commit_sha],
473
- capture_output=True, text=True, encoding="utf-8", errors="replace",
474
- )
475
- if branch_res.returncode != 0:
476
- reason = f"branch update failed: {branch_res.stderr.strip()}"
477
- else:
528
+ reason = move_branch(repo, adhoc_branch, commit_sha, base_sha)
529
+ if not reason:
478
530
  # An OSError from the helper (git not runnable) lands in the `except OSError` below.
479
531
  problem = caddis_worktree_safety.safe_remove_worktree(repo, wt_path)
480
532
  if problem or wt_path.exists():
481
533
  reason = problem or f"worktree remove failed: {wt_path}"
482
534
  else:
483
- kept_worktree = None
484
- subprocess.run(["git", "-C", str(repo), "worktree", "prune"], capture_output=True)
535
+ kept_worktree = None # `git worktree remove` dropped its registration; no prune (robustness #19)
485
536
  attempt_branch = packet.get("branch")
486
537
  if attempt_branch and attempt_branch != adhoc_branch:
487
538
  subprocess.run(
@@ -503,7 +554,7 @@ def run_adhoc(
503
554
  diff_stat = git_output(repo, "diff", "--shortstat", base_sha, diff_ref)
504
555
  diff_names = verified_names or changed_files
505
556
 
506
- # Save state to latest.json
557
+ # Save this session's state (latest-<session>.json)
507
558
  state = {
508
559
  "slug": slug,
509
560
  "title": title,
@@ -525,7 +576,7 @@ def run_adhoc(
525
576
  "plan_path": plan_rel,
526
577
  "created": datetime.now(timezone.utc).isoformat(),
527
578
  }
528
- adhoc_latest_path(repo).write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
579
+ write_latest(repo, state)
529
580
 
530
581
  # Render Verdict Card
531
582
  is_success = not reason and kept_worktree is None
@@ -618,13 +669,8 @@ def drop_adhoc(repo: Path) -> int:
618
669
  if problem:
619
670
  print(f"[mmr] Could not remove kept worktree: {problem}")
620
671
  return EXIT_FAIL
621
- pruned = subprocess.run(
622
- ["git", "-C", str(repo), "worktree", "prune"],
623
- capture_output=True, text=True, encoding="utf-8", errors="replace",
624
- )
625
- if pruned.returncode != 0:
626
- print(f"[mmr] Could not prune worktrees: {pruned.stderr.strip()}")
627
- return EXIT_FAIL
672
+ # No `git worktree prune` here: it drops the registrations of every other worktree whose folder is missing
673
+ # for the moment, and `git worktree remove` has already dropped this one (robustness #19).
628
674
 
629
675
  if branch:
630
676
  subprocess.run(["git", "-C", str(repo), "branch", "-D", branch], capture_output=True)
@@ -3,6 +3,11 @@
3
3
  `shared` (handoff-integrity Phase 5, the design of fleet-handoff Phase 2): a checkout is shared when its root
4
4
  `AGENTS.md` says `Shared checkout: yes`, or when `git status` lists a path this session did not change. In a
5
5
  shared checkout handoff writes no tracked file there; it uses `caddis_docs_pr.py` instead.
6
+
7
+ `checkout_is_shared` and `foreign_changes` (robustness #11) are the two halves of that test. `caddis_lanes.py accept`
8
+ calls them before it fast-forwards the session checkout, and the commands that switch, rebase or pull run
9
+ `shared` first (pr-merge, ship-pr, ship-merge, cloud-phases). A linked worktree is never shared: AGENTS.md is
10
+ tracked, so a worktree carries the line too, but the worktree is the safe place the rule sends the work to.
6
11
  """
7
12
 
8
13
  from __future__ import annotations
@@ -79,28 +84,74 @@ def _norm(path: str) -> str:
79
84
  return path.strip().replace("\\", "/")
80
85
 
81
86
 
82
- def is_shared_checkout(root: Path, own_files: set[str]) -> tuple[bool, str]:
83
- """(True, reason) when handoff must not write tracked files in `root`; else (False, "").
87
+ class CheckoutStatusError(OSError):
88
+ """`git status` could not run in the checkout, so foreign changes cannot be ruled out."""
84
89
 
85
- `Shared checkout: no` in AGENTS.md never hides a change this session did not make. When git status cannot
86
- run, the checkout counts as shared: a wrong "no" could commit another session's files.
87
- """
88
- agents = Path(root) / "AGENTS.md"
90
+
91
+ def _repo_top(start: Path) -> Path:
92
+ """The nearest folder at or above `start` that holds `.git` (a folder or a gitfile); `start` when none does."""
93
+ here = Path(start).resolve()
94
+ for folder in (here, *here.parents):
95
+ if (folder / ".git").exists():
96
+ return folder
97
+ return here
98
+
99
+
100
+ def is_linked_worktree(top: Path) -> bool:
101
+ """True when `top/.git` is a gitfile that points into `<repo>/.git/worktrees/` (a `git worktree add` tree)."""
102
+ gitfile = Path(top) / ".git"
103
+ if not gitfile.is_file():
104
+ return False
89
105
  try:
90
- text = agents.read_text(encoding="utf-8", errors="replace") if agents.is_file() else ""
106
+ line = gitfile.read_text(encoding="utf-8", errors="replace").strip()
91
107
  except OSError:
92
- text = ""
93
- if SHARED_RE.search(text):
94
- return True, "AGENTS.md says Shared checkout: yes"
108
+ return False
109
+ return line.lower().startswith("gitdir:") and "/worktrees/" in line.replace("\\", "/").lower()
110
+
111
+
112
+ def checkout_is_shared(root: Path) -> bool:
113
+ """True when the repo that holds `root` says `Shared checkout: yes` in its top `AGENTS.md` (any case).
114
+
115
+ A linked worktree is never shared (see the module note). A missing or unreadable AGENTS.md means no."""
116
+ top = _repo_top(Path(root))
117
+ if is_linked_worktree(top):
118
+ return False
119
+ try:
120
+ text = (top / "AGENTS.md").read_text(encoding="utf-8", errors="replace")
121
+ except OSError:
122
+ return False
123
+ return bool(SHARED_RE.search(text))
124
+
125
+
126
+ def foreign_changes(root: Path, own_files: set[str] | None = None) -> list[str]:
127
+ """The dirty paths in `root` that are not in `own_files`, in `git status` order. A rename counts both paths.
128
+
129
+ Without session tracking (`own_files` None or empty), every dirty path counts. Raises CheckoutStatusError
130
+ when `git status` fails: the caller must then treat the checkout as unsafe."""
95
131
  ret, lines = get_porcelain_lines(Path(root))
96
132
  if ret != 0:
97
- return True, "git status failed, so changes by other sessions cannot be ruled out"
98
- own = {_norm(p) for p in own_files}
133
+ raise CheckoutStatusError(f"git status failed in {root}")
134
+ own = {_norm(p) for p in (own_files or set())}
99
135
  foreign: list[str] = []
100
136
  for entry in lines:
101
137
  for path in entry_paths(entry):
102
138
  if _norm(path) not in own and path not in foreign:
103
139
  foreign.append(path)
140
+ return foreign
141
+
142
+
143
+ def is_shared_checkout(root: Path, own_files: set[str]) -> tuple[bool, str]:
144
+ """(True, reason) when handoff must not write tracked files in `root`; else (False, "").
145
+
146
+ `Shared checkout: no` in AGENTS.md never hides a change this session did not make. When git status cannot
147
+ run, the checkout counts as shared: a wrong "no" could commit another session's files.
148
+ """
149
+ if checkout_is_shared(Path(root)):
150
+ return True, "AGENTS.md says Shared checkout: yes"
151
+ try:
152
+ foreign = foreign_changes(Path(root), own_files)
153
+ except CheckoutStatusError:
154
+ return True, "git status failed, so changes by other sessions cannot be ruled out"
104
155
  if foreign:
105
156
  return True, "changes not made by this session: " + ", ".join(foreign[:3])
106
157
  return False, ""
@@ -209,11 +209,10 @@ def _remove(main: Path, worktree: Path, branch: str) -> None:
209
209
  caddis_worktree_safety.safe_remove_worktree(main, worktree, force_locked=True, env=roadmap._quiet_env())
210
210
  except OSError:
211
211
  pass
212
- for args in (("worktree", "prune"), ("branch", "-D", branch)):
213
- try:
214
- _git(main, *args, check=False)
215
- except (roadmap.RoadmapError, OSError):
216
- pass
212
+ try: # no `git worktree prune`: it would drop other tools' registrations too (robustness #19)
213
+ _git(main, "branch", "-D", branch, check=False)
214
+ except (roadmap.RoadmapError, OSError):
215
+ pass
217
216
  if worktree.exists():
218
217
  caddis_worktree_safety.remove_tree(worktree)
219
218
 
@@ -234,6 +233,7 @@ def finish(worktree: Path, title: str, body: str, out=print) -> int:
234
233
  out("PR: none")
235
234
  return caddis_exit.CLEAN
236
235
  pushed = False
236
+ pushed_tip = ""
237
237
  try:
238
238
  gh_repo = roadmap.gh_repo_of(main)
239
239
  if gh_repo is None:
@@ -242,14 +242,15 @@ def finish(worktree: Path, title: str, body: str, out=print) -> int:
242
242
  _git(worktree, "commit", "--quiet", "-m", title)
243
243
  _git(worktree, "push", "--quiet", "-u", "origin", branch)
244
244
  pushed = True
245
+ pushed_tip = _git(worktree, "rev-parse", "HEAD").stdout.strip() # the only tip we may delete
245
246
  url = open_pr(gh_repo, branch, base, title, body)
246
247
  except (DocsPrError, roadmap.RoadmapError, OSError) as exc:
247
248
  out(f"docs PR failed: {exc}")
248
249
  out(f"PATCH: {patch}")
249
250
  if pushed:
250
- try:
251
- _git(main, "push", "--quiet", "origin", "--delete", branch, check=False)
252
- except (roadmap.RoadmapError, OSError):
251
+ try: # only while the remote tip is still the commit we pushed
252
+ _git(main, *caddis_worktree_safety.remote_delete_args(branch, pushed_tip), check=False)
253
+ except (roadmap.RoadmapError, OSError, ValueError):
253
254
  pass
254
255
  _remove(main, worktree, branch)
255
256
  return getattr(exc, "code", caddis_exit.NOT_RUN)
@@ -602,7 +602,26 @@ def is_test_file(path: str) -> bool:
602
602
  return False
603
603
 
604
604
 
605
+ _STEERING_FILENAMES = {"conftest.py", "pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"}
606
+
607
+
608
+ def is_review_steering_file(path: str) -> bool:
609
+ """True for config that steers reviews, merges or tests: never tier 0, and never a 0-reviewer "1-small".
610
+
611
+ `.caddis/*.toml` (review/implement routing, merge-chain checks, spend cap), any `conftest.py`, the pytest and
612
+ packaging config, and `.github/workflows/**`. A fix diff that changes these can turn a gate off, so it is reviewed.
613
+ """
614
+ norm = path.replace("\\", "/").lower().removeprefix("./")
615
+ if norm.startswith(".caddis/") and norm.endswith(".toml"):
616
+ return True
617
+ if norm.startswith(".github/workflows/"):
618
+ return True
619
+ return norm.rsplit("/", 1)[-1] in _STEERING_FILENAMES
620
+
621
+
605
622
  def is_tier0_file(path: str) -> bool:
623
+ if is_review_steering_file(path):
624
+ return False
606
625
  norm = path.replace("\\", "/").lower()
607
626
  if norm.startswith("./"):
608
627
  norm = norm[2:]
@@ -685,6 +704,8 @@ def review_tier(files: list[tuple[str, int]], plan_risk: str = "normal") -> str:
685
704
  non_tier0_lines = sum(lines for _, lines in non_tier0)
686
705
  if review_triggers(non_tier0_paths, non_tier0_lines):
687
706
  return "2"
707
+ if any(is_review_steering_file(p) for p in non_tier0_paths):
708
+ return "1"
688
709
  has_test = any(is_test_file(p) for p, _ in files)
689
710
  if non_tier0_lines <= 40 and has_test:
690
711
  return "1-small"
@@ -33,6 +33,7 @@ except ModuleNotFoundError as exc:
33
33
  import caddis_exit
34
34
  from caddis_heavy_slot import HeavySlotBusy, heavy_slot
35
35
 
36
+ import caddis_checkout_guard
36
37
  import caddis_routing
37
38
  import caddis_redact
38
39
  import caddis_spend
@@ -274,7 +275,6 @@ def remove_phase_worktree(
274
275
  problem = f"worktree remove left {resolved} on disk"
275
276
  if problem:
276
277
  raise LaneConfigError(problem)
277
- _git_output(repo_root, "worktree", "prune")
278
278
  if delete_branch:
279
279
  _git_output(repo_root, "branch", "-D", "--", wt.branch)
280
280
 
@@ -1265,7 +1265,10 @@ def child_env(base: dict, lane: str, model: str) -> dict:
1265
1265
  for name in LANE_SECRET_VARS:
1266
1266
  env.pop(name, None)
1267
1267
  env.update(LANE_NO_PROMPT_ENV)
1268
- env["CADDIS_HEADLESS"] = "1"
1268
+ try:
1269
+ env = caddis_spend.bump_headless_depth(env) # sets CADDIS_HEADLESS=1 and the depth (robustness #19)
1270
+ except caddis_spend.HeadlessDepthExceeded as exc:
1271
+ raise LaneConfigError(str(exc)) from exc
1269
1272
  if lane == "glm":
1270
1273
  env["OSS_MODEL"] = model
1271
1274
  return env
@@ -2165,6 +2168,28 @@ def _printable(value: object) -> str:
2165
2168
  return _CONTROL_CHARS.sub("", text)
2166
2169
 
2167
2170
 
2171
+ # Accept fast-forwards the SESSION checkout. In a checkout a dev service serves from, or one that holds a peer's
2172
+ # uncommitted edits, that moves files under them (robustness #11). The lane's own scaffolding is not a peer's change.
2173
+ _LANE_OWN_PREFIX = ".caddis/orchestrator/"
2174
+
2175
+
2176
+ def shared_checkout_reason(repo: Path) -> str:
2177
+ """Why accept must not fast-forward `repo`, or ''. Shared (AGENTS.md) and/or foreign changes (git status):
2178
+ every reason that holds, joined with "; ", so an --allow-shared audit records all of them."""
2179
+ reasons = []
2180
+ if caddis_checkout_guard.checkout_is_shared(repo):
2181
+ reasons.append("AGENTS.md says Shared checkout: yes")
2182
+ try:
2183
+ dirty = caddis_checkout_guard.foreign_changes(repo)
2184
+ except caddis_checkout_guard.CheckoutStatusError:
2185
+ reasons.append("git status failed, so changes by other sessions cannot be ruled out")
2186
+ dirty = []
2187
+ foreign = [path for path in dirty if not path.replace("\\", "/").startswith(_LANE_OWN_PREFIX)]
2188
+ if foreign:
2189
+ reasons.append("changes not made by this lane: " + ", ".join(foreign[:3]))
2190
+ return "; ".join(reasons)
2191
+
2192
+
2168
2193
  def _accept_command(
2169
2194
  args,
2170
2195
  repo: Path,
@@ -2221,6 +2246,16 @@ def _accept_command(
2221
2246
  or packet["worktree_digest"] != worktree_digest(wt.path, files)):
2222
2247
  print("refusing: worktree changed since check")
2223
2248
  return EXIT_FAIL
2249
+ unsafe = shared_checkout_reason(repo)
2250
+ if unsafe and not getattr(args, "allow_shared", False):
2251
+ print(f"refusing: the session checkout is not safe to fast-forward ({_printable(unsafe)}). "
2252
+ "Run the programme from a worktree of origin/<default> under %TEMP% (git worktree add), "
2253
+ "or pass --allow-shared to fast-forward here anyway")
2254
+ return EXIT_USAGE
2255
+ if unsafe:
2256
+ packet["allow_shared"] = unsafe
2257
+ write_packet(packet_path, packet)
2258
+ print(f"ALLOW SHARED: {_printable(unsafe)}")
2224
2259
  if args.override_review is not None:
2225
2260
  packet["override_review"] = args.override_review
2226
2261
  write_packet(packet_path, packet)
@@ -2458,6 +2493,8 @@ def main(argv: list[str] | None = None) -> int:
2458
2493
  _add_phase_arguments(accept)
2459
2494
  accept.add_argument("--message", required=True)
2460
2495
  accept.add_argument("--override-review")
2496
+ accept.add_argument("--allow-shared", action="store_true",
2497
+ help="fast-forward a shared checkout or one with foreign changes anyway (recorded)")
2461
2498
  abandon = subparsers.add_parser("abandon")
2462
2499
  _add_phase_arguments(abandon)
2463
2500
  finish = subparsers.add_parser("finish")
@@ -1,14 +1,16 @@
1
1
  """Merge pull requests one at a time, from green CI to a verified deploy, and stop the line at the first problem.
2
2
 
3
3
  For each PR (design section 11): when BEHIND, update the branch and cancel the old head's runs (force-cancel after
4
- 2 minutes); a cancelled run on the head is re-run, never merged on; "no checks reported" waits; a failed check stops;
5
- no merge while a push run is in progress on the default branch; squash merge with --match-head-commit; then the merge
6
- SHA must get a push run, and `caddis_prod_check.py` must pass before the next PR starts. A STOP file is checked before
7
- each update-branch and each merge. --dry-run makes read-only `gh` calls and prints what it would do.
4
+ 2 minutes); a cancelled run on the head is re-run, never merged on; "checks green" is ci-watch's verdict on the
5
+ `statusCheckRollup` of the PR's `headRefOid` (never the `gh pr checks` table, which can show an older head); "no
6
+ checks reported" waits, then stops; every check skipped stops (no job ran), both unless --allow-no-checks; a failed
7
+ check stops; no merge while a push run is in progress on the default branch; squash merge with --match-head-commit;
8
+ then the merge SHA must get a push run, and `caddis_prod_check.py` must pass before the next PR starts. A STOP file is
9
+ checked before each update-branch and each merge. --dry-run makes read-only `gh` calls and prints what it would do.
8
10
 
9
11
  Exit 0 when every PR merged and verified; 1 at the first stop; 3 when the profile cannot be read.
10
12
 
11
- Usage: caddis_merge_chain.py [--repo PATH] [--gh-repo OWNER/NAME] [--docs-only] [--dry-run]
13
+ Usage: caddis_merge_chain.py [--repo PATH] [--gh-repo OWNER/NAME] [--docs-only] [--dry-run] [--allow-no-checks]
12
14
  [--stop-file PATH ...] <pr> [<pr> ...]
13
15
  """
14
16
 
@@ -21,13 +23,15 @@ from pathlib import Path
21
23
 
22
24
  try:
23
25
  import caddis_exit
26
+ import caddis_lanes
24
27
  import caddis_prod_check as prod
25
28
  except ModuleNotFoundError as exc:
26
- if exc.name not in {"caddis_exit", "caddis_prod_check"}:
29
+ if exc.name not in {"caddis_exit", "caddis_lanes", "caddis_prod_check", "caddis_heavy_slot", "caddis_routing"}:
27
30
  raise
28
31
  sys.path.insert(0, str(Path(__file__).resolve().parent))
29
32
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
30
33
  import caddis_exit
34
+ import caddis_lanes
31
35
  import caddis_prod_check as prod
32
36
 
33
37
  MAX_ATTEMPTS = 8
@@ -45,6 +49,7 @@ class Stop(Exception):
45
49
  class ChainContext(prod.Context):
46
50
  stop_files: list[Path] = field(default_factory=list)
47
51
  docs_only: bool = False
52
+ allow_no_checks: bool = False
48
53
 
49
54
 
50
55
  def _stop_check(ctx: ChainContext, before: str) -> None:
@@ -90,26 +95,63 @@ def _head_run(ctx: ChainContext, branch: str, head: str) -> dict | None:
90
95
  return next((r for r in runs if r.get("headSha") == head), None) # type: ignore[union-attr]
91
96
 
92
97
 
93
- def _wait_checks(ctx: ChainContext, pr: int) -> str:
94
- """Return 'pass', 'fail' or 'cancel'. Raise Stop when no check reports in time."""
98
+ def _rollup_state(rollup: object, allow_no_checks: bool) -> tuple[str, str]:
99
+ """(state, detail) from the head's `statusCheckRollup`, by ci-watch's own verdict (`caddis_lanes._rollup_verdict`).
100
+
101
+ state is 'pass', 'fail', 'cancel', 'pending', 'none' (no check reported) or 'skipped' (every check skipped).
102
+ A check that only SKIPPED or NEUTRAL ran no job, so a rollup with no SUCCESS proves nothing: 'skipped', unless
103
+ the owner passed --allow-no-checks for a repo that has no CI at all.
104
+ """
105
+ verdict = caddis_lanes._rollup_verdict(rollup)
106
+ if verdict is None:
107
+ return ("pass", "no checks reported (--allow-no-checks)") if allow_no_checks else ("none", "")
108
+ _green, failed, unfinished = verdict
109
+ if failed:
110
+ cancelled = {str(i.get("name") or i.get("context") or "?") for i in rollup # type: ignore[union-attr]
111
+ if isinstance(i, dict) and str(i.get("conclusion") or "").upper() == "CANCELLED"}
112
+ return ("cancel" if set(failed) <= cancelled else "fail"), ", ".join(failed)
113
+ if unfinished:
114
+ return "pending", ", ".join(unfinished)
115
+ ran = sum(1 for i in rollup if isinstance(i, dict) # type: ignore[union-attr]
116
+ and str(i.get("conclusion") or i.get("state") or "").upper() == "SUCCESS")
117
+ if not ran:
118
+ return ("pass", "every check skipped (--allow-no-checks)") if allow_no_checks else ("skipped", "")
119
+ return "pass", ""
120
+
121
+
122
+ def _wait_checks(ctx: ChainContext, pr: int, head: str) -> str:
123
+ """Return 'pass', 'fail', 'cancel' or 'moved' for the commit `head`. Raise Stop when no proof comes in time.
124
+
125
+ The verdict is ci-watch's: the `statusCheckRollup` of the PR's `headRefOid`, read in one `gh pr view`. The
126
+ `gh pr checks` table is never read: it can still show an older head's checks.
127
+ """
95
128
  deadline = ctx.clock.now() + CHECKS_WAIT_MINUTES * 60
96
129
  while True:
97
- proc = ctx.gh("pr", "checks", str(pr), "--json", "name,bucket")
98
- if proc.rc != 0 and not proc.out.strip() and "no checks reported" not in proc.err + proc.out:
130
+ proc = ctx.gh("pr", "view", "--json", "headRefOid,statusCheckRollup", str(pr))
131
+ if proc.rc != 0:
99
132
  # Auth or network failure: say so now instead of waiting 30 minutes as if no check had started.
100
- raise Stop(f"#{pr}: gh pr checks failed: {(proc.err or proc.out).strip()[:300]}")
101
- buckets: list[str] = []
133
+ raise Stop(f"#{pr}: gh pr view failed: {(proc.err or proc.out).strip()[:300]}")
102
134
  try:
103
- buckets = [c.get("bucket", "") for c in prod.json.loads(proc.out)] if proc.out.strip() else []
135
+ data = prod.json.loads(proc.out) if proc.out.strip() else {}
104
136
  except ValueError:
105
- buckets = []
106
- if "fail" in buckets:
107
- return "fail"
108
- if "cancel" in buckets:
109
- return "cancel"
110
- if buckets and "pending" not in buckets:
137
+ data = {}
138
+ if not isinstance(data, dict):
139
+ data = {}
140
+ if data.get("headRefOid") != head:
141
+ ctx.out(f" #{pr}: the head is now {str(data.get('headRefOid'))[:12]}, not {head[:12]}; reading again")
142
+ return "moved"
143
+ state, detail = _rollup_state(data.get("statusCheckRollup"), ctx.allow_no_checks)
144
+ if state == "pass":
145
+ if detail:
146
+ ctx.out(f" #{pr}: WARNING: {detail} on head {head[:12]}")
111
147
  return "pass"
112
- waiting = "no checks reported yet" if not buckets else "checks pending"
148
+ if state in ("fail", "cancel"):
149
+ return state
150
+ if state == "skipped":
151
+ raise Stop(f"#{pr}: every check on head {head[:12]} was skipped: no job ran, so this is not a pass. "
152
+ "If this repo has no CI at all, run again with --allow-no-checks")
153
+ waiting = (f"checks pending ({detail})" if state == "pending" else
154
+ "no checks reported yet (if this repo has no CI at all, run again with --allow-no-checks)")
113
155
  if ctx.clock.now() >= deadline:
114
156
  raise Stop(f"#{pr}: {waiting} after {CHECKS_WAIT_MINUTES} min")
115
157
  if ctx.dry_run:
@@ -159,10 +201,10 @@ def merge_one(ctx: ChainContext, pr: int) -> str | None:
159
201
  ctx.out(f" WARNING: re-run of {run['databaseId']} refused: {(rerun.err or rerun.out).strip()[:200]}")
160
202
  ctx.clock.sleep(60)
161
203
  continue
162
- result = _wait_checks(ctx, pr)
204
+ result = _wait_checks(ctx, pr, head)
163
205
  if result == "fail":
164
206
  raise Stop(f"#{pr}: CI failed on head {head[:12]}")
165
- if result == "cancel":
207
+ if result in ("cancel", "moved"):
166
208
  continue
167
209
  if ctx.dry_run:
168
210
  ctx.out(f"#{pr} ({merge_state}): checks green; would wait for idle push runs, then squash-merge {head[:12]}")
@@ -219,6 +261,8 @@ def main(argv: list[str] | None = None) -> int:
219
261
  parser.add_argument("--profile", type=Path, help="default: <repo>/.caddis/prod-check.toml")
220
262
  parser.add_argument("--docs-only", action="store_true", help="the PRs change docs only: expect no deploy")
221
263
  parser.add_argument("--dry-run", action="store_true", help="read-only gh calls; print the steps")
264
+ parser.add_argument("--allow-no-checks", action="store_true",
265
+ help="the repo has no CI: accept a head with no check, or with every check skipped")
222
266
  parser.add_argument("--stop-file", type=Path, action="append", default=[],
223
267
  help="extra STOP file (~/.caddis/STOP is always checked)")
224
268
  args = parser.parse_args(argv)
@@ -229,6 +273,7 @@ def main(argv: list[str] | None = None) -> int:
229
273
  return caddis_exit.NOT_RUN
230
274
  ctx = ChainContext(run=prod.real_runner(args.repo), clock=prod.Clock(), profile=profile, repo=args.repo,
231
275
  gh_repo=args.gh_repo, dry_run=args.dry_run, docs_only=args.docs_only,
276
+ allow_no_checks=args.allow_no_checks,
232
277
  stop_files=[Path.home() / ".caddis" / "STOP", *args.stop_file])
233
278
  return merge_chain(ctx, args.prs)
234
279