@caddis/cli 0.2.5 → 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 (54) 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 +2 -0
  34. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +22 -0
  35. package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
  37. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
  39. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +33 -0
  40. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +213 -0
  41. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
  42. package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
  43. package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
  44. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  45. package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
  46. package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
  47. package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
  48. package/bundles/manifest.json +3 -3
  49. package/dist/claude-deepseek.js +544 -0
  50. package/dist/claude-glm.js +544 -0
  51. package/dist/claude-oss.js +544 -0
  52. package/dist/cli.js +375 -46
  53. package/package.json +5 -2
  54. package/bundles/antigravity-plugin/statusline-command-agy.sh +0 -91
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env python3
2
+ """caddis_tidy — deterministic done/-folder lifecycle for `.caddis/plans/` and `.caddis/prompts/`.
3
+
4
+ WHY
5
+ ---
6
+ `.caddis/plans/` and `.caddis/prompts/` accumulate finished artifacts. Moving a terminal-status
7
+ file into its sibling `done/` was done BY HAND twice in one week (2026-08-05, 2026-08-06) across
8
+ this repo alone. `.caddis/plans/artifact-lifecycle-tidy.md` designed the fix; this is Phase 1.
9
+
10
+ DESIGN (locked, see the plan doc — do not re-litigate here)
11
+ -------------------------------------------------------------
12
+ - Frontmatter `status:` is the single source of truth. Terminal = `done` (canonical) and
13
+ `superseded`; `shipped`/`implemented` are accepted legacy synonyms, all case-insensitive.
14
+ Everything else (`draft`, `current`, `ready`, ...) is active. `ready` is approved-and-waiting,
15
+ NOT terminal.
16
+ - A DETERMINISTIC SCRIPT does the moving — never model judgment, never a mutating hook. With
17
+ multiple concurrent sessions per repo, a background hook that moves files mid-session is a
18
+ race; a command-invoked script is predictable and testable.
19
+ - Dry-run by default; --apply moves; --check validates conformance (frontmatter-less or an
20
+ unknown status on a top-level artifact) and exits 1 on a violation, for a future CI/pre-push
21
+ gate. Legacy frontmatter-less prompts are left alone by the mover, only flagged by --check.
22
+ - NEVER auto-flips `status:` (that's the implementing session's judgment call) and NEVER
23
+ overwrites an existing `done/<name>` — a collision is reported and skipped, not clobbered.
24
+
25
+ Usage:
26
+ python scripts/caddis_tidy.py # report only (default = dry-run)
27
+ python scripts/caddis_tidy.py --apply # actually move terminal-status artifacts
28
+ python scripts/caddis_tidy.py --check # conformance check; exit 1 on a violation
29
+ python scripts/caddis_tidy.py --repo-root <p> # scan a different repo (default: cwd)
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import re
36
+ import subprocess
37
+ import sys
38
+ from dataclasses import dataclass, field
39
+ from pathlib import Path
40
+
41
+ TERMINAL_STATUSES = {"done", "superseded", "shipped", "implemented"}
42
+
43
+ # Legacy prompts written before the frontmatter convention existed (2026-07-23). They were
44
+ # moved by hand already, per this feature's own Non-goals: "No retro-tagging of historical
45
+ # prompts... the convention applies going forward." Without this allowlist, --check could never
46
+ # be wired into CI/pre-push in THIS repo - it would exit 1 forever on content nobody intends to
47
+ # retrofit. New violations (a prompt written after 2026-07-23 with no frontmatter) still fail.
48
+ LEGACY_FRONTMATTER_EXEMPT = {
49
+ "prompts/caddis-rename-and-publish.md",
50
+ "prompts/db-diagram-fix-er-arrows-theme-formatting.md",
51
+ "prompts/docket-phase-2c-ux-correctness.md",
52
+ "prompts/docket-phase-3-f12-worktree.md",
53
+ "prompts/docket-reaudit-f12-and-pipeline-runner.md",
54
+ "prompts/driver-remaining-toolchain-work.md",
55
+ "prompts/fable-inspect-claudster.md",
56
+ "prompts/fable-inspect-docket.md",
57
+ "prompts/fable-verify-docket-reaudit.md",
58
+ }
59
+
60
+ _PHASE_HEADING_RE = re.compile(r"^#{2,4}\s*Phase\s+.+$", re.MULTILINE)
61
+ _DONE_MARKERS = ("✅",)
62
+ _NOT_DONE_MARKERS = ("⏳", "🔨", "🔲")
63
+
64
+ _KINDS = ("plans", "prompts", "parking-lot")
65
+
66
+ # ── parking-lot: the ONE register of future work ────────────────────────────────────────────
67
+ # Before this landed, future work lived in nine places at once (measured 2026-08-14 in this repo):
68
+ # individual parking-lot files, a 90 KB `future-work-register.md`, parked plans, relay.md's
69
+ # "things owed", KB notes, `.caddis/plans/backlog/`, the comms register, docket, and the Hub's
70
+ # carried-open lists. Nothing was wrong with any single one; the DEFECT was that a reader had to
71
+ # know all nine to answer "what is left to do?". Nobody did, so items were re-raised and dropped.
72
+ #
73
+ # The rule is now: one item, one file, in `.caddis/parking-lot/`. Everything else POINTS here.
74
+ # These constants are what makes that a check instead of a sentence.
75
+ PARKING_LOT_DIR = "parking-lot"
76
+ PARKING_LOT_TYPE = "parking-lot"
77
+ # Lifecycle. Deliberately four words and no synonyms — the plans/prompts vocabulary accreted three
78
+ # legacy spellings of "done" and every consumer now has to know all of them.
79
+ PARKING_LOT_STATUSES = {"open", "doing", "done", "dropped"}
80
+ PARKING_LOT_TERMINAL = {"done", "dropped"}
81
+ # `dropped` is terminal ON PURPOSE, and it must carry a reason in the body. An item deleted outright
82
+ # gets re-raised by the next session that has the same idea; an item kept with "no, because ..."
83
+ # does not. That is the whole difference between a backlog and a memory.
84
+ #
85
+ # COMMITMENT is a second, INDEPENDENT axis from status. `future: yes` means decided-and-owed;
86
+ # absent or `no` means candidate. Folding this into `status:` was considered and rejected — an item
87
+ # that is both committed AND in progress would have had no legal value.
88
+ FUTURE_KEY = "future"
89
+ FUTURE_VALUES = {"yes", "no"}
90
+ # A parking-lot file over this size is a REGISTER wearing an item's clothes. This is the exact
91
+ # shape being retired: `future-work-register.md` was 90,079 bytes and hid 3 open items among ~40
92
+ # resolved ones, so in practice nobody read it and its open items went unworked for weeks. The
93
+ # longest genuine single item in the same directory is 9,961 bytes, so 20 KB leaves real headroom.
94
+ PARKING_LOT_MAX_BYTES = 20_000
95
+
96
+
97
+ def _parse_frontmatter(text: str) -> dict:
98
+ text = text.lstrip("")
99
+ if not text.startswith("---"):
100
+ return {}
101
+ lines = text.splitlines()
102
+ if lines[0].strip() != "---":
103
+ return {}
104
+ fm: dict[str, str] = {}
105
+ for line in lines[1:]:
106
+ if line.strip() == "---":
107
+ break
108
+ if ":" in line:
109
+ k, _, v = line.partition(":")
110
+ fm[k.strip()] = v.strip()
111
+ return fm
112
+
113
+
114
+ def _phases_all_done(text: str) -> bool:
115
+ headings = _PHASE_HEADING_RE.findall(text)
116
+ if not headings:
117
+ return False
118
+ for h in headings:
119
+ if any(m in h for m in _NOT_DONE_MARKERS):
120
+ return False
121
+ if not any(m in h for m in _DONE_MARKERS):
122
+ return False
123
+ return True
124
+
125
+
126
+ def classify_status(status: str | None, kind: str = "plans") -> str:
127
+ """`terminal` (move it) / `active` (leave it) / `none` (no status field) / `unknown` (bad word).
128
+
129
+ `unknown` is returned for parking-lot only. plans/prompts deliberately keep their open
130
+ vocabulary — `draft`, `current`, `ready` and friends all mean "not finished" and the mover only
131
+ ever needed to know "is this terminal?". parking-lot is a CLOSED vocabulary because its whole
132
+ job is to be countable: "how many open items are there?" has no answer if `wip`, `todo` and
133
+ `pending` are all legal spellings of the same state.
134
+ """
135
+ if status is None:
136
+ return "none"
137
+ s = status.strip().lower()
138
+ if kind == PARKING_LOT_DIR:
139
+ if s not in PARKING_LOT_STATUSES:
140
+ return "unknown"
141
+ return "terminal" if s in PARKING_LOT_TERMINAL else "active"
142
+ return "terminal" if s in TERMINAL_STATUSES else "active"
143
+
144
+
145
+ @dataclass
146
+ class Item:
147
+ path: Path
148
+ status: str | None
149
+ frontmatter: dict
150
+ phases_all_done: bool
151
+ size: int = 0
152
+
153
+
154
+ def scan(dir_path: Path) -> list[Item]:
155
+ """Top-level `*.md` only — `done/` (or anything else nested) is excluded by construction."""
156
+ if not dir_path.is_dir():
157
+ return []
158
+ items = []
159
+ for f in sorted(dir_path.glob("*.md")):
160
+ if f.name.lower() == "readme.md":
161
+ continue
162
+ text = f.read_text(encoding="utf-8", errors="ignore")
163
+ fm = _parse_frontmatter(text)
164
+ items.append(Item(
165
+ path=f,
166
+ status=fm.get("status"),
167
+ frontmatter=fm,
168
+ phases_all_done=_phases_all_done(text),
169
+ size=len(text.encode("utf-8")),
170
+ ))
171
+ return items
172
+
173
+
174
+ @dataclass
175
+ class Report:
176
+ moved: list[Path] = field(default_factory=list)
177
+ would_move: list[Path] = field(default_factory=list)
178
+ stale_suspects: list[Path] = field(default_factory=list)
179
+ frontmatter_less: list[Path] = field(default_factory=list)
180
+ unknown_status: list[Path] = field(default_factory=list)
181
+ collisions: list[Path] = field(default_factory=list)
182
+ # Subset of frontmatter_less/unknown_status that ISN'T grandfathered by
183
+ # LEGACY_FRONTMATTER_EXEMPT - these are what actually fail --check.
184
+ check_flagged: list[Path] = field(default_factory=list)
185
+ # parking-lot only. Each carries its own message because "your parking-lot item is wrong" is
186
+ # useless to the agent that has to fix it - it needs to know WHICH rule and WHAT the legal
187
+ # values are, in the failure text, without opening another document.
188
+ violations: list[tuple[Path, str]] = field(default_factory=list)
189
+
190
+ def check_violations(self) -> int:
191
+ return len(self.check_flagged)
192
+
193
+ def _flag(self, path: Path, message: str) -> None:
194
+ self.violations.append((path, message))
195
+ if path not in self.check_flagged:
196
+ self.check_flagged.append(path)
197
+
198
+
199
+ def _is_git_repo(repo_root: Path) -> bool:
200
+ return (repo_root / ".git").exists()
201
+
202
+
203
+ def _run_git_mv(repo_root: Path, src: Path, dst: Path) -> bool:
204
+ """Returns True on success. Degrades to plain move (caller's job) on any failure."""
205
+ try:
206
+ result = subprocess.run(
207
+ ["git", "mv", str(src), str(dst)],
208
+ cwd=str(repo_root), capture_output=True, text=True, timeout=30,
209
+ )
210
+ return result.returncode == 0
211
+ except Exception:
212
+ return False
213
+
214
+
215
+ def _move_one(repo_root: Path, item: Item, apply: bool, report: Report) -> None:
216
+ done_dir = item.path.parent / "done"
217
+ dst = done_dir / item.path.name
218
+ if dst.exists():
219
+ report.collisions.append(item.path)
220
+ return
221
+ if not apply:
222
+ report.would_move.append(item.path)
223
+ return
224
+ done_dir.mkdir(parents=True, exist_ok=True)
225
+ moved_via_git = False
226
+ if _is_git_repo(repo_root):
227
+ moved_via_git = _run_git_mv(repo_root, item.path, dst)
228
+ if not moved_via_git:
229
+ dst.parent.mkdir(parents=True, exist_ok=True)
230
+ item.path.rename(dst)
231
+ report.moved.append(item.path)
232
+
233
+
234
+ def _is_legacy_exempt(repo_root: Path, path: Path) -> bool:
235
+ try:
236
+ rel = path.relative_to(repo_root / ".caddis").as_posix()
237
+ except ValueError:
238
+ return False
239
+ return rel in LEGACY_FRONTMATTER_EXEMPT
240
+
241
+
242
+ def _check_parking_lot_item(item: Item, report: Report) -> None:
243
+ """Validate ONE future-work item against the closed contract. Records every violation it finds,
244
+ it does not stop at the first - an agent that fixes one rule per run needs three round trips."""
245
+ fm = item.frontmatter
246
+ if not fm:
247
+ report.frontmatter_less.append(item.path)
248
+ report._flag(item.path, "no frontmatter. A parking-lot item needs at least "
249
+ "`type: parking-lot` and `status: open`.")
250
+ return
251
+
252
+ got_type = (fm.get("type") or "").strip().lower()
253
+ if got_type != PARKING_LOT_TYPE:
254
+ shown = got_type or "(missing)"
255
+ report._flag(item.path, f"type is `{shown}`, must be `{PARKING_LOT_TYPE}`. "
256
+ "Everything in this directory is one future-work item, whatever "
257
+ "it started life as.")
258
+
259
+ cls = classify_status(item.status, PARKING_LOT_DIR)
260
+ if cls == "none":
261
+ report.frontmatter_less.append(item.path)
262
+ report._flag(item.path, "no `status:` field. Use one of: "
263
+ f"{', '.join(sorted(PARKING_LOT_STATUSES))}.")
264
+ elif cls == "unknown":
265
+ report.unknown_status.append(item.path)
266
+ report._flag(item.path, f"status is `{item.status}`, which is not a legal value. Use one "
267
+ f"of: {', '.join(sorted(PARKING_LOT_STATUSES))}.")
268
+
269
+ if FUTURE_KEY in fm:
270
+ val = (fm.get(FUTURE_KEY) or "").strip().lower()
271
+ if val not in FUTURE_VALUES:
272
+ report._flag(item.path, f"`{FUTURE_KEY}:` is `{val or '(empty)'}`, must be "
273
+ f"{' or '.join(sorted(FUTURE_VALUES))}. `yes` means committed "
274
+ "work, not a candidate.")
275
+
276
+ if item.size > PARKING_LOT_MAX_BYTES:
277
+ report._flag(item.path, f"{item.size:,} bytes, over the {PARKING_LOT_MAX_BYTES:,} byte "
278
+ "ceiling. This is a register, not one item. Split each open item "
279
+ "into its own file and move the resolved history to done/.")
280
+
281
+
282
+ def tidy(repo_root: Path, apply: bool = False, check: bool = False,
283
+ kinds: tuple[str, ...] = _KINDS) -> Report:
284
+ report = Report()
285
+ for kind in kinds:
286
+ items = scan(repo_root / ".caddis" / kind)
287
+ for item in items:
288
+ if kind == PARKING_LOT_DIR:
289
+ _check_parking_lot_item(item, report)
290
+ # A terminal item still moves even if it broke another rule. Sweeping finished work
291
+ # out of the way is independent of whether its frontmatter was tidy.
292
+ if classify_status(item.status, PARKING_LOT_DIR) == "terminal":
293
+ _move_one(repo_root, item, apply, report)
294
+ continue
295
+ cls = classify_status(item.status)
296
+ if cls == "terminal":
297
+ _move_one(repo_root, item, apply, report)
298
+ continue
299
+ if cls == "none":
300
+ report.frontmatter_less.append(item.path)
301
+ # never moved, never even attempted - the mover leaves it silently in --apply;
302
+ # --check is what surfaces it (unless grandfathered as pre-convention legacy).
303
+ if not _is_legacy_exempt(repo_root, item.path):
304
+ report.check_flagged.append(item.path)
305
+ if item.phases_all_done:
306
+ report.stale_suspects.append(item.path)
307
+ continue
308
+ # active status
309
+ if item.phases_all_done:
310
+ report.stale_suspects.append(item.path)
311
+ return report
312
+
313
+
314
+ def nudge_line(repo_root: Path) -> str | None:
315
+ """The single SessionStart nudge line, or None. PURE dry-run scan (no subprocess, no LLM, no
316
+ auto-fix), mirroring claudster_doctor.nudge_line's contract - visibility only, never a move."""
317
+ try:
318
+ report = tidy(repo_root, apply=False)
319
+ except Exception:
320
+ return None
321
+ n = len(report.would_move)
322
+ if not n:
323
+ return None
324
+ plural = "artifact" if n == 1 else "artifacts"
325
+ return f"[caddis] {n} finished {plural} awaiting tidy — /handoff moves them"
326
+
327
+
328
+ def main(argv: list[str] | None = None) -> int:
329
+ ap = argparse.ArgumentParser(description="Move terminal-status plans/prompts into done/")
330
+ ap.add_argument("--repo-root", default=".", help="repo root containing .caddis/ (default: cwd)")
331
+ ap.add_argument("--apply", action="store_true", help="actually move files (default: dry-run report)")
332
+ ap.add_argument("--check", action="store_true",
333
+ help="conformance check - exit 1 if any top-level artifact lacks a known status")
334
+ args = ap.parse_args(argv)
335
+
336
+ repo_root = Path(args.repo_root).resolve()
337
+ report = tidy(repo_root, apply=args.apply, check=args.check)
338
+
339
+ mode = "APPLY" if args.apply else ("CHECK" if args.check else "DRY-RUN")
340
+ print(f"[caddis_tidy] mode={mode} root={repo_root}")
341
+ moved_or_would = report.moved if args.apply else report.would_move
342
+ label = "moved" if args.apply else "would move"
343
+ for p in moved_or_would:
344
+ print(f" {label}: {p.relative_to(repo_root)}")
345
+ for p in report.collisions:
346
+ print(f" COLLISION (skipped, done/ already has this name): {p.relative_to(repo_root)}")
347
+ for p in report.stale_suspects:
348
+ print(f" stale-suspect (all phases done, status still active): {p.relative_to(repo_root)}")
349
+ if args.check:
350
+ exempt = [p for p in report.frontmatter_less if p not in report.check_flagged]
351
+ detailed = {p for p, _ in report.violations}
352
+ for p, msg in report.violations:
353
+ print(f" CHECK VIOLATION: {p.relative_to(repo_root)}: {msg}")
354
+ for p in report.check_flagged:
355
+ if p not in detailed:
356
+ print(f" CHECK VIOLATION: no status field: {p.relative_to(repo_root)}")
357
+ for p in exempt:
358
+ print(f" (legacy, grandfathered): {p.relative_to(repo_root)}")
359
+
360
+ if args.check:
361
+ return 1 if report.check_violations() else 0
362
+ return 0
363
+
364
+
365
+ if __name__ == "__main__":
366
+ raise SystemExit(main())