@caddis/cli 0.4.63 → 0.4.64

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.149",
3
+ "version": "1.3.150",
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
  }
@@ -37,6 +37,7 @@ import caddis_checkout_guard
37
37
  import caddis_routing
38
38
  import caddis_redact
39
39
  import caddis_spend
40
+ import caddis_worktree_root
40
41
  import caddis_worktree_safety
41
42
 
42
43
  if hasattr(sys.stdout, "reconfigure"):
@@ -61,7 +62,15 @@ DEFAULT_IDLE_TIMEOUT_S = 900
61
62
  IDLE_SAMPLE_S = 15
62
63
  LANE_IDS = ("claude", "glm", "codex-sol", "codex-astra", "agy")
63
64
  TASK_TYPES = ("backend", "hard", "ui", "tests", "docs")
65
+ # Where lane git WORKTREES live.
66
+ # New lane worktrees go to `<worktree root>/<repo>/caddis-lane-<hash>-<plan>-p<NN>-a<K>` (`lane_worktree_path`, which asks
67
+ # `caddis_worktree_root`: $CADDIS_WORKTREE_ROOT, else `<parent of the main checkout>/.worktrees`). Never %TEMP%: Windows
68
+ # cleanup (Storage Sense) can empty it and delete a lane worktree that holds unmerged work (owner decision, 20 Oct 2026).
69
+ # LANES_TMP is the OLD place, kept ONLY so lanes made before that decision keep working where they are: it is read to
70
+ # accept, finish and abandon an old record, and is never written to. Nothing else lives under it. Lane scratch that is not
71
+ # a worktree (packets, locks) is under `<repo>/.caddis/orchestrator/lane-runs/` (`run_dir`); it never used LANES_TMP.
64
72
  LANES_TMP = Path(tempfile.gettempdir()) / "caddis-lanes"
73
+ LANE_WORKTREE_PREFIX = "caddis-lane-"
65
74
  DEFAULT_MODELS = {
66
75
  "claude": "claude-opus-5",
67
76
  "glm": "glm-5.3[1m]",
@@ -227,6 +236,32 @@ def _git_output(repo_root: Path, *args: str) -> str:
227
236
  return result.stdout.strip()
228
237
 
229
238
 
239
+ def lane_worktree_path(repo_root: Path, plan_stem: str, phase: int, attempt: int) -> Path:
240
+ """The path of a NEW lane worktree: `<worktree root>/<repo>/caddis-lane-<repo hash>-<plan>-p<NN>-a<K>`.
241
+ Creates nothing. The hash keeps two repos with the same folder name apart."""
242
+ repo_hash = hashlib.sha256(str(repo_root.resolve()).encode("utf-8")).hexdigest()[:8]
243
+ short_name = f"{LANE_WORKTREE_PREFIX}{repo_hash}-{plan_stem}-p{phase:02d}-a{attempt}"
244
+ try:
245
+ return caddis_worktree_root.worktree_path(repo_root, short_name)
246
+ except (ValueError, caddis_worktree_root.NotARepoError) as exc:
247
+ raise LaneConfigError(str(exc)) from exc
248
+
249
+
250
+ def is_lane_worktree_path(repo_root: Path, path: Path) -> bool:
251
+ """True for a folder that a lane may remove: an OLD one under LANES_TMP (any depth, as before), or a NEW one named
252
+ `caddis-lane-*` directly inside this repo's folder under the current worktree root."""
253
+ resolved = path.resolve()
254
+ if LANES_TMP.resolve() in resolved.parents:
255
+ return True
256
+ if not resolved.name.startswith(LANE_WORKTREE_PREFIX):
257
+ return False
258
+ try:
259
+ lane_dir = caddis_worktree_root.worktree_path(repo_root, resolved.name).parent
260
+ except (ValueError, caddis_worktree_root.NotARepoError):
261
+ return False
262
+ return resolved.parent == lane_dir.resolve()
263
+
264
+
230
265
  def create_phase_worktree(
231
266
  repo_root: Path,
232
267
  plan_stem: str,
@@ -235,14 +270,11 @@ def create_phase_worktree(
235
270
  ) -> Worktree:
236
271
  base_sha = _git_output(repo_root, "rev-parse", "HEAD")
237
272
  branch = f"lane/{plan_stem}/phase-{phase:02d}-a{attempt}"
238
- repo_hash = hashlib.sha256(str(repo_root.resolve()).encode("utf-8")).hexdigest()[:8]
239
- path = LANES_TMP / (
240
- f"{repo_root.name}-{repo_hash}-{plan_stem}-p{phase:02d}-a{attempt}"
241
- )
273
+ path = lane_worktree_path(repo_root, plan_stem, phase, attempt)
242
274
  if path.exists():
243
275
  raise LaneConfigError(f"worktree already exists: {path} — run abandon first")
244
276
 
245
- LANES_TMP.mkdir(parents=True, exist_ok=True)
277
+ path.parent.mkdir(parents=True, exist_ok=True)
246
278
  _git_output(
247
279
  repo_root,
248
280
  "worktree",
@@ -262,8 +294,8 @@ def remove_phase_worktree(
262
294
  delete_branch: bool,
263
295
  ) -> None:
264
296
  resolved = wt.path.resolve()
265
- if LANES_TMP.resolve() not in resolved.parents:
266
- raise LaneConfigError(f"refusing to remove {resolved}: not under {LANES_TMP}")
297
+ if not is_lane_worktree_path(repo_root, resolved):
298
+ raise LaneConfigError(f"refusing to remove {resolved}: not a lane worktree (a caddis-lane-* folder under the worktree root, or an old one under {LANES_TMP})")
267
299
  if delete_branch and not wt.branch.startswith("lane/"):
268
300
  raise LaneConfigError(f"refusing to delete non-lane branch: {wt.branch}")
269
301
 
@@ -2342,8 +2374,8 @@ def _validate_packet_worktree(
2342
2374
  ) -> None:
2343
2375
  resolved = wt.path.resolve()
2344
2376
  expected_branch = f"lane/{plan.stem}/phase-{phase:02d}-a{attempt}"
2345
- if LANES_TMP.resolve() not in resolved.parents:
2346
- raise LaneConfigError(f"refusing packet worktree outside {LANES_TMP}: {resolved}")
2377
+ if not is_lane_worktree_path(repo, resolved):
2378
+ raise LaneConfigError(f"refusing packet worktree that is not a lane worktree: {resolved}")
2347
2379
  if wt.branch != expected_branch:
2348
2380
  raise LaneConfigError(
2349
2381
  f"refusing packet branch {wt.branch}: expected {expected_branch}"
@@ -2392,8 +2424,7 @@ def _finish_command(args) -> int:
2392
2424
  resolved = path.resolve()
2393
2425
  if resolved == main_path:
2394
2426
  continue
2395
- in_lanes_tmp = LANES_TMP.resolve() in resolved.parents
2396
- if not branch.startswith(prefix) or not in_lanes_tmp:
2427
+ if not branch.startswith(prefix) or not is_lane_worktree_path(repo, resolved):
2397
2428
  print(f"OTHER {path} {branch or 'HEAD'}")
2398
2429
  continue
2399
2430
  if branch_is_locked(branch):
@@ -118,7 +118,9 @@ def _hash(text: str) -> str:
118
118
 
119
119
  def read_relay(root: Path, name: str) -> tuple[str, str | None]:
120
120
  artifact, path = _path(root, name)
121
- if not path.resolve().is_relative_to(artifact.resolve()) or path.is_symlink():
121
+ # Only a relay that EXISTS can point elsewhere; resolving one that does not exist yet raced a concurrent writer
122
+ # creating its folder (see caddis_todo._path, #103).
123
+ if path.is_symlink() or (path.exists() and not path.resolve().is_relative_to(artifact.resolve())):
122
124
  raise ValueError("relay path escapes artifact root")
123
125
  try:
124
126
  text = path.read_bytes().decode("utf-8")
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.149",
2
+ "poolVersion": "1.3.150",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.149",
4
+ "antigravity-plugin": "1.3.150",
5
5
  "antigravity-plugin-extras": "1.3.45",
6
6
  "codex": "unknown"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.63",
3
+ "version": "0.4.64",
4
4
  "description": "Install, update and diagnose the caddis dev harness across every coding agent you use (Claude Code, agy/Antigravity, and more).",
5
5
  "keywords": [
6
6
  "caddis",