@caddis/cli 0.4.52 → 0.4.53

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 (100) hide show
  1. package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/repo_freshness.py +132 -5
  4. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
  9. package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
  10. package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
  11. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
  12. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
  13. package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
  14. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
  15. package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
  16. package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
  17. package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
  18. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
  19. package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
  20. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
  21. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
  22. package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
  23. package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
  24. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
  25. package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
  26. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
  27. package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
  28. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
  29. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
  30. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
  31. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
  32. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
  33. package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
  34. package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
  35. package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
  36. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
  37. package/bundles/antigravity-plugin/session_end_agy.py +3 -3
  38. package/bundles/antigravity-plugin/skills/_registry.md +2 -6
  39. package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
  40. package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
  41. package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
  42. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
  43. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
  44. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
  45. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
  46. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
  47. package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
  48. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
  50. package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
  51. package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
  52. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
  53. package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
  54. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
  55. package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
  56. package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
  57. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
  58. package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
  59. package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
  60. package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
  61. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
  62. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
  63. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
  64. package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
  65. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  66. package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
  67. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  68. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
  69. package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
  70. package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
  71. package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
  72. package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
  73. package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
  74. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
  75. package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
  76. package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
  77. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
  78. package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
  79. package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
  80. package/bundles/codex/AGENTS.md +5 -4
  81. package/bundles/manifest.json +3 -3
  82. package/package.json +1 -1
  83. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
  84. package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
  85. package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
  86. package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
  87. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
  88. package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
  89. package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
  90. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
  91. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
  92. package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
  93. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
  94. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
  95. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
  96. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
  97. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
  98. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
  99. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
  100. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
@@ -0,0 +1,380 @@
1
+ """Check a merge on prod, item by item, from the repo's profile `.caddis/prod-check.toml`.
2
+
3
+ Items (design section 10): (1) a push run exists for the merge SHA and is not cancelled; (2) the required jobs
4
+ succeeded (with --docs-only: the deploy job was skipped); (3) the version endpoint SHA equals the merge SHA or contains
5
+ it; (4) the health endpoint JSON has every value `true`, not only HTTP 200; (5) optional: named background jobs were
6
+ attempted after the deploy with zero failures. A repo with no profile gets items 1 and 2 only.
7
+
8
+ Prints one PASS or FAIL line per item and a RESULT line. Exit 0 only when every item passes; 1 when one fails; 3 when
9
+ the profile cannot be read; 4 when the SHA is ambiguous or not found. Read-only: it never writes to GitHub or prod.
10
+
11
+ Usage: caddis_prod_check.py [--repo PATH] [--gh-repo OWNER/NAME] [--docs-only] [--dry-run] <sha>
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import re
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ import tomllib
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Callable, NamedTuple
27
+
28
+ try:
29
+ import caddis_exit
30
+ except ModuleNotFoundError as exc:
31
+ if exc.name != "caddis_exit":
32
+ raise
33
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
34
+ import caddis_exit
35
+
36
+ FULL_SHA = re.compile(r"[0-9a-f]{40}")
37
+ SHORT_SHA = re.compile(r"[0-9a-f]{4,39}")
38
+ POLL_SECONDS = 30
39
+
40
+
41
+ class Proc(NamedTuple):
42
+ rc: int
43
+ out: str
44
+ err: str
45
+
46
+
47
+ Runner = Callable[[list[str]], Proc]
48
+
49
+
50
+ class Clock:
51
+ def now(self) -> float:
52
+ return time.monotonic()
53
+
54
+ def sleep(self, seconds: float) -> None:
55
+ time.sleep(seconds)
56
+
57
+
58
+ def real_runner(cwd: Path) -> Runner:
59
+ def run(cmd: list[str]) -> Proc:
60
+ try:
61
+ done = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace")
62
+ except FileNotFoundError as exc:
63
+ return Proc(127, "", str(exc))
64
+ return Proc(done.returncode, done.stdout, done.stderr)
65
+ return run
66
+
67
+
68
+ @dataclass
69
+ class Profile:
70
+ found: bool = False
71
+ branch: str = "main"
72
+ required_jobs: list[str] = field(default_factory=list)
73
+ deploy_job: str = "deploy_prod"
74
+ push_run_wait_minutes: float = 10
75
+ run_wait_minutes: float = 60
76
+ version_url: str | None = None
77
+ version_field: str = "sha"
78
+ health_url: str | None = None
79
+ auth: str = "none"
80
+ timeout_seconds: int = 60
81
+ background: dict | None = None
82
+
83
+
84
+ _KEY_TYPES: dict[str, type | tuple[type, ...]] = {
85
+ "branch": str, "required_jobs": list, "deploy_job": str, "push_run_wait_minutes": (int, float),
86
+ "run_wait_minutes": (int, float), "version_url": str, "version_field": str, "health_url": str, "auth": str,
87
+ "timeout_seconds": int,
88
+ }
89
+
90
+
91
+ def load_profile(repo: Path, path: Path | None = None) -> Profile:
92
+ """Read `.caddis/prod-check.toml`. No file is a valid, empty profile. A bad file raises ValueError."""
93
+ path = path or Path(repo) / ".caddis" / "prod-check.toml"
94
+ if not path.is_file():
95
+ return Profile()
96
+ try:
97
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
98
+ except (OSError, tomllib.TOMLDecodeError) as exc:
99
+ raise ValueError(f"{path}: {exc}") from exc
100
+ ci, prod = data.get("ci", {}), data.get("prod", {})
101
+ background = data.get("background")
102
+ if not isinstance(ci, dict) or not isinstance(prod, dict) or not isinstance(background, (dict, type(None))):
103
+ raise ValueError(f"{path}: [ci], [prod] and [background] must be tables")
104
+ profile = Profile(found=True)
105
+ for key, value in {**ci, **prod}.items():
106
+ expected = _KEY_TYPES.get(key)
107
+ if expected is None:
108
+ raise ValueError(f"{path}: unknown key {key!r}")
109
+ if isinstance(value, bool) or not isinstance(value, expected):
110
+ raise ValueError(f"{path}: {key} has the wrong type")
111
+ if key == "required_jobs" and not all(isinstance(job, str) for job in value):
112
+ raise ValueError(f"{path}: required_jobs must be a list of job names")
113
+ setattr(profile, key, value)
114
+ if profile.auth not in ("none", "ntlm"):
115
+ raise ValueError(f"{path}: auth must be 'none' or 'ntlm'")
116
+ if background is not None:
117
+ need = ("url", "list_key", "name_key", "attempt_key", "failure_key")
118
+ missing = [key for key in need if not isinstance(background.get(key), str)]
119
+ if missing:
120
+ raise ValueError(f"{path}: [background] needs {', '.join(missing)}")
121
+ profile.background = background
122
+ return profile
123
+
124
+
125
+ @dataclass
126
+ class Context:
127
+ run: Runner
128
+ clock: Clock
129
+ profile: Profile
130
+ repo: Path
131
+ gh_repo: str | None = None
132
+ dry_run: bool = False
133
+ out: Callable[[str], None] = print
134
+
135
+ def gh(self, *args: str) -> Proc:
136
+ cmd = ["gh", *args]
137
+ if self.gh_repo and args and args[0] in ("pr", "run"):
138
+ cmd += ["-R", self.gh_repo]
139
+ return self.run(cmd)
140
+
141
+ def gh_json(self, *args: str) -> object:
142
+ proc = self.gh(*args)
143
+ if proc.rc != 0:
144
+ raise RuntimeError(f"gh {' '.join(args[:3])} failed: {(proc.err or proc.out).strip()[:300]}")
145
+ return json.loads(proc.out)
146
+
147
+
148
+ def resolve_sha(ctx: Context, sha: str) -> tuple[int, str]:
149
+ """Return (exit code, full SHA). A full SHA is used as given; a short one is resolved with git."""
150
+ sha = sha.strip().lower()
151
+ if FULL_SHA.fullmatch(sha):
152
+ return caddis_exit.CLEAN, sha
153
+ if not SHORT_SHA.fullmatch(sha):
154
+ ctx.out(f"FAIL sha {sha!r} is not a commit SHA")
155
+ return caddis_exit.ERROR, ""
156
+ for attempt in range(2):
157
+ proc = ctx.run(["git", "rev-parse", "--verify", "--quiet", f"{sha}^{{commit}}"])
158
+ if proc.rc == 0 and FULL_SHA.fullmatch(proc.out.strip()):
159
+ return caddis_exit.CLEAN, proc.out.strip()
160
+ if "ambiguous" in proc.err:
161
+ ctx.out(f"FAIL short sha {sha} is ambiguous; give more characters")
162
+ return caddis_exit.ERROR, ""
163
+ if attempt == 0:
164
+ ctx.run(["git", "fetch", "-q", "origin"])
165
+ ctx.out(f"FAIL sha {sha} not found, also after git fetch")
166
+ return caddis_exit.ERROR, ""
167
+
168
+
169
+ def push_runs(ctx: Context, limit: int = 20) -> list[dict]:
170
+ return ctx.gh_json("run", "list", "--branch", ctx.profile.branch, "--event", "push", "--limit", str(limit),
171
+ "--json", "databaseId,headSha,status,conclusion") # type: ignore[return-value]
172
+
173
+
174
+ def find_push_run(ctx: Context, sha: str) -> dict | None:
175
+ """Wait up to the profile's minutes for a push run of `sha` to appear."""
176
+ deadline = ctx.clock.now() + ctx.profile.push_run_wait_minutes * 60
177
+ while True:
178
+ run = next((r for r in push_runs(ctx) if r.get("headSha") == sha), None)
179
+ if run or ctx.dry_run or ctx.clock.now() >= deadline:
180
+ return run
181
+ ctx.clock.sleep(15)
182
+
183
+
184
+ def wait_run(ctx: Context, run_id: int) -> dict | None:
185
+ """Return the completed run view (status, conclusion, jobs), or None when it does not complete in time."""
186
+ deadline = ctx.clock.now() + ctx.profile.run_wait_minutes * 60
187
+ while True:
188
+ view = ctx.gh_json("run", "view", str(run_id), "--json", "status,conclusion,jobs,headSha")
189
+ if view.get("status") == "completed" or ctx.dry_run: # type: ignore[union-attr]
190
+ return view # type: ignore[return-value]
191
+ if ctx.clock.now() >= deadline:
192
+ return None
193
+ ctx.clock.sleep(POLL_SECONDS)
194
+
195
+
196
+ def _jobs(view: dict) -> dict[str, str]:
197
+ return {j.get("name", ""): (j.get("conclusion") or j.get("status") or "") for j in view.get("jobs", [])}
198
+
199
+
200
+ def _deploy_done(view: dict, deploy_job: str) -> datetime | None:
201
+ for job in view.get("jobs", []):
202
+ if job.get("name") == deploy_job and job.get("completedAt"):
203
+ return _utc(job["completedAt"])
204
+ return None
205
+
206
+
207
+ def _utc(text: str) -> datetime:
208
+ when = datetime.fromisoformat(text.replace("Z", "+00:00"))
209
+ return when if when.tzinfo else when.replace(tzinfo=timezone.utc)
210
+
211
+
212
+ def check_ci(ctx: Context, sha: str, docs_only: bool) -> tuple[list[str], dict | None]:
213
+ """Items 1 and 2. Return (failed item labels, completed run view)."""
214
+ run = find_push_run(ctx, sha)
215
+ if not run:
216
+ ctx.out(f"FAIL 1 no push run for {sha} on {ctx.profile.branch} after "
217
+ f"{ctx.profile.push_run_wait_minutes:g} min")
218
+ return ["1 push run"], None
219
+ if ctx.dry_run and run.get("status") != "completed":
220
+ ctx.out(f"INFO 1 push run {run['databaseId']} is {run.get('status')}; would wait for it")
221
+ return [], None
222
+ view = wait_run(ctx, run["databaseId"])
223
+ if view is None:
224
+ ctx.out(f"FAIL 1 push run {run['databaseId']} did not complete in {ctx.profile.run_wait_minutes:g} min")
225
+ return ["1 push run"], None
226
+ conclusion = view.get("conclusion") or ""
227
+ if conclusion in ("cancelled", ""):
228
+ ctx.out(f"FAIL 1 push run {run['databaseId']} conclusion {conclusion or 'none'}")
229
+ return ["1 push run"], view
230
+ ctx.out(f"PASS 1 push run {run['databaseId']} completed ({conclusion})")
231
+ jobs = _jobs(view)
232
+ ctx.out("jobs: " + ", ".join(f"{name}={state}" for name, state in jobs.items()))
233
+ deploy = ctx.profile.deploy_job
234
+ if docs_only:
235
+ if jobs.get(deploy) == "skipped" and conclusion == "success":
236
+ ctx.out(f"PASS 2 docs-only: {deploy} skipped, run conclusion success")
237
+ return [], view
238
+ if conclusion != "success":
239
+ ctx.out(f"FAIL 2 docs-only merge, but the run concluded {conclusion}")
240
+ return ["2 jobs"], view
241
+ ctx.out(f"FAIL 2 docs-only merge, but {deploy} = {jobs.get(deploy, 'absent')}; run the full prod check")
242
+ return ["2 jobs"], view
243
+ if not ctx.profile.found:
244
+ if conclusion == "success":
245
+ ctx.out("PASS 2 run conclusion success (no profile: required jobs unknown)")
246
+ return [], view
247
+ ctx.out(f"FAIL 2 run conclusion {conclusion}")
248
+ return ["2 jobs"], view
249
+ bad = {name: jobs.get(name, "absent") for name in ctx.profile.required_jobs if jobs.get(name) != "success"}
250
+ if deploy and jobs.get(deploy) != "success":
251
+ # A code merge that did not deploy fails, also when the job is not listed as required.
252
+ # A repo with no deploy job sets `deploy_job = ""` in its profile.
253
+ bad[deploy] = jobs.get(deploy, "absent")
254
+ if bad:
255
+ skipped = " (skipped without --docs-only)" if bad.get(deploy) == "skipped" else ""
256
+ ctx.out("FAIL 2 " + ", ".join(f"{n}={c}" for n, c in bad.items()) + skipped)
257
+ return ["2 jobs"], view
258
+ ctx.out(f"PASS 2 {', '.join(ctx.profile.required_jobs) or 'no required jobs'} success")
259
+ return [], view
260
+
261
+
262
+ def curl(ctx: Context, url: str) -> tuple[int, object]:
263
+ """GET `url`. Return (HTTP code, parsed JSON or None)."""
264
+ auth = ["--ntlm", "-u", ":"] if ctx.profile.auth == "ntlm" else []
265
+ proc = ctx.run(["curl", "-s", "-m", str(ctx.profile.timeout_seconds), *auth, "-w", "\n%{http_code}", url])
266
+ body, _, code = proc.out.rpartition("\n")
267
+ try:
268
+ status = int(code.strip())
269
+ except ValueError:
270
+ status = 0
271
+ try:
272
+ return status, json.loads(body)
273
+ except ValueError:
274
+ return status, None
275
+
276
+
277
+ def _false_leaves(value: object, path: str = "") -> list[str]:
278
+ """Paths whose leaf value is not exactly True. An empty object counts as not healthy."""
279
+ if isinstance(value, dict):
280
+ if not value:
281
+ return [path or "(empty)"]
282
+ return [p for key, sub in value.items() for p in _false_leaves(sub, f"{path}.{key}" if path else str(key))]
283
+ return [] if value is True else [f"{path}={value!r}"]
284
+
285
+
286
+ def check_version(ctx: Context, sha: str) -> list[str]:
287
+ status, body = curl(ctx, ctx.profile.version_url or "")
288
+ found = body.get(ctx.profile.version_field) if isinstance(body, dict) else None
289
+ if status == 200 and isinstance(found, str) and found:
290
+ if found == sha:
291
+ ctx.out(f"PASS 3 version sha {found}")
292
+ return []
293
+ if ctx.run(["git", "merge-base", "--is-ancestor", sha, found]).rc == 0:
294
+ ctx.out(f"PASS 3 version sha {found} contains {sha}")
295
+ return []
296
+ ctx.out(f"FAIL 3 version sha {found!r} (HTTP {status}) is not {sha} and does not contain it")
297
+ return ["3 version"]
298
+
299
+
300
+ def check_health(ctx: Context) -> list[str]:
301
+ status, body = curl(ctx, ctx.profile.health_url or "")
302
+ bad = _false_leaves(body) if isinstance(body, dict) else ["body is not a JSON object"]
303
+ if status == 200 and not bad:
304
+ ctx.out(f"PASS 4 health {json.dumps(body)}")
305
+ return []
306
+ ctx.out(f"FAIL 4 health HTTP {status}; not true: {', '.join(bad) or '-'}")
307
+ return ["4 health"]
308
+
309
+
310
+ def check_background(ctx: Context, since: datetime | None) -> list[str]:
311
+ spec = ctx.profile.background or {}
312
+ since = since or datetime.now(timezone.utc)
313
+ deadline = ctx.clock.now() + float(spec.get("wait_minutes", 15)) * 60
314
+ while True:
315
+ status, body = curl(ctx, spec["url"])
316
+ jobs = body.get(spec["list_key"]) if isinstance(body, dict) and status == 200 else None
317
+ if not isinstance(jobs, list) or not jobs:
318
+ ctx.out(f"FAIL 5 background list {spec['list_key']!r} missing or empty (HTTP {status})")
319
+ return ["5 background"]
320
+ failed = [(j.get(spec["name_key"]), j.get(spec["failure_key"])) for j in jobs if j.get(spec["failure_key"])]
321
+ late = [j.get(spec["name_key"]) for j in jobs
322
+ if not j.get(spec["attempt_key"]) or _utc(str(j[spec["attempt_key"]])) < since]
323
+ if failed:
324
+ ctx.out(f"FAIL 5 failed background jobs: {failed}")
325
+ return ["5 background"]
326
+ if not late:
327
+ ctx.out(f"PASS 5 {len(jobs)} background jobs attempted after {since:%H:%M:%S} UTC, no failures")
328
+ return []
329
+ if ctx.clock.now() >= deadline:
330
+ ctx.out(f"FAIL 5 not attempted since the deploy: {late}")
331
+ return ["5 background"]
332
+ ctx.clock.sleep(60)
333
+
334
+
335
+ def check(ctx: Context, sha: str, docs_only: bool = False) -> int:
336
+ code, full = resolve_sha(ctx, sha)
337
+ if code != caddis_exit.CLEAN:
338
+ return code
339
+ if not ctx.profile.found:
340
+ ctx.out("no profile .caddis/prod-check.toml: checking items 1 and 2 only")
341
+ try:
342
+ fails, view = check_ci(ctx, full, docs_only)
343
+ if not fails and not docs_only and ctx.profile.found:
344
+ if ctx.dry_run:
345
+ ctx.out("would check items 3 to 5 against prod (version, health, background jobs)")
346
+ else:
347
+ if ctx.profile.version_url:
348
+ fails += check_version(ctx, full)
349
+ if ctx.profile.health_url:
350
+ fails += check_health(ctx)
351
+ if ctx.profile.background:
352
+ fails += check_background(ctx, _deploy_done(view or {}, ctx.profile.deploy_job))
353
+ except (RuntimeError, ValueError, KeyError) as exc:
354
+ ctx.out(f"FAIL could not read CI state: {exc}")
355
+ fails = ["CI state"]
356
+ ctx.out("RESULT PASS" if not fails else "RESULT FAIL " + ", ".join(fails))
357
+ return caddis_exit.CLEAN if not fails else caddis_exit.BLOCKED
358
+
359
+
360
+ def main(argv: list[str] | None = None) -> int:
361
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
362
+ parser.add_argument("sha")
363
+ parser.add_argument("--repo", type=Path, default=Path("."))
364
+ parser.add_argument("--gh-repo", help="OWNER/NAME when the repo's remote is not the one to read")
365
+ parser.add_argument("--profile", type=Path, help="default: <repo>/.caddis/prod-check.toml")
366
+ parser.add_argument("--docs-only", action="store_true", help="the merge changed docs only: expect no deploy")
367
+ parser.add_argument("--dry-run", action="store_true", help="read CI state only; never call prod")
368
+ args = parser.parse_args(argv)
369
+ try:
370
+ profile = load_profile(args.repo, args.profile)
371
+ except ValueError as exc:
372
+ print(f"bad profile: {exc}", file=sys.stderr)
373
+ return caddis_exit.NOT_RUN
374
+ ctx = Context(run=real_runner(args.repo), clock=Clock(), profile=profile, repo=args.repo,
375
+ gh_repo=args.gh_repo, dry_run=args.dry_run)
376
+ return check(ctx, args.sha, docs_only=args.docs_only)
377
+
378
+
379
+ if __name__ == "__main__":
380
+ sys.exit(main())
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python3
2
+ """caddis_redact - keep provider keys out of caddis logs and captured output.
3
+
4
+ Why: on 10 Oct 2026 a review launcher session printed the GLM API key into its local session log while
5
+ debugging (caddis-next proposal, issue 4); the owner had to rotate the key. caddis captures model and
6
+ launcher output into review files, lane packets and printed summaries. Every such text goes through
7
+ `redact()` (or `redact_obj()` for a packet) first.
8
+
9
+ Secrets are the VALUES (never the names) of:
10
+ - every `NAME=value` line in the keys file whose NAME looks secret (contains KEY, TOKEN, SECRET, PASS,
11
+ AUTH or CREDENTIAL): `$CADDIS_KEYS_FILE` if set (with `~` expanded, as oss_model and oss_review do),
12
+ else `~/.caddis/keys.env`; optional `export `, quotes stripped the way the key consumers strip them;
13
+ - the same kind of variables in the environment (`REVIEW_API_KEY`, `OSS_API_KEY`, `<P>_API_KEY`, which
14
+ the consumers prefer over the file).
15
+ Values shorter than 8 characters are ignored. Each value is also masked in its JSON-escaped form, so a key
16
+ with `\\`, `"` or `<>&` (Go-style `\\u003c`) does not survive inside raw JSON output.
17
+
18
+ Limit: only whole, exact copies are masked. A key split across lines, cut short (`sk-abc...xyz`) or
19
+ re-encoded (base64) passes.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import re
27
+ from pathlib import Path
28
+
29
+ MIN_SECRET_LEN = 8
30
+ MASK = "***"
31
+ _SENSITIVE_NAME = re.compile(r"KEY|TOKEN|SECRET|PASS|AUTH|CREDENTIAL", re.I)
32
+
33
+
34
+ def keys_file(home: Path) -> Path:
35
+ override = os.environ.get("CADDIS_KEYS_FILE")
36
+ return Path(override).expanduser() if override else home / ".caddis" / "keys.env"
37
+
38
+
39
+ def _forms(value: str) -> set[str]:
40
+ """The value and the forms it takes inside raw JSON output (standard and Go-style escapes)."""
41
+ forms = {value}
42
+ escaped = json.dumps(value)[1:-1]
43
+ forms.add(escaped)
44
+ forms.add(escaped.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026"))
45
+ return forms
46
+
47
+
48
+ def load_secrets(home: Path) -> list[str]:
49
+ """The secret values in every form to mask, longest first (so a key that contains another is masked whole)."""
50
+ values: set[str] = set()
51
+ try:
52
+ text = keys_file(home).read_text(encoding="utf-8-sig", errors="replace")
53
+ except OSError:
54
+ text = ""
55
+ for raw in text.splitlines():
56
+ line = raw.strip()
57
+ if not line or line.startswith("#") or "=" not in line:
58
+ continue
59
+ if line.startswith("export "):
60
+ line = line[len("export "):].lstrip()
61
+ name, value = line.split("=", 1)
62
+ if not _SENSITIVE_NAME.search(name):
63
+ continue
64
+ value = value.strip()
65
+ # The consumers (oss_model, oss_review) use .strip('"').strip("'"); mask that value AND the
66
+ # balanced-quote value, so an unbalanced quote cannot leave the key that is really used unmasked.
67
+ for candidate in {value.strip('"').strip("'"),
68
+ value[1:-1] if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"" else value}:
69
+ if len(candidate) >= MIN_SECRET_LEN:
70
+ values.add(candidate)
71
+ for name, value in os.environ.items():
72
+ if (_SENSITIVE_NAME.search(name) and len(value) >= MIN_SECRET_LEN and name not in ("CADDIS_KEYS_FILE",)
73
+ and not name.startswith("GIT_CONFIG_")): # GIT_CONFIG_KEY_<n> holds a setting NAME, not a secret
74
+ values.add(value)
75
+ forms: set[str] = set()
76
+ for value in values:
77
+ forms |= _forms(value)
78
+ return sorted(forms, key=len, reverse=True)
79
+
80
+
81
+ def redact(text: str, secrets: list[str]) -> str:
82
+ """`text` with every secret form replaced by ***."""
83
+ if not text:
84
+ return text
85
+ for secret in secrets:
86
+ if secret in text:
87
+ text = text.replace(secret, MASK)
88
+ return text
89
+
90
+
91
+ def redact_obj(obj, secrets: list[str]):
92
+ """A copy of a JSON-able packet (dict/list/str) with every string redacted."""
93
+ if isinstance(obj, str):
94
+ return redact(obj, secrets)
95
+ if isinstance(obj, dict):
96
+ return {k: redact_obj(v, secrets) for k, v in obj.items()}
97
+ if isinstance(obj, list):
98
+ return [redact_obj(v, secrets) for v in obj]
99
+ return obj
@@ -124,8 +124,8 @@ def fetch_relay(repo: Path, workstream: str, *, replace: bool = False) -> str:
124
124
 
125
125
 
126
126
  def _headless() -> bool:
127
- """No human is watching: the caddis launchers and the docket runner set these."""
128
- return any(os.environ.get(name) for name in ("CADDIS_HEADLESS", "DOCKET_PLAN", "DOCKET_BRANCH"))
127
+ """No human is watching: the caddis launchers set CADDIS_HEADLESS."""
128
+ return bool(os.environ.get("CADDIS_HEADLESS"))
129
129
 
130
130
 
131
131
  def main(argv: list[str] | None = None) -> int: