@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,369 @@
1
+ #!/usr/bin/env python3
2
+ """caddis_inventory — enumerate what EXISTS in a repo, so a handover stops being a memory test.
3
+
4
+ WHY
5
+ ---
6
+ A succession prompt used to be composed from what the outgoing session remembered to mention.
7
+ Anything it forgot, the incoming session never learned — and the incoming session cannot know what
8
+ it was not told. Bringing one Hub to a usable state took **nine** corrective round trips, and the
9
+ user caught six of the nine (`.caddis/parking-lot/004-handover-is-recalled-not-generated.md`).
10
+
11
+ Look at what the user caught, because the pattern is the whole argument:
12
+
13
+ "the handover listed no reports, documents, KB notes or scripts"
14
+ "planned future work is absent"
15
+ "which prompt exists — the legacy one or the contract-shaped one?"
16
+
17
+ None of those are judgement calls. Every one is a directory listing. They were missed because the
18
+ agent was recalling *what it did* rather than enumerating *what exists*, and those are different
19
+ sets. A generator cannot forget the report it never wrote.
20
+
21
+ WHAT THIS IS NOT
22
+ ----------------
23
+ This does not write the handover. Judgement — what matters, what to do next, what nearly went
24
+ wrong — is the outgoing session's job and cannot be derived from disk. This produces the half that
25
+ can be, so the human-written half is all judgement and no clerical work.
26
+
27
+ STALENESS IS MEASURED IN COMMITS, NOT MTIMES
28
+ --------------------------------------------
29
+ A git checkout stamps every file with the checkout time, so mtimes say nothing about whether a
30
+ generated report predates the feature it should describe. Last-commit time does. That exact failure
31
+ is item 8 of the nine: reports committed before a feature was added and never rebuilt.
32
+
33
+ Usage:
34
+ python scripts/caddis_inventory.py # markdown to stdout
35
+ python scripts/caddis_inventory.py --format json # machine-readable
36
+ python scripts/caddis_inventory.py --with-tests # also RUN the suite (never quote a
37
+ # remembered test count — one handover
38
+ # claimed 20 when there were 19)
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import argparse
43
+ import json
44
+ import re
45
+ import subprocess
46
+ import sys
47
+ from dataclasses import dataclass, field
48
+ from pathlib import Path
49
+
50
+ if hasattr(sys.stdout, "reconfigure"):
51
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
52
+
53
+ ART = ".caddis"
54
+
55
+ # Artefact kinds worth enumerating, and what a reader needs from each. Order matters: it is the
56
+ # order a reader wants them in, not alphabetical.
57
+ PLAN_DIRS = ("plans", "prd", "prompts", "handoffs", "agent-docs")
58
+
59
+
60
+ def _run(args: list[str], cwd: Path) -> str:
61
+ try:
62
+ r = subprocess.run(args, cwd=str(cwd), capture_output=True, text=True,
63
+ encoding="utf-8", errors="replace", timeout=30)
64
+ return r.stdout.strip() if r.returncode == 0 else ""
65
+ except Exception:
66
+ return ""
67
+
68
+
69
+ def frontmatter(path: Path) -> dict[str, str]:
70
+ try:
71
+ text = path.read_text(encoding="utf-8", errors="ignore").lstrip("")
72
+ except OSError:
73
+ return {}
74
+ if not text.startswith("---"):
75
+ return {}
76
+ end = text.find("\n---", 3)
77
+ out: dict[str, str] = {}
78
+ for line in text[3:end if end > 0 else 800].splitlines():
79
+ if ":" in line and not line.startswith((" ", "\t")):
80
+ k, _, v = line.partition(":")
81
+ out[k.strip()] = v.strip()
82
+ return out
83
+
84
+
85
+ def title_of(path: Path) -> str:
86
+ """First markdown H1, else the filename. What a reader scans for."""
87
+ try:
88
+ for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
89
+ if line.startswith("# "):
90
+ return line[2:].strip()
91
+ except OSError:
92
+ pass
93
+ return path.stem
94
+
95
+
96
+ @dataclass
97
+ class Inventory:
98
+ repo: str = ""
99
+ branch: str = ""
100
+ uncommitted: int = 0
101
+ unpushed: int = 0
102
+ artifacts: dict[str, list[dict]] = field(default_factory=dict)
103
+ parking_lot: list[dict] = field(default_factory=list)
104
+ kb: list[dict] = field(default_factory=list)
105
+ comms_open: list[dict] = field(default_factory=list)
106
+ scripts: list[dict] = field(default_factory=list)
107
+ stale_artifacts: list[dict] = field(default_factory=list)
108
+ environment_map: str = "missing"
109
+ tests: str = "not measured — run the suite, never quote a remembered number"
110
+
111
+
112
+ def scan_artifacts(root: Path) -> dict[str, list[dict]]:
113
+ out: dict[str, list[dict]] = {}
114
+ for kind in PLAN_DIRS:
115
+ d = root / ART / kind
116
+ if not d.is_dir():
117
+ continue
118
+ rows = []
119
+ for f in sorted(d.glob("*.md")):
120
+ if f.name.lower() == "readme.md":
121
+ continue
122
+ fm = frontmatter(f)
123
+ rows.append({"file": f"{ART}/{kind}/{f.name}",
124
+ "title": title_of(f),
125
+ "status": fm.get("status", "(none)")})
126
+ done = len(list((d / "done").glob("*.md"))) if (d / "done").is_dir() else 0
127
+ if rows or done:
128
+ out[kind] = rows
129
+ if done:
130
+ out[kind].append({"file": f"{ART}/{kind}/done/", "title": f"{done} finished",
131
+ "status": "archived"})
132
+ return out
133
+
134
+
135
+ def scan_parking_lot(root: Path) -> list[dict]:
136
+ """The backlog. Listed separately from plans because a reader's question is different:
137
+ plans ask "what is running?", the parking lot asks "what did we agree to do and have not?"."""
138
+ d = root / ART / "parking-lot"
139
+ if not d.is_dir():
140
+ return []
141
+ rows = []
142
+ for f in sorted(d.glob("*.md")):
143
+ if f.name.lower() == "readme.md":
144
+ continue
145
+ fm = frontmatter(f)
146
+ rows.append({"file": f.name, "title": title_of(f),
147
+ "status": fm.get("status", "(none)"),
148
+ "severity": fm.get("severity", ""),
149
+ "future": fm.get("future", "")})
150
+ # committed work first, then by severity — the order a reader needs, not the filesystem's
151
+ order = {"high": 0, "medium": 1, "low": 2, "": 3}
152
+ rows.sort(key=lambda r: (r["future"].lower() != "yes", order.get(r["severity"].lower(), 3)))
153
+ return rows
154
+
155
+
156
+ def scan_kb(root: Path) -> list[dict]:
157
+ d = root / ART / "kb"
158
+ if not d.is_dir():
159
+ return []
160
+ return [{"file": f"{ART}/kb/{f.name}", "title": title_of(f)}
161
+ for f in sorted(d.glob("*.md")) if f.name != "DOC-MAP.md"]
162
+
163
+
164
+ def environment_map_state(root: Path) -> str:
165
+ """`missing`, `empty` or `filled` — surfaced because an absent environment map is invisible.
166
+
167
+ Every other gap in a handover shows up as a section with nothing under it. This one shows up as
168
+ nothing at all, which reads exactly like "there was nothing to say". One session searched the
169
+ filesystem for a repository that lived in Gitea while the project's own docs cited a file inside
170
+ it by path — the fact existed, it just had no shelf.
171
+ """
172
+ f = root / ART / "kb" / "environment-map.md"
173
+ if not f.is_file():
174
+ return "missing"
175
+ try:
176
+ text = f.read_text(encoding="utf-8", errors="ignore")
177
+ except OSError:
178
+ return "missing"
179
+ # The scaffolded template is all headings and "(none recorded yet)" placeholders. A file that
180
+ # still carries every placeholder has never been filled in, and saying "present" would be a lie
181
+ # of exactly the kind this whole item is about.
182
+ return "empty" if "_(none recorded yet)_" in text and text.count("|") < 60 else "filled"
183
+
184
+
185
+ def scan_comms(root: Path) -> list[dict]:
186
+ """Rows in the comms register that are not yet ACTIONED or DROPPED — an ask nobody is chasing
187
+ is indistinguishable from a forgotten one, which is the whole reason that register exists."""
188
+ reg = root / ART / "comms" / "register.md"
189
+ if not reg.is_file():
190
+ return []
191
+ out = []
192
+ for line in reg.read_text(encoding="utf-8", errors="ignore").splitlines():
193
+ if not line.startswith("|") or line.startswith("|---") or "_(none yet)_" in line:
194
+ continue
195
+ cells = [c.strip() for c in line.strip("|").split("|")]
196
+ if len(cells) < 4 or cells[0].lower() in ("raised", "status"):
197
+ continue
198
+ status = cells[3].strip("`").upper()
199
+ if status in ("DRAFT", "SENT", "ANSWERED"):
200
+ out.append({"raised": cells[0], "audience": cells[1],
201
+ "subject": cells[2], "status": status})
202
+ return out
203
+
204
+
205
+ def scan_scripts(root: Path) -> list[dict]:
206
+ """Every script and the first line of its docstring. A handover that omits these makes the
207
+ next session re-discover the tooling it already owns."""
208
+ d = root / "scripts"
209
+ if not d.is_dir():
210
+ return []
211
+ out = []
212
+ for f in sorted(d.glob("*.py")):
213
+ first = ""
214
+ try:
215
+ text = f.read_text(encoding="utf-8", errors="ignore")
216
+ m = re.search(r'^\s*"""(.+?)$', text, re.M)
217
+ if m:
218
+ first = m.group(1).strip().rstrip('"')
219
+ except OSError:
220
+ pass
221
+ out.append({"file": f"scripts/{f.name}", "purpose": first})
222
+ return out
223
+
224
+
225
+ # A path literal, not a bare filename. Requiring a directory separator is what stops a script that
226
+ # merely MENTIONS "README.md" in a docstring from being reported as that file's generator — the
227
+ # first version of this did exactly that, and a check with obvious false positives gets switched off
228
+ # before it ever catches a real one.
229
+ _OUTPUT_LITERAL = re.compile(r'["\']((?:[\w.-]+/)+[\w.-]+\.(?:html|json|csv|svg|png|md))["\']')
230
+ # Names that are always somebody else's file, never a generated artefact.
231
+ _NEVER_GENERATED = {"README.md", "AGENTS.md", "CLAUDE.md", "package.json", "pyproject.toml"}
232
+ # A generator often builds its output path from parts (`_HERE.parent / ".caddis" / "page.html"`),
233
+ # so the literal in the source is a bare filename. Accept that shape too, but ONLY for artefact
234
+ # extensions and ONLY when the file really sits in the artefact dir — a bare `.md` literal is
235
+ # almost always prose, which is how the README false positive got in.
236
+ _BARE_ARTIFACT = re.compile(r'["\']([\w.-]+\.(?:html|json|csv|svg|png))["\']')
237
+
238
+
239
+ def stale_artifacts(root: Path) -> list[dict]:
240
+ """Generated files whose last commit PREDATES their generator's last commit.
241
+
242
+ Pairing needs no new convention to maintain: a generator names the file it writes, so grepping
243
+ each script for a path literal that actually exists on disk finds the pair. A manifest would be
244
+ more precise and would rot the first time nobody updated it.
245
+
246
+ Uses git commit times, never mtimes — see the module docstring.
247
+ """
248
+ d = root / "scripts"
249
+ if not d.is_dir() or not (root / ".git").exists():
250
+ return []
251
+ out = []
252
+ for script in sorted(d.glob("*.py")):
253
+ try:
254
+ text = script.read_text(encoding="utf-8", errors="ignore")
255
+ except OSError:
256
+ continue
257
+ candidates = set(_OUTPUT_LITERAL.findall(text))
258
+ candidates |= {f"{ART}/{n}" for n in _BARE_ARTIFACT.findall(text)
259
+ if (root / ART / n).is_file()}
260
+ for m in sorted(candidates):
261
+ if Path(m).name in _NEVER_GENERATED:
262
+ continue
263
+ target = root / m
264
+ if not target.is_file():
265
+ continue
266
+ t_art = _run(["git", "log", "-1", "--format=%ct", "--", m], root)
267
+ t_gen = _run(["git", "log", "-1", "--format=%ct", "--", f"scripts/{script.name}"], root)
268
+ if not t_art or not t_gen:
269
+ continue
270
+ if int(t_art) < int(t_gen):
271
+ out.append({"artifact": m, "generator": f"scripts/{script.name}",
272
+ "behind_seconds": int(t_gen) - int(t_art)})
273
+ return out
274
+
275
+
276
+ def collect(root: Path, with_tests: bool = False) -> Inventory:
277
+ inv = Inventory(repo=root.name)
278
+ inv.branch = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], root)
279
+ inv.uncommitted = len([l for l in _run(["git", "status", "--short"], root).splitlines() if l])
280
+ upstream = _run(["git", "rev-parse", "--abbrev-ref", "@{upstream}"], root)
281
+ if upstream:
282
+ inv.unpushed = len(_run(["git", "log", "--oneline", f"{upstream}..HEAD"], root).splitlines())
283
+ inv.artifacts = scan_artifacts(root)
284
+ inv.parking_lot = scan_parking_lot(root)
285
+ inv.kb = scan_kb(root)
286
+ inv.comms_open = scan_comms(root)
287
+ inv.scripts = scan_scripts(root)
288
+ inv.stale_artifacts = stale_artifacts(root)
289
+ inv.environment_map = environment_map_state(root)
290
+ if with_tests:
291
+ # Run it. A handover once claimed 20 tests when there were 19, and the incoming Hub found
292
+ # the discrepancy before anyone else did.
293
+ out = _run([sys.executable, "-m", "pytest", "-q", "--tb=no"], root)
294
+ tail = [l for l in out.splitlines() if "passed" in l or "failed" in l]
295
+ inv.tests = tail[-1] if tail else "suite did not report a summary — investigate"
296
+ return inv
297
+
298
+
299
+ def _table(rows: list[dict], cols: list[tuple[str, str]]) -> list[str]:
300
+ head = "| " + " | ".join(c[0] for c in cols) + " |"
301
+ sep = "|" + "|".join("---" for _ in cols) + "|"
302
+ body = ["| " + " | ".join(str(r.get(c[1], "") or "") for c in cols) + " |" for r in rows]
303
+ return [head, sep, *body, ""]
304
+
305
+
306
+ def render_md(inv: Inventory) -> str:
307
+ L: list[str] = [
308
+ f"# Repository inventory — {inv.repo}", "",
309
+ "> Generated by `scripts/caddis_inventory.py`. **Do not hand-edit.** Everything here is a",
310
+ "> directory listing, not a judgement — regenerate it rather than correcting it. The",
311
+ "> judgement half of a handover (what matters, what nearly went wrong, what to do next) is",
312
+ "> written by a human or an agent and belongs above this section, not inside it.", "",
313
+ f"**Branch** `{inv.branch}` · **uncommitted** {inv.uncommitted} file(s) · "
314
+ f"**unpushed** {inv.unpushed} commit(s)", "",
315
+ f"**Tests:** {inv.tests}", "",
316
+ ]
317
+ if inv.environment_map != "filled":
318
+ why = ("does not exist" if inv.environment_map == "missing"
319
+ else "exists but has never been filled in")
320
+ L += [f"> **Environment map {why}.** Hosts, which login actually works, where other",
321
+ "> repositories really live — none of it is written down, so the next session starts",
322
+ "> from zero and asks you again. Write what you know to",
323
+ f"> `{ART}/kb/environment-map.md` before handing over.", ""]
324
+ if inv.stale_artifacts:
325
+ L += ["## ⚠ Generated artefacts older than their generator", "",
326
+ "Each of these was committed BEFORE the script that builds it last changed, so it may",
327
+ "describe a state that no longer exists. Rebuild before quoting any of them.", ""]
328
+ L += _table(inv.stale_artifacts, [("Artefact", "artifact"), ("Built by", "generator")])
329
+ for kind, rows in inv.artifacts.items():
330
+ if not rows:
331
+ continue
332
+ L += [f"## {kind}", ""] + _table(rows, [("File", "file"), ("Title", "title"),
333
+ ("Status", "status")])
334
+ if inv.parking_lot:
335
+ L += ["## Parking lot — the backlog", "",
336
+ "Committed work (`future: yes`) first, then by severity.", ""]
337
+ L += _table(inv.parking_lot, [("Item", "file"), ("Severity", "severity"),
338
+ ("Future", "future"), ("Status", "status")])
339
+ if inv.kb:
340
+ L += ["## KB notes", ""] + _table(inv.kb, [("File", "file"), ("Title", "title")])
341
+ if inv.comms_open:
342
+ L += ["## Outbound comms still open", "",
343
+ "Anything not yet ACTIONED or DROPPED. An unanswered ask looks exactly like a",
344
+ "forgotten one unless something tracks it.", ""]
345
+ L += _table(inv.comms_open, [("Raised", "raised"), ("Audience", "audience"),
346
+ ("Subject", "subject"), ("Status", "status")])
347
+ if inv.scripts:
348
+ L += ["## Scripts", ""] + _table(inv.scripts, [("Script", "file"), ("Purpose", "purpose")])
349
+ return "\n".join(L) + "\n"
350
+
351
+
352
+ def main(argv: list[str] | None = None) -> int:
353
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
354
+ ap.add_argument("--repo-root", default=".")
355
+ ap.add_argument("--format", choices=("md", "json"), default="md")
356
+ ap.add_argument("--with-tests", action="store_true",
357
+ help="RUN the suite and report its real result instead of a placeholder")
358
+ a = ap.parse_args(argv)
359
+ root = Path(a.repo_root).resolve()
360
+ inv = collect(root, with_tests=a.with_tests)
361
+ if a.format == "json":
362
+ print(json.dumps(inv.__dict__, indent=2))
363
+ else:
364
+ print(render_md(inv), end="")
365
+ return 0
366
+
367
+
368
+ if __name__ == "__main__":
369
+ raise SystemExit(main())
@@ -0,0 +1,270 @@
1
+ """caddis_migrate_dir — rename a repo's legacy `.claudster/` artifact dir to `.caddis/`.
2
+
3
+ The `.claudster` → `.caddis` rename is OPT-IN, per repo, and never automatic: `git mv`-ing a
4
+ directory under a concurrent session would move files out from under its in-flight writes (a Stop
5
+ hook appending `usage-log.jsonl`, an agent writing a plan). So the SessionStart hook only *nudges*;
6
+ this command does the work when you say so.
7
+
8
+ Everything keeps working either way — every caddis reader tries `.caddis` then `.claudster`, and
9
+ every writer writes where the repo already lives. Migration is about consistency, not function.
10
+
11
+ Behavior (per target repo):
12
+ • SIMPLE CASE — only `.claudster/` exists: one `git mv .claudster .caddis` (history preserved).
13
+ • BOTH EXIST (the straggler case — an out-of-date session or teammate recreated `.claudster/`
14
+ after a migration): per-CHILD merge into `.caddis/`. A child that exists only in the legacy dir
15
+ is moved. A child that exists in BOTH is merged by kind:
16
+ - `.jsonl` append-logs (usage-log / agent-log / memory) → CONCATENATED, legacy lines FIRST
17
+ (they are older), so no session's history is dropped;
18
+ - directories → recursed, same rules;
19
+ - anything else → left in place and REPORTED (a human decides; we never clobber content).
20
+ An emptied `.claudster/` is removed at the end; one with unresolved conflicts is kept.
21
+ • LIVE REFS are rewritten so the migrated repo's own artifacts don't point at the old dir:
22
+ `.caddis/workstreams.json` (parked-workstream plan paths) and `relay.md`.
23
+ • Idempotent: a re-run on a migrated repo is a no-op. `--dry-run` is the DEFAULT (`--apply`
24
+ executes). Refuses a dirty tree unless `--allow-dirty` — the git mv must be reviewable.
25
+
26
+ Usage:
27
+ python scripts/caddis_migrate_dir.py [target] # dry-run (default)
28
+ python scripts/caddis_migrate_dir.py [target] --apply # execute
29
+ python scripts/caddis_migrate_dir.py [target] --check # exit 1 if a legacy dir remains (gate)
30
+
31
+ Exit codes: 0 ok / nothing to do · 1 unresolved conflicts (or --check found a legacy dir) · 2 bad
32
+ target / not a git repo / dirty tree.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import json
38
+ import os
39
+ import shutil
40
+ import subprocess
41
+ import sys
42
+ from pathlib import Path
43
+
44
+ NEW_DIR = ".caddis"
45
+ OLD_DIR = ".claudster"
46
+
47
+ #: Append-only JSONL logs: safe to concatenate when both dirs carry one (legacy lines first).
48
+ JSONL_MERGE_NAMES = frozenset({"usage-log.jsonl", "agent-log.jsonl", "memory.jsonl"})
49
+
50
+ #: Files whose CONTENT may name the old dir and is read live after the migration. Historical
51
+ #: artifacts (plans, handoffs, past reviews) are deliberately NOT rewritten — they are a record of
52
+ #: what was true then, and rewriting them would churn the diff for no functional gain.
53
+ LIVE_REF_FILES = ("workstreams.json", "relay.md")
54
+
55
+
56
+ # ── git helpers (mirrors claudster_migrate_rules.py) ─────────────────────────
57
+ def _git(target: Path, *args: str) -> subprocess.CompletedProcess:
58
+ return subprocess.run(["git", *args], cwd=str(target), capture_output=True, text=True)
59
+
60
+
61
+ def _in_git_repo(target: Path) -> bool:
62
+ r = _git(target, "rev-parse", "--is-inside-work-tree")
63
+ return r.returncode == 0 and r.stdout.strip() == "true"
64
+
65
+
66
+ def _is_dirty(target: Path) -> bool:
67
+ return bool(_git(target, "status", "--porcelain").stdout.strip())
68
+
69
+
70
+ def _is_tracked(target: Path, rel: str) -> bool:
71
+ return _git(target, "ls-files", "--error-unmatch", "--", rel).returncode == 0
72
+
73
+
74
+ def _move(target: Path, src_rel: str, dst_rel: str, apply: bool, report: list[str]) -> None:
75
+ """Move one path, preferring `git mv` so history follows the file."""
76
+ report.append(f"move: {src_rel} -> {dst_rel}")
77
+ if not apply:
78
+ return
79
+ dst = target / dst_rel
80
+ dst.parent.mkdir(parents=True, exist_ok=True)
81
+ if _is_tracked(target, src_rel) and _git(target, "mv", src_rel, dst_rel).returncode == 0:
82
+ return
83
+ # untracked (or git mv refused): a plain move keeps the content; git sees add+delete
84
+ shutil.move(str(target / src_rel), str(dst))
85
+
86
+
87
+ # ── merge (the both-exist case) ──────────────────────────────────────────────
88
+ def _concat_jsonl(old: Path, new: Path, apply: bool) -> int:
89
+ """Prepend `old`'s lines to `new` (legacy is older). Returns the number of lines merged.
90
+
91
+ Blank lines are dropped and a trailing newline is guaranteed, so a half-written last line in
92
+ either file can't glue two records together.
93
+ """
94
+ old_lines = [ln for ln in old.read_text(encoding="utf-8", errors="replace").splitlines() if ln.strip()]
95
+ if not apply:
96
+ return len(old_lines)
97
+ new_lines = [ln for ln in new.read_text(encoding="utf-8", errors="replace").splitlines() if ln.strip()]
98
+ new.write_text("\n".join(old_lines + new_lines) + "\n", encoding="utf-8")
99
+ return len(old_lines)
100
+
101
+
102
+ def _merge_tree(target: Path, old_rel: str, new_rel: str, apply: bool,
103
+ report: list[str], conflicts: list[str]) -> None:
104
+ """Merge `<old_rel>/` into `<new_rel>/`, child by child. Both are repo-relative posix paths."""
105
+ old_dir = target / old_rel
106
+ for child in sorted(old_dir.iterdir()):
107
+ c_old = f"{old_rel}/{child.name}"
108
+ c_new = f"{new_rel}/{child.name}"
109
+ dest = target / c_new
110
+ if not dest.exists():
111
+ _move(target, c_old, c_new, apply, report)
112
+ continue
113
+ if child.is_dir() and dest.is_dir():
114
+ _merge_tree(target, c_old, c_new, apply, report, conflicts)
115
+ continue
116
+ if child.is_file() and dest.is_file() and child.name in JSONL_MERGE_NAMES:
117
+ n = _concat_jsonl(child, dest, apply)
118
+ report.append(f"merge: {c_old} -> {c_new} ({n} legacy line(s), oldest first)")
119
+ if apply:
120
+ _remove(target, c_old)
121
+ continue
122
+ conflicts.append(c_old)
123
+ report.append(f"CONFLICT: {c_old} and {c_new} both exist and cannot be merged — left in place")
124
+
125
+
126
+ def _remove(target: Path, rel: str) -> None:
127
+ """Delete a merged-away file, via `git rm` when tracked so the index stays consistent."""
128
+ if _is_tracked(target, rel) and _git(target, "rm", "-q", "-f", "--", rel).returncode == 0:
129
+ return
130
+ try:
131
+ (target / rel).unlink()
132
+ except OSError:
133
+ pass
134
+
135
+
136
+ def _prune_empty(target: Path, rel: str) -> bool:
137
+ """Remove `<rel>` if it is now empty (depth-first). True when it is gone."""
138
+ d = target / rel
139
+ if not d.is_dir():
140
+ return True
141
+ for child in sorted(d.iterdir()):
142
+ if child.is_dir():
143
+ _prune_empty(target, f"{rel}/{child.name}")
144
+ try:
145
+ d.rmdir()
146
+ return True
147
+ except OSError:
148
+ return False
149
+
150
+
151
+ # ── live-reference rewriting ─────────────────────────────────────────────────
152
+ def rewrite_refs_text(text: str) -> str:
153
+ """Replace `.claudster/` path prefixes with `.caddis/` in a live artifact's text. Pure.
154
+
155
+ Only the dir followed by a separator is rewritten, so prose about "the .claudster rename"
156
+ survives intact while `.claudster/plans/foo.md` becomes `.caddis/plans/foo.md`. Backslash
157
+ separators are handled too — a Windows-written workstreams.json can carry either.
158
+ """
159
+ return (text.replace(f"{OLD_DIR}/", f"{NEW_DIR}/")
160
+ .replace(f"{OLD_DIR}\\", f"{NEW_DIR}\\"))
161
+
162
+
163
+ def rewrite_live_refs(target: Path, apply: bool, report: list[str]) -> None:
164
+ """Rewrite `.claudster/...` paths inside the migrated dir's live state files."""
165
+ for name in LIVE_REF_FILES:
166
+ p = target / NEW_DIR / name
167
+ if not p.is_file():
168
+ continue
169
+ original = p.read_text(encoding="utf-8", errors="replace")
170
+ updated = rewrite_refs_text(original)
171
+ if updated == original:
172
+ continue
173
+ n = original.count(f"{OLD_DIR}/")
174
+ report.append(f"rewrite: {NEW_DIR}/{name} ({n} legacy path ref(s) -> {NEW_DIR}/)")
175
+ if apply:
176
+ p.write_text(updated, encoding="utf-8")
177
+ # workstreams.json must stay valid JSON — verify, and roll back the rewrite if it doesn't.
178
+ ws = target / NEW_DIR / "workstreams.json"
179
+ if apply and ws.is_file():
180
+ try:
181
+ json.loads(ws.read_text(encoding="utf-8"))
182
+ except Exception:
183
+ report.append(f"WARNING: {NEW_DIR}/workstreams.json is not valid JSON after rewriting "
184
+ "— check it by hand")
185
+
186
+
187
+ # ── the migration ────────────────────────────────────────────────────────────
188
+ def migrate(target: Path, apply: bool) -> tuple[int, list[str]]:
189
+ """Migrate `target`. Returns `(exit_code, report_lines)`."""
190
+ report: list[str] = []
191
+ conflicts: list[str] = []
192
+ old, new = target / OLD_DIR, target / NEW_DIR
193
+
194
+ if not old.is_dir():
195
+ report.append(f"nothing to do: no {OLD_DIR}/ in this repo"
196
+ + (f" ({NEW_DIR}/ already present)" if new.is_dir() else ""))
197
+ return 0, report
198
+
199
+ if not new.exists():
200
+ _move(target, OLD_DIR, NEW_DIR, apply, report)
201
+ else:
202
+ report.append(f"both {OLD_DIR}/ and {NEW_DIR}/ exist — merging per child "
203
+ "(a straggler dir recreated by an out-of-date session)")
204
+ _merge_tree(target, OLD_DIR, NEW_DIR, apply, report, conflicts)
205
+ if apply:
206
+ if _prune_empty(target, OLD_DIR):
207
+ report.append(f"removed: empty {OLD_DIR}/")
208
+ else:
209
+ report.append(f"kept: {OLD_DIR}/ still holds {len(conflicts)} unmerged path(s)")
210
+
211
+ rewrite_live_refs(target, apply, report)
212
+
213
+ if conflicts:
214
+ report.append(f"\n{len(conflicts)} conflict(s) need a human: " + ", ".join(conflicts))
215
+ return 1, report
216
+ return 0, report
217
+
218
+
219
+ def main(argv: list[str] | None = None) -> int:
220
+ for _stream in (sys.stdout, sys.stderr): # Windows cp1252 can't encode the em-dashes below
221
+ if hasattr(_stream, "reconfigure"):
222
+ _stream.reconfigure(encoding="utf-8")
223
+ ap = argparse.ArgumentParser(
224
+ description=f"Migrate a repo's legacy {OLD_DIR}/ artifact dir to {NEW_DIR}/.")
225
+ ap.add_argument("target", nargs="?", default=".", type=Path)
226
+ ap.add_argument("--apply", action="store_true", help="Execute (default is --dry-run).")
227
+ ap.add_argument("--allow-dirty", action="store_true", help="Proceed even if the tree is dirty.")
228
+ ap.add_argument("--check", action="store_true",
229
+ help=f"Report only; exit 1 if {OLD_DIR}/ is still present (gate mode).")
230
+ args = ap.parse_args(argv)
231
+
232
+ target = args.target.resolve()
233
+ if not target.is_dir():
234
+ print(f"ERROR: target not a directory: {target}", file=sys.stderr)
235
+ return 2
236
+
237
+ if args.check:
238
+ if (target / OLD_DIR).is_dir():
239
+ print(f" legacy {OLD_DIR}/ present — run /caddis:migrate-dir to convert it.")
240
+ return 1
241
+ print(f"No legacy {OLD_DIR}/ — this repo is on {NEW_DIR}/.")
242
+ return 0
243
+
244
+ apply = args.apply
245
+ if not _in_git_repo(target):
246
+ print(f"ERROR: {target} is not a git repository (the migration uses git mv to keep history).",
247
+ file=sys.stderr)
248
+ return 2
249
+ # The dirty-tree refusal only applies when there is actually a rename to make — a re-run on an
250
+ # already-migrated repo is a no-op and must not fail just because the tree has unrelated edits.
251
+ if apply and (target / OLD_DIR).is_dir() and _is_dirty(target) and not args.allow_dirty:
252
+ print("ERROR: working tree is dirty — commit/stash first, or pass --allow-dirty.\n"
253
+ " (A rename mixed into unrelated edits is unreviewable, and this repo's tree "
254
+ "may be shared with a live session.)", file=sys.stderr)
255
+ return 2
256
+
257
+ print(f"=== caddis-migrate-dir -> {target} {'(APPLY)' if apply else '(dry-run)'}")
258
+ rc, report = migrate(target, apply)
259
+ for line in report:
260
+ print(f" {line}")
261
+ if not apply:
262
+ print("\n(dry-run — no files changed; pass --apply to execute)")
263
+ elif rc == 0:
264
+ print("\nMigrated. Review with `git status` / `git diff --staged`, then commit.")
265
+ print(f"\nresult: {'CONFLICTS' if rc else 'ok'}")
266
+ return rc
267
+
268
+
269
+ if __name__ == "__main__":
270
+ raise SystemExit(main())