@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,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())