@caddis/cli 0.4.71 → 0.4.73

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.
@@ -482,6 +482,17 @@ def render_resume(session: str, session_id: str, workstream: str, prompt: str, r
482
482
  f"{_fenced(shown)}\n{start}\n")
483
483
 
484
484
 
485
+ def render_coordinator_resume(session: str, session_id: str, workstream: str) -> str:
486
+ """The resume block for the repo's coordinator: three lines to type, no prompt and no start line.
487
+
488
+ `/caddis:coordinate start` reads the coordinator's relay and tasks itself, so a pasted prompt would only race it."""
489
+ return (f"=== RESUME ===\nSession: {session} ({session_id})\nWorkstream: {workstream}\n"
490
+ "This session is the repo's coordinator. To resume, type these three lines in this window, one at a time:\n"
491
+ "/clear\n/rename coordinator\n/caddis:coordinate start\n"
492
+ f"Do not paste a resume prompt; /caddis:coordinate start reads .caddis/relay/{workstream}.md "
493
+ "and its tasks itself.\n")
494
+
495
+
485
496
  # ---- CLI --------------------------------------------------------------------------------------
486
497
 
487
498
  def _load_acks(fleet_dir: Path) -> list[Ack]:
@@ -491,6 +502,28 @@ def _load_acks(fleet_dir: Path) -> list[Ack]:
491
502
  return [parse_ack(p.read_text(encoding="utf-8")) for p in sorted(acks_dir.glob("*.md"))]
492
503
 
493
504
 
505
+ def _coordinator_relay_problem(root: Path, name: str) -> str | None:
506
+ """None when `<root>/.caddis/coordinator.md` loads and its `relay` is `name`, else one line that says why not.
507
+
508
+ The coordinator block says `/caddis:coordinate start` reads `.caddis/relay/<name>.md` itself; start reads the
509
+ config's `relay`, so the block is true only when the two are the same name."""
510
+ try:
511
+ import caddis_coordinate
512
+ except ModuleNotFoundError as exc:
513
+ if exc.name != "caddis_coordinate":
514
+ raise
515
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
516
+ import caddis_coordinate
517
+ try:
518
+ relay = caddis_coordinate.load_config(root).get("relay")
519
+ except (OSError, ValueError) as exc: # FileNotFoundError is an OSError
520
+ return f"--coordinator needs a valid .caddis/coordinator.md with relay {name!r}: {' '.join(str(exc).split())}"
521
+ if relay != name:
522
+ return (f"--coordinator: --name {name!r} is not the coordinator config relay {relay!r}"
523
+ if relay else f"--coordinator: .caddis/coordinator.md has no relay (needs relay {name!r})")
524
+ return None
525
+
526
+
494
527
  def _resume_block_cli(args: argparse.Namespace) -> int:
495
528
  try:
496
529
  import caddis_workstreams
@@ -500,15 +533,23 @@ def _resume_block_cli(args: argparse.Namespace) -> int:
500
533
  sys.path.insert(0, str(Path(__file__).resolve().parent))
501
534
  import caddis_workstreams
502
535
  root = args.root.resolve()
536
+ if args.coordinator:
537
+ problem = _coordinator_relay_problem(root, args.name)
538
+ if problem:
539
+ sys.stderr.write(f"resume-block failed: {problem}\n")
540
+ return caddis_exit.BLOCKED
503
541
  try:
504
542
  text, digest = caddis_workstreams.read_relay(root, args.name)
505
543
  if digest is None:
506
544
  raise ResumeError(f"relay {args.name!r} not found")
507
- prompt = resume_prompt(text)
545
+ prompt = "" if args.coordinator else resume_prompt(text)
508
546
  except (ValueError, OSError) as exc: # ResumeError is a ValueError; a bad relay name is one too
509
547
  sys.stderr.write(f"resume-block failed: {exc}\n")
510
548
  return caddis_exit.BLOCKED
511
549
  session, known = session_name(args.session_id, root)
550
+ if args.coordinator:
551
+ print(render_coordinator_resume(session, args.session_id, args.name), end="")
552
+ return caddis_exit.CLEAN
512
553
  role = args.role or (role_of(session) if known else args.name)
513
554
  if not _SAFE_NAME.match(role): # the role goes unquoted into the start line
514
555
  sys.stderr.write(f"resume-block failed: bad role {role!r}\n")
@@ -553,7 +594,11 @@ def main(argv: list[str] | None = None) -> int:
553
594
  p.add_argument("--root", default=".", type=Path)
554
595
  p.add_argument("--role", help="role for the new session's name (default: the session name's role, else the relay name)")
555
596
  p.add_argument("--fleet", action="store_true", help="fleet close: print no start line (the restart sheet has it)")
597
+ p.add_argument("--coordinator", action="store_true",
598
+ help="this session holds the repo's coordinator lock: print the three restart lines, no prompt")
556
599
  args = parser.parse_args(argv)
600
+ if args.cmd == "resume-block" and args.coordinator and args.role:
601
+ parser.error("resume-block: --role cannot go with --coordinator (the coordinator keeps its name)")
557
602
 
558
603
  if args.cmd == "role-of":
559
604
  print(role_of(args.name))
@@ -46,6 +46,15 @@ def _name(value: str) -> str:
46
46
  return value
47
47
 
48
48
 
49
+ def is_plain_name(value: object) -> bool:
50
+ """True for a plain workstream name as typed (no lower-casing, no `branch.` namespace), e.g. `coordinator`."""
51
+ try:
52
+ _name(value) # type: ignore[arg-type]
53
+ except ValueError:
54
+ return False
55
+ return bool(_PLAIN_NAME.fullmatch(value)) # type: ignore[arg-type]
56
+
57
+
49
58
  def _plain_name(value: str) -> str:
50
59
  if not isinstance(value, str):
51
60
  raise TypeError("plan or suggestion must be text")
@@ -0,0 +1,94 @@
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. Only `relay` is optional: delete its line if the
11
+ coordinator has no relay.
12
+ - Put no host names and no secrets here. The production base URL comes from the environment variable that
13
+ `deploy.base_url_env` names.
14
+
15
+ What the keys mean:
16
+
17
+ | Key | Meaning |
18
+ |---|---|
19
+ | `schema` | The config format version. Keep `1`. |
20
+ | `default_branch` | The branch that PRs merge into. |
21
+ | `merge_method` | `squash`, `merge` or `rebase`. Match the repo's branch protection (linear history needs `squash` or `rebase`). |
22
+ | `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. |
23
+ | `not_required` | Checks that may fail or skip on a PR without blocking the merge. |
24
+ | `docs_only.signal` | The CI job that decides if a change is docs-only (the other required checks then conclude `skipped`). |
25
+ | `docs_only.deploy_check` | The deploy checks for a docs-only merge. A docs-only push deploys nothing. |
26
+ | `main_workflow` | The name of the CI workflow that runs on the default branch. No merge while it runs. |
27
+ | `reviews.count` | How many reviews a PR needs before it is ready. |
28
+ | `reviews.stop_on` | Which review findings stop a merge. `blocking`: only blocking findings. |
29
+ | `peer_ack.warning_min` | Minutes between the merge warning and the merge. |
30
+ | `peer_ack.ack_file` | Where each peer writes its ack: `{head, acked[], at}`. An ack older than 30 minutes does not count. |
31
+ | `deploy.event` | The event that starts the deploy run (for example `push`). |
32
+ | `deploy.jobs` | The number of jobs in the deploy run. |
33
+ | `deploy.tag` | The release tag scheme. With `calver`, the tag must point at the merge SHA. |
34
+ | `deploy.base_url_env` | The environment variable that holds the production base URL on this machine. |
35
+ | `deploy.auth` | How the checks authenticate to production. |
36
+ | `deploy.version_path` | The JSON endpoint whose `sha` field must equal the merge SHA after the deploy. |
37
+ | `deploy.health_path` | The endpoint that must answer 200 after the deploy. |
38
+ | `deploy.never_health_path` | A path that looks healthy even when the API is dead. Never used as a health check. |
39
+ | `deploy.render_check` | How the PR's session checks a served page: 0 console errors and one value checked against its source. |
40
+ | `deploy.wait` | How to wait for the deploy run. No fixed sleep. |
41
+ | `owner_reserved` | Changes that only the owner may approve. |
42
+ | `stall.busy_silent_min` | Minutes a busy session may stay silent before the coordinator asks. |
43
+ | `stall.ready_no_ready_msg_min` | Minutes a green PR may wait for its session's ready message. |
44
+ | `quiet.coordination` | Quiet hours for merges (`none`: no quiet hours). |
45
+ | `quiet.timing_checks` | When timing checks may run. |
46
+ | `relay` | The workstream name of the coordinator's relay (optional; the coordinator reads `.caddis/relay/<relay>.md` and its tasks file at start). |
47
+
48
+ ```json
49
+ {
50
+ "schema": 1,
51
+ "default_branch": "<fill: the branch that PRs merge into, for example main>",
52
+ "merge_method": "squash",
53
+ "required_checks": [
54
+ "<fill: one required status check as branch protection names it, for example ci / lint_and_test; one string per check>"
55
+ ],
56
+ "not_required": [
57
+ "<fill: one check that may fail or skip on a PR, for example notify; one string per check, or an empty list>"
58
+ ],
59
+ "docs_only": {
60
+ "signal": "<fill: name of the CI job that decides if a change is docs-only>",
61
+ "deploy_check": ["push_run", "health"]
62
+ },
63
+ "main_workflow": "<fill: name of the CI workflow that runs on the default branch>",
64
+ "reviews": {"count": 2, "stop_on": "blocking"},
65
+ "peer_ack": {
66
+ "warning_min": 2,
67
+ "ack_file": "~/.claude/merge-acks/<repo>/pr-<n>.json"
68
+ },
69
+ "deploy": {
70
+ "event": "push",
71
+ "jobs": "<fill: number of jobs in the deploy run>",
72
+ "tag": "<fill: release tag scheme, for example calver, or none>",
73
+ "base_url_env": "<fill: name of the environment variable that holds the production base URL>",
74
+ "auth": "<fill: how the checks authenticate to production, for example none>",
75
+ "version_path": "<fill: path of the JSON endpoint whose sha field is the deployed commit>",
76
+ "health_path": "<fill: path of the endpoint that must answer 200 after the deploy>",
77
+ "never_health_path": "<fill: path that looks healthy even when the API is dead, or null>",
78
+ "render_check": "<fill: how the PR's session checks a served page, for example a headless browser>",
79
+ "wait": "gh run watch"
80
+ },
81
+ "owner_reserved": [
82
+ "schema change or data write on prod",
83
+ "permission change",
84
+ "any open blocking review finding",
85
+ "<fill: a rule the owner reserved by name; one string per rule, or delete this line>"
86
+ ],
87
+ "stall": {"busy_silent_min": 45, "ready_no_ready_msg_min": 20},
88
+ "quiet": {
89
+ "coordination": "none",
90
+ "timing_checks": "<fill: when timing checks may run, for example quiet window only>"
91
+ },
92
+ "relay": "<fill: workstream name of the coordinator's relay, e.g. coordinator>"
93
+ }
94
+ ```
@@ -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,360 @@
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
+ ## Restart and resume
23
+
24
+ - Planned restart: run `/caddis:handoff`, `/clear`, `/rename coordinator`, `/caddis:coordinate start`, in
25
+ that order, in this window.
26
+ - After a crash: run `/caddis:coordinate start` only (first `/rename coordinator` if the name is lost).
27
+ - Do not paste a resume prompt. Start reads the coordinator's relay and tasks itself (step 1.8).
28
+
29
+ ## How to call the script
30
+
31
+ - Run these commands with the Bash tool: the quoting and the `GH_TOKEN=$(...)` forms are POSIX shell.
32
+ - Every queue, lock and ledger change goes through
33
+ `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" <subcommand> ... --json`.
34
+ Never edit `coordinator.json`, the lock file or the ledger by hand.
35
+ - Exit codes: **0** ok; **1** error (one line on stderr); **2** usage error (fix the call); **3** the
36
+ lock is refused.
37
+ - `<R>` is the repo id, `<P>` is this session's pid, `<N>` is this session's name, `<S>` is this
38
+ session's id. Step 1 finds them once. Pass `--repo <R>` to every call that takes it.
39
+ - `<account>` is the GitHub account of this repo (step 1.1 finds it first).
40
+ Put `GH_TOKEN=$(gh auth token -u <account>)` in front of every command that calls GitHub: `gh`,
41
+ `repo-id`, `ci-watch`, `watch`, `intake`, `queue add` and `deploy-verify`. Never run `gh auth switch`.
42
+ - Free text (`--title`, `--why-waiting`, `--reason`, `--what`): always put free text in single quotes, and
43
+ write each `'` inside it as `'\''`. Never use double quotes: the shell expands `$` and backticks in them.
44
+ Example: `--reason 'the owner'\''s words'`.
45
+ - `<owner/name>` is the GitHub repo of `<R>` (`acme__shop` is `acme/shop`).
46
+
47
+ ## 1. Start (design 0.1)
48
+
49
+ 1. Find `<account>`, the GitHub account of this repo: the repo's `AGENTS.md` names it; else ask the
50
+ owner. Do this first: `repo-id` already calls GitHub.
51
+ 2. Find the repo id:
52
+ ```
53
+ GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" repo-id --json
54
+ ```
55
+ Keep `repo` as `<R>`.
56
+ 3. Find `<S>`: the session start output prints `[caddis] this session's id: <id>`. Never guess it. If
57
+ you cannot find that line, stop and ask the owner for the id.
58
+ 4. Find this session's pid and name:
59
+ ```
60
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" session --session-id <S> --json
61
+ ```
62
+ Keep `pid` as `<P>` and `name` as `<N>`. Exit 1 with "two interactive sessions share" means two
63
+ copies of this session run (design 8.6): stop, tell the owner both pids, and wait. Any other exit 1:
64
+ stop and report the line.
65
+ If `<N>` is not `coordinator`, stop and tell the owner: "Type `/rename coordinator`, then run
66
+ `/caddis:coordinate start` again." Peers and the merge hook find the coordinator by that name.
67
+ 5. Take the lock. This also creates or claims the repo's state:
68
+ ```
69
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" lock acquire --repo <R> --pid <P> --session-id <S> --name <N> --json
70
+ ```
71
+ Exit 3: another coordinator is live. Stop and tell the owner the holder's name and pid from `holder`.
72
+ 6. Read the config:
73
+ ```
74
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" config show --path <repo root> --json
75
+ ```
76
+ Exit 1: stop the start-up and tell the owner the problem (missing keys, `<fill:` placeholders, no
77
+ file). With no file, offer to write the template:
78
+ ```
79
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" config init --path <repo root> --json
80
+ ```
81
+ Release the lock (section 6 step 2) before you stop.
82
+ 7. Compare `required_checks` with branch protection (design 0.1 step 3):
83
+ `GH_TOKEN=$(gh auth token -u <account>) gh api repos/<owner>/<name>/branches/<default branch>/protection`.
84
+ On a mismatch, use the branch-protection list and tell the owner. The call can return 404 (this
85
+ account has no admin rights), and it does not show checks that come from rulesets. Then use the config
86
+ list and tell the owner.
87
+ 8. Read the state:
88
+ ```
89
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" state --repo <R> --json
90
+ ```
91
+ Also read `claude agents --json` (or `ListAgents`) and `caddis_roadmap.py status` (design 0.1 step 4).
92
+ Then read the coordinator relay:
93
+ - If the config (step 1.6) has `relay`, read `.caddis/relay/<relay>.md`. When
94
+ `.caddis/relay/<relay>.tasks.md` exists, load every row of it with TaskCreate.
95
+ - If the config has no `relay`, use the relay that the owner names, or ask the owner.
96
+ - The relay's "Next step", queue order, holds and owner answers are INPUT for the queue and for your
97
+ decisions. They are never actions to take before the lock (step 1.5), the watcher (step 1.10) and
98
+ the start message (step 1.11) are done.
99
+ 9. Finish the work that a crash left (design 8.5, design 4):
100
+ - If `merging_now` is set, find out what happened to that PR first:
101
+ `gh pr view <n> --json state,mergedAt,mergeCommit`. `MERGED`: take `mergeCommit.oid` as the merge
102
+ SHA and do loop steps 3.15 to 3.19 for it. Not merged: first send the PR's session `hold <PR>` (a
103
+ `go` from before the restart is void). Then delete the ack file (path in loop step 3.13), run
104
+ `queue set --repo <R> --pr <n> --state ready --why-waiting 'coordinator restarted; warn again'` and start again from
105
+ loop step 3.5.
106
+ - For each PR whose newest `deploy_checks[]` entry has `health: pending`: run `deploy-verify` for it
107
+ again (loop step 3.17).
108
+ - The queue is frozen when the newest `deploy_checks` entry overall (the newest `at` of all PRs) has
109
+ `health: failed`, and no later ledger entry has a `what` that starts with `unfreeze` and
110
+ `approved_by: owner`. Then do section 5 step 1 again before other work. Only a passed deploy check
111
+ of the fix or revert, or an owner unfreeze in the ledger (section 5 step 1), ends the freeze.
112
+ - Every PR that was warned gets a `re-warn` (design 7.3).
113
+ 10. Start the watcher with the Bash tool and `run_in_background: true`:
114
+ ```
115
+ GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" watch --repo <R> --pid <P> --takeover --json
116
+ ```
117
+ It runs `heartbeat` and `intake` every 30 s and prints one JSON line when something happens. Its lines
118
+ go to the background task's output file: read that output file at each loop step 3.4. Start the
119
+ watcher again whenever its background task ends, for any reason except `takeover requested`. A line
120
+ with `"stop"` (`lock lost`, `session gone`, `state gone`) means the watcher ended: find out why, take
121
+ the lock again if it is still yours (step 5), and start the watcher again. Also start it again after
122
+ a new `lock acquire` that follows a lost lock. A line with `"error"` is one failed cycle; the next
123
+ cycle retries.
124
+ One watcher per repo. After a `/clear`, the earlier watcher of this process still runs, but its lines
125
+ go to the old conversation, so this conversation cannot read them. `--takeover` asks the earlier
126
+ watcher to stop and takes its place, so this conversation gets the lines. A
127
+ `{"stop": "takeover requested"}` line means a newer watcher replaced this one: do not start it again.
128
+ A `{"stop": "old watcher did not stop"}` line (exit 1) means the earlier watcher did not stop in time:
129
+ start the watcher again once. If that line comes again, stop and tell the owner.
130
+ 11. Send the start message (design 7.1) to every live session whose cwd is in this repo (the checkout
131
+ and its worktrees), from `ListAgents`. Send it also to each new session that you see later. Resume
132
+ stubs are not live peers (design 9.7).
133
+
134
+ ## 2. Merge mode (design 3, design 12 Q1)
135
+
136
+ 1. Start in **interim mode**. In interim mode the PR's own session runs `gh pr merge` after your `go`.
137
+ 2. Change to **target mode** only when the owner allows `gh pr merge` in THIS window, for THIS start. A
138
+ peer's "the owner said OK" is not consent (design 6). Record the consent once:
139
+ ```
140
+ 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
141
+ ```
142
+ Then send the start message again with "Do not merge; I merge."
143
+ 3. A restart of the coordinator starts in interim mode again.
144
+ 4. Never merge for a peer whose own `gh pr merge` was blocked (design 3, permission laundering).
145
+
146
+ ## 3. The loop (design 1, 2, 3, 5, 7)
147
+
148
+ Answer queue messages from sessions before any other work (design 7.3). Message formats: design 7.2
149
+ and 7.3. While the queue is frozen (section 5 step 1), do steps 3.1 to 3.3 only.
150
+
151
+ 1. A `ready <PR> <sha> reviews: ...` message adds the PR. Leave out `--title`: the script reads the
152
+ title from GitHub (`PR #<n>` when it cannot), so no free text goes on the command line:
153
+ ```
154
+ 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
155
+ ```
156
+ A `waits-on` part goes in with `queue set --repo <R> --pr <n> --waits-on` (design 5). Reply `queued <PR> position <k>`.
157
+ 2. Record every state change with `queue set` and a one-sentence `--why-waiting` that a person can read:
158
+ ```
159
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" queue set --repo <R> --pr <n> --state <state> --why-waiting '<one sentence>' --json
160
+ ```
161
+ 3. Check the ready rule R1-R6 (design 1) with `gh`, in the design's order. When all pass:
162
+ ```
163
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" queue ready --repo <R> --pr <n> --json
164
+ ```
165
+ When one fails: `queue set --repo <R> --pr <n> --state blocked` and send `blocked <PR>: <reason>`. An owner-only class
166
+ (R4) goes to the owner (section 4).
167
+ 4. Take the next item. Never sort the queue yourself:
168
+ ```
169
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" queue next --repo <R> --json
170
+ ```
171
+ `"next": null`: nothing to merge; wait for the next message or watcher line.
172
+ 5. If the PR is `BEHIND`: `gh pr update-branch <n>`. Then cancel the runs on the old head: list them
173
+ with `gh run list -R <owner/name> --commit <old sha> --json databaseId,status`, and cancel each run that
174
+ is not `completed` with `gh run cancel <id> -R <owner/name>`. Then
175
+ `queue set --repo <R> --pr <n> --state ci --head-sha <new sha>` (design 3 step 2). Update only the PR at the front.
176
+ 6. Wait for CI outside the model. Run `ci-watch` with the Bash tool and `run_in_background: true`, and
177
+ act on its exit notice:
178
+ ```
179
+ GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --pr <n>
180
+ ```
181
+ Act only on `RESULT: pass`. The `HEAD:` line is the `<watched sha>`. Exit 1: CI is red (design 8.1;
182
+ one re-run for a flake, design 8.2). Exit 3: run `ci-watch` again; if it stays 3, stop and report.
183
+ Exit 4: a usage error; stop and report.
184
+ 7. If the item is in `ci` (after step 3.5 or a new head), check R1 and R2 again at the watched head,
185
+ then run `queue ready` (step 3.3). Do not warn for an item that is not `ready`.
186
+ 8. Check that the default branch is idle (M1): `gh run list -R <owner/name> --branch <default branch> --workflow '<main_workflow>' --json status,databaseId`
187
+ lists no `in_progress` or `queued` run. If one runs, wait for it with
188
+ `GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --run <id> --repo <owner/name>`,
189
+ in the background (`run_in_background: true`). When its `RESULT:` line comes, check M1 again.
190
+ 9. Send `merge-warning <PR> <watched sha> in 2 min. Reply ack <PR> <sha> or hold.` to every live peer
191
+ (design 7.3), and `queue set --repo <R> --pr <n> --state merging`. Send `re-warn <PR> <sha>` after any coordinator
192
+ restart and after any new head on a warned PR. Old acks do not count.
193
+ 10. Collect the acks (M2). Wait the full `peer_ack.warning_min` minutes after the warning, even when every
194
+ ack comes early. For that wait, run `sleep 120` (`peer_ack.warning_min` x 60 s) with the Bash tool
195
+ and `run_in_background: true`, or use the Monitor tool, and act on its exit notice; never a
196
+ foreground sleep. Answer messages while it runs. No ack after 5 min: one nudge. After 10 min: tell
197
+ the owner. A peer that waits on the owner cannot answer: tell the owner "ack needed from <name>; it
198
+ waits on you". Do not write the ack file yet (step 3.13).
199
+ 11. Check again that the default branch is idle (M1, the same `gh run list` as step 3.8), just before the
200
+ merge. If a run started, wait for it (step 3.8). If the warning went out more than 30 min ago, warn
201
+ again (step 3.9).
202
+ 12. Read the state again just before the merge, and read the item's `held` again:
203
+ ```
204
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" state --repo <R> --json
205
+ ```
206
+ Then check the PR itself: `gh pr view <n> --json state,headRefOid,mergeStateStatus,mergeCommit`.
207
+ - `state` is `MERGED` (the PR's session merged it already): take `mergeCommit.oid` as the merge SHA
208
+ and go straight to step 3.15.
209
+ - Else `headRefOid` must be the `HEAD:` of the `ci-watch` run that printed `RESULT: pass`, and
210
+ `mergeStateStatus` must be `CLEAN`.
211
+ If any check fails, do not merge now: first leave `merging`, then delete the ack file (step 3.13) if
212
+ it exists, then go back to step 3.4:
213
+ - `held: true` or a peer `hold`: `queue set --repo <R> --pr <n> --state ready --why-waiting '<who holds it and why>'`.
214
+ - a new head: `queue set --repo <R> --pr <n> --state ci --head-sha <new sha> --why-waiting 'new head; CI runs again'`.
215
+ - not `CLEAN`: `queue set --repo <R> --pr <n> --state ci --why-waiting 'merge state <mergeStateStatus>; checks run again'`.
216
+ If you merge now, decide docs-only (step 3.17 needs it) from evidence only, and record the evidence
217
+ while the item is still in the queue:
218
+ `queue set --repo <R> --pr <n> --why-waiting 'merging; docs-only: <yes|no> from <the evidence>'`. The evidence is the
219
+ `docs_only.signal` job result in the PR's CI run, or the file list from `gh pr view <n> --json files`.
220
+ 13. Write the ack file that the merge hook reads. This is the LAST action before `go` (interim mode) or
221
+ `gh pr merge` (target mode): after the M1 check (step 3.11) and the `held` and PR checks (step 3.12). The
222
+ path is `~/.claude/merge-acks/<checkout folder name>/pr-<n>.json`. The checkout folder name is the
223
+ last path part of the first `worktree` line of `git worktree list --porcelain` (the repo's main
224
+ checkout), NOT the repo id `<R>`. The content is exactly:
225
+ ```
226
+ {"pr": <n>, "head": "<full sha>", "acked": [<names>], "skipped_waiting": [<names waiting on the owner>], "at": "<UTC ISO 8601 with Z>"}
227
+ ```
228
+ `acked` lists every peer that sent `ack <PR> <sha>` for this head, by its session name. An unnamed
229
+ peer is listed under the first 8 characters of its session id (the hook uses the same name). A peer
230
+ that waits on the owner goes under `skipped_waiting`, never under `acked`. Interim mode: also put
231
+ your own name `<N>` in `acked` (the PR's session runs the merge, and its hook counts you as a peer).
232
+ Target mode: you run the merge, so `acked` must list the PR's session too. The hook refuses an ack
233
+ file older than 30 min.
234
+ 14. Merge the watched head (design 3 step 6), checked in step 3.12. The watched head is the `HEAD:` of the
235
+ `caddis_lanes.py ci-watch` run (step 3.6) that printed `RESULT: pass`; never merge without it.
236
+ Do only the merge or the `go`:
237
+ - Target mode: `GH_TOKEN=$(gh auth token -u <account>) gh pr merge <n> --<merge_method> --match-head-commit <watched sha>`.
238
+ The merge SHA is `mergeCommit.oid` from `gh pr view <n> --json state,mergeCommit`.
239
+ - Interim mode: send `go <PR> <watched sha>`; the PR's session merges with the same command. Then
240
+ poll with a Bash command in `run_in_background`: `gh pr view <n> --json state,mergeCommit` once a
241
+ minute, for at most 15 min, until `state` is `MERGED`. Take `mergeCommit.oid` as the merge SHA; the
242
+ session's `merged` reply only tells you to look. No merge after 15 min: ask the PR's session once.
243
+ After 5 more min, tell the owner, delete the ack file, and leave `merging` with
244
+ `queue set --repo <R> --pr <n> --state ready --why-waiting 'go sent; no merge from <session>'`.
245
+ If `--match-head-commit` fails, the head moved: delete the ack file,
246
+ `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
247
+ a merge.
248
+ 15. Record the merge as soon as the merge SHA is known, before any wait. This removes the item and adds
249
+ a `pending` deploy check:
250
+ ```
251
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" queue merged --repo <R> --pr <n> --merge-sha <full merge sha> --json
252
+ ```
253
+ Exit 0 with `already recorded` is success (a retry). Then delete the ack file: the item has left
254
+ `merging`.
255
+ 16. Find the push run for the merge SHA, then wait until it ends. Run each wait with the Bash tool and
256
+ `run_in_background: true`, and act on its exit notice.
257
+ - Find it once a minute, for at most 10 min:
258
+ `gh run list -R <owner/name> --branch <default branch> --event push --workflow '<main_workflow>' --json databaseId,headSha`,
259
+ and match `headSha` to the merge SHA. No push run after 10 min: go to step 3.17 now;
260
+ `deploy-verify` records D1 failed.
261
+ - Wait for it:
262
+ `GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --run <id> --repo <owner/name>`.
263
+ It prints a `RESULT:` line when the run ends (pass or fail); then go to step 3.17, which reads the
264
+ result itself. Exit 3: run it again. Exit 4: a usage error; stop and report.
265
+ 17. Run the deploy check D1-D5 (design 4). Add `--docs-only` only when the evidence that step 3.12
266
+ recorded in `--why-waiting` says docs-only: the `docs_only.signal` job result in the PR's CI run, or
267
+ the file list from `gh pr view <n> --json files`. Never decide it from the title or a session's word.
268
+ When that evidence is not available (for example after a restart), decide again from
269
+ `gh pr view <n> --json files`: docs-only only when every changed file ends in `.md` (or is under a docs
270
+ path that the config's `docs_only` block names). Say in your report that you decided it again. When in
271
+ doubt, do not pass `--docs-only`.
272
+ ```
273
+ 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
274
+ ```
275
+ It records the result. Exit 0 (`health: ok`): pass. Exit 1 with `health: pending`: the run has not
276
+ ended; wait for it (step 3.16) and call it again. Exit 1 with `health: failed`: section 5 step 1.
277
+ Exit 1 with no JSON on stdout (an `error:` line on stderr): a usage or state problem, for example a
278
+ merge SHA that is not the recorded one. Stop and report the line. Exit 2: fix the call.
279
+ 18. D6 (render check) comes from the PR's session (`render-check ...`). D7 (a post-deploy data job)
280
+ is recorded when the job ends:
281
+ ```
282
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" deploy-check set --repo <R> --pr <n> --health <ok|failed> --json
283
+ ```
284
+ A failed D6 or D7: section 5 step 1.
285
+ 19. Tell the PR's session and every live peer: `merged <PR> <merge sha>`, then `deployed <PR> ok`. Tell a
286
+ stacked PR's session to retarget to the default branch (design 3). Go back to step 3.4.
287
+
288
+ Stalls (design 7.4): one nudge for a busy, silent session that holds a queue item, and for a green,
289
+ `CLEAN` PR with no `ready` message; tell the owner when a waiting session blocks the queue.
290
+
291
+ ## 4. Approvals (design 6)
292
+
293
+ 1. Routine gates are yours. Record each one:
294
+ ```
295
+ 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
296
+ ```
297
+ 2. The owner-only list (design 6, plus config `owner_reserved`) goes to the owner. Never approve it
298
+ yourself. Send it with:
299
+ ```
300
+ recommendation: approve | hold
301
+ reason: <one or two sentences>
302
+ evidence: <cost estimate | test or CI result | risk | how to undo>
303
+ ```
304
+ 3. A recommendation is never an approval. Only the owner's click in the hub or the owner's own words in
305
+ this window approve. Record the owner's answer with `ledger record --by owner --window <N>`.
306
+ 4. Hotfix marks come only from the owner (design 2). You may recommend one.
307
+
308
+ ## 5. Failures (design 8) and stalls (design 7.4)
309
+
310
+ 1. A failed deploy check (D1-D7, design 4, design 8.4): freeze the queue. Send no merge warnings and
311
+ no `go`, write no ack file, and do no merges. For an item in `merging`, first run
312
+ `gh pr view <n> --json state,mergeCommit`. `MERGED`: do step 3.15 (`queue merged`) for it before you
313
+ freeze, then its deploy check (steps 3.16 and 3.17). Not merged: delete the ack file and move it to
314
+ `ready`. Set every item's why-waiting with
315
+ `queue set --repo <R> --pr <n> --why-waiting 'queue frozen: deploy check failed for #<n>'`, tell every live session
316
+ `frozen: <reason>`, read the per-job conclusions, and send the owner a recommendation (revert or
317
+ hold). The owner decides a rollback; a rollback is a revert PR through the queue. Record an incident
318
+ with `/caddis:rca`. Unfreeze only when the fix or revert passes its own deploy check, or the owner
319
+ says so. Record an owner unfreeze in the ledger (step 1.9 reads it after a restart), then send
320
+ `unfrozen`:
321
+ ```
322
+ 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
323
+ ```
324
+ 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
325
+ job (`gh run rerun <id> --failed`, design 8.2).
326
+ 3. A run stuck or a runner suspected (design 8.3): check
327
+ `https://www.githubstatus.com/api/v2/summary.json` first. During a GitHub incident, wait. Never
328
+ restart a runner or a service.
329
+ 4. Duplicate copies (design 8.6) show as `session` exit 1. Tell the owner the other pid. Stop a process
330
+ only when the owner asks in this window, and record that in the ledger.
331
+ 5. A single exit 1 from `heartbeat` or `intake` (a watcher `"error"` line) can be transient. Act only
332
+ when it repeats.
333
+ 6. Pilot notes. D3 runs `git fetch --tags`, which can need git credentials in this session; then D3
334
+ fails. A short gh or curl failure in D1 or D5 records `failed` and freezes the queue. That is the
335
+ safe direction: the owner checks the deploy and unfreezes (step 5.1).
336
+
337
+ ## 6. Stop
338
+
339
+ 1. Stop the watcher: stop its background Bash task.
340
+ 2. Release the lock:
341
+ ```
342
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" lock release --repo <R> --pid <P> --json
343
+ ```
344
+ 3. Send every live session on the repo: `coordinator <N> stopped for <repo>. Do not merge until a coordinator is live again.`
345
+
346
+ ## 7. Rules that never bend
347
+
348
+ - Merge only on `ci-watch` `RESULT: pass` at the watched head, with `--match-head-commit <full sha>`.
349
+ Never merge while a default-branch CI run is in progress.
350
+ - Never write `coordinator.json` by hand; `lock acquire` creates or claims the state. Exit 3 means
351
+ stop. Always pass `--repo <R>`.
352
+ - Use `queue next`; never sort the queue yourself. Read `held` again just before a merge.
353
+ - Write the ack file last, just before `go` or `gh pr merge`. Delete it when the item leaves `merging`.
354
+ - Free text goes in single quotes. Long waits run in the background, never a foreground sleep.
355
+ - `queue merged` exit 0 `already recorded` is success.
356
+ - Liveness is the lock file and `heartbeat_at`, not a peer's word.
357
+ - Owner-only approvals go to the owner. Never self-approve them.
358
+ - No stress tests and no load tests.
359
+ - `GH_TOKEN=$(gh auth token -u <account>)` per repo account. Never `gh auth switch`.
360
+ - Never add the `--admin` flag to a merge. Never force push. Never restart services.
@@ -146,7 +146,9 @@ history, and the relay has a 4,000-character budget (`caddis_budget.py`).
146
146
  > Your session id is printed at session start as `[caddis] this session's id: <id>`; use it for
147
147
  > `session_id` and `--session-id`. Never guess one: a wrong id disables session matching.
148
148
  >
149
- > 1. **Name the workstream** (active plan's `feature`, else the branch; the script prints nothing
149
+ > 1. **Name the workstream.** First run the coordinator check of Step 9. When it says this session is the
150
+ > repo's coordinator, `<name>` is the config's `relay`: do not run `resolve` and do not ask. Else name it
151
+ > from the active plan's `feature`, else the branch (the script prints nothing
150
152
  > on the default branch with no plan — run `caddis_workstreams.py list` (`python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_workstreams.py" list`) to show existing workstreams, then ask the user which one to use or for a short name):
151
153
  > `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_workstreams.py" resolve --plan <feature> --branch <branch> --default-branch <main>`
152
154
  > 2. **Legacy repo:** if `.caddis/relay.md` exists and does not start with
@@ -398,7 +400,40 @@ Then, in a shared checkout, `Docs PR needed:` with the `PR:` line and the file l
398
400
  Step 9 prints after this report. It is the last output of the handoff.
399
401
 
400
402
  ## Step 9 — print the resume prompt (always the last output)
401
- The owner should never have to ask for the resume prompt. After the checklist report, run:
403
+ The owner should never have to ask for the resume prompt. After the checklist report, check for a
404
+ coordinator first.
405
+
406
+ **Coordinator check.** Step 3, point 1 also uses this check. When the repo has `.caddis/coordinator.md`,
407
+ find out if this session is the repo's coordinator:
408
+ 1. Read the config. It must have `relay`:
409
+ ```
410
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" config show --path . --json
411
+ ```
412
+ Exit 1, or no `relay`: this session is not treated as the coordinator here. Use the normal block.
413
+ 2. Find the repo id `<R>` (`<account>` is the repo's GitHub account, from the repo's `AGENTS.md`):
414
+ ```
415
+ GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" repo-id --json
416
+ ```
417
+ Keep `repo` as `<R>`.
418
+ 3. Find this session's pid. Keep `pid`:
419
+ ```
420
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" session --session-id <session-id> --json
421
+ ```
422
+ 4. Read the lock holder. It never takes or changes the lock:
423
+ ```
424
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" lock holder --repo <R> --json
425
+ ```
426
+ 5. This session is the coordinator when the holder's `sessionId` is this session's id, OR the holder's `pid`
427
+ is this session's `pid` (the same coordinator process, for example after a `/clear` that never ran start).
428
+
429
+ When this session is the coordinator, `<name>` is the config's `relay`, and you add `--coordinator` to the
430
+ `resume-block` call below. The block then prints only `/clear`, `/rename coordinator` and
431
+ `/caddis:coordinate start`, and no resume prompt: `/caddis:coordinate start` reads the relay and its tasks
432
+ itself. `resume-block --coordinator` refuses (exit 1) a `--name` that is not the config's `relay`. In all
433
+ other cases (no `.caddis/coordinator.md`, no `relay`, `{"holder": null}`, another holder, an exit 1), run
434
+ `resume-block` as written.
435
+
436
+ Then run:
402
437
  ```
403
438
  python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_fleet.py" resume-block --name <name> --session-id <session-id> --root .
404
439
  ```
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.157",
2
+ "poolVersion": "1.3.159",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.157",
4
+ "antigravity-plugin": "1.3.159",
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.73",
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",