@caddis/cli 0.4.56 → 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/guard_agy.py +700 -68
- package/bundles/antigravity-plugin/hooks.json +2 -1
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +69 -23
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +9 -8
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +21 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +39 -2
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
- package/bundles/antigravity-plugin/scripts/caddis_review.py +81 -11
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +124 -29
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +80 -7
- 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/oss_review.py +300 -79
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +20 -6
- package/bundles/antigravity-plugin/session_end_agy.py +13 -10
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +53 -8
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +9 -5
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +18 -11
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +4 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +44 -4
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +26 -7
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +31 -0
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +42 -4
- 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/antigravity-plugin/warm_start_agy.py +26 -15
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +9 -5
- package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +52 -175
- package/bundles/manifest.json +3 -3
- package/dist/cli.js +310 -46
- package/package.json +1 -1
|
@@ -77,6 +77,33 @@ USAGE_LIMIT_RE = re.compile(
|
|
|
77
77
|
r"Usage limit reached for|Limit Exhausted|Insufficient balance|You.ve hit your .*limit|RESOURCE_EXHAUSTED",
|
|
78
78
|
re.IGNORECASE,
|
|
79
79
|
)
|
|
80
|
+
# A provider failure, not a review: HTTP 429, any 5xx, a rate-limit or overload error. Counts only when the run gave no
|
|
81
|
+
# verdict line (a review may quote such text from the code). 18-19 Oct 2026: a GLM 429 was reported as
|
|
82
|
+
# "FAILED no verdict line" = BLOCKED, which can start a fix session for nothing.
|
|
83
|
+
PROVIDER_FAILURE_RE = re.compile(
|
|
84
|
+
r"API Error: (?:429|5\d\d)\b|\bHTTP (?:429|5\d\d)\b|Too Many Requests|rate_limit_error|overloaded_error"
|
|
85
|
+
r"|Service Unavailable|Bad Gateway|Gateway Time-?out|Internal Server Error",
|
|
86
|
+
re.IGNORECASE,
|
|
87
|
+
)
|
|
88
|
+
CARD_TIMEOUT_SECS = 30 * 60 # one card review; at the limit the launcher tree is killed and the run is NOT_RUN
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _provider_failure(output: str, review_text: str) -> str | None:
|
|
92
|
+
"""Why a run with no verdict line is a provider failure (NOT_RUN), or None when it is a real review."""
|
|
93
|
+
if _verdicts(review_text):
|
|
94
|
+
return None # any verdict line, even two that conflict, means a model wrote a review: BLOCKED, never NOT_RUN
|
|
95
|
+
if not review_text.strip():
|
|
96
|
+
return "empty reply"
|
|
97
|
+
match = PROVIDER_FAILURE_RE.search(output)
|
|
98
|
+
if match:
|
|
99
|
+
return f"provider error ({match.group(0)})"
|
|
100
|
+
try:
|
|
101
|
+
data = _leading_json(output)
|
|
102
|
+
except ValueError: # not JSON (JSONDecodeError is a ValueError)
|
|
103
|
+
data = None
|
|
104
|
+
if isinstance(data, dict) and data.get("is_error") is True:
|
|
105
|
+
return "the run reported is_error"
|
|
106
|
+
return None
|
|
80
107
|
|
|
81
108
|
|
|
82
109
|
def _review_role(repo: Path) -> list[str]:
|
|
@@ -120,12 +147,30 @@ def launch(provider: str, prompt: str, cwd: Path) -> tuple[int, str]:
|
|
|
120
147
|
# and the verdict is lost (seen with DeepSeek on 10 Oct 2026). claude-oss.ps1 sets this for GLM only.
|
|
121
148
|
env = {**os.environ, "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1"}
|
|
122
149
|
# stdin closed: started from a background shell with an open stdin, the launcher hung 40 minutes (10 Oct 2026).
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
150
|
+
# Its own process group (Windows) or session (POSIX), so the timeout kills the whole tree (pwsh, claude), not only
|
|
151
|
+
# the top process. A timeout is NOT_RUN (3): the run switches provider, never BLOCKED.
|
|
152
|
+
group = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt"
|
|
153
|
+
else {"start_new_session": True})
|
|
154
|
+
proc = subprocess.Popen(cmd, cwd=cwd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
155
|
+
text=True, encoding="utf-8", errors="replace", env=env, **group)
|
|
156
|
+
try:
|
|
157
|
+
stdout, stderr = proc.communicate(timeout=CARD_TIMEOUT_SECS)
|
|
158
|
+
except subprocess.TimeoutExpired:
|
|
159
|
+
_kill_tree(proc)
|
|
160
|
+
try:
|
|
161
|
+
stdout, _stderr = proc.communicate(timeout=60)
|
|
162
|
+
except subprocess.TimeoutExpired:
|
|
163
|
+
stdout = ""
|
|
164
|
+
return caddis_exit.NOT_RUN, (f"caddis_review: card review timed out after {CARD_TIMEOUT_SECS} s; process tree "
|
|
165
|
+
f"killed\n{stdout or ''}")
|
|
166
|
+
except BaseException:
|
|
167
|
+
_kill_tree(proc)
|
|
168
|
+
raise
|
|
169
|
+
stdout, stderr = stdout or "", stderr or ""
|
|
170
|
+
if proc.returncode != 0 and stderr:
|
|
126
171
|
# A limit or key message often comes on stderr; keep it for the switch decision.
|
|
127
|
-
return
|
|
128
|
-
return
|
|
172
|
+
return proc.returncode, (stdout + "\n" + stderr).strip() + "\n"
|
|
173
|
+
return proc.returncode, stdout
|
|
129
174
|
|
|
130
175
|
|
|
131
176
|
def _leading_json(output: str) -> object:
|
|
@@ -261,12 +306,15 @@ def run_review(
|
|
|
261
306
|
break
|
|
262
307
|
|
|
263
308
|
# Limit text counts only when the run gave no verdict: a review may quote such text from the code.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
309
|
+
extracted = _extract_review_text(output)
|
|
310
|
+
has_usage_limit = bool(USAGE_LIMIT_RE.search(output)) and not (code == 0 and _verdicts(extracted))
|
|
311
|
+
# A 429, a 5xx or an empty reply is the provider failing, not the reviewer finding nothing: switch, and
|
|
312
|
+
# NOT_RUN when no provider is left. Never "FAILED no verdict line" (BLOCKED).
|
|
313
|
+
provider_failed = _provider_failure(output, extracted)
|
|
314
|
+
needs_switch = (code == 3) or (code == 2) or has_usage_limit or provider_failed is not None
|
|
267
315
|
|
|
268
316
|
# Again after the JSON decode: an escaped key form that survived in the raw JSON is plain text now.
|
|
269
|
-
review_text = caddis_redact.redact(
|
|
317
|
+
review_text = caddis_redact.redact(extracted, masks)
|
|
270
318
|
review_file = out_dir / f"{card.stem}-{provider}.txt"
|
|
271
319
|
content = review_text.rstrip("\r\n") + f"\nEXIT {code} secs {duration}\n"
|
|
272
320
|
|
|
@@ -277,6 +325,8 @@ def run_review(
|
|
|
277
325
|
reason = "exit 2"
|
|
278
326
|
elif has_usage_limit:
|
|
279
327
|
reason = "usage limit reached"
|
|
328
|
+
elif provider_failed is not None:
|
|
329
|
+
reason = provider_failed
|
|
280
330
|
else:
|
|
281
331
|
reason = f"exit {code}"
|
|
282
332
|
|
|
@@ -634,6 +684,16 @@ def launch_claude(prompt: str, worktree: Path, out: Path) -> str:
|
|
|
634
684
|
# there is no sandbox on native Windows. The diff comes in the prompt (`_diff_material`).
|
|
635
685
|
# No `--allowedTools`: unscoped, it approves files outside the worktree (smoke run 18 Oct 2026: without it a
|
|
636
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
|
|
637
697
|
exe = _claude_exe()
|
|
638
698
|
if exe is None:
|
|
639
699
|
output = f"{_PREFIX}claude could not start: not found on PATH (the current folder is not searched)\n"
|
|
@@ -647,11 +707,21 @@ def launch_claude(prompt: str, worktree: Path, out: Path) -> str:
|
|
|
647
707
|
governor = default_governor()
|
|
648
708
|
if governor:
|
|
649
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)
|
|
650
713
|
else:
|
|
651
|
-
|
|
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
|
|
652
721
|
# CADDIS_HEADLESS keeps relay and session-end hooks quiet (claude-oss.ps1 sets it too).
|
|
653
722
|
# No program from the current folder (Windows searches it first), no git index rewrite by the reviewer's tools.
|
|
654
|
-
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",
|
|
655
725
|
"NoDefaultCurrentDirectoryInExePath": "1", "GIT_OPTIONAL_LOCKS": "0"}
|
|
656
726
|
# Its own process group (Windows) or session (POSIX), so a timeout kills the whole tree, not only the top process.
|
|
657
727
|
group = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt"
|
|
@@ -241,9 +241,13 @@ def default_branch(root: Path) -> str:
|
|
|
241
241
|
caddis_exit.NOT_RUN)
|
|
242
242
|
|
|
243
243
|
|
|
244
|
+
FETCH = True # `render --no-fetch` (the waiting hook) turns it off for one command: read the origin refs it has
|
|
245
|
+
|
|
246
|
+
|
|
244
247
|
def fetch(root: Path) -> str:
|
|
245
248
|
"""Fetch origin and return the default branch name."""
|
|
246
|
-
|
|
249
|
+
if FETCH:
|
|
250
|
+
_git(root, "fetch", "--quiet", "origin")
|
|
247
251
|
return default_branch(root)
|
|
248
252
|
|
|
249
253
|
|
|
@@ -1068,7 +1072,6 @@ def merged_checkout(root: Path, branch: str):
|
|
|
1068
1072
|
yield worktree
|
|
1069
1073
|
finally:
|
|
1070
1074
|
caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
|
|
1071
|
-
_git(root, "worktree", "prune", check=False)
|
|
1072
1075
|
caddis_worktree_safety.remove_tree(tmp) # links first: a junction's target survives
|
|
1073
1076
|
|
|
1074
1077
|
|
|
@@ -1818,11 +1821,12 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
|
|
|
1818
1821
|
_git(worktree, "add", "--", path)
|
|
1819
1822
|
_git(worktree, "commit", "--quiet", "-m", title)
|
|
1820
1823
|
_git(worktree, "push", "--quiet", "-u", "origin", branch)
|
|
1824
|
+
pushed_tip = _git(worktree, "rev-parse", "HEAD").stdout.strip() # the tip we pushed: the only one we delete
|
|
1821
1825
|
try:
|
|
1822
1826
|
pr = open_pr(root, gh_repo, branch, snap.branch, title,
|
|
1823
1827
|
f"Roadmap change by caddis_roadmap.py: {slug} {mid} {change}.")
|
|
1824
1828
|
except Exception:
|
|
1825
|
-
_git(root,
|
|
1829
|
+
_git(root, *caddis_worktree_safety.remote_delete_args(branch, pushed_tip), check=False)
|
|
1826
1830
|
raise
|
|
1827
1831
|
try:
|
|
1828
1832
|
rc = merge_pr(root, pr, gh_repo)
|
|
@@ -1830,13 +1834,12 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
|
|
|
1830
1834
|
merge_exit = "timeout"
|
|
1831
1835
|
raise
|
|
1832
1836
|
out(f"#{pr} {title}: " + ("merged" if rc == caddis_exit.CLEAN else f"not merged (merge chain exit {rc})"))
|
|
1833
|
-
if rc == caddis_exit.CLEAN:
|
|
1834
|
-
_git(root,
|
|
1837
|
+
if rc == caddis_exit.CLEAN: # only while the remote tip is still the commit we pushed
|
|
1838
|
+
_git(root, *caddis_worktree_safety.remote_delete_args(branch, pushed_tip), check=False)
|
|
1835
1839
|
else:
|
|
1836
1840
|
rc = caddis_exit.NOT_RUN if rc == caddis_exit.NOT_RUN else caddis_exit.BLOCKED
|
|
1837
1841
|
finally:
|
|
1838
1842
|
caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
|
|
1839
|
-
_git(root, "worktree", "prune", check=False)
|
|
1840
1843
|
_git(root, "branch", "-D", branch, check=False)
|
|
1841
1844
|
caddis_worktree_safety.remove_tree(tmp)
|
|
1842
1845
|
_audit(root, slug, {"utc": _utc(), "roadmap": slug, "id": mid, "change": change, "branch": branch,
|
|
@@ -2218,9 +2221,93 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
2218
2221
|
which.add_argument("--all", action="store_true", help="every roadmap on origin (used by hooks and commands)")
|
|
2219
2222
|
p.add_argument("--root", default=".")
|
|
2220
2223
|
p.add_argument("--out", default=None, help="the HTML file (default ~/.caddis/roadmap-pages/<repo>/<slug>.html)")
|
|
2224
|
+
p.add_argument("--no-fetch", action="store_true",
|
|
2225
|
+
help="use the origin refs this checkout has; no git fetch (the waiting hook's background render)")
|
|
2226
|
+
p.add_argument("--delay", type=int, default=0,
|
|
2227
|
+
help="wait this many seconds (at most 60) first: the waiting hook's follow-up render")
|
|
2221
2228
|
return parser
|
|
2222
2229
|
|
|
2223
2230
|
|
|
2231
|
+
RENDER_LOCK_STALE_S = 600 # a render lock older than this is left by a killed render
|
|
2232
|
+
PENDING_NAME = ".roadmap-render.pending" # session_waiting.py writes it beside its debounce stamp
|
|
2233
|
+
|
|
2234
|
+
|
|
2235
|
+
def _clear(path: Path | None) -> bool:
|
|
2236
|
+
"""Delete `path`; True when it was there."""
|
|
2237
|
+
if path is None:
|
|
2238
|
+
return False
|
|
2239
|
+
try:
|
|
2240
|
+
path.unlink()
|
|
2241
|
+
return True
|
|
2242
|
+
except FileNotFoundError:
|
|
2243
|
+
return False
|
|
2244
|
+
except OSError:
|
|
2245
|
+
return path.exists()
|
|
2246
|
+
|
|
2247
|
+
|
|
2248
|
+
def _take_render_lock(lock: Path) -> bool:
|
|
2249
|
+
"""One render per repo at a time (robustness #8): create `lock` with O_EXCL, never wait. A lock older than
|
|
2250
|
+
RENDER_LOCK_STALE_S is taken over once. False: another render holds it."""
|
|
2251
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
2252
|
+
for attempt in range(2):
|
|
2253
|
+
try:
|
|
2254
|
+
fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
2255
|
+
except FileExistsError:
|
|
2256
|
+
pass
|
|
2257
|
+
except PermissionError: # Windows: a deleted lock whose delete is still pending
|
|
2258
|
+
return False
|
|
2259
|
+
else:
|
|
2260
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
2261
|
+
fh.write(str(os.getpid()))
|
|
2262
|
+
return True
|
|
2263
|
+
try:
|
|
2264
|
+
stale = time.time() - lock.stat().st_mtime > RENDER_LOCK_STALE_S
|
|
2265
|
+
except FileNotFoundError:
|
|
2266
|
+
stale = True # released between the create and the stat: try again
|
|
2267
|
+
except OSError: # Windows: a lock being deleted cannot be read either
|
|
2268
|
+
return False
|
|
2269
|
+
if attempt or not stale:
|
|
2270
|
+
return False
|
|
2271
|
+
try:
|
|
2272
|
+
lock.unlink()
|
|
2273
|
+
except FileNotFoundError:
|
|
2274
|
+
pass
|
|
2275
|
+
except OSError:
|
|
2276
|
+
return False
|
|
2277
|
+
return False
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
def _render(root: Path, args: argparse.Namespace) -> int:
|
|
2281
|
+
"""`render`: the pages, the prune and the hub. The caller holds the repo's render lock."""
|
|
2282
|
+
import caddis_roadmap_page
|
|
2283
|
+
slugs, on_origin = list_roadmaps_checked(root) if args.all else ([args.roadmap], None)
|
|
2284
|
+
failed = written = pruned = 0
|
|
2285
|
+
for slug in slugs: # --all: one bad roadmap must not stop the others
|
|
2286
|
+
try:
|
|
2287
|
+
path = caddis_roadmap_page.write_page(root, slug, Path(args.out) if args.out else None,
|
|
2288
|
+
sys.modules[__name__])
|
|
2289
|
+
if path is None: # a newer caddis owns this page: kept, and neither written nor failed
|
|
2290
|
+
continue
|
|
2291
|
+
written += 1
|
|
2292
|
+
print(f"[ROADMAP] page written: {path.resolve()}")
|
|
2293
|
+
except Exception as exc: # --all: one roadmap must never stop the others
|
|
2294
|
+
if not args.all:
|
|
2295
|
+
raise
|
|
2296
|
+
failed += 1
|
|
2297
|
+
print(f"[ROADMAP] {slug}: page not written: {exc}", file=sys.stderr)
|
|
2298
|
+
try: # the hub is a convenience: nothing below ever fails a render or changes its exit code
|
|
2299
|
+
if args.all and on_origin is not None: # a roadmap removed from origin leaves the hub too; a listing
|
|
2300
|
+
# that failed removes nothing, and a folder still on origin keeps its page even if its render failed
|
|
2301
|
+
pruned = caddis_roadmap_page.prune_folder(
|
|
2302
|
+
caddis_roadmap_page.default_page_path(root, "_").parent, on_origin | set(slugs))
|
|
2303
|
+
hub = caddis_roadmap_page.write_hub() if not args.out and (written or pruned) else None
|
|
2304
|
+
if hub is not None: # None: a newer caddis owns the hub (write_hub said so)
|
|
2305
|
+
print(f"[ROADMAP] hub written: {hub.resolve()}")
|
|
2306
|
+
except Exception as exc:
|
|
2307
|
+
print(f"[ROADMAP] hub not written: {exc}", file=sys.stderr)
|
|
2308
|
+
return caddis_exit.BLOCKED if failed else caddis_exit.CLEAN
|
|
2309
|
+
|
|
2310
|
+
|
|
2224
2311
|
def _dispatch(args: argparse.Namespace) -> int:
|
|
2225
2312
|
root = _repo_root(args.root)
|
|
2226
2313
|
if args.cmd == "list":
|
|
@@ -2266,29 +2353,37 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
2266
2353
|
import caddis_roadmap_page # the page is separate; only `render` needs it
|
|
2267
2354
|
if args.all and args.out:
|
|
2268
2355
|
raise RoadmapError("--out works with one --roadmap, not with --all", caddis_exit.ERROR)
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2356
|
+
if args.delay > 0:
|
|
2357
|
+
time.sleep(min(args.delay, 60))
|
|
2358
|
+
folder = caddis_roadmap_page.default_page_path(root, "_").parent.name
|
|
2359
|
+
lock = caddis_roadmap_page.pages_root() / ".locks" / f"{folder}.lock"
|
|
2360
|
+
# The waiting hook's debounce (and a render that finds the lock held) leaves PENDING_NAME: the render that
|
|
2361
|
+
# holds the lock renders once more at its end, so the last mark change always reaches the page.
|
|
2362
|
+
state = caddis_roadmap_page.main_checkout(root) / ".caddis" / "session-state"
|
|
2363
|
+
pending = state / PENDING_NAME if args.all and not args.out else None
|
|
2364
|
+
if not _take_render_lock(lock):
|
|
2365
|
+
if pending is not None and state.is_dir():
|
|
2366
|
+
try:
|
|
2367
|
+
pending.write_text(str(os.getpid()), encoding="utf-8")
|
|
2368
|
+
except OSError:
|
|
2369
|
+
pass
|
|
2370
|
+
print("[ROADMAP] another render of this repo is running; skipped")
|
|
2371
|
+
return caddis_exit.CLEAN
|
|
2372
|
+
global FETCH
|
|
2373
|
+
FETCH = not args.no_fetch
|
|
2374
|
+
try:
|
|
2375
|
+
_clear(pending) # this render reads the marks as they are now
|
|
2376
|
+
code = _render(root, args)
|
|
2377
|
+
if _clear(pending): # a mark changed while it ran: once more, under the same lock
|
|
2378
|
+
code = _render(root, args)
|
|
2379
|
+
return code
|
|
2380
|
+
finally:
|
|
2381
|
+
FETCH = True
|
|
2382
|
+
try: # only our own lock: a render that ran past the stale age may have been taken over
|
|
2383
|
+
if lock.read_text(encoding="utf-8").strip() == str(os.getpid()):
|
|
2384
|
+
lock.unlink()
|
|
2385
|
+
except (OSError, UnicodeDecodeError):
|
|
2386
|
+
pass
|
|
2292
2387
|
return caddis_exit.ERROR
|
|
2293
2388
|
|
|
2294
2389
|
|
|
@@ -19,6 +19,7 @@ from the files is escaped: a plan, a relay or a hook message can hold any text.
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import datetime
|
|
22
|
+
import functools
|
|
22
23
|
import hashlib
|
|
23
24
|
import html
|
|
24
25
|
import json
|
|
@@ -452,11 +453,13 @@ def default_page_path(root: Path, slug: str, home: Path | None = None) -> Path:
|
|
|
452
453
|
return pages_root(home) / f"{main.name}-{tag}" / f"{slug}.html"
|
|
453
454
|
|
|
454
455
|
|
|
455
|
-
def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path:
|
|
456
|
+
def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path | None:
|
|
456
457
|
"""The page. At the default path it also writes `<slug>.json`, the one-row summary the hub is built from; an
|
|
457
|
-
`--out` page is a one-off copy and stays out of the hub."""
|
|
458
|
-
model = build(root, slug, roadmap_mod)
|
|
458
|
+
`--out` page is a one-off copy and stays out of the hub. None: a newer caddis wrote this page, so it is kept."""
|
|
459
459
|
path = Path(out) if out else default_page_path(root, slug)
|
|
460
|
+
if out is None and _owned_by_newer(slug, path.with_suffix(".json")): # before build: a skip does no work
|
|
461
|
+
return None
|
|
462
|
+
model = build(root, slug, roadmap_mod)
|
|
460
463
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
461
464
|
_write_atomic(path, render_html(model))
|
|
462
465
|
if out is None:
|
|
@@ -468,6 +471,66 @@ def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path:
|
|
|
468
471
|
return path
|
|
469
472
|
|
|
470
473
|
|
|
474
|
+
# ---- the version gate: a session on an old plugin never overwrites what a newer caddis wrote (robustness #8) -------
|
|
475
|
+
|
|
476
|
+
@functools.lru_cache(maxsize=8)
|
|
477
|
+
def generator_version(here: Path | None = None) -> str:
|
|
478
|
+
"""This caddis's version: the Claude plugin's `.claude-plugin/plugin.json` or the agy plugin's root
|
|
479
|
+
`plugin.json` (both beside `scripts/`), else the source checkout's `caddis` entry in
|
|
480
|
+
`.github/runtime-targets.json`, else "dev"."""
|
|
481
|
+
base = Path(here or Path(__file__).resolve().parent).parent
|
|
482
|
+
for manifest in (base / ".claude-plugin" / "plugin.json", base / "plugin.json"):
|
|
483
|
+
data, _ = _read_json(manifest)
|
|
484
|
+
if data and data.get("version"):
|
|
485
|
+
return str(data["version"])
|
|
486
|
+
data, _ = _read_json(base / ".github" / "runtime-targets.json")
|
|
487
|
+
for target in (data or {}).get("targets") or []:
|
|
488
|
+
plugin = target.get("plugin") if isinstance(target, dict) else None
|
|
489
|
+
if isinstance(plugin, dict) and plugin.get("name") == "caddis" and plugin.get("version"):
|
|
490
|
+
return str(plugin["version"])
|
|
491
|
+
return "dev"
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _version_key(text) -> tuple[int, ...] | None:
|
|
495
|
+
parts = str(text or "").strip().split(".")
|
|
496
|
+
return tuple(int(p) for p in parts) if all(p.isdigit() for p in parts) else None
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def is_newer(theirs, mine) -> bool:
|
|
500
|
+
"""True only when both are numeric versions and `theirs` is the higher one; "dev" or "" never blocks."""
|
|
501
|
+
a, b = _version_key(theirs), _version_key(mine)
|
|
502
|
+
return a is not None and b is not None and a > b
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
GENERATOR_OWNERSHIP_S = 1800 # a newer caddis owns a page only while it keeps writing it (review of #71)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _newer_owner(summary: Path) -> tuple[str, bool]:
|
|
509
|
+
"""(version, live): the newer caddis that wrote `summary` ("" when it is not newer), and whether it wrote it in
|
|
510
|
+
the last GENERATOR_OWNERSHIP_S. An unreadable time is not live: a downgrade must never freeze a page."""
|
|
511
|
+
data, _ = _read_json(summary)
|
|
512
|
+
theirs = str((data or {}).get("generator") or "")
|
|
513
|
+
if not is_newer(theirs, generator_version()):
|
|
514
|
+
return "", False
|
|
515
|
+
age_h = _age_h(str((data or {}).get("generated_utc") or ""), datetime.datetime.now(datetime.timezone.utc))
|
|
516
|
+
return theirs, age_h is not None and age_h * 3600 <= GENERATOR_OWNERSHIP_S
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _gate(label: str, theirs: str, live: bool) -> bool:
|
|
520
|
+
"""True: skip the write. Prints one line when a newer caddis owns it, or when this one takes it over."""
|
|
521
|
+
if theirs and live:
|
|
522
|
+
print(f"[ROADMAP] {label}: a newer caddis ({theirs}) owns this page; skipped")
|
|
523
|
+
return True
|
|
524
|
+
if theirs:
|
|
525
|
+
print(f"[ROADMAP] {label}: taking over from caddis {theirs} (it has not written for "
|
|
526
|
+
f"{GENERATOR_OWNERSHIP_S // 60} min)")
|
|
527
|
+
return False
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _owned_by_newer(slug: str, summary: Path) -> bool:
|
|
531
|
+
return _gate(slug, *_newer_owner(summary))
|
|
532
|
+
|
|
533
|
+
|
|
471
534
|
# ---- the hub: one page over every roadmap page on this machine, grouped per repo ---------------------------------
|
|
472
535
|
|
|
473
536
|
HUB_STALE_H = 24 # a summary older than this is marked "not refreshed"
|
|
@@ -507,7 +570,7 @@ def page_summary(model: dict, main: Path, path: Path) -> dict:
|
|
|
507
570
|
else f"stopped: {stop.get('reason')}" if stop.get("reason") else "")
|
|
508
571
|
nxt = model.get("next") or {}
|
|
509
572
|
return {
|
|
510
|
-
"version": 1, "repo": main.name, "checkout": str(main), "folder": path.parent.name, "slug": model["roadmap"],
|
|
573
|
+
"version": 1, "generator": generator_version(), "repo": main.name, "checkout": str(main), "folder": path.parent.name, "slug": model["roadmap"],
|
|
511
574
|
"branch": model["branch"], "origin_commit": model.get("origin_commit", ""),
|
|
512
575
|
"generated_utc": model["generated"],
|
|
513
576
|
"milestones_done": model["done"], "milestones_total": model["total"],
|
|
@@ -530,7 +593,7 @@ def prune_folder(folder: Path, keep: set[str]) -> int:
|
|
|
530
593
|
if not folder.is_dir():
|
|
531
594
|
return removed
|
|
532
595
|
for js in folder.glob("*.json"):
|
|
533
|
-
if js.stem in keep:
|
|
596
|
+
if js.stem in keep or _newer_owner(js)[1]: # a live newer caddis may know a roadmap this one cannot list
|
|
534
597
|
continue
|
|
535
598
|
for f in (js, js.with_suffix(".html")):
|
|
536
599
|
try:
|
|
@@ -667,11 +730,21 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime) -> s
|
|
|
667
730
|
return "".join(out)
|
|
668
731
|
|
|
669
732
|
|
|
670
|
-
def write_hub(home: Path | None = None, now: datetime.datetime | None = None) -> Path:
|
|
671
|
-
"""`~/.caddis/roadmap-pages/index.html`: every roadmap page on this machine, grouped per repo.
|
|
733
|
+
def write_hub(home: Path | None = None, now: datetime.datetime | None = None) -> Path | None:
|
|
734
|
+
"""`~/.caddis/roadmap-pages/index.html`: every roadmap page on this machine, grouped per repo. None: a newer
|
|
735
|
+
caddis wrote the hub (its version is in `.hub-generator`), so it is kept."""
|
|
672
736
|
root_dir = pages_root(home)
|
|
673
737
|
root_dir.mkdir(parents=True, exist_ok=True)
|
|
738
|
+
stamp = root_dir / ".hub-generator"
|
|
739
|
+
try:
|
|
740
|
+
theirs = stamp.read_text(encoding="utf-8").strip()
|
|
741
|
+
live = time.time() - stamp.stat().st_mtime <= GENERATOR_OWNERSHIP_S
|
|
742
|
+
except (OSError, UnicodeDecodeError):
|
|
743
|
+
theirs, live = "", False
|
|
744
|
+
if _gate("hub", theirs if is_newer(theirs, generator_version()) else "", live):
|
|
745
|
+
return None
|
|
674
746
|
rows, errors = read_summaries(root_dir)
|
|
675
747
|
path = root_dir / "index.html"
|
|
676
748
|
_write_atomic(path, render_hub(rows, errors, now or datetime.datetime.now(datetime.timezone.utc)))
|
|
749
|
+
_write_atomic(stamp, generator_version() + "\n")
|
|
677
750
|
return path
|
|
@@ -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
|