@caddis/cli 0.4.57 → 0.4.59
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 +45 -11
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +69 -23
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +9 -8
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +4 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +22 -2
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +14 -8
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +26 -9
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +24 -4
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +120 -10
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +45 -11
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +14 -9
- package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +12 -0
- package/bundles/antigravity-plugin/scripts/hook_log.py +13 -2
- package/bundles/antigravity-plugin/scripts/secret_filter.py +6 -2
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +16 -6
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +3 -0
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +3 -2
- package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +52 -175
- package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +52 -175
- package/bundles/manifest.json +2 -2
- package/dist/cli.js +310 -46
- package/package.json +1 -1
|
@@ -329,7 +329,12 @@ def ram_facts():
|
|
|
329
329
|
return None
|
|
330
330
|
|
|
331
331
|
|
|
332
|
-
def
|
|
332
|
+
def _safe_session_id(session_id) -> str:
|
|
333
|
+
"""A file-name-safe form of a session id (letters, digits, - and _ only, 64 chars at most); "" when empty."""
|
|
334
|
+
return "".join(ch if (ch.isalnum() and ch.isascii()) or ch in "-_" else "_" for ch in str(session_id or ""))[:64].strip(".")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def record_context(cwd: str, pct: float, session_id: str = "") -> None:
|
|
333
338
|
"""Cache the runtime context figure for tools that never see the statusline payload.
|
|
334
339
|
|
|
335
340
|
Claude Code hands `context_window.used_percentage` to the statusline and to NOTHING else.
|
|
@@ -342,6 +347,11 @@ def record_context(cwd: str, pct: float) -> None:
|
|
|
342
347
|
render. Only writes where `.caddis/` already exists, so cd-ing into an unrelated folder
|
|
343
348
|
never scatters artifact dirs. Fail-open and silent: a status line must never break a
|
|
344
349
|
prompt, and this is the least important thing it does.
|
|
350
|
+
|
|
351
|
+
One folder can host several sessions, so the figure is also kept per session, in
|
|
352
|
+
`.caddis/context-window/<session id>.json`; a reader that knows its session id reads that file and
|
|
353
|
+
never another session's figure. `context-window.json` stays as the shared "last writer" for a reader
|
|
354
|
+
that does not know its session (robustness #19). Per-session files older than 2 days are removed.
|
|
345
355
|
"""
|
|
346
356
|
try:
|
|
347
357
|
if not cwd:
|
|
@@ -349,20 +359,44 @@ def record_context(cwd: str, pct: float) -> None:
|
|
|
349
359
|
art = os.path.join(cwd, ".caddis")
|
|
350
360
|
if not os.path.isdir(art):
|
|
351
361
|
return
|
|
352
|
-
target = os.path.join(art, "context-window.json")
|
|
353
362
|
payload = {"used_percentage": round(float(pct), 1), "at": int(time.time())}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
363
|
+
sid = _safe_session_id(session_id)
|
|
364
|
+
if sid:
|
|
365
|
+
per_dir = os.path.join(art, "context-window")
|
|
366
|
+
os.makedirs(per_dir, exist_ok=True)
|
|
367
|
+
_write_if_changed(os.path.join(per_dir, sid + ".json"), payload)
|
|
368
|
+
try:
|
|
369
|
+
cutoff = time.time() - 2 * 86400
|
|
370
|
+
for name in os.listdir(per_dir):
|
|
371
|
+
old = os.path.join(per_dir, name)
|
|
372
|
+
if os.path.getmtime(old) < cutoff:
|
|
373
|
+
os.unlink(old)
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
_write_if_changed(os.path.join(art, "context-window.json"), payload)
|
|
377
|
+
except Exception:
|
|
378
|
+
pass
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _write_if_changed(target: str, payload: dict) -> None:
|
|
382
|
+
"""Write the JSON `payload` atomically (a temp file named for this process), only when the figure changed."""
|
|
383
|
+
try:
|
|
384
|
+
with open(target, encoding="utf-8") as fh:
|
|
385
|
+
if json.load(fh).get("used_percentage") == payload["used_percentage"]:
|
|
386
|
+
return
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
tmp = f"{target}.{os.getpid()}.tmp"
|
|
390
|
+
try:
|
|
361
391
|
with open(tmp, "w", encoding="utf-8") as fh:
|
|
362
392
|
json.dump(payload, fh)
|
|
363
393
|
os.replace(tmp, target)
|
|
364
394
|
except Exception:
|
|
365
|
-
|
|
395
|
+
try:
|
|
396
|
+
os.unlink(tmp)
|
|
397
|
+
except OSError:
|
|
398
|
+
pass
|
|
399
|
+
raise
|
|
366
400
|
|
|
367
401
|
|
|
368
402
|
def relay_present(cwd: str) -> bool:
|
|
@@ -501,7 +535,7 @@ def render_claude(data: dict, cfg: dict) -> str:
|
|
|
501
535
|
used = as_int(cw.get("total_input_tokens"))
|
|
502
536
|
max_tokens = as_int(cw.get("context_window_size"))
|
|
503
537
|
pct = float(as_int(cw.get("used_percentage")))
|
|
504
|
-
record_context(cwd, pct) # feeds /caddis:spawn-session's 95% gate; see record_context
|
|
538
|
+
record_context(cwd, pct, str(dig(data, "session_id") or "")) # feeds /caddis:spawn-session's 95% gate; see record_context
|
|
505
539
|
parts.append(context_segment(c, cfg, used, max_tokens, pct))
|
|
506
540
|
loud = cached_segment(c, cfg, cached)
|
|
507
541
|
if loud:
|
|
@@ -12,6 +12,7 @@ import ast
|
|
|
12
12
|
import json
|
|
13
13
|
import os
|
|
14
14
|
import re
|
|
15
|
+
import secrets
|
|
15
16
|
import shutil
|
|
16
17
|
import subprocess
|
|
17
18
|
import sys
|
|
@@ -131,7 +132,8 @@ def slugify_prompt(prompt: str) -> str:
|
|
|
131
132
|
if not slug:
|
|
132
133
|
slug = "task"
|
|
133
134
|
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
134
|
-
|
|
135
|
+
# Random suffix: two runs on the same day and topic must never share a branch, plan or run dir.
|
|
136
|
+
return f"{date_str}-{slug}-{secrets.token_hex(3)}"
|
|
135
137
|
|
|
136
138
|
|
|
137
139
|
def synthesize_plan(
|
|
@@ -168,18 +170,73 @@ Task requirements are met, existing tests pass, and new test assertions verify t
|
|
|
168
170
|
return plan_path, slug, title
|
|
169
171
|
|
|
170
172
|
|
|
173
|
+
def session_key() -> str:
|
|
174
|
+
"""Id of this agent session, safe in a file name. One checkout can host several sessions."""
|
|
175
|
+
for var in ("CADDIS_SESSION_ID", "CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID"):
|
|
176
|
+
raw = os.environ.get(var, "").strip()
|
|
177
|
+
key = re.sub(r"[^A-Za-z0-9_-]", "-", raw)[:64].strip("-")
|
|
178
|
+
if key:
|
|
179
|
+
return key
|
|
180
|
+
return "nosession"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _adhoc_dir(repo: Path) -> Path:
|
|
184
|
+
return repo / ".caddis" / "orchestrator" / "adhoc"
|
|
185
|
+
|
|
186
|
+
|
|
171
187
|
def adhoc_latest_path(repo: Path) -> Path:
|
|
172
|
-
return repo / "
|
|
188
|
+
return _adhoc_dir(repo) / f"latest-{session_key()}.json"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def write_latest(repo: Path, state: dict) -> None:
|
|
192
|
+
path = adhoc_latest_path(repo)
|
|
193
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
path.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
|
|
173
195
|
|
|
174
196
|
|
|
175
197
|
def read_latest(repo: Path) -> dict | None:
|
|
176
198
|
path = adhoc_latest_path(repo)
|
|
177
199
|
if not path.is_file():
|
|
178
|
-
|
|
200
|
+
# Migration: the old single latest.json belongs to whoever reads it first. The rename is
|
|
201
|
+
# atomic, so exactly one session adopts it and no other session ever sees it.
|
|
202
|
+
legacy = _adhoc_dir(repo) / "latest.json"
|
|
203
|
+
if not legacy.is_file():
|
|
204
|
+
return None
|
|
205
|
+
try:
|
|
206
|
+
os.replace(legacy, path)
|
|
207
|
+
except OSError:
|
|
208
|
+
return None
|
|
179
209
|
try:
|
|
180
|
-
|
|
210
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
181
211
|
except (OSError, json.JSONDecodeError):
|
|
182
212
|
return None
|
|
213
|
+
return data if isinstance(data, dict) else None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def move_branch(repo: Path, branch: str, commit_sha: str, base_sha: str) -> str:
|
|
217
|
+
"""Point `branch` at `commit_sha`. Return "" on success, else why it was refused.
|
|
218
|
+
|
|
219
|
+
An existing branch is moved only when its tip is merged into the base, equals the new commit, or
|
|
220
|
+
is the tip this session recorded for it. Otherwise it holds work that moving would orphan.
|
|
221
|
+
"""
|
|
222
|
+
tip = git_output(repo, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}")
|
|
223
|
+
if tip and tip != commit_sha:
|
|
224
|
+
merged = subprocess.run(
|
|
225
|
+
["git", "-C", str(repo), "merge-base", "--is-ancestor", tip, base_sha],
|
|
226
|
+
capture_output=True,
|
|
227
|
+
).returncode == 0
|
|
228
|
+
state = read_latest(repo) or {}
|
|
229
|
+
own = state.get("branch") == branch and state.get("commit_sha") == tip
|
|
230
|
+
if not merged and not own:
|
|
231
|
+
return (f"branch '{branch}' has commits not merged into {base_sha[:7]} "
|
|
232
|
+
f"(tip {tip[:7]}); refusing to move it. Delete or merge it first.")
|
|
233
|
+
res = subprocess.run(
|
|
234
|
+
["git", "-C", str(repo), "branch", "-f", branch, commit_sha],
|
|
235
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
236
|
+
)
|
|
237
|
+
if res.returncode != 0:
|
|
238
|
+
return f"branch update failed: {res.stderr.strip()}"
|
|
239
|
+
return ""
|
|
183
240
|
|
|
184
241
|
|
|
185
242
|
# Tool caches every gate run regenerates. They are the only ignored files a worktree may be
|
|
@@ -355,7 +412,7 @@ def run_adhoc(
|
|
|
355
412
|
}
|
|
356
413
|
if len(kept_paths) > 1:
|
|
357
414
|
state["kept_worktrees"] = [str(path) for path in kept_paths]
|
|
358
|
-
|
|
415
|
+
write_latest(repo, state)
|
|
359
416
|
return EXIT_FAIL
|
|
360
417
|
|
|
361
418
|
worktree_str = packet.get("worktree")
|
|
@@ -468,20 +525,14 @@ def run_adhoc(
|
|
|
468
525
|
|
|
469
526
|
if not reason:
|
|
470
527
|
try:
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
474
|
-
)
|
|
475
|
-
if branch_res.returncode != 0:
|
|
476
|
-
reason = f"branch update failed: {branch_res.stderr.strip()}"
|
|
477
|
-
else:
|
|
528
|
+
reason = move_branch(repo, adhoc_branch, commit_sha, base_sha)
|
|
529
|
+
if not reason:
|
|
478
530
|
# An OSError from the helper (git not runnable) lands in the `except OSError` below.
|
|
479
531
|
problem = caddis_worktree_safety.safe_remove_worktree(repo, wt_path)
|
|
480
532
|
if problem or wt_path.exists():
|
|
481
533
|
reason = problem or f"worktree remove failed: {wt_path}"
|
|
482
534
|
else:
|
|
483
|
-
kept_worktree = None
|
|
484
|
-
subprocess.run(["git", "-C", str(repo), "worktree", "prune"], capture_output=True)
|
|
535
|
+
kept_worktree = None # `git worktree remove` dropped its registration; no prune (robustness #19)
|
|
485
536
|
attempt_branch = packet.get("branch")
|
|
486
537
|
if attempt_branch and attempt_branch != adhoc_branch:
|
|
487
538
|
subprocess.run(
|
|
@@ -503,7 +554,7 @@ def run_adhoc(
|
|
|
503
554
|
diff_stat = git_output(repo, "diff", "--shortstat", base_sha, diff_ref)
|
|
504
555
|
diff_names = verified_names or changed_files
|
|
505
556
|
|
|
506
|
-
# Save state
|
|
557
|
+
# Save this session's state (latest-<session>.json)
|
|
507
558
|
state = {
|
|
508
559
|
"slug": slug,
|
|
509
560
|
"title": title,
|
|
@@ -525,7 +576,7 @@ def run_adhoc(
|
|
|
525
576
|
"plan_path": plan_rel,
|
|
526
577
|
"created": datetime.now(timezone.utc).isoformat(),
|
|
527
578
|
}
|
|
528
|
-
|
|
579
|
+
write_latest(repo, state)
|
|
529
580
|
|
|
530
581
|
# Render Verdict Card
|
|
531
582
|
is_success = not reason and kept_worktree is None
|
|
@@ -618,13 +669,8 @@ def drop_adhoc(repo: Path) -> int:
|
|
|
618
669
|
if problem:
|
|
619
670
|
print(f"[mmr] Could not remove kept worktree: {problem}")
|
|
620
671
|
return EXIT_FAIL
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
624
|
-
)
|
|
625
|
-
if pruned.returncode != 0:
|
|
626
|
-
print(f"[mmr] Could not prune worktrees: {pruned.stderr.strip()}")
|
|
627
|
-
return EXIT_FAIL
|
|
672
|
+
# No `git worktree prune` here: it drops the registrations of every other worktree whose folder is missing
|
|
673
|
+
# for the moment, and `git worktree remove` has already dropped this one (robustness #19).
|
|
628
674
|
|
|
629
675
|
if branch:
|
|
630
676
|
subprocess.run(["git", "-C", str(repo), "branch", "-D", branch], capture_output=True)
|
|
@@ -209,11 +209,10 @@ def _remove(main: Path, worktree: Path, branch: str) -> None:
|
|
|
209
209
|
caddis_worktree_safety.safe_remove_worktree(main, worktree, force_locked=True, env=roadmap._quiet_env())
|
|
210
210
|
except OSError:
|
|
211
211
|
pass
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
pass
|
|
212
|
+
try: # no `git worktree prune`: it would drop other tools' registrations too (robustness #19)
|
|
213
|
+
_git(main, "branch", "-D", branch, check=False)
|
|
214
|
+
except (roadmap.RoadmapError, OSError):
|
|
215
|
+
pass
|
|
217
216
|
if worktree.exists():
|
|
218
217
|
caddis_worktree_safety.remove_tree(worktree)
|
|
219
218
|
|
|
@@ -234,6 +233,7 @@ def finish(worktree: Path, title: str, body: str, out=print) -> int:
|
|
|
234
233
|
out("PR: none")
|
|
235
234
|
return caddis_exit.CLEAN
|
|
236
235
|
pushed = False
|
|
236
|
+
pushed_tip = ""
|
|
237
237
|
try:
|
|
238
238
|
gh_repo = roadmap.gh_repo_of(main)
|
|
239
239
|
if gh_repo is None:
|
|
@@ -242,14 +242,15 @@ def finish(worktree: Path, title: str, body: str, out=print) -> int:
|
|
|
242
242
|
_git(worktree, "commit", "--quiet", "-m", title)
|
|
243
243
|
_git(worktree, "push", "--quiet", "-u", "origin", branch)
|
|
244
244
|
pushed = True
|
|
245
|
+
pushed_tip = _git(worktree, "rev-parse", "HEAD").stdout.strip() # the only tip we may delete
|
|
245
246
|
url = open_pr(gh_repo, branch, base, title, body)
|
|
246
247
|
except (DocsPrError, roadmap.RoadmapError, OSError) as exc:
|
|
247
248
|
out(f"docs PR failed: {exc}")
|
|
248
249
|
out(f"PATCH: {patch}")
|
|
249
250
|
if pushed:
|
|
250
|
-
try:
|
|
251
|
-
_git(main,
|
|
252
|
-
except (roadmap.RoadmapError, OSError):
|
|
251
|
+
try: # only while the remote tip is still the commit we pushed
|
|
252
|
+
_git(main, *caddis_worktree_safety.remote_delete_args(branch, pushed_tip), check=False)
|
|
253
|
+
except (roadmap.RoadmapError, OSError, ValueError):
|
|
253
254
|
pass
|
|
254
255
|
_remove(main, worktree, branch)
|
|
255
256
|
return getattr(exc, "code", caddis_exit.NOT_RUN)
|
|
@@ -275,7 +275,6 @@ def remove_phase_worktree(
|
|
|
275
275
|
problem = f"worktree remove left {resolved} on disk"
|
|
276
276
|
if problem:
|
|
277
277
|
raise LaneConfigError(problem)
|
|
278
|
-
_git_output(repo_root, "worktree", "prune")
|
|
279
278
|
if delete_branch:
|
|
280
279
|
_git_output(repo_root, "branch", "-D", "--", wt.branch)
|
|
281
280
|
|
|
@@ -1266,7 +1265,10 @@ def child_env(base: dict, lane: str, model: str) -> dict:
|
|
|
1266
1265
|
for name in LANE_SECRET_VARS:
|
|
1267
1266
|
env.pop(name, None)
|
|
1268
1267
|
env.update(LANE_NO_PROMPT_ENV)
|
|
1269
|
-
|
|
1268
|
+
try:
|
|
1269
|
+
env = caddis_spend.bump_headless_depth(env) # sets CADDIS_HEADLESS=1 and the depth (robustness #19)
|
|
1270
|
+
except caddis_spend.HeadlessDepthExceeded as exc:
|
|
1271
|
+
raise LaneConfigError(str(exc)) from exc
|
|
1270
1272
|
if lane == "glm":
|
|
1271
1273
|
env["OSS_MODEL"] = model
|
|
1272
1274
|
return env
|
|
@@ -684,6 +684,16 @@ def launch_claude(prompt: str, worktree: Path, out: Path) -> str:
|
|
|
684
684
|
# there is no sandbox on native Windows. The diff comes in the prompt (`_diff_material`).
|
|
685
685
|
# No `--allowedTools`: unscoped, it approves files outside the worktree (smoke run 18 Oct 2026: without it a
|
|
686
686
|
# Read in the worktree ran, a Read of ~/.caddis/spend-cap.toml was refused).
|
|
687
|
+
try:
|
|
688
|
+
import caddis_spend # the recursion guard lives beside the spend cap (robustness #19)
|
|
689
|
+
env_depth = caddis_spend.bump_headless_depth(os.environ)
|
|
690
|
+
except ImportError:
|
|
691
|
+
env_depth = None
|
|
692
|
+
except RuntimeError as exc: # HeadlessDepthExceeded
|
|
693
|
+
output = f"{_PREFIX}{exc}\n"
|
|
694
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
695
|
+
out.write_text(output, encoding="utf-8")
|
|
696
|
+
return output
|
|
687
697
|
exe = _claude_exe()
|
|
688
698
|
if exe is None:
|
|
689
699
|
output = f"{_PREFIX}claude could not start: not found on PATH (the current folder is not searched)\n"
|
|
@@ -697,11 +707,21 @@ def launch_claude(prompt: str, worktree: Path, out: Path) -> str:
|
|
|
697
707
|
governor = default_governor()
|
|
698
708
|
if governor:
|
|
699
709
|
cmd = governor + cmd
|
|
710
|
+
elif os.environ.get("CADDIS_ALLOW_UNGOVERNED") == "1": # the owner's explicit opt-out
|
|
711
|
+
print("WARNING: caddis_governor.py not found; the review runs without a CPU and memory cap "
|
|
712
|
+
"(CADDIS_ALLOW_UNGOVERNED=1)", file=sys.stderr)
|
|
700
713
|
else:
|
|
701
|
-
|
|
714
|
+
# The plan reviewer needs the governor, as the roadmap runner does (robustness #19): an ungoverned
|
|
715
|
+
# `claude -p` can fill the machine's CPU and memory, and a review that cannot run is only NOT_RUN.
|
|
716
|
+
output = (f"{_PREFIX}caddis_governor.py not found, so the review is not started without a CPU and memory "
|
|
717
|
+
"cap (set CADDIS_ALLOW_UNGOVERNED=1 to run it ungoverned)\n")
|
|
718
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
719
|
+
out.write_text(output, encoding="utf-8")
|
|
720
|
+
return output
|
|
702
721
|
# CADDIS_HEADLESS keeps relay and session-end hooks quiet (claude-oss.ps1 sets it too).
|
|
703
722
|
# No program from the current folder (Windows searches it first), no git index rewrite by the reviewer's tools.
|
|
704
|
-
env = {**os.environ, "CADDIS_HEADLESS": "1",
|
|
723
|
+
env = {**(env_depth if env_depth is not None else os.environ), "CADDIS_HEADLESS": "1",
|
|
724
|
+
"CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1",
|
|
705
725
|
"NoDefaultCurrentDirectoryInExePath": "1", "GIT_OPTIONAL_LOCKS": "0"}
|
|
706
726
|
# Its own process group (Windows) or session (POSIX), so a timeout kills the whole tree, not only the top process.
|
|
707
727
|
group = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt"
|
|
@@ -148,12 +148,19 @@ OUTPUT_MASKS: list[tuple[re.Pattern, str]] = [
|
|
|
148
148
|
(re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), "***"), # secret_filter github-token
|
|
149
149
|
(re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"), "***"), # secret_filter slack-token
|
|
150
150
|
(re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"), "***"), # secret_filter jwt
|
|
151
|
-
# secret_filter assignment, widened: a quoted value may hold spaces (a passphrase of several words).
|
|
152
|
-
|
|
151
|
+
# secret_filter assignment, widened: a quoted value may hold spaces (a passphrase of several words). A match
|
|
152
|
+
# starts only at the start of a word: a match inside a word always has one from the word start too, and without
|
|
153
|
+
# the lookbehind each character of a long word started a new scan (quadratic: 50 KB of one word took 64 s).
|
|
154
|
+
(re.compile(r"(?i)(?<![A-Za-z0-9_])"
|
|
155
|
+
r"([A-Za-z0-9_]*(?:password|passwd|secret|api[_-]?key|token)[A-Za-z0-9_]*\s*[:=]\s*)"
|
|
153
156
|
r"(['\"])[^'\"\n]{8,}\2"), r"\1\2***\2"),
|
|
154
157
|
(re.compile(r"(?im)^(\s*(?:export\s+)?[A-Za-z0-9_]*(?:PASSWORD|PASSWD|SECRET|API_KEY|APIKEY|TOKEN|PRIVATE_KEY)"
|
|
155
158
|
r"[A-Za-z0-9_]*\s*=\s*)[^\s'\"#]{8,}"), r"\1***"), # secret_filter env-assignment
|
|
156
|
-
|
|
159
|
+
# URL credentials. The scheme starts at a letter after a word boundary. Every such letter in one run of scheme
|
|
160
|
+
# characters reaches the same `://`, so only the first one is tried: group 1 skips, possessively, the run's
|
|
161
|
+
# digits, `+.-` and letters that follow a word character. Without that, `a.a.a.a...` was quadratic.
|
|
162
|
+
(re.compile(r"(?<![A-Za-z0-9+.-])((?:[0-9+.-]|(?<=\w)[A-Za-z])*+)([A-Za-z][A-Za-z0-9+.-]*://)"
|
|
163
|
+
r"[^/\s:@]+:[^/\s@]+@"), r"\1\2***@"),
|
|
157
164
|
# Absolute paths, which can name a user or a host. A path may hold spaces, so each one runs to the end of the
|
|
158
165
|
# line or a quote; a Windows drive or UNC path also stops at `<`, `>` or `|` (never part of a Windows path).
|
|
159
166
|
(re.compile(r"(?<![A-Za-z0-9])[A-Za-z]:[\\/][^\r\n'\"<>|]*"), "<path>"), # a Windows drive path
|
|
@@ -1072,7 +1079,6 @@ def merged_checkout(root: Path, branch: str):
|
|
|
1072
1079
|
yield worktree
|
|
1073
1080
|
finally:
|
|
1074
1081
|
caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
|
|
1075
|
-
_git(root, "worktree", "prune", check=False)
|
|
1076
1082
|
caddis_worktree_safety.remove_tree(tmp) # links first: a junction's target survives
|
|
1077
1083
|
|
|
1078
1084
|
|
|
@@ -1822,11 +1828,12 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
|
|
|
1822
1828
|
_git(worktree, "add", "--", path)
|
|
1823
1829
|
_git(worktree, "commit", "--quiet", "-m", title)
|
|
1824
1830
|
_git(worktree, "push", "--quiet", "-u", "origin", branch)
|
|
1831
|
+
pushed_tip = _git(worktree, "rev-parse", "HEAD").stdout.strip() # the tip we pushed: the only one we delete
|
|
1825
1832
|
try:
|
|
1826
1833
|
pr = open_pr(root, gh_repo, branch, snap.branch, title,
|
|
1827
1834
|
f"Roadmap change by caddis_roadmap.py: {slug} {mid} {change}.")
|
|
1828
1835
|
except Exception:
|
|
1829
|
-
_git(root,
|
|
1836
|
+
_git(root, *caddis_worktree_safety.remote_delete_args(branch, pushed_tip), check=False)
|
|
1830
1837
|
raise
|
|
1831
1838
|
try:
|
|
1832
1839
|
rc = merge_pr(root, pr, gh_repo)
|
|
@@ -1834,13 +1841,12 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
|
|
|
1834
1841
|
merge_exit = "timeout"
|
|
1835
1842
|
raise
|
|
1836
1843
|
out(f"#{pr} {title}: " + ("merged" if rc == caddis_exit.CLEAN else f"not merged (merge chain exit {rc})"))
|
|
1837
|
-
if rc == caddis_exit.CLEAN:
|
|
1838
|
-
_git(root,
|
|
1844
|
+
if rc == caddis_exit.CLEAN: # only while the remote tip is still the commit we pushed
|
|
1845
|
+
_git(root, *caddis_worktree_safety.remote_delete_args(branch, pushed_tip), check=False)
|
|
1839
1846
|
else:
|
|
1840
1847
|
rc = caddis_exit.NOT_RUN if rc == caddis_exit.NOT_RUN else caddis_exit.BLOCKED
|
|
1841
1848
|
finally:
|
|
1842
1849
|
caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
|
|
1843
|
-
_git(root, "worktree", "prune", check=False)
|
|
1844
1850
|
_git(root, "branch", "-D", branch, check=False)
|
|
1845
1851
|
caddis_worktree_safety.remove_tree(tmp)
|
|
1846
1852
|
_audit(root, slug, {"utc": _utc(), "roadmap": slug, "id": mid, "change": change, "branch": branch,
|
|
@@ -200,6 +200,8 @@ def headless_env(base: dict | None = None) -> dict:
|
|
|
200
200
|
console, so every session and every caddis_roadmap.py call the runner starts carries the marker."""
|
|
201
201
|
env = dict(os.environ if base is None else base)
|
|
202
202
|
env.update(HEADLESS_ENV)
|
|
203
|
+
# One level deeper for every session it starts. main() refuses to start past the limit (robustness #19).
|
|
204
|
+
env[caddis_spend.HEADLESS_DEPTH_VAR] = str(caddis_spend.headless_depth(env) + 1)
|
|
203
205
|
return env
|
|
204
206
|
|
|
205
207
|
|
|
@@ -223,12 +225,19 @@ def runner_env() -> dict:
|
|
|
223
225
|
|
|
224
226
|
|
|
225
227
|
def build_command(engine: str, model: str, prompt: str, *, usd: float, timeout_min: float,
|
|
226
|
-
governor: Sequence[str], engine_cmd: Sequence[str] | None = None
|
|
228
|
+
governor: Sequence[str], engine_cmd: Sequence[str] | None = None,
|
|
229
|
+
worktree: Path | str | None = None) -> list[str]:
|
|
227
230
|
"""The full command line for one session: the governor prefix, then the engine's print-mode call.
|
|
228
231
|
|
|
229
232
|
`claude` gets its prompt on stdin, not as an argument (PROMPT_ON_STDIN): on Windows `claude` is an npm `.cmd`
|
|
230
233
|
shim, and cmd.exe cuts a multi-line argument at its first newline, which drops the rest of the prompt and every
|
|
231
|
-
argument after it.
|
|
234
|
+
argument after it.
|
|
235
|
+
|
|
236
|
+
Every engine gets its permission mode on the command line (robustness #19), none is left to a settings file or
|
|
237
|
+
a default that a new version may change: `claude` bypassPermissions (with the deny rules), `agy` accept-edits
|
|
238
|
+
plus --dangerously-skip-permissions (as the lanes do; a print session cannot answer a prompt), `codex` the
|
|
239
|
+
workspace-write sandbox confined to `worktree` with `-C` (as the lanes do; the session's own folder, default
|
|
240
|
+
the current one). The boundary is the same for all three: no publish token (session_env) and the guard."""
|
|
232
241
|
if engine not in ENGINES:
|
|
233
242
|
raise ValueError(f"unknown engine {engine!r}; known: {', '.join(ENGINES)}")
|
|
234
243
|
exe = list(engine_cmd) if engine_cmd else [engine]
|
|
@@ -240,10 +249,10 @@ def build_command(engine: str, model: str, prompt: str, *, usd: float, timeout_m
|
|
|
240
249
|
tail = [*exe, "-p", "--disallowedTools", *SESSION_DENY_RULES, "--permission-mode", "bypassPermissions",
|
|
241
250
|
"--model", model, "--output-format", "json", "--max-budget-usd", _usd(usd)]
|
|
242
251
|
elif engine == "agy": # probe 5: --model must come before --print
|
|
243
|
-
tail = [*exe, "--model", model, "--
|
|
244
|
-
"--print-timeout", f"{max(1, int(timeout_min))}m"]
|
|
252
|
+
tail = [*exe, "--model", model, "--mode", "accept-edits", "--dangerously-skip-permissions",
|
|
253
|
+
"--print", prompt, "--output-format", "json", "--print-timeout", f"{max(1, int(timeout_min))}m"]
|
|
245
254
|
else:
|
|
246
|
-
tail = [*exe, "exec", "--model", model, "--json", prompt]
|
|
255
|
+
tail = [*exe, "exec", "--model", model, "-s", "workspace-write", "-C", str(worktree or "."), "--json", prompt]
|
|
247
256
|
return [*governor, *tail]
|
|
248
257
|
|
|
249
258
|
|
|
@@ -888,7 +897,7 @@ def _one_session(ctx: _Ctx, prompt: str, relay_slug: str, n: int, spent: float,
|
|
|
888
897
|
remaining_s = max(1.0, min(caps.session_timeout_min * 60, caps.max_hours * 3600 - elapsed))
|
|
889
898
|
usd = min(caps.max_usd_per_session, caps.max_usd_total - spent)
|
|
890
899
|
cmd = build_command(ctx.engine, ctx.model, prompt, usd=usd, timeout_min=remaining_s / 60, governor=ctx.governor,
|
|
891
|
-
engine_cmd=ctx.engine_cmd)
|
|
900
|
+
engine_cmd=ctx.engine_cmd, worktree=cwd)
|
|
892
901
|
rid = _spend_reserve(ctx.home, spend_slug, ctx.now(), n > 1) if ctx.engine == "agy" else None
|
|
893
902
|
launched, outcome = ctx.clock(), None
|
|
894
903
|
try:
|
|
@@ -1273,7 +1282,8 @@ def _render_page(ctx: "_Ctx", roadmap_cmd: Sequence[str]) -> None:
|
|
|
1273
1282
|
def _dry_run_tail(ctx: _Ctx, template: Path, prompt: str, cwd: Path | None = None) -> None:
|
|
1274
1283
|
caps = ctx.caps
|
|
1275
1284
|
cmd = build_command(ctx.engine, ctx.model, "<resume prompt>", usd=min(caps.max_usd_per_session, caps.max_usd_total),
|
|
1276
|
-
timeout_min=caps.session_timeout_min, governor=ctx.governor, engine_cmd=ctx.engine_cmd
|
|
1285
|
+
timeout_min=caps.session_timeout_min, governor=ctx.governor, engine_cmd=ctx.engine_cmd,
|
|
1286
|
+
worktree=cwd or ctx.repo)
|
|
1277
1287
|
print(f"would launch (cwd {cwd or ctx.repo}, CADDIS_HEADLESS=1): {subprocess.list2cmdline(cmd)}"
|
|
1278
1288
|
+ (" (resume prompt on stdin)" if ctx.engine in PROMPT_ON_STDIN else ""))
|
|
1279
1289
|
print(f"resume prompt from {template}:\n{prompt}")
|
|
@@ -1552,7 +1562,10 @@ def ensure_worktree(repo: Path, slug: str, default: str, root: Path | None = Non
|
|
|
1552
1562
|
if path.resolve() in _registered_worktrees(repo):
|
|
1553
1563
|
if path.is_dir():
|
|
1554
1564
|
return path
|
|
1555
|
-
|
|
1565
|
+
# Listed, but its folder is gone (temp cleanup): drop this one registration and make it again. Not
|
|
1566
|
+
# `git worktree prune`: that would drop every other tool's registration with a missing folder too.
|
|
1567
|
+
if caddis_worktree_safety.safe_remove_worktree(repo, path) is not None:
|
|
1568
|
+
raise _Stop(f"FAILED cannot drop the registration of the missing worktree {path}", caddis_exit.BLOCKED)
|
|
1556
1569
|
if path.exists():
|
|
1557
1570
|
raise _Stop(f"FAILED worktree path {path} exists and is not a worktree of this repo", caddis_exit.BLOCKED)
|
|
1558
1571
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -1586,7 +1599,6 @@ def remove_worktree_if_clean(repo: Path, slug: str, root: Path | None = None) ->
|
|
|
1586
1599
|
return f"worktree kept (not clean, {len(work)} change(s)): {path}"
|
|
1587
1600
|
if caddis_worktree_safety.safe_remove_worktree(repo, path) is not None:
|
|
1588
1601
|
return f"worktree kept (remove failed): {path}"
|
|
1589
|
-
_git_out(repo, "worktree", "prune")
|
|
1590
1602
|
_state_file(repo, slug, "worktree").unlink(missing_ok=True)
|
|
1591
1603
|
return f"worktree removed: {path}"
|
|
1592
1604
|
|
|
@@ -2660,6 +2672,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2660
2672
|
ok, why = last_stop_restartable(args.repo, args.roadmap)
|
|
2661
2673
|
print(why)
|
|
2662
2674
|
return caddis_exit.CLEAN if ok else caddis_exit.BLOCKED
|
|
2675
|
+
try:
|
|
2676
|
+
caddis_spend.bump_headless_depth(os.environ) # a check only: the sessions get the bump in headless_env
|
|
2677
|
+
except caddis_spend.HeadlessDepthExceeded as exc:
|
|
2678
|
+
print(f"caddis_roadmap_run: {exc}", file=sys.stderr)
|
|
2679
|
+
return caddis_exit.BLOCKED
|
|
2663
2680
|
caps = Caps(args.max_hours, args.max_sessions, args.session_timeout_min, args.max_usd_per_session,
|
|
2664
2681
|
args.max_usd_total)
|
|
2665
2682
|
return run(repo=args.repo, plan=args.plan, roadmap=args.roadmap, caps=caps, engine=args.engine, model=args.model,
|
|
@@ -258,14 +258,32 @@ CONTEXT_NOTE_ABOVE = 85.0
|
|
|
258
258
|
CONTEXT_MAX_AGE_S = 900
|
|
259
259
|
|
|
260
260
|
|
|
261
|
-
|
|
261
|
+
SESSION_ID_ENV = ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CADDIS_SESSION_ID")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _session_id(explicit: str | None = None) -> str:
|
|
265
|
+
"""This session's id: the explicit one, else the first of SESSION_ID_ENV that is set. Made file-name safe."""
|
|
266
|
+
raw = explicit or next((os.environ[v] for v in SESSION_ID_ENV if os.environ.get(v)), "")
|
|
267
|
+
return "".join(ch if (ch.isalnum() and ch.isascii()) or ch in "-_" else "_" for ch in raw)[:64].strip(".")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def read_context_pct(repo_root: Path, session_id: str | None = None) -> tuple[float | None, str]:
|
|
262
271
|
"""(percentage, provenance) from the statusline's cache, or (None, why not).
|
|
263
272
|
|
|
273
|
+
With a session id (the argument, or CLAUDE_SESSION_ID and the like) it reads that session's own file,
|
|
274
|
+
`.caddis/context-window/<id>.json`, so a second session in the same folder cannot change the figure.
|
|
275
|
+
Without one it reads the shared `context-window.json`, which any session may have written last; the
|
|
276
|
+
provenance then says "shared".
|
|
277
|
+
|
|
264
278
|
Written by `caddis_statusline.record_context`. Claude Code gives the figure to the status
|
|
265
279
|
line and to nothing else, so this file is the only runtime-sourced number a plain command
|
|
266
280
|
can reach.
|
|
267
281
|
"""
|
|
268
|
-
|
|
282
|
+
sid = _session_id(session_id)
|
|
283
|
+
shared = repo_root / ART / "context-window.json"
|
|
284
|
+
target = repo_root / ART / "context-window" / f"{sid}.json" if sid else shared
|
|
285
|
+
if sid and not target.is_file():
|
|
286
|
+
target, sid = shared, "" # the status line has not written this session's file yet (or is old)
|
|
269
287
|
try:
|
|
270
288
|
raw = json.loads(target.read_text(encoding="utf-8"))
|
|
271
289
|
pct = float(raw["used_percentage"])
|
|
@@ -275,8 +293,10 @@ def read_context_pct(repo_root: Path) -> tuple[float | None, str]:
|
|
|
275
293
|
+ f"{ART}/context-window.json — run `/caddis:install-statusline` to install the "
|
|
276
294
|
"status line that writes it")
|
|
277
295
|
if age > CONTEXT_MAX_AGE_S:
|
|
278
|
-
|
|
279
|
-
|
|
296
|
+
scope = "this session's" if sid else "shared (session unknown)"
|
|
297
|
+
return pct, f"{scope} runtime figure, STALE by {age}s (a lower bound — the real number is >= this)"
|
|
298
|
+
scope = "this session's" if sid else "shared (session unknown)"
|
|
299
|
+
return pct, f"runtime figure, {scope}, {age}s old"
|
|
280
300
|
|
|
281
301
|
|
|
282
302
|
@dataclass
|