@caddis/cli 0.4.35 → 0.4.37

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.124",
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
  }
@@ -21,9 +21,6 @@ that hard-fails when it cannot read its inputs breaks every consumer the day it
21
21
  first thing anyone does with a gate that cries wolf is switch it off. Only a genuine, positively
22
22
  identified violation is non-zero.
23
23
 
24
- One exception: `deploy-boundary` guards the production boundary and FAILS CLOSED. When it
25
- cannot tell, it exits 3 ("could not run" on the caddis scale), never 0.
26
-
27
24
  EXIT CODES
28
25
  0 proceed
29
26
  1 blocked — a real violation; the caller must stop
@@ -96,10 +93,11 @@ def phase_lane(block: str) -> str:
96
93
  def launch_command(block: str) -> str:
97
94
  """The literal command for a non-claude lane, if the phase names one.
98
95
 
99
- Also matches a caddis_lanes.py run command for the codex, agy and glm lanes.
96
+ Also matches a caddis_lanes.py run command or a direct codex/agy command.
100
97
  """
101
98
  m = re.search(
102
- r'`((?:claude-(?:glm|oss|deepseek)|' + _LANES_RUN + r')[^`]*)`',
99
+ r'`((?:claude-(?:glm|oss|deepseek)\b|' + _LANES_RUN
100
+ + r'\b|codex\s+exec\b|agy\b)[^`]*)`',
103
101
  block,
104
102
  )
105
103
  return m.group(1).strip() if m else ""
@@ -142,6 +140,28 @@ def gate_lane(plan: Path, phase: int) -> int:
142
140
  f"[caddis-gate] phase {phase}'s caddis_lanes command names --phase {named.group(1)}: {cmd}\n"
143
141
  " The lane would run another phase's work. Refusing to run it.\n")
144
142
  return EXIT_MALFORMED
143
+ elif re.match(r"(?:codex\s+exec|agy)\b", cmd):
144
+ if "HEADLESS RUN RULES" not in cmd:
145
+ sys.stderr.write(
146
+ f"[caddis-gate] phase {phase}'s direct command has no HEADLESS RUN RULES: {cmd}\n"
147
+ " The child has no headless run rules. Refusing to run it.\n")
148
+ return EXIT_MALFORMED
149
+ named = re.search(r"Implement Phase (\d+) only", cmd)
150
+ if not named:
151
+ sys.stderr.write(
152
+ f"[caddis-gate] phase {phase}'s direct command has no phase restriction: {cmd}\n"
153
+ " The lane could run the wrong phase. Refusing to run it.\n")
154
+ return EXIT_MALFORMED
155
+ if int(named.group(1)) != phase:
156
+ sys.stderr.write(
157
+ f"[caddis-gate] phase {phase}'s direct command names Phase {named.group(1)}: {cmd}\n"
158
+ " The lane would run another phase's work. Refusing to run it.\n")
159
+ return EXIT_MALFORMED
160
+ if cmd.startswith("agy") and not re.search(r"(?:^|\s)(?:-p|--print)(?:\s|$)", cmd):
161
+ sys.stderr.write(
162
+ f"[caddis-gate] phase {phase}'s direct agy command has no -p/--print: {cmd}\n"
163
+ " The child would not run headless. Refusing to run it.\n")
164
+ return EXIT_MALFORMED
145
165
  elif not re.search(r"(?:^|\s)(?:-p|--print)(?:\s|$)", cmd):
146
166
  sys.stderr.write(
147
167
  f"[caddis-gate] phase {phase}'s launch command has no -p/--print: {cmd}\n"
@@ -628,166 +648,6 @@ def gate_docs_check(root: Path) -> int:
628
648
  return EXIT_BLOCKED if failures else EXIT_OK
629
649
 
630
650
 
631
- # ── gate: deploy-boundary ───────────────────────────────────────────────────
632
- # The owner's production boundary: an agent never merges into a repo whose default branch deploys
633
- # on every push. /ship, /ship-merge and /pr-merge run this before any merge or push to the default
634
- # branch, and hand the merge to the owner on anything but exit 0.
635
- #
636
- # FAILS CLOSED — the one deliberate exception to DEGRADE OPEN above. "Cannot tell" is exit 3,
637
- # never 0, because a false "clean" here is a production deploy nobody approved. Any positive
638
- # signal wins over every negative one: config cannot switch the check off.
639
- EXIT_CANNOT_TELL = 3
640
-
641
- _WORKFLOW_DIRS = (".github/workflows", ".gitea/workflows", ".forgejo/workflows")
642
-
643
- # An inherited GIT_DIR (from a hook or wrapper) would make `git -C <repo>` read another repository.
644
- _GIT_REPO_ENV = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY",
645
- "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_NAMESPACE")
646
-
647
-
648
- def _git_out(repo: Path, *args: str) -> str | None:
649
- """stdout of a git command in `repo`, or None when it fails."""
650
- env = {k: v for k, v in os.environ.items() if k not in _GIT_REPO_ENV}
651
- try:
652
- r = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True,
653
- encoding="utf-8", errors="replace", timeout=30, env=env)
654
- except (OSError, subprocess.SubprocessError):
655
- return None
656
- return r.stdout if r.returncode == 0 else None
657
-
658
-
659
- def _ref_exists(repo: Path, ref: str) -> bool:
660
- return _git_out(repo, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}") is not None
661
-
662
-
663
- def _default_branch_refs(repo: Path) -> list[str]:
664
- """Every copy of the default branch: each remote's and the local branch of that name.
665
-
666
- The name comes from any remote's recorded HEAD; with none recorded, `main` and `master` stand
667
- in. Reading every copy is deliberate: a positive signal on any of them blocks.
668
- """
669
- remotes = (_git_out(repo, "remote") or "").split()
670
- names: set[str] = set()
671
- for remote in remotes:
672
- target = (_git_out(repo, "symbolic-ref", "--quiet", f"refs/remotes/{remote}/HEAD") or "").strip()
673
- if target.startswith(f"refs/remotes/{remote}/"):
674
- names.add(target[len(f"refs/remotes/{remote}/"):])
675
- names = names or {"main", "master"}
676
- refs = [f"refs/remotes/{r}/{n}" for r in remotes for n in sorted(names)]
677
- refs += [f"refs/heads/{n}" for n in sorted(names)]
678
- return [r for r in refs if _ref_exists(repo, r)]
679
-
680
-
681
- def _config_production_on_main(text: str) -> bool | None:
682
- """True/False from `[deploy] production_on_main`; None when absent. Raises on a bad file."""
683
- import tomllib
684
- deploy = tomllib.loads(text).get("deploy", {})
685
- if not isinstance(deploy, dict):
686
- raise ValueError("[deploy] is not a table")
687
- value = deploy.get("production_on_main")
688
- if value is not None and not isinstance(value, bool):
689
- raise ValueError(f"production_on_main is {value!r}, not true or false")
690
- return value
691
-
692
-
693
- # A deploy is often not named "deploy". Letters-only boundaries on prod, so `deploy_prod` matches
694
- # and `product` or `reproduce` do not.
695
- _DEPLOY_WORD = re.compile(r"deploy|release|publish|(?<![a-z])prod(?:uction)?(?![a-z])", re.I)
696
-
697
-
698
- def _workflow_deploys(text: str) -> bool:
699
- """True when the workflow's name, a job id or a job name looks like a deploy, or a job sets a
700
- GitHub `environment:` (the forge's own marker for a deployment). Raises on a bad file.
701
-
702
- The caller also checks the file name: a forge shows the file path as the workflow's name when
703
- `name:` is unset.
704
- """
705
- import yaml
706
- doc = yaml.safe_load(text)
707
- if not isinstance(doc, dict) or not isinstance(doc.get("jobs"), dict):
708
- raise ValueError("not a workflow (no jobs mapping)")
709
- labels = [doc.get("name")]
710
- for job_id, job in doc["jobs"].items():
711
- labels.append(job_id)
712
- if isinstance(job, dict):
713
- if job.get("environment"):
714
- return True
715
- labels.append(job.get("name"))
716
- return any(_DEPLOY_WORD.search(str(label)) for label in labels if label is not None)
717
-
718
-
719
- def gate_deploy_boundary(repo: Path) -> int:
720
- """EXIT_BLOCKED when a push to the default branch deploys to production, EXIT_OK when it
721
- positively does not, EXIT_CANNOT_TELL otherwise."""
722
- blocked: list[str] = []
723
- unknown: list[str] = []
724
-
725
- top = _git_out(repo, "rev-parse", "--show-toplevel") if repo.is_dir() else None
726
- root = Path(top.strip()) if top else repo
727
- refs = _default_branch_refs(root) if top else []
728
- if not repo.is_dir():
729
- unknown.append(f"{repo} is not a directory")
730
- elif not top:
731
- unknown.append(f"{repo} is not a git repository")
732
- elif not refs:
733
- unknown.append("cannot find the default branch (no remote HEAD, no main or master)")
734
- # HEAD is what is about to be merged or pushed: a deploy it adds lands on the default branch.
735
- # It only ever adds signals; the default branch is still read, so deleting there hides nothing.
736
- sources = refs + (["HEAD"] if top and _ref_exists(root, "HEAD") else [])
737
-
738
- # Config: the working tree and every source. Only `true` counts; config cannot switch it off.
739
- configs: list[tuple[str, str | None]] = []
740
- cfg = root / ".caddis" / "config.toml"
741
- if cfg.is_file():
742
- configs.append(("working tree", None))
743
- for ref in sources:
744
- text = _git_out(root, "show", f"{ref}:.caddis/config.toml")
745
- if text is not None:
746
- configs.append((ref, text))
747
- for where, text in configs:
748
- try:
749
- if _config_production_on_main(cfg.read_text(encoding="utf-8") if text is None else text):
750
- blocked.append(f".caddis/config.toml ({where}) sets production_on_main = true")
751
- except Exception as exc:
752
- unknown.append(f".caddis/config.toml ({where}) unreadable: {exc}")
753
-
754
- # Workflows: read from committed trees, never the working tree.
755
- for ref in sources:
756
- listing = _git_out(root, "ls-tree", "-r", "-z", "--name-only", ref, "--", *_WORKFLOW_DIRS)
757
- if listing is None:
758
- unknown.append(f"cannot list workflows on {ref}")
759
- continue
760
- for path in listing.split("\0"):
761
- # Forges run only the files directly inside the workflows directory.
762
- if path.rsplit("/", 1)[0] not in _WORKFLOW_DIRS:
763
- continue
764
- if not path.lower().endswith((".yml", ".yaml")):
765
- continue
766
- text = _git_out(root, "show", f"{ref}:{path}")
767
- try:
768
- if text is None:
769
- raise OSError("git show failed")
770
- if _DEPLOY_WORD.search(path.rsplit("/", 1)[1]) or _workflow_deploys(text):
771
- blocked.append(f"{path} on {ref} deploys")
772
- except Exception as exc:
773
- unknown.append(f"{path} on {ref} unreadable: {type(exc).__name__}: {exc}")
774
-
775
- if blocked:
776
- print("[gate] deploy-boundary: BLOCKED — a push to the default branch deploys to production")
777
- for reason in dict.fromkeys(blocked):
778
- print(f" {reason}")
779
- print(" Do not merge. Hand the merge to the owner with the PR link.")
780
- return EXIT_BLOCKED
781
- if unknown:
782
- print("[gate] deploy-boundary: CANNOT TELL — treat as production")
783
- for reason in dict.fromkeys(unknown):
784
- print(f" {reason}")
785
- print(" Do not merge. Hand the merge to the owner with the PR link.")
786
- return EXIT_CANNOT_TELL
787
- print(f"[gate] deploy-boundary: clean — no deploy signal found on {', '.join(sources)}")
788
- return EXIT_OK
789
-
790
-
791
651
  def main(argv: list[str] | None = None) -> int:
792
652
  ap = argparse.ArgumentParser(description="caddis machine gates")
793
653
  sub = ap.add_subparsers(dest="cmd", required=True)
@@ -815,17 +675,9 @@ def main(argv: list[str] | None = None) -> int:
815
675
  help="block a doc longer than this (relays: 4000); default: no cap")
816
676
  dc = sub.add_parser("docs-check")
817
677
  dc.add_argument("--root", default=".")
818
- db = sub.add_parser("deploy-boundary")
819
- db.add_argument("--repo", default=".")
820
678
  a = ap.parse_args(argv)
821
679
 
822
680
  plan = Path(getattr(a, "plan", "") or ".")
823
- if a.cmd == "deploy-boundary":
824
- try:
825
- return gate_deploy_boundary(Path(a.repo).resolve())
826
- except Exception as exc:
827
- sys.stderr.write(f"[caddis-gate] deploy-boundary cannot evaluate: {type(exc).__name__}: {exc}\n")
828
- return EXIT_CANNOT_TELL # never degrade open across the production boundary
829
681
  if a.cmd == "docs-check":
830
682
  try:
831
683
  return gate_docs_check(Path(a.root).resolve())
@@ -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`.
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,27 +94,24 @@ 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
 
101
- ## Step 6b — Deploy boundary (before the merge; nothing skips it)
102
-
103
- The PR exists and is green. Run this before the merge:
104
-
105
- ```bash
106
- python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" deploy-boundary --repo .
107
- ```
108
-
109
- Exit **0** — go on to Step 7. Exit **1** (a push to the default branch deploys to production), exit
110
- **3** (it cannot tell), or any other non-zero code — **stop. Do not merge.** Hand the merge to the
111
- owner with the PR link; skip Steps 7–9. Show the gate's output and end the command. This holds
112
- whatever confirmation was given: `--yes`, a confirm flag, "just merge it" or an earlier approval
113
- cannot bypass it.
114
-
115
107
  ## Step 7 — Merge to default branch
116
108
 
117
109
  Once all required checks are **green**, there are no merge conflicts, and review requirements are satisfied:
110
+ - Re-read PR head and merge state before merging (GitHub lane):
111
+ ```bash
112
+ gh pr view <pr> --json headRefOid,mergeStateStatus
113
+ ```
114
+ 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
115
  - Use the repo's configured **merge strategy** — read it from repo settings (Gitea repo settings API /
119
116
  `gh repo view --json squashMergeAllowed,mergeCommitAllowed,rebaseMergeAllowed`) and `AGENTS.md`;
120
117
  an explicit argument wins. Options: **squash**, **merge-commit**, rebase-merge. **Don't assume** —
@@ -128,7 +125,12 @@ Once all required checks are **green**, there are no merge conflicts, and review
128
125
 
129
126
  If the default branch triggers an automated deployment pipeline:
130
127
  - 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.
128
+ - GitHub: wait for the deploy run on the default branch outside the Claude session via `ci-watch`:
129
+ ```bash
130
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --run <run-id>
131
+ ```
132
+ 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).
133
+ 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
134
  - Validate health and deployed SHA if a live service is deployed. If deploy validation fails, report
133
135
  immediately and **skip cleanup** (Step 9).
134
136
 
@@ -157,7 +159,6 @@ Cleanup: remote branch deleted / local branch deleted / backup ref dropped
157
159
  ## Rules
158
160
 
159
161
  - Never merge a PR whose required checks are red or absent.
160
- - Never merge unless `deploy-boundary` exited 0 on this run. No confirmation overrides it.
161
162
  - Never delete a feature branch before its merge is confirmed and any deploy validated.
162
163
  - Never `git add -A` without reviewing `git status` first (stage named files only).
163
164
  - Never force-push without a `backup/*-preship` recovery ref; force pushes are `--force-with-lease` only.
@@ -64,19 +64,6 @@ over 400 changed lines. No judgement in any of those — deliberately, because j
64
64
  what failed.
65
65
  <!-- /shared:cross-review-trigger -->
66
66
 
67
- **2c. DEPLOY BOUNDARY** — before the push; nothing skips it:
68
-
69
- ```bash
70
- python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" deploy-boundary --repo .
71
- ```
72
-
73
- Exit **0** (no deploy signal found) — push. Exit **1** (a push to the default branch deploys to
74
- production), exit **3** (it cannot tell), or any other non-zero code — **stop. Do not merge or push to
75
- the default branch.** The commit stays local. If it sits on the default branch, move it to a feature
76
- branch first (`git switch -c <feature-branch>`). Then open a PR with `/ship-pr` and hand the merge
77
- to the owner with the PR link. Show the gate's output and end the command. This holds whatever confirmation was given:
78
- `--yes`, a confirm flag, "just merge it" or an earlier approval cannot bypass it.
79
-
80
67
  **3. PUSH** — to the repo's default branch (confirm the branch first; don't assume `main`):
81
68
  ```
82
69
  git push origin <branch>
@@ -95,12 +82,17 @@ Track jobs in order: `lint_and_test` → `frontend_checks` (if present) → `dep
95
82
  `release_metadata` → `notify`. Report status as each job completes. On failure, classify it and apply
96
83
  the minimum fix (see `deploy-local` skill §6).
97
84
 
98
- **GitHub lane** — Watch the run the push triggered via the `gh` CLI:
99
- ```
85
+ **GitHub lane** — Find the run id for the pushed SHA, then wait outside the Claude session via `ci-watch`:
86
+ ```bash
100
87
  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
88
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --run <run-id>
89
+ ```
90
+ 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.
91
+ On failure, pull only the failing job logs:
92
+ ```bash
102
93
  gh run view <run-id> --log-failed # on failure, pull only the failing job logs
103
94
  ```
95
+ Fallback when `caddis_lanes.py` is not available: watch in-session with `gh run watch <run-id> --exit-status` (`gh-cli` skill).
104
96
  Report each job's status as it resolves. On failure, classify it and apply the minimum source fix.
105
97
 
106
98
  **Local-only lane** — no CI; skip monitoring.
@@ -127,7 +119,6 @@ Release tag: <tag> → <sha> | n/a
127
119
 
128
120
  ## Rules
129
121
  - Never push before preflight gates are green.
130
- - Never push to the default branch unless `deploy-boundary` exited 0 on this run.
131
122
  - Never use `git add -A` without reviewing `git status` first.
132
123
  - Do not edit a workflow file (`.gitea/workflows/` or `.github/workflows/`) to make a failing gate
133
124
  pass — fix the source.
@@ -28,20 +28,6 @@ Resolve the target PR from `$ARGUMENTS`, else the current branch's open PR. No o
28
28
  that and point at `/ship-pr`. Read `AGENTS.md` + the workflow file(s) for the repo's real deploy
29
29
  job names and default branch — never assume.
30
30
 
31
- ## Step 0b — Deploy boundary (before the confirm; nothing skips it)
32
-
33
- Run it before asking for any confirmation, in a checkout of the PR's own repository. If the
34
- PR belongs to another repository, treat that as exit **3**.
35
-
36
- ```bash
37
- python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" deploy-boundary --repo .
38
- ```
39
-
40
- Exit **0** — go on to Step 1. Exit **1** (a push to the default branch deploys to production), exit
41
- **3** (it cannot tell), or any other non-zero code — **stop. Do not merge.** Hand the merge to the
42
- owner with the PR link. Show the gate's output and end the command. This holds whatever confirmation
43
- was given: `--yes`, a confirm flag, "just merge it" or an earlier approval cannot bypass it.
44
-
45
31
  ## Step 1 — Refuse unless green + mergeable + reviewed
46
32
 
47
33
  All of, per the repo's own rules:
@@ -64,10 +50,24 @@ future run — ask every time, even for the same PR five minutes later.
64
50
 
65
51
  ## Step 3 — Merge
66
52
 
53
+ Re-read PR head and merge state before merging (GitHub lane):
54
+ ```bash
55
+ gh pr view <pr> --json headRefOid,mergeStateStatus
56
+ ```
57
+ Merge only when `headRefOid` equals `<head-sha>` (the head that Step 1 found green and Step 2's
58
+ confirmation named) and `mergeStateStatus` is `CLEAN` (or `HAS_HOOKS`/`UNSTABLE` only if the repo's
59
+ own rules allow it). Otherwise stop and report: a new push needs a fresh Step 1 and a fresh confirm.
60
+ A check report is a signal, not proof.
61
+
67
62
  Use the repo's configured **merge strategy** — read it from repo settings (Gitea repo settings API /
68
63
  `gh repo view --json squashMergeAllowed,mergeCommitAllowed,rebaseMergeAllowed`) and `AGENTS.md`;
69
- an explicit argument wins. Options: **squash**, **merge-commit**, rebase-merge. **Don't assume** —
64
+ an explicit argument wins. Options: **squash**, **merge-commit**, **rebase-merge**. **Don't assume** —
70
65
  if detection is inconclusive, say so and use merge-commit (never silently squash someone's history).
66
+
67
+ Then merge:
68
+ - GitHub: `gh pr merge <pr>` with `--squash` (squash), `--merge` (merge-commit) or `--rebase` (rebase-merge).
69
+ - Gitea: call the Gitea PR merge API with the chosen strategy.
70
+
71
71
  Record the merge SHA.
72
72
 
73
73
  ## Step 4 — Monitor the deploy
@@ -75,8 +75,12 @@ Record the merge SHA.
75
75
  The merge lands on the default branch and triggers the pipeline. Watch it via the lane's skill:
76
76
  - Gitea: `lint_and_test` → `frontend_checks` (if present) → **`deploy_prod`** → `release_metadata`
77
77
  → `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.
78
+ - GitHub: wait for the deploy run on the default branch outside the Claude session via `ci-watch`:
79
+ ```bash
80
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" ci-watch --run <run-id>
81
+ ```
82
+ 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).
83
+ 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
84
 
81
85
  ## Step 5 — Post-deploy validation
82
86
 
@@ -108,7 +112,6 @@ Cleanup: remote branch ✗ deleted / local ✗ deleted / backup ref droppe
108
112
 
109
113
  ## Rules
110
114
  - Never merge without: green required checks **and** the explicit human deploy-confirm.
111
- - Never merge unless `deploy-boundary` exited 0 on this run. No confirmation overrides it.
112
115
  - Never delete a branch before its deploy is validated green.
113
116
  - Never `git add -A` without reviewing `git status`; never edit a workflow file to make a gate pass.
114
117
  - Local-only lane: no PR, no merge — refuse and point at `/ship`.
@@ -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.124",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.122",
4
+ "antigravity-plugin": "1.3.124",
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.37",
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",