@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.
- 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_fleet.py +110 -5
- 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_run.py +3 -0
- package/bundles/antigravity-plugin/scripts/caddis_temp_prune.py +376 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +32 -5
- package/bundles/antigravity-plugin/scripts/caddis_worktree_root.py +26 -3
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +45 -10
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +4 -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/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:
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
|