@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.
Files changed (25) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/scripts/caddis_created.py +191 -0
  3. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +11 -3
  4. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +4 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_own_cleanup.py +424 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +205 -70
  7. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +3 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_worktree_root.py +26 -3
  9. package/bundles/antigravity-plugin/scripts/roadmap_assets/LICENSE-Lucide.txt +43 -0
  10. package/bundles/antigravity-plugin/scripts/roadmap_assets/{OFL-JetBrainsMono.txt → OFL-Atkinson.txt} +2 -2
  11. package/bundles/antigravity-plugin/scripts/roadmap_assets/fonts.css +3 -3
  12. package/bundles/antigravity-plugin/scripts/roadmap_assets/icons.svg +2 -0
  13. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +11 -5
  14. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +4 -3
  15. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
  16. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +58 -28
  17. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +4 -3
  18. package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +3 -3
  19. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +4 -3
  20. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +4 -3
  21. package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +7 -1
  22. package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +7 -1
  23. package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +3 -3
  24. package/bundles/manifest.json +2 -2
  25. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.151",
3
+ "version": "1.3.153",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -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", "caddis_worktree_safety"}:
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
- _remove(main, worktree, branch)
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