@caddis/cli 0.4.52 → 0.4.54
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/CADDIS-GLOSSARY.md +1 -0
- package/bundles/antigravity-plugin/guard_agy.py +184 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +132 -5
- package/bundles/antigravity-plugin/scripts/caddis_budget.py +283 -0
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +227 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
- package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
- package/bundles/antigravity-plugin/scripts/caddis_fleet.py +439 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +255 -195
- package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +411 -127
- package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
- package/bundles/antigravity-plugin/scripts/caddis_notify.py +125 -0
- package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
- package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2109 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +676 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2605 -0
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +7 -6
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
- package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
- package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
- package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
- package/bundles/antigravity-plugin/session_end_agy.py +3 -3
- package/bundles/antigravity-plugin/skills/_registry.md +4 -6
- package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +11 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +35 -83
- package/bundles/antigravity-plugin/skills/fleet-close/SKILL.md +167 -0
- package/bundles/antigravity-plugin/skills/fleet-open/SKILL.md +110 -0
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +177 -48
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
- package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +28 -20
- package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +260 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +16 -16
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +15 -5
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +36 -16
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
- package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +2 -1
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +2 -1
- package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +260 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
- package/bundles/codex/AGENTS.md +5 -4
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
- package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Merge pull requests one at a time, from green CI to a verified deploy, and stop the line at the first problem.
|
|
2
|
+
|
|
3
|
+
For each PR (design section 11): when BEHIND, update the branch and cancel the old head's runs (force-cancel after
|
|
4
|
+
2 minutes); a cancelled run on the head is re-run, never merged on; "no checks reported" waits; a failed check stops;
|
|
5
|
+
no merge while a push run is in progress on the default branch; squash merge with --match-head-commit; then the merge
|
|
6
|
+
SHA must get a push run, and `caddis_prod_check.py` must pass before the next PR starts. A STOP file is checked before
|
|
7
|
+
each update-branch and each merge. --dry-run makes read-only `gh` calls and prints what it would do.
|
|
8
|
+
|
|
9
|
+
Exit 0 when every PR merged and verified; 1 at the first stop; 3 when the profile cannot be read.
|
|
10
|
+
|
|
11
|
+
Usage: caddis_merge_chain.py [--repo PATH] [--gh-repo OWNER/NAME] [--docs-only] [--dry-run]
|
|
12
|
+
[--stop-file PATH ...] <pr> [<pr> ...]
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import caddis_exit
|
|
24
|
+
import caddis_prod_check as prod
|
|
25
|
+
except ModuleNotFoundError as exc:
|
|
26
|
+
if exc.name not in {"caddis_exit", "caddis_prod_check"}:
|
|
27
|
+
raise
|
|
28
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
29
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
30
|
+
import caddis_exit
|
|
31
|
+
import caddis_prod_check as prod
|
|
32
|
+
|
|
33
|
+
MAX_ATTEMPTS = 8
|
|
34
|
+
CHECKS_WAIT_MINUTES = 30
|
|
35
|
+
PUSH_IDLE_WAIT_MINUTES = 45
|
|
36
|
+
FORCE_CANCEL_AFTER_SECONDS = 120
|
|
37
|
+
MERGEABLE = ("CLEAN", "HAS_HOOKS")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Stop(Exception):
|
|
41
|
+
"""The line stops: print the reason, merge nothing more."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ChainContext(prod.Context):
|
|
46
|
+
stop_files: list[Path] = field(default_factory=list)
|
|
47
|
+
docs_only: bool = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _stop_check(ctx: ChainContext, before: str) -> None:
|
|
51
|
+
for path in ctx.stop_files:
|
|
52
|
+
if Path(path).exists():
|
|
53
|
+
raise Stop(f"STOP file {path} exists; nothing more before {before}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _view(ctx: ChainContext, pr: int) -> dict:
|
|
57
|
+
return ctx.gh_json("pr", "view", str(pr), "--json",
|
|
58
|
+
"state,mergeStateStatus,headRefOid,headRefName,mergeCommit") # type: ignore[return-value]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _update_branch(ctx: ChainContext, pr: int, branch: str) -> None:
|
|
62
|
+
old = [r["databaseId"] for r in ctx.gh_json("run", "list", "--branch", branch, "--limit", "10",
|
|
63
|
+
"--json", "databaseId,status") # type: ignore[union-attr]
|
|
64
|
+
if r.get("status") != "completed"]
|
|
65
|
+
_stop_check(ctx, "update-branch")
|
|
66
|
+
proc = ctx.gh("pr", "update-branch", str(pr))
|
|
67
|
+
if proc.rc != 0:
|
|
68
|
+
raise Stop(f"update-branch failed on #{pr}: {(proc.err or proc.out).strip()[:300]}")
|
|
69
|
+
ctx.out(f" #{pr} was behind: branch updated; cancelling {len(old)} old run(s)")
|
|
70
|
+
for run_id in old:
|
|
71
|
+
cancel = ctx.gh("run", "cancel", str(run_id))
|
|
72
|
+
if cancel.rc != 0:
|
|
73
|
+
ctx.out(f" WARNING: could not cancel run {run_id}: {(cancel.err or cancel.out).strip()[:200]}")
|
|
74
|
+
if not old:
|
|
75
|
+
return
|
|
76
|
+
ctx.clock.sleep(FORCE_CANCEL_AFTER_SECONDS)
|
|
77
|
+
for run_id in old:
|
|
78
|
+
view = ctx.gh_json("run", "view", str(run_id), "--json", "status")
|
|
79
|
+
if view.get("status") != "completed": # type: ignore[union-attr]
|
|
80
|
+
ctx.out(f" run {run_id} still {view.get('status')}; force-cancel") # type: ignore[union-attr]
|
|
81
|
+
repo = ctx.gh_repo or "{owner}/{repo}"
|
|
82
|
+
forced = ctx.gh("api", "-X", "POST", f"repos/{repo}/actions/runs/{run_id}/force-cancel")
|
|
83
|
+
if forced.rc != 0:
|
|
84
|
+
ctx.out(f" WARNING: force-cancel of run {run_id} failed: {(forced.err or forced.out).strip()[:200]}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _head_run(ctx: ChainContext, branch: str, head: str) -> dict | None:
|
|
88
|
+
runs = ctx.gh_json("run", "list", "--event", "pull_request", "--branch", branch, "--limit", "20",
|
|
89
|
+
"--json", "databaseId,headSha,status,conclusion")
|
|
90
|
+
return next((r for r in runs if r.get("headSha") == head), None) # type: ignore[union-attr]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _wait_checks(ctx: ChainContext, pr: int) -> str:
|
|
94
|
+
"""Return 'pass', 'fail' or 'cancel'. Raise Stop when no check reports in time."""
|
|
95
|
+
deadline = ctx.clock.now() + CHECKS_WAIT_MINUTES * 60
|
|
96
|
+
while True:
|
|
97
|
+
proc = ctx.gh("pr", "checks", str(pr), "--json", "name,bucket")
|
|
98
|
+
if proc.rc != 0 and not proc.out.strip() and "no checks reported" not in proc.err + proc.out:
|
|
99
|
+
# Auth or network failure: say so now instead of waiting 30 minutes as if no check had started.
|
|
100
|
+
raise Stop(f"#{pr}: gh pr checks failed: {(proc.err or proc.out).strip()[:300]}")
|
|
101
|
+
buckets: list[str] = []
|
|
102
|
+
try:
|
|
103
|
+
buckets = [c.get("bucket", "") for c in prod.json.loads(proc.out)] if proc.out.strip() else []
|
|
104
|
+
except ValueError:
|
|
105
|
+
buckets = []
|
|
106
|
+
if "fail" in buckets:
|
|
107
|
+
return "fail"
|
|
108
|
+
if "cancel" in buckets:
|
|
109
|
+
return "cancel"
|
|
110
|
+
if buckets and "pending" not in buckets:
|
|
111
|
+
return "pass"
|
|
112
|
+
waiting = "no checks reported yet" if not buckets else "checks pending"
|
|
113
|
+
if ctx.clock.now() >= deadline:
|
|
114
|
+
raise Stop(f"#{pr}: {waiting} after {CHECKS_WAIT_MINUTES} min")
|
|
115
|
+
if ctx.dry_run:
|
|
116
|
+
raise Stop(f"#{pr}: {waiting}; a real run would wait")
|
|
117
|
+
ctx.clock.sleep(30)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _wait_push_idle(ctx: ChainContext) -> None:
|
|
121
|
+
deadline = ctx.clock.now() + PUSH_IDLE_WAIT_MINUTES * 60
|
|
122
|
+
while True:
|
|
123
|
+
busy = [r for r in prod.push_runs(ctx, limit=10) if r.get("status") != "completed"]
|
|
124
|
+
if not busy:
|
|
125
|
+
return
|
|
126
|
+
if ctx.clock.now() >= deadline:
|
|
127
|
+
raise Stop(f"a push run on {ctx.profile.branch} is still in progress after {PUSH_IDLE_WAIT_MINUTES} min")
|
|
128
|
+
ctx.out(f" push run {busy[0].get('databaseId')} in progress on {ctx.profile.branch}; waiting")
|
|
129
|
+
ctx.clock.sleep(60)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def merge_one(ctx: ChainContext, pr: int) -> str | None:
|
|
133
|
+
"""Bring one PR to a merge. Return the merge SHA, or None after a dry run that stops before merging."""
|
|
134
|
+
for _attempt in range(MAX_ATTEMPTS):
|
|
135
|
+
_stop_check(ctx, f"#{pr}")
|
|
136
|
+
view = _view(ctx, pr)
|
|
137
|
+
state, merge_state = view.get("state"), view.get("mergeStateStatus")
|
|
138
|
+
if state == "MERGED":
|
|
139
|
+
sha = (view.get("mergeCommit") or {}).get("oid")
|
|
140
|
+
ctx.out(f"#{pr} already merged at {sha}")
|
|
141
|
+
return sha
|
|
142
|
+
if state != "OPEN":
|
|
143
|
+
raise Stop(f"#{pr} is {state}, not open")
|
|
144
|
+
head, branch = view["headRefOid"], view["headRefName"]
|
|
145
|
+
if merge_state == "BEHIND":
|
|
146
|
+
if ctx.dry_run:
|
|
147
|
+
ctx.out(f"#{pr} is BEHIND: would update-branch, cancel the old runs, wait for checks, then merge")
|
|
148
|
+
return None
|
|
149
|
+
_update_branch(ctx, pr, branch)
|
|
150
|
+
continue
|
|
151
|
+
run = _head_run(ctx, branch, head)
|
|
152
|
+
if run and run.get("conclusion") == "cancelled":
|
|
153
|
+
if ctx.dry_run:
|
|
154
|
+
ctx.out(f"#{pr}: the run on head {head[:12]} was cancelled: would re-run it, never merge on it")
|
|
155
|
+
return None
|
|
156
|
+
ctx.out(f" #{pr}: run {run['databaseId']} on the head was cancelled; re-running")
|
|
157
|
+
rerun = ctx.gh("run", "rerun", str(run["databaseId"]))
|
|
158
|
+
if rerun.rc != 0:
|
|
159
|
+
ctx.out(f" WARNING: re-run of {run['databaseId']} refused: {(rerun.err or rerun.out).strip()[:200]}")
|
|
160
|
+
ctx.clock.sleep(60)
|
|
161
|
+
continue
|
|
162
|
+
result = _wait_checks(ctx, pr)
|
|
163
|
+
if result == "fail":
|
|
164
|
+
raise Stop(f"#{pr}: CI failed on head {head[:12]}")
|
|
165
|
+
if result == "cancel":
|
|
166
|
+
continue
|
|
167
|
+
if ctx.dry_run:
|
|
168
|
+
ctx.out(f"#{pr} ({merge_state}): checks green; would wait for idle push runs, then squash-merge {head[:12]}")
|
|
169
|
+
return None
|
|
170
|
+
_wait_push_idle(ctx)
|
|
171
|
+
_stop_check(ctx, f"the merge of #{pr}")
|
|
172
|
+
view = _view(ctx, pr)
|
|
173
|
+
if view.get("state") == "MERGED":
|
|
174
|
+
continue
|
|
175
|
+
if view.get("mergeStateStatus") == "BEHIND" or view.get("headRefOid") != head:
|
|
176
|
+
continue
|
|
177
|
+
if view.get("mergeStateStatus") not in MERGEABLE:
|
|
178
|
+
ctx.out(f" #{pr}: merge refused (state {view.get('mergeStateStatus')}); waiting")
|
|
179
|
+
ctx.clock.sleep(120)
|
|
180
|
+
continue
|
|
181
|
+
proc = ctx.gh("pr", "merge", str(pr), "--squash", "--match-head-commit", head)
|
|
182
|
+
if proc.rc != 0:
|
|
183
|
+
ctx.out(f" #{pr}: merge refused: {(proc.err or proc.out).strip()[:300]}")
|
|
184
|
+
ctx.clock.sleep(120)
|
|
185
|
+
continue
|
|
186
|
+
sha = (_view(ctx, pr).get("mergeCommit") or {}).get("oid")
|
|
187
|
+
if not sha:
|
|
188
|
+
raise Stop(f"#{pr}: merge reported success but no merge commit is visible")
|
|
189
|
+
ctx.out(f"#{pr} merged at {sha}")
|
|
190
|
+
return sha
|
|
191
|
+
raise Stop(f"#{pr} did not merge after {MAX_ATTEMPTS} attempts")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def merge_chain(ctx: ChainContext, prs: list[int]) -> int:
|
|
195
|
+
try:
|
|
196
|
+
for pr in prs:
|
|
197
|
+
sha = merge_one(ctx, pr)
|
|
198
|
+
if sha is None:
|
|
199
|
+
ctx.out("dry run: stopping before any write")
|
|
200
|
+
return caddis_exit.CLEAN
|
|
201
|
+
code = prod.check(ctx, sha, docs_only=ctx.docs_only)
|
|
202
|
+
if code != caddis_exit.CLEAN:
|
|
203
|
+
raise Stop(f"the check after #{pr} did not pass; the line stops here")
|
|
204
|
+
except Stop as stop:
|
|
205
|
+
ctx.out(f"STOP: {stop}")
|
|
206
|
+
return caddis_exit.BLOCKED
|
|
207
|
+
except (RuntimeError, ValueError, KeyError) as exc:
|
|
208
|
+
ctx.out(f"STOP: could not read GitHub state: {exc}")
|
|
209
|
+
return caddis_exit.BLOCKED
|
|
210
|
+
ctx.out(("DRY RUN DONE: " if ctx.dry_run else "ALL MERGED AND VERIFIED: ") + ", ".join(f"#{p}" for p in prs))
|
|
211
|
+
return caddis_exit.CLEAN
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main(argv: list[str] | None = None) -> int:
|
|
215
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
216
|
+
parser.add_argument("prs", nargs="+", type=int)
|
|
217
|
+
parser.add_argument("--repo", type=Path, default=Path("."))
|
|
218
|
+
parser.add_argument("--gh-repo", help="OWNER/NAME when the repo's remote is not the one to read")
|
|
219
|
+
parser.add_argument("--profile", type=Path, help="default: <repo>/.caddis/prod-check.toml")
|
|
220
|
+
parser.add_argument("--docs-only", action="store_true", help="the PRs change docs only: expect no deploy")
|
|
221
|
+
parser.add_argument("--dry-run", action="store_true", help="read-only gh calls; print the steps")
|
|
222
|
+
parser.add_argument("--stop-file", type=Path, action="append", default=[],
|
|
223
|
+
help="extra STOP file (~/.caddis/STOP is always checked)")
|
|
224
|
+
args = parser.parse_args(argv)
|
|
225
|
+
try:
|
|
226
|
+
profile = prod.load_profile(args.repo, args.profile)
|
|
227
|
+
except ValueError as exc:
|
|
228
|
+
print(f"bad profile: {exc}", file=sys.stderr)
|
|
229
|
+
return caddis_exit.NOT_RUN
|
|
230
|
+
ctx = ChainContext(run=prod.real_runner(args.repo), clock=prod.Clock(), profile=profile, repo=args.repo,
|
|
231
|
+
gh_repo=args.gh_repo, dry_run=args.dry_run, docs_only=args.docs_only,
|
|
232
|
+
stop_files=[Path.home() / ".caddis" / "STOP", *args.stop_file])
|
|
233
|
+
return merge_chain(ctx, args.prs)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
sys.exit(main())
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Programme notifications: a Windows toast, and the Claude push-notification fallback call.
|
|
2
|
+
|
|
3
|
+
Design: programme-method-design.md section 8.3. Probe 6 of .caddis/kb/headless-runner-probes.md: Windows PowerShell 5.1
|
|
4
|
+
shows a WinRT toast (template ToastText02) with no extra module, and a `claude -p` session has the `PushNotification`
|
|
5
|
+
tool. A session that ends on a status other than CONTINUE sends the push itself and prints `PUSH-SENT`; the runner calls
|
|
6
|
+
`push` only when the session did not.
|
|
7
|
+
|
|
8
|
+
Exit codes: 0 sent, 3 not sent (no PowerShell, not Windows, the call failed, or no `PUSH-SENT` marker). A failed send never
|
|
9
|
+
hides a stop: the caller logs the exit code and goes on.
|
|
10
|
+
|
|
11
|
+
python caddis_notify.py toast --title T --message M
|
|
12
|
+
python caddis_notify.py push --message M
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Callable, Sequence
|
|
24
|
+
from xml.sax.saxutils import escape
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import caddis_exit
|
|
28
|
+
except ModuleNotFoundError as exc:
|
|
29
|
+
if exc.name != "caddis_exit":
|
|
30
|
+
raise
|
|
31
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
32
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
33
|
+
import caddis_exit
|
|
34
|
+
|
|
35
|
+
APP_ID = "caddis"
|
|
36
|
+
PUSH_MARKER = "PUSH-SENT"
|
|
37
|
+
PUSH_BUDGET_USD = "1"
|
|
38
|
+
Runner = Callable[..., "subprocess.CompletedProcess[str]"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ps_literal(text: str) -> str:
|
|
42
|
+
"""A PowerShell single-quoted literal: only `'` needs escaping (doubled)."""
|
|
43
|
+
return "'" + text.replace("'", "''") + "'"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def toast_script(title: str, message: str) -> str:
|
|
47
|
+
"""The PowerShell 5.1 script for one ToastText02 toast. Text is XML-escaped, then quoted for PowerShell."""
|
|
48
|
+
xml = (f"<toast><visual><binding template=\"ToastText02\"><text id=\"1\">{escape(title)}</text>"
|
|
49
|
+
f"<text id=\"2\">{escape(message)}</text></binding></visual></toast>")
|
|
50
|
+
return "; ".join([
|
|
51
|
+
"$ErrorActionPreference = 'Stop'",
|
|
52
|
+
"[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]",
|
|
53
|
+
"[void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]",
|
|
54
|
+
"$doc = New-Object Windows.Data.Xml.Dom.XmlDocument",
|
|
55
|
+
f"$doc.LoadXml({_ps_literal(xml)})",
|
|
56
|
+
f"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier({_ps_literal(APP_ID)})"
|
|
57
|
+
".Show([Windows.UI.Notifications.ToastNotification]::new($doc))",
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def toast(title: str, message: str, *, run: Runner = subprocess.run, platform: str | None = None) -> int:
|
|
62
|
+
"""Show one toast. Windows PowerShell 5.1 (`powershell.exe`) has the WinRT bridge; pwsh 7 does not."""
|
|
63
|
+
if (platform or sys.platform) != "win32":
|
|
64
|
+
print("caddis_notify: a toast needs Windows", file=sys.stderr)
|
|
65
|
+
return caddis_exit.NOT_RUN
|
|
66
|
+
cmd = ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", toast_script(title, message)]
|
|
67
|
+
try:
|
|
68
|
+
proc = run(cmd, capture_output=True, text=True, timeout=60)
|
|
69
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
70
|
+
print(f"caddis_notify: toast not sent: {exc}", file=sys.stderr)
|
|
71
|
+
return caddis_exit.NOT_RUN
|
|
72
|
+
if proc.returncode != 0:
|
|
73
|
+
print(f"caddis_notify: toast not sent (exit {proc.returncode}): {(proc.stderr or '').strip()[:300]}",
|
|
74
|
+
file=sys.stderr)
|
|
75
|
+
return caddis_exit.NOT_RUN
|
|
76
|
+
return caddis_exit.CLEAN
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def push_prompt(message: str) -> str:
|
|
80
|
+
return ("Your only task: call the PushNotification tool once with exactly this message, then print the line "
|
|
81
|
+
f"{PUSH_MARKER} and stop. Do nothing else.\n\nMessage: {message}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def push(message: str, *, run: Runner = subprocess.run, claude: Sequence[str] | None = None) -> int:
|
|
85
|
+
"""The fallback: a short `claude -p` call whose only task is the push notification. The prompt goes on stdin: on
|
|
86
|
+
Windows `claude` is a `.cmd` shim that cuts a multi-line argument at its first newline."""
|
|
87
|
+
claude = list(claude) if claude else [shutil.which("claude") or "claude"]
|
|
88
|
+
# An explicit, least mode: the only task is one PushNotification call. Since Claude Code 2.1.302 a headless run with
|
|
89
|
+
# no mode depends on the provider and the machine's settings; this one must not inherit bypassPermissions.
|
|
90
|
+
# `--allowedTools PushNotification`: in `default` mode a tool that asks would stall a print session with no human.
|
|
91
|
+
cmd = [*claude, "-p", "--permission-mode", "default", "--allowedTools", "PushNotification",
|
|
92
|
+
"--output-format", "json", "--max-budget-usd", PUSH_BUDGET_USD]
|
|
93
|
+
try:
|
|
94
|
+
proc = run(cmd, input=push_prompt(message), capture_output=True, text=True, encoding="utf-8",
|
|
95
|
+
errors="replace", timeout=300)
|
|
96
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
97
|
+
print(f"caddis_notify: push not sent: {exc}", file=sys.stderr)
|
|
98
|
+
return caddis_exit.NOT_RUN
|
|
99
|
+
try:
|
|
100
|
+
result = json.loads(proc.stdout or "")
|
|
101
|
+
except ValueError:
|
|
102
|
+
result = {}
|
|
103
|
+
text = result.get("result", "") if isinstance(result, dict) else ""
|
|
104
|
+
if proc.returncode != 0 or not isinstance(text, str) or PUSH_MARKER not in text:
|
|
105
|
+
print(f"caddis_notify: push not confirmed (exit {proc.returncode})", file=sys.stderr)
|
|
106
|
+
return caddis_exit.NOT_RUN
|
|
107
|
+
return caddis_exit.CLEAN
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main(argv: list[str] | None = None) -> int:
|
|
111
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
112
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
113
|
+
t = sub.add_parser("toast", help="show one Windows toast")
|
|
114
|
+
t.add_argument("--title", required=True)
|
|
115
|
+
t.add_argument("--message", required=True)
|
|
116
|
+
p = sub.add_parser("push", help="send a Claude push notification through a short claude -p call")
|
|
117
|
+
p.add_argument("--message", required=True)
|
|
118
|
+
args = parser.parse_args(argv)
|
|
119
|
+
if args.command == "toast":
|
|
120
|
+
return toast(args.title, args.message)
|
|
121
|
+
return push(args.message)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
raise SystemExit(main())
|