@caddis/cli 0.4.35 → 0.4.36

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.122",
3
+ "version": "1.3.123",
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
  }
@@ -96,10 +96,11 @@ def phase_lane(block: str) -> str:
96
96
  def launch_command(block: str) -> str:
97
97
  """The literal command for a non-claude lane, if the phase names one.
98
98
 
99
- Also matches a caddis_lanes.py run command for the codex, agy and glm lanes.
99
+ Also matches a caddis_lanes.py run command or a direct codex/agy command.
100
100
  """
101
101
  m = re.search(
102
- r'`((?:claude-(?:glm|oss|deepseek)|' + _LANES_RUN + r')[^`]*)`',
102
+ r'`((?:claude-(?:glm|oss|deepseek)\b|' + _LANES_RUN
103
+ + r'\b|codex\s+exec\b|agy\b)[^`]*)`',
103
104
  block,
104
105
  )
105
106
  return m.group(1).strip() if m else ""
@@ -142,6 +143,28 @@ def gate_lane(plan: Path, phase: int) -> int:
142
143
  f"[caddis-gate] phase {phase}'s caddis_lanes command names --phase {named.group(1)}: {cmd}\n"
143
144
  " The lane would run another phase's work. Refusing to run it.\n")
144
145
  return EXIT_MALFORMED
146
+ elif re.match(r"(?:codex\s+exec|agy)\b", cmd):
147
+ if "HEADLESS RUN RULES" not in cmd:
148
+ sys.stderr.write(
149
+ f"[caddis-gate] phase {phase}'s direct command has no HEADLESS RUN RULES: {cmd}\n"
150
+ " The child has no headless run rules. Refusing to run it.\n")
151
+ return EXIT_MALFORMED
152
+ named = re.search(r"Implement Phase (\d+) only", cmd)
153
+ if not named:
154
+ sys.stderr.write(
155
+ f"[caddis-gate] phase {phase}'s direct command has no phase restriction: {cmd}\n"
156
+ " The lane could run the wrong phase. Refusing to run it.\n")
157
+ return EXIT_MALFORMED
158
+ if int(named.group(1)) != phase:
159
+ sys.stderr.write(
160
+ f"[caddis-gate] phase {phase}'s direct command names Phase {named.group(1)}: {cmd}\n"
161
+ " The lane would run another phase's work. Refusing to run it.\n")
162
+ return EXIT_MALFORMED
163
+ if cmd.startswith("agy") and not re.search(r"(?:^|\s)(?:-p|--print)(?:\s|$)", cmd):
164
+ sys.stderr.write(
165
+ f"[caddis-gate] phase {phase}'s direct agy command has no -p/--print: {cmd}\n"
166
+ " The child would not run headless. Refusing to run it.\n")
167
+ return EXIT_MALFORMED
145
168
  elif not re.search(r"(?:^|\s)(?:-p|--print)(?:\s|$)", cmd):
146
169
  sys.stderr.write(
147
170
  f"[caddis-gate] phase {phase}'s launch command has no -p/--print: {cmd}\n"
@@ -28,6 +28,8 @@ except ModuleNotFoundError as exc:
28
28
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
29
29
  import caddis_exit
30
30
 
31
+ import caddis_routing
32
+
31
33
  if hasattr(sys.stdout, "reconfigure"):
32
34
  sys.stdout.reconfigure(encoding="utf-8", errors="replace")
33
35
  sys.stderr.reconfigure(encoding="utf-8", errors="replace")
@@ -69,7 +71,17 @@ DEFAULT_CHAINS = {
69
71
  "docs": ("agy", "glm", "claude"),
70
72
  }
71
73
  BUDGET_PATTERNS: dict[str, tuple[str, ...]] = {
72
- "claude": (),
74
+ # Claude Code 2.1.283 CLI bundle (limit-message builder and its prefix list).
75
+ "claude": (
76
+ r"You[’']ve hit your (?:session|weekly|Opus|Sonnet|Fable|usage credit) limit\b",
77
+ r"You[’']ve hit your (?:channel's )?monthly spend limit\b",
78
+ r"You[’']ve hit your team's shared budget\b",
79
+ r"You[’']ve reached your Fable limit\b",
80
+ r"You[’']re out of (?:usage credits|extra usage)\b",
81
+ r"Your org is out of usage\b",
82
+ r"Your usage allocation has been disabled by your admin\b",
83
+ r"Your group's usage limit is set to \$0\b",
84
+ ),
73
85
  # https://docs.z.ai/api-reference/api-code
74
86
  "glm": (
75
87
  r"API Error: 429.*\"type\"\s*:\s*\"(?:1113|1308|1309|1310|1316|1317)\"",
@@ -943,6 +955,216 @@ def classify_exit(
943
955
  return RESULT_LANE_FAILED
944
956
 
945
957
 
958
+ CLAUDE_FAMILY = frozenset({"opus", "sonnet", "claude"})
959
+
960
+
961
+ def next_lane(
962
+ role: str,
963
+ after: str,
964
+ *,
965
+ home: Path,
966
+ repo: Path,
967
+ which=shutil.which,
968
+ ) -> str | None:
969
+ """Find the next available model, sharing one budget across Claude models."""
970
+ routing = caddis_routing.resolve(home, repo, which=which)
971
+ chain = routing[role]
972
+ if role != "coding" and after not in chain and after not in CLAUDE_FAMILY:
973
+ # `after` came from the coding fallback (a Claude model ran out first): carry on down
974
+ # the coding chain, and never hand the exhausted Claude budget back.
975
+ coding = routing["coding"]
976
+ if after not in coding:
977
+ return None
978
+ rest = [model for model in coding[coding.index(after) + 1:] if model not in CLAUDE_FAMILY]
979
+ return rest[0] if rest else None
980
+ candidates = chain[chain.index(after) + 1:] if after in chain else chain
981
+ if after in CLAUDE_FAMILY:
982
+ candidates = [model for model in candidates if model not in CLAUDE_FAMILY]
983
+ if not candidates and role != "coding":
984
+ # Fall to coding models this role has not already tried. The coding chain ran before
985
+ # its own Claude fallback, and a role that lists agy or codex ran them before Claude;
986
+ # restarting either would loop.
987
+ candidates = [
988
+ model for model in routing["coding"]
989
+ if model not in CLAUDE_FAMILY and model not in chain
990
+ ]
991
+ return candidates[0] if candidates else None
992
+
993
+
994
+ def _ci_watch_block(output: str) -> dict[str, str] | None:
995
+ """Read the last complete watch report, ignoring an echoed prompt template."""
996
+ fields: dict[str, str] = {}
997
+ reports: list[dict[str, str]] = []
998
+ for line in output.splitlines():
999
+ match = re.match(
1000
+ r"^\s*(?:[`*]+\s*)?(RESULT|CHECKS|MERGE_STATE|FAILING)[`*]*\s*:\s*[`*]*\s*(.*?)\s*[`*]*\s*$",
1001
+ line, re.I,
1002
+ )
1003
+ if not match:
1004
+ continue
1005
+ key, value = match.group(1).upper(), match.group(2).strip().strip("`* ")
1006
+ if key == "RESULT":
1007
+ if fields:
1008
+ reports.append(fields)
1009
+ fields = {}
1010
+ if fields or key == "RESULT":
1011
+ fields[key] = value
1012
+ if fields:
1013
+ reports.append(fields)
1014
+ if not reports:
1015
+ return None
1016
+ last = reports[-1]
1017
+ if set(last) != {"RESULT", "CHECKS", "MERGE_STATE", "FAILING"}:
1018
+ return None
1019
+ if last["RESULT"].lower() not in {"pass", "fail", "timeout"}:
1020
+ return None
1021
+ return last
1022
+
1023
+
1024
+ def _print_ci_watch(result: str, checks: str, merge_state: str, failing: str, watcher: str) -> None:
1025
+ print(f"RESULT: {result}")
1026
+ print(f"CHECKS: {checks}")
1027
+ print(f"MERGE_STATE: {merge_state}")
1028
+ print(f"FAILING: {failing}")
1029
+ print(f"WATCHER: {watcher}")
1030
+
1031
+
1032
+ def ci_watch(
1033
+ target_kind: str,
1034
+ target_id: int,
1035
+ repo_slug: str | None,
1036
+ timeout_s: int,
1037
+ *,
1038
+ home: Path,
1039
+ repo: Path,
1040
+ which=shutil.which,
1041
+ run_agy=run_lane,
1042
+ run_gh=subprocess.run,
1043
+ clock=time.monotonic,
1044
+ ) -> int:
1045
+ """Wait for CI with the resolved watcher, using one shared time budget."""
1046
+ if target_kind not in {"pr", "run"} or target_id <= 0 or timeout_s <= 0:
1047
+ print("[caddis-lanes] ci-watch: target and timeout must be positive", file=sys.stderr)
1048
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1049
+ return EXIT_CONFIG
1050
+ if repo_slug is not None and not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo_slug):
1051
+ print("[caddis-lanes] ci-watch: --repo must be OWNER/NAME", file=sys.stderr)
1052
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1053
+ return EXIT_CONFIG
1054
+
1055
+ started = clock()
1056
+ try:
1057
+ watchers = caddis_routing.resolve(home, repo, which=which)["ci-watch"]
1058
+ except caddis_routing.RoutingConfigError as exc:
1059
+ print(f"[caddis-lanes] {exc}", file=sys.stderr)
1060
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1061
+ return EXIT_CONFIG
1062
+
1063
+ repo_opt = f" --repo {repo_slug}" if repo_slug else ""
1064
+ if target_kind == "pr":
1065
+ poll = f"gh pr checks {target_id}{repo_opt}"
1066
+ state = f"gh pr view {target_id} --json mergeStateStatus,headRefOid{repo_opt}"
1067
+ else:
1068
+ poll = f"gh run view {target_id}{repo_opt}"
1069
+ state = "n/a"
1070
+ prompt = (
1071
+ "Read-only CI watch. Poll with " + poll + " until every check has finished or the time runs out. "
1072
+ + (f"For the PR, also poll {state}. " if target_kind == "pr" else "")
1073
+ + "Never merge, push, comment, re-run, approve, close or edit anything. "
1074
+ + "Then print exactly this block as the last thing you output, with actual values:\n"
1075
+ "RESULT: pass|fail|timeout\n"
1076
+ "CHECKS: <n passed>/<n total>\n"
1077
+ "MERGE_STATE: <mergeStateStatus or n/a>\n"
1078
+ "FAILING: <comma-separated failing check names, or none>"
1079
+ )
1080
+
1081
+ for watcher in watchers:
1082
+ if watcher == "claude":
1083
+ break
1084
+ if watcher != "agy":
1085
+ print(f"[caddis-lanes] ci-watch: unsupported watcher {watcher}; skipping", file=sys.stderr)
1086
+ continue
1087
+ agy_path = which("agy")
1088
+ if agy_path is None:
1089
+ break
1090
+ remaining = timeout_s - (clock() - started)
1091
+ if remaining <= 0:
1092
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "agy")
1093
+ return EXIT_CONFIG
1094
+ try:
1095
+ model = resolve_models(load_lanes_config(repo))["agy"]
1096
+ effort = DEFAULT_EFFORT["agy"]
1097
+ except LaneConfigError as exc:
1098
+ print(f"[caddis-lanes] {exc}", file=sys.stderr)
1099
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "agy")
1100
+ return EXIT_CONFIG
1101
+ agy_argv = [
1102
+ agy_path, "--model", f"{model}-{effort}",
1103
+ "--dangerously-skip-permissions", "--output-format", "text", "--print",
1104
+ ]
1105
+ try:
1106
+ run = run_agy(agy_argv, repo, prompt, False, remaining, child_env(os.environ, "agy", model))
1107
+ except OSError as exc:
1108
+ print(f"[caddis-lanes] ci-watch: agy could not start ({exc}); watching with gh", file=sys.stderr)
1109
+ break
1110
+ outcome = classify_exit("agy", run.returncode, run.output, run.timed_out)
1111
+ if run.timed_out:
1112
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "agy")
1113
+ return EXIT_CONFIG
1114
+ if outcome in {RESULT_OUT_OF_BUDGET, RESULT_CONFIG_ERROR}:
1115
+ reason = "out of budget" if outcome == RESULT_OUT_OF_BUDGET else "configuration error"
1116
+ print(f"[caddis-lanes] ci-watch: agy {reason}; watching with gh", file=sys.stderr)
1117
+ break
1118
+ report = _ci_watch_block(run.output)
1119
+ if report is not None:
1120
+ result = report["RESULT"].lower()
1121
+ _print_ci_watch(result, report["CHECKS"], report["MERGE_STATE"], report["FAILING"], "agy")
1122
+ return {"pass": EXIT_OK, "fail": EXIT_FAIL, "timeout": EXIT_CONFIG}[result]
1123
+ break
1124
+
1125
+ remaining = timeout_s - (clock() - started)
1126
+ if remaining <= 0:
1127
+ print("[caddis-lanes] ci-watch: time budget expired before gh", file=sys.stderr)
1128
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1129
+ return EXIT_CONFIG
1130
+ gh_path = which("gh")
1131
+ if gh_path is None:
1132
+ print("[caddis-lanes] ci-watch: gh not found on PATH", file=sys.stderr)
1133
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1134
+ return EXIT_CONFIG
1135
+ command = (
1136
+ [gh_path, "pr", "checks", str(target_id), "--watch"]
1137
+ if target_kind == "pr" else [gh_path, "run", "watch", str(target_id), "--exit-status"]
1138
+ )
1139
+ if repo_slug:
1140
+ command.extend(["--repo", repo_slug])
1141
+ try:
1142
+ completed = run_gh(
1143
+ command, cwd=repo, capture_output=True, text=True,
1144
+ encoding="utf-8", errors="replace", timeout=remaining,
1145
+ )
1146
+ except subprocess.TimeoutExpired:
1147
+ print("[caddis-lanes] ci-watch: gh timed out", file=sys.stderr)
1148
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1149
+ return EXIT_CONFIG
1150
+ except OSError as exc:
1151
+ print(f"[caddis-lanes] ci-watch: gh could not start ({exc})", file=sys.stderr)
1152
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1153
+ return EXIT_CONFIG
1154
+ output = "\n".join(part for part in (completed.stdout, completed.stderr) if part)
1155
+ if output:
1156
+ print("\n".join(output.splitlines()[-40:]), file=sys.stderr)
1157
+ no_checks = "no checks reported" in output.lower()
1158
+ code = EXIT_OK if completed.returncode == 0 else (
1159
+ EXIT_FAIL if completed.returncode == 1 and not no_checks else EXIT_CONFIG
1160
+ )
1161
+ _print_ci_watch(
1162
+ "pass" if code == EXIT_OK else "fail" if code == EXIT_FAIL else "timeout",
1163
+ "n/a", "n/a", "see gh output" if code == EXIT_FAIL else "none", "gh",
1164
+ )
1165
+ return code
1166
+
1167
+
946
1168
  def load_wording_findings(text: str) -> list[str]:
947
1169
  findings = []
948
1170
  seen = set()
@@ -1595,6 +1817,17 @@ def main(argv: list[str] | None = None) -> int:
1595
1817
  show_chain.add_argument("--lane")
1596
1818
  show_chain.add_argument("--repo", type=Path)
1597
1819
 
1820
+ next_model = subparsers.add_parser("next-lane")
1821
+ next_model.add_argument("--role", required=True, choices=tuple(caddis_routing.DEFAULT_ROUTING))
1822
+ next_model.add_argument("--after", required=True)
1823
+
1824
+ watch = subparsers.add_parser("ci-watch")
1825
+ watch_target = watch.add_mutually_exclusive_group(required=True)
1826
+ watch_target.add_argument("--pr", type=int)
1827
+ watch_target.add_argument("--run", type=int)
1828
+ watch.add_argument("--repo")
1829
+ watch.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_S)
1830
+
1598
1831
  run = subparsers.add_parser("run")
1599
1832
  _add_phase_arguments(run)
1600
1833
  run.add_argument("--type", required=True)
@@ -1618,6 +1851,34 @@ def main(argv: list[str] | None = None) -> int:
1618
1851
  args = parser.parse_args(argv)
1619
1852
 
1620
1853
  try:
1854
+ if args.command == "next-lane":
1855
+ if args.after not in caddis_routing.KNOWN_MODELS:
1856
+ print(f"[caddis-lanes] next-lane: unknown model {args.after!r}", file=sys.stderr)
1857
+ return EXIT_CONFIG
1858
+ try:
1859
+ candidate = next_lane(
1860
+ args.role, args.after, home=Path.home(), repo=Path.cwd(), which=shutil.which
1861
+ )
1862
+ except caddis_routing.RoutingConfigError as exc:
1863
+ print(f"[caddis-lanes] next-lane: {exc}", file=sys.stderr)
1864
+ return EXIT_CONFIG
1865
+ if candidate is None:
1866
+ print(
1867
+ f"[caddis-lanes] next-lane: no lane left for {args.role} after {args.after}",
1868
+ file=sys.stderr,
1869
+ )
1870
+ return EXIT_CONFIG
1871
+ print(candidate)
1872
+ return EXIT_OK
1873
+ if args.command == "ci-watch":
1874
+ return ci_watch(
1875
+ "pr" if args.pr is not None else "run",
1876
+ args.pr if args.pr is not None else args.run,
1877
+ args.repo,
1878
+ args.timeout,
1879
+ home=Path.home(),
1880
+ repo=Path.cwd(),
1881
+ )
1621
1882
  if args.command == "show-chain":
1622
1883
  cfg = load_lanes_config(args.repo if args.repo is not None else Path.cwd())
1623
1884
  chain = resolve_chain(args.type, cfg, first_lane=args.lane)
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve the local model order for each caddis role."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import sys
10
+ import tomllib
11
+ from pathlib import Path
12
+ from typing import Callable, Mapping
13
+
14
+ import oss_model
15
+
16
+
17
+ DEFAULT_ROUTING: dict[str, list[str]] = {
18
+ "critical": ["opus"],
19
+ "light": ["sonnet"],
20
+ "coding": ["agy", "codex"],
21
+ "review": ["glm", "deepseek"],
22
+ "consult": ["agy", "glm", "codex"],
23
+ "ci-watch": ["agy"],
24
+ }
25
+ KNOWN_MODELS = frozenset({"opus", "sonnet", "agy", "codex", "glm", "deepseek", "claude"})
26
+
27
+
28
+ class RoutingConfigError(Exception):
29
+ """A routing file has an invalid value or cannot be parsed."""
30
+
31
+
32
+ def _routing_from(path: Path) -> dict[str, list[str]]:
33
+ if not path.is_file():
34
+ return {}
35
+ try:
36
+ with path.open("rb") as file:
37
+ config = tomllib.load(file)
38
+ except (OSError, tomllib.TOMLDecodeError) as exc:
39
+ raise RoutingConfigError(f"{path}: {exc}") from exc
40
+
41
+ routing = config.get("routing", {})
42
+ if not isinstance(routing, dict):
43
+ raise RoutingConfigError(f"{path}: [routing] must be a table")
44
+ for role, models in routing.items():
45
+ if role not in DEFAULT_ROUTING:
46
+ raise RoutingConfigError(f"{path}: unknown routing role {role!r}")
47
+ if not isinstance(models, list):
48
+ raise RoutingConfigError(f"{path}: routing.{role} must be a list")
49
+ for model in models:
50
+ if not isinstance(model, str) or model not in KNOWN_MODELS:
51
+ raise RoutingConfigError(f"{path}: unknown model {model!r} in routing.{role}")
52
+ return routing
53
+
54
+
55
+ def resolve(
56
+ home: Path,
57
+ repo: Path,
58
+ which: Callable[[str], str | None] = shutil.which,
59
+ env: Mapping[str, str] | None = None,
60
+ ) -> dict[str, list[str]]:
61
+ """Apply user and repo overrides, then keep only locally available models."""
62
+ env = os.environ if env is None else env
63
+ configured = dict(DEFAULT_ROUTING)
64
+ configured.update(_routing_from(Path(home) / ".caddis" / "config.toml"))
65
+ configured.update(_routing_from(Path(repo) / ".caddis" / "config.toml"))
66
+
67
+ providers = set(oss_model.configured_providers(dict(env)))
68
+ available = {"opus", "sonnet", "claude"} | (providers & {"glm", "deepseek"})
69
+ for tool in ("agy", "codex"):
70
+ if which(tool):
71
+ available.add(tool)
72
+
73
+ resolved = {}
74
+ for role, models in configured.items():
75
+ resolved[role] = list(dict.fromkeys(
76
+ model for model in models if model != "claude" and model in available
77
+ )) + ["claude"]
78
+ return resolved
79
+
80
+
81
+ def main(argv: list[str] | None = None, env: Mapping[str, str] | None = None) -> int:
82
+ parser = argparse.ArgumentParser(description=__doc__)
83
+ commands = parser.add_subparsers(dest="command", required=True)
84
+ show = commands.add_parser("show", help="print the resolved routing table")
85
+ show.add_argument("--json", action="store_true", help="print JSON")
86
+ show.add_argument("--repo", type=Path, default=Path.cwd(), help="repository path")
87
+ args = parser.parse_args(argv)
88
+
89
+ try:
90
+ routing = resolve(Path.home(), args.repo, env=env)
91
+ except RoutingConfigError as exc:
92
+ print(exc, file=sys.stderr)
93
+ return 3
94
+ if args.json:
95
+ print(json.dumps(routing))
96
+ else:
97
+ for role, models in routing.items():
98
+ print(f"{role}: {', '.join(models)}")
99
+ return 0
100
+
101
+
102
+ if __name__ == "__main__":
103
+ raise SystemExit(main())
@@ -77,6 +77,7 @@
77
77
  |-------|------|-------------|
78
78
  | 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. |
79
79
  | Catchup | `catchup/` | Where were we? A quick list of what this session was doing and what is still open |
80
+ | 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 |
80
81
  | Decide | `decide/` | Record a decision in one step — .caddis/decisions/<date>-<slug>.md |
81
82
  | Digress | `digress/` | Park the current workstream on the stack and switch to a new task — without losing the original |
82
83
  | 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,185 @@
1
+ ---
2
+ name: cloud-phases
3
+ description: Run plan phases in Claude Code cloud sessions — write one prompt per phase, push, hand out paste lines, then take the PRs back
4
+ ---
5
+
6
+ # /caddis:cloud-phases — run plan phases in the cloud, then take them back
7
+
8
+ Use this when a plan has phases that can run in parallel and you want them to run in Claude Code
9
+ cloud sessions (claude.ai/code) instead of on this machine. This session stays the orchestrator:
10
+ it writes the work orders, and it reviews and merges the results. The cloud sessions only implement.
11
+
12
+ **How the code moves.** Nothing syncs between the cloud and this disk. GitHub is the only meeting
13
+ point. This session pushes the feature branch and the prompts. Each cloud session clones from
14
+ GitHub, works on its own branch, and opens a pull request (PR) into the feature branch. This session
15
+ merges each PR on GitHub, then pulls. So a cloud session sees only what is committed and pushed.
16
+
17
+ Arguments: `$ARGUMENTS`
18
+
19
+ Two modes:
20
+ - `/caddis:cloud-phases [plan] [phases]` — **dispatch**: Steps 1–6.
21
+ - `/caddis:cloud-phases intake` — **intake**: Step 7, after the owner has run the sessions.
22
+
23
+ With no plan argument, use the active plan (`python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_todo.py" active-plan`,
24
+ or the one `status: current` plan in `.caddis/plans/`). If there is none, or more than one, ask.
25
+
26
+ ## Step 1 — check the preconditions (stop on any failure)
27
+
28
+ ```
29
+ git remote get-url origin
30
+ git branch --show-current
31
+ git status --short
32
+ gh auth status
33
+ ```
34
+
35
+ - `origin` must be a GitHub repository. Cloud sessions clone from GitHub; a repo with no GitHub
36
+ remote cannot use this command. Say so and stop.
37
+ - The current branch must be the plan's feature branch, never the default branch. The cloud PRs
38
+ target this branch, and it goes to `main` later through the normal `/caddis:ship-pr`.
39
+ - Tracked changes must be committed. A cloud session cannot see uncommitted work.
40
+ - `gh` must be signed in as an account that can push to the repository and open PRs.
41
+
42
+ ## Step 2 — pick the phases for the cloud
43
+
44
+ Take the plan's not-done Tracker rows (or the phase numbers given). Keep a phase **local** when
45
+ any of these is true, and say why:
46
+ - It needs a tool the cloud does not have: Codex, agy, GLM, DeepSeek, a local database, a VPN
47
+ host, or real data.
48
+ - It needs a gitignored file (for example `.caddis/relay/`, `.env`, keys). The cloud clone does
49
+ not have them.
50
+ - It needs the owner's go-ahead when it runs (model evals, production), or it is orchestrator
51
+ work (verification, measurement, release judgement).
52
+ - It depends on a phase that has not merged yet.
53
+
54
+ Then check the **Touches** lists. Two cloud phases may run at the same time only if they share no
55
+ file. When two phases touch the same file — a shared registry or manifest is the usual case — run
56
+ them one after the other, or keep that shared edit local and make it yourself after both merge.
57
+
58
+ ## Step 3 — check the facts each phase depends on (rule: think before you send)
59
+
60
+ For each cloud phase, at the feature branch's current head:
61
+ - Confirm every path, function and line number the phase names. Record what you found, with
62
+ line numbers and the short SHA.
63
+ - Write the edge cases the implementer must handle.
64
+ - Find the exact CI commands for the files it touches (read `.github/workflows/`).
65
+
66
+ A cloud session cannot ask you anything mid-run. Anything you do not write down, it will guess.
67
+
68
+ ## Step 4 — write one prompt per phase
69
+
70
+ Write `.caddis/prompts/<release-or-feature>-cloud-phase-<NN>.md`. Every prompt is self-contained,
71
+ with these sections in this order:
72
+
73
+ ```markdown
74
+ ---
75
+ type: prompt
76
+ title: <feature> Phase <N> (cloud) — <phase goal in a few words>
77
+ created: <YYYY-MM-DD>
78
+ ---
79
+
80
+ # <feature> Phase <N> — <phase title>
81
+
82
+ Suggested model: <Opus | Sonnet> (`/model <alias>`). <one line on why>
83
+
84
+ ## Where you are
85
+ - Repository `<owner>/<repo>`. Start from branch `<feature branch>` at its current head.
86
+ Create branch `<prefix>/phase-<NN>` from it. Work only on that branch.
87
+ - You run in a Claude cloud session. You have no Codex, agy, GLM or DeepSeek, and you cannot see
88
+ gitignored files. This file and the tracked repo hold everything you need.
89
+ - Read `AGENTS.md` at the repo root first. For background only, read the "Constraints" section of
90
+ `<plan path>`. Where it differs, this file wins.
91
+
92
+ ## The phase (copied from the plan)
93
+ <Goal, Touches, Design, TDD, Exit gate, Commit message — copied, not summarised>
94
+
95
+ ## Facts already checked (<date>, at <short SHA>)
96
+ - <path:line — what is there>
97
+
98
+ ## Edge cases to handle
99
+ - <edge case>
100
+
101
+ ## Checks to run before you open the PR (the same as CI)
102
+ - `<exact command>`
103
+ Report the literal result of each.
104
+
105
+ ## Finish
106
+ 1. Commit on `<prefix>/phase-<NN>` with the commit message above. Stage named files only.
107
+ 2. Push the branch: `git push -u origin <prefix>/phase-<NN>`.
108
+ 3. Open a PR into `<feature branch>` (never into the default branch). Put the check results in
109
+ the PR description.
110
+ 4. Do not merge. Do not touch other branches. Stop and report the PR link.
111
+ ```
112
+
113
+ The repository may be public. A prompt is committed and pushed, so it must hold no secrets, no
114
+ internal host names, no machine paths and no credentials.
115
+
116
+ ## Step 5 — commit and push, then prove GitHub has it
117
+
118
+ ```
119
+ git add <each prompt file by name>
120
+ git commit -m "docs(prompts): cloud prompts for <feature> phases <N, M>"
121
+ git push -u origin <feature branch>
122
+ git rev-parse HEAD
123
+ git ls-remote origin refs/heads/<feature branch>
124
+ ```
125
+
126
+ The two SHAs must match. If they do not, the cloud sessions will start from old code — stop and fix
127
+ the push first.
128
+
129
+ ## Step 6 — hand the owner the paste table, then stop
130
+
131
+ Print one row per cloud phase:
132
+
133
+ | Phase | Model | Branch to pick | Paste this |
134
+ |---|---|---|---|
135
+ | <N> | <Sonnet> | `<feature branch>` | `Read .caddis/prompts/<file> and follow it exactly.` |
136
+
137
+ Tell the owner, in these words: open https://claude.ai/code, choose repository `<owner>/<repo>`,
138
+ choose branch `<feature branch>`, paste the line, and start. Phases in the table share no file, so
139
+ they can run at the same time. Then **stop**. Record "dispatched to cloud, PR pending" in each
140
+ phase's Tracker row. When the PRs exist, the owner runs `/caddis:cloud-phases intake`.
141
+
142
+ Do not use the Agent tool with `isolation: "remote"` for this. It has run locally instead of in the
143
+ cloud, which uses no cloud credit and leaves a worktree behind.
144
+
145
+ ## Step 7 — intake: review and merge each PR, then pull
146
+
147
+ ```
148
+ gh pr list --base <feature branch> --state open --json number,title,headRefName,headRefOid
149
+ ```
150
+
151
+ For each PR from a `<prefix>/phase-<NN>` branch, one at a time:
152
+ 1. Read the whole diff (`gh pr diff <N>`). Check it against the phase's Design and Touches. A file
153
+ outside Touches is a finding.
154
+ 2. Wait for CI: `gh pr checks <N> --watch` (or the ci-watch lane, if this caddis has it).
155
+ 3. Review under the three rules: one review, stop at the first CLEAN; after two blocking reviews,
156
+ ask the owner. A fix goes back to the same cloud session or is made locally on the PR branch.
157
+ 4. Just before merging, re-read `gh pr view <N> --json headRefOid,mergeStateStatus`. Merge only if
158
+ the head SHA is the one you reviewed and CI passed on it:
159
+ `gh pr merge <N> --squash --delete-branch`.
160
+ 5. Pull: `git pull --ff-only origin <feature branch>`.
161
+ 6. Run the phase's exit gate locally, and after the last PR, the full test suite.
162
+ 7. Update the phase's Tracker row: status, PR number, merge commit, review result. Set the prompt's
163
+ frontmatter `status: done` so `/caddis:handoff` tidies it.
164
+
165
+ Merge in dependency order. If a later PR conflicts after an earlier merge, ask its session to
166
+ rebase on the feature branch, or resolve it locally on that PR branch — never on the feature branch
167
+ directly.
168
+
169
+ ## Running the orchestrator in the cloud too
170
+ The orchestrator can itself be a cloud session. Nothing above needs this machine, because every
171
+ step goes through GitHub. What changes:
172
+ - The owner still opens each worker session by hand at claude.ai/code. An orchestrator cannot
173
+ start cloud sessions itself.
174
+ - The cloud orchestrator has no Codex, agy, GLM or DeepSeek. Phases on those lanes, and
175
+ second-vendor reviews, stay for a local session.
176
+ - It cannot see gitignored files, so there is no relay: `/caddis:handoff` state does not carry to
177
+ the next session. Put the resume state in the plan's Tracker and push it.
178
+ - Its container ends with the session. Push before you stop, every time.
179
+
180
+ ## Rules
181
+ - This command never merges into the default branch. The feature branch goes there later through
182
+ `/caddis:ship-pr`, and the production boundary check still applies there.
183
+ - One phase, one prompt, one branch, one PR.
184
+ - The orchestrator reads every diff. A green CI run proves only that the tests pass, not that the
185
+ phase did what its Design says.
@@ -154,6 +154,21 @@ session that is handed a phase belonging to another lane spawns that lane rather
154
154
  work; a headless one records the deviation in the Tracker. Write the launch command out in full, per
155
155
  phase — it is executed verbatim.
156
156
 
157
+ **Choose each phase's Lane from the routing table.** Run
158
+ `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_routing.py" show --json`. A phase the orchestrator
159
+ does itself (verification, measurement, release judgement) uses the `critical` role. A phase whose
160
+ Implementer line says "light" uses `light`. Every other phase uses `coding`. Take the first model
161
+ of that role and write its Lane line as follows:
162
+
163
+ - `opus`: `claude — /model opus`
164
+ - `sonnet`: `claude — /model sonnet`
165
+ - `claude`: `claude — this session`
166
+ - `codex`: codex — `codex exec --ephemeral --sandbox workspace-write "HEADLESS RUN RULES. Read <plan path> and its Constraints. Implement Phase N only in the current checkout. Do not switch or create branches, commit, push, or edit files outside this phase. Run its exit gate and report the literal result."`
167
+ - `agy`: agy — `agy --mode accept-edits --dangerously-skip-permissions --add-dir . --print-timeout 20m --output-format text -p "HEADLESS RUN RULES. Read <plan path> and its Constraints. Implement Phase N only in the current checkout. Do not switch or create branches, commit, push, or edit files outside this phase. Run its exit gate and report the literal result."`
168
+
169
+ Replace `<plan path>` and `N` with this plan's path and phase number. If the routing command fails
170
+ or exits 3, use `claude — this session` and state the reason in that phase.
171
+
157
172
  #### A phase may take a no-escalation lane only if its exit gate would fail on a spec error
158
173
  `glm-headless` cannot escalate: it does what the phase says. So completeness is only half the test —
159
174
  the other half is whether being **wrong** would be caught. Ask literally: *"if the instruction I just
@@ -166,8 +166,16 @@ row, visible to any later reader without an Advisory Hub having to catch it live
166
166
  lane worktrees and branches. It reports unmerged or dirty ones. It pushes the feature branch.
167
167
  The plan is not done until it prints `LEFTOVERS: 0`. Then run `/caddis:ship-pr`. Merging and
168
168
  post-merge cleanup stay with `/caddis:ship-merge` after the owner's go.
169
- 10. **When Claude is out of budget**, the owner opens `caddis-run codex -m gpt-6-astra` and asks it
170
- to follow this same subsection.
169
+ 10. **When a dispatched Claude run is out of budget**, read the output from the subagent or
170
+ `claude -p`. If it holds Claude usage-limit text (for example `You've hit your session limit`
171
+ or `You're out of extra usage`; the full list is `BUDGET_PATTERNS["claude"]` in
172
+ `caddis_lanes.py`), run `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" next-lane --role <the phase's role> --after <the model that ran>`.
173
+ Re-dispatch the same phase prompt to the printed lane with its direct launch command:
174
+ `codex exec ...` or `agy ... -p "..."`, using the same form that `/feature-plan` writes.
175
+ An out-of-budget switch is not an attempt (item 4). Record the fallback in the Tracker
176
+ `Lane` column, for example `sonnet -> agy (claude out of budget)`.
177
+ If `next-lane` exits 3, stop and report that no lane is left. As a last resort, the owner can
178
+ open `caddis-run codex -m gpt-6-astra` and ask it to follow this same subsection.
171
179
 
172
180
  **Advisory-Hub mode (conditional — OFF by default).** Check whether a companion file
173
181
  `<plan-dir>/<plan-stem>-advisory-context.md` exists (e.g. plan `.caddis/plans/foo.md` → look for
@@ -94,7 +94,13 @@ what failed.
94
94
 
95
95
  Watch the PR's checks job-by-job until completion:
96
96
  - Gitea lane: poll the run for the PR's head SHA via the API (`deploy-local` skill procedure).
97
- - GitHub lane: `gh pr checks <pr> --watch` / `gh run watch` (`gh-cli` skill).
97
+ - GitHub lane: first record the head you are about to watch, then wait for PR checks outside the Claude session via `ci-watch`:
98
+ ```bash
99
+ gh pr view <pr> --json headRefOid # write down headRefOid as <watched-sha>
100
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --pr <pr>
101
+ ```
102
+ Exit codes: **0** pass → continue; **1** fail → read `FAILING:` and the gh output, classify the cause, apply the minimum source fix, and re-push (stop before merging); **3** could not tell → stop and report, do not merge.
103
+ Fallback when `caddis_lanes.py` is not available: watch in-session with `gh pr checks <pr> --watch` / `gh run watch` (`gh-cli` skill).
98
104
  - If any check fails (red check): report the failure, classify the cause, and **stop before merging**.
99
105
  Do not merge a failing PR. Apply the minimum source fix and re-push.
100
106
 
@@ -115,6 +121,11 @@ cannot bypass it.
115
121
  ## Step 7 — Merge to default branch
116
122
 
117
123
  Once all required checks are **green**, there are no merge conflicts, and review requirements are satisfied:
124
+ - Re-read PR head and merge state before merging (GitHub lane):
125
+ ```bash
126
+ gh pr view <pr> --json headRefOid,mergeStateStatus
127
+ ```
128
+ Merge only when `headRefOid` equals `<watched-sha>`, the head recorded before the watch started and `mergeStateStatus` is `CLEAN` (or `HAS_HOOKS`/`UNSTABLE` only if the repo's own rules allow it; else stop and report). The ci-watch report is a signal, not proof.
118
129
  - Use the repo's configured **merge strategy** — read it from repo settings (Gitea repo settings API /
119
130
  `gh repo view --json squashMergeAllowed,mergeCommitAllowed,rebaseMergeAllowed`) and `AGENTS.md`;
120
131
  an explicit argument wins. Options: **squash**, **merge-commit**, rebase-merge. **Don't assume** —
@@ -128,7 +139,12 @@ Once all required checks are **green**, there are no merge conflicts, and review
128
139
 
129
140
  If the default branch triggers an automated deployment pipeline:
130
141
  - Gitea: `lint_and_test` → `deploy_prod` → `release_metadata` (`deploy-local` skill).
131
- - GitHub: `gh run watch <run-id> --exit-status` on the default branch. If CI-only, note that no deploy occurs.
142
+ - GitHub: wait for the deploy run on the default branch outside the Claude session via `ci-watch`:
143
+ ```bash
144
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --run <run-id>
145
+ ```
146
+ Exit codes: **0** pass → continue; **1** fail → read `FAILING:` and the gh output, classify, apply the minimum source fix; **3** could not tell → stop, report, and **skip cleanup** (Step 9).
147
+ Fallback when `caddis_lanes.py` is not available: watch in-session with `gh run watch <run-id> --exit-status` on the default branch (`gh-cli` skill). If CI-only, note that no deploy occurs.
132
148
  - Validate health and deployed SHA if a live service is deployed. If deploy validation fails, report
133
149
  immediately and **skip cleanup** (Step 9).
134
150
 
@@ -95,12 +95,17 @@ Track jobs in order: `lint_and_test` → `frontend_checks` (if present) → `dep
95
95
  `release_metadata` → `notify`. Report status as each job completes. On failure, classify it and apply
96
96
  the minimum fix (see `deploy-local` skill §6).
97
97
 
98
- **GitHub lane** — Watch the run the push triggered via the `gh` CLI:
99
- ```
98
+ **GitHub lane** — Find the run id for the pushed SHA, then wait outside the Claude session via `ci-watch`:
99
+ ```bash
100
100
  gh run list --branch <branch> --limit 1 # find the run id for the pushed SHA
101
- gh run watch <run-id> --exit-status # stream until done; non-zero on failure
101
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --run <run-id>
102
+ ```
103
+ Exit codes: **0** pass → continue; **1** fail → read `FAILING:` and the gh output, classify, apply the minimum source fix; **3** could not tell → stop and report.
104
+ On failure, pull only the failing job logs:
105
+ ```bash
102
106
  gh run view <run-id> --log-failed # on failure, pull only the failing job logs
103
107
  ```
108
+ Fallback when `caddis_lanes.py` is not available: watch in-session with `gh run watch <run-id> --exit-status` (`gh-cli` skill).
104
109
  Report each job's status as it resolves. On failure, classify it and apply the minimum source fix.
105
110
 
106
111
  **Local-only lane** — no CI; skip monitoring.
@@ -64,10 +64,24 @@ future run — ask every time, even for the same PR five minutes later.
64
64
 
65
65
  ## Step 3 — Merge
66
66
 
67
+ Re-read PR head and merge state before merging (GitHub lane):
68
+ ```bash
69
+ gh pr view <pr> --json headRefOid,mergeStateStatus
70
+ ```
71
+ Merge only when `headRefOid` equals `<head-sha>` (the head that Step 1 found green and Step 2's
72
+ confirmation named) and `mergeStateStatus` is `CLEAN` (or `HAS_HOOKS`/`UNSTABLE` only if the repo's
73
+ own rules allow it). Otherwise stop and report: a new push needs a fresh Step 1 and a fresh confirm.
74
+ A check report is a signal, not proof.
75
+
67
76
  Use the repo's configured **merge strategy** — read it from repo settings (Gitea repo settings API /
68
77
  `gh repo view --json squashMergeAllowed,mergeCommitAllowed,rebaseMergeAllowed`) and `AGENTS.md`;
69
- an explicit argument wins. Options: **squash**, **merge-commit**, rebase-merge. **Don't assume** —
78
+ an explicit argument wins. Options: **squash**, **merge-commit**, **rebase-merge**. **Don't assume** —
70
79
  if detection is inconclusive, say so and use merge-commit (never silently squash someone's history).
80
+
81
+ Then merge:
82
+ - GitHub: `gh pr merge <pr>` with `--squash` (squash), `--merge` (merge-commit) or `--rebase` (rebase-merge).
83
+ - Gitea: call the Gitea PR merge API with the chosen strategy.
84
+
71
85
  Record the merge SHA.
72
86
 
73
87
  ## Step 4 — Monitor the deploy
@@ -75,8 +89,12 @@ Record the merge SHA.
75
89
  The merge lands on the default branch and triggers the pipeline. Watch it via the lane's skill:
76
90
  - Gitea: `lint_and_test` → `frontend_checks` (if present) → **`deploy_prod`** → `release_metadata`
77
91
  → `notify` (`deploy-local` skill §monitoring; classify + minimum-fix on failure).
78
- - GitHub: `gh run watch <run-id> --exit-status` on the default branch; if the workflow has a deploy
79
- job, watch it through; if CI-only, note that no deploy occurs.
92
+ - GitHub: wait for the deploy run on the default branch outside the Claude session via `ci-watch`:
93
+ ```bash
94
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --run <run-id>
95
+ ```
96
+ Exit codes: **0** pass → continue; **1** fail → read `FAILING:` and the gh output, classify, apply the minimum source fix; **3** could not tell → stop, report, and **skip cleanup** (Step 6).
97
+ Fallback when `caddis_lanes.py` is not available: watch in-session with `gh run watch <run-id> --exit-status` on the default branch (`gh-cli` skill). If the workflow has a deploy job, watch it through; if CI-only, note that no deploy occurs.
80
98
 
81
99
  ## Step 5 — Post-deploy validation
82
100
 
@@ -103,7 +103,12 @@ what failed.
103
103
  Watch the PR's checks job-by-job (never the deploy — PRs don't deploy here), including the
104
104
  scope-guard check when present:
105
105
  - Gitea lane: poll the run for the PR's head SHA via the API (`deploy-local` skill procedure).
106
- - GitHub lane: `gh pr checks <pr> --watch` / `gh run watch` (`gh-cli` skill).
106
+ - GitHub lane: wait for PR checks outside the Claude session via `ci-watch`:
107
+ ```bash
108
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --pr <pr>
109
+ ```
110
+ Exit codes: **0** pass → continue; **1** fail → read `FAILING:` and the gh output, classify, apply the minimum source fix, then re-push (through Step 4's safety if history must change); **3** could not tell → stop and report, do not merge.
111
+ Fallback when `caddis_lanes.py` is not available: watch in-session with `gh pr checks <pr> --watch` / `gh run watch` (`gh-cli` skill).
107
112
  On a red check, classify the failure and apply the minimum **source** fix, then re-push (through
108
113
  Step 4's safety if history must change).
109
114
 
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.122",
2
+ "poolVersion": "1.3.123",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.122",
4
+ "antigravity-plugin": "1.3.123",
5
5
  "antigravity-plugin-extras": "1.3.40",
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.35",
3
+ "version": "0.4.36",
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",