@caddis/cli 0.4.61 → 0.4.63
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/caddis_statusline.py +78 -6
- package/bundles/antigravity-plugin/guard_agy.py +2 -1
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +13 -1
- package/bundles/antigravity-plugin/scripts/caddis_hidden_desktop.py +232 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +3 -2
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +62 -5
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +306 -54
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +10 -4
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +25 -3
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +78 -6
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +4 -1
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +3 -1
- package/bundles/antigravity-plugin/scripts/caddis_worktree_root.py +109 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +5 -3
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +5 -3
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +5 -3
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +5 -3
- package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +10 -3
- package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +10 -3
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +1 -1
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -42,6 +42,8 @@ CLI:
|
|
|
42
42
|
CADDIS_SPEND_RUN_TIMEOUT_S, else 3600 s) and its process tree was killed. 75 and 78 are never
|
|
43
43
|
agy's own codes for a refusal, so a caller can tell "the cap refused" from "agy failed". Each
|
|
44
44
|
refusal also prints a `caddis_spend:` line.
|
|
45
|
+
On Windows the command runs on a hidden desktop (caddis_hidden_desktop.py), so the console windows agy opens
|
|
46
|
+
for its tools never show; CADDIS_AGY_VISIBLE=1 runs it on the normal desktop.
|
|
45
47
|
"""
|
|
46
48
|
|
|
47
49
|
from __future__ import annotations
|
|
@@ -50,6 +52,7 @@ import argparse
|
|
|
50
52
|
import json
|
|
51
53
|
import math
|
|
52
54
|
import os
|
|
55
|
+
import shutil
|
|
53
56
|
import subprocess
|
|
54
57
|
import sys
|
|
55
58
|
import time
|
|
@@ -385,11 +388,30 @@ def _kill_tree(proc: subprocess.Popen) -> None:
|
|
|
385
388
|
pass
|
|
386
389
|
|
|
387
390
|
|
|
391
|
+
HIDDEN_DESKTOP_SCRIPT = Path(__file__).with_name("caddis_hidden_desktop.py")
|
|
392
|
+
AGY_VISIBLE_VAR = "CADDIS_AGY_VISIBLE"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def hidden_argv(argv: list[str], env: dict | None = None) -> list[str]:
|
|
396
|
+
"""`argv` wrapped to run on the hidden desktop (caddis_hidden_desktop.py), so the console windows agy opens for
|
|
397
|
+
its tools never show on the owner's screen. Unchanged when: not Windows, env CADDIS_AGY_VISIBLE=1, the launcher
|
|
398
|
+
is not beside this file, or the program is not found. In the last case the caller's own start raises
|
|
399
|
+
FileNotFoundError as before; the program is resolved here, so the launcher never has to report it."""
|
|
400
|
+
env = os.environ if env is None else env
|
|
401
|
+
if os.name != "nt" or not argv or env.get(AGY_VISIBLE_VAR) == "1" or not HIDDEN_DESKTOP_SCRIPT.is_file():
|
|
402
|
+
return list(argv)
|
|
403
|
+
program = shutil.which(argv[0], path=env.get("PATH"))
|
|
404
|
+
if program is None:
|
|
405
|
+
return list(argv)
|
|
406
|
+
return [sys.executable, str(HIDDEN_DESKTOP_SCRIPT), "--", program, *argv[1:]]
|
|
407
|
+
|
|
408
|
+
|
|
388
409
|
def _run_child(argv: list[str], env: dict, timeout_s: float) -> tuple[int | None, str, str]:
|
|
389
|
-
"""Run `argv` with a time limit
|
|
390
|
-
the whole process tree was killed. Raises FileNotFoundError when
|
|
410
|
+
"""Run `argv` with a time limit, on the hidden desktop on Windows (hidden_argv). Returns (returncode, stdout,
|
|
411
|
+
stderr); returncode is None on a timeout, after the whole process tree was killed. Raises FileNotFoundError when
|
|
412
|
+
the program does not exist."""
|
|
391
413
|
group = {} if os.name == "nt" else {"start_new_session": True}
|
|
392
|
-
proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8",
|
|
414
|
+
proc = subprocess.Popen(hidden_argv(argv, env), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8",
|
|
393
415
|
errors="replace", env=env, **_NO_WINDOW_KW, **group)
|
|
394
416
|
try:
|
|
395
417
|
out, err = proc.communicate(timeout=timeout_s)
|
|
@@ -71,8 +71,9 @@ DEFAULT_CONFIG = {
|
|
|
71
71
|
"bar_width": 8,
|
|
72
72
|
"token_counts": True, # ◉ 412k/1M — off falls back to a bare ctx:41%
|
|
73
73
|
"cached_tokens": True, # ⚡38k cached — rendered LOUD, see render_claude()
|
|
74
|
-
"ram":
|
|
75
|
-
"
|
|
74
|
+
"ram": False, # 💻 69% (44 GB) - off by default (owner, 20 Oct 2026)
|
|
75
|
+
"thinking": False, # Claude's "thinking" flag, agy's "thinking" state - off by default (20 Oct 2026)
|
|
76
|
+
"clock": False, # ◷ 03:41 - off by default (owner, 20 Oct 2026)
|
|
76
77
|
"relay": True, # ⏺ relay (a parked resume pointer exists)
|
|
77
78
|
"rate_limits": True, # 5h:12% 7d:40%
|
|
78
79
|
"session_id": False, # #a1b2c3d4
|
|
@@ -468,7 +469,7 @@ def cached_segment(c: C, cfg: dict, cached: int) -> str:
|
|
|
468
469
|
|
|
469
470
|
|
|
470
471
|
def ram_segment(c: C, cfg: dict) -> str:
|
|
471
|
-
if not cfg.get("ram",
|
|
472
|
+
if not cfg.get("ram", False):
|
|
472
473
|
return ""
|
|
473
474
|
min_w = as_int(cfg.get("ram_min_width"), 0)
|
|
474
475
|
if min_w and term_width() < min_w:
|
|
@@ -482,7 +483,7 @@ def ram_segment(c: C, cfg: dict) -> str:
|
|
|
482
483
|
|
|
483
484
|
|
|
484
485
|
def clock_segment(c: C, cfg: dict) -> str:
|
|
485
|
-
if not cfg.get("clock",
|
|
486
|
+
if not cfg.get("clock", False):
|
|
486
487
|
return ""
|
|
487
488
|
return c.dim + icon(cfg, "clock") + time.strftime("%H:%M") + c.reset
|
|
488
489
|
|
|
@@ -549,7 +550,7 @@ def render_claude(data: dict, cfg: dict) -> str:
|
|
|
549
550
|
if style and str(style).lower() != "default":
|
|
550
551
|
parts.append(c.blue + "style:" + str(style) + c.reset)
|
|
551
552
|
|
|
552
|
-
if dig(data, "thinking", "enabled") is True:
|
|
553
|
+
if cfg.get("thinking", False) and dig(data, "thinking", "enabled") is True:
|
|
553
554
|
parts.append(c.magenta + "thinking" + c.reset)
|
|
554
555
|
|
|
555
556
|
if cfg.get("rate_limits", True):
|
|
@@ -581,7 +582,7 @@ def render_claude(data: dict, cfg: dict) -> str:
|
|
|
581
582
|
|
|
582
583
|
session_name = dig(data, "session_name")
|
|
583
584
|
if session_name:
|
|
584
|
-
parts.append(c.
|
|
585
|
+
parts.append(c.bold + c.magenta + str(session_name) + c.reset) # bright, so the session is easy to spot
|
|
585
586
|
|
|
586
587
|
if cfg.get("session_id", False):
|
|
587
588
|
sid = str(dig(data, "session_id") or "")
|
|
@@ -616,6 +617,8 @@ def render_agy(data: dict, cfg: dict) -> str:
|
|
|
616
617
|
parts.append(c.white + c.bold + "agy" + c.reset)
|
|
617
618
|
|
|
618
619
|
state = str(dig(data, "agent_state") or dig(data, "execution_mode") or "").lower()
|
|
620
|
+
if state == "thinking" and not cfg.get("thinking", False):
|
|
621
|
+
state = "" # working and idle still show
|
|
619
622
|
if state:
|
|
620
623
|
label, col = {
|
|
621
624
|
"working": (icon(cfg, "bolt") + "working", c.green),
|
|
@@ -704,6 +707,61 @@ HOST_SETTINGS = {
|
|
|
704
707
|
}
|
|
705
708
|
|
|
706
709
|
|
|
710
|
+
VERSION_STAMP = INSTALL_DIR / "statusline.version"
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def plugin_version(here: Path | None = None) -> str:
|
|
714
|
+
"""The caddis version of the plugin this file ships in: `.claude-plugin/plugin.json` or a root `plugin.json`
|
|
715
|
+
one folder above `scripts/`. "" in a source checkout or when unreadable."""
|
|
716
|
+
base = Path(here or Path(__file__).resolve().parent).parent
|
|
717
|
+
for manifest in (base / ".claude-plugin" / "plugin.json", base / "plugin.json"):
|
|
718
|
+
try:
|
|
719
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
720
|
+
except Exception:
|
|
721
|
+
continue
|
|
722
|
+
if isinstance(data, dict) and str(data.get("name", "")).startswith("caddis") and data.get("version"):
|
|
723
|
+
return str(data["version"])
|
|
724
|
+
return ""
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _version_key(text: str):
|
|
728
|
+
parts = str(text or "").strip().split(".")
|
|
729
|
+
return tuple(int(x) for x in parts) if parts and all(x.isdigit() for x in parts) else None
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def refresh_installed(src: Path | None = None, version: str | None = None) -> bool:
|
|
733
|
+
"""Bring the installed renderer (`~/.caddis/statusline.py`) up to this plugin's copy.
|
|
734
|
+
|
|
735
|
+
Why (20 Oct 2026): only `--install` copied the renderer, so a caddis update never reached it. This machine
|
|
736
|
+
kept a copy whose `git` calls opened a console window each time agy drew its status line.
|
|
737
|
+
Rules: nothing happens unless the status line was installed; the copy happens only when this plugin's version
|
|
738
|
+
is newer than the one stamped at the last copy (an older session never downgrades it); a source checkout
|
|
739
|
+
(no plugin version) never copies. Returns True when it copied."""
|
|
740
|
+
src = Path(src or Path(__file__).resolve())
|
|
741
|
+
version = plugin_version() if version is None else version
|
|
742
|
+
if not INSTALLED_SCRIPT.is_file() or _version_key(version) is None:
|
|
743
|
+
return False
|
|
744
|
+
try:
|
|
745
|
+
stamped = VERSION_STAMP.read_text(encoding="utf-8").strip()
|
|
746
|
+
except OSError:
|
|
747
|
+
stamped = ""
|
|
748
|
+
old = _version_key(stamped)
|
|
749
|
+
if old is not None and old >= _version_key(version):
|
|
750
|
+
return False
|
|
751
|
+
try:
|
|
752
|
+
if src.resolve() != INSTALLED_SCRIPT.resolve() and src.read_bytes() != INSTALLED_SCRIPT.read_bytes():
|
|
753
|
+
tmp = INSTALLED_SCRIPT.with_name("%s.%d.tmp" % (INSTALLED_SCRIPT.name, os.getpid()))
|
|
754
|
+
shutil.copyfile(str(src), str(tmp))
|
|
755
|
+
os.replace(str(tmp), str(INSTALLED_SCRIPT))
|
|
756
|
+
copied = True
|
|
757
|
+
else:
|
|
758
|
+
copied = False
|
|
759
|
+
VERSION_STAMP.write_text(version + "\n", encoding="utf-8")
|
|
760
|
+
return copied
|
|
761
|
+
except OSError:
|
|
762
|
+
return False # a session start must never fail over the status line
|
|
763
|
+
|
|
764
|
+
|
|
707
765
|
def python_command() -> str:
|
|
708
766
|
"""The interpreter NAME to write into settings — not an absolute path.
|
|
709
767
|
|
|
@@ -726,6 +784,14 @@ def install(hosts, dry_run: bool = False) -> int:
|
|
|
726
784
|
# because the version is in the path.
|
|
727
785
|
if not dry_run and src != INSTALLED_SCRIPT.resolve():
|
|
728
786
|
shutil.copyfile(str(src), str(INSTALLED_SCRIPT))
|
|
787
|
+
if not dry_run:
|
|
788
|
+
# Stamp the version, so a later --refresh from an OLDER plugin session cannot replace this copy.
|
|
789
|
+
version = plugin_version()
|
|
790
|
+
if _version_key(version) is not None:
|
|
791
|
+
try:
|
|
792
|
+
VERSION_STAMP.write_text(version + "\n", encoding="utf-8")
|
|
793
|
+
except OSError:
|
|
794
|
+
pass
|
|
729
795
|
print("renderer -> %s" % INSTALLED_SCRIPT)
|
|
730
796
|
|
|
731
797
|
if not CONFIG_PATH.exists():
|
|
@@ -903,8 +969,14 @@ def main(argv=None) -> int:
|
|
|
903
969
|
parser.add_argument("--dry-run", action="store_true", help="with --install: print, write nothing")
|
|
904
970
|
parser.add_argument("--no-color", action="store_true", help="render without ANSI colour")
|
|
905
971
|
parser.add_argument("--title", action="store_true", help="render terminal title (agy)")
|
|
972
|
+
parser.add_argument("--refresh", action="store_true",
|
|
973
|
+
help="copy this plugin's renderer over an older installed one (session-start hook)")
|
|
906
974
|
args = parser.parse_args(argv)
|
|
907
975
|
|
|
976
|
+
if args.refresh:
|
|
977
|
+
refresh_installed()
|
|
978
|
+
return 0 # silent and never failing: it runs at every session start
|
|
979
|
+
|
|
908
980
|
# The line is Unicode. On Windows the default cp1252 stdout raises on ◆/⎇/█.
|
|
909
981
|
try:
|
|
910
982
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
@@ -43,7 +43,10 @@ def _path(root: Path, workstream: str | None) -> tuple[Path, Path]:
|
|
|
43
43
|
artifact = _artifact(root)
|
|
44
44
|
name = _name(workstream) if workstream is not None else "inbox"
|
|
45
45
|
folder = artifact / "todo-list"
|
|
46
|
-
|
|
46
|
+
# Only a folder that EXISTS can point elsewhere. Resolving one that does not exist yet raced a concurrent
|
|
47
|
+
# writer creating it: on Windows the two paths could resolve differently (a short 8.3 name on one side), and a
|
|
48
|
+
# safe add failed with this error (main CI, 20 Oct 2026, test_concurrent_inbox_add_loses_no_item).
|
|
49
|
+
if folder.is_symlink() or (folder.exists() and not folder.resolve().is_relative_to(artifact.resolve())):
|
|
47
50
|
raise ValueError("todo-list path escapes artifact root")
|
|
48
51
|
path = folder / f"{name}.md"
|
|
49
52
|
# Reads are unlocked, so refuse here as well as in locked_path. On Windows a writer replacing the file
|
|
@@ -58,7 +58,9 @@ def _plain_name(value: str) -> str:
|
|
|
58
58
|
def _artifact(root: Path) -> Path:
|
|
59
59
|
project = Path(root).resolve()
|
|
60
60
|
artifact = Path(root) / ".caddis"
|
|
61
|
-
|
|
61
|
+
# Only an existing .caddis can point elsewhere; resolving one that does not exist yet raced a concurrent writer
|
|
62
|
+
# creating it (see caddis_todo._path, 20 Oct 2026).
|
|
63
|
+
if artifact.is_symlink() or (artifact.exists() and not artifact.resolve().is_relative_to(project)):
|
|
62
64
|
raise ValueError("artifact root escapes project")
|
|
63
65
|
return artifact
|
|
64
66
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Where a NEW git worktree goes. One rule, one place; every caller and every doc uses it.
|
|
2
|
+
|
|
3
|
+
Never %TEMP%: Windows cleanup / Storage Sense can empty it and delete a worktree that holds unmerged work (owner
|
|
4
|
+
decision, 20 Oct 2026). The path is
|
|
5
|
+
|
|
6
|
+
<root>/<main checkout folder name>/<short-name>
|
|
7
|
+
|
|
8
|
+
where <root> is, in order:
|
|
9
|
+
1. the `root` argument (a test seam and an explicit override);
|
|
10
|
+
2. the `CADDIS_WORKTREE_ROOT` environment variable, when set and not blank;
|
|
11
|
+
3. `<parent folder of the repo's MAIN checkout>/.worktrees`. For `E:\\Projects\\<repo>` that is
|
|
12
|
+
`E:\\Projects\\.worktrees\\<repo>\\<short-name>`. No machine path is written in code. There is no %TEMP% fallback.
|
|
13
|
+
|
|
14
|
+
The main checkout is the parent of `git rev-parse --git-common-dir`, so the answer is the same from the main checkout,
|
|
15
|
+
from a subfolder and from any linked worktree.
|
|
16
|
+
|
|
17
|
+
Only the default for a NEW worktree lives here. A worktree that already exists, or whose path a runner recorded,
|
|
18
|
+
stays where it is: moving it breaks a venv, which keeps absolute paths.
|
|
19
|
+
|
|
20
|
+
worktree_root(repo, root=None) -> the folder that holds the per-repo folders
|
|
21
|
+
worktree_path(repo, short_name, root=None) -> the path for a new worktree (nothing is created)
|
|
22
|
+
main_checkout(repo) -> the main checkout's folder
|
|
23
|
+
|
|
24
|
+
CLI: `python caddis_worktree_root.py [--repo PATH] [--root PATH] <short-name>` prints the path. Exit 2 on a bad name
|
|
25
|
+
or a folder that is not in a git repo.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import os
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
ENV_VAR = "CADDIS_WORKTREE_ROOT"
|
|
37
|
+
FALLBACK_DIR = ".worktrees"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NotARepoError(RuntimeError):
|
|
41
|
+
"""The folder is not inside a git repository."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _git_out(cwd: Path, *args: str) -> str | None:
|
|
45
|
+
try:
|
|
46
|
+
res = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, encoding="utf-8",
|
|
47
|
+
errors="replace", stdin=subprocess.DEVNULL, check=False)
|
|
48
|
+
except OSError:
|
|
49
|
+
return None
|
|
50
|
+
return res.stdout.strip() if res.returncode == 0 else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main_checkout(repo: Path | str) -> Path:
|
|
54
|
+
"""The MAIN checkout's folder for `repo` (the main checkout, a subfolder of it or a linked worktree).
|
|
55
|
+
|
|
56
|
+
It is the parent of the git common dir. A common dir not named `.git` (a submodule's, a bare repo's) has no such
|
|
57
|
+
parent: then the work-tree top is used, or the common dir itself when there is no work tree."""
|
|
58
|
+
repo = Path(repo).absolute()
|
|
59
|
+
common = _git_out(repo, "rev-parse", "--git-common-dir") if repo.is_dir() else None
|
|
60
|
+
if not common:
|
|
61
|
+
raise NotARepoError(f"{repo} is not inside a git repository")
|
|
62
|
+
common_dir = Path(os.path.normpath(repo / common)) # relative to `repo`, or absolute already
|
|
63
|
+
if common_dir.name == ".git":
|
|
64
|
+
return common_dir.parent
|
|
65
|
+
top = _git_out(repo, "rev-parse", "--show-toplevel")
|
|
66
|
+
return Path(os.path.normpath(top)) if top else common_dir
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _explicit_root(root: Path | str | None) -> Path | None:
|
|
70
|
+
"""`root`, else `$CADDIS_WORKTREE_ROOT` when not blank, else None (the fallback needs the main checkout)."""
|
|
71
|
+
if root:
|
|
72
|
+
return Path(root)
|
|
73
|
+
env = os.environ.get(ENV_VAR, "").strip()
|
|
74
|
+
return Path(env) if env else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def worktree_root(repo: Path | str, root: Path | str | None = None) -> Path:
|
|
78
|
+
"""The folder that holds one folder per repo: `root`, else `$CADDIS_WORKTREE_ROOT`, else
|
|
79
|
+
`<parent of the main checkout>/.worktrees`."""
|
|
80
|
+
return _explicit_root(root) or main_checkout(repo).parent / FALLBACK_DIR
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def worktree_path(repo: Path | str, short_name: str, root: Path | str | None = None) -> Path:
|
|
84
|
+
"""The path for a NEW worktree of `repo`: `<root>/<main checkout folder name>/<short_name>`. Creates nothing.
|
|
85
|
+
`short_name` is one plain folder name: ValueError for an empty name, `.`, `..`, or one with a path separator or a
|
|
86
|
+
colon (a drive such as `C:foo` would replace the root on Windows)."""
|
|
87
|
+
if (not short_name or short_name in (".", "..") or any(c in short_name for c in "/\:")
|
|
88
|
+
or short_name != short_name.strip()):
|
|
89
|
+
raise ValueError(f"the short name must be one plain folder name, not {short_name!r}")
|
|
90
|
+
main = main_checkout(repo) # one git call
|
|
91
|
+
return (_explicit_root(root) or main.parent / FALLBACK_DIR) / main.name / short_name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main(argv: list[str] | None = None) -> int:
|
|
95
|
+
ap = argparse.ArgumentParser(description="Print the path for a new git worktree (never under %TEMP%).")
|
|
96
|
+
ap.add_argument("short_name", help="one folder name, e.g. fix-login")
|
|
97
|
+
ap.add_argument("--repo", default=".", help="any folder of the repo or of one of its worktrees (default: .)")
|
|
98
|
+
ap.add_argument("--root", default=None, help=f"override the root (default: ${ENV_VAR}, then <repo parent>/.worktrees)")
|
|
99
|
+
args = ap.parse_args(argv)
|
|
100
|
+
try:
|
|
101
|
+
print(worktree_path(args.repo, args.short_name, args.root))
|
|
102
|
+
except (ValueError, NotARepoError) as exc:
|
|
103
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
104
|
+
return 2
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
sys.exit(main())
|
|
@@ -37,10 +37,10 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
37
37
|
|
|
38
38
|
- **On `SHARED: no`,** do the steps as written, in this checkout.
|
|
39
39
|
- **On `SHARED: yes`,** never move this checkout. Leave its changes as they are, and report them. Instead:
|
|
40
|
-
- **Rebase:** `git worktree add --detach "$
|
|
40
|
+
- **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name>)" <branch>`, then
|
|
41
41
|
`git -C <worktree> rebase origin/<default>` and `git -C <worktree> push --force-with-lease origin HEAD:<branch>`.
|
|
42
42
|
- **Pull a branch to test it:** `git fetch origin`, then
|
|
43
|
-
`git worktree add --detach "$
|
|
43
|
+
`git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name>)" origin/<branch>` (for the default branch,
|
|
44
44
|
`origin/<default>`), and run the gates there. Install dependencies fresh; never link `node_modules` or a venv.
|
|
45
45
|
- **Clean up after a merge:** do not switch or pull. Run `git fetch --prune`, then
|
|
46
46
|
`git fetch origin <default>:<default>`: it fast-forwards the default branch with no checkout. Git refuses when a
|
|
@@ -48,7 +48,9 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
48
48
|
checkout has it out.
|
|
49
49
|
- Remove each worktree you made when its step ends: `git worktree remove <worktree>`.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
`<name>` is the branch name with each `/` written as `-`. `caddis_worktree_root.py` prints the path of a new worktree:
|
|
52
|
+
`$CADDIS_WORKTREE_ROOT/<repo>/<short-name>`, else `<parent of the main checkout>/.worktrees/<repo>/<short-name>`. Never `%TEMP%`: Windows
|
|
53
|
+
cleanup can empty it. The caddis guard checks only the `Shared checkout: yes` line in
|
|
52
54
|
`AGENTS.md`, not a dirty tree: with that line, it asks before a tree move, and denies it in a headless session.
|
|
53
55
|
<!-- /checkout-safety -->
|
|
54
56
|
|
|
@@ -43,10 +43,10 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
43
43
|
|
|
44
44
|
- **On `SHARED: no`,** do the steps as written, in this checkout.
|
|
45
45
|
- **On `SHARED: yes`,** never move this checkout. Leave its changes as they are, and report them. Instead:
|
|
46
|
-
- **Rebase:** `git worktree add --detach "$
|
|
46
|
+
- **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name>)" <branch>`, then
|
|
47
47
|
`git -C <worktree> rebase origin/<default>` and `git -C <worktree> push --force-with-lease origin HEAD:<branch>`.
|
|
48
48
|
- **Pull a branch to test it:** `git fetch origin`, then
|
|
49
|
-
`git worktree add --detach "$
|
|
49
|
+
`git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name>)" origin/<branch>` (for the default branch,
|
|
50
50
|
`origin/<default>`), and run the gates there. Install dependencies fresh; never link `node_modules` or a venv.
|
|
51
51
|
- **Clean up after a merge:** do not switch or pull. Run `git fetch --prune`, then
|
|
52
52
|
`git fetch origin <default>:<default>`: it fast-forwards the default branch with no checkout. Git refuses when a
|
|
@@ -54,7 +54,9 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
54
54
|
checkout has it out.
|
|
55
55
|
- Remove each worktree you made when its step ends: `git worktree remove <worktree>`.
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
`<name>` is the branch name with each `/` written as `-`. `caddis_worktree_root.py` prints the path of a new worktree:
|
|
58
|
+
`$CADDIS_WORKTREE_ROOT/<repo>/<short-name>`, else `<parent of the main checkout>/.worktrees/<repo>/<short-name>`. Never `%TEMP%`: Windows
|
|
59
|
+
cleanup can empty it. The caddis guard checks only the `Shared checkout: yes` line in
|
|
58
60
|
`AGENTS.md`, not a dirty tree: with that line, it asks before a tree move, and denies it in a headless session.
|
|
59
61
|
<!-- /checkout-safety -->
|
|
60
62
|
|
|
@@ -154,7 +154,7 @@ unattended; `-Plan <slug>` runs one plain plan instead (the two are exclusive).
|
|
|
154
154
|
or the owner, or that has **no Lane line**, is not run (fail closed): the runner adds
|
|
155
155
|
`block --type owner-gate --reason "phase <n> lane: <lane>"` (`lane: none` when there is no line) and goes on.
|
|
156
156
|
3. A `todo` milestone is set `in-progress` (a docs PR). Every session of a roadmap runs in ONE dedicated worktree,
|
|
157
|
-
|
|
157
|
+
`<worktree root>/<repo>/caddis-roadmap-<slug>-<8 hex of the repo's git common dir>` (`$CADDIS_WORKTREE_ROOT`, else the `.worktrees` folder beside the main checkout, never `%TEMP%`; an existing recorded worktree stays where it is): made with `git worktree add --detach` from `origin/<default>` on the first
|
|
158
158
|
session, reused on restart, recorded in the runner folder, never the `-Repo` checkout (plan mode keeps working in
|
|
159
159
|
the given checkout). In that worktree the runner makes a fresh branch `caddis/roadmap/<slug>/<plan>-p<n>` from
|
|
160
160
|
`origin/<default>` (it refuses a branch of that name already on origin that it did not make) and ONE session runs
|
|
@@ -42,10 +42,10 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
42
42
|
|
|
43
43
|
- **On `SHARED: no`,** do the steps as written, in this checkout.
|
|
44
44
|
- **On `SHARED: yes`,** never move this checkout. Leave its changes as they are, and report them. Instead:
|
|
45
|
-
- **Rebase:** `git worktree add --detach "$
|
|
45
|
+
- **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name>)" <branch>`, then
|
|
46
46
|
`git -C <worktree> rebase origin/<default>` and `git -C <worktree> push --force-with-lease origin HEAD:<branch>`.
|
|
47
47
|
- **Pull a branch to test it:** `git fetch origin`, then
|
|
48
|
-
`git worktree add --detach "$
|
|
48
|
+
`git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name>)" origin/<branch>` (for the default branch,
|
|
49
49
|
`origin/<default>`), and run the gates there. Install dependencies fresh; never link `node_modules` or a venv.
|
|
50
50
|
- **Clean up after a merge:** do not switch or pull. Run `git fetch --prune`, then
|
|
51
51
|
`git fetch origin <default>:<default>`: it fast-forwards the default branch with no checkout. Git refuses when a
|
|
@@ -53,7 +53,9 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
53
53
|
checkout has it out.
|
|
54
54
|
- Remove each worktree you made when its step ends: `git worktree remove <worktree>`.
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
`<name>` is the branch name with each `/` written as `-`. `caddis_worktree_root.py` prints the path of a new worktree:
|
|
57
|
+
`$CADDIS_WORKTREE_ROOT/<repo>/<short-name>`, else `<parent of the main checkout>/.worktrees/<repo>/<short-name>`. Never `%TEMP%`: Windows
|
|
58
|
+
cleanup can empty it. The caddis guard checks only the `Shared checkout: yes` line in
|
|
57
59
|
`AGENTS.md`, not a dirty tree: with that line, it asks before a tree move, and denies it in a headless session.
|
|
58
60
|
<!-- /checkout-safety -->
|
|
59
61
|
|
|
@@ -41,10 +41,10 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
41
41
|
|
|
42
42
|
- **On `SHARED: no`,** do the steps as written, in this checkout.
|
|
43
43
|
- **On `SHARED: yes`,** never move this checkout. Leave its changes as they are, and report them. Instead:
|
|
44
|
-
- **Rebase:** `git worktree add --detach "$
|
|
44
|
+
- **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name>)" <branch>`, then
|
|
45
45
|
`git -C <worktree> rebase origin/<default>` and `git -C <worktree> push --force-with-lease origin HEAD:<branch>`.
|
|
46
46
|
- **Pull a branch to test it:** `git fetch origin`, then
|
|
47
|
-
`git worktree add --detach "$
|
|
47
|
+
`git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name>)" origin/<branch>` (for the default branch,
|
|
48
48
|
`origin/<default>`), and run the gates there. Install dependencies fresh; never link `node_modules` or a venv.
|
|
49
49
|
- **Clean up after a merge:** do not switch or pull. Run `git fetch --prune`, then
|
|
50
50
|
`git fetch origin <default>:<default>`: it fast-forwards the default branch with no checkout. Git refuses when a
|
|
@@ -52,7 +52,9 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
52
52
|
checkout has it out.
|
|
53
53
|
- Remove each worktree you made when its step ends: `git worktree remove <worktree>`.
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
`<name>` is the branch name with each `/` written as `-`. `caddis_worktree_root.py` prints the path of a new worktree:
|
|
56
|
+
`$CADDIS_WORKTREE_ROOT/<repo>/<short-name>`, else `<parent of the main checkout>/.worktrees/<repo>/<short-name>`. Never `%TEMP%`: Windows
|
|
57
|
+
cleanup can empty it. The caddis guard checks only the `Shared checkout: yes` line in
|
|
56
58
|
`AGENTS.md`, not a dirty tree: with that line, it asks before a tree move, and denies it in a headless session.
|
|
57
59
|
<!-- /checkout-safety -->
|
|
58
60
|
|
|
@@ -31,13 +31,20 @@ If none is true, stop here and use `git switch -c`.
|
|
|
31
31
|
1. Fetch: `git fetch origin`.
|
|
32
32
|
2. Find the default branch (`main` in most repos): `git symbolic-ref --short refs/remotes/origin/HEAD`.
|
|
33
33
|
It prints `origin/main`. Use the part after `origin/` as `<default>`.
|
|
34
|
-
3. Create the worktree from `origin/<default>`, never from the local HEAD,
|
|
34
|
+
3. Create the worktree from `origin/<default>`, never from the local HEAD, at
|
|
35
|
+
`<worktree root>\<repo>\<short-name>`. Never under `%TEMP%`: Windows cleanup (Storage Sense) can empty it
|
|
36
|
+
and delete a worktree that holds unmerged work. The worktree root is `$env:CADDIS_WORKTREE_ROOT` when set,
|
|
37
|
+
else the `.worktrees` folder beside the main checkout (for `E:\Projects\<repo>` that is
|
|
38
|
+
`E:\Projects\.worktrees\<repo>\<short-name>`). `<repo>` is the main checkout's folder name. The caddis
|
|
39
|
+
script `caddis_worktree_root.py` prints the path; use it so the rule lives in one place:
|
|
35
40
|
|
|
36
41
|
```powershell
|
|
37
|
-
|
|
42
|
+
$wt = python "<caddis scripts>\caddis_worktree_root.py" <short-name> # run in the repo; prints the path
|
|
43
|
+
git worktree add -b <branch> $wt origin/<default>
|
|
38
44
|
```
|
|
39
45
|
|
|
40
|
-
Do not create worktrees inside the
|
|
46
|
+
`<caddis scripts>` is the `scripts` folder of the installed caddis plugin. Do not create worktrees inside the
|
|
47
|
+
repo. An existing worktree stays where it is: moving it breaks a venv, which keeps absolute paths.
|
|
41
48
|
4. State the full path of the worktree. Work only there, and do not switch branches in it.
|
|
42
49
|
|
|
43
50
|
## Install dependencies fresh
|
|
@@ -31,13 +31,20 @@ If none is true, stop here and use `git switch -c`.
|
|
|
31
31
|
1. Fetch: `git fetch origin`.
|
|
32
32
|
2. Find the default branch (`main` in most repos): `git symbolic-ref --short refs/remotes/origin/HEAD`.
|
|
33
33
|
It prints `origin/main`. Use the part after `origin/` as `<default>`.
|
|
34
|
-
3. Create the worktree from `origin/<default>`, never from the local HEAD,
|
|
34
|
+
3. Create the worktree from `origin/<default>`, never from the local HEAD, at
|
|
35
|
+
`<worktree root>\<repo>\<short-name>`. Never under `%TEMP%`: Windows cleanup (Storage Sense) can empty it
|
|
36
|
+
and delete a worktree that holds unmerged work. The worktree root is `$env:CADDIS_WORKTREE_ROOT` when set,
|
|
37
|
+
else the `.worktrees` folder beside the main checkout (for `E:\Projects\<repo>` that is
|
|
38
|
+
`E:\Projects\.worktrees\<repo>\<short-name>`). `<repo>` is the main checkout's folder name. The caddis
|
|
39
|
+
script `caddis_worktree_root.py` prints the path; use it so the rule lives in one place:
|
|
35
40
|
|
|
36
41
|
```powershell
|
|
37
|
-
|
|
42
|
+
$wt = python "<caddis scripts>\caddis_worktree_root.py" <short-name> # run in the repo; prints the path
|
|
43
|
+
git worktree add -b <branch> $wt origin/<default>
|
|
38
44
|
```
|
|
39
45
|
|
|
40
|
-
Do not create worktrees inside the
|
|
46
|
+
`<caddis scripts>` is the `scripts` folder of the installed caddis plugin. Do not create worktrees inside the
|
|
47
|
+
repo. An existing worktree stays where it is: moving it breaks a venv, which keeps absolute paths.
|
|
41
48
|
4. State the full path of the worktree. Work only there, and do not switch branches in it.
|
|
42
49
|
|
|
43
50
|
## Install dependencies fresh
|
|
@@ -154,7 +154,7 @@ unattended; `-Plan <slug>` runs one plain plan instead (the two are exclusive).
|
|
|
154
154
|
or the owner, or that has **no Lane line**, is not run (fail closed): the runner adds
|
|
155
155
|
`block --type owner-gate --reason "phase <n> lane: <lane>"` (`lane: none` when there is no line) and goes on.
|
|
156
156
|
3. A `todo` milestone is set `in-progress` (a docs PR). Every session of a roadmap runs in ONE dedicated worktree,
|
|
157
|
-
|
|
157
|
+
`<worktree root>/<repo>/caddis-roadmap-<slug>-<8 hex of the repo's git common dir>` (`$CADDIS_WORKTREE_ROOT`, else the `.worktrees` folder beside the main checkout, never `%TEMP%`; an existing recorded worktree stays where it is): made with `git worktree add --detach` from `origin/<default>` on the first
|
|
158
158
|
session, reused on restart, recorded in the runner folder, never the `-Repo` checkout (plan mode keeps working in
|
|
159
159
|
the given checkout). In that worktree the runner makes a fresh branch `caddis/roadmap/<slug>/<plan>-p<n>` from
|
|
160
160
|
`origin/<default>` (it refuses a branch of that name already on origin that it did not make) and ONE session runs
|
package/bundles/manifest.json
CHANGED
package/package.json
CHANGED