@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.
Files changed (111) hide show
  1. package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
  2. package/bundles/antigravity-plugin/guard_agy.py +184 -0
  3. package/bundles/antigravity-plugin/plugin.json +1 -1
  4. package/bundles/antigravity-plugin/repo_freshness.py +132 -5
  5. package/bundles/antigravity-plugin/scripts/caddis_budget.py +283 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +227 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_fleet.py +439 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_gate.py +255 -195
  12. package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
  13. package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
  14. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
  15. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +411 -127
  16. package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
  17. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
  18. package/bundles/antigravity-plugin/scripts/caddis_notify.py +125 -0
  19. package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
  20. package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
  21. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
  22. package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
  23. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2109 -0
  24. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +676 -0
  25. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2605 -0
  26. package/bundles/antigravity-plugin/scripts/caddis_routing.py +7 -6
  27. package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
  28. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
  29. package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
  30. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
  31. package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
  32. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
  33. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
  34. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
  35. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
  36. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
  37. package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
  38. package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
  39. package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
  40. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
  41. package/bundles/antigravity-plugin/session_end_agy.py +3 -3
  42. package/bundles/antigravity-plugin/skills/_registry.md +4 -6
  43. package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
  44. package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
  45. package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
  46. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +11 -0
  47. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
  48. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
  49. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +35 -83
  50. package/bundles/antigravity-plugin/skills/fleet-close/SKILL.md +167 -0
  51. package/bundles/antigravity-plugin/skills/fleet-open/SKILL.md +110 -0
  52. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +2 -1
  53. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +177 -48
  54. package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
  55. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +4 -3
  56. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
  57. package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
  58. package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
  59. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +28 -20
  60. package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
  61. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +2 -1
  62. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +1 -1
  63. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +260 -0
  64. package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
  65. package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
  66. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
  67. package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
  68. package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
  69. package/bundles/antigravity-plugin/skills/ship/SKILL.md +16 -16
  70. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +15 -5
  71. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +36 -16
  72. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
  73. package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
  74. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  75. package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
  76. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  77. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
  78. package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
  79. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +2 -1
  80. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +2 -1
  81. package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
  82. package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
  83. package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
  84. package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
  85. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +260 -0
  86. package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
  87. package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
  88. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
  89. package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
  90. package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
  91. package/bundles/codex/AGENTS.md +5 -4
  92. package/bundles/manifest.json +3 -3
  93. package/package.json +1 -1
  94. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
  95. package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
  96. package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
  97. package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
  98. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
  99. package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
  100. package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
  101. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
  102. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
  103. package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
  104. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
  105. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
  106. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
  107. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
  108. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
  109. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
  110. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
  111. 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())