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