@caddis/cli 0.4.71 → 0.4.72

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.157",
3
+ "version": "1.3.158",
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
  }
@@ -15,8 +15,10 @@ import hashlib
15
15
  import json
16
16
  import os
17
17
  import re
18
+ import shutil
18
19
  import subprocess
19
20
  import sys
21
+ import time
20
22
  from collections.abc import Callable
21
23
  from pathlib import Path
22
24
 
@@ -43,10 +45,10 @@ _URL = re.compile(
43
45
  )
44
46
 
45
47
 
46
- def _run(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
48
+ def _run(args: list[str], cwd: Path | None = None, timeout: float = _TIMEOUT) -> subprocess.CompletedProcess[str]:
47
49
  # utf-8 explicitly: the Windows default code page garbles non-ASCII paths and titles.
48
50
  return subprocess.run(args, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace",
49
- timeout=_TIMEOUT, creationflags=_NO_WINDOW)
51
+ timeout=timeout, creationflags=_NO_WINDOW)
50
52
 
51
53
 
52
54
  def repo_id(remote_name_with_owner: str | None, is_fork_of: str | None, git_root: Path) -> str:
@@ -815,8 +817,8 @@ def queue_merged(rid: str, pr: int, merge_sha: str) -> bool:
815
817
 
816
818
 
817
819
  def deploy_check_set(rid: str, pr: int, *, deploy_run: int | None = None, health: str | None = None) -> None:
818
- """Set the deploy run and/or health of the newest deploy check for a PR. Raises ValueError for a health not in
819
- `DEPLOY_HEALTH`, a run that is not an integer, or a PR with no deploy check."""
820
+ """Set the deploy run and/or health of the newest deploy check for a PR; a health also sets `at` to now.
821
+ Raises ValueError for a health not in `DEPLOY_HEALTH`, a run that is not an integer, or a PR with no deploy check."""
820
822
  if health is not None and health not in DEPLOY_HEALTH:
821
823
  raise ValueError(f"unknown deploy health: {health!r}")
822
824
  if deploy_run is not None and (isinstance(deploy_run, bool) or not isinstance(deploy_run, int)):
@@ -829,8 +831,9 @@ def deploy_check_set(rid: str, pr: int, *, deploy_run: int | None = None, health
829
831
  raise ValueError(f"no deploy check for PR {pr}")
830
832
  if deploy_run is not None:
831
833
  check["deploy_run"] = deploy_run
832
- if health is not None:
834
+ if health is not None: # stamp `at`, so the newest entry by `at` is the newest health (freeze rule)
833
835
  check["health"] = health
836
+ check["at"] = _now()
834
837
  _mutate_state(rid, change)
835
838
 
836
839
 
@@ -886,6 +889,20 @@ def _gh_head_of(rid: str) -> Callable[[int], str | None]:
886
889
  return head_of
887
890
 
888
891
 
892
+ def _gh_title(rid: str, pr: int) -> str:
893
+ """The PR title from gh, so the model never puts free text on a command line; `PR #<n>` on any failure."""
894
+ fallback = f"PR #{pr}"
895
+ if rid.startswith("local__") or "__" not in rid:
896
+ return fallback
897
+ owner, name = rid.split("__", 1)
898
+ try:
899
+ result = _run(["gh", "pr", "view", str(pr), "-R", f"{owner}/{name}", "--json", "title", "-q", ".title"])
900
+ except (OSError, subprocess.SubprocessError):
901
+ return fallback
902
+ title = result.stdout.strip() if result.returncode == 0 else ""
903
+ return title or fallback
904
+
905
+
889
906
  def _claim_state(rid: str, session: dict) -> None:
890
907
  """After the lock is taken: create `coordinator.json`, or record the new coordinator session in it."""
891
908
  with locked_path(state_path(rid)) as path:
@@ -899,6 +916,445 @@ def _claim_state(rid: str, session: dict) -> None:
899
916
  _write_state_unlocked(path, state)
900
917
 
901
918
 
919
+ def session_lookup(sessions: list[dict], *, session_id: str | None = None, name: str | None = None) -> dict:
920
+ """Return `{"pid", "sessionId", "name"}` of the ONE interactive session with this sessionId or name.
921
+
922
+ Background entries are ignored: their pid is not a long-lived Claude Code session (ruling R12)."""
923
+ if (session_id is None) == (name is None):
924
+ raise ValueError("session_lookup takes exactly one of session_id or name")
925
+ key, wanted = ("sessionId", session_id) if session_id is not None else ("name", name)
926
+ hits = [s for s in sessions if isinstance(s, dict) and s.get("kind") == "interactive" and s.get(key) == wanted]
927
+ if not hits:
928
+ raise ValueError(f"no such session: {key} {wanted!r}")
929
+ if len(hits) > 1:
930
+ pids = ", ".join(str(s.get("pid")) for s in hits)
931
+ raise ValueError(f"two interactive sessions share {key} {wanted!r}: pids {pids}")
932
+ hit = hits[0]
933
+ pid = hit.get("pid")
934
+ if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
935
+ raise ValueError(f"session {key} {wanted!r} has no valid pid: {pid!r}")
936
+ for field in ("sessionId", "name"):
937
+ if not isinstance(hit.get(field), str) or not hit[field]:
938
+ raise ValueError(f"session {key} {wanted!r} has no {field}")
939
+ return {"pid": pid, "sessionId": hit["sessionId"], "name": hit["name"]}
940
+
941
+
942
+ # --- the per-repo config `.caddis/coordinator.md` (C2.2, ruling R10: one fenced json block) --------------------
943
+
944
+ CONFIG_KEYS = ("schema", "default_branch", "merge_method", "required_checks", "not_required", "docs_only",
945
+ "main_workflow", "reviews", "peer_ack", "deploy", "owner_reserved", "stall", "quiet")
946
+ CONFIG_SCHEMA = 1
947
+ _PLACEHOLDER = "<fill:"
948
+ _FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)") # a Markdown code fence and its info string
949
+ _TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "coordinator.md"
950
+
951
+
952
+ def config_path(repo_root: Path) -> Path:
953
+ return Path(repo_root) / ".caddis" / "coordinator.md"
954
+
955
+
956
+ def _closes(line: str, fence: str) -> bool:
957
+ """True when `line` closes a block opened by `fence`: same character, at least as long, nothing after it."""
958
+ closed = re.match(r"^ {0,3}(`{3,}|~{3,})[ \t]*$", line)
959
+ return bool(closed) and closed.group(1)[0] == fence[0] and len(closed.group(1)) >= len(fence)
960
+
961
+
962
+ def _config_block(text: str) -> object:
963
+ """Parse the FIRST fenced block whose info string is `json`. Raise ValueError when there is none."""
964
+ lines = text.splitlines()
965
+ i = 0
966
+ while i < len(lines):
967
+ opened = _FENCE.match(lines[i])
968
+ if not opened:
969
+ i += 1
970
+ continue
971
+ fence, info = opened.group(1), opened.group(2).lower()
972
+ body: list[str] = []
973
+ i += 1
974
+ while i < len(lines) and not _closes(lines[i], fence):
975
+ body.append(lines[i])
976
+ i += 1
977
+ i += 1
978
+ if info == "json":
979
+ try:
980
+ return json.loads("\n".join(body))
981
+ except ValueError as exc:
982
+ raise ValueError(f"the json block in the config does not parse: {exc}") from exc
983
+ raise ValueError("the config has no fenced json block")
984
+
985
+
986
+ def _placeholders(value: object, where: str = "") -> list[str]:
987
+ """Every key path (at any depth) whose value is a string that starts with `<fill:`."""
988
+ if isinstance(value, str):
989
+ return [where] if value.startswith(_PLACEHOLDER) else []
990
+ if isinstance(value, dict):
991
+ return [hit for k, v in value.items() for hit in _placeholders(v, f"{where}.{k}" if where else str(k))]
992
+ if isinstance(value, list):
993
+ return [hit for n, v in enumerate(value) for hit in _placeholders(v, f"{where}[{n}]")]
994
+ return []
995
+
996
+
997
+ def load_config(repo_root: Path) -> dict:
998
+ """Read `<repo_root>/.caddis/coordinator.md` and return its json block.
999
+
1000
+ Raise FileNotFoundError when the file is missing, ValueError when there is no json block, a key is missing,
1001
+ `schema` is not 1, or a value is still a `<fill: ...>` placeholder."""
1002
+ path = config_path(repo_root)
1003
+ try:
1004
+ text = path.read_text(encoding="utf-8")
1005
+ except FileNotFoundError as exc:
1006
+ raise FileNotFoundError(f"no coordinator config at {path} (run: caddis_coordinate.py config init)") from exc
1007
+ config = _config_block(text)
1008
+ if not isinstance(config, dict):
1009
+ raise ValueError("the config json block must be one JSON object")
1010
+ missing = [key for key in CONFIG_KEYS if key not in config]
1011
+ if missing:
1012
+ raise ValueError(f"the config is missing keys: {', '.join(missing)}")
1013
+ schema = config["schema"]
1014
+ if not isinstance(schema, int) or isinstance(schema, bool) or schema != CONFIG_SCHEMA:
1015
+ raise ValueError(f"the config schema must be {CONFIG_SCHEMA}, not {schema!r}")
1016
+ unfilled = _placeholders(config)
1017
+ if unfilled:
1018
+ raise ValueError(f"the config still has {_PLACEHOLDER} placeholders at: {', '.join(unfilled)}")
1019
+ return config
1020
+
1021
+
1022
+ def init_config(repo_root: Path) -> Path:
1023
+ """Copy the template to `<repo_root>/.caddis/coordinator.md` when it is absent. Never overwrite."""
1024
+ path = config_path(repo_root)
1025
+ template = _TEMPLATE_PATH.read_text(encoding="utf-8")
1026
+ path.parent.mkdir(parents=True, exist_ok=True)
1027
+ try:
1028
+ with open(path, "x", encoding="utf-8", newline="") as handle: # "x": fails if the file exists, no race
1029
+ handle.write(template)
1030
+ except FileExistsError:
1031
+ pass
1032
+ return path
1033
+
1034
+
1035
+ # --- the watcher loop (C2.3) --------------------------------------------------------------------------------------
1036
+
1037
+
1038
+ def _say(exc: BaseException) -> str:
1039
+ return " ".join(str(exc).split()) or type(exc).__name__
1040
+
1041
+
1042
+ def watch_cycle(rid: str, pid: int, now: datetime.datetime, head_of: Callable[[int], str | None]) -> dict:
1043
+ """One watcher cycle: check the lock still names `pid` and that session is alive, then heartbeat and intake.
1044
+
1045
+ Returns `{"stop": "lock lost" | "session gone" | "state gone"}` when the watcher must end, else
1046
+ `{"stop": None, "results": [...], "error": str | None}`. A transient failure (a lock file or state file that
1047
+ cannot be read now, a lock timeout) goes into `error` and is never raised: the next cycle retries. The watcher
1048
+ never takes the lock and changes the queue only through `intake`. The pid check is local (no `claude agents`).
1049
+ """
1050
+ def failed(message: str) -> dict:
1051
+ return {"stop": None, "results": [], "error": message}
1052
+
1053
+ try:
1054
+ holder = _read_holder(_lock_path(rid))
1055
+ except OSError as exc: # fail closed: it may still name us, so retry next tick rather than stop
1056
+ if not _pid_alive(pid): # ... unless our own session is dead: then nothing is left to watch for
1057
+ return {"stop": "session gone"}
1058
+ return failed(f"lock file unreadable: {_say(exc)}")
1059
+ if holder is None or holder["pid"] != pid:
1060
+ return {"stop": "lock lost"}
1061
+ if not _holder_alive(holder): # the pid is dead, or now belongs to a newer process (pid reuse)
1062
+ return {"stop": "session gone"}
1063
+ if not state_path(rid).exists(): # checked first: heartbeat must never recreate the repo folder
1064
+ return {"stop": "state gone"}
1065
+ try:
1066
+ heartbeat(rid)
1067
+ results = intake(rid, now, head_of)
1068
+ except FileNotFoundError:
1069
+ return {"stop": "state gone"}
1070
+ except ValueError as exc:
1071
+ if not state_path(rid).exists():
1072
+ return {"stop": "state gone"}
1073
+ return failed(_say(exc))
1074
+ except OSError as exc: # sharing error, lock timeout (TimeoutError): retry next tick
1075
+ return failed(_say(exc))
1076
+ except Exception as exc: # e.g. a hand-edited state file: report it, keep watching
1077
+ return failed(f"unexpected {type(exc).__name__}: {_say(exc)}")
1078
+ return {"stop": None, "results": results, "error": None}
1079
+
1080
+
1081
+ def watch(rid: str, pid: int, interval: float, max_cycles: int | None = None) -> int:
1082
+ """Run `watch_cycle` every `interval` seconds. Print one JSON line (flushed) for each cycle with results or a
1083
+ stop, and for an error that differs from the previous cycle's error; print nothing for a quiet cycle or a
1084
+ repeated error. Return 0 on a stop or after `max_cycles` cycles."""
1085
+ head_of = _gh_head_of(rid)
1086
+ cycles = 0
1087
+ last_error: str | None = None
1088
+ try:
1089
+ while True:
1090
+ out = watch_cycle(rid, pid, datetime.datetime.now(datetime.timezone.utc), head_of)
1091
+ error = out.get("error")
1092
+ if out.get("stop") or out.get("results") or (error and error != last_error):
1093
+ print(json.dumps(out, separators=(",", ":")), flush=True)
1094
+ last_error = error
1095
+ if out.get("stop"):
1096
+ return EXIT_OK
1097
+ cycles += 1
1098
+ if max_cycles is not None and cycles >= max_cycles:
1099
+ return EXIT_OK
1100
+ time.sleep(interval)
1101
+ except KeyboardInterrupt:
1102
+ return EXIT_OK
1103
+
1104
+
1105
+ # --- deploy check D1-D5 by script (C2.4, ruling R11) -------------------------------------------------------------
1106
+
1107
+ DEPLOY_CHECKS = ("D1", "D2", "D3", "D4", "D5")
1108
+ _DEPLOY_TIMEOUT = 60 # one subprocess: `git fetch --tags`, or curl with its own --max-time 20
1109
+ _CURL_MAX_TIME = "20"
1110
+
1111
+
1112
+ def _deploy_run(args: list[str]) -> subprocess.CompletedProcess[str]:
1113
+ """The default runner of `deploy_verify`: `_run` with a timeout longer than curl's own `--max-time`."""
1114
+ return _run(args, timeout=_DEPLOY_TIMEOUT)
1115
+
1116
+
1117
+ def _curl() -> str:
1118
+ """Windows: System32 curl.exe under %SystemRoot%, by full path, when it exists (a bare name lets CreateProcess
1119
+ pick a curl.exe from the current folder first), else `curl.exe` on PATH. Elsewhere: `curl` on PATH."""
1120
+ if os.name != "nt":
1121
+ return "curl"
1122
+ system = Path(os.environ.get("SystemRoot") or "") / "System32" / "curl.exe"
1123
+ return str(system) if os.environ.get("SystemRoot") and system.is_file() else "curl.exe"
1124
+
1125
+
1126
+ def _plain_path(path: object) -> str:
1127
+ """A URL path compared for `never_health_path`: lowercased, no query string or fragment, no trailing slash."""
1128
+ return re.split(r"[?#]", str(path), maxsplit=1)[0].rstrip("/").lower()
1129
+
1130
+
1131
+ def _unset(value: object) -> bool:
1132
+ return value is None or (isinstance(value, str) and value.strip().lower() in ("", "none", "null"))
1133
+
1134
+
1135
+ def _checks_summary(checks: dict, docs_only: bool) -> str:
1136
+ """`passed: D1, D2, D5; skipped: D3, D4`, from the checks dict, with a `docs-only: ` prefix."""
1137
+ parts = []
1138
+ for label, mark in (("passed", "pass"), ("skipped", "skip")):
1139
+ names = [name for name, value in checks.items() if value == mark]
1140
+ if names:
1141
+ parts.append(f"{label}: {', '.join(names)}")
1142
+ return ("docs-only: " if docs_only else "") + "; ".join(parts)
1143
+
1144
+
1145
+ class _CheckFailed(Exception):
1146
+ def __init__(self, reason: str, health: str = "failed") -> None:
1147
+ super().__init__(reason)
1148
+ self.health = health
1149
+
1150
+
1151
+ def _call(runner: Callable[[list[str]], subprocess.CompletedProcess[str]], argv: list[str],
1152
+ what: str) -> subprocess.CompletedProcess[str]:
1153
+ """Run one command; a missing tool, a timeout or a non-zero exit is a check failure, never a crash."""
1154
+ try:
1155
+ result = runner(argv)
1156
+ except subprocess.TimeoutExpired:
1157
+ raise _CheckFailed(f"{what} timed out") from None
1158
+ except (OSError, subprocess.SubprocessError) as exc:
1159
+ raise _CheckFailed(f"{what} could not run ({Path(argv[0]).name}): {_say(exc)}") from None
1160
+ if result.returncode != 0:
1161
+ detail = " ".join((result.stderr or result.stdout or "").split()) or f"exit {result.returncode}"
1162
+ raise _CheckFailed(f"{what} failed: {detail}")
1163
+ return result
1164
+
1165
+
1166
+ def deploy_verify(rid: str, cfg: dict, pr: int, merge_sha: str, *, docs_only: bool,
1167
+ runner: Callable[[list[str]], subprocess.CompletedProcess[str]] | None = None,
1168
+ env=os.environ, repo_root: Path | None = None) -> dict:
1169
+ """Run deploy checks D1-D5 for a merged PR, record the result with `deploy_check_set`, and return
1170
+ `{"checks": {"D1".."D5": "pass|fail|skip"}, "run": int | None, "health": "ok|failed|pending", "reason": str}`.
1171
+
1172
+ Checks run in order and stop at the first failure (the rest are `skip`). D2 does not wait: the model waits
1173
+ with `caddis_lanes.py ci-watch --run` first. Docs-only runs D1, D2 and D5 only, and D2 fails when the run
1174
+ contradicts the flag (a job named `deploy...` succeeded, or `deploy.jobs` jobs succeeded). A non-docs run needs
1175
+ `deploy.jobs` successful jobs, and every job when it has more. `deploy.never_health_path` is never requested.
1176
+ `GH_TOKEN` comes from the inherited environment only. `repo_root` is where the git commands of D3 run.
1177
+ A run still in progress at D2 records health `pending`, not `failed`.
1178
+ Raises ValueError, before any network call and with nothing recorded, for a merge SHA that is not a full SHA,
1179
+ a PR with no deploy check, or a merge SHA that differs from the one in the PR's newest deploy check."""
1180
+ if not isinstance(merge_sha, str) or not _SHA.fullmatch(merge_sha):
1181
+ raise ValueError(f"the merge SHA must be a full 40-character SHA: {merge_sha!r}")
1182
+ state = read_state(rid)
1183
+ check = next((c for c in reversed((state or {}).get("deploy_checks") or [])
1184
+ if isinstance(c, dict) and c.get("pr") == pr), None)
1185
+ if check is None:
1186
+ raise ValueError(f"no deploy check for PR {pr} (run queue merged first)")
1187
+ if str(check.get("merge_sha", "")).lower() != merge_sha.lower():
1188
+ raise ValueError(f"merge SHA {merge_sha[:12]} is not the recorded merge SHA "
1189
+ f"{str(check.get('merge_sha'))[:12]} of PR {pr}")
1190
+ runner = runner or _deploy_run
1191
+ deploy = cfg.get("deploy") if isinstance(cfg.get("deploy"), dict) else {}
1192
+ sha = merge_sha.lower()
1193
+ checks = {name: "skip" for name in DEPLOY_CHECKS}
1194
+ found: dict = {"run": None}
1195
+
1196
+ def curl(path: str, *extra: str) -> list[str]:
1197
+ auth = deploy.get("auth")
1198
+ if _unset(auth):
1199
+ auth_args: list[str] = []
1200
+ elif auth == "ntlm":
1201
+ auth_args = ["--ntlm", "-u", ":"]
1202
+ else:
1203
+ raise _CheckFailed(f"unknown deploy.auth {auth!r} (known: ntlm, none)")
1204
+ name = deploy.get("base_url_env")
1205
+ if _unset(name):
1206
+ raise _CheckFailed("deploy.base_url_env is not set in the config")
1207
+ base = env.get(name)
1208
+ if not base:
1209
+ raise _CheckFailed(f"base URL env {name} not set")
1210
+ if not isinstance(path, str) or not path.startswith("/"):
1211
+ raise _CheckFailed(f"a deploy path must start with '/': {path!r}")
1212
+ never = deploy.get("never_health_path")
1213
+ if not _unset(never) and _plain_path(path) == _plain_path(never):
1214
+ raise _CheckFailed(f"refused: {path} is deploy.never_health_path")
1215
+ return [_curl(), "-s", "--max-time", _CURL_MAX_TIME, *auth_args, *extra, base.rstrip("/") + path]
1216
+
1217
+ def d1() -> None:
1218
+ if rid.startswith("local__") or "__" not in rid:
1219
+ raise _CheckFailed(f"repo {rid} has no GitHub repository")
1220
+ owner, name = rid.split("__", 1)
1221
+ branch, workflow = cfg.get("default_branch"), cfg.get("main_workflow")
1222
+ event = deploy.get("event") or "push"
1223
+ if _unset(workflow) or not isinstance(workflow, str):
1224
+ raise _CheckFailed("main_workflow is not set in the config")
1225
+ out = _call(runner, ["gh", "run", "list", "-R", f"{owner}/{name}", "--branch", str(branch),
1226
+ "--event", str(event), "--workflow", workflow, "--limit", "50",
1227
+ "--json", "databaseId,headSha,status,conclusion"], "gh run list")
1228
+ try:
1229
+ runs = json.loads(out.stdout)
1230
+ except ValueError:
1231
+ raise _CheckFailed("gh run list did not print JSON") from None
1232
+ hits = [r for r in runs if isinstance(r, dict) and str(r.get("headSha", "")).lower() == sha] \
1233
+ if isinstance(runs, list) else []
1234
+ if not hits:
1235
+ raise _CheckFailed(f"no {event} run of {workflow} on {branch} for {merge_sha[:12]}")
1236
+ found["run"] = hits[0] # gh lists newest first
1237
+ run_id = hits[0].get("databaseId")
1238
+ found["id"] = run_id if isinstance(run_id, int) and not isinstance(run_id, bool) else None
1239
+
1240
+ def d2() -> None:
1241
+ run = found["run"]
1242
+ status, conclusion = run.get("status"), run.get("conclusion")
1243
+ if status != "completed":
1244
+ raise _CheckFailed(f"run {found['id']} still running: wait for it with ci-watch --run first", "pending")
1245
+ if conclusion != "success":
1246
+ raise _CheckFailed(f"run {found['id']} concluded {conclusion}")
1247
+ want = deploy.get("jobs")
1248
+ want_ok = isinstance(want, int) and not isinstance(want, bool) and want >= 1
1249
+ if not docs_only and not want_ok:
1250
+ raise _CheckFailed(f"deploy.jobs must be a job count of 1 or more, not {want!r}")
1251
+ if found["id"] is None:
1252
+ raise _CheckFailed("the push run has no databaseId")
1253
+ owner, name = rid.split("__", 1)
1254
+ out = _call(runner, ["gh", "run", "view", str(found["id"]), "-R", f"{owner}/{name}", "--json", "jobs"],
1255
+ "gh run view")
1256
+ try:
1257
+ jobs = json.loads(out.stdout).get("jobs")
1258
+ except (ValueError, AttributeError):
1259
+ jobs = None
1260
+ if not isinstance(jobs, list):
1261
+ raise _CheckFailed("gh run view did not print a jobs list")
1262
+ succeeded = [j for j in jobs if isinstance(j, dict) and j.get("conclusion") == "success"]
1263
+ done = len(succeeded)
1264
+ if docs_only: # a docs-only push deploys nothing: a run that deployed contradicts the flag
1265
+ deployed = any("deploy" in str(j.get("name", "")).lower() for j in succeeded)
1266
+ if deployed or (want_ok and done >= want):
1267
+ raise _CheckFailed(f"docs-only flag contradicts the run: {done} jobs succeeded")
1268
+ return
1269
+ others = ", ".join(f"{j.get('name')}={j.get('conclusion')}" for j in jobs
1270
+ if isinstance(j, dict) and j.get("conclusion") != "success")
1271
+ detail = f" ({others})" if others else ""
1272
+ if done < want:
1273
+ raise _CheckFailed(f"run {found['id']}: {done} of {want} jobs succeeded{detail}")
1274
+ if len(jobs) != want and done != len(jobs): # extra jobs: every one must succeed
1275
+ raise _CheckFailed(f"run {found['id']}: {len(jobs)} jobs, not {want}, and not all succeeded{detail}")
1276
+
1277
+ def d3() -> None:
1278
+ git = ["git", "-C", str(repo_root)] if repo_root is not None else ["git"]
1279
+ _call(runner, [*git, "fetch", "--tags"], "git fetch --tags")
1280
+ out = _call(runner, [*git, "tag", "--points-at", merge_sha], "git tag --points-at")
1281
+ if not out.stdout.strip():
1282
+ raise _CheckFailed(f"no release tag points at {merge_sha[:12]}")
1283
+
1284
+ def d4() -> None:
1285
+ path = deploy.get("version_path")
1286
+ out = _call(runner, curl(path), f"curl {path}")
1287
+ try:
1288
+ body = json.loads(out.stdout)
1289
+ except ValueError:
1290
+ raise _CheckFailed(f"{path} did not return JSON") from None
1291
+ reported = body.get("sha") if isinstance(body, dict) else None
1292
+ if not isinstance(reported, str) or not reported.lower().startswith(sha):
1293
+ raise _CheckFailed(f"{path} reports sha {reported!r}, not {merge_sha[:12]}")
1294
+
1295
+ def d5() -> None:
1296
+ path = deploy.get("health_path")
1297
+ if _unset(path):
1298
+ raise _CheckFailed("deploy.health_path is not set in the config")
1299
+ null = "NUL" if os.name == "nt" else "/dev/null"
1300
+ out = _call(runner, curl(path, "-o", null, "-w", "%{http_code}"), f"curl {path}")
1301
+ code = out.stdout.strip()
1302
+ if code != "200":
1303
+ raise _CheckFailed(f"{path} returned HTTP {code or 'nothing'}, not 200")
1304
+
1305
+ steps = [("D1", d1, True), ("D2", d2, True),
1306
+ ("D3", d3, not docs_only and not _unset(deploy.get("tag"))),
1307
+ ("D4", d4, not docs_only and not _unset(deploy.get("version_path"))),
1308
+ ("D5", d5, True)]
1309
+ health, reason = "ok", ""
1310
+ for name, step, applies in steps:
1311
+ if not applies:
1312
+ continue
1313
+ try:
1314
+ step()
1315
+ except _CheckFailed as exc:
1316
+ checks[name], health, reason = "fail", exc.health, str(exc)
1317
+ break
1318
+ checks[name] = "pass"
1319
+ if health == "ok":
1320
+ reason = _checks_summary(checks, docs_only)
1321
+ run_id = found.get("id")
1322
+ deploy_check_set(rid, pr, deploy_run=run_id, health=health)
1323
+ return {"checks": checks, "run": run_id, "health": health, "reason": reason}
1324
+
1325
+
1326
+ def _deploy_root(config_path_arg: str) -> Path:
1327
+ """`--config-path` names the config file or the repo root; return the repo root."""
1328
+ given = Path(config_path_arg)
1329
+ if given.is_dir():
1330
+ return given
1331
+ root = given.resolve().parent.parent
1332
+ if config_path(root).resolve() != given.resolve():
1333
+ raise ValueError(f"--config-path must be a repo root or its .caddis/coordinator.md: {given}")
1334
+ return root
1335
+
1336
+
1337
+ def _claude_agents() -> list[dict]:
1338
+ """Run `claude agents --json` and return its list. Raise OSError or ValueError on failure."""
1339
+ claude = shutil.which("claude") # on Windows this finds claude.CMD
1340
+ if not claude:
1341
+ raise OSError("claude not found on PATH")
1342
+ try:
1343
+ result = _run([claude, "agents", "--json"])
1344
+ except subprocess.TimeoutExpired as exc:
1345
+ raise OSError(f"claude agents --json timed out after {_TIMEOUT} s") from exc
1346
+ if result.returncode != 0:
1347
+ detail = " ".join((result.stderr or result.stdout or "").split()) or f"exit {result.returncode}"
1348
+ raise OSError(f"claude agents --json failed: {detail}")
1349
+ try:
1350
+ value = json.loads(result.stdout)
1351
+ except ValueError as exc:
1352
+ raise ValueError("claude agents --json did not print JSON") from exc
1353
+ if not isinstance(value, list):
1354
+ raise ValueError("claude agents --json did not print a JSON list")
1355
+ return value
1356
+
1357
+
902
1358
  def _waits_on(text: str) -> list[dict]:
903
1359
  try:
904
1360
  value = json.loads(text)
@@ -932,6 +1388,16 @@ def _parser() -> argparse.ArgumentParser:
932
1388
  p = command("repo-id", repo=False)
933
1389
  p.add_argument("--path", default=".", help="a folder inside the checkout (default: current folder)")
934
1390
 
1391
+ config = sub.add_parser("config").add_subparsers(dest="config_command", required=True, parser_class=_Parser)
1392
+ for name in ("show", "init"):
1393
+ p = command(name, config, repo=False)
1394
+ p.add_argument("--path", default=".", help="the repo root (default: current folder)")
1395
+
1396
+ p = command("session", repo=False)
1397
+ which = p.add_mutually_exclusive_group(required=True)
1398
+ which.add_argument("--session-id", help="the Claude Code sessionId to find")
1399
+ which.add_argument("--name", help="the Claude Code session name to find")
1400
+
935
1401
  lock = sub.add_parser("lock").add_subparsers(dest="lock_command", required=True, parser_class=_Parser)
936
1402
  p = command("acquire", lock)
937
1403
  p.add_argument("--pid", type=int, required=True)
@@ -942,13 +1408,17 @@ def _parser() -> argparse.ArgumentParser:
942
1408
 
943
1409
  command("heartbeat")
944
1410
  command("intake")
1411
+ p = command("watch")
1412
+ p.add_argument("--pid", type=int, required=True, help="the coordinator session pid that holds the lock")
1413
+ p.add_argument("--interval", type=float, default=30.0, help="seconds between cycles (default 30)")
1414
+ p.add_argument("--max-cycles", type=int, help="stop after this many cycles (for tests)")
945
1415
  command("state")
946
1416
 
947
1417
  queue = sub.add_parser("queue").add_subparsers(dest="queue_command", required=True, parser_class=_Parser)
948
1418
  p = command("add", queue)
949
1419
  p.add_argument("--pr", type=int, required=True)
950
1420
  p.add_argument("--head-sha", required=True)
951
- p.add_argument("--title", required=True)
1421
+ p.add_argument("--title", help="the PR title (default: read from gh; `PR #<n>` when gh fails)")
952
1422
  p = command("set", queue)
953
1423
  p.add_argument("--pr", type=int, required=True)
954
1424
  p.add_argument("--state", choices=QUEUE_STATES)
@@ -970,6 +1440,12 @@ def _parser() -> argparse.ArgumentParser:
970
1440
  p.add_argument("--run", type=int, help="the deploy run id")
971
1441
  p.add_argument("--health", choices=DEPLOY_HEALTH)
972
1442
 
1443
+ p = command("deploy-verify")
1444
+ p.add_argument("--pr", type=int, required=True)
1445
+ p.add_argument("--merge-sha", required=True, help="the full merge commit SHA")
1446
+ p.add_argument("--config-path", required=True, help="the repo root, or its .caddis/coordinator.md")
1447
+ p.add_argument("--docs-only", action="store_true", help="a docs-only merge: D1, D2 and D5 only")
1448
+
973
1449
  ledger = sub.add_parser("ledger").add_subparsers(dest="ledger_command", required=True, parser_class=_Parser)
974
1450
  p = command("record", ledger)
975
1451
  p.add_argument("--what", required=True)
@@ -990,6 +1466,18 @@ def _dispatch(args: argparse.Namespace) -> int:
990
1466
  rid = repo_id_for(Path(args.path))
991
1467
  _emit(args, {"repo": rid}, rid)
992
1468
  return EXIT_OK
1469
+ if args.command == "session":
1470
+ found = session_lookup(_claude_agents(), session_id=args.session_id, name=args.name)
1471
+ _emit(args, found, f"pid {found['pid']} ({found['name']})")
1472
+ return EXIT_OK
1473
+ if args.command == "config":
1474
+ if args.config_command == "init":
1475
+ path = init_config(Path(args.path))
1476
+ _emit(args, {"path": str(path)}, str(path))
1477
+ else:
1478
+ loaded = load_config(Path(args.path))
1479
+ _emit(args, loaded, json.dumps(loaded, indent=2))
1480
+ return EXIT_OK
993
1481
  rid = args.repo or repo_id_for(Path.cwd())
994
1482
  _repo_dir(rid) # refuse a bad id before anything is written
995
1483
 
@@ -1012,6 +1500,8 @@ def _dispatch(args: argparse.Namespace) -> int:
1012
1500
  heartbeat(rid)
1013
1501
  _emit(args, {"ok": True}, "ok")
1014
1502
  return EXIT_OK
1503
+ if args.command == "watch":
1504
+ return watch(rid, args.pid, args.interval, args.max_cycles)
1015
1505
  if args.command == "intake":
1016
1506
  results = intake(rid, datetime.datetime.now(datetime.timezone.utc), _gh_head_of(rid))
1017
1507
  lines = [f"{r['id']}: {r['result']} {r['reason']}".rstrip() for r in results]
@@ -1030,13 +1520,20 @@ def _dispatch(args: argparse.Namespace) -> int:
1030
1520
  item = queue_next(state)
1031
1521
  _emit(args, {"next": item}, f"PR #{item['pr']}" if item else "none")
1032
1522
  return EXIT_OK
1523
+ if args.command == "deploy-verify":
1524
+ root = _deploy_root(args.config_path)
1525
+ out = deploy_verify(rid, load_config(root), args.pr, args.merge_sha, docs_only=args.docs_only,
1526
+ runner=_deploy_run, repo_root=root)
1527
+ marks = " ".join(f"{name}={mark}" for name, mark in out["checks"].items())
1528
+ _emit(args, out, f"{out['health']}: {marks} ({out['reason']})")
1529
+ return EXIT_OK if out["health"] == "ok" else EXIT_ERROR
1033
1530
  if args.command == "deploy-check":
1034
1531
  deploy_check_set(rid, args.pr, deploy_run=args.run, health=args.health)
1035
1532
  elif args.command == "ledger":
1036
1533
  ledger_record(rid, what=args.what, decision=args.decision, by=args.by, reason=args.reason,
1037
1534
  window=args.window, request_id=args.request_id)
1038
1535
  elif args.queue_command == "add":
1039
- queue_add(rid, args.pr, args.head_sha, args.title)
1536
+ queue_add(rid, args.pr, args.head_sha, args.title if args.title is not None else _gh_title(rid, args.pr))
1040
1537
  elif args.queue_command == "set":
1041
1538
  queue_set(rid, args.pr, state=args.state, head_sha=args.head_sha, why_waiting=args.why_waiting,
1042
1539
  waits_on=args.waits_on)
@@ -1060,8 +1557,17 @@ def main(argv: list[str] | None = None) -> int:
1060
1557
  args = parser.parse_args(argv)
1061
1558
  if args.command == "deploy-check" and args.run is None and args.health is None:
1062
1559
  parser.error("deploy-check set needs --run or --health")
1560
+ if args.command == "deploy-verify" and not _SHA.fullmatch(args.merge_sha):
1561
+ parser.error("deploy-verify --merge-sha must be a full 40-character SHA")
1063
1562
  if args.command == "lock" and args.lock_command == "acquire" and args.pid <= 0:
1064
1563
  parser.error("lock acquire --pid must be a positive process id")
1564
+ if args.command == "watch":
1565
+ if args.pid <= 0:
1566
+ parser.error("watch --pid must be a positive process id")
1567
+ if not 1 <= args.interval <= 86400: # also refuses NaN and inf (time.sleep(inf) raises)
1568
+ parser.error("watch --interval must be 1 to 86400 seconds")
1569
+ if args.max_cycles is not None and args.max_cycles <= 0:
1570
+ parser.error("watch --max-cycles must be 1 or more")
1065
1571
  except _UsageError as exc:
1066
1572
  print(f"usage error: {exc}", file=sys.stderr)
1067
1573
  return EXIT_USAGE
@@ -0,0 +1,91 @@
1
+ # Coordinator config
2
+
3
+ This file is the per-repo config for `/caddis:coordinate`. The coordinator session reads the ONE fenced
4
+ `json` block below. The prose around it is for people.
5
+
6
+ How to fill it:
7
+
8
+ - Replace every `"<fill: ...>"` string with the real value. The coordinator refuses to start while any
9
+ `<fill:` string is left (`caddis_coordinate.py config show` lists them).
10
+ - Keep every key. A missing key also stops the coordinator.
11
+ - Put no host names and no secrets here. The production base URL comes from the environment variable that
12
+ `deploy.base_url_env` names.
13
+
14
+ What the keys mean:
15
+
16
+ | Key | Meaning |
17
+ |---|---|
18
+ | `schema` | The config format version. Keep `1`. |
19
+ | `default_branch` | The branch that PRs merge into. |
20
+ | `merge_method` | `squash`, `merge` or `rebase`. Match the repo's branch protection (linear history needs `squash` or `rebase`). |
21
+ | `required_checks` | The required status checks, as branch protection names them. A fallback only: the coordinator reads branch protection first and tells the owner on a mismatch. |
22
+ | `not_required` | Checks that may fail or skip on a PR without blocking the merge. |
23
+ | `docs_only.signal` | The CI job that decides if a change is docs-only (the other required checks then conclude `skipped`). |
24
+ | `docs_only.deploy_check` | The deploy checks for a docs-only merge. A docs-only push deploys nothing. |
25
+ | `main_workflow` | The name of the CI workflow that runs on the default branch. No merge while it runs. |
26
+ | `reviews.count` | How many reviews a PR needs before it is ready. |
27
+ | `reviews.stop_on` | Which review findings stop a merge. `blocking`: only blocking findings. |
28
+ | `peer_ack.warning_min` | Minutes between the merge warning and the merge. |
29
+ | `peer_ack.ack_file` | Where each peer writes its ack: `{head, acked[], at}`. An ack older than 30 minutes does not count. |
30
+ | `deploy.event` | The event that starts the deploy run (for example `push`). |
31
+ | `deploy.jobs` | The number of jobs in the deploy run. |
32
+ | `deploy.tag` | The release tag scheme. With `calver`, the tag must point at the merge SHA. |
33
+ | `deploy.base_url_env` | The environment variable that holds the production base URL on this machine. |
34
+ | `deploy.auth` | How the checks authenticate to production. |
35
+ | `deploy.version_path` | The JSON endpoint whose `sha` field must equal the merge SHA after the deploy. |
36
+ | `deploy.health_path` | The endpoint that must answer 200 after the deploy. |
37
+ | `deploy.never_health_path` | A path that looks healthy even when the API is dead. Never used as a health check. |
38
+ | `deploy.render_check` | How the PR's session checks a served page: 0 console errors and one value checked against its source. |
39
+ | `deploy.wait` | How to wait for the deploy run. No fixed sleep. |
40
+ | `owner_reserved` | Changes that only the owner may approve. |
41
+ | `stall.busy_silent_min` | Minutes a busy session may stay silent before the coordinator asks. |
42
+ | `stall.ready_no_ready_msg_min` | Minutes a green PR may wait for its session's ready message. |
43
+ | `quiet.coordination` | Quiet hours for merges (`none`: no quiet hours). |
44
+ | `quiet.timing_checks` | When timing checks may run. |
45
+
46
+ ```json
47
+ {
48
+ "schema": 1,
49
+ "default_branch": "<fill: the branch that PRs merge into, for example main>",
50
+ "merge_method": "squash",
51
+ "required_checks": [
52
+ "<fill: one required status check as branch protection names it, for example ci / lint_and_test; one string per check>"
53
+ ],
54
+ "not_required": [
55
+ "<fill: one check that may fail or skip on a PR, for example notify; one string per check, or an empty list>"
56
+ ],
57
+ "docs_only": {
58
+ "signal": "<fill: name of the CI job that decides if a change is docs-only>",
59
+ "deploy_check": ["push_run", "health"]
60
+ },
61
+ "main_workflow": "<fill: name of the CI workflow that runs on the default branch>",
62
+ "reviews": {"count": 2, "stop_on": "blocking"},
63
+ "peer_ack": {
64
+ "warning_min": 2,
65
+ "ack_file": "~/.claude/merge-acks/<repo>/pr-<n>.json"
66
+ },
67
+ "deploy": {
68
+ "event": "push",
69
+ "jobs": "<fill: number of jobs in the deploy run>",
70
+ "tag": "<fill: release tag scheme, for example calver, or none>",
71
+ "base_url_env": "<fill: name of the environment variable that holds the production base URL>",
72
+ "auth": "<fill: how the checks authenticate to production, for example none>",
73
+ "version_path": "<fill: path of the JSON endpoint whose sha field is the deployed commit>",
74
+ "health_path": "<fill: path of the endpoint that must answer 200 after the deploy>",
75
+ "never_health_path": "<fill: path that looks healthy even when the API is dead, or null>",
76
+ "render_check": "<fill: how the PR's session checks a served page, for example a headless browser>",
77
+ "wait": "gh run watch"
78
+ },
79
+ "owner_reserved": [
80
+ "schema change or data write on prod",
81
+ "permission change",
82
+ "any open blocking review finding",
83
+ "<fill: a rule the owner reserved by name; one string per rule, or delete this line>"
84
+ ],
85
+ "stall": {"busy_silent_min": 45, "ready_no_ready_msg_min": 20},
86
+ "quiet": {
87
+ "coordination": "none",
88
+ "timing_checks": "<fill: when timing checks may run, for example quiet window only>"
89
+ }
90
+ }
91
+ ```
@@ -80,6 +80,7 @@
80
80
  | Add Rules | `add-rules/` | Create canonical rules for ANY folder — write <folder>/AGENTS.md (from the folder template) + a <folder>/CLAUDE.md @import shim. For folders the stack-map generator doesn't cover. |
81
81
  | Catchup | `catchup/` | Where were we? A quick list of what this session was doing and what is still open |
82
82
  | Cloud Phases | `cloud-phases/` | Run plan phases in Claude Code cloud sessions — write one prompt per phase, push, hand out paste lines, then take the PRs back |
83
+ | Coordinate | `coordinate/` | Run this session as the repo's coordinator — one merge queue for all sessions, one merge at a time, a deploy check after each merge, routine approvals in the ledger. |
83
84
  | Decide | `decide/` | Record a decision in one step — .caddis/decisions/<date>-<slug>.md |
84
85
  | Digress | `digress/` | Park the current workstream on the stack and switch to a new task — without losing the original |
85
86
  | Excalidraw Db | `excalidraw-db/` | Turn a SQL artifact into an Excalidraw diagram for a design review / ARB pack / slide — higher-level, drag-the-boxes format |
@@ -0,0 +1,339 @@
1
+ ---
2
+ name: coordinate
3
+ description: Run this session as the repo's coordinator — one merge queue for all sessions, one merge at a time, a deploy check after each merge, routine approvals in the ledger.
4
+ ---
5
+
6
+ # /coordinate — the coordinator session for one repo
7
+
8
+ This session becomes the ONE coordinator of the current repo. It keeps the merge queue, merges one PR at
9
+ a time, checks each deploy and approves routine gates. Other sessions send it messages; they do not merge
10
+ on their own.
11
+
12
+ The behaviour is in `.caddis/plans/caddis-coordinator.design.md` (owner-approved, 23 Oct 2026). This
13
+ command gives the steps and names the design section for each. When this command and the design differ,
14
+ the design is correct: stop and tell the owner.
15
+
16
+ Argument: **$ARGUMENTS**
17
+ - `start` (or empty): do **1. Start**, then **2. Merge mode**, then run **3. The loop**.
18
+ - `status`: find `<account>` and `<R>` (steps 1.1 and 1.2), then print `state` (step 1.8) and the
19
+ `queue next` result. Change nothing.
20
+ - `stop`: do **6. Stop**.
21
+
22
+ ## How to call the script
23
+
24
+ - Run these commands with the Bash tool: the quoting and the `GH_TOKEN=$(...)` forms are POSIX shell.
25
+ - Every queue, lock and ledger change goes through
26
+ `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" <subcommand> ... --json`.
27
+ Never edit `coordinator.json`, the lock file or the ledger by hand.
28
+ - Exit codes: **0** ok; **1** error (one line on stderr); **2** usage error (fix the call); **3** the
29
+ lock is refused.
30
+ - `<R>` is the repo id, `<P>` is this session's pid, `<N>` is this session's name, `<S>` is this
31
+ session's id. Step 1 finds them once. Pass `--repo <R>` to every call that takes it.
32
+ - `<account>` is the GitHub account of this repo (step 1.1 finds it first).
33
+ Put `GH_TOKEN=$(gh auth token -u <account>)` in front of every command that calls GitHub: `gh`,
34
+ `repo-id`, `ci-watch`, `watch`, `intake`, `queue add` and `deploy-verify`. Never run `gh auth switch`.
35
+ - Free text (`--title`, `--why-waiting`, `--reason`, `--what`): always put free text in single quotes, and
36
+ write each `'` inside it as `'\''`. Never use double quotes: the shell expands `$` and backticks in them.
37
+ Example: `--reason 'the owner'\''s words'`.
38
+ - `<owner/name>` is the GitHub repo of `<R>` (`acme__shop` is `acme/shop`).
39
+
40
+ ## 1. Start (design 0.1)
41
+
42
+ 1. Find `<account>`, the GitHub account of this repo: the repo's `AGENTS.md` names it; else ask the
43
+ owner. Do this first: `repo-id` already calls GitHub.
44
+ 2. Find the repo id:
45
+ ```
46
+ GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" repo-id --json
47
+ ```
48
+ Keep `repo` as `<R>`.
49
+ 3. Find `<S>`: the session start output prints `[caddis] this session's id: <id>`. Never guess it. If
50
+ you cannot find that line, stop and ask the owner for the id.
51
+ 4. Find this session's pid and name:
52
+ ```
53
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" session --session-id <S> --json
54
+ ```
55
+ Keep `pid` as `<P>` and `name` as `<N>`. Exit 1 with "two interactive sessions share" means two
56
+ copies of this session run (design 8.6): stop, tell the owner both pids, and wait. Any other exit 1:
57
+ stop and report the line.
58
+ 5. Take the lock. This also creates or claims the repo's state:
59
+ ```
60
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" lock acquire --repo <R> --pid <P> --session-id <S> --name <N> --json
61
+ ```
62
+ Exit 3: another coordinator is live. Stop and tell the owner the holder's name and pid from `holder`.
63
+ 6. Read the config:
64
+ ```
65
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" config show --path <repo root> --json
66
+ ```
67
+ Exit 1: stop the start-up and tell the owner the problem (missing keys, `<fill:` placeholders, no
68
+ file). With no file, offer to write the template:
69
+ ```
70
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" config init --path <repo root> --json
71
+ ```
72
+ Release the lock (section 6 step 2) before you stop.
73
+ 7. Compare `required_checks` with branch protection (design 0.1 step 3):
74
+ `GH_TOKEN=$(gh auth token -u <account>) gh api repos/<owner>/<name>/branches/<default branch>/protection`.
75
+ On a mismatch, use the branch-protection list and tell the owner. The call can return 404 (this
76
+ account has no admin rights), and it does not show checks that come from rulesets. Then use the config
77
+ list and tell the owner.
78
+ 8. Read the state:
79
+ ```
80
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" state --repo <R> --json
81
+ ```
82
+ Also read the coordinator relay, `claude agents --json` (or `ListAgents`) and
83
+ `caddis_roadmap.py status` (design 0.1 step 4).
84
+ 9. Finish the work that a crash left (design 8.5, design 4):
85
+ - If `merging_now` is set, find out what happened to that PR first:
86
+ `gh pr view <n> --json state,mergedAt,mergeCommit`. `MERGED`: take `mergeCommit.oid` as the merge
87
+ SHA and do loop steps 3.15 to 3.19 for it. Not merged: first send the PR's session `hold <PR>` (a
88
+ `go` from before the restart is void). Then delete the ack file (path in loop step 3.13), run
89
+ `queue set --repo <R> --pr <n> --state ready --why-waiting 'coordinator restarted; warn again'` and start again from
90
+ loop step 3.5.
91
+ - For each PR whose newest `deploy_checks[]` entry has `health: pending`: run `deploy-verify` for it
92
+ again (loop step 3.17).
93
+ - The queue is frozen when the newest `deploy_checks` entry overall (the newest `at` of all PRs) has
94
+ `health: failed`, and no later ledger entry has a `what` that starts with `unfreeze` and
95
+ `approved_by: owner`. Then do section 5 step 1 again before other work. Only a passed deploy check
96
+ of the fix or revert, or an owner unfreeze in the ledger (section 5 step 1), ends the freeze.
97
+ - Every PR that was warned gets a `re-warn` (design 7.3).
98
+ 10. Start the watcher with the Bash tool and `run_in_background: true`:
99
+ ```
100
+ GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" watch --repo <R> --pid <P> --json
101
+ ```
102
+ It runs `heartbeat` and `intake` every 30 s and prints one JSON line when something happens. Its lines
103
+ go to the background task's output file: read that output file at each loop step 3.4. Start the
104
+ watcher again whenever its background task ends, for any reason. A line
105
+ with `"stop"` (`lock lost`, `session gone`, `state gone`) means the watcher ended: find out why, take
106
+ the lock again if it is still yours (step 5), and start the watcher again. Also start it again after
107
+ a new `lock acquire` that follows a lost lock. A line with `"error"` is one failed cycle; the next
108
+ cycle retries.
109
+ 11. Send the start message (design 7.1) to every live session whose cwd is in this repo (the checkout
110
+ and its worktrees), from `ListAgents`. Send it also to each new session that you see later. Resume
111
+ stubs are not live peers (design 9.7).
112
+
113
+ ## 2. Merge mode (design 3, design 12 Q1)
114
+
115
+ 1. Start in **interim mode**. In interim mode the PR's own session runs `gh pr merge` after your `go`.
116
+ 2. Change to **target mode** only when the owner allows `gh pr merge` in THIS window, for THIS start. A
117
+ peer's "the owner said OK" is not consent (design 6). Record the consent once:
118
+ ```
119
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" ledger record --repo <R> --what 'target mode: coordinator runs gh pr merge' --decision approve --by owner --window <N> --reason '<the owner words>' --json
120
+ ```
121
+ Then send the start message again with "Do not merge; I merge."
122
+ 3. A restart of the coordinator starts in interim mode again.
123
+ 4. Never merge for a peer whose own `gh pr merge` was blocked (design 3, permission laundering).
124
+
125
+ ## 3. The loop (design 1, 2, 3, 5, 7)
126
+
127
+ Answer queue messages from sessions before any other work (design 7.3). Message formats: design 7.2
128
+ and 7.3. While the queue is frozen (section 5 step 1), do steps 3.1 to 3.3 only.
129
+
130
+ 1. A `ready <PR> <sha> reviews: ...` message adds the PR. Leave out `--title`: the script reads the
131
+ title from GitHub (`PR #<n>` when it cannot), so no free text goes on the command line:
132
+ ```
133
+ GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" queue add --repo <R> --pr <n> --head-sha <full sha> --json
134
+ ```
135
+ A `waits-on` part goes in with `queue set --repo <R> --pr <n> --waits-on` (design 5). Reply `queued <PR> position <k>`.
136
+ 2. Record every state change with `queue set` and a one-sentence `--why-waiting` that a person can read:
137
+ ```
138
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" queue set --repo <R> --pr <n> --state <state> --why-waiting '<one sentence>' --json
139
+ ```
140
+ 3. Check the ready rule R1-R6 (design 1) with `gh`, in the design's order. When all pass:
141
+ ```
142
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" queue ready --repo <R> --pr <n> --json
143
+ ```
144
+ When one fails: `queue set --repo <R> --pr <n> --state blocked` and send `blocked <PR>: <reason>`. An owner-only class
145
+ (R4) goes to the owner (section 4).
146
+ 4. Take the next item. Never sort the queue yourself:
147
+ ```
148
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" queue next --repo <R> --json
149
+ ```
150
+ `"next": null`: nothing to merge; wait for the next message or watcher line.
151
+ 5. If the PR is `BEHIND`: `gh pr update-branch <n>`. Then cancel the runs on the old head: list them
152
+ with `gh run list -R <owner/name> --commit <old sha> --json databaseId,status`, and cancel each run that
153
+ is not `completed` with `gh run cancel <id> -R <owner/name>`. Then
154
+ `queue set --repo <R> --pr <n> --state ci --head-sha <new sha>` (design 3 step 2). Update only the PR at the front.
155
+ 6. Wait for CI outside the model. Run `ci-watch` with the Bash tool and `run_in_background: true`, and
156
+ act on its exit notice:
157
+ ```
158
+ GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --pr <n>
159
+ ```
160
+ Act only on `RESULT: pass`. The `HEAD:` line is the `<watched sha>`. Exit 1: CI is red (design 8.1;
161
+ one re-run for a flake, design 8.2). Exit 3: run `ci-watch` again; if it stays 3, stop and report.
162
+ Exit 4: a usage error; stop and report.
163
+ 7. If the item is in `ci` (after step 3.5 or a new head), check R1 and R2 again at the watched head,
164
+ then run `queue ready` (step 3.3). Do not warn for an item that is not `ready`.
165
+ 8. Check that the default branch is idle (M1): `gh run list -R <owner/name> --branch <default branch> --workflow '<main_workflow>' --json status,databaseId`
166
+ lists no `in_progress` or `queued` run. If one runs, wait for it with
167
+ `GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --run <id> --repo <owner/name>`,
168
+ in the background (`run_in_background: true`). When its `RESULT:` line comes, check M1 again.
169
+ 9. Send `merge-warning <PR> <watched sha> in 2 min. Reply ack <PR> <sha> or hold.` to every live peer
170
+ (design 7.3), and `queue set --repo <R> --pr <n> --state merging`. Send `re-warn <PR> <sha>` after any coordinator
171
+ restart and after any new head on a warned PR. Old acks do not count.
172
+ 10. Collect the acks (M2). Wait the full `peer_ack.warning_min` minutes after the warning, even when every
173
+ ack comes early. For that wait, run `sleep 120` (`peer_ack.warning_min` x 60 s) with the Bash tool
174
+ and `run_in_background: true`, or use the Monitor tool, and act on its exit notice; never a
175
+ foreground sleep. Answer messages while it runs. No ack after 5 min: one nudge. After 10 min: tell
176
+ the owner. A peer that waits on the owner cannot answer: tell the owner "ack needed from <name>; it
177
+ waits on you". Do not write the ack file yet (step 3.13).
178
+ 11. Check again that the default branch is idle (M1, the same `gh run list` as step 3.8), just before the
179
+ merge. If a run started, wait for it (step 3.8). If the warning went out more than 30 min ago, warn
180
+ again (step 3.9).
181
+ 12. Read the state again just before the merge, and read the item's `held` again:
182
+ ```
183
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" state --repo <R> --json
184
+ ```
185
+ Then check the PR itself: `gh pr view <n> --json state,headRefOid,mergeStateStatus,mergeCommit`.
186
+ - `state` is `MERGED` (the PR's session merged it already): take `mergeCommit.oid` as the merge SHA
187
+ and go straight to step 3.15.
188
+ - Else `headRefOid` must be the `HEAD:` of the `ci-watch` run that printed `RESULT: pass`, and
189
+ `mergeStateStatus` must be `CLEAN`.
190
+ If any check fails, do not merge now: first leave `merging`, then delete the ack file (step 3.13) if
191
+ it exists, then go back to step 3.4:
192
+ - `held: true` or a peer `hold`: `queue set --repo <R> --pr <n> --state ready --why-waiting '<who holds it and why>'`.
193
+ - a new head: `queue set --repo <R> --pr <n> --state ci --head-sha <new sha> --why-waiting 'new head; CI runs again'`.
194
+ - not `CLEAN`: `queue set --repo <R> --pr <n> --state ci --why-waiting 'merge state <mergeStateStatus>; checks run again'`.
195
+ If you merge now, decide docs-only (step 3.17 needs it) from evidence only, and record the evidence
196
+ while the item is still in the queue:
197
+ `queue set --repo <R> --pr <n> --why-waiting 'merging; docs-only: <yes|no> from <the evidence>'`. The evidence is the
198
+ `docs_only.signal` job result in the PR's CI run, or the file list from `gh pr view <n> --json files`.
199
+ 13. Write the ack file that the merge hook reads. This is the LAST action before `go` (interim mode) or
200
+ `gh pr merge` (target mode): after the M1 check (step 3.11) and the `held` and PR checks (step 3.12). The
201
+ path is `~/.claude/merge-acks/<checkout folder name>/pr-<n>.json`. The checkout folder name is the
202
+ last path part of the first `worktree` line of `git worktree list --porcelain` (the repo's main
203
+ checkout), NOT the repo id `<R>`. The content is exactly:
204
+ ```
205
+ {"pr": <n>, "head": "<full sha>", "acked": [<names>], "skipped_waiting": [<names waiting on the owner>], "at": "<UTC ISO 8601 with Z>"}
206
+ ```
207
+ `acked` lists every peer that sent `ack <PR> <sha>` for this head, by its session name. An unnamed
208
+ peer is listed under the first 8 characters of its session id (the hook uses the same name). A peer
209
+ that waits on the owner goes under `skipped_waiting`, never under `acked`. Interim mode: also put
210
+ your own name `<N>` in `acked` (the PR's session runs the merge, and its hook counts you as a peer).
211
+ Target mode: you run the merge, so `acked` must list the PR's session too. The hook refuses an ack
212
+ file older than 30 min.
213
+ 14. Merge the watched head (design 3 step 6), checked in step 3.12. The watched head is the `HEAD:` of the
214
+ `caddis_lanes.py ci-watch` run (step 3.6) that printed `RESULT: pass`; never merge without it.
215
+ Do only the merge or the `go`:
216
+ - Target mode: `GH_TOKEN=$(gh auth token -u <account>) gh pr merge <n> --<merge_method> --match-head-commit <watched sha>`.
217
+ The merge SHA is `mergeCommit.oid` from `gh pr view <n> --json state,mergeCommit`.
218
+ - Interim mode: send `go <PR> <watched sha>`; the PR's session merges with the same command. Then
219
+ poll with a Bash command in `run_in_background`: `gh pr view <n> --json state,mergeCommit` once a
220
+ minute, for at most 15 min, until `state` is `MERGED`. Take `mergeCommit.oid` as the merge SHA; the
221
+ session's `merged` reply only tells you to look. No merge after 15 min: ask the PR's session once.
222
+ After 5 more min, tell the owner, delete the ack file, and leave `merging` with
223
+ `queue set --repo <R> --pr <n> --state ready --why-waiting 'go sent; no merge from <session>'`.
224
+ If `--match-head-commit` fails, the head moved: delete the ack file,
225
+ `queue set --repo <R> --pr <n> --state ci --head-sha <new sha>`, and go back to step 3.6. Never add the `--admin` flag to
226
+ a merge.
227
+ 15. Record the merge as soon as the merge SHA is known, before any wait. This removes the item and adds
228
+ a `pending` deploy check:
229
+ ```
230
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" queue merged --repo <R> --pr <n> --merge-sha <full merge sha> --json
231
+ ```
232
+ Exit 0 with `already recorded` is success (a retry). Then delete the ack file: the item has left
233
+ `merging`.
234
+ 16. Find the push run for the merge SHA, then wait until it ends. Run each wait with the Bash tool and
235
+ `run_in_background: true`, and act on its exit notice.
236
+ - Find it once a minute, for at most 10 min:
237
+ `gh run list -R <owner/name> --branch <default branch> --event push --workflow '<main_workflow>' --json databaseId,headSha`,
238
+ and match `headSha` to the merge SHA. No push run after 10 min: go to step 3.17 now;
239
+ `deploy-verify` records D1 failed.
240
+ - Wait for it:
241
+ `GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --run <id> --repo <owner/name>`.
242
+ It prints a `RESULT:` line when the run ends (pass or fail); then go to step 3.17, which reads the
243
+ result itself. Exit 3: run it again. Exit 4: a usage error; stop and report.
244
+ 17. Run the deploy check D1-D5 (design 4). Add `--docs-only` only when the evidence that step 3.12
245
+ recorded in `--why-waiting` says docs-only: the `docs_only.signal` job result in the PR's CI run, or
246
+ the file list from `gh pr view <n> --json files`. Never decide it from the title or a session's word.
247
+ When that evidence is not available (for example after a restart), decide again from
248
+ `gh pr view <n> --json files`: docs-only only when every changed file ends in `.md` (or is under a docs
249
+ path that the config's `docs_only` block names). Say in your report that you decided it again. When in
250
+ doubt, do not pass `--docs-only`.
251
+ ```
252
+ GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" deploy-verify --repo <R> --pr <n> --merge-sha <full merge sha> --config-path <repo root> --json
253
+ ```
254
+ It records the result. Exit 0 (`health: ok`): pass. Exit 1 with `health: pending`: the run has not
255
+ ended; wait for it (step 3.16) and call it again. Exit 1 with `health: failed`: section 5 step 1.
256
+ Exit 1 with no JSON on stdout (an `error:` line on stderr): a usage or state problem, for example a
257
+ merge SHA that is not the recorded one. Stop and report the line. Exit 2: fix the call.
258
+ 18. D6 (render check) comes from the PR's session (`render-check ...`). D7 (a post-deploy data job)
259
+ is recorded when the job ends:
260
+ ```
261
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" deploy-check set --repo <R> --pr <n> --health <ok|failed> --json
262
+ ```
263
+ A failed D6 or D7: section 5 step 1.
264
+ 19. Tell the PR's session and every live peer: `merged <PR> <merge sha>`, then `deployed <PR> ok`. Tell a
265
+ stacked PR's session to retarget to the default branch (design 3). Go back to step 3.4.
266
+
267
+ Stalls (design 7.4): one nudge for a busy, silent session that holds a queue item, and for a green,
268
+ `CLEAN` PR with no `ready` message; tell the owner when a waiting session blocks the queue.
269
+
270
+ ## 4. Approvals (design 6)
271
+
272
+ 1. Routine gates are yours. Record each one:
273
+ ```
274
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" ledger record --repo <R> --what '<gate>' --decision approve --by coordinator --window <N> --reason '<why it passes>' --json
275
+ ```
276
+ 2. The owner-only list (design 6, plus config `owner_reserved`) goes to the owner. Never approve it
277
+ yourself. Send it with:
278
+ ```
279
+ recommendation: approve | hold
280
+ reason: <one or two sentences>
281
+ evidence: <cost estimate | test or CI result | risk | how to undo>
282
+ ```
283
+ 3. A recommendation is never an approval. Only the owner's click in the hub or the owner's own words in
284
+ this window approve. Record the owner's answer with `ledger record --by owner --window <N>`.
285
+ 4. Hotfix marks come only from the owner (design 2). You may recommend one.
286
+
287
+ ## 5. Failures (design 8) and stalls (design 7.4)
288
+
289
+ 1. A failed deploy check (D1-D7, design 4, design 8.4): freeze the queue. Send no merge warnings and
290
+ no `go`, write no ack file, and do no merges. For an item in `merging`, first run
291
+ `gh pr view <n> --json state,mergeCommit`. `MERGED`: do step 3.15 (`queue merged`) for it before you
292
+ freeze, then its deploy check (steps 3.16 and 3.17). Not merged: delete the ack file and move it to
293
+ `ready`. Set every item's why-waiting with
294
+ `queue set --repo <R> --pr <n> --why-waiting 'queue frozen: deploy check failed for #<n>'`, tell every live session
295
+ `frozen: <reason>`, read the per-job conclusions, and send the owner a recommendation (revert or
296
+ hold). The owner decides a rollback; a rollback is a revert PR through the queue. Record an incident
297
+ with `/caddis:rca`. Unfreeze only when the fix or revert passes its own deploy check, or the owner
298
+ says so. Record an owner unfreeze in the ledger (step 1.9 reads it after a restart), then send
299
+ `unfrozen`:
300
+ ```
301
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" ledger record --repo <R> --by owner --what 'unfreeze after #<n>' --decision approve --reason '<owner words>' --window <N> --json
302
+ ```
303
+ 2. CI red (design 8.1): `queue set --repo <R> --pr <n> --state blocked` with the failed job's name. One re-run for a flaky
304
+ job (`gh run rerun <id> --failed`, design 8.2).
305
+ 3. A run stuck or a runner suspected (design 8.3): check
306
+ `https://www.githubstatus.com/api/v2/summary.json` first. During a GitHub incident, wait. Never
307
+ restart a runner or a service.
308
+ 4. Duplicate copies (design 8.6) show as `session` exit 1. Tell the owner the other pid. Stop a process
309
+ only when the owner asks in this window, and record that in the ledger.
310
+ 5. A single exit 1 from `heartbeat` or `intake` (a watcher `"error"` line) can be transient. Act only
311
+ when it repeats.
312
+ 6. Pilot notes. D3 runs `git fetch --tags`, which can need git credentials in this session; then D3
313
+ fails. A short gh or curl failure in D1 or D5 records `failed` and freezes the queue. That is the
314
+ safe direction: the owner checks the deploy and unfreezes (step 5.1).
315
+
316
+ ## 6. Stop
317
+
318
+ 1. Stop the watcher: stop its background Bash task.
319
+ 2. Release the lock:
320
+ ```
321
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" lock release --repo <R> --pid <P> --json
322
+ ```
323
+ 3. Send every live session on the repo: `coordinator <N> stopped for <repo>. Do not merge until a coordinator is live again.`
324
+
325
+ ## 7. Rules that never bend
326
+
327
+ - Merge only on `ci-watch` `RESULT: pass` at the watched head, with `--match-head-commit <full sha>`.
328
+ Never merge while a default-branch CI run is in progress.
329
+ - Never write `coordinator.json` by hand; `lock acquire` creates or claims the state. Exit 3 means
330
+ stop. Always pass `--repo <R>`.
331
+ - Use `queue next`; never sort the queue yourself. Read `held` again just before a merge.
332
+ - Write the ack file last, just before `go` or `gh pr merge`. Delete it when the item leaves `merging`.
333
+ - Free text goes in single quotes. Long waits run in the background, never a foreground sleep.
334
+ - `queue merged` exit 0 `already recorded` is success.
335
+ - Liveness is the lock file and `heartbeat_at`, not a peer's word.
336
+ - Owner-only approvals go to the owner. Never self-approve them.
337
+ - No stress tests and no load tests.
338
+ - `GH_TOKEN=$(gh auth token -u <account>)` per repo account. Never `gh auth switch`.
339
+ - Never add the `--admin` flag to a merge. Never force push. Never restart services.
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.157",
2
+ "poolVersion": "1.3.158",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.157",
4
+ "antigravity-plugin": "1.3.158",
5
5
  "antigravity-plugin-extras": "1.3.46",
6
6
  "codex": "unknown"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.71",
3
+ "version": "0.4.72",
4
4
  "description": "Install, update and diagnose the caddis dev harness across every coding agent you use (Claude Code, agy/Antigravity, and more).",
5
5
  "keywords": [
6
6
  "caddis",