@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.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +25 -173
- 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 -17
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +8 -17
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +21 -18
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +6 -1
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,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:
|
|
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:
|
|
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** —
|
|
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
|
-
|
|
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
|
|
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:
|
|
79
|
-
|
|
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:
|
|
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