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