@caddis/cli 0.4.61 → 0.4.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/caddis_statusline.py +78 -6
- package/bundles/antigravity-plugin/guard_agy.py +2 -1
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +13 -1
- package/bundles/antigravity-plugin/scripts/caddis_hidden_desktop.py +232 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +3 -2
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +62 -5
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +306 -54
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +10 -4
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +25 -3
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +78 -6
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +4 -1
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +3 -1
- package/bundles/antigravity-plugin/scripts/caddis_worktree_root.py +109 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +5 -3
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +5 -3
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +5 -3
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +5 -3
- package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +10 -3
- package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +10 -3
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +1 -1
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -28,6 +28,8 @@ import html
|
|
|
28
28
|
import json
|
|
29
29
|
import os
|
|
30
30
|
import re
|
|
31
|
+
import shutil
|
|
32
|
+
import stat
|
|
31
33
|
import subprocess
|
|
32
34
|
import sys
|
|
33
35
|
import time
|
|
@@ -276,6 +278,8 @@ _CSS = _THEME_CSS + _BASE_CSS
|
|
|
276
278
|
# (an open roadmap view is never reset); the phone menu closes when the view changes; and the nav item of the
|
|
277
279
|
# shown view gets aria-current. No network.
|
|
278
280
|
_THEME_SCRIPT = ('<script id="theme-toggle">(function(){var k="caddis-roadmap-theme",d=document.documentElement;try{var t=localStorage.getItem(k);if(t==="light"||t==="dark")d.dataset.theme=t}catch(e){}window.caddisTheme=function(){var c=d.dataset.theme||(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"),n=c==="dark"?"light":"dark";d.dataset.theme=n;try{localStorage.setItem(k,n)}catch(e){}};function shown(){var h=location.hash.slice(1),e=h&&document.getElementById(h),v=e&&e.closest(".view");return v?v.id:"inbox"}function cur(){var a=document.querySelectorAll(".nav a.nav-i");for(var i=0;i<a.length;i++){if(a[i].getAttribute("href")==="#"+shown())a[i].setAttribute("aria-current","page");else a[i].removeAttribute("aria-current")}}addEventListener("DOMContentLoaded",cur);setInterval(function(){if(document.getElementById("inbox")&&document.visibilityState==="visible"&&shown()==="inbox")location.reload()},60000);addEventListener("hashchange",function(){var b=document.getElementById("nav-cb");if(b)b.checked=false;cur()})})();</script>')
|
|
281
|
+
# The shared copy is a static page: the inbox reload would only re-request it every minute, so it has no timer.
|
|
282
|
+
_SITE_THEME_SCRIPT = re.sub(r"setInterval\(function\(\)\{.*?\},60000\);", "", _THEME_SCRIPT)
|
|
279
283
|
_THEME_BUTTON = '<button type="button" class="theme-btn" onclick="caddisTheme()" aria-label="Switch between light and dark theme">◐ Theme</button>'
|
|
280
284
|
|
|
281
285
|
|
|
@@ -318,13 +322,14 @@ def _pill(status: str) -> str:
|
|
|
318
322
|
return f'<span class="pill s-{_e(status)}">{_e(_LABEL.get(status, status))}</span>'
|
|
319
323
|
|
|
320
324
|
|
|
321
|
-
def _shell(*, title: str, base: str, nav: str, crumbs: str, body: str, css: str = ""
|
|
325
|
+
def _shell(*, title: str, base: str, nav: str, crumbs: str, body: str, css: str = "",
|
|
326
|
+
script: str | None = None) -> str:
|
|
322
327
|
"""The app frame around every page: head, left nav, top bar (crumbs and the theme button) and the body.
|
|
323
328
|
`nav`, `crumbs`, `body` and `css` are text the caller has already made safe; `title` is plain text."""
|
|
324
329
|
return "".join([
|
|
325
330
|
'<!doctype html><html lang="en"><head><meta charset="utf-8">',
|
|
326
331
|
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
327
|
-
f"<title>{_e(title)}</title>{_fonts_link(base)}<style>{_CSS}{_SHELL_CSS}{css}</style>{_THEME_SCRIPT}</head>",
|
|
332
|
+
f"<title>{_e(title)}</title>{_fonts_link(base)}<style>{_CSS}{_SHELL_CSS}{css}</style>{script or _THEME_SCRIPT}</head>",
|
|
328
333
|
f'<body><input type="checkbox" id="nav-cb" class="nav-cb" aria-label="Toggle the navigation menu"><div class="app">{nav}<label for="nav-cb" class="nav-scrim" aria-hidden="true"></label><div class="main">',
|
|
329
334
|
'<header class="top"><label for="nav-cb" class="nav-btn" aria-label="Open the menu">☰</label>',
|
|
330
335
|
f'<div class="crumbs">{crumbs}</div>{_THEME_BUTTON}</header>',
|
|
@@ -360,10 +365,12 @@ def _group_rows(rows: list[dict]) -> tuple[list[tuple[str, list[dict]]], list[tu
|
|
|
360
365
|
return order, need, count
|
|
361
366
|
|
|
362
367
|
|
|
363
|
-
def _nav(rows: list[dict], base: str) -> str:
|
|
368
|
+
def _nav(rows: list[dict], base: str, site: bool = False) -> str:
|
|
364
369
|
"""The left nav: Needs you (inbox, gates, sessions, runners), then every repo with its roadmaps. Every link opens a
|
|
365
370
|
view of `index.html` (`#inbox`, `#<rid>`, ...); on the hub itself (`base` "") the link is the bare `#view`. The
|
|
366
|
-
active item and its sub-links are shown by `_view_css`, so the nav needs no per-page copy.
|
|
371
|
+
active item and its sub-links are shown by `_view_css`, so the nav needs no per-page copy. A `site` (shared copy)
|
|
372
|
+
nav has no Sessions or Runners item, no idle-session note and no Runner sub-link: those show only on the owner's
|
|
373
|
+
machine."""
|
|
367
374
|
order, _need, count = _group_rows(rows)
|
|
368
375
|
counts = {"inbox": count, "gates": sum(len(r.get("blockers") or r.get("owner_gates") or []) for r in rows),
|
|
369
376
|
"sessions": sum(len(_sessions_of(group)) for _f, group in order),
|
|
@@ -372,11 +379,13 @@ def _nav(rows: list[dict], base: str) -> str:
|
|
|
372
379
|
out = ['<nav class="nav" aria-label="Roadmaps">'
|
|
373
380
|
'<label for="nav-cb" class="nav-close" aria-label="Close the menu">✕</label>',
|
|
374
381
|
f'<a class="brand" href="{_e(hub or "#inbox")}">caddis · Roadmaps</a>', '<p class="nav-h">Needs you</p>']
|
|
375
|
-
for key, label in
|
|
382
|
+
for key, label in _NAV_ITEMS:
|
|
383
|
+
if site and key in _LOCAL_VIEWS:
|
|
384
|
+
continue
|
|
376
385
|
out.append(f'<a class="nav-i" href="{_e(hub)}#{key}">{_e(label)}<span class="n">{_e(counts[key])}</span></a>')
|
|
377
386
|
out.append('<p class="nav-h">Repos</p>')
|
|
378
387
|
for folder, group in order:
|
|
379
|
-
idle = sum(1 for s in _sessions_of(group) if not s.get("asking"))
|
|
388
|
+
idle = 0 if site else sum(1 for s in _sessions_of(group) if not s.get("asking"))
|
|
380
389
|
idle_txt = f" · {idle} idle session(s)" if idle else ""
|
|
381
390
|
out.append(f'<p class="nav-repo">{_e(group[0].get("repo"))}{_e(idle_txt)}</p>')
|
|
382
391
|
for r in sorted(group, key=lambda r: str(r["slug"])):
|
|
@@ -387,22 +396,29 @@ def _nav(rows: list[dict], base: str) -> str:
|
|
|
387
396
|
pct = int(round(100 * _int(r.get("phases_done")) / total)) if total else 0
|
|
388
397
|
out.append(f'<a class="nav-i nav-rm" href="{_e(hub)}#{rid}">{_e(r["slug"])}<span class="n">{pct}%</span></a>'
|
|
389
398
|
f'<div class="nav-sub" data-rid="{rid}">'
|
|
390
|
-
+ "".join(f'<a href="{_e(hub)}#{rid}-{anchor}">{_e(label)}</a>' for anchor, label in _PAGE_ANCHORS
|
|
399
|
+
+ "".join(f'<a href="{_e(hub)}#{rid}-{anchor}">{_e(label)}</a>' for anchor, label in _PAGE_ANCHORS
|
|
400
|
+
if not (site and anchor in _LOCAL_ANCHORS))
|
|
391
401
|
+ "</div>")
|
|
392
402
|
out.append("</nav>")
|
|
393
403
|
return "".join(out)
|
|
394
404
|
|
|
395
405
|
|
|
406
|
+
_NAV_ITEMS = (("inbox", "Inbox"), ("gates", "Gates"), ("sessions", "Sessions"), ("runners", "Runners"))
|
|
407
|
+
_LOCAL_VIEWS = ("sessions", "runners") # views of this machine's live state: a shared copy has neither
|
|
408
|
+
_LOCAL_ANCHORS = ("runner",) # the section of a roadmap view that shows this machine's runner
|
|
409
|
+
|
|
396
410
|
_ON = "{background:var(--prog-bg);color:var(--prog-fg);font-weight:600}"
|
|
397
411
|
|
|
398
412
|
|
|
399
|
-
def _view_css(rids: list[str]) -> str:
|
|
413
|
+
def _view_css(rids: list[str], site: bool = False) -> str:
|
|
400
414
|
"""Marks the nav item of the view that shows, and opens its sub-links. One rule per roadmap: CSS cannot take
|
|
401
415
|
the target's id into a selector. Every rid comes from `_rid` (hex and slug characters only), so it is safe
|
|
402
416
|
in a selector."""
|
|
403
417
|
shown = ".view:target,.view :target"
|
|
404
418
|
out = [f'body:not(:has({shown})) .nav a[href="#inbox"]{_ON}']
|
|
405
|
-
for key
|
|
419
|
+
for key, _label in _NAV_ITEMS:
|
|
420
|
+
if site and key in _LOCAL_VIEWS:
|
|
421
|
+
continue
|
|
406
422
|
out.append(f'body:has(#{key}:target,#{key} :target) .nav a[href="#{key}"]{_ON}')
|
|
407
423
|
for rid in rids:
|
|
408
424
|
on = f"body:has(#{rid}:target,#{rid} :target)"
|
|
@@ -410,11 +426,14 @@ def _view_css(rids: list[str]) -> str:
|
|
|
410
426
|
return "".join(out)
|
|
411
427
|
|
|
412
428
|
|
|
413
|
-
def _waiting_section(model: dict) -> str:
|
|
429
|
+
def _waiting_section(model: dict, site: bool = False) -> str:
|
|
430
|
+
"""Sessions that wait for input, then the open owner gates. A shared copy (`site`) has the owner gates only: its
|
|
431
|
+
sessions are not on the reader's machine, so a table of them would be empty for a wrong reason."""
|
|
414
432
|
out = ['<section id="waiting" class="card wait"><h2>Waiting for you</h2>']
|
|
415
|
-
sessions, gates = model["sessions"], model["waiting"]
|
|
433
|
+
sessions, gates = ([] if site else model["sessions"]), model["waiting"]
|
|
416
434
|
if not sessions and not gates:
|
|
417
|
-
out.append('<p class="empty">Nothing is waiting for you
|
|
435
|
+
out.append('<p class="empty">' + ("No owner gate is waiting." if site else "Nothing is waiting for you.")
|
|
436
|
+
+ "</p></section>")
|
|
418
437
|
return "".join(out)
|
|
419
438
|
# A question or a permission first; a session that only finished its turn and sits idle comes after it.
|
|
420
439
|
asking = [s for s in sessions if not str(s.get("kind", "")).startswith("idle")]
|
|
@@ -645,9 +664,10 @@ def render_html(model: dict) -> str:
|
|
|
645
664
|
body=render_body(model))
|
|
646
665
|
|
|
647
666
|
|
|
648
|
-
def render_body(model: dict) -> str:
|
|
667
|
+
def render_body(model: dict, site: bool = False) -> str:
|
|
649
668
|
"""Everything inside `<main>`: the header and the sections. `write_page` keeps it as `<slug>.body.html`; the
|
|
650
|
-
hub puts it, with its ids prefixed, in `index.html` as the roadmap's view.
|
|
669
|
+
hub puts it, with its ids prefixed, in `index.html` as the roadmap's view. A shared copy (`site`) leaves out the
|
|
670
|
+
session tables and the Runner section."""
|
|
651
671
|
title = f"Roadmap {model['roadmap']}"
|
|
652
672
|
phases = [ph for ms in model["milestones"] for ph in ms["phases"]]
|
|
653
673
|
done = sum(1 for ph in phases if ph["tile"] in ("done", "moved"))
|
|
@@ -662,35 +682,41 @@ def render_body(model: dict) -> str:
|
|
|
662
682
|
f'milestones · {_e(done)} of {_e(len(phases))} phases done · branch <code>{_e(model["branch"])}</code>'
|
|
663
683
|
f'{commit} · generated {_e(model["generated"])}</p>'
|
|
664
684
|
+ (f'<div class="chips">{chips}</div>' if chips else "") + "</div></header>",
|
|
665
|
-
_waiting_section(model), _summary(model), _flow(model), _now_next_later(model), _swimlane(model),
|
|
666
|
-
_gates(model), _milestones(model), _runner(model),
|
|
685
|
+
_waiting_section(model, site), _summary(model), _flow(model), _now_next_later(model), _swimlane(model),
|
|
686
|
+
_gates(model), _milestones(model), "" if site else _runner(model),
|
|
687
|
+
'<p class="empty">Built from roadmap.md and the plan Trackers and headers on origin.</p>' if site else
|
|
667
688
|
'<p class="empty">Built from roadmap.md, the plan Trackers and headers on origin, the runner state and the '
|
|
668
689
|
"session-state folder. Re-rendered by the runner, /handoff, /pr-merge, /catchup and the waiting hooks.</p>"])
|
|
669
690
|
|
|
670
691
|
|
|
671
|
-
def default_page_path(root: Path, slug: str, home: Path | None = None) -> Path:
|
|
692
|
+
def default_page_path(root: Path, slug: str, home: Path | None = None, base: Path | None = None) -> Path:
|
|
672
693
|
"""`~/.caddis/roadmap-pages/<repo folder>-<6 hex>/<slug>.html`: outside every checkout, so a render never makes a
|
|
673
694
|
repo dirty (17 Oct: the in-repo `.local/` folder is not gitignored in every repo). The folder is the MAIN
|
|
674
|
-
checkout (a worktree shares its page); the 6 hex of its path keep two clones with one name apart.
|
|
695
|
+
checkout (a worktree shares its page); the 6 hex of its path keep two clones with one name apart. `base` (the
|
|
696
|
+
shared copy's build folder) stands in for the pages root."""
|
|
675
697
|
main = main_checkout(root)
|
|
676
698
|
tag = hashlib.sha1(str(main).lower().encode("utf-8")).hexdigest()[:6]
|
|
677
|
-
return pages_root(home) / f"{main.name}-{tag}" / f"{slug}.html"
|
|
699
|
+
return (base if base is not None else pages_root(home)) / f"{main.name}-{tag}" / f"{slug}.html"
|
|
678
700
|
|
|
679
701
|
|
|
680
|
-
def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path | None:
|
|
702
|
+
def write_page(root: Path, slug: str, out: Path | None, roadmap_mod, *, site_dir: Path | None = None) -> Path | None:
|
|
681
703
|
"""At the default path: `<slug>.body.html` (the page body, which the hub shows as a view of `index.html`), a
|
|
682
704
|
one-line `<slug>.html` that opens that view, and `<slug>.json` (the one-row summary the hub is built from). An
|
|
683
705
|
`--out` page is a one-off self-contained copy and stays out of the hub. None: a newer caddis wrote this page,
|
|
684
|
-
so it is kept.
|
|
685
|
-
|
|
686
|
-
|
|
706
|
+
so it is kept. `site_dir` (the shared copy's build folder) writes there instead, with no version gate, and
|
|
707
|
+
leaves this machine's waiting sessions and runner out of the model before anything is rendered."""
|
|
708
|
+
path = Path(out) if out else default_page_path(root, slug, base=site_dir)
|
|
709
|
+
if out is None and site_dir is None and _owned_by_newer(slug, path.with_suffix(".json")): # a skip does no work
|
|
687
710
|
return None
|
|
688
711
|
model = build(root, slug, roadmap_mod)
|
|
712
|
+
if site_dir is not None: # a CI run has no session marks and no runner: hide them, never show them as empty
|
|
713
|
+
model["sessions"] = []
|
|
714
|
+
model["runner"] = {"lock": None, "stale_lock": None, "last_stop": None, "errors": []}
|
|
689
715
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
690
716
|
if out is not None:
|
|
691
717
|
_write_atomic(path, render_html(model))
|
|
692
718
|
return path
|
|
693
|
-
_write_atomic(path.with_suffix(".body.html"), render_body(model))
|
|
719
|
+
_write_atomic(path.with_suffix(".body.html"), render_body(model, site=site_dir is not None))
|
|
694
720
|
rid = _rid(path.parent.name, model["roadmap"])
|
|
695
721
|
_write_atomic(path, _stub(rid, model["roadmap"]) if rid else render_html(model))
|
|
696
722
|
try: # the summary feeds the hub only: the page is written, so a failure here never fails the render
|
|
@@ -733,17 +759,45 @@ def is_newer(theirs, mine) -> bool:
|
|
|
733
759
|
|
|
734
760
|
|
|
735
761
|
GENERATOR_OWNERSHIP_S = 1800 # a newer caddis owns a page only while it keeps writing it (review of #71)
|
|
762
|
+
# While the newer version is still INSTALLED on this machine, it owns the page much longer: an older session that is
|
|
763
|
+
# still open must not undo a release (20 Oct 2026: a 1.3.145 session re-rendered the 1.3.147 hub in the old layout
|
|
764
|
+
# 30 minutes after the release). A real downgrade removes the newer version, and the 30 minutes apply again.
|
|
765
|
+
INSTALLED_OWNERSHIP_S = 24 * 3600
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def installed_versions(home: Path | None = None) -> set[str]:
|
|
769
|
+
"""The caddis versions installed on this machine: the Claude Code plugin cache folders and agy's plugin
|
|
770
|
+
manifest. Empty when none can be read."""
|
|
771
|
+
base = Path(home or Path.home())
|
|
772
|
+
found: set[str] = set()
|
|
773
|
+
try:
|
|
774
|
+
for d in (base / ".claude" / "plugins" / "cache" / "caddis" / "caddis").iterdir():
|
|
775
|
+
if d.is_dir() and _version_key(d.name) is not None:
|
|
776
|
+
found.add(d.name)
|
|
777
|
+
except OSError:
|
|
778
|
+
pass
|
|
779
|
+
data, _ = _read_json(base / ".gemini" / "config" / "plugins" / "caddis" / "plugin.json")
|
|
780
|
+
if isinstance(data, dict) and _version_key(data.get("version")) is not None:
|
|
781
|
+
found.add(str(data["version"]))
|
|
782
|
+
return found
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _ownership_s(theirs: str, home: Path | None = None) -> int:
|
|
786
|
+
"""How long a newer caddis owns what it wrote: a day while it is installed under `home`, else
|
|
787
|
+
GENERATOR_OWNERSHIP_S."""
|
|
788
|
+
return INSTALLED_OWNERSHIP_S if theirs in installed_versions(home) else GENERATOR_OWNERSHIP_S
|
|
736
789
|
|
|
737
790
|
|
|
738
791
|
def _newer_owner(summary: Path) -> tuple[str, bool]:
|
|
739
|
-
"""(version, live): the newer caddis that wrote `summary` ("" when it is not newer), and whether it wrote it
|
|
740
|
-
|
|
792
|
+
"""(version, live): the newer caddis that wrote `summary` ("" when it is not newer), and whether it wrote it
|
|
793
|
+
within its ownership window (`_ownership_s`). An unreadable time is not live: a downgrade must never freeze a
|
|
794
|
+
page."""
|
|
741
795
|
data, _ = _read_json(summary)
|
|
742
796
|
theirs = str((data or {}).get("generator") or "")
|
|
743
797
|
if not is_newer(theirs, generator_version()):
|
|
744
798
|
return "", False
|
|
745
799
|
age_h = _age_h(str((data or {}).get("generated_utc") or ""), datetime.datetime.now(datetime.timezone.utc))
|
|
746
|
-
return theirs, age_h is not None and age_h * 3600 <=
|
|
800
|
+
return theirs, age_h is not None and age_h * 3600 <= _ownership_s(theirs)
|
|
747
801
|
|
|
748
802
|
|
|
749
803
|
def _gate(label: str, theirs: str, live: bool) -> bool:
|
|
@@ -753,7 +807,7 @@ def _gate(label: str, theirs: str, live: bool) -> bool:
|
|
|
753
807
|
return True
|
|
754
808
|
if theirs:
|
|
755
809
|
print(f"[ROADMAP] {label}: taking over from caddis {theirs} (it has not written for "
|
|
756
|
-
f"{
|
|
810
|
+
f"{_ownership_s(theirs) // 60} min)")
|
|
757
811
|
return False
|
|
758
812
|
|
|
759
813
|
|
|
@@ -943,26 +997,121 @@ def _int(value) -> int:
|
|
|
943
997
|
return 0
|
|
944
998
|
|
|
945
999
|
|
|
1000
|
+
def _roadmap_link(r: dict, label=None) -> str:
|
|
1001
|
+
"""The roadmap's name linked to its view; plain text for a row that has no view."""
|
|
1002
|
+
rid = _rid(r.get("folder"), r.get("slug"))
|
|
1003
|
+
text = _e(r.get("slug") if label is None else label)
|
|
1004
|
+
return f'<a href="#{rid}">{text}</a>' if rid else text
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _repo_slug_key(r: dict) -> tuple[str, str, str]:
|
|
1008
|
+
return (str(r.get("repo", "")).lower(), str(r.get("folder", "")), str(r.get("slug", "")))
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _table(head: list[str], body: list[str]) -> str:
|
|
1012
|
+
return ('<div class="scroll"><table><tr>' + "".join(f"<th>{_e(h)}</th>" for h in head) + "</tr>"
|
|
1013
|
+
+ "".join(body) + "</table></div>")
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _gates_view(rows: list[dict]) -> str:
|
|
1017
|
+
items = []
|
|
1018
|
+
for r in rows:
|
|
1019
|
+
for b in r.get("blockers") or r.get("owner_gates") or []: # a v1 summary has owner_gates only
|
|
1020
|
+
items.append((r, b))
|
|
1021
|
+
items.sort(key=lambda rb: (str(rb[1].get("type") or "owner-gate") != "owner-gate", *_repo_slug_key(rb[0]),
|
|
1022
|
+
str(rb[1].get("milestone", ""))))
|
|
1023
|
+
body = []
|
|
1024
|
+
for r, b in items:
|
|
1025
|
+
rid, ms = _rid(r.get("folder"), r.get("slug")), str(b.get("milestone", ""))
|
|
1026
|
+
ms_text = _e(ms)
|
|
1027
|
+
if rid and re.fullmatch(r"[A-Za-z][\w-]*", ms): # the id is prefixed by `_prefix_ids` only when it is plain
|
|
1028
|
+
ms_text = f'<a href="#{rid}-ms-{ms_text}">{ms_text}</a>'
|
|
1029
|
+
kind = str(b.get("type") or "owner-gate")
|
|
1030
|
+
body.append(f'<tr><td><strong>{_e(r.get("repo"))}</strong></td><td>{_roadmap_link(r)}</td><td>{ms_text}</td>'
|
|
1031
|
+
f'<td><span class="kind k-{_e(kind)}">{_e(kind)}</span></td><td>{_e(b.get("reason"))}</td>'
|
|
1032
|
+
f'<td><code>{_e(b.get("check") or "-")}</code></td></tr>')
|
|
1033
|
+
if not body:
|
|
1034
|
+
return '<p class="empty">No open gates or blockers.</p>'
|
|
1035
|
+
return _table(["Repo", "Roadmap", "Milestone", "Type", "Reason", "Check"], body)
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _sessions_view(rows: list[dict]) -> str:
|
|
1039
|
+
out = []
|
|
1040
|
+
for _folder, group in _group_rows(rows)[0]:
|
|
1041
|
+
sessions = sorted(_sessions_of(group), key=lambda s: not s.get("asking")) # asking first, then by mark order
|
|
1042
|
+
if not sessions:
|
|
1043
|
+
continue
|
|
1044
|
+
body = []
|
|
1045
|
+
for s in sessions:
|
|
1046
|
+
stale = " (stale?)" if s.get("stale") else ""
|
|
1047
|
+
body.append(f'<tr><td><code>{_e(s.get("session"))}</code></td>'
|
|
1048
|
+
f'<td>{"asking" if s.get("asking") else "idle"}</td><td>{_e(s.get("message"))}</td>'
|
|
1049
|
+
f'<td>{_e(s.get("since"))}{_e(stale)}</td></tr>')
|
|
1050
|
+
out.append(f'<div class="card"><h3>{_e(group[0].get("repo"))}</h3>'
|
|
1051
|
+
+ _table(["Session", "Asking or idle", "Message", "Since"], body) + "</div>")
|
|
1052
|
+
return "".join(out) or '<p class="empty">No session is waiting.</p>'
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _runners_view(rows: list[dict]) -> str:
|
|
1056
|
+
body = []
|
|
1057
|
+
for r in sorted(rows, key=lambda r: (r.get("runner") != "running", *_repo_slug_key(r))):
|
|
1058
|
+
detail = r.get("runner_detail") if isinstance(r.get("runner_detail"), dict) else {}
|
|
1059
|
+
lock, stop = detail.get("lock"), detail.get("last_stop")
|
|
1060
|
+
batch = lock.get("batch") if isinstance(lock, dict) else None
|
|
1061
|
+
stop_text = " · ".join(str(v) for v in ((stop or {}).get("reason"), (stop or {}).get("utc")) if v) if isinstance(stop, dict) else ""
|
|
1062
|
+
runner = str(r.get("runner") or "")
|
|
1063
|
+
pill = ("s-in-progress" if runner == "running" else "s-blocked" if runner else "s-todo")
|
|
1064
|
+
body.append(f'<tr><td><strong>{_e(r.get("repo"))}</strong></td><td>{_roadmap_link(r)}</td>'
|
|
1065
|
+
f'<td><span class="pill {pill}">{_e(runner or "idle")}</span></td><td>{_e(batch or "-")}</td>'
|
|
1066
|
+
f'<td>{_e(stop_text or "-")}</td><td>{_e(r.get("next") or "-")}</td></tr>')
|
|
1067
|
+
if not body:
|
|
1068
|
+
return '<p class="empty">No roadmap pages yet.</p>'
|
|
1069
|
+
return _table(["Repo", "Roadmap", "Runner", "Batch", "Last stop", "Next"], body)
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
_VIEWS = {"gates": ("Gates", "Every open owner gate and blocker, across all repos. Owner gates come first.", _gates_view),
|
|
1073
|
+
"sessions": ("Sessions", "The newest session marks of each repo.", _sessions_view),
|
|
1074
|
+
"runners": ("Runners", "The roadmap runner of every roadmap on this machine.", _runners_view)}
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def render_view(kind: str, rows: list[dict]) -> str:
|
|
1078
|
+
"""`<section class="view" id="gates|sessions|runners">`: a cross-repo table over the page summaries. These sections
|
|
1079
|
+
sit outside any roadmap body, so their ids are not prefixed; links into a roadmap use its prefixed ids."""
|
|
1080
|
+
title, note, fn = _VIEWS[kind]
|
|
1081
|
+
return f'<section class="view" id="{kind}"><h2>{_e(title)}</h2><p class="sub">{_e(note)}</p>{fn(rows)}</section>'
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
_SITE_NOTE = "Shared copy — built by CI. Live sessions and runners show only on the owner's machine."
|
|
1085
|
+
_SITE_CSS = (".site-banner{margin:0 0 12px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;"
|
|
1086
|
+
"border-left:4px solid var(--accent);background:var(--panel2);color:var(--fg);font-size:13px}")
|
|
1087
|
+
|
|
1088
|
+
|
|
946
1089
|
def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
|
|
947
|
-
bodies: dict[str, str] | None = None) -> str:
|
|
1090
|
+
bodies: dict[str, str] | None = None, *, site: bool = False, banner: str = "") -> str:
|
|
948
1091
|
"""The whole app in one file: the inbox and overview view, then one view per roadmap (its body from `bodies`,
|
|
949
1092
|
ids prefixed). CSS shows one view at a time, so the nav is the same for every view and is never stale. While the
|
|
950
|
-
inbox shows, the page reloads itself every 60 s (the script), so an open tab follows the renders.
|
|
1093
|
+
inbox shows, the page reloads itself every 60 s (the script), so an open tab follows the renders. A shared copy
|
|
1094
|
+
(`site`, built by CI) shows `banner` and leaves out everything of this machine: the sessions and the runners,
|
|
1095
|
+
in the nav, the inbox and the views. Owner gates stay: they come from git."""
|
|
951
1096
|
bodies = bodies or {}
|
|
952
1097
|
errors = list(errors)
|
|
1098
|
+
if site: # whatever a summary holds of live state is dropped here too, not only at the writer
|
|
1099
|
+
rows = [{**r, "sessions": [], "runner": "", "runner_detail": {}} for r in rows]
|
|
953
1100
|
order, need, count = _group_rows(rows)
|
|
954
1101
|
phases_done = sum(_int(r.get("phases_done")) for r in rows)
|
|
955
1102
|
phases_total = sum(_int(r.get("phases_total")) for r in rows)
|
|
956
1103
|
live = sum(1 for r in rows if r.get("runner") == "running")
|
|
957
1104
|
blocked = sum(_int(r.get("blocked")) for r in rows)
|
|
958
|
-
out = ['<
|
|
1105
|
+
out = [f'<p class="site-banner" role="note">{_e(banner or _SITE_NOTE)}</p>' if site else "",
|
|
1106
|
+
'<section class="view" id="inbox"><section class="kpis">',
|
|
959
1107
|
f'<div class="kpi"><b>{_e(count)}</b>Needs you</div>',
|
|
960
1108
|
f'<div class="kpi"><b>{_e(phases_done)} / {_e(phases_total)}</b>Phases done'
|
|
961
1109
|
f'{_bar(phases_done, phases_total)}</div>',
|
|
962
1110
|
f'<div class="kpi"><b>{_e(blocked)}</b>Blocked phases</div>',
|
|
963
|
-
f'<div class="kpi"><b>{_e(live)}</b>Runners live</div
|
|
1111
|
+
"" if site else f'<div class="kpi"><b>{_e(live)}</b>Runners live</div>', "</section>",
|
|
964
1112
|
f'<p class="sub">{_e(len(order))} repos · {_e(len(rows))} roadmaps · {_e(count)} item(s) need you · '
|
|
965
|
-
f'generated {_e(now.strftime("%Y-%m-%d %H:%M UTC"))}
|
|
1113
|
+
f'generated {_e(now.strftime("%Y-%m-%d %H:%M UTC"))}'
|
|
1114
|
+
+ ("" if site else " · the inbox reloads every minute") + "</p>",
|
|
966
1115
|
'<section id="needs" class="card wait"><h2>Needs you</h2>']
|
|
967
1116
|
if not need:
|
|
968
1117
|
out.append('<p class="empty">Nothing is waiting for you.</p>')
|
|
@@ -997,13 +1146,13 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
|
|
|
997
1146
|
f'<div>{_bar(_int(r.get("phases_done")), _int(r.get("phases_total")))} '
|
|
998
1147
|
f'{_e(r.get("milestones_done"))}/{_e(r.get("milestones_total"))} milestones</div>'
|
|
999
1148
|
f'<div class="dots">{dots}</div>'
|
|
1000
|
-
f'<div class="empty">next: {_e(r.get("next") or "-")}
|
|
1001
|
-
f'{_e(old)}</div></a>')
|
|
1149
|
+
f'<div class="empty">next: {_e(r.get("next") or "-")}'
|
|
1150
|
+
+ ("" if site else f' · runner: {_e(r.get("runner") or "idle")}') + f'{_e(old)}</div></a>')
|
|
1002
1151
|
out.append("</div>")
|
|
1003
1152
|
for err in errors:
|
|
1004
1153
|
out.append(f'<p class="empty">Could not read {_e(err)}.</p>')
|
|
1005
|
-
out.append('<p class="empty">Built from the
|
|
1006
|
-
"</p></section>")
|
|
1154
|
+
out.append('<p class="empty">' + ("Built by CI from the roadmaps on origin." if site else
|
|
1155
|
+
"Built from the page summaries in this folder. Every roadmap page render rebuilds it.") + "</p></section>")
|
|
1007
1156
|
rids = []
|
|
1008
1157
|
for r in sorted(rows, key=lambda r: (str(r.get("repo", "")).lower(), str(r["folder"]), str(r["slug"]))):
|
|
1009
1158
|
rid = _rid(r.get("folder"), r.get("slug"))
|
|
@@ -1016,27 +1165,33 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
|
|
|
1016
1165
|
"in its repo.</p>")
|
|
1017
1166
|
out.append(f'<section class="view" id="{rid}"><p class="view-crumbs"><a href="#inbox">Roadmaps</a> › '
|
|
1018
1167
|
f'{_e(r.get("repo"))} › <strong>{_e(r["slug"])}</strong></p>{inner}</section>')
|
|
1019
|
-
for
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1168
|
+
for kind in _VIEWS:
|
|
1169
|
+
if not (site and kind in _LOCAL_VIEWS):
|
|
1170
|
+
out.append(render_view(kind, rows))
|
|
1171
|
+
return _shell(title="Roadmaps", base="", nav=_nav(rows, "", site), crumbs="Roadmaps", body="".join(out),
|
|
1172
|
+
css=_view_css(rids, site) + (_SITE_CSS if site else ""),
|
|
1173
|
+
script=_SITE_THEME_SCRIPT if site else None)
|
|
1025
1174
|
|
|
1026
1175
|
|
|
1027
|
-
def write_hub(home: Path | None = None, now: datetime.datetime | None = None
|
|
1176
|
+
def write_hub(home: Path | None = None, now: datetime.datetime | None = None, *,
|
|
1177
|
+
site_dir: Path | None = None, banner: str = "") -> Path | None:
|
|
1028
1178
|
"""`~/.caddis/roadmap-pages/index.html`: every roadmap page on this machine, grouped per repo. None: a newer
|
|
1029
|
-
caddis wrote the hub (its version is in `.hub-generator`), so it is kept.
|
|
1030
|
-
|
|
1179
|
+
caddis wrote the hub (its version is in `.hub-generator`), so it is kept. `site_dir` (the shared copy's build
|
|
1180
|
+
folder) is written instead, with `banner`, with no live sessions or runners, and with no version gate and no
|
|
1181
|
+
stamp: CI is the only writer of that folder."""
|
|
1182
|
+
root_dir = pages_root(home) if site_dir is None else Path(site_dir)
|
|
1031
1183
|
root_dir.mkdir(parents=True, exist_ok=True)
|
|
1032
1184
|
stamp = root_dir / ".hub-generator"
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1185
|
+
if site_dir is None:
|
|
1186
|
+
try:
|
|
1187
|
+
theirs = stamp.read_text(encoding="utf-8").strip()
|
|
1188
|
+
if not is_newer(theirs, generator_version()):
|
|
1189
|
+
theirs = ""
|
|
1190
|
+
live = bool(theirs) and time.time() - stamp.stat().st_mtime <= _ownership_s(theirs, home)
|
|
1191
|
+
except (OSError, UnicodeDecodeError):
|
|
1192
|
+
theirs, live = "", False
|
|
1193
|
+
if _gate("hub", theirs, live):
|
|
1194
|
+
return None
|
|
1040
1195
|
try:
|
|
1041
1196
|
install_assets(root_dir)
|
|
1042
1197
|
except Exception as exc: # a missing font file must never stop the hub
|
|
@@ -1048,6 +1203,103 @@ def write_hub(home: Path | None = None, now: datetime.datetime | None = None) ->
|
|
|
1048
1203
|
print(f"[ROADMAP] page bodies not read: {exc}", file=sys.stderr)
|
|
1049
1204
|
bodies = {}
|
|
1050
1205
|
path = root_dir / "index.html"
|
|
1051
|
-
|
|
1206
|
+
now = now or datetime.datetime.now(datetime.timezone.utc)
|
|
1207
|
+
if site_dir is not None:
|
|
1208
|
+
_write_atomic(path, render_hub(rows, errors, now, bodies, site=True, banner=banner))
|
|
1209
|
+
return path
|
|
1210
|
+
_write_atomic(path, render_hub(rows, errors, now, bodies))
|
|
1052
1211
|
_write_atomic(stamp, generator_version() + "\n")
|
|
1053
1212
|
return path
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
# ---- the shared copy: `render --site <dir>` builds in <dir>/build and publishes an allow-list to <dir>/public -----
|
|
1216
|
+
|
|
1217
|
+
def _short_sha(root: Path) -> str:
|
|
1218
|
+
try:
|
|
1219
|
+
done = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=root, capture_output=True, text=True,
|
|
1220
|
+
stdin=subprocess.DEVNULL, timeout=15,
|
|
1221
|
+
creationflags=0x08000000 if os.name == "nt" else 0)
|
|
1222
|
+
except (OSError, subprocess.SubprocessError):
|
|
1223
|
+
return "unknown"
|
|
1224
|
+
return done.stdout.strip() or "unknown" if done.returncode == 0 else "unknown"
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def site_banner(root: Path, now: datetime.datetime | None = None, env=None) -> str:
|
|
1228
|
+
"""The shared copy's banner (plain text; `render_hub` escapes it). CI names the repo and commit in
|
|
1229
|
+
`GITHUB_REPOSITORY` and `GITHUB_SHA`; on a local run they are the checkout's folder name and `git rev-parse`."""
|
|
1230
|
+
env = os.environ if env is None else env
|
|
1231
|
+
repo = (env.get("GITHUB_REPOSITORY") or "").strip() or main_checkout(Path(root)).name
|
|
1232
|
+
sha = (env.get("GITHUB_SHA") or "").strip()[:7] or _short_sha(Path(root))
|
|
1233
|
+
when = (now or datetime.datetime.now(datetime.timezone.utc)).strftime("%Y-%m-%d %H:%M UTC")
|
|
1234
|
+
return (f"Shared copy — built by CI from {repo} at {sha}, {when}. "
|
|
1235
|
+
"Live sessions and runners show only on the owner's machine.")
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _is_link(path: Path) -> bool:
|
|
1239
|
+
"""A symlink or a Windows junction: never followed by the export."""
|
|
1240
|
+
if path.is_symlink():
|
|
1241
|
+
return True
|
|
1242
|
+
try:
|
|
1243
|
+
return bool(os.lstat(path).st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT) # Windows only
|
|
1244
|
+
except (AttributeError, OSError):
|
|
1245
|
+
return False
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _plain_file(path: Path) -> bool:
|
|
1249
|
+
return path.is_file() and not _is_link(path)
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def export_site(build: Path, public: Path) -> list[str]:
|
|
1253
|
+
"""Copy the allow-list of `build` to a fresh `public` and return the published paths (sorted, `/` separated):
|
|
1254
|
+
`index.html`, every file under `_assets/` except `*.json`, `*.body.html` and dot files, and each
|
|
1255
|
+
`<folder>/<slug>.html` whose `<slug>.json` sits beside it and whose slug passes the slug rule. Nothing else is
|
|
1256
|
+
published: not a `*.json` (the local checkout path and session data), not a `*.body.html`, not `.locks/`, not
|
|
1257
|
+
`.hub-generator`, not a dot file or folder, and not a link. `public` is wiped first, but only a folder this
|
|
1258
|
+
function made (a marker beside it says so), or an empty one; a link, a file, `build` itself and a folder that
|
|
1259
|
+
holds `build` or sits in it are refused (ValueError)."""
|
|
1260
|
+
build, public = Path(build), Path(public)
|
|
1261
|
+
if public.name in ("", ".", ".."):
|
|
1262
|
+
raise ValueError(f"the public folder needs a plain folder name, not {str(public)!r}")
|
|
1263
|
+
if not build.is_dir() or _is_link(build):
|
|
1264
|
+
raise ValueError(f"the build folder is not a plain folder: {build}")
|
|
1265
|
+
b = build.resolve()
|
|
1266
|
+
p = public.parent.resolve() / public.name
|
|
1267
|
+
if _is_link(public) or (public.exists() and not public.is_dir()):
|
|
1268
|
+
raise ValueError(f"the public folder is a link or a file: {public}")
|
|
1269
|
+
if p == b or b in p.parents or p in b.parents:
|
|
1270
|
+
raise ValueError(f"the public folder and the build folder overlap: {public}")
|
|
1271
|
+
marker = public.parent / f".{public.name}.created-by-caddis"
|
|
1272
|
+
if public.exists():
|
|
1273
|
+
if not marker.exists() and any(public.iterdir()):
|
|
1274
|
+
raise ValueError(f"{public} exists and was not made by this command; remove it or choose another folder")
|
|
1275
|
+
shutil.rmtree(public)
|
|
1276
|
+
public.mkdir(parents=True)
|
|
1277
|
+
marker.write_text("made by caddis_roadmap.py render --site; wiped on each run\n", encoding="utf-8")
|
|
1278
|
+
out: list[str] = []
|
|
1279
|
+
|
|
1280
|
+
def put(src: Path, rel: str) -> None:
|
|
1281
|
+
dst = public / rel
|
|
1282
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1283
|
+
shutil.copyfile(src, dst)
|
|
1284
|
+
out.append(rel)
|
|
1285
|
+
|
|
1286
|
+
if _plain_file(build / "index.html"):
|
|
1287
|
+
put(build / "index.html", "index.html")
|
|
1288
|
+
assets = build / "_assets"
|
|
1289
|
+
if assets.is_dir() and not _is_link(assets):
|
|
1290
|
+
for dirpath, dirnames, filenames in os.walk(assets, followlinks=False):
|
|
1291
|
+
here = Path(dirpath)
|
|
1292
|
+
dirnames[:] = sorted(d for d in dirnames if not d.startswith(".") and not _is_link(here / d))
|
|
1293
|
+
for name in sorted(filenames):
|
|
1294
|
+
if name.startswith(".") or name.endswith((".json", ".body.html")) or not _plain_file(here / name):
|
|
1295
|
+
continue
|
|
1296
|
+
put(here / name, (here / name).relative_to(build).as_posix())
|
|
1297
|
+
for folder in sorted(build.iterdir()):
|
|
1298
|
+
if folder.name.startswith(".") or folder.name == "_assets" or not folder.is_dir() or _is_link(folder):
|
|
1299
|
+
continue
|
|
1300
|
+
for page_file in sorted(folder.glob("*.html")):
|
|
1301
|
+
slug = page_file.stem
|
|
1302
|
+
if (page_file.suffix == ".html" and _SLUG_RE.fullmatch(slug) and _plain_file(page_file)
|
|
1303
|
+
and _plain_file(folder / f"{slug}.json")):
|
|
1304
|
+
put(page_file, f"{folder.name}/{page_file.name}")
|
|
1305
|
+
return sorted(out)
|
|
@@ -30,7 +30,8 @@ with new commits on that branch (still checked out, nothing under `.caddis/roadm
|
|
|
30
30
|
persistent claim for the phase, pushes the branch, opens a PR (`gh api`) and stops `BLOCKED waiting for merge
|
|
31
31
|
<plan>#<phase> <url>` (exit 1); `--last-stop` lets the recheck task restart it once the PR merged; the restart checks
|
|
32
32
|
the merged origin roadmap against the hash taken at publish time, removes the claim, rolls that milestone up and goes
|
|
33
|
-
on. Every roadmap runs its sessions in ONE dedicated worktree,
|
|
33
|
+
on. Every roadmap runs its sessions in ONE dedicated worktree, `<worktree root>/<repo>/caddis-roadmap-<slug>-<repo tag>`
|
|
34
|
+
(`$CADDIS_WORKTREE_ROOT`, else `<parent of the main checkout>/.worktrees`, never %TEMP%; `caddis_worktree_root.py`; made with
|
|
34
35
|
`git worktree add --detach` from origin/<default> on the first session, reused on restart, recorded in the runner
|
|
35
36
|
folder, never the `--repo` checkout); when the roadmap is finished the runner removes it if it is clean, and keeps it
|
|
36
37
|
(saying so in the stop line) if not. The caps (hours, sessions, USD per session and in total) are per run, in memory. CONTINUE with the phase already merged on
|
|
@@ -111,7 +112,6 @@ import re
|
|
|
111
112
|
import socket
|
|
112
113
|
import subprocess
|
|
113
114
|
import sys
|
|
114
|
-
import tempfile
|
|
115
115
|
import time
|
|
116
116
|
import uuid
|
|
117
117
|
from collections import Counter
|
|
@@ -135,6 +135,7 @@ import caddis_redact # noqa: E402
|
|
|
135
135
|
import caddis_review # noqa: E402
|
|
136
136
|
import caddis_roadmap # noqa: E402
|
|
137
137
|
import caddis_spend # noqa: E402
|
|
138
|
+
import caddis_worktree_root # noqa: E402
|
|
138
139
|
import caddis_worktree_safety # noqa: E402
|
|
139
140
|
import caddis_workstreams # noqa: E402
|
|
140
141
|
|
|
@@ -899,6 +900,9 @@ def _one_session(ctx: _Ctx, prompt: str, relay_slug: str, n: int, spent: float,
|
|
|
899
900
|
cmd = build_command(ctx.engine, ctx.model, prompt, usd=usd, timeout_min=remaining_s / 60, governor=ctx.governor,
|
|
900
901
|
engine_cmd=ctx.engine_cmd, worktree=cwd)
|
|
901
902
|
rid = _spend_reserve(ctx.home, spend_slug, ctx.now(), n > 1) if ctx.engine == "agy" else None
|
|
903
|
+
if ctx.engine == "agy": # agy's tool consoles open on a hidden desktop, not on the owner's screen
|
|
904
|
+
g = len(ctx.governor)
|
|
905
|
+
cmd = [*cmd[:g], *caddis_spend.hidden_argv(cmd[g:], session_env())]
|
|
902
906
|
launched, outcome = ctx.clock(), None
|
|
903
907
|
try:
|
|
904
908
|
_, hash_before = _read_relay(cwd, relay_slug)
|
|
@@ -1542,11 +1546,13 @@ def _registered_worktrees(repo: Path) -> set[Path]:
|
|
|
1542
1546
|
|
|
1543
1547
|
|
|
1544
1548
|
def worktree_path(repo: Path, slug: str, root: Path | None = None) -> Path:
|
|
1545
|
-
"""The roadmap's session worktree: the recorded path, else
|
|
1549
|
+
"""The roadmap's session worktree: the recorded path, else (a NEW one) `caddis-roadmap-<slug>-<repo tag>` under
|
|
1550
|
+
`<worktree root>/<repo>/` (`caddis_worktree_root`: `root`, `$CADDIS_WORKTREE_ROOT`, else `<parent of the main
|
|
1551
|
+
checkout>/.worktrees`; never %TEMP%)."""
|
|
1546
1552
|
recorded = _read_record(_state_file(repo, slug, "worktree")).get("path")
|
|
1547
1553
|
if isinstance(recorded, str) and recorded:
|
|
1548
1554
|
return Path(recorded)
|
|
1549
|
-
return
|
|
1555
|
+
return caddis_worktree_root.worktree_path(repo, f"{WORKTREE_PREFIX}{slug}-{_repo_tag(repo)}", root)
|
|
1550
1556
|
|
|
1551
1557
|
|
|
1552
1558
|
def _repo_tag(repo: Path) -> str:
|