@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.
- package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
- package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
- package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
- package/bundles/antigravity-plugin/guard_agy.py +23 -4
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
- package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
- package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
- package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
- package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
- package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
- package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
- package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
- package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
- package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
- package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
- package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
- package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
- package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
- package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
- package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
- package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
- package/bundles/antigravity-plugin/session_end_agy.py +21 -2
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
- package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
- package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
- package/bundles/manifest.json +3 -3
- package/dist/claude-deepseek.js +544 -0
- package/dist/claude-glm.js +544 -0
- package/dist/claude-oss.js +544 -0
- package/dist/cli.js +365 -44
- package/package.json +5 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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())
|