@caddis/cli 0.4.36 → 0.4.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.123",
3
+ "version": "1.3.125",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -21,9 +21,6 @@ that hard-fails when it cannot read its inputs breaks every consumer the day it
21
21
  first thing anyone does with a gate that cries wolf is switch it off. Only a genuine, positively
22
22
  identified violation is non-zero.
23
23
 
24
- One exception: `deploy-boundary` guards the production boundary and FAILS CLOSED. When it
25
- cannot tell, it exits 3 ("could not run" on the caddis scale), never 0.
26
-
27
24
  EXIT CODES
28
25
  0 proceed
29
26
  1 blocked — a real violation; the caller must stop
@@ -651,166 +648,6 @@ def gate_docs_check(root: Path) -> int:
651
648
  return EXIT_BLOCKED if failures else EXIT_OK
652
649
 
653
650
 
654
- # ── gate: deploy-boundary ───────────────────────────────────────────────────
655
- # The owner's production boundary: an agent never merges into a repo whose default branch deploys
656
- # on every push. /ship, /ship-merge and /pr-merge run this before any merge or push to the default
657
- # branch, and hand the merge to the owner on anything but exit 0.
658
- #
659
- # FAILS CLOSED — the one deliberate exception to DEGRADE OPEN above. "Cannot tell" is exit 3,
660
- # never 0, because a false "clean" here is a production deploy nobody approved. Any positive
661
- # signal wins over every negative one: config cannot switch the check off.
662
- EXIT_CANNOT_TELL = 3
663
-
664
- _WORKFLOW_DIRS = (".github/workflows", ".gitea/workflows", ".forgejo/workflows")
665
-
666
- # An inherited GIT_DIR (from a hook or wrapper) would make `git -C <repo>` read another repository.
667
- _GIT_REPO_ENV = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY",
668
- "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_NAMESPACE")
669
-
670
-
671
- def _git_out(repo: Path, *args: str) -> str | None:
672
- """stdout of a git command in `repo`, or None when it fails."""
673
- env = {k: v for k, v in os.environ.items() if k not in _GIT_REPO_ENV}
674
- try:
675
- r = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True,
676
- encoding="utf-8", errors="replace", timeout=30, env=env)
677
- except (OSError, subprocess.SubprocessError):
678
- return None
679
- return r.stdout if r.returncode == 0 else None
680
-
681
-
682
- def _ref_exists(repo: Path, ref: str) -> bool:
683
- return _git_out(repo, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}") is not None
684
-
685
-
686
- def _default_branch_refs(repo: Path) -> list[str]:
687
- """Every copy of the default branch: each remote's and the local branch of that name.
688
-
689
- The name comes from any remote's recorded HEAD; with none recorded, `main` and `master` stand
690
- in. Reading every copy is deliberate: a positive signal on any of them blocks.
691
- """
692
- remotes = (_git_out(repo, "remote") or "").split()
693
- names: set[str] = set()
694
- for remote in remotes:
695
- target = (_git_out(repo, "symbolic-ref", "--quiet", f"refs/remotes/{remote}/HEAD") or "").strip()
696
- if target.startswith(f"refs/remotes/{remote}/"):
697
- names.add(target[len(f"refs/remotes/{remote}/"):])
698
- names = names or {"main", "master"}
699
- refs = [f"refs/remotes/{r}/{n}" for r in remotes for n in sorted(names)]
700
- refs += [f"refs/heads/{n}" for n in sorted(names)]
701
- return [r for r in refs if _ref_exists(repo, r)]
702
-
703
-
704
- def _config_production_on_main(text: str) -> bool | None:
705
- """True/False from `[deploy] production_on_main`; None when absent. Raises on a bad file."""
706
- import tomllib
707
- deploy = tomllib.loads(text).get("deploy", {})
708
- if not isinstance(deploy, dict):
709
- raise ValueError("[deploy] is not a table")
710
- value = deploy.get("production_on_main")
711
- if value is not None and not isinstance(value, bool):
712
- raise ValueError(f"production_on_main is {value!r}, not true or false")
713
- return value
714
-
715
-
716
- # A deploy is often not named "deploy". Letters-only boundaries on prod, so `deploy_prod` matches
717
- # and `product` or `reproduce` do not.
718
- _DEPLOY_WORD = re.compile(r"deploy|release|publish|(?<![a-z])prod(?:uction)?(?![a-z])", re.I)
719
-
720
-
721
- def _workflow_deploys(text: str) -> bool:
722
- """True when the workflow's name, a job id or a job name looks like a deploy, or a job sets a
723
- GitHub `environment:` (the forge's own marker for a deployment). Raises on a bad file.
724
-
725
- The caller also checks the file name: a forge shows the file path as the workflow's name when
726
- `name:` is unset.
727
- """
728
- import yaml
729
- doc = yaml.safe_load(text)
730
- if not isinstance(doc, dict) or not isinstance(doc.get("jobs"), dict):
731
- raise ValueError("not a workflow (no jobs mapping)")
732
- labels = [doc.get("name")]
733
- for job_id, job in doc["jobs"].items():
734
- labels.append(job_id)
735
- if isinstance(job, dict):
736
- if job.get("environment"):
737
- return True
738
- labels.append(job.get("name"))
739
- return any(_DEPLOY_WORD.search(str(label)) for label in labels if label is not None)
740
-
741
-
742
- def gate_deploy_boundary(repo: Path) -> int:
743
- """EXIT_BLOCKED when a push to the default branch deploys to production, EXIT_OK when it
744
- positively does not, EXIT_CANNOT_TELL otherwise."""
745
- blocked: list[str] = []
746
- unknown: list[str] = []
747
-
748
- top = _git_out(repo, "rev-parse", "--show-toplevel") if repo.is_dir() else None
749
- root = Path(top.strip()) if top else repo
750
- refs = _default_branch_refs(root) if top else []
751
- if not repo.is_dir():
752
- unknown.append(f"{repo} is not a directory")
753
- elif not top:
754
- unknown.append(f"{repo} is not a git repository")
755
- elif not refs:
756
- unknown.append("cannot find the default branch (no remote HEAD, no main or master)")
757
- # HEAD is what is about to be merged or pushed: a deploy it adds lands on the default branch.
758
- # It only ever adds signals; the default branch is still read, so deleting there hides nothing.
759
- sources = refs + (["HEAD"] if top and _ref_exists(root, "HEAD") else [])
760
-
761
- # Config: the working tree and every source. Only `true` counts; config cannot switch it off.
762
- configs: list[tuple[str, str | None]] = []
763
- cfg = root / ".caddis" / "config.toml"
764
- if cfg.is_file():
765
- configs.append(("working tree", None))
766
- for ref in sources:
767
- text = _git_out(root, "show", f"{ref}:.caddis/config.toml")
768
- if text is not None:
769
- configs.append((ref, text))
770
- for where, text in configs:
771
- try:
772
- if _config_production_on_main(cfg.read_text(encoding="utf-8") if text is None else text):
773
- blocked.append(f".caddis/config.toml ({where}) sets production_on_main = true")
774
- except Exception as exc:
775
- unknown.append(f".caddis/config.toml ({where}) unreadable: {exc}")
776
-
777
- # Workflows: read from committed trees, never the working tree.
778
- for ref in sources:
779
- listing = _git_out(root, "ls-tree", "-r", "-z", "--name-only", ref, "--", *_WORKFLOW_DIRS)
780
- if listing is None:
781
- unknown.append(f"cannot list workflows on {ref}")
782
- continue
783
- for path in listing.split("\0"):
784
- # Forges run only the files directly inside the workflows directory.
785
- if path.rsplit("/", 1)[0] not in _WORKFLOW_DIRS:
786
- continue
787
- if not path.lower().endswith((".yml", ".yaml")):
788
- continue
789
- text = _git_out(root, "show", f"{ref}:{path}")
790
- try:
791
- if text is None:
792
- raise OSError("git show failed")
793
- if _DEPLOY_WORD.search(path.rsplit("/", 1)[1]) or _workflow_deploys(text):
794
- blocked.append(f"{path} on {ref} deploys")
795
- except Exception as exc:
796
- unknown.append(f"{path} on {ref} unreadable: {type(exc).__name__}: {exc}")
797
-
798
- if blocked:
799
- print("[gate] deploy-boundary: BLOCKED — a push to the default branch deploys to production")
800
- for reason in dict.fromkeys(blocked):
801
- print(f" {reason}")
802
- print(" Do not merge. Hand the merge to the owner with the PR link.")
803
- return EXIT_BLOCKED
804
- if unknown:
805
- print("[gate] deploy-boundary: CANNOT TELL — treat as production")
806
- for reason in dict.fromkeys(unknown):
807
- print(f" {reason}")
808
- print(" Do not merge. Hand the merge to the owner with the PR link.")
809
- return EXIT_CANNOT_TELL
810
- print(f"[gate] deploy-boundary: clean — no deploy signal found on {', '.join(sources)}")
811
- return EXIT_OK
812
-
813
-
814
651
  def main(argv: list[str] | None = None) -> int:
815
652
  ap = argparse.ArgumentParser(description="caddis machine gates")
816
653
  sub = ap.add_subparsers(dest="cmd", required=True)
@@ -838,17 +675,9 @@ def main(argv: list[str] | None = None) -> int:
838
675
  help="block a doc longer than this (relays: 4000); default: no cap")
839
676
  dc = sub.add_parser("docs-check")
840
677
  dc.add_argument("--root", default=".")
841
- db = sub.add_parser("deploy-boundary")
842
- db.add_argument("--repo", default=".")
843
678
  a = ap.parse_args(argv)
844
679
 
845
680
  plan = Path(getattr(a, "plan", "") or ".")
846
- if a.cmd == "deploy-boundary":
847
- try:
848
- return gate_deploy_boundary(Path(a.repo).resolve())
849
- except Exception as exc:
850
- sys.stderr.write(f"[caddis-gate] deploy-boundary cannot evaluate: {type(exc).__name__}: {exc}\n")
851
- return EXIT_CANNOT_TELL # never degrade open across the production boundary
852
681
  if a.cmd == "docs-check":
853
682
  try:
854
683
  return gate_docs_check(Path(a.root).resolve())
@@ -179,7 +179,7 @@ step goes through GitHub. What changes:
179
179
 
180
180
  ## Rules
181
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.
182
+ `/caddis:ship-pr`.
183
183
  - One phase, one prompt, one branch, one PR.
184
184
  - The orchestrator reads every diff. A green CI run proves only that the tests pass, not that the
185
185
  phase did what its Design says.
@@ -104,20 +104,6 @@ Watch the PR's checks job-by-job until completion:
104
104
  - If any check fails (red check): report the failure, classify the cause, and **stop before merging**.
105
105
  Do not merge a failing PR. Apply the minimum source fix and re-push.
106
106
 
107
- ## Step 6b — Deploy boundary (before the merge; nothing skips it)
108
-
109
- The PR exists and is green. Run this before the merge:
110
-
111
- ```bash
112
- python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" deploy-boundary --repo .
113
- ```
114
-
115
- Exit **0** — go on to Step 7. Exit **1** (a push to the default branch deploys to production), exit
116
- **3** (it cannot tell), or any other non-zero code — **stop. Do not merge.** Hand the merge to the
117
- owner with the PR link; skip Steps 7–9. Show the gate's output and end the command. This holds
118
- whatever confirmation was given: `--yes`, a confirm flag, "just merge it" or an earlier approval
119
- cannot bypass it.
120
-
121
107
  ## Step 7 — Merge to default branch
122
108
 
123
109
  Once all required checks are **green**, there are no merge conflicts, and review requirements are satisfied:
@@ -173,7 +159,6 @@ Cleanup: remote branch deleted / local branch deleted / backup ref dropped
173
159
  ## Rules
174
160
 
175
161
  - Never merge a PR whose required checks are red or absent.
176
- - Never merge unless `deploy-boundary` exited 0 on this run. No confirmation overrides it.
177
162
  - Never delete a feature branch before its merge is confirmed and any deploy validated.
178
163
  - Never `git add -A` without reviewing `git status` first (stage named files only).
179
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>
@@ -132,7 +119,6 @@ Release tag: <tag> → <sha> | n/a
132
119
 
133
120
  ## Rules
134
121
  - Never push before preflight gates are green.
135
- - Never push to the default branch unless `deploy-boundary` exited 0 on this run.
136
122
  - Never use `git add -A` without reviewing `git status` first.
137
123
  - Do not edit a workflow file (`.gitea/workflows/` or `.github/workflows/`) to make a failing gate
138
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:
@@ -126,7 +112,6 @@ Cleanup: remote branch ✗ deleted / local ✗ deleted / backup ref droppe
126
112
 
127
113
  ## Rules
128
114
  - Never merge without: green required checks **and** the explicit human deploy-confirm.
129
- - Never merge unless `deploy-boundary` exited 0 on this run. No confirmation overrides it.
130
115
  - Never delete a branch before its deploy is validated green.
131
116
  - Never `git add -A` without reviewing `git status`; never edit a workflow file to make a gate pass.
132
117
  - Local-only lane: no PR, no merge — refuse and point at `/ship`.
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.123",
2
+ "poolVersion": "1.3.125",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.123",
4
+ "antigravity-plugin": "1.3.125",
5
5
  "antigravity-plugin-extras": "1.3.40",
6
6
  "codex": "unknown"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.36",
3
+ "version": "0.4.38",
4
4
  "description": "Install, update and diagnose the caddis dev harness across every coding agent you use (Claude Code, agy/Antigravity, and more).",
5
5
  "keywords": [
6
6
  "caddis",