@caddis/cli 0.2.6 → 0.3.0

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 (51) hide show
  1. package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
  2. package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
  3. package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
  4. package/bundles/antigravity-plugin/guard_agy.py +23 -4
  5. package/bundles/antigravity-plugin/plugin.json +1 -1
  6. package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
  12. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
  13. package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
  14. package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
  15. package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
  16. package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
  17. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
  18. package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
  19. package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
  20. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
  21. package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
  22. package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
  23. package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
  24. package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
  25. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
  26. package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
  27. package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
  28. package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
  29. package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
  30. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
  31. package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
  32. package/bundles/antigravity-plugin/session_end_agy.py +21 -2
  33. package/bundles/antigravity-plugin/skills/_registry.md +1 -0
  34. package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
  35. package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
  37. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
  39. package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
  40. package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
  41. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  42. package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
  43. package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
  44. package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
  45. package/bundles/manifest.json +3 -3
  46. package/dist/claude-deepseek.js +544 -0
  47. package/dist/claude-glm.js +544 -0
  48. package/dist/claude-oss.js +544 -0
  49. package/dist/cli.js +365 -44
  50. package/package.json +5 -2
  51. package/bundles/antigravity-plugin/statusline-command-agy.sh +0 -91
@@ -0,0 +1,21 @@
1
+ # {{FOLDER}}/ — conventions
2
+
3
+ Scope: {{FOLDER_PURPOSE}}
4
+
5
+ Inherits all root laws (see the root `AGENTS.md`): TDD, no absolute paths, no silent failures,
6
+ verify-don't-assume, honest status, stay in scope.
7
+
8
+ ## Conventions
9
+ - <the rules specific to this folder — how code here is structured, named, layered, and tested;
10
+ the framework idioms and patterns a change here must follow>
11
+
12
+ ## Gotchas / non-obvious
13
+ - <traps a fresh session would hit: a required setup step, a load-bearing file, a deviation from
14
+ convention that isn't visible from the code in < 5 minutes>
15
+
16
+ ## Adding functionality (the path)
17
+ 1. Failing test first (TDD — Law 1).
18
+ 2. <the folder-specific steps to add a unit of behavior here, ending in a green run>
19
+
20
+ <!-- Ongoing updates flow through the knowledge-transfer / claude-md-curator agents (they target ANY
21
+ existing AGENTS.md). Keep this file the canonical rules; its sibling CLAUDE.md is an @import shim. -->
@@ -287,6 +287,25 @@ def _emit(tier: str, reason: str) -> None:
287
287
  sys.stdout.write(payload)
288
288
 
289
289
 
290
+
291
+ def _hook_note(feature, exc, root=None):
292
+ """Record a swallowed failure in the caddis hook ledger. Never raises, never prints.
293
+
294
+ agy hooks ship FLAT at the plugin root while hook_log.py ships under scripts/, and in
295
+ the source repo this file sits in claude-harness/agy/ with hook_log.py one level up in
296
+ claude-harness/scripts/. Try both layouts.
297
+ """
298
+ try:
299
+ _here = os.path.dirname(os.path.abspath(__file__))
300
+ for _cand in (os.path.join(_here, "scripts"),
301
+ os.path.join(os.path.dirname(_here), "scripts")):
302
+ if os.path.isdir(_cand) and _cand not in sys.path:
303
+ sys.path.insert(0, _cand)
304
+ import hook_log as _hl # noqa: E402
305
+ _hl.record(os.path.basename(__file__).replace(".py", ""), feature, exc, root)
306
+ except Exception as _exc:
307
+ _hook_note("agy guard config read", _exc)
308
+
290
309
  def main() -> None:
291
310
  tier, reason = "allow", ""
292
311
  try:
@@ -294,8 +313,8 @@ def main() -> None:
294
313
  if _reconfig:
295
314
  try:
296
315
  _reconfig(encoding="utf-8")
297
- except Exception:
298
- pass
316
+ except Exception as _exc:
317
+ _hook_note("agy guard config read", _exc)
299
318
  try:
300
319
  data = json.load(sys.stdin)
301
320
  except Exception:
@@ -317,8 +336,8 @@ def main() -> None:
317
336
  finally:
318
337
  try:
319
338
  _emit(tier, reason)
320
- except Exception:
321
- pass
339
+ except Exception as _exc:
340
+ _hook_note("agy guard config read", _exc)
322
341
  # Always exit 0: a non-zero code from a PreToolUse hook is an agy hook *error*.
323
342
  sys.exit(0)
324
343
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.75",
3
+ "version": "1.3.77",
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
  }
@@ -0,0 +1,524 @@
1
+ #!/usr/bin/env python3
2
+ """Machine-enforced gates for the caddis implement loop.
3
+
4
+ WHY
5
+ ---
6
+ Most of caddis's guarantees are sentences in markdown that a model may or may not honour
7
+ (measured: 7.0% machine-enforced at the time this landed — `scripts/enforcement_inventory.py`).
8
+ The three gates below are the ones that have actually bitten, so they become exit codes:
9
+
10
+ lane-check the assigned execution lane was never read, so a "cheap" lane ran nothing
11
+ verdict-gate an Advisory-Hub verdict from a DIFFERENT plan could unlock the next phase,
12
+ and a batched verdict (`phase-10-11`) falsely blocked the phase after it
13
+ tracker-vs-git a stray staged edit reverted four completed Tracker rows to "not started"
14
+ and it was caught two milestones later
15
+
16
+ DEGRADE OPEN, ALWAYS
17
+ --------------------
18
+ Every failure to *evaluate* is exit 0 with a note on stderr. A missing plan, an unparseable
19
+ Tracker, no git — none of those are gate failures, they are "this gate could not run". A gate
20
+ that hard-fails when it cannot read its inputs breaks every consumer the day it ships, and the
21
+ first thing anyone does with a gate that cries wolf is switch it off. Only a genuine, positively
22
+ identified violation is non-zero.
23
+
24
+ EXIT CODES
25
+ 0 proceed
26
+ 1 blocked — a real violation; the caller must stop
27
+ 2 proceed-with-note — degraded/uncertain; caller proceeds but must record it
28
+ 3 wrong-lane — this phase belongs to another lane; the launch command is on stdout
29
+ 4 malformed — the phase's launch command cannot be spawned safely
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import os
35
+ import re
36
+ import subprocess
37
+ import sys
38
+ from pathlib import Path
39
+
40
+ if hasattr(sys.stdout, "reconfigure"):
41
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
42
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
43
+
44
+ EXIT_OK, EXIT_BLOCKED, EXIT_NOTE, EXIT_WRONG_LANE, EXIT_MALFORMED = 0, 1, 2, 3, 4
45
+
46
+ _TRUTHY = {"1", "true", "yes", "on"}
47
+
48
+
49
+ def _degrade(msg: str) -> int:
50
+ """Could not evaluate. Never a failure — see DEGRADE OPEN above."""
51
+ sys.stderr.write(f"[caddis-gate] cannot evaluate: {msg} — proceeding\n")
52
+ return EXIT_OK
53
+
54
+
55
+ def is_headless(env=None) -> bool:
56
+ e = env if env is not None else os.environ
57
+ if str(e.get("CADDIS_HEADLESS", "")).strip().lower() in _TRUTHY:
58
+ return True
59
+ return bool(e.get("DOCKET_PLAN") or e.get("DOCKET_BRANCH"))
60
+
61
+
62
+ # ── plan parsing ────────────────────────────────────────────────────────────
63
+
64
+ _PHASE_HEAD = re.compile(r"^###\s+Phase\s+(\d+)\b", re.M)
65
+
66
+
67
+ def phase_block(plan_text: str, phase: int) -> str:
68
+ """The text of one phase, from its heading to the next."""
69
+ starts = [(int(m.group(1)), m.start()) for m in _PHASE_HEAD.finditer(plan_text)]
70
+ for i, (num, pos) in enumerate(starts):
71
+ if num == phase:
72
+ end = starts[i + 1][1] if i + 1 < len(starts) else len(plan_text)
73
+ return plan_text[pos:end]
74
+ return ""
75
+
76
+
77
+ def phase_lane(block: str) -> str:
78
+ m = re.search(r"^\*\*Lane:\*\*\s*(.+)$", block, re.M)
79
+ if not m:
80
+ return ""
81
+ # "claude — this session | glm-headless — `cmd`" → the lane is the first token.
82
+ return m.group(1).strip().split()[0].strip("`*")
83
+
84
+
85
+ def launch_command(block: str) -> str:
86
+ """The literal command for a non-claude lane, if the phase names one."""
87
+ m = re.search(r"`(claude-(?:glm|oss|deepseek)[^`]*)`", block)
88
+ return m.group(1).strip() if m else ""
89
+
90
+
91
+ # ── gate: lane-check ────────────────────────────────────────────────────────
92
+
93
+ def gate_lane(plan: Path, phase: int) -> int:
94
+ if not plan.is_file():
95
+ return _degrade(f"no plan at {plan}")
96
+ block = phase_block(plan.read_text(encoding="utf-8", errors="ignore"), phase)
97
+ if not block:
98
+ return _degrade(f"phase {phase} not found in {plan.name}")
99
+ lane = phase_lane(block)
100
+ if not lane:
101
+ return _degrade(f"phase {phase} names no Lane:")
102
+
103
+ # The asymmetric rule, and the reason this gate is machine-checkable at all: we cannot prove
104
+ # which model we are, but we CAN prove whether someone placed us. A headless/runner session
105
+ # was placed deliberately; an interactive one was not.
106
+ if lane.startswith("claude") or is_headless():
107
+ return EXIT_OK
108
+
109
+ cmd = launch_command(block)
110
+ if not cmd:
111
+ sys.stderr.write(f"[caddis-gate] phase {phase} is lane '{lane}' but names no launch command\n")
112
+ return EXIT_MALFORMED
113
+ if not re.search(r"(?:^|\s)(?:-p|--print)(?:\s|$)", cmd):
114
+ sys.stderr.write(
115
+ f"[caddis-gate] phase {phase}'s launch command has no -p/--print: {cmd}\n"
116
+ " Without it the child is not marked headless, reads the same Lane line, and spawns\n"
117
+ " again — unbounded recursion. Refusing to run it.\n")
118
+ return EXIT_MALFORMED
119
+ print(cmd)
120
+ sys.stderr.write(f"[caddis-gate] phase {phase} belongs to lane '{lane}' — spawn the command above\n")
121
+ return EXIT_WRONG_LANE
122
+
123
+
124
+ # ── gate: verdict-gate ──────────────────────────────────────────────────────
125
+
126
+ def _frontmatter(p: Path) -> dict:
127
+ t = p.read_text(encoding="utf-8", errors="ignore")
128
+ if not t.startswith("---"):
129
+ return {}
130
+ end = t.find("\n---", 3)
131
+ out = {}
132
+ for line in t[3:end if end > 0 else 400].splitlines():
133
+ if ":" in line:
134
+ k, _, v = line.partition(":")
135
+ out[k.strip()] = v.strip()
136
+ return out
137
+
138
+
139
+ def _covers(phase_field: str, n: int) -> bool:
140
+ s = str(phase_field).strip()
141
+ m = re.fullmatch(r"(\d+)\s*-\s*(\d+)", s)
142
+ if m:
143
+ return int(m.group(1)) <= n <= int(m.group(2))
144
+ return s.isdigit() and int(s) == n
145
+
146
+
147
+ def _hub_context_for(plan: Path) -> Path | None:
148
+ """The advisory-context doc guarding this plan, or None if Hub mode is off.
149
+
150
+ Primary: the documented `<plan-stem>-advisory-context.md`.
151
+
152
+ Fallback by IDENTITY, and it is load-bearing: a real plan was renamed
153
+ `genesys-rebuild.md` -> `genesys-rebuild-ui-revamp.md` and its context doc kept the old stem.
154
+ The filename convention therefore stopped matching, Advisory-Hub mode silently switched OFF
155
+ for every subsequent run, and nobody noticed because the Hub kept being driven by hand — 31
156
+ artifacts were written while the gate meant to enforce them was dark. Both files still carry
157
+ `feature: genesys-rebuild-ui-revamp`, so identity survives a rename where a filename does not.
158
+
159
+ Requires an explicit matching `feature:` on BOTH sides, so it cannot switch Hub mode on for a
160
+ plan that never opted in — "no file -> zero behaviour change" still holds.
161
+ """
162
+ exact = plan.with_name(plan.stem + "-advisory-context.md")
163
+ if exact.is_file():
164
+ return exact
165
+ mine = _frontmatter(plan).get("feature", "").strip()
166
+ if not mine:
167
+ return None
168
+ for cand in sorted(plan.parent.glob("*-advisory-context.md")):
169
+ if _frontmatter(cand).get("feature", "").strip() == mine:
170
+ sys.stderr.write(
171
+ "[caddis-gate] Hub context matched by feature slug, not filename: "
172
+ f"{cand.name} (expected {exact.name} - a rename left the convention broken)\n")
173
+ return cand
174
+ return None
175
+
176
+
177
+ def gate_verdict(plan: Path, phase: int, reports: Path | None = None) -> int:
178
+ if phase <= 1:
179
+ return EXIT_OK # no predecessor
180
+ if not _hub_context_for(plan):
181
+ return EXIT_OK # Advisory-Hub mode is OFF — the normal case, zero behaviour change
182
+ d = reports or (plan.parent.parent / "advisory-hub-reports")
183
+ if not d.is_dir():
184
+ sys.stderr.write(f"[caddis-gate] hub mode ON but no {d} — phase {phase-1} unvalidated\n")
185
+ return EXIT_BLOCKED
186
+
187
+ want = phase - 1
188
+ slug, path_s = plan.stem, str(plan).replace("\\", "/")
189
+ for f in sorted(d.glob("*.verdict.md")):
190
+ fm = _frontmatter(f)
191
+ if not _covers(fm.get("phase", ""), want):
192
+ continue
193
+ ident = fm.get("plan") or fm.get("feature") or ""
194
+ # Accept either spelling: both are in live fleet use and neither is wrong.
195
+ if ident and not (slug in ident or ident.replace("\\", "/") in path_s or ident == slug):
196
+ continue
197
+ v = fm.get("verdict", "")
198
+ if v in ("accept", "accept-with-correction"):
199
+ return EXIT_OK
200
+ if v == "accept-degraded":
201
+ sys.stderr.write(f"[caddis-gate] {f.name} is accept-degraded — validated WITHOUT an "
202
+ "advisory context; record that you proceeded on it\n")
203
+ return EXIT_NOTE
204
+ sys.stderr.write(f"[caddis-gate] {f.name} is '{v}' — phase {want} must be redone\n")
205
+ return EXIT_BLOCKED
206
+ sys.stderr.write(f"[caddis-gate] no verdict covering phase {want} for plan '{slug}'\n")
207
+ return EXIT_BLOCKED
208
+
209
+
210
+ # ── gate: tracker-vs-git ────────────────────────────────────────────────────
211
+
212
+ _ROW = re.compile(r"^\|\s*(\d+(?:-\d+)?)\s*\|(.*)$", re.M)
213
+
214
+
215
+ def gate_tracker(plan: Path, cwd: Path | None = None) -> int:
216
+ if not plan.is_file():
217
+ return _degrade(f"no plan at {plan}")
218
+ text = plan.read_text(encoding="utf-8", errors="ignore")
219
+ if "## Tracker" not in text:
220
+ return _degrade("plan has no ## Tracker")
221
+ tracker = text.split("## Tracker", 1)[1]
222
+
223
+ problems: list[str] = []
224
+ for m in _ROW.finditer(tracker):
225
+ cells = [c.strip() for c in m.group(2).split("|")]
226
+ if len(cells) < 4:
227
+ continue
228
+ status, commit = cells[2].lower(), cells[3].strip("`* ")
229
+ done = "done" in status or "✅" in status
230
+ has_commit = commit not in ("", "—", "-", "–")
231
+ if done and not has_commit:
232
+ problems.append(f"phase {m.group(1)} is '{cells[2]}' with no commit recorded")
233
+ if not problems:
234
+ return EXIT_OK
235
+ for p in problems:
236
+ sys.stderr.write(f"[caddis-gate] tracker/git mismatch: {p}\n")
237
+ sys.stderr.write(" A done row with no commit means the Tracker and the repo disagree. Four rows\n"
238
+ " were once reverted by a stray staged edit and caught two milestones later.\n")
239
+ return EXIT_NOTE
240
+
241
+
242
+
243
+ # ── gate: hub-artifacts ─────────────────────────────────────────────────────
244
+
245
+ def gate_hub_artifacts(reports: Path, extra_refs: list[Path] | None = None) -> int:
246
+ """An artifact that exists on disk but is referenced nowhere the next Hub will look.
247
+
248
+ `/spawn-hub` already conserves LEDGER IDS mechanically and fails the spawn when one vanishes.
249
+ There was no equivalent for the artifacts the Hub itself writes, so the failure mode was exactly
250
+ inverted: the ledger check catches an id that DISAPPEARED; nothing caught a file that EXISTS and
251
+ is invisible.
252
+
253
+ Observed 2026-08-05: a Hub wrote `phase-15-16.prompt.md` and `phase-17.prompt.md`, and its spawn
254
+ prompt named only the first. The spawn passed cleanly - ids conserved, every context question
255
+ answered, plan drift caught. Nothing failed because nothing was looking. The successor would have
256
+ found no prompt for Phase 17, written a fresh one, and silently lost the single load-bearing
257
+ paragraph explaining why that phase must run from a cold process - the whole reason it was split
258
+ out of its batch. A regenerated prompt would have looked complete.
259
+
260
+ Degrades open like every gate here: no directory, no artifacts, nothing readable -> exit 0.
261
+ """
262
+ if not reports.is_dir():
263
+ return _degrade(f"no reports dir at {reports}")
264
+ # PROMPTS ONLY, and deliberately so. A successor Hub regenerates a phase PROMPT it does not
265
+ # know about - that is the documented near-miss. It does not regenerate reports (the
266
+ # implementer writes those) or verdicts (it writes those after the fact, per phase). Checking
267
+ # all three flagged 11 of 37 artifacts in one live repo and 25 of 37 in another, which is a
268
+ # gate nobody keeps switched on. Narrow to the file class that can actually be clobbered.
269
+ artifacts = sorted(reports.glob("phase-*.prompt.md"))
270
+ if not artifacts:
271
+ return _degrade(f"no phase artifacts in {reports}")
272
+
273
+ # Everywhere a successor Hub is told to look: the dir's own index/rules, the most recent
274
+ # spawn docs, and any relay handed over. Read them all as one corpus.
275
+ # This gate is about HANDOVER conservation. With no spawn doc, no handover has happened,
276
+ # so there is no successor and nothing can be orphaned from one. Flagging every prompt in a
277
+ # single-Hub repo would be pure noise: one live repo with 8 prompts and zero handovers was
278
+ # reporting all 8 until this check was added.
279
+ if not any(reports.glob("hub-*.spawn.md")):
280
+ return _degrade(f"no handover has occurred in {reports.name} (no spawn doc)")
281
+
282
+ ref_files: list[Path] = []
283
+ for pat in ("README.md", "AGENTS.md", "hub-*.spawn.md"):
284
+ ref_files += sorted(reports.glob(pat))
285
+ for extra in (extra_refs or []):
286
+ if extra.is_file():
287
+ ref_files.append(extra)
288
+ corpus = ""
289
+ for f in ref_files:
290
+ try:
291
+ corpus += f.read_text(encoding="utf-8", errors="ignore")
292
+ except Exception:
293
+ continue
294
+ if not corpus:
295
+ return _degrade(f"nothing references artifacts in {reports} (no index, no spawn doc)")
296
+
297
+ # A spawn prompt that never names the resume doc produces a Hub that keeps no tracker.
298
+ #
299
+ # Measured: a spawned Hub read relay.md, passed a thirteen-question context check, found four
300
+ # real defects in its handover — and created no tracker, because nothing asked it to. The work
301
+ # survived only because the OUTGOING Hub had written one into relay.md an hour earlier, unasked.
302
+ #
303
+ # The parking-lot note suggested checking that relay.md was "modified in the same commit range".
304
+ # That cannot work: relay.md is GITIGNORED by design, so git never sees it. What is checkable is
305
+ # the instruction — does the spawn prompt tell its successor to keep the tracker at all?
306
+ # ONLY the newest spawn doc, by hub number. Older ones are immutable history: a spawn prompt
307
+ # that was already used cannot be fixed, only falsified, and a gate that stays red on a record
308
+ # nobody may edit is the "always red, therefore ignored" failure this file keeps warning about.
309
+ spawns = sorted(reports.glob("hub-*.spawn.md"),
310
+ key=lambda p: (int(m.group(1)) if (m := re.search(r"hub-(\d+)", p.name)) else 0,
311
+ p.name))
312
+ newest = spawns[-1] if spawns else None
313
+ if newest and "relay" not in newest.read_text(encoding="utf-8", errors="ignore").lower():
314
+ sys.stderr.write(
315
+ f"[caddis-gate] the newest spawn prompt never mentions the resume doc:\n"
316
+ f" {newest.name}\n")
317
+ sys.stderr.write(
318
+ " The succession table records WHO HELD THE ROLE; .caddis/relay.md records WHERE THE\n"
319
+ " WORK IS. The table is only written when a Hub ends, so nothing covers the hours a\n"
320
+ " Hub spends working — the exact period this mechanism exists to protect. Tell the\n"
321
+ " incoming Hub to mirror its task list into relay.md as it goes.\n")
322
+ return EXIT_BLOCKED
323
+
324
+ orphans = [f.name for f in artifacts if f.name not in corpus and f.stem not in corpus]
325
+ if not orphans:
326
+ return EXIT_OK
327
+ sys.stderr.write(
328
+ f"[caddis-gate] {len(orphans)} Hub artifact(s) exist on disk but are referenced nowhere "
329
+ "the next Hub will look:\n")
330
+ for o in orphans:
331
+ sys.stderr.write(f" {o}\n")
332
+ sys.stderr.write(
333
+ " A successor will not know these exist, will regenerate them, and will lose whatever\n"
334
+ " reasoning they carried. Reference them in the spawn doc or the index before handing over.\n")
335
+ return EXIT_BLOCKED
336
+
337
+
338
+ def gate_parking_lot(repo_root: Path) -> int:
339
+ """Every future-work item conforms to the one-register contract, or the push stops.
340
+
341
+ caddis had the parking-lot convention for months and it drifted anyway: on 2026-08-14 the
342
+ directory held four different `type:` values (`issue`, `proposal`, `plan`, `parking-lot`), four
343
+ different `status:` spellings, and one 89 KB file that was a second register wearing an item's
344
+ clothes. None of that was caught, because nothing was looking.
345
+
346
+ Docs alone were already tried. Parking-lot item 002 is the record of an agent that filed a hub
347
+ artifact in the wrong directory while the correct path sat unread on line 188 of the command it
348
+ skipped. The lesson generalises: a convention nothing can fail is a convention that decays.
349
+
350
+ Degrades open like every gate here — no `.caddis/`, no directory, an unreadable file: exit 0.
351
+ Only a positively identified violation blocks.
352
+ """
353
+ try:
354
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
355
+ import caddis_tidy
356
+ except Exception as exc:
357
+ return _degrade(f"caddis_tidy unavailable: {type(exc).__name__}")
358
+
359
+ d = repo_root / ".caddis" / caddis_tidy.PARKING_LOT_DIR
360
+ if not d.is_dir():
361
+ return _degrade(f"no parking-lot at {d}")
362
+ report = caddis_tidy.tidy(repo_root, apply=False, kinds=(caddis_tidy.PARKING_LOT_DIR,))
363
+ if not report.violations:
364
+ return EXIT_OK
365
+ sys.stderr.write(
366
+ f"[caddis-gate] {len(report.violations)} parking-lot violation(s) — future work must be "
367
+ "one item per file, under one contract:\n")
368
+ for path, msg in report.violations:
369
+ try:
370
+ shown = path.relative_to(repo_root)
371
+ except ValueError:
372
+ shown = path
373
+ sys.stderr.write(f" {shown}: {msg}\n")
374
+ sys.stderr.write(
375
+ " Fix them, or run `/caddis:park` to file the item correctly. The contract is in\n"
376
+ " .caddis/parking-lot/README.md.\n")
377
+ return EXIT_BLOCKED
378
+
379
+
380
+ def gate_vendor_drift(repo_root: Path) -> int:
381
+ """A vendored `.github/tools/*.py` that no longer matches the caddis copy it came from.
382
+
383
+ This gate exists because of a FALSE PASS, not a crash. On 2026-08-10 `/caddis:cross-review`
384
+ returned CLEAN on a database write path that was never reviewed. The tool was fixed on
385
+ 2026-08-01; the repo was running its own vendored copy from before that, because the command
386
+ resolves `.github/tools/` FIRST and nothing compared the two files.
387
+
388
+ The general shape is the dangerous part: a vendored copy makes every caddis fix conditional on
389
+ a file nobody remembers copying. It fails in the safe-looking direction, which is why it went
390
+ unnoticed for nine days.
391
+
392
+ Degrades open: no CLAUDE_PLUGIN_ROOT (nothing to compare), no vendored dir, or the caddis
393
+ authoring repo itself -> exit 0.
394
+ """
395
+ try:
396
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
397
+ import claudster_doctor
398
+ except Exception as exc:
399
+ return _degrade(f"claudster_doctor unavailable: {type(exc).__name__}")
400
+
401
+ if not os.environ.get("CLAUDE_PLUGIN_ROOT"):
402
+ return _degrade("CLAUDE_PLUGIN_ROOT unset — nothing to compare the vendored copies against")
403
+ drift = claudster_doctor.vendored_drift(repo_root)
404
+ if not drift:
405
+ return EXIT_OK
406
+ sys.stderr.write(
407
+ f"[caddis-gate] {len(drift)} vendored script(s) differ from the caddis copy:\n")
408
+ for line in drift:
409
+ sys.stderr.write(f" {line}\n")
410
+ sys.stderr.write(
411
+ " The vendored copy WINS at run time, so a caddis fix you already paid for may never\n"
412
+ " reach you. Delete the vendored copy to use the shipped one, or diff them and keep the\n"
413
+ " local change deliberately.\n")
414
+ return EXIT_BLOCKED
415
+
416
+
417
+ # A backticked path in a handover. Placeholders (`<feature>`, `{slug}`, globs) are excluded here
418
+ # rather than filtered later, because a gate that reports `\.caddis/plans/<feature>.md` as missing
419
+ # teaches the reader to skim its output, and a skimmed gate catches nothing.
420
+ _DOC_PATH = re.compile(r"`([^`\s<>{}*?]+?\.(?:md|py|ts|tsx|json|ya?ml|sh|ps1|html|toml|sql))`")
421
+
422
+
423
+ def gate_handover_check(doc: Path, repo_root: Path) -> int:
424
+ """Every file a handover names must exist. Nothing else in a handover is machine-checkable.
425
+
426
+ A succession prompt is written from memory, so it names files the writer BELIEVES are there.
427
+ Bringing one Hub to a usable state took nine corrective round trips and the user caught six of
428
+ them; one was "which prompt exists — the legacy one or the contract-shaped one?", which is this
429
+ check exactly (`.caddis/parking-lot/004-handover-is-recalled-not-generated.md`).
430
+
431
+ SCOPE, STATED HONESTLY. The parking-lot note asks for four checks. Two are mechanical and are
432
+ implemented here: paths exist, and generated artefacts are not older than their generators. The
433
+ other two — "do the numbers match the live system" and "is a settled claim contradicted by a
434
+ later withdrawal" — need conventions that do not exist yet, and a gate that pretends to check
435
+ them would be worse than one that says it does not.
436
+
437
+ Degrades open on a missing/unreadable doc. Blocks only on a path the doc claims and the repo
438
+ does not have.
439
+ """
440
+ if not doc.is_file():
441
+ return _degrade(f"no handover doc at {doc}")
442
+ try:
443
+ text = doc.read_text(encoding="utf-8", errors="ignore")
444
+ except OSError as exc:
445
+ return _degrade(f"cannot read {doc}: {type(exc).__name__}")
446
+
447
+ missing: list[str] = []
448
+ for raw in sorted(set(_DOC_PATH.findall(text))):
449
+ # A backtick often wraps a whole command; the path is the token that looks like one.
450
+ cand = next((tok for tok in raw.split() if "/" in tok or tok.endswith(".md")), raw)
451
+ cand = cand.strip("(),;:'\"")
452
+ if cand.startswith(("http://", "https://", "~", "$")) or ".." in cand:
453
+ continue
454
+ if not (repo_root / cand).exists():
455
+ missing.append(cand)
456
+
457
+ stale: list[dict] = []
458
+ try:
459
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
460
+ import caddis_inventory
461
+ stale = caddis_inventory.stale_artifacts(repo_root)
462
+ except Exception:
463
+ pass # advisory half — never let it decide the exit code
464
+
465
+ for s in stale:
466
+ sys.stderr.write(
467
+ f"[caddis-gate] note: {s['artifact']} was committed before {s['generator']} last "
468
+ "changed — rebuild it before quoting it\n")
469
+
470
+ if not missing:
471
+ return EXIT_OK
472
+ sys.stderr.write(
473
+ f"[caddis-gate] the handover names {len(missing)} path(s) that do not exist:\n")
474
+ for m in missing:
475
+ sys.stderr.write(f" {m}\n")
476
+ sys.stderr.write(
477
+ " The incoming session cannot know these are wrong — it has no other source for them.\n"
478
+ " Fix the paths, or generate the inventory with `python scripts/caddis_inventory.py`\n"
479
+ " instead of writing it from memory.\n")
480
+ return EXIT_BLOCKED
481
+
482
+
483
+ def main(argv: list[str] | None = None) -> int:
484
+ ap = argparse.ArgumentParser(description="caddis machine gates")
485
+ sub = ap.add_subparsers(dest="cmd", required=True)
486
+ for name in ("lane-check", "verdict-gate"):
487
+ s = sub.add_parser(name)
488
+ s.add_argument("--plan", required=True)
489
+ s.add_argument("--phase", type=int, required=True)
490
+ t = sub.add_parser("tracker-vs-git")
491
+ t.add_argument("--plan", required=True)
492
+ h = sub.add_parser("hub-artifacts")
493
+ h.add_argument("--reports", required=True)
494
+ h.add_argument("--refs", nargs="*", default=[])
495
+ pl = sub.add_parser("parking-lot")
496
+ pl.add_argument("--repo-root", default=".")
497
+ vd = sub.add_parser("vendor-drift")
498
+ vd.add_argument("--repo-root", default=".")
499
+ hc = sub.add_parser("handover-check")
500
+ hc.add_argument("--doc", required=True)
501
+ hc.add_argument("--repo-root", default=".")
502
+ a = ap.parse_args(argv)
503
+
504
+ plan = Path(getattr(a, "plan", "") or ".")
505
+ try:
506
+ if a.cmd == "lane-check":
507
+ return gate_lane(plan, a.phase)
508
+ if a.cmd == "verdict-gate":
509
+ return gate_verdict(plan, a.phase)
510
+ if a.cmd == "hub-artifacts":
511
+ return gate_hub_artifacts(Path(a.reports), [Path(r) for r in a.refs])
512
+ if a.cmd == "parking-lot":
513
+ return gate_parking_lot(Path(a.repo_root).resolve())
514
+ if a.cmd == "vendor-drift":
515
+ return gate_vendor_drift(Path(a.repo_root).resolve())
516
+ if a.cmd == "handover-check":
517
+ return gate_handover_check(Path(a.doc), Path(a.repo_root).resolve())
518
+ return gate_tracker(plan)
519
+ except Exception as exc: # pragma: no cover — a gate must never crash the caller
520
+ return _degrade(f"{type(exc).__name__}: {exc}")
521
+
522
+
523
+ if __name__ == "__main__":
524
+ raise SystemExit(main())