@caddis/cli 0.4.53 → 0.4.55

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 (32) hide show
  1. package/bundles/antigravity-plugin/guard_agy.py +184 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/scripts/caddis_budget.py +283 -0
  4. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +5 -1
  5. package/bundles/antigravity-plugin/scripts/caddis_fleet.py +439 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_gate.py +29 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +127 -10
  8. package/bundles/antigravity-plugin/scripts/caddis_notify.py +5 -1
  9. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +56 -3
  10. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +676 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +39 -6
  12. package/bundles/antigravity-plugin/scripts/caddis_routing.py +4 -4
  13. package/bundles/antigravity-plugin/skills/_registry.md +2 -0
  14. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +4 -0
  15. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +6 -0
  16. package/bundles/antigravity-plugin/skills/fleet-close/SKILL.md +167 -0
  17. package/bundles/antigravity-plugin/skills/fleet-open/SKILL.md +110 -0
  18. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +2 -1
  19. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +101 -1
  20. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +3 -2
  21. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +20 -6
  22. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +2 -1
  23. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +1 -1
  24. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +1 -0
  25. package/bundles/antigravity-plugin/skills/ship/SKILL.md +2 -2
  26. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +9 -5
  27. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +3 -2
  28. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +2 -1
  29. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +2 -1
  30. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +1 -0
  31. package/bundles/manifest.json +2 -2
  32. package/package.json +1 -1
@@ -0,0 +1,439 @@
1
+ """Fleet handoff rules: session names, acks, the roster and the restart sheet.
2
+
3
+ Plan: .caddis/plans/fleet-handoff.md (Phase 4). `/caddis:fleet-close` and `/caddis:fleet-open` only call
4
+ this script, so every fleet rule lives here and has a test.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ try:
19
+ import caddis_exit
20
+ except ModuleNotFoundError as exc:
21
+ if exc.name != "caddis_exit":
22
+ raise
23
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
24
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
25
+ import caddis_exit
26
+
27
+ ACK_FIELDS = ("name", "role", "machine", "folder", "relay", "tasks", "mode", "owns-watchers", "worktrees")
28
+ NO_ACK = "no ack"
29
+ NO_NEW_SESSION = ("keeps-running", "nothing", NO_ACK)
30
+ TASKS_HEADER = "| Old # | Status | Subject |"
31
+
32
+ _STAMP = re.compile(r"-\d{4}-\d{2}$")
33
+ _SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
34
+ _ACK_LINE = re.compile(r"^\s*FLEET-ACK\s+(.*)$", re.MULTILINE)
35
+ _PAIR = re.compile(r"([a-z][a-z-]*)=(.*?)(?=\s+[a-z][a-z-]*=|\s*$)")
36
+ _SHARED_MARKER = re.compile(r"^Shared checkout:\s*yes\b", re.IGNORECASE | re.MULTILINE)
37
+
38
+
39
+ class AckError(ValueError):
40
+ """An ack that cannot be used; the message names the missing or bad field."""
41
+
42
+
43
+ @dataclass
44
+ class Ack:
45
+ name: str
46
+ role: str
47
+ machine: str
48
+ folder: str
49
+ relay: str
50
+ tasks: str
51
+ mode: str
52
+ owns_watchers: bool
53
+ worktrees: str
54
+ prompt: str = ""
55
+ tasks_inline: str = ""
56
+
57
+
58
+ # ---- names ------------------------------------------------------------------------------------
59
+
60
+ def role_of(name: str) -> str:
61
+ """The name without a trailing `-MMDD-HH`; a name without it is its own role."""
62
+ return _STAMP.sub("", name)
63
+
64
+
65
+ def new_name(role: str, now: datetime) -> str:
66
+ return f"{role}-{now:%m%d}-{now:%H}"
67
+
68
+
69
+ def newest(role: str, names: list[str]) -> str | None:
70
+ """The name of `role` with the largest `MMDD-HH`; a bare `role` ranks lowest.
71
+
72
+ Across a year end (`1231-23` vs `0101-00`) this compares wrong; accepted, the roster holds the
73
+ true name.
74
+ """
75
+ pattern = re.compile(r"^" + re.escape(role) + r"-(\d{4}-\d{2})$")
76
+ best: tuple[str, str] | None = None
77
+ for name in names:
78
+ name = name.strip()
79
+ if name == role:
80
+ key = ""
81
+ else:
82
+ m = pattern.match(name)
83
+ if not m:
84
+ continue
85
+ key = m.group(1)
86
+ if best is None or key > best[0]:
87
+ best = (key, name)
88
+ return best[1] if best else None
89
+
90
+
91
+ # ---- acks -------------------------------------------------------------------------------------
92
+
93
+ def _split_prompt(body: str) -> tuple[str, str]:
94
+ """Pull an inline tasks table (remote sessions) out of the resume prompt."""
95
+ lines = body.splitlines()
96
+ for i, line in enumerate(lines):
97
+ if line.strip() == TASKS_HEADER:
98
+ end = i + 1
99
+ while end < len(lines) and lines[end].strip().startswith("|"):
100
+ end += 1
101
+ table = "\n".join(lines[i:end])
102
+ prompt = "\n".join(lines[:i] + lines[end:])
103
+ return prompt.strip(), table
104
+ return body.strip(), ""
105
+
106
+
107
+ def parse_ack(text: str) -> Ack:
108
+ """Read a `FLEET-ACK` block and the resume prompt after it (all text after the fenced block)."""
109
+ m = _ACK_LINE.search(text)
110
+ if not m:
111
+ raise AckError("no FLEET-ACK line")
112
+ fields = {k: v.strip() for k, v in _PAIR.findall(m.group(1))}
113
+ for key in ACK_FIELDS:
114
+ if not fields.get(key):
115
+ raise AckError(f"missing field: {key}")
116
+ mode = fields["mode"]
117
+ if mode not in ("new", "keeps-running", "nothing") and not re.fullmatch(r"restore-on:\S+", mode):
118
+ raise AckError(f"bad field: mode={mode} (new, keeps-running, restore-on:<machine> or nothing)")
119
+ watchers = fields["owns-watchers"].lower()
120
+ if watchers not in ("yes", "no"):
121
+ raise AckError(f"bad field: owns-watchers={fields['owns-watchers']} (yes or no)")
122
+ if not _SAFE_NAME.match(fields["name"]):
123
+ raise AckError(f"bad field: name={fields['name']}")
124
+ rest = text[m.end():]
125
+ fence = rest.find("```")
126
+ body = rest
127
+ if fence >= 0: # skip the closing fence line of the FLEET-ACK block
128
+ after = rest[fence:]
129
+ body = after.split("\n", 1)[1] if "\n" in after else ""
130
+ prompt, inline = _split_prompt(body)
131
+ return Ack(
132
+ name=fields["name"], role=fields["role"], machine=fields["machine"], folder=fields["folder"],
133
+ relay=fields["relay"], tasks=fields["tasks"], mode=mode, owns_watchers=watchers == "yes",
134
+ worktrees=fields["worktrees"], prompt=prompt, tasks_inline=inline,
135
+ )
136
+
137
+
138
+ def no_ack(name: str) -> Ack:
139
+ """A roster row for a session that never acked."""
140
+ return Ack(name=name, role=role_of(name), machine="-", folder="-", relay="-", tasks="-", mode=NO_ACK,
141
+ owns_watchers=False, worktrees="-")
142
+
143
+
144
+ # ---- order, roster, sheet ---------------------------------------------------------------------
145
+
146
+ def _norm_folder(folder: str) -> str:
147
+ return folder.replace("\\", "/").rstrip("/").lower()
148
+
149
+
150
+ def _group(ack: Ack, coord_folder: str | None) -> int:
151
+ """1 owns watchers; 2 coordinator's repo; 3 other repos; 4 restore-on; 5 no new session.
152
+
153
+ A session that needs no new session is group 5 even when it owns watchers: its watchers keep running.
154
+ """
155
+ if ack.mode in NO_NEW_SESSION:
156
+ return 5
157
+ if ack.owns_watchers:
158
+ return 1
159
+ if ack.mode.startswith("restore-on:"):
160
+ return 4
161
+ if coord_folder is not None and _norm_folder(ack.folder) == coord_folder:
162
+ return 2
163
+ return 3
164
+
165
+
166
+ def order(acks: list[Ack], coordinator: str) -> list[Ack]:
167
+ coord = next((a for a in acks if a.name == coordinator), None)
168
+ coord_folder = _norm_folder(coord.folder) if coord else None
169
+ return sorted(acks, key=lambda a: (_group(a, coord_folder), a.name != coordinator, a.name))
170
+
171
+
172
+ def _cell(value: str) -> str:
173
+ return value.replace("|", "\\|")
174
+
175
+
176
+ def render_roster(acks: list[Ack], coordinator: str) -> str:
177
+ lines = [
178
+ f"# Fleet roster (coordinator {coordinator})",
179
+ "",
180
+ "| Role | Current name | Machine | Folder | Relay | Tasks file | Mode | Worktrees |",
181
+ "|---|---|---|---|---|---|---|---|",
182
+ ]
183
+ for a in order(acks, coordinator):
184
+ cells = (a.role, a.name, a.machine, a.folder, a.relay, a.tasks, a.mode, a.worktrees)
185
+ lines.append("| " + " | ".join(_cell(c) for c in cells) + " |")
186
+ return "\n".join(lines) + "\n"
187
+
188
+
189
+ def _fenced(text: str) -> str:
190
+ longest = max((len(r) for r in re.findall(r"`+", text)), default=0)
191
+ fence = "`" * max(3, longest + 1)
192
+ return f"{fence}text\n{text}\n{fence}"
193
+
194
+
195
+ def start_line(ack: Ack, stamp: datetime) -> str:
196
+ """The one line the owner pastes into a new terminal (works in PowerShell and bash): go to the row's folder, start
197
+ claude named for the role at the sheet's hour, with `/caddis:fleet-open <role>` as the first prompt.
198
+
199
+ No permission or Remote Control flags: every start reads `defaultMode` and `remoteControlAtStartup` from the
200
+ owner's settings (owner, 16 Oct). caddis starts no process; the owner does (`claude --bg` decided against)."""
201
+ return f'cd "{ack.folder}"; claude --name {new_name(ack.role, stamp)} "/caddis:fleet-open {ack.role}"'
202
+
203
+
204
+ def render_sheet(acks: list[Ack], coordinator: str, stamp: datetime) -> str:
205
+ ordered = order(acks, coordinator)
206
+ coord = next((a for a in acks if a.name == coordinator), None)
207
+ coord_folder = _norm_folder(coord.folder) if coord else None
208
+ lines = [
209
+ f"# Restart sheet (coordinator {coordinator}, {stamp:%Y-%m-%d %H:%M})",
210
+ "",
211
+ "| Order | Role | Start claude in | Start line |",
212
+ "|---|---|---|---|",
213
+ ]
214
+ for i, a in enumerate(ordered, 1):
215
+ where = a.folder if not a.mode.startswith("restore-on:") else f"{a.mode[len('restore-on:'):]}: {a.folder}"
216
+ first = "no new session" if _group(a, coord_folder) == 5 else f"`{start_line(a, stamp)}`"
217
+ lines.append(f"| {i} | {_cell(a.role)} | {_cell(where)} | {first} |")
218
+ for i, a in enumerate(ordered, 1):
219
+ lines += ["", f"### {i}. {a.role}", ""]
220
+ if a.mode == NO_ACK:
221
+ lines.append(f"No ack from {a.name}: run `/caddis:handoff` in that session by hand.")
222
+ continue
223
+ if _group(a, coord_folder) == 5:
224
+ lines.append(f"Mode `{a.mode}`: no new session.")
225
+ lines.append("")
226
+ lines.append(_fenced(a.prompt))
227
+ if a.tasks_inline:
228
+ lines += ["", "Tasks (inline, remote session):", "", a.tasks_inline]
229
+ return "\n".join(lines) + "\n"
230
+
231
+
232
+ # ---- check ------------------------------------------------------------------------------------
233
+
234
+ def _roster_rows(roster_text: str) -> list[dict[str, str]]:
235
+ rows: list[dict[str, str]] = []
236
+ header: list[str] | None = None
237
+ for line in roster_text.splitlines():
238
+ if not line.startswith("|"):
239
+ continue
240
+ cells = [c.strip().replace("\\|", "|") for c in re.split(r"(?<!\\)\|", line.strip())[1:-1]]
241
+ if header is None:
242
+ header = cells
243
+ continue
244
+ if all(set(c) <= {"-"} for c in cells):
245
+ continue
246
+ rows.append(dict(zip(header, cells)))
247
+ return rows
248
+
249
+
250
+ def _sheet_prompts(sheet_text: str) -> dict[str, str]:
251
+ """Role -> the text of the first fenced block in its `### <n>. <role>` section."""
252
+ prompts: dict[str, str] = {}
253
+ sections = re.split(r"^### \d+\. ", sheet_text, flags=re.MULTILINE)[1:]
254
+ for section in sections:
255
+ role, _, body = section.partition("\n")
256
+ m = re.search(r"^(`{3,})[^\n]*\n(.*?)\n\1\s*$", body, re.MULTILINE | re.DOTALL)
257
+ prompts[role.strip()] = m.group(2).strip() if m else ""
258
+ return prompts
259
+
260
+
261
+ def _is_shared(folder: str, folder_counts: dict[str, int]) -> bool:
262
+ """Shared when the folder's AGENTS.md says so, or two sessions that restart both work in it."""
263
+ if folder_counts.get(_norm_folder(folder), 0) > 1:
264
+ return True
265
+ try:
266
+ return bool(_SHARED_MARKER.search((Path(folder) / "AGENTS.md").read_text(encoding="utf-8")))
267
+ except (OSError, ValueError):
268
+ return False
269
+
270
+
271
+ def check_sheet(sheet_text: str, roster_text: str) -> list[str]:
272
+ rows = [r for r in _roster_rows(roster_text)
273
+ if r.get("Mode") == "new" or r.get("Mode", "").startswith("restore-on:")]
274
+ prompts = _sheet_prompts(sheet_text)
275
+ counts: dict[str, int] = {}
276
+ for r in rows:
277
+ key = _norm_folder(r.get("Folder", ""))
278
+ counts[key] = counts.get(key, 0) + 1
279
+ errors: list[str] = []
280
+ for r in rows:
281
+ role = r.get("Role", "")
282
+ prompt = prompts.get(role, "")
283
+ if not prompt:
284
+ errors.append(f"{role}: no resume prompt on the sheet")
285
+ continue
286
+ tasks = r.get("Tasks file", "none")
287
+ if tasks not in ("none", "-", "") and tasks not in prompt:
288
+ errors.append(f"{role}: the resume prompt does not load {tasks}")
289
+ if "is now" not in prompt:
290
+ errors.append(f"{role}: the resume prompt does not greet peers with `is now`")
291
+ if _is_shared(r.get("Folder", ""), counts) and "origin/main" not in prompt:
292
+ errors.append(f"{role}: shared checkout, but the resume prompt does not say origin/main")
293
+ return errors
294
+
295
+
296
+ # ---- session presence -------------------------------------------------------------------------
297
+
298
+ def whoami(session_id: str, name: str, root: Path) -> Path:
299
+ """Write `<artifacts>/session-state/<sid>.name`: one tab-separated line `name repo root branch`."""
300
+ slug = "".join(c for c in session_id if c.isalnum() or c in "-_")[:64]
301
+ if not slug:
302
+ raise ValueError("empty session id")
303
+ try:
304
+ from claudster_config import artifact_root
305
+ art = Path(artifact_root(str(root)))
306
+ except Exception:
307
+ art = root / ".caddis"
308
+ branch = "-"
309
+ for cmd in (["symbolic-ref", "--short", "-q", "HEAD"], ["rev-parse", "--short", "HEAD"]): # branch, else detached
310
+ proc = subprocess.run(["git", "-C", str(root), *cmd], capture_output=True, text=True,
311
+ encoding="utf-8", errors="replace")
312
+ if proc.returncode == 0 and proc.stdout.strip():
313
+ branch = proc.stdout.strip()
314
+ break
315
+ out = art / "session-state" / f"{slug}.name"
316
+ out.parent.mkdir(parents=True, exist_ok=True)
317
+ out.write_text(f"{name}\t{root}\t{branch}\n", encoding="utf-8")
318
+ return out
319
+
320
+
321
+ # ---- CLI --------------------------------------------------------------------------------------
322
+
323
+ def _load_acks(fleet_dir: Path) -> list[Ack]:
324
+ acks_dir = fleet_dir / "acks"
325
+ if not acks_dir.is_dir():
326
+ return []
327
+ return [parse_ack(p.read_text(encoding="utf-8")) for p in sorted(acks_dir.glob("*.md"))]
328
+
329
+
330
+ def main(argv: list[str] | None = None) -> int:
331
+ parser = argparse.ArgumentParser(prog="caddis_fleet.py", description=__doc__.splitlines()[0])
332
+ sub = parser.add_subparsers(dest="cmd", required=True)
333
+ p = sub.add_parser("role-of", help="the role of a session name")
334
+ p.add_argument("name")
335
+ p = sub.add_parser("new-name", help="<role>-<MMDD>-<HH> for now, local time")
336
+ p.add_argument("role")
337
+ p = sub.add_parser("newest", help="the newest name of a role")
338
+ p.add_argument("role")
339
+ p.add_argument("--names-file", required=True, type=Path, help="one session name per line")
340
+ ack_p = sub.add_parser("ack", help="acks from closing sessions")
341
+ ack_sub = ack_p.add_subparsers(dest="ack_cmd", required=True)
342
+ p = ack_sub.add_parser("add", help="validate an ack and save it as acks/<name>.md")
343
+ p.add_argument("--dir", required=True, type=Path)
344
+ p.add_argument("--text-file", required=True, type=Path)
345
+ p = sub.add_parser("write", help="write roster.md and restart-sheet.md, print the sheet")
346
+ p.add_argument("--dir", required=True, type=Path)
347
+ p.add_argument("--coordinator", required=True)
348
+ p.add_argument("--no-ack", action="append", default=[], metavar="NAME",
349
+ help="a session that never acked (repeatable)")
350
+ p = sub.add_parser("check", help="check the restart sheet against the roster")
351
+ p.add_argument("--dir", required=True, type=Path)
352
+ p = sub.add_parser("whoami", help="record this session's name, repo root and branch")
353
+ p.add_argument("--session-id", required=True)
354
+ p.add_argument("--name", required=True)
355
+ p.add_argument("--root", default=".", type=Path)
356
+ args = parser.parse_args(argv)
357
+
358
+ if args.cmd == "role-of":
359
+ print(role_of(args.name))
360
+ return caddis_exit.CLEAN
361
+ if args.cmd == "new-name":
362
+ print(new_name(args.role, datetime.now()))
363
+ return caddis_exit.CLEAN
364
+ if args.cmd == "newest":
365
+ try:
366
+ names = args.names_file.read_text(encoding="utf-8").splitlines()
367
+ except OSError as exc:
368
+ sys.stderr.write(f"cannot read {args.names_file}: {exc}\n")
369
+ return caddis_exit.NOT_RUN
370
+ found = newest(args.role, names)
371
+ if found is None:
372
+ return caddis_exit.BLOCKED
373
+ print(found)
374
+ return caddis_exit.CLEAN
375
+ if args.cmd == "ack":
376
+ try:
377
+ text = args.text_file.read_text(encoding="utf-8")
378
+ except OSError as exc:
379
+ sys.stderr.write(f"cannot read {args.text_file}: {exc}\n")
380
+ return caddis_exit.NOT_RUN
381
+ try:
382
+ ack = parse_ack(text)
383
+ except AckError as exc:
384
+ print(f"[FLEET] bad ack: {exc}")
385
+ return caddis_exit.BLOCKED
386
+ out = args.dir / "acks" / f"{ack.name}.md"
387
+ out.parent.mkdir(parents=True, exist_ok=True)
388
+ out.write_text(text, encoding="utf-8")
389
+ print(f"[FLEET] ack saved: {ack.name} ({ack.mode}) -> {out}")
390
+ return caddis_exit.CLEAN
391
+ if args.cmd == "write":
392
+ try:
393
+ acks = _load_acks(args.dir)
394
+ except AckError as exc:
395
+ print(f"[FLEET] bad saved ack: {exc}")
396
+ return caddis_exit.BLOCKED
397
+ known = {a.name for a in acks}
398
+ acks += [no_ack(n) for n in args.no_ack if n not in known]
399
+ if not acks:
400
+ print(f"[FLEET] no acks in {args.dir / 'acks'}")
401
+ return caddis_exit.NOT_RUN
402
+ sheet = render_sheet(acks, args.coordinator, datetime.now())
403
+ args.dir.mkdir(parents=True, exist_ok=True)
404
+ (args.dir / "roster.md").write_text(render_roster(acks, args.coordinator), encoding="utf-8")
405
+ (args.dir / "restart-sheet.md").write_text(sheet, encoding="utf-8")
406
+ print(sheet, end="")
407
+ return caddis_exit.CLEAN
408
+ if args.cmd == "check":
409
+ try:
410
+ sheet = (args.dir / "restart-sheet.md").read_text(encoding="utf-8")
411
+ roster = (args.dir / "roster.md").read_text(encoding="utf-8")
412
+ except OSError as exc:
413
+ sys.stderr.write(f"cannot read the fleet files in {args.dir}: {exc}\n")
414
+ return caddis_exit.NOT_RUN
415
+ errors = check_sheet(sheet, roster)
416
+ for error in errors:
417
+ print(error)
418
+ if errors:
419
+ return caddis_exit.BLOCKED
420
+ print("[FLEET] sheet ok")
421
+ return caddis_exit.CLEAN
422
+ # whoami
423
+ try:
424
+ out = whoami(args.session_id, args.name, args.root.resolve())
425
+ except (OSError, ValueError) as exc:
426
+ sys.stderr.write(f"whoami failed: {exc}\n")
427
+ return caddis_exit.NOT_RUN
428
+ print(f"[FLEET] {args.name} -> {out}")
429
+ return caddis_exit.CLEAN
430
+
431
+
432
+ if __name__ == "__main__":
433
+ _reconfig = getattr(sys.stdout, "reconfigure", None)
434
+ if _reconfig:
435
+ try:
436
+ _reconfig(encoding="utf-8")
437
+ except Exception:
438
+ pass
439
+ sys.exit(main())
@@ -387,6 +387,35 @@ def gate_handover_check(doc: Path, repo_root: Path, max_chars: int | None = None
387
387
  )
388
388
  return EXIT_BLOCKED
389
389
 
390
+ # `/handoff` Step 3c saves the open tasks beside the relay. A tasks file the resume prompt
391
+ # never names is a task list the next session never reloads.
392
+ tasks_file = doc.with_name(doc.stem + ".tasks.md")
393
+ if tasks_file.is_file() and tasks_file.name not in text:
394
+ sys.stderr.write(f"handover-check: the resume prompt does not load {tasks_file.name}\n")
395
+ return EXIT_BLOCKED
396
+
397
+ done_bullets = 0
398
+ in_done = in_fence = False
399
+ for line in text.splitlines():
400
+ stripped = line.strip()
401
+ if stripped.startswith("```"): # a heading or bullet inside a code block is text, not structure
402
+ in_fence = not in_fence
403
+ continue
404
+ if in_fence:
405
+ continue
406
+ if stripped.startswith("## Done"):
407
+ in_done = True
408
+ continue
409
+ if in_done:
410
+ if re.match(r"#{1,2} ", stripped): # the next ## (or #) section ends Done; a ### stays inside it
411
+ in_done = False
412
+ continue
413
+ if line.lstrip().startswith(("- ", "* ")):
414
+ done_bullets += 1
415
+ if done_bullets > 8:
416
+ sys.stderr.write(f"handover-check: Done has {done_bullets} bullets; the cap is 8\n")
417
+ return EXIT_BLOCKED
418
+
390
419
  missing: list[str] = []
391
420
  try:
392
421
  # One rule for both gates: `caddis_spawn.check_document` already treats a bare filename
@@ -1329,15 +1329,50 @@ _RUN_ID_IN_URL = re.compile(r"/actions/runs/(\d+)")
1329
1329
 
1330
1330
 
1331
1331
  def _print_ci_watch(result: str, checks: str, merge_state: str, failing: str, watcher: str,
1332
- summary: str | None = None) -> None:
1332
+ summary: str | None = None, head: str = "n/a") -> None:
1333
1333
  print(f"RESULT: {result}")
1334
1334
  print(f"CHECKS: {checks}")
1335
1335
  print(f"MERGE_STATE: {merge_state}")
1336
+ print(f"HEAD: {head}")
1336
1337
  print(f"FAILING: {failing}")
1337
1338
  print(f"WATCHER: {watcher}")
1338
1339
  print(f"SUMMARY: {summary or 'none'}")
1339
1340
 
1340
1341
 
1342
+ _ROLLUP_GREEN = {"SUCCESS", "SKIPPED", "NEUTRAL"}
1343
+ _STATUS_CONTEXT_DONE = {"SUCCESS", "FAILURE", "ERROR"} # a commit status' final states; PENDING/EXPECTED are not
1344
+
1345
+
1346
+ def _rollup_verdict(rollup) -> tuple[int, list[str], list[str]] | None:
1347
+ """(green count, failed names, unfinished names) from `statusCheckRollup` of the PR's head commit.
1348
+
1349
+ A check run is green only when COMPLETED with SUCCESS, SKIPPED or NEUTRAL; a status context only on SUCCESS.
1350
+ None when the rollup is missing, empty or not a list: nothing proves the head is green.
1351
+ """
1352
+ if not isinstance(rollup, list) or not rollup:
1353
+ return None
1354
+ green, failed, unfinished = 0, [], []
1355
+ for item in rollup:
1356
+ if not isinstance(item, dict):
1357
+ return None
1358
+ name = str(item.get("name") or item.get("context") or "?")
1359
+ kind = item.get("__typename") or ("StatusContext" if "context" in item and "status" not in item
1360
+ else "CheckRun")
1361
+ if kind == "StatusContext": # a commit status
1362
+ state = str(item.get("state") or "").upper()
1363
+ outcome = state if state in _STATUS_CONTEXT_DONE else ""
1364
+ else: # a check run
1365
+ done = str(item.get("status") or "").upper() == "COMPLETED"
1366
+ outcome = str(item.get("conclusion") or "").upper() if done else ""
1367
+ if outcome in _ROLLUP_GREEN:
1368
+ green += 1
1369
+ elif outcome: # FAILURE, CANCELLED, TIMED_OUT, ... and any final state not known here: never a pass
1370
+ failed.append(name)
1371
+ else:
1372
+ unfinished.append(name)
1373
+ return green, failed, unfinished
1374
+
1375
+
1341
1376
  def _parse_pr_checks(output: str) -> list[tuple[str, str, str]]:
1342
1377
  """Read `gh pr checks` rows (tab-separated: name, state, elapsed, url) as (name, state, url).
1343
1378
 
@@ -1451,10 +1486,14 @@ def ci_watch(
1451
1486
  ) -> int:
1452
1487
  """Wait for CI with `gh`, using one shared time budget. agy only summarises a failure.
1453
1488
 
1454
- `gh pr checks --watch` (or `gh run watch`) does the waiting. On a PR the check table gives
1455
- CHECKS and FAILING, and one short `gh pr view` gives MERGE_STATE. When the result is `fail` and the
1456
- `ci-watch` routing chain holds agy, agy reads the last lines of the failed log (inside its prompt)
1457
- and returns one line for SUMMARY. agy never changes the result or the exit code.
1489
+ `gh pr checks --watch` (or `gh run watch`) does the waiting, but its exit code is not proof. On a PR,
1490
+ RESULT is `pass` only when the check rollup of the PR's head (one `gh pr view --json
1491
+ mergeStateStatus,headRefOid,statusCheckRollup`) is non-empty and every check is COMPLETED and green, and
1492
+ the head is the one read before the watch began (a push during the watch is exit 3). On a run, `gh run
1493
+ view --json status,conclusion,headSha` must read COMPLETED and SUCCESS (a skipped run ran no job). HEAD prints
1494
+ the commit. When the result is `fail` and the `ci-watch` routing chain holds agy, agy reads the last lines
1495
+ of the failed log (inside its prompt) and returns one line for SUMMARY. agy never changes the result or
1496
+ the exit code.
1458
1497
  """
1459
1498
  if target_kind not in {"pr", "run"} or target_id <= 0 or timeout_s <= 0:
1460
1499
  print("[caddis-lanes] ci-watch: target and timeout must be positive", file=sys.stderr)
@@ -1486,7 +1525,17 @@ def ci_watch(
1486
1525
  _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1487
1526
  return EXIT_CONFIG
1488
1527
  repo_args = ["--repo", repo_slug] if repo_slug else []
1528
+ head_before: str | None = None
1489
1529
  if target_kind == "pr":
1530
+ # The head before the watch: a push during the watch can make a new head look green before all its
1531
+ # checks exist, so a pass needs the same head after the watch.
1532
+ before = _ci_gh_run(run_gh, [gh_path, "pr", "view", str(target_id), "--json", "headRefOid", *repo_args],
1533
+ repo, min(_CI_GH_SHORT_TIMEOUT_S, remaining()))
1534
+ if before is not None and before.returncode == 0:
1535
+ try:
1536
+ head_before = str(json.loads(before.stdout or "").get("headRefOid") or "") or None
1537
+ except (ValueError, AttributeError):
1538
+ head_before = None
1490
1539
  command = [gh_path, "pr", "checks", str(target_id), "--watch", "--interval", "30", *repo_args]
1491
1540
  else:
1492
1541
  command = [gh_path, "run", "watch", str(target_id), "--exit-status", "--interval", "30", *repo_args]
@@ -1531,16 +1580,84 @@ def ci_watch(
1531
1580
  if found:
1532
1581
  run_id = found.group(1)
1533
1582
  break
1583
+ head = "n/a"
1584
+ if target_kind == "pr" and code != EXIT_CONFIG:
1585
+ # The watch table alone is not proof. gh exited 0 on 16 Oct 2026 with a cancelled row (CHECKS 6/7) and
1586
+ # with a row still unfinished (5/6), and its table can still show an older head's checks. So a pass
1587
+ # also needs the check rollup of the PR's CURRENT head: every check COMPLETED and green. HEAD names
1588
+ # that commit, so a caller can merge only that head.
1589
+ rollup_view = None
1534
1590
  left = remaining()
1535
1591
  if left > 0:
1536
1592
  view = _ci_gh_run(
1537
1593
  run_gh,
1538
- [gh_path, "pr", "view", str(target_id), "--json", "mergeStateStatus",
1539
- "-q", ".mergeStateStatus", *repo_args],
1594
+ [gh_path, "pr", "view", str(target_id), "--json", "mergeStateStatus,headRefOid,statusCheckRollup",
1595
+ *repo_args],
1540
1596
  repo, min(_CI_GH_SHORT_TIMEOUT_S, left),
1541
1597
  )
1542
- value = (view.stdout or "").strip() if view is not None and view.returncode == 0 else ""
1543
- merge_state = value or "n/a"
1598
+ if view is not None and view.returncode == 0:
1599
+ try:
1600
+ rollup_view = json.loads(view.stdout or "")
1601
+ except ValueError:
1602
+ rollup_view = None
1603
+ if isinstance(rollup_view, dict):
1604
+ merge_state = str(rollup_view.get("mergeStateStatus") or "") or "n/a"
1605
+ head = str(rollup_view.get("headRefOid") or "") or "n/a"
1606
+ verdict = _rollup_verdict(rollup_view.get("statusCheckRollup")) if isinstance(rollup_view, dict) else None
1607
+ if code == EXIT_OK:
1608
+ if verdict is None or head == "n/a":
1609
+ print("[caddis-lanes] ci-watch: gh said pass, but the head's checks could not be read",
1610
+ file=sys.stderr)
1611
+ code, result, failing = EXIT_CONFIG, "timeout", "none"
1612
+ else:
1613
+ green, failed, unfinished = verdict
1614
+ checks = f"{green}/{green + len(failed) + len(unfinished)}"
1615
+ if failed:
1616
+ code, result, failing = EXIT_FAIL, "fail", ", ".join(failed)
1617
+ elif unfinished:
1618
+ print(f"[caddis-lanes] ci-watch: gh said pass, but not finished: {', '.join(unfinished)}",
1619
+ file=sys.stderr)
1620
+ code, result, failing = EXIT_CONFIG, "timeout", "none"
1621
+ elif head_before is None:
1622
+ print("[caddis-lanes] ci-watch: the head before the watch could not be read, so a push "
1623
+ "during the watch cannot be ruled out; watch again", file=sys.stderr)
1624
+ code, result, failing = EXIT_CONFIG, "timeout", "head not read before the watch"
1625
+ elif head != head_before:
1626
+ print(f"[caddis-lanes] ci-watch: the head moved during the watch ({head_before} -> {head}); "
1627
+ "watch again", file=sys.stderr)
1628
+ code, result, failing = EXIT_CONFIG, "timeout", "head moved during the watch"
1629
+ elif target_kind == "run" and code == EXIT_OK:
1630
+ # The same rule for one run: pass only when the run reads COMPLETED and SUCCESS; HEAD is its commit. A whole
1631
+ # run that is SKIPPED or NEUTRAL ran no job (gh run watch --exit-status exits 0 on it): could not tell.
1632
+ run_view = None
1633
+ left = remaining()
1634
+ if left > 0:
1635
+ view = _ci_gh_run(
1636
+ run_gh, [gh_path, "run", "view", str(target_id), "--json", "status,conclusion,headSha", *repo_args],
1637
+ repo, min(_CI_GH_SHORT_TIMEOUT_S, left),
1638
+ )
1639
+ if view is not None and view.returncode == 0:
1640
+ try:
1641
+ run_view = json.loads(view.stdout or "")
1642
+ except ValueError:
1643
+ run_view = None
1644
+ if not isinstance(run_view, dict) or not run_view.get("headSha"):
1645
+ print("[caddis-lanes] ci-watch: gh said pass, but the run could not be read", file=sys.stderr)
1646
+ code, result = EXIT_CONFIG, "timeout"
1647
+ else:
1648
+ head = str(run_view["headSha"])
1649
+ status = str(run_view.get("status") or "").upper()
1650
+ conclusion = str(run_view.get("conclusion") or "").upper()
1651
+ if status != "COMPLETED":
1652
+ print(f"[caddis-lanes] ci-watch: gh said pass, but run {target_id} is {status.lower() or 'unknown'}",
1653
+ file=sys.stderr)
1654
+ code, result = EXIT_CONFIG, "timeout"
1655
+ elif conclusion in {"SKIPPED", "NEUTRAL"}:
1656
+ print(f"[caddis-lanes] ci-watch: run {target_id} is {conclusion.lower()}: no job ran, so this is "
1657
+ "not a pass", file=sys.stderr)
1658
+ code, result, failing = EXIT_CONFIG, "timeout", f"run {target_id} ({conclusion.lower()})"
1659
+ elif conclusion != "SUCCESS":
1660
+ code, result, failing = EXIT_FAIL, "fail", f"run {target_id} ({conclusion.lower() or 'unknown'})"
1544
1661
 
1545
1662
  summary: str | None = None
1546
1663
  # Walk the chain in order: agy summarises only when it comes before claude (claude = no helper).
@@ -1551,7 +1668,7 @@ def ci_watch(
1551
1668
  summary = _ci_summary_from_log(
1552
1669
  gh_path, run_id, repo_args, repo, run_gh, remaining, agy_path, run_agy,
1553
1670
  )
1554
- _print_ci_watch(result, checks, merge_state, failing, "gh+agy" if summary else "gh", summary)
1671
+ _print_ci_watch(result, checks, merge_state, failing, "gh+agy" if summary else "gh", summary, head)
1555
1672
  return code
1556
1673
 
1557
1674