@caddis/cli 0.4.65 → 0.4.67
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/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_created.py +191 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +11 -3
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +4 -0
- package/bundles/antigravity-plugin/scripts/caddis_own_cleanup.py +424 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +205 -70
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +3 -0
- package/bundles/antigravity-plugin/scripts/caddis_worktree_root.py +26 -3
- package/bundles/antigravity-plugin/scripts/roadmap_assets/LICENSE-Lucide.txt +43 -0
- package/bundles/antigravity-plugin/scripts/roadmap_assets/{OFL-JetBrainsMono.txt → OFL-Atkinson.txt} +2 -2
- package/bundles/antigravity-plugin/scripts/roadmap_assets/fonts.css +3 -3
- package/bundles/antigravity-plugin/scripts/roadmap_assets/icons.svg +2 -0
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +11 -5
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +58 -28
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +3 -3
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +7 -1
- package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +7 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +3 -3
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""What a session CREATED and must clean up: worktrees and scratch folders, recorded per session.
|
|
2
|
+
|
|
3
|
+
Why a file of its own. The Stop hook rewrites `.caddis/session-state/<id>.md` every turn, so a record kept there is
|
|
4
|
+
lost. A session also often makes worktrees in OTHER repos. So the record lives outside every repo:
|
|
5
|
+
|
|
6
|
+
~/.caddis/sessions/<session-id>.created.jsonl (append-only; one JSON object per line)
|
|
7
|
+
|
|
8
|
+
(`~` is `$CADDIS_HOME` when set, else the user's home folder; the `home` argument beats both. The folder also holds
|
|
9
|
+
other code's `<session-id>.json` files; this module never touches those.)
|
|
10
|
+
|
|
11
|
+
A line is one fact about one path:
|
|
12
|
+
|
|
13
|
+
{"path", "kind": "worktree"|"scratch", "created": <ISO UTC>, "purpose", "pr": str|null, "repo": str|null,
|
|
14
|
+
"branch": str|null, "status": "created"|"removed", "by": "helper"|"lanes"|"roadmap-run"|"docs-pr"|
|
|
15
|
+
"transcript"|"cli"}
|
|
16
|
+
|
|
17
|
+
`read` FOLDS the lines by normalised path (case-insensitive on Windows): `created` keeps the first value and every
|
|
18
|
+
other field takes the latest non-null value. So "record again with the PR number" and "mark removed" are just new
|
|
19
|
+
lines. One exception, which is a safety rule: a `created` line for a path whose last status was `removed` starts a
|
|
20
|
+
NEW entry. A path that is used again (a roadmap worktree has a fixed name) must not inherit the old PR.
|
|
21
|
+
|
|
22
|
+
This module only RECORDS. It deletes nothing; `cleanup` hands off to `caddis_own_cleanup.py`, which does.
|
|
23
|
+
|
|
24
|
+
session_id(explicit=None) -> a safe session id, or None
|
|
25
|
+
record(path, kind, purpose, *, pr, repo, branch, status, by, session_id, home) -> bool (never raises)
|
|
26
|
+
read(session_id, home=None) -> list[dict] (folded; oldest first; bad lines skipped)
|
|
27
|
+
|
|
28
|
+
CLI (exit 0 on success; `record` with no session id warns on stderr and still exits 0, so a caller never breaks;
|
|
29
|
+
exit 2 on bad arguments):
|
|
30
|
+
python caddis_created.py record --path P --kind worktree --purpose "why" [--pr N] [--repo R] [--branch B]
|
|
31
|
+
[--status removed] [--by cli] [--session-id S]
|
|
32
|
+
python caddis_created.py list [--session-id S] [--json]
|
|
33
|
+
python caddis_created.py cleanup [--session-id S] [--apply] [--json] (dry run by default; always exit 0)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import argparse
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import sys
|
|
43
|
+
from datetime import datetime, timezone
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
SESSION_ENV_VARS = ("CADDIS_SESSION_ID", "CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID") # as caddis_adhoc.session_key
|
|
47
|
+
KINDS = ("worktree", "scratch")
|
|
48
|
+
STATUSES = ("created", "removed")
|
|
49
|
+
BYS = ("helper", "lanes", "roadmap-run", "docs-pr", "transcript", "cli")
|
|
50
|
+
_SAFE_ID = re.compile(r"[A-Za-z0-9_-]{1,100}") # no separator, drive colon or dots: a plain file-name stem
|
|
51
|
+
_FIELDS = ("purpose", "pr", "repo", "branch", "by")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sid(explicit: str | None = None) -> str | None:
|
|
55
|
+
"""`explicit` (even "", which gives None), else the first non-blank session env var. None when there is none or not a safe file
|
|
56
|
+
name (a bad explicit id does NOT fall back to the environment: that would record under the wrong session)."""
|
|
57
|
+
candidates = [explicit] if explicit is not None else [os.environ.get(name) for name in SESSION_ENV_VARS]
|
|
58
|
+
for raw in candidates:
|
|
59
|
+
value = (raw or "").strip()
|
|
60
|
+
if value:
|
|
61
|
+
return value if _SAFE_ID.fullmatch(value) else None
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def session_id(explicit: str | None = None) -> str | None:
|
|
66
|
+
"""The session id to record under: see `_sid`."""
|
|
67
|
+
return _sid(explicit)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _file(sid: str, home: Path | str | None) -> Path:
|
|
71
|
+
root = Path(home) if home else Path(os.environ.get("CADDIS_HOME") or Path.home())
|
|
72
|
+
return root / ".caddis" / "sessions" / f"{sid}.created.jsonl"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _normalise(path: str | os.PathLike) -> str:
|
|
76
|
+
"""Absolute, `..` collapsed. Not resolved: resolving would follow a link, and the record names the path as made."""
|
|
77
|
+
return os.path.abspath(os.fspath(path))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _key(path: str) -> str:
|
|
81
|
+
return os.path.normcase(path) # lower-case and backslashes on Windows; unchanged elsewhere
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def record(path: str | os.PathLike, kind: str, purpose: str = "", *, pr: str | int | None = None,
|
|
85
|
+
repo: str | os.PathLike | None = None, branch: str | None = None, status: str = "created",
|
|
86
|
+
by: str = "cli", session_id: str | None = None, home: Path | str | None = None) -> bool:
|
|
87
|
+
"""Append one line. False, and nothing written, with no usable session id, an unknown kind or status, or any I/O
|
|
88
|
+
error. Never raises: callers are hooks and runners."""
|
|
89
|
+
try:
|
|
90
|
+
sid = _sid(session_id)
|
|
91
|
+
if not sid or kind not in KINDS or status not in STATUSES or not str(path).strip():
|
|
92
|
+
return False
|
|
93
|
+
line = {
|
|
94
|
+
"path": _normalise(path), "kind": kind,
|
|
95
|
+
"created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
96
|
+
"purpose": str(purpose or ""), "pr": None if pr is None or str(pr) == "" else str(pr),
|
|
97
|
+
"repo": _normalise(repo) if repo else None, "branch": branch or None, "status": status, "by": by,
|
|
98
|
+
}
|
|
99
|
+
target = _file(sid, home)
|
|
100
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
with open(target, "a", encoding="utf-8", newline="\n") as handle:
|
|
102
|
+
handle.write(json.dumps(line, ensure_ascii=False) + "\n") # one write call: appends do not interleave
|
|
103
|
+
return True
|
|
104
|
+
except Exception: # noqa: BLE001 - a recording failure must never break the caller
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def read(session_id: str, home: Path | str | None = None) -> list[dict]:
|
|
109
|
+
"""The session's entries, folded by path, oldest `created` first. [] when there is no file; bad lines are skipped."""
|
|
110
|
+
try:
|
|
111
|
+
sid = session_id if _SAFE_ID.fullmatch(session_id or "") else None # strict: no env fallback when reading
|
|
112
|
+
if not sid:
|
|
113
|
+
return []
|
|
114
|
+
text = _file(sid, home).read_text(encoding="utf-8", errors="replace")
|
|
115
|
+
except Exception: # noqa: BLE001
|
|
116
|
+
return []
|
|
117
|
+
folded: dict[str, dict] = {}
|
|
118
|
+
for raw in text.splitlines():
|
|
119
|
+
try:
|
|
120
|
+
line = json.loads(raw)
|
|
121
|
+
except ValueError:
|
|
122
|
+
continue
|
|
123
|
+
if not (isinstance(line, dict) and isinstance(line.get("path"), str) and line["path"]
|
|
124
|
+
and line.get("kind") in KINDS and line.get("status") in STATUSES):
|
|
125
|
+
continue
|
|
126
|
+
key = _key(line["path"])
|
|
127
|
+
entry = folded.get(key)
|
|
128
|
+
if entry is None or (entry["status"] == "removed" and line["status"] == "created"):
|
|
129
|
+
folded[key] = {"path": line["path"], "kind": line["kind"], "created": str(line.get("created") or ""),
|
|
130
|
+
"status": line["status"], **{name: line.get(name) for name in _FIELDS}}
|
|
131
|
+
continue
|
|
132
|
+
entry["status"] = line["status"]
|
|
133
|
+
entry["kind"] = line["kind"]
|
|
134
|
+
for name in _FIELDS:
|
|
135
|
+
if line.get(name) is not None and not (name == "purpose" and line[name] == ""):
|
|
136
|
+
entry[name] = line[name]
|
|
137
|
+
return sorted(folded.values(), key=lambda e: e["created"]) # stable: equal stamps keep file order
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _summary(entry: dict) -> str:
|
|
141
|
+
text = f"{entry['path']} | {entry['kind']} | {entry['status']} | {entry.get('purpose') or ''}"
|
|
142
|
+
return text + (f" | PR {entry['pr']}" if entry.get("pr") else "")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main(argv: list[str] | None = None) -> int:
|
|
146
|
+
ap = argparse.ArgumentParser(description="Record or list what this session created (worktrees, scratch folders).")
|
|
147
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
148
|
+
rec = sub.add_parser("record", help="append one line for a path")
|
|
149
|
+
rec.add_argument("--path", required=True)
|
|
150
|
+
rec.add_argument("--kind", required=True, choices=KINDS)
|
|
151
|
+
rec.add_argument("--purpose", default="")
|
|
152
|
+
rec.add_argument("--pr", default=None)
|
|
153
|
+
rec.add_argument("--repo", default=None)
|
|
154
|
+
rec.add_argument("--branch", default=None)
|
|
155
|
+
rec.add_argument("--status", default="created", choices=STATUSES)
|
|
156
|
+
rec.add_argument("--by", default="cli", choices=BYS)
|
|
157
|
+
rec.add_argument("--session-id", default=None)
|
|
158
|
+
lst = sub.add_parser("list", help="print the folded entries")
|
|
159
|
+
lst.add_argument("--session-id", default=None)
|
|
160
|
+
lst.add_argument("--json", action="store_true")
|
|
161
|
+
cln = sub.add_parser("cleanup", help="remove this session's merged, clean worktrees and scratch folders")
|
|
162
|
+
cln.add_argument("--session-id", default=None)
|
|
163
|
+
cln.add_argument("--apply", action="store_true", help="act; without it, only list (the default)")
|
|
164
|
+
cln.add_argument("--json", action="store_true")
|
|
165
|
+
try:
|
|
166
|
+
args = ap.parse_args(argv)
|
|
167
|
+
except SystemExit as exc:
|
|
168
|
+
return int(exc.code) if isinstance(exc.code, int) else 2
|
|
169
|
+
sid = session_id(args.session_id)
|
|
170
|
+
if args.cmd == "record":
|
|
171
|
+
if not record(args.path, args.kind, args.purpose, pr=args.pr, repo=args.repo, branch=args.branch,
|
|
172
|
+
status=args.status, by=args.by, session_id=args.session_id):
|
|
173
|
+
why = "no usable session id" if not sid else "the record could not be written"
|
|
174
|
+
print(f"caddis_created: not recorded ({why}): {args.path}", file=sys.stderr)
|
|
175
|
+
return 0
|
|
176
|
+
if args.cmd == "cleanup":
|
|
177
|
+
import caddis_own_cleanup # it deletes, so it lives apart from this record-only module (and imports it)
|
|
178
|
+
return caddis_own_cleanup.run(sid, args.apply, args.json)
|
|
179
|
+
if not sid:
|
|
180
|
+
print("caddis_created: no usable session id; nothing to list", file=sys.stderr)
|
|
181
|
+
entries = read(sid) if sid else []
|
|
182
|
+
if args.json:
|
|
183
|
+
print(json.dumps(entries, indent=2, ensure_ascii=False))
|
|
184
|
+
else:
|
|
185
|
+
for entry in entries:
|
|
186
|
+
print(_summary(entry))
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
sys.exit(main())
|
|
@@ -37,15 +37,18 @@ import tempfile
|
|
|
37
37
|
from pathlib import Path
|
|
38
38
|
|
|
39
39
|
try:
|
|
40
|
+
import caddis_created
|
|
40
41
|
import caddis_exit
|
|
41
42
|
import caddis_checkout_guard
|
|
42
43
|
import caddis_roadmap as roadmap
|
|
43
44
|
import caddis_worktree_safety
|
|
44
45
|
except ModuleNotFoundError as exc:
|
|
45
|
-
if exc.name not in {"caddis_exit", "caddis_checkout_guard", "caddis_roadmap",
|
|
46
|
+
if exc.name not in {"caddis_created", "caddis_exit", "caddis_checkout_guard", "caddis_roadmap",
|
|
47
|
+
"caddis_worktree_safety"}:
|
|
46
48
|
raise
|
|
47
49
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
48
50
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
51
|
+
import caddis_created
|
|
49
52
|
import caddis_exit
|
|
50
53
|
import caddis_checkout_guard
|
|
51
54
|
import caddis_roadmap as roadmap
|
|
@@ -87,6 +90,7 @@ def start(repo: Path, out=print) -> int:
|
|
|
87
90
|
worktree = tmp_root / f"{DIR_PREFIX}{stamp}{suffix}"
|
|
88
91
|
branch = f"{BRANCH_PREFIX}{stamp}{suffix}"
|
|
89
92
|
_git(repo, "worktree", "add", "--quiet", "-b", branch, str(worktree), f"origin/{base}")
|
|
93
|
+
caddis_created.record(worktree, "worktree", "handoff docs PR", repo=repo, branch=branch, by="docs-pr")
|
|
90
94
|
out(f"WORKTREE: {worktree}")
|
|
91
95
|
return caddis_exit.CLEAN
|
|
92
96
|
|
|
@@ -199,7 +203,7 @@ def _save_patch(worktree: Path) -> Path:
|
|
|
199
203
|
return Path(name)
|
|
200
204
|
|
|
201
205
|
|
|
202
|
-
def _remove(main: Path, worktree: Path, branch: str) -> None:
|
|
206
|
+
def _remove(main: Path, worktree: Path, branch: str, pr: str | None = None) -> None:
|
|
203
207
|
"""Remove the worktree (links first, so a junction's target survives), prune, delete the branch.
|
|
204
208
|
|
|
205
209
|
Best effort, as before: the result of each step is not checked. Every caller has already saved the session's
|
|
@@ -215,6 +219,9 @@ def _remove(main: Path, worktree: Path, branch: str) -> None:
|
|
|
215
219
|
pass
|
|
216
220
|
if worktree.exists():
|
|
217
221
|
caddis_worktree_safety.remove_tree(worktree)
|
|
222
|
+
if not worktree.exists(): # only a folder that is really gone is recorded as removed
|
|
223
|
+
caddis_created.record(worktree, "worktree", "", pr=pr, repo=main, branch=branch, status="removed",
|
|
224
|
+
by="docs-pr")
|
|
218
225
|
|
|
219
226
|
|
|
220
227
|
def finish(worktree: Path, title: str, body: str, out=print) -> int:
|
|
@@ -257,7 +264,8 @@ def finish(worktree: Path, title: str, body: str, out=print) -> int:
|
|
|
257
264
|
out(f"PR: {url}")
|
|
258
265
|
for path in files:
|
|
259
266
|
out(f"FILE: {path}")
|
|
260
|
-
|
|
267
|
+
pr_number = re.search(r"/pull/(\d+)\s*$", url)
|
|
268
|
+
_remove(main, worktree, branch, pr=pr_number.group(1) if pr_number else None)
|
|
261
269
|
try:
|
|
262
270
|
patch.unlink()
|
|
263
271
|
except OSError:
|
|
@@ -34,6 +34,7 @@ except ModuleNotFoundError as exc:
|
|
|
34
34
|
from caddis_heavy_slot import HeavySlotBusy, heavy_slot
|
|
35
35
|
|
|
36
36
|
import caddis_checkout_guard
|
|
37
|
+
import caddis_created
|
|
37
38
|
import caddis_routing
|
|
38
39
|
import caddis_redact
|
|
39
40
|
import caddis_spend
|
|
@@ -284,6 +285,8 @@ def create_phase_worktree(
|
|
|
284
285
|
str(path),
|
|
285
286
|
base_sha,
|
|
286
287
|
)
|
|
288
|
+
caddis_created.record(path, "worktree", f"lane {plan_stem} phase {phase:02d} attempt {attempt}", repo=repo_root,
|
|
289
|
+
branch=branch, by="lanes")
|
|
287
290
|
print(f"[caddis-lanes] worktree: {path}", file=sys.stderr)
|
|
288
291
|
return Worktree(path=path, branch=branch, base_sha=base_sha)
|
|
289
292
|
|
|
@@ -307,6 +310,7 @@ def remove_phase_worktree(
|
|
|
307
310
|
problem = f"worktree remove left {resolved} on disk"
|
|
308
311
|
if problem:
|
|
309
312
|
raise LaneConfigError(problem)
|
|
313
|
+
caddis_created.record(resolved, "worktree", "", repo=repo_root, branch=wt.branch, status="removed", by="lanes")
|
|
310
314
|
if delete_branch:
|
|
311
315
|
_git_output(repo_root, "branch", "-D", "--", wt.branch)
|
|
312
316
|
|