@caddis/cli 0.4.53 → 0.4.54
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.
- package/bundles/antigravity-plugin/guard_agy.py +184 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_budget.py +283 -0
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +5 -1
- package/bundles/antigravity-plugin/scripts/caddis_fleet.py +439 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +29 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +127 -10
- package/bundles/antigravity-plugin/scripts/caddis_notify.py +5 -1
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +56 -3
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +676 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +39 -6
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +4 -4
- package/bundles/antigravity-plugin/skills/_registry.md +2 -0
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +4 -0
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +6 -0
- package/bundles/antigravity-plugin/skills/fleet-close/SKILL.md +167 -0
- package/bundles/antigravity-plugin/skills/fleet-open/SKILL.md +110 -0
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +101 -1
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +3 -2
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +20 -6
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +1 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +2 -2
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +9 -5
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +3 -2
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +2 -1
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +2 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +1 -0
- package/bundles/manifest.json +2 -2
- 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
|
|
1455
|
-
|
|
1456
|
-
`
|
|
1457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1543
|
-
|
|
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
|
|