@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,300 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""caddis_spawn — shared machinery for handing work to a fresh session.
|
|
3
|
+
|
|
4
|
+
WHY THIS IS ONE MODULE AND NOT TWO
|
|
5
|
+
----------------------------------
|
|
6
|
+
`/caddis:spawn-hub` and `/caddis:spawn-session` transfer different things — a validation ROLE and
|
|
7
|
+
the WORK itself — but they fail the same way and defend against it with the same four mechanisms:
|
|
8
|
+
|
|
9
|
+
1. capture from the repo, never from recall
|
|
10
|
+
2. a self-check whose naive answer is WRONG, so a successor that skimmed fails visibly
|
|
11
|
+
3. no stored answer key: re-derive at validation time, which checks the DOCUMENT as well
|
|
12
|
+
4. refuse to write anything that goes stale (a commit hash, a test count) as a fact
|
|
13
|
+
|
|
14
|
+
Built as one module so a fix lands in both. The alternative was already visible in the codebase:
|
|
15
|
+
`succession.md` §J had the trap idea and `spawn-session` was about to grow a second copy of it.
|
|
16
|
+
|
|
17
|
+
THE FAILURE MODE, PRECISELY
|
|
18
|
+
---------------------------
|
|
19
|
+
A long session does not forget. It **recalls superseded facts fluently**. Measured in one session:
|
|
20
|
+
two throughput figures the agent had itself withdrawn were re-quoted days later; "E8 is blocked on
|
|
21
|
+
F1" was repeated for three days without a re-test; a commit hash written into a handover went stale
|
|
22
|
+
within the hour. None of that is amnesia, and none of it is caught by asking the outgoing agent
|
|
23
|
+
what it knows — because the failure mode IS what it knows.
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
python scripts/caddis_spawn.py preflight # refuse to spawn from an unsafe state
|
|
27
|
+
python scripts/caddis_spawn.py fingerprint # head + counts, for stale-paste detection
|
|
28
|
+
python scripts/caddis_spawn.py check --doc <file> # round 0: the parent checks its own handover
|
|
29
|
+
python scripts/caddis_spawn.py verify-question --answer-in <file> --needle <text>
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import re
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
41
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
42
|
+
|
|
43
|
+
EXIT_OK, EXIT_REFUSED = 0, 1
|
|
44
|
+
|
|
45
|
+
ART = ".caddis"
|
|
46
|
+
SPAWN_DIR = f"{ART}/spawn-session"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run(args: list[str], cwd: Path) -> str:
|
|
50
|
+
try:
|
|
51
|
+
r = subprocess.run(args, cwd=str(cwd), capture_output=True, text=True,
|
|
52
|
+
encoding="utf-8", errors="replace", timeout=120)
|
|
53
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
54
|
+
except Exception:
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── things that go stale ────────────────────────────────────────────────────────────────────
|
|
59
|
+
#
|
|
60
|
+
# A 7-to-40 character hex run is a commit hash. A handover that states one as a fact is wrong
|
|
61
|
+
# within the hour — measured twice in one session. The fix is not a fresher hash, it is to write
|
|
62
|
+
# the COMMAND that produces it, so the reader gets today's answer instead of yesterday's.
|
|
63
|
+
_HASH = re.compile(r"(?<![0-9a-zA-Z/])[0-9a-f]{7,40}(?![0-9a-zA-Z/])")
|
|
64
|
+
# "1,066 tests" / "821 passed". Same problem: true when written, false an hour later.
|
|
65
|
+
_COUNT = re.compile(r"\b\d[\d,]*\s+(?:tests?|passed|failed|skipped)\b", re.I)
|
|
66
|
+
|
|
67
|
+
# A fingerprint is a MEASUREMENT, explicitly stamped at a moment, and it exists precisely so a
|
|
68
|
+
# stale paste can be detected. That is the opposite of a fact stated as durable, so it is exempt.
|
|
69
|
+
#
|
|
70
|
+
# ANCHORED TO `SPAWN`, AND ONLY THAT. The first version also exempted any line starting with
|
|
71
|
+
# `head` — which exempts "HEAD is currently abc1234", the single most common way of writing the
|
|
72
|
+
# exact thing this check exists to catch. Its own test found it. An exemption is a hole in a check,
|
|
73
|
+
# so it gets the narrowest possible shape: the literal first token of `fingerprint_line()`.
|
|
74
|
+
_FINGERPRINT_LINE = re.compile(r"^\s*SPAWN\b", re.I)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# THE DISTINCTION THAT KEEPS THIS CHECK SWITCHED ON.
|
|
78
|
+
#
|
|
79
|
+
# A hash naming a PAST commit ("shipped in abc1234") is immutable and perfectly safe. A hash
|
|
80
|
+
# offered as CURRENT STATE ("HEAD is abc1234", "we are currently at abc1234") is wrong within the
|
|
81
|
+
# hour — that is the failure 007 recorded, twice. The first version of this flagged both and
|
|
82
|
+
# produced seven findings on one real handover, every one a historical citation. A check with that
|
|
83
|
+
# hit rate gets switched off before it ever catches the one that matters.
|
|
84
|
+
_CURRENT_STATE = re.compile(
|
|
85
|
+
r"\b(head|current|currently|latest|now at|tip of|as of now|at present|working tree)\b", re.I)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_exempt(line: str, in_fence: bool) -> bool:
|
|
89
|
+
"""Fenced blocks are commands and transcripts — quoting a hash there is the CURE, not the bug."""
|
|
90
|
+
return in_fence or bool(_FINGERPRINT_LINE.match(line))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class DocFindings:
|
|
95
|
+
"""`stale_prone` refuses; `historical` only advises.
|
|
96
|
+
|
|
97
|
+
Two tiers because the cost of each error differs. A current-state hash that goes stale sends
|
|
98
|
+
the successor to the wrong commit — block it. A historical citation is durable and merely worth
|
|
99
|
+
a second look — flagging it as a failure is how a check earns a reputation for crying wolf.
|
|
100
|
+
"""
|
|
101
|
+
stale_prone: list[tuple[int, str]] = field(default_factory=list)
|
|
102
|
+
historical: list[tuple[int, str]] = field(default_factory=list)
|
|
103
|
+
missing_paths: list[str] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
def ok(self) -> bool:
|
|
106
|
+
return not self.stale_prone and not self.missing_paths
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_DOC_PATH = re.compile(r"`([^`\s<>{}*?]+?\.(?:md|py|ts|tsx|json|ya?ml|sh|ps1|html|toml|sql))`")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def check_document(text: str, repo_root: Path | None = None) -> DocFindings:
|
|
113
|
+
"""Round 0: the parent validates its OWN handover before anyone reads it.
|
|
114
|
+
|
|
115
|
+
This is the cheapest round and the one issue 004 is really about. In the manual run a child
|
|
116
|
+
caught a stale commit hash the parent could have caught alone — a wasted relay trip, and the
|
|
117
|
+
relay trip is the expensive part of the whole exercise.
|
|
118
|
+
|
|
119
|
+
Fails closed: a claim that cannot survive this does not go in the handover.
|
|
120
|
+
"""
|
|
121
|
+
out = DocFindings()
|
|
122
|
+
in_fence = False
|
|
123
|
+
for n, line in enumerate(text.splitlines(), 1):
|
|
124
|
+
if line.lstrip().startswith("```"):
|
|
125
|
+
in_fence = not in_fence
|
|
126
|
+
continue
|
|
127
|
+
if _is_exempt(line, in_fence):
|
|
128
|
+
continue
|
|
129
|
+
for m in _HASH.finditer(line):
|
|
130
|
+
# A bare decimal number is not a hash; require at least one a-f digit so version
|
|
131
|
+
# strings and dates do not produce a check nobody can ever get to green.
|
|
132
|
+
if not any(c in "abcdef" for c in m.group(0)):
|
|
133
|
+
continue
|
|
134
|
+
if _CURRENT_STATE.search(line):
|
|
135
|
+
out.stale_prone.append((n, f"commit hash `{m.group(0)}` given as CURRENT state — "
|
|
136
|
+
"wrong within the hour, twice. Write the command "
|
|
137
|
+
"(`git rev-parse --short HEAD`) instead"))
|
|
138
|
+
else:
|
|
139
|
+
out.historical.append((n, f"commit hash `{m.group(0)}` — reads as a historical "
|
|
140
|
+
"citation, which is durable. Check that it is not "
|
|
141
|
+
"standing in for current state"))
|
|
142
|
+
for m in _COUNT.finditer(line):
|
|
143
|
+
out.stale_prone.append((n, f"`{m.group(0)}` stated as a fact — write the command "
|
|
144
|
+
"instead; this was wrong within the hour, twice"))
|
|
145
|
+
if repo_root is not None:
|
|
146
|
+
for raw in sorted(set(_DOC_PATH.findall(text))):
|
|
147
|
+
cand = next((tok for tok in raw.split() if "/" in tok or tok.endswith(".md")), raw)
|
|
148
|
+
cand = cand.strip("(),;:'\"")
|
|
149
|
+
if cand.startswith(("http://", "https://", "~", "$")) or ".." in cand:
|
|
150
|
+
continue
|
|
151
|
+
if not (repo_root / cand).exists():
|
|
152
|
+
out.missing_paths.append(cand)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── refusals ────────────────────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class Preflight:
|
|
160
|
+
refusals: list[str] = field(default_factory=list)
|
|
161
|
+
dirty: int = 0
|
|
162
|
+
parking_open: int | None = None
|
|
163
|
+
|
|
164
|
+
def ok(self) -> bool:
|
|
165
|
+
return not self.refusals
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def preflight(repo_root: Path) -> Preflight:
|
|
169
|
+
"""Conditions under which a spawn must not happen at all.
|
|
170
|
+
|
|
171
|
+
A dirty tree is the sharp one: the successor starts by pulling, so **uncommitted work is
|
|
172
|
+
invisible to it**. It will then either redo that work or build on a state that does not exist.
|
|
173
|
+
"""
|
|
174
|
+
p = Preflight()
|
|
175
|
+
status = _run(["git", "status", "--short"], repo_root)
|
|
176
|
+
p.dirty = len([l for l in status.splitlines() if l.strip()])
|
|
177
|
+
if p.dirty:
|
|
178
|
+
p.refusals.append(
|
|
179
|
+
f"{p.dirty} uncommitted file(s). The successor pulls, so uncommitted work is invisible "
|
|
180
|
+
"to it — it will redo the work or build on a state that does not exist. Commit first.")
|
|
181
|
+
|
|
182
|
+
d = repo_root / ART / "parking-lot"
|
|
183
|
+
if d.is_dir():
|
|
184
|
+
p.parking_open = len([f for f in d.glob("*.md") if f.name.lower() != "readme.md"])
|
|
185
|
+
else:
|
|
186
|
+
# The count is the INTEGRITY CHECK on the handover: a successor that revives 13 of 15 has
|
|
187
|
+
# silently dropped two, and nothing else would show it.
|
|
188
|
+
p.refusals.append(
|
|
189
|
+
f"no {ART}/parking-lot/ — the open-item count is the integrity check on a handover, "
|
|
190
|
+
"and without it a dropped item is undetectable.")
|
|
191
|
+
return p
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── fingerprint ─────────────────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
def fingerprint(repo_root: Path, with_tests: bool = False) -> dict:
|
|
197
|
+
"""A stamp the child echoes back, so the parent can spot a stale or mismatched paste.
|
|
198
|
+
|
|
199
|
+
NOT a fact for the handover body — a measurement, at a moment, for exactly one purpose.
|
|
200
|
+
"""
|
|
201
|
+
fp = {
|
|
202
|
+
"head": _run(["git", "rev-parse", "--short", "HEAD"], repo_root) or "unknown",
|
|
203
|
+
"branch": _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], repo_root) or "unknown",
|
|
204
|
+
"parking_open": preflight(repo_root).parking_open,
|
|
205
|
+
"tests": "not measured",
|
|
206
|
+
}
|
|
207
|
+
if with_tests:
|
|
208
|
+
out = _run([sys.executable, "-m", "pytest", "-q", "--tb=no"], repo_root)
|
|
209
|
+
tail = [l for l in out.splitlines() if "passed" in l or "failed" in l]
|
|
210
|
+
fp["tests"] = tail[-1] if tail else "suite reported no summary"
|
|
211
|
+
return fp
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def fingerprint_line(fp: dict) -> str:
|
|
215
|
+
return (f"SPAWN | head {fp['head']} ({fp['branch']}) | parking-lot open {fp['parking_open']} "
|
|
216
|
+
f"| tests: {fp['tests']}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ── question answerability ──────────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
def verify_answerable(repo_root: Path, answer_in: str, needle: str) -> tuple[bool, str]:
|
|
222
|
+
"""A question must be answerable from a COMMITTED file, or it tests memory.
|
|
223
|
+
|
|
224
|
+
Testing memory is the thing being replaced, so a question that fails this is not a harder
|
|
225
|
+
question — it is the old failure wearing an exam's clothes.
|
|
226
|
+
"""
|
|
227
|
+
path = repo_root / answer_in
|
|
228
|
+
if not path.is_file():
|
|
229
|
+
return False, f"{answer_in} does not exist — the answer is not written down anywhere"
|
|
230
|
+
tracked = _run(["git", "ls-files", "--error-unmatch", answer_in], repo_root)
|
|
231
|
+
try:
|
|
232
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
233
|
+
except OSError as exc:
|
|
234
|
+
return False, f"{answer_in} unreadable: {type(exc).__name__}"
|
|
235
|
+
if needle.lower() not in text.lower():
|
|
236
|
+
return False, f"{answer_in} does not contain {needle!r} — the answer is not in that file"
|
|
237
|
+
if not tracked:
|
|
238
|
+
return True, (f"{answer_in} contains the answer, but is NOT COMMITTED — the successor "
|
|
239
|
+
"pulls and will not see it")
|
|
240
|
+
return True, f"{answer_in} contains {needle!r} and is committed"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main(argv: list[str] | None = None) -> int:
|
|
244
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
245
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
246
|
+
for name in ("preflight", "fingerprint"):
|
|
247
|
+
s = sub.add_parser(name)
|
|
248
|
+
s.add_argument("--repo-root", default=".")
|
|
249
|
+
if name == "fingerprint":
|
|
250
|
+
s.add_argument("--with-tests", action="store_true")
|
|
251
|
+
c = sub.add_parser("check")
|
|
252
|
+
c.add_argument("--doc", required=True)
|
|
253
|
+
c.add_argument("--repo-root", default=".")
|
|
254
|
+
q = sub.add_parser("verify-question")
|
|
255
|
+
q.add_argument("--answer-in", required=True)
|
|
256
|
+
q.add_argument("--needle", required=True)
|
|
257
|
+
q.add_argument("--repo-root", default=".")
|
|
258
|
+
a = ap.parse_args(argv)
|
|
259
|
+
root = Path(a.repo_root).resolve()
|
|
260
|
+
|
|
261
|
+
if a.cmd == "preflight":
|
|
262
|
+
p = preflight(root)
|
|
263
|
+
if p.ok():
|
|
264
|
+
print(f"[spawn] ready — clean tree, {p.parking_open} open parking-lot item(s)")
|
|
265
|
+
return EXIT_OK
|
|
266
|
+
sys.stderr.write("[spawn] REFUSING to spawn:\n")
|
|
267
|
+
for r in p.refusals:
|
|
268
|
+
sys.stderr.write(f" {r}\n")
|
|
269
|
+
return EXIT_REFUSED
|
|
270
|
+
|
|
271
|
+
if a.cmd == "fingerprint":
|
|
272
|
+
print(fingerprint_line(fingerprint(root, with_tests=a.with_tests)))
|
|
273
|
+
return EXIT_OK
|
|
274
|
+
|
|
275
|
+
if a.cmd == "check":
|
|
276
|
+
doc = Path(a.doc)
|
|
277
|
+
if not doc.is_file():
|
|
278
|
+
sys.stderr.write(f"[spawn] no document at {doc}\n")
|
|
279
|
+
return EXIT_REFUSED
|
|
280
|
+
f = check_document(doc.read_text(encoding="utf-8", errors="ignore"), root)
|
|
281
|
+
for n, msg in f.historical:
|
|
282
|
+
print(f" note line {n}: {msg}")
|
|
283
|
+
if f.ok():
|
|
284
|
+
print(f"[spawn] {doc.name} passes round 0 — nothing stale-prone, every path exists")
|
|
285
|
+
return EXIT_OK
|
|
286
|
+
sys.stderr.write(f"[spawn] {doc.name} FAILS round 0:\n")
|
|
287
|
+
for n, msg in f.stale_prone:
|
|
288
|
+
sys.stderr.write(f" line {n}: {msg}\n")
|
|
289
|
+
for p_ in f.missing_paths:
|
|
290
|
+
sys.stderr.write(f" names a path that does not exist: {p_}\n")
|
|
291
|
+
sys.stderr.write(" A claim that cannot be re-derived does not go in the handover.\n")
|
|
292
|
+
return EXIT_REFUSED
|
|
293
|
+
|
|
294
|
+
ok, msg = verify_answerable(root, a.answer_in, a.needle)
|
|
295
|
+
(print if ok else sys.stderr.write)(f"[spawn] {msg}" + ("" if ok else "\n"))
|
|
296
|
+
return EXIT_OK if ok else EXIT_REFUSED
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
if __name__ == "__main__":
|
|
300
|
+
raise SystemExit(main())
|