@caddis/cli 0.4.66 → 0.4.68

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.152",
3
+ "version": "1.3.154",
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:
@@ -11,6 +11,7 @@ import os
11
11
  import re
12
12
  import subprocess
13
13
  import sys
14
+ import time
14
15
  from dataclasses import dataclass
15
16
  from datetime import datetime
16
17
  from pathlib import Path
@@ -332,6 +333,78 @@ def whoami(session_id: str, name: str, root: Path) -> Path:
332
333
  return out
333
334
 
334
335
 
336
+ # ---- live peers -------------------------------------------------------------------------------
337
+
338
+ # The same rule as `inject_relay._live_peers` (the SessionStart hook cannot be imported: it runs at import).
339
+ # test_peers_apply_the_same_liveness_rule_as_the_session_start_hook runs the hook's own source to keep them equal.
340
+ PEER_FRESH_S = 1800
341
+
342
+
343
+ def _slug(session_id: str) -> str:
344
+ return "".join(c for c in session_id if c.isalnum() or c in "-_")[:64]
345
+
346
+
347
+ def _worktree_roots(root: Path) -> list[Path]:
348
+ """`root` plus every worktree of its repo: a session in a linked worktree writes its state there, not in the main
349
+ checkout, so a repo-wide scan reads each one. Not a git repo (or no git): just `root`."""
350
+ roots = [root]
351
+ proc = subprocess.run(["git", "-C", str(root), "worktree", "list", "--porcelain"], capture_output=True,
352
+ text=True, encoding="utf-8", errors="replace")
353
+ if proc.returncode == 0:
354
+ for line in proc.stdout.splitlines():
355
+ if line.startswith("worktree "):
356
+ path = Path(line[len("worktree "):].strip())
357
+ if path not in roots and path.resolve() != root:
358
+ roots.append(path)
359
+ return roots
360
+
361
+
362
+ def _peer_name(base: Path, sid: str) -> str:
363
+ try:
364
+ return (base / f"{sid}.name").read_text(encoding="utf-8").split("\n", 1)[0].split("\t")[0].strip() or "-"
365
+ except (OSError, UnicodeDecodeError):
366
+ return "-"
367
+
368
+
369
+ def live_peers(session_id: str, root: Path, now: float | None = None) -> list[tuple[str, str, int]]:
370
+ """[(name, session id, seconds since its state file was written)] for the OTHER live local sessions in this repo
371
+ (the checkout and its worktrees), freshest first. The name is `-` when that session never ran `whoami`.
372
+
373
+ Live: a `<sid>.md` state file written within PEER_FRESH_S, with no newer `<sid>.ended` marker (a resumed session
374
+ rewrites its state, which is newer than the marker). A cloud or offline session writes no state here.
375
+ """
376
+ now = time.time() if now is None else now
377
+ own = _slug(session_id)
378
+ found: dict[str, tuple[str, int]] = {}
379
+ for wt in _worktree_roots(root):
380
+ try:
381
+ from claudster_config import artifact_root
382
+ base = Path(artifact_root(str(wt))) / "session-state"
383
+ except Exception:
384
+ base = wt / ".caddis" / "session-state"
385
+ try:
386
+ names = os.listdir(base)
387
+ except OSError:
388
+ continue
389
+ for fname in names:
390
+ sid = fname[:-3]
391
+ if not fname.endswith(".md") or (own and sid == own):
392
+ continue
393
+ try:
394
+ mtime = (base / fname).stat().st_mtime
395
+ except OSError:
396
+ continue
397
+ age = int(now - mtime)
398
+ try:
399
+ if (base / f"{sid}.ended").stat().st_mtime >= mtime:
400
+ continue
401
+ except OSError:
402
+ pass
403
+ if 0 <= age <= PEER_FRESH_S and (sid not in found or age < found[sid][1]):
404
+ found[sid] = (_peer_name(base, sid), age)
405
+ return sorted(((name, sid, age) for sid, (name, age) in found.items()), key=lambda p: p[2])
406
+
407
+
335
408
  # ---- resume block -----------------------------------------------------------------------------
336
409
 
337
410
  class ResumeError(ValueError):
@@ -375,15 +448,39 @@ def shell_arg(text: str) -> str:
375
448
  return "'" + one_line.replace("'", "’").replace('"', "”") + "'"
376
449
 
377
450
 
451
+ ANNOUNCE_ID = "<your session id>"
452
+
453
+
454
+ def announce_text(script: Path, name: str, folder: str) -> str:
455
+ """The sentence the start line adds, so the resumed session names itself and tells its live peers once.
456
+
457
+ A new session cannot expand `${CLAUDE_PLUGIN_ROOT}`, so the script path is this script's own, written out. It
458
+ holds no quote or double quote (`shell_arg` would change them). A peer without a name (`-`) is not messaged."""
459
+ py = f"python {script.as_posix()}"
460
+ return (f"Then announce yourself once. Run `{py} whoami --session-id {ANNOUNCE_ID} --name {name} --root .` and "
461
+ f"`{py} peers --session-id {ANNOUNCE_ID} --root .` (your session id is printed at session start). "
462
+ f"Send each peer that has a name (not `-`) one SendMessage: "
463
+ f"`{name} started in {folder}: <next action>. No reply needed.` Send nothing else, and do not answer "
464
+ f"a reply. If the script is missing or lists no peer, skip this.")
465
+
466
+
378
467
  def render_resume(session: str, session_id: str, workstream: str, prompt: str, role: str, stamp: datetime,
379
- fleet_mode: bool = False) -> str:
380
- """The last output of `/caddis:handoff`. In a fleet close the restart sheet holds the only start line."""
468
+ fleet_mode: bool = False, announce_script: Path | None = None, folder: str = "") -> str:
469
+ """The last output of `/caddis:handoff`. In a fleet close the restart sheet holds the only start line.
470
+
471
+ With `announce_script`, the start line (not the fenced block, which stays the relay's text) ends with the
472
+ whoami + announce sentence: the start line is what the new session receives with its name."""
473
+ shown = prompt # the fenced block stays the relay text
381
474
  if fleet_mode:
382
475
  start = "Start line: from the restart sheet"
383
476
  else:
384
- start = f"Start line:\nclaude --name {new_name(role, stamp)} {shell_arg(prompt)}"
477
+ name = new_name(role, stamp)
478
+ if announce_script is not None:
479
+ prompt = f"{prompt}\n{announce_text(announce_script, name, folder)}"
480
+ start = f"Start line:\nclaude --name {name} {shell_arg(prompt)}"
385
481
  return (f"=== RESUME ===\nSession: {session} ({session_id})\nWorkstream: {workstream}\n"
386
- f"{_fenced(prompt)}\n{start}\n")
482
+ f"{_fenced(shown)}\n{start}\n")
483
+
387
484
 
388
485
  # ---- CLI --------------------------------------------------------------------------------------
389
486
 
@@ -416,7 +513,8 @@ def _resume_block_cli(args: argparse.Namespace) -> int:
416
513
  if not _SAFE_NAME.match(role): # the role goes unquoted into the start line
417
514
  sys.stderr.write(f"resume-block failed: bad role {role!r}\n")
418
515
  return caddis_exit.BLOCKED
419
- print(render_resume(session, args.session_id, args.name, prompt, role, datetime.now(), args.fleet), end="")
516
+ print(render_resume(session, args.session_id, args.name, prompt, role, datetime.now(), args.fleet,
517
+ Path(__file__).resolve(), root.name), end="")
420
518
  return caddis_exit.CLEAN
421
519
 
422
520
 
@@ -446,6 +544,9 @@ def main(argv: list[str] | None = None) -> int:
446
544
  p.add_argument("--session-id", required=True)
447
545
  p.add_argument("--name", required=True)
448
546
  p.add_argument("--root", default=".", type=Path)
547
+ p = sub.add_parser("peers", help="the other live local sessions in this repo: <name> TAB <session id> TAB <age s>")
548
+ p.add_argument("--session-id", required=True, help="this session's id (never listed)")
549
+ p.add_argument("--root", default=".", type=Path)
449
550
  p = sub.add_parser("resume-block", help="print the session name and the relay's resume prompt (last step of handoff)")
450
551
  p.add_argument("--name", required=True, help="the relay's workstream name")
451
552
  p.add_argument("--session-id", required=True)
@@ -520,6 +621,10 @@ def main(argv: list[str] | None = None) -> int:
520
621
  return caddis_exit.CLEAN
521
622
  if args.cmd == "resume-block":
522
623
  return _resume_block_cli(args)
624
+ if args.cmd == "peers":
625
+ for peer_name, sid, age in live_peers(args.session_id, args.root.resolve()):
626
+ print(f"{peer_name} {sid} {age}")
627
+ return caddis_exit.CLEAN
523
628
  # whoami
524
629
  try:
525
630
  out = whoami(args.session_id, args.name, args.root.resolve())
@@ -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