@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.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +25 -2
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +262 -1
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +103 -0
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +185 -0
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +15 -0
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +10 -2
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +18 -2
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +8 -3
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +21 -3
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +6 -1
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
170
|
-
|
|
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:
|
|
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:
|
|
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** —
|
|
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
|
-
|
|
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
|
|
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:
|
|
79
|
-
|
|
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:
|
|
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
|
|
package/bundles/manifest.json
CHANGED
package/package.json
CHANGED