@caddis/cli 0.4.57 → 0.4.58
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 +4 -5
- 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/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
|
@@ -38,8 +38,10 @@ CLI:
|
|
|
38
38
|
python caddis_spend.py status prints the committed spend of the last 24 h
|
|
39
39
|
python caddis_spend.py run --caller X -- agy ...
|
|
40
40
|
reserve, run, settle. Returns agy's own exit code, or 75 over the cap / could not lock,
|
|
41
|
-
or 78 not configured
|
|
42
|
-
|
|
41
|
+
or 78 not configured, or 124 when the command ran past its time limit (--timeout-s, else
|
|
42
|
+
CADDIS_SPEND_RUN_TIMEOUT_S, else 3600 s) and its process tree was killed. 75 and 78 are never
|
|
43
|
+
agy's own codes for a refusal, so a caller can tell "the cap refused" from "agy failed". Each
|
|
44
|
+
refusal also prints a `caddis_spend:` line.
|
|
43
45
|
"""
|
|
44
46
|
|
|
45
47
|
from __future__ import annotations
|
|
@@ -62,12 +64,51 @@ EXIT_OVER_CAP = 1 # `check` only
|
|
|
62
64
|
EXIT_NOT_CONFIGURED = 3 # `check` only
|
|
63
65
|
RUN_REFUSED = 75 # `run`: over the cap, or could not lock (EX_TEMPFAIL)
|
|
64
66
|
RUN_NOT_CONFIGURED = 78 # `run`: not configured (EX_CONFIG)
|
|
67
|
+
RUN_TIMEOUT = 124 # `run`: the child ran past its time limit and its process tree was killed (as timeout(1))
|
|
68
|
+
DEFAULT_RUN_TIMEOUT_S = 3600.0 # one hour: a run over about 1 hour needs the owner's approval anyway
|
|
69
|
+
# No console window for the child on Windows (robustness #19; see no_window.py: never DETACHED_PROCESS).
|
|
70
|
+
_NO_WINDOW_KW = {"creationflags": 0x08000000} if os.name == "nt" else {}
|
|
65
71
|
LOCK_TIMEOUT_S = 10.0
|
|
66
72
|
NOT_CONFIGURED_MESSAGE = ("caddis_spend: spend cap not configured: set eur_per_24h, reserve_eur_per_call and both "
|
|
67
73
|
"token prices (finite, above 0) in ~/.caddis/spend-cap.toml; every agy launch is refused "
|
|
68
74
|
"until then")
|
|
69
75
|
|
|
70
76
|
|
|
77
|
+
# Recursion guard on headless starts (robustness #19). Every launcher of a headless caddis session passes
|
|
78
|
+
# CADDIS_HEADLESS_DEPTH one higher than it got, and refuses to launch once the depth is at the limit, so a
|
|
79
|
+
# headless session that starts a headless session that starts one... ends after MAX_HEADLESS_DEPTH levels.
|
|
80
|
+
# The owner may change the limit with CADDIS_HEADLESS_MAX_DEPTH. This stops accidents and loops; a hostile
|
|
81
|
+
# process can reset the variable, like the cap file (see the Limits above).
|
|
82
|
+
HEADLESS_DEPTH_VAR = "CADDIS_HEADLESS_DEPTH"
|
|
83
|
+
MAX_HEADLESS_DEPTH = 3
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class HeadlessDepthExceeded(RuntimeError):
|
|
87
|
+
"""A headless start was refused because headless sessions are already nested too deep."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def headless_depth(env) -> int:
|
|
91
|
+
"""How many headless launches lie above this process: CADDIS_HEADLESS_DEPTH, or 0 when unset or not a count."""
|
|
92
|
+
raw = str(env.get(HEADLESS_DEPTH_VAR, "")).strip()
|
|
93
|
+
return int(raw) if raw.isdigit() else 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def bump_headless_depth(env) -> dict:
|
|
97
|
+
"""A copy of `env` for a child headless session: CADDIS_HEADLESS=1 and the depth one higher.
|
|
98
|
+
Raises HeadlessDepthExceeded when the depth is already at the limit."""
|
|
99
|
+
limit_raw = str(env.get("CADDIS_HEADLESS_MAX_DEPTH", "")).strip()
|
|
100
|
+
limit = int(limit_raw) if limit_raw.isdigit() and int(limit_raw) > 0 else MAX_HEADLESS_DEPTH
|
|
101
|
+
depth = headless_depth(env)
|
|
102
|
+
if depth >= limit:
|
|
103
|
+
raise HeadlessDepthExceeded(
|
|
104
|
+
f"caddis: headless depth {depth} is at the limit ({limit}); not starting another headless session "
|
|
105
|
+
f"({HEADLESS_DEPTH_VAR}; the owner can raise CADDIS_HEADLESS_MAX_DEPTH)")
|
|
106
|
+
out = dict(env)
|
|
107
|
+
out[HEADLESS_DEPTH_VAR] = str(depth + 1)
|
|
108
|
+
out["CADDIS_HEADLESS"] = "1"
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
71
112
|
class NotConfigured(Exception):
|
|
72
113
|
"""The cap file is missing, unreadable, or holds a value that is not a finite number above 0."""
|
|
73
114
|
|
|
@@ -317,9 +358,67 @@ def result_text(stdout: str) -> str:
|
|
|
317
358
|
return stdout
|
|
318
359
|
|
|
319
360
|
|
|
320
|
-
def
|
|
321
|
-
"""
|
|
361
|
+
def run_timeout_s() -> float:
|
|
362
|
+
"""The wall-clock limit of one `run` child: CADDIS_SPEND_RUN_TIMEOUT_S, else DEFAULT_RUN_TIMEOUT_S.
|
|
363
|
+
A value that is not a finite number above 0 is ignored."""
|
|
364
|
+
try:
|
|
365
|
+
value = float(os.environ.get("CADDIS_SPEND_RUN_TIMEOUT_S", ""))
|
|
366
|
+
except ValueError:
|
|
367
|
+
return DEFAULT_RUN_TIMEOUT_S
|
|
368
|
+
return value if math.isfinite(value) and value > 0 else DEFAULT_RUN_TIMEOUT_S
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _kill_tree(proc: subprocess.Popen) -> None:
|
|
372
|
+
"""Kill the child and everything it started (taskkill /T on Windows, the process group on POSIX)."""
|
|
373
|
+
try:
|
|
374
|
+
if os.name == "nt":
|
|
375
|
+
subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True, timeout=15,
|
|
376
|
+
**_NO_WINDOW_KW)
|
|
377
|
+
else:
|
|
378
|
+
import signal
|
|
379
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
382
|
+
try:
|
|
383
|
+
proc.kill()
|
|
384
|
+
except Exception:
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _run_child(argv: list[str], env: dict, timeout_s: float) -> tuple[int | None, str, str]:
|
|
389
|
+
"""Run `argv` with a time limit. Returns (returncode, stdout, stderr); returncode is None on a timeout, after
|
|
390
|
+
the whole process tree was killed. Raises FileNotFoundError when the program does not exist."""
|
|
391
|
+
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",
|
|
393
|
+
errors="replace", env=env, **_NO_WINDOW_KW, **group)
|
|
394
|
+
try:
|
|
395
|
+
out, err = proc.communicate(timeout=timeout_s)
|
|
396
|
+
return proc.returncode, out or "", err or ""
|
|
397
|
+
except subprocess.TimeoutExpired:
|
|
398
|
+
_kill_tree(proc)
|
|
399
|
+
try: # the pipes close once the tree is dead; do not wait for ever if a stray holder survives
|
|
400
|
+
out, err = proc.communicate(timeout=10)
|
|
401
|
+
except Exception:
|
|
402
|
+
out, err = "", ""
|
|
403
|
+
return None, out or "", err or ""
|
|
404
|
+
except BaseException:
|
|
405
|
+
# Ctrl-C or any other error while waiting: the child (agy, spending) must not outlive us. It runs in its
|
|
406
|
+
# own session/group, so it does not get our SIGINT. Kill the tree, then let the exception go on; run()
|
|
407
|
+
# settles the reserve in its `finally`.
|
|
408
|
+
_kill_tree(proc)
|
|
409
|
+
raise
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def run(argv: list[str], caller: str, home: Path, timeout_s: float | None = None) -> int:
|
|
413
|
+
"""Reserve, run agy, settle. Returns agy's exit code, or RUN_REFUSED (75) / RUN_NOT_CONFIGURED (78) /
|
|
414
|
+
RUN_TIMEOUT (124: the child ran past `timeout_s`, default run_timeout_s(); its process tree was killed and the
|
|
415
|
+
call is booked at its reserve)."""
|
|
322
416
|
now = lambda: datetime.now(timezone.utc) # noqa: E731
|
|
417
|
+
try:
|
|
418
|
+
env = bump_headless_depth(os.environ) # before the reserve: a refused start costs nothing
|
|
419
|
+
except HeadlessDepthExceeded as exc:
|
|
420
|
+
print(str(exc), file=sys.stderr)
|
|
421
|
+
return RUN_REFUSED
|
|
323
422
|
try:
|
|
324
423
|
rid = reserve(home, caller, now())
|
|
325
424
|
except NotConfigured:
|
|
@@ -334,19 +433,25 @@ def run(argv: list[str], caller: str, home: Path) -> int:
|
|
|
334
433
|
if rid is None:
|
|
335
434
|
print(f"caddis_spend: over cap: agy refused ({status_line(home, now())})", file=sys.stderr)
|
|
336
435
|
return RUN_REFUSED
|
|
337
|
-
env = {**os.environ, "CADDIS_HEADLESS": "1"}
|
|
338
436
|
out = ""
|
|
339
437
|
counts: tuple[int, int] | None = None
|
|
340
438
|
started = time.monotonic()
|
|
341
439
|
try:
|
|
342
|
-
|
|
343
|
-
out =
|
|
440
|
+
limit = run_timeout_s() if timeout_s is None else timeout_s
|
|
441
|
+
returncode, out, err = _run_child(argv, env, limit)
|
|
442
|
+
if returncode is None: # timed out: whatever it printed is passed on, but no token count is trusted
|
|
443
|
+
sys.stdout.write(out)
|
|
444
|
+
sys.stderr.write(err)
|
|
445
|
+
print(f"caddis_spend: {argv[0]} timed out after {limit:g}s; its process tree was killed "
|
|
446
|
+
f"(booked at the reserve). Set CADDIS_SPEND_RUN_TIMEOUT_S or --timeout-s to change the limit.",
|
|
447
|
+
file=sys.stderr)
|
|
448
|
+
return RUN_TIMEOUT
|
|
344
449
|
# Tokens only from JSON mode: in text mode the model's own lines could pose as a usage document.
|
|
345
450
|
if json_mode(argv):
|
|
346
451
|
counts = tokens_from_output(out)
|
|
347
452
|
sys.stdout.write(out)
|
|
348
|
-
sys.stderr.write(
|
|
349
|
-
return
|
|
453
|
+
sys.stderr.write(err)
|
|
454
|
+
return returncode
|
|
350
455
|
except FileNotFoundError:
|
|
351
456
|
counts = (0, 0) # nothing ran, nothing was spent
|
|
352
457
|
print(f"caddis_spend: command not found: {argv[0]}", file=sys.stderr)
|
|
@@ -380,6 +485,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
380
485
|
sub.add_parser("status")
|
|
381
486
|
p_run = sub.add_parser("run")
|
|
382
487
|
p_run.add_argument("--caller", required=True)
|
|
488
|
+
p_run.add_argument("--timeout-s", type=float, default=None,
|
|
489
|
+
help="kill the command's process tree after this many seconds "
|
|
490
|
+
"(default: CADDIS_SPEND_RUN_TIMEOUT_S, else 3600)")
|
|
383
491
|
args = parser.parse_args(raw)
|
|
384
492
|
home = Path.home()
|
|
385
493
|
now = datetime.now(timezone.utc)
|
|
@@ -408,7 +516,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
408
516
|
return EXIT_OK
|
|
409
517
|
if not tail:
|
|
410
518
|
parser.error("run needs the agy command after --")
|
|
411
|
-
|
|
519
|
+
if args.timeout_s is not None and not (math.isfinite(args.timeout_s) and args.timeout_s > 0):
|
|
520
|
+
parser.error("--timeout-s must be a number above 0")
|
|
521
|
+
return run(tail, args.caller, home, timeout_s=args.timeout_s)
|
|
412
522
|
|
|
413
523
|
|
|
414
524
|
if __name__ == "__main__":
|
|
@@ -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:
|
|
@@ -294,17 +294,22 @@ def close_worktree(
|
|
|
294
294
|
# Someone pushed after the merge: that work is not in main. Keep the remote branch.
|
|
295
295
|
print(f"kept origin/{branch_name}: its tip {remote_tip[:12]} is not contained in the merged PR head {str(head_ref_oid)[:12]}")
|
|
296
296
|
elif remote_tip:
|
|
297
|
-
|
|
297
|
+
# The check above is followed by a compare-and-delete on the server: a push that lands in between wins.
|
|
298
|
+
cmd_push_del = ["git", "-C", str(repo), *caddis_worktree_safety.remote_delete_args(branch_name, remote_tip)]
|
|
298
299
|
proc = subprocess.run(cmd_push_del, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
299
300
|
if proc.returncode != 0:
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
301
|
+
# A push that landed after our check makes the server refuse the compare-and-delete. That is the same
|
|
302
|
+
# outcome as the "kept" path above, not an error: look at the remote again.
|
|
303
|
+
again = subprocess.run(cmd_ls, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
304
|
+
new_tip = again.stdout.split()[0] if again.returncode == 0 and again.stdout.split() else ""
|
|
305
|
+
if new_tip and new_tip != remote_tip:
|
|
306
|
+
print(f"kept origin/{branch_name}: its tip moved from {remote_tip[:12]} to {new_tip[:12]} while it was being deleted")
|
|
307
|
+
else:
|
|
308
|
+
print(f"failed command: {' '.join(cmd_push_del)}\n{proc.stderr.strip() or proc.stdout.strip()}", file=sys.stderr)
|
|
309
|
+
return caddis_exit.ERROR
|
|
310
|
+
|
|
311
|
+
# No `git worktree prune`: `git worktree remove` (above) dropped this registration, and a prune would also drop
|
|
312
|
+
# the registrations of other tools whose folders are missing for the moment (robustness #19).
|
|
308
313
|
|
|
309
314
|
print(f"removed {worktree} ({branch_name}); merge {merge_oid}; files compared: {files_compared}")
|
|
310
315
|
return caddis_exit.CLEAN
|
|
@@ -129,6 +129,18 @@ def _worktree_blocks(porcelain: str) -> list[list[str]]:
|
|
|
129
129
|
return blocks
|
|
130
130
|
|
|
131
131
|
|
|
132
|
+
def remote_delete_args(branch: str, expected_tip: str, remote: str = "origin") -> tuple[str, ...]:
|
|
133
|
+
"""The `git` arguments that delete `remote/<branch>` only while its tip is `expected_tip` (a full object id).
|
|
134
|
+
|
|
135
|
+
`--force-with-lease=<ref>:<oid>` makes the push a compare-and-delete in one step on the server: when someone
|
|
136
|
+
pushed more commits to the branch after we looked, git refuses and the branch stays (robustness #19). Raises
|
|
137
|
+
ValueError for anything but a 40 or 64 digit hex id, so a bad value cannot turn into a plain delete."""
|
|
138
|
+
tip = str(expected_tip or "").strip().lower()
|
|
139
|
+
if len(tip) not in (40, 64) or any(ch not in "0123456789abcdef" for ch in tip):
|
|
140
|
+
raise ValueError(f"not a full object id: {expected_tip!r}")
|
|
141
|
+
return ("push", "--quiet", f"--force-with-lease=refs/heads/{branch}:{tip}", remote, "--delete", branch)
|
|
142
|
+
|
|
143
|
+
|
|
132
144
|
def safe_remove_worktree(repo: Path, worktree: Path, *, force: bool = True, force_locked: bool = False,
|
|
133
145
|
git_flags: Sequence[str] = (), env: Mapping[str, str] | None = None) -> str | None:
|
|
134
146
|
"""Remove the linked worktree `worktree` of `repo`: its links first, then `git worktree remove`.
|
|
@@ -90,8 +90,19 @@ def record(hook: str, feature: str, exc: BaseException, root: str | None = None)
|
|
|
90
90
|
"message": emsg,
|
|
91
91
|
}, ensure_ascii=False))
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
# Write beside the ledger, then swap in one step: a crash or a second hook mid-write
|
|
94
|
+
# leaves the old ledger whole, never a torn one. The temp name is per process.
|
|
95
|
+
tmp = f"{path}.{os.getpid()}.tmp"
|
|
96
|
+
try:
|
|
97
|
+
with open(tmp, "w", encoding="utf-8", newline="\n") as fh:
|
|
98
|
+
fh.write("\n".join(lines[-_MAX_LINES:]) + "\n")
|
|
99
|
+
os.replace(tmp, path)
|
|
100
|
+
except BaseException:
|
|
101
|
+
try:
|
|
102
|
+
os.unlink(tmp)
|
|
103
|
+
except OSError:
|
|
104
|
+
pass
|
|
105
|
+
raise
|
|
95
106
|
except Exception:
|
|
96
107
|
return # the ledger must never be the thing that breaks a hook
|
|
97
108
|
|
|
@@ -1207,6 +1207,8 @@ session-state.md
|
|
|
1207
1207
|
session-state/
|
|
1208
1208
|
context-window.json
|
|
1209
1209
|
context-window.json.tmp
|
|
1210
|
+
context-window.json.*.tmp
|
|
1211
|
+
context-window/
|
|
1210
1212
|
.session-state-*.tmp
|
|
1211
1213
|
hook-errors.jsonl
|
|
1212
1214
|
.relay-injected/
|
|
@@ -1420,6 +1422,18 @@ def _user_settings_path(home: Path | None = None) -> Path:
|
|
|
1420
1422
|
return root / ".claude" / "settings.json"
|
|
1421
1423
|
|
|
1422
1424
|
|
|
1425
|
+
def _atomic_write_json(path: Path, data: dict) -> None:
|
|
1426
|
+
"""Write `data` to `path` through a temp file. The temp name is unique per process: every
|
|
1427
|
+
SessionStart runs this (inject_relay.py), and two sessions starting at once must not share one."""
|
|
1428
|
+
tmp = path.with_name(f"{path.name}.caddis-tmp-{os.getpid()}")
|
|
1429
|
+
try:
|
|
1430
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
1431
|
+
os.replace(tmp, path)
|
|
1432
|
+
finally:
|
|
1433
|
+
if tmp.exists():
|
|
1434
|
+
tmp.unlink()
|
|
1435
|
+
|
|
1436
|
+
|
|
1423
1437
|
def ensure_user_env(dry: bool, home: Path | None = None) -> list[str]:
|
|
1424
1438
|
"""Add `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` to the user's global settings, additively.
|
|
1425
1439
|
|
|
@@ -1470,9 +1484,7 @@ def ensure_user_env(dry: bool, home: Path | None = None) -> list[str]:
|
|
|
1470
1484
|
# whole function exists to notice.
|
|
1471
1485
|
if raw:
|
|
1472
1486
|
path.with_suffix(".json.caddis-bak").write_text(raw, encoding="utf-8")
|
|
1473
|
-
|
|
1474
|
-
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
1475
|
-
os.replace(tmp, path)
|
|
1487
|
+
_atomic_write_json(path, data)
|
|
1476
1488
|
verb = "would add" if dry else "added"
|
|
1477
1489
|
return [f"user env: {verb} {TODO_TOOLS_ENV_KEY}=1 to {path}"
|
|
1478
1490
|
+ ("" if not raw else " (previous version saved as settings.json.caddis-bak)")]
|
|
@@ -1513,9 +1525,7 @@ def ensure_cleanup_period(dry: bool, home: Path | None = None, days: int = CLEAN
|
|
|
1513
1525
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1514
1526
|
if raw:
|
|
1515
1527
|
path.with_suffix(".json.caddis-retention-bak").write_text(raw, encoding="utf-8")
|
|
1516
|
-
|
|
1517
|
-
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
1518
|
-
os.replace(tmp, path)
|
|
1528
|
+
_atomic_write_json(path, data)
|
|
1519
1529
|
verb = "would set" if dry else "set"
|
|
1520
1530
|
return f"transcript retention: {verb} {CLEANUP_PERIOD_KEY}={days} in {path}"
|
|
1521
1531
|
|
|
@@ -58,6 +58,9 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_adhoc.py" "<task description>"
|
|
|
58
58
|
- **Worktree result:** PASS commits code changes to `lane/adhoc-<slug>` and removes the worktree. FAIL keeps it. A Claude-lane `PENDING` keeps it and prints the `NEXT:` step. Gates that never ran show `Gates: not run`.
|
|
59
59
|
- **No-op and gate failures:** A run with no code changes fails and keeps its worktree. A pytest gate that collects no tests also fails.
|
|
60
60
|
- **Verdict Card:** Prints PASS, FAIL, or PENDING with diff stats and next actions.
|
|
61
|
+
- **State per session:** The active task is saved in `.caddis/orchestrator/adhoc/latest-<session-id>.json` (session id from `CADDIS_SESSION_ID`, `CLAUDE_SESSION_ID` or `CLAUDE_CODE_SESSION_ID`). `keep`, `drop` and `status` use only your own session's task. An old un-keyed `latest.json` is adopted once by the first session that reads it.
|
|
62
|
+
- **Unique slug:** The `<slug>` ends in a random 6-character suffix, so two runs on the same day and topic never share a branch or plan.
|
|
63
|
+
- **No branch overwrite:** If `lane/adhoc-<slug>` already exists with commits not merged into the base (and is not this session's own recorded branch), the run fails, keeps its worktree and prints why.
|
|
61
64
|
|
|
62
65
|
### 2. Keep the Result
|
|
63
66
|
```bash
|
|
@@ -133,8 +133,9 @@ symmetric either: refusing early costs this session's remaining headroom, while
|
|
|
133
133
|
handover costs the successor's whole session and everything built on the error.
|
|
134
134
|
|
|
135
135
|
**Where the number comes from.** Claude Code hands `context_window.used_percentage` to the status
|
|
136
|
-
line and to nothing else, so the caddis status line caches it per repo and
|
|
137
|
-
back
|
|
136
|
+
line and to nothing else, so the caddis status line caches it per repo and per session (`.caddis/context-window/<session id>.json`),
|
|
137
|
+
and `preflight` reads it back (your own session's file when `CLAUDE_SESSION_ID` is set, else the shared
|
|
138
|
+
`context-window.json`, which another session may have written last). **No status line on this machine?** Pass your own reading:
|
|
138
139
|
|
|
139
140
|
```bash
|
|
140
141
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" preflight --context-pct 72
|