@caddis/cli 0.4.60 → 0.4.62
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/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 +2 -1
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +62 -5
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +406 -98
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +3 -0
- 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/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
|
|
@@ -217,8 +219,8 @@ def build(root: Path, slug: str, roadmap_mod, now: datetime.datetime | None = No
|
|
|
217
219
|
|
|
218
220
|
# One colour set for every page: light = slate + blue, dark = Dracula (owner, 17 Oct): https://draculatheme.com/contribute
|
|
219
221
|
# Dark applies when the OS asks for it, unless the reader picked light; the toggle sets data-theme on <html>.
|
|
220
|
-
_PALETTE_LIGHT = '--bg:#f4f6f9;--panel:#fff;--panel2:#f8fafc;--fg:#0f172a;--muted:#475569;--line:#e2e8f0;--line2:#cbd5e1;--accent:#2563eb;--todo-bg:#f1f5f9;--todo-fg:#334155;--prog:#2563eb;--prog-bg:#dbeafe;--prog-fg:#1d4ed8;--done:#16a34a;--done-bg:#dcfce7;--done-fg:#15803d;--block:#d97706;--block-bg:#fef3c7;--block-fg:#92400e;--high:#dc2626;--wait-bg:#fee2e2;--wait-fg:#b91c1c;--nav:#f8fafc;--nav-fg:#0f172a;--on-accent:#fff;--shadow:0 1px 3px rgba(15,23,42,.06);--title:var(--fg);--summary:var(--fg);--code:var(--fg);--sans:"Maple Mono","Segoe UI Variable","Segoe UI",system-ui,sans-serif;--mono:"JetBrains Mono","Cascadia Mono",Consolas,ui-monospace,monospace;color-scheme:light;'
|
|
221
|
-
_PALETTE_DARK = '--bg:#282a36;--panel:#21222c;--panel2:#343746;--fg:#f8f8f2;--muted:#6272a4;--line:#44475a;--line2:#6272a4;--accent:#8be9fd;--todo-bg:#44475a;--todo-fg:#f8f8f2;--prog:#bd93f9;--prog-bg:#3c3352;--prog-fg:#bd93f9;--done:#50fa7b;--done-bg:#2b4a36;--done-fg:#50fa7b;--block:#ffb86c;--block-bg:#4a3a2b;--block-fg:#ffb86c;--high:#ff5555;--wait-bg:#4a2b33;--wait-fg:#ff5555;--nav:#21222c;--nav-fg:#f8f8f2;--on-accent:#282a36;--shadow:none;--title:#bd93f9;--summary:#ff79c6;--code:#f1fa8c;color-scheme:dark;'
|
|
222
|
+
_PALETTE_LIGHT = '--bg:#f4f6f9;--panel:#fff;--panel2:#f8fafc;--fg:#0f172a;--muted:#475569;--line:#e2e8f0;--line2:#cbd5e1;--accent:#2563eb;--todo-bg:#f1f5f9;--todo-fg:#334155;--prog:#2563eb;--prog-bg:#dbeafe;--prog-fg:#1d4ed8;--done:#16a34a;--done-bg:#dcfce7;--done-fg:#15803d;--block:#d97706;--block-bg:#fef3c7;--block-fg:#92400e;--high:#dc2626;--wait-bg:#fee2e2;--wait-fg:#b91c1c;--nav:#f8fafc;--nav-fg:#0f172a;--on-accent:#fff;--shadow:0 1px 3px rgba(15,23,42,.06);--title:var(--fg);--summary:var(--fg);--code:var(--fg);--c0:#2563eb;--c1:#7c3aed;--c2:#0d9488;--c3:#ea580c;--c4:#db2777;--c5:#475569;--sans:"Maple Mono","Segoe UI Variable","Segoe UI",system-ui,sans-serif;--mono:"JetBrains Mono","Cascadia Mono",Consolas,ui-monospace,monospace;color-scheme:light;'
|
|
223
|
+
_PALETTE_DARK = '--bg:#282a36;--panel:#21222c;--panel2:#343746;--fg:#f8f8f2;--muted:#6272a4;--line:#44475a;--line2:#6272a4;--accent:#8be9fd;--todo-bg:#44475a;--todo-fg:#f8f8f2;--prog:#bd93f9;--prog-bg:#3c3352;--prog-fg:#bd93f9;--done:#50fa7b;--done-bg:#2b4a36;--done-fg:#50fa7b;--block:#ffb86c;--block-bg:#4a3a2b;--block-fg:#ffb86c;--high:#ff5555;--wait-bg:#4a2b33;--wait-fg:#ff5555;--nav:#21222c;--nav-fg:#f8f8f2;--on-accent:#282a36;--shadow:none;--title:#bd93f9;--summary:#ff79c6;--code:#f1fa8c;--c0:#8be9fd;--c1:#bd93f9;--c2:#50fa7b;--c3:#ffb86c;--c4:#ff79c6;--c5:#6272a4;color-scheme:dark;'
|
|
222
224
|
_THEME_CSS = (":root{" + _PALETTE_LIGHT + "}" + '@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){'
|
|
223
225
|
+ _PALETTE_DARK + "}}" + ':root[data-theme="dark"]{' + _PALETTE_DARK + "}")
|
|
224
226
|
|
|
@@ -230,7 +232,7 @@ h1{font-size:24px;margin:0;color:var(--title)}h2{font-size:16px;margin:0}.sub{co
|
|
|
230
232
|
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
|
|
231
233
|
details.card>summary{cursor:pointer;font-weight:700;font-size:16px;list-style:none;color:var(--summary)}
|
|
232
234
|
details.card>summary::-webkit-details-marker{display:none}details.card>summary::before{content:"\\25B8 ";color:var(--muted)}
|
|
233
|
-
details.card[open]>summary::before{content:"\\25BE "}details.card
|
|
235
|
+
details.card[open]>summary::before{content:"\\25BE "}details.card+details.card{margin-top:8px}
|
|
234
236
|
.wait{border-color:var(--high)}.wait h2{color:var(--wait-fg)}.empty{color:var(--muted)}
|
|
235
237
|
table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:7px 9px;border-top:1px solid var(--line);
|
|
236
238
|
vertical-align:top}th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);border-top:0}
|
|
@@ -239,15 +241,21 @@ padding:1px 8px;border-radius:999px;white-space:nowrap}
|
|
|
239
241
|
.s-todo{background:var(--todo-bg);color:var(--todo-fg)}.s-in-progress{background:var(--prog-bg);color:var(--prog-fg)}
|
|
240
242
|
.s-done,.s-moved,.s-not-needed{background:var(--done-bg);color:var(--done-fg)}.s-blocked{background:var(--block-bg);color:var(--block-fg)}
|
|
241
243
|
.s-code-done\\/data-pending{background:var(--block-bg);color:var(--block-fg)}
|
|
242
|
-
.lanes{display:grid
|
|
244
|
+
.lanes{display:grid}.lanes>div{border-top:1px solid var(--line);border-left:1px dashed var(--line);
|
|
243
245
|
padding:6px}.lanes .hd{background:var(--panel2);border-top:0;font-weight:700}.lanes .ln{position:sticky;left:0;z-index:1;
|
|
244
|
-
background:var(--panel);border-left:
|
|
245
|
-
.tile{display:flex;gap:
|
|
246
|
+
background:var(--panel);border-left:4px solid var(--lc,var(--line2));border-right:1px solid var(--line2);font-weight:600;overflow-wrap:anywhere}
|
|
247
|
+
.tile{display:flex;gap:5px;align-items:center;font-size:12px;padding:3px 5px;position:relative;margin:0 0 4px;border-radius:5px;
|
|
246
248
|
border:1px solid var(--line2);background:var(--panel);max-width:100%}.tile b{font:600 11px var(--mono)}
|
|
247
|
-
.tile span{
|
|
249
|
+
.tile span{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;white-space:normal;overflow-wrap:anywhere}.tile.in-progress{background:var(--prog-bg);
|
|
248
250
|
border-color:var(--prog);color:var(--prog-fg)}.tile.done{background:var(--done-bg);border-color:var(--done);
|
|
249
251
|
color:var(--done-fg)}.tile.moved{background:repeating-linear-gradient(135deg,var(--done-bg) 0 5px,var(--panel) 5px 10px);
|
|
250
252
|
border-color:var(--done)}.tile.blocked{background:var(--block-bg);border-color:var(--block);color:var(--block-fg)}
|
|
253
|
+
.tile.hi::after{position:absolute;top:3px;right:3px;margin:0}
|
|
254
|
+
.ltile{display:flex;gap:8px;align-items:baseline;font-size:13px;padding:5px 8px;margin:0 0 4px;border-radius:6px;border:1px solid var(--line2);background:var(--panel)}.ltile b{font:600 11px var(--mono);white-space:nowrap}
|
|
255
|
+
.ltile.in-progress{background:var(--prog-bg);border-color:var(--prog);color:var(--prog-fg)}.ltile.done{background:var(--done-bg);border-color:var(--done);color:var(--done-fg)}
|
|
256
|
+
.ltile.moved{background:repeating-linear-gradient(135deg,var(--done-bg) 0 5px,var(--panel) 5px 10px);border-color:var(--done)}.ltile.blocked{background:var(--block-bg);border-color:var(--block);color:var(--block-fg)}
|
|
257
|
+
.bcount{display:inline-block;font-size:11px;font-weight:700;color:var(--block-fg);background:var(--block-bg);border:1px dashed var(--block);border-radius:6px;padding:0 6px;margin-top:3px}
|
|
258
|
+
.lane-list{display:none}.lane-list h3{margin:10px 0 6px}@media (max-width:900px){.timeline .scroll{display:none}.lane-list{display:block}}
|
|
251
259
|
.hi::after{content:"";width:7px;height:7px;border-radius:50%;background:var(--high);flex:none;margin-left:auto}
|
|
252
260
|
.gate{font-size:11px;color:var(--block-fg);background:var(--block-bg);border:1px dashed var(--block);border-radius:6px;
|
|
253
261
|
padding:3px 6px;margin:2px 0;white-space:normal;overflow-wrap:anywhere}.legend{display:flex;flex-wrap:wrap;gap:12px;font-size:12px;color:var(--muted);margin-top:8px}
|
|
@@ -257,6 +265,11 @@ padding:3px 6px;margin:2px 0;white-space:normal;overflow-wrap:anywhere}.legend{d
|
|
|
257
265
|
.bar i{display:block;height:100%;background:var(--done)}a{color:var(--accent)}td.num{white-space:nowrap}
|
|
258
266
|
.theme-btn{font:600 12px var(--sans);padding:6px 11px;border-radius:999px;border:1px solid var(--line);background:var(--panel2);color:var(--fg);cursor:pointer}
|
|
259
267
|
.hdr{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
|
|
268
|
+
.chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}.chip{border:1px solid var(--line);border-left:4px solid var(--lc);border-radius:8px;padding:5px 10px;background:var(--panel2);font-size:12.5px}.chip span{color:var(--muted);font-family:var(--mono)}
|
|
269
|
+
.flow{display:flex;gap:6px;align-items:stretch;min-width:max-content}.fnode{display:grid;gap:3px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--panel2);text-decoration:none;color:inherit;min-width:120px}.fnode span{font:11px var(--mono);color:var(--muted)}.fnode span.pill{font:700 11px var(--sans);justify-self:start}.arrow{align-self:center;color:var(--muted)}
|
|
270
|
+
.kind{font-size:10.5px;font-weight:700;text-transform:uppercase;padding:2px 8px;border-radius:6px;background:var(--block-bg);color:var(--block-fg)}.k-owner-gate{background:var(--wait-bg);color:var(--wait-fg)}
|
|
271
|
+
.note{color:var(--muted);font-weight:400;font-size:13px}.sec-h{display:flex;flex-wrap:wrap;gap:4px 10px;align-items:baseline;margin-bottom:8px}h3{font-size:14px;margin:0 0 8px}
|
|
272
|
+
.cols .rcard{margin-bottom:8px}.cols .rcard .bar{margin:0}
|
|
260
273
|
"""
|
|
261
274
|
|
|
262
275
|
_CSS = _THEME_CSS + _BASE_CSS
|
|
@@ -265,6 +278,8 @@ _CSS = _THEME_CSS + _BASE_CSS
|
|
|
265
278
|
# (an open roadmap view is never reset); the phone menu closes when the view changes; and the nav item of the
|
|
266
279
|
# shown view gets aria-current. No network.
|
|
267
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)
|
|
268
283
|
_THEME_BUTTON = '<button type="button" class="theme-btn" onclick="caddisTheme()" aria-label="Switch between light and dark theme">◐ Theme</button>'
|
|
269
284
|
|
|
270
285
|
|
|
@@ -285,7 +300,7 @@ _SHELL_CSS = """
|
|
|
285
300
|
.main{min-width:0}.top{display:flex;align-items:center;gap:12px;padding:10px 20px;border-bottom:1px solid var(--line);background:var(--panel);position:sticky;top:0;z-index:5}
|
|
286
301
|
.crumbs{flex:1;min-width:0;font-size:13px;color:var(--muted)}.crumbs a{color:var(--accent);text-decoration:none}
|
|
287
302
|
main [id]{scroll-margin-top:60px}main>.view{scroll-margin-top:200px}
|
|
288
|
-
.view{display:none}.view:target,.view:has(:target){display:block}main:not(:has(.view:target,.view :target))>#inbox{display:block}
|
|
303
|
+
.view>*+*{margin-top:16px}.view{display:none}.view:target,.view:has(:target){display:block}main:not(:has(.view:target,.view :target))>#inbox{display:block}
|
|
289
304
|
.nav-sub{display:none}.view-crumbs{font-size:13px;color:var(--muted);margin:0 0 8px}
|
|
290
305
|
.nav-cb{display:none}.nav-btn,.nav-close,.nav-scrim{display:none}
|
|
291
306
|
.nav-cb:focus-visible~.app .nav-btn{outline:2px solid var(--accent);outline-offset:2px}
|
|
@@ -307,13 +322,14 @@ def _pill(status: str) -> str:
|
|
|
307
322
|
return f'<span class="pill s-{_e(status)}">{_e(_LABEL.get(status, status))}</span>'
|
|
308
323
|
|
|
309
324
|
|
|
310
|
-
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:
|
|
311
327
|
"""The app frame around every page: head, left nav, top bar (crumbs and the theme button) and the body.
|
|
312
328
|
`nav`, `crumbs`, `body` and `css` are text the caller has already made safe; `title` is plain text."""
|
|
313
329
|
return "".join([
|
|
314
330
|
'<!doctype html><html lang="en"><head><meta charset="utf-8">',
|
|
315
331
|
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
316
|
-
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>",
|
|
317
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">',
|
|
318
334
|
'<header class="top"><label for="nav-cb" class="nav-btn" aria-label="Open the menu">☰</label>',
|
|
319
335
|
f'<div class="crumbs">{crumbs}</div>{_THEME_BUTTON}</header>',
|
|
@@ -349,10 +365,12 @@ def _group_rows(rows: list[dict]) -> tuple[list[tuple[str, list[dict]]], list[tu
|
|
|
349
365
|
return order, need, count
|
|
350
366
|
|
|
351
367
|
|
|
352
|
-
def _nav(rows: list[dict], base: str) -> str:
|
|
368
|
+
def _nav(rows: list[dict], base: str, site: bool = False) -> str:
|
|
353
369
|
"""The left nav: Needs you (inbox, gates, sessions, runners), then every repo with its roadmaps. Every link opens a
|
|
354
370
|
view of `index.html` (`#inbox`, `#<rid>`, ...); on the hub itself (`base` "") the link is the bare `#view`. The
|
|
355
|
-
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."""
|
|
356
374
|
order, _need, count = _group_rows(rows)
|
|
357
375
|
counts = {"inbox": count, "gates": sum(len(r.get("blockers") or r.get("owner_gates") or []) for r in rows),
|
|
358
376
|
"sessions": sum(len(_sessions_of(group)) for _f, group in order),
|
|
@@ -361,11 +379,13 @@ def _nav(rows: list[dict], base: str) -> str:
|
|
|
361
379
|
out = ['<nav class="nav" aria-label="Roadmaps">'
|
|
362
380
|
'<label for="nav-cb" class="nav-close" aria-label="Close the menu">✕</label>',
|
|
363
381
|
f'<a class="brand" href="{_e(hub or "#inbox")}">caddis · Roadmaps</a>', '<p class="nav-h">Needs you</p>']
|
|
364
|
-
for key, label in
|
|
382
|
+
for key, label in _NAV_ITEMS:
|
|
383
|
+
if site and key in _LOCAL_VIEWS:
|
|
384
|
+
continue
|
|
365
385
|
out.append(f'<a class="nav-i" href="{_e(hub)}#{key}">{_e(label)}<span class="n">{_e(counts[key])}</span></a>')
|
|
366
386
|
out.append('<p class="nav-h">Repos</p>')
|
|
367
387
|
for folder, group in order:
|
|
368
|
-
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"))
|
|
369
389
|
idle_txt = f" · {idle} idle session(s)" if idle else ""
|
|
370
390
|
out.append(f'<p class="nav-repo">{_e(group[0].get("repo"))}{_e(idle_txt)}</p>')
|
|
371
391
|
for r in sorted(group, key=lambda r: str(r["slug"])):
|
|
@@ -376,22 +396,29 @@ def _nav(rows: list[dict], base: str) -> str:
|
|
|
376
396
|
pct = int(round(100 * _int(r.get("phases_done")) / total)) if total else 0
|
|
377
397
|
out.append(f'<a class="nav-i nav-rm" href="{_e(hub)}#{rid}">{_e(r["slug"])}<span class="n">{pct}%</span></a>'
|
|
378
398
|
f'<div class="nav-sub" data-rid="{rid}">'
|
|
379
|
-
+ "".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))
|
|
380
401
|
+ "</div>")
|
|
381
402
|
out.append("</nav>")
|
|
382
403
|
return "".join(out)
|
|
383
404
|
|
|
384
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
|
+
|
|
385
410
|
_ON = "{background:var(--prog-bg);color:var(--prog-fg);font-weight:600}"
|
|
386
411
|
|
|
387
412
|
|
|
388
|
-
def _view_css(rids: list[str]) -> str:
|
|
413
|
+
def _view_css(rids: list[str], site: bool = False) -> str:
|
|
389
414
|
"""Marks the nav item of the view that shows, and opens its sub-links. One rule per roadmap: CSS cannot take
|
|
390
415
|
the target's id into a selector. Every rid comes from `_rid` (hex and slug characters only), so it is safe
|
|
391
416
|
in a selector."""
|
|
392
417
|
shown = ".view:target,.view :target"
|
|
393
418
|
out = [f'body:not(:has({shown})) .nav a[href="#inbox"]{_ON}']
|
|
394
|
-
for key
|
|
419
|
+
for key, _label in _NAV_ITEMS:
|
|
420
|
+
if site and key in _LOCAL_VIEWS:
|
|
421
|
+
continue
|
|
395
422
|
out.append(f'body:has(#{key}:target,#{key} :target) .nav a[href="#{key}"]{_ON}')
|
|
396
423
|
for rid in rids:
|
|
397
424
|
on = f"body:has(#{rid}:target,#{rid} :target)"
|
|
@@ -399,11 +426,14 @@ def _view_css(rids: list[str]) -> str:
|
|
|
399
426
|
return "".join(out)
|
|
400
427
|
|
|
401
428
|
|
|
402
|
-
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."""
|
|
403
432
|
out = ['<section id="waiting" class="card wait"><h2>Waiting for you</h2>']
|
|
404
|
-
sessions, gates = model["sessions"], model["waiting"]
|
|
433
|
+
sessions, gates = ([] if site else model["sessions"]), model["waiting"]
|
|
405
434
|
if not sessions and not gates:
|
|
406
|
-
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>")
|
|
407
437
|
return "".join(out)
|
|
408
438
|
# A question or a permission first; a session that only finished its turn and sits idle comes after it.
|
|
409
439
|
asking = [s for s in sessions if not str(s.get("kind", "")).startswith("idle")]
|
|
@@ -430,20 +460,24 @@ def _waiting_section(model: dict) -> str:
|
|
|
430
460
|
def _swimlane(model: dict) -> str:
|
|
431
461
|
milestones, lanes = model["milestones"], model["lanes"]
|
|
432
462
|
cols = len(milestones)
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
463
|
+
# The section has the class `timeline` as well as the id: the hub prefixes every id, so the phone CSS selects by class.
|
|
464
|
+
out = ['<section class="card timeline" id="timeline"><div class="sec-h"><h2>Timeline</h2><span class="note">plans as '
|
|
465
|
+
'lanes, milestones as columns in roadmap order, one tile per phase</span></div><div class="scroll">',
|
|
466
|
+
f'<div class="lanes" style="grid-template-columns:150px repeat({cols},minmax(110px,1fr));width:100%;'
|
|
467
|
+
f'min-width:{150 + cols * 110}px">',
|
|
436
468
|
'<div class="hd ln">Plan / milestone</div>']
|
|
437
469
|
for ms in milestones:
|
|
438
470
|
dep = f"after {', '.join(ms['depends'])}" if ms["depends"] else "starts first"
|
|
439
|
-
|
|
471
|
+
n = len(ms["blockers"])
|
|
472
|
+
count = f' <span class="bcount">{n} gate(s)</span>' if n else ""
|
|
440
473
|
out.append(f'<div class="hd">{_e(ms["id"])} {_pill(ms["status"])}<div class="empty" style="font-weight:400;'
|
|
441
|
-
f'font-size:11px">{_e(dep)}
|
|
442
|
-
|
|
474
|
+
f'font-size:11px">{_e(dep)}</div>{count}</div>')
|
|
475
|
+
plan_lanes = lanes or ["-"]
|
|
476
|
+
for i, lane in enumerate(plan_lanes):
|
|
443
477
|
done = sum(1 for ms in milestones for ph in ms["phases"] if ph["plan"] == lane and ph["tile"] in ("done", "moved"))
|
|
444
478
|
total = sum(1 for ms in milestones for ph in ms["phases"] if ph["plan"] == lane)
|
|
445
|
-
out.append(f'<div class="ln">{_e(lane)}<div class="empty"
|
|
446
|
-
f'{done}/{total} done</div></div>')
|
|
479
|
+
out.append(f'<div class="ln" style="--lc:var(--c{i % 6})">{_e(lane)}<div class="empty" '
|
|
480
|
+
f'style="font-weight:400;font-size:11px">{done}/{total} done</div></div>')
|
|
447
481
|
for ms in milestones:
|
|
448
482
|
tiles = []
|
|
449
483
|
for ph in ms["phases"]:
|
|
@@ -454,8 +488,24 @@ def _swimlane(model: dict) -> str:
|
|
|
454
488
|
tiles.append(f'<div class="tile {_e(ph["tile"])}{hi}" title="{_e(title)}"><b>{_e(ph["num"])}</b>'
|
|
455
489
|
f'<span>{_e(ph["name"] or ph["ref"])}</span></div>')
|
|
456
490
|
out.append(f"<div>{''.join(tiles)}</div>")
|
|
457
|
-
|
|
458
|
-
|
|
491
|
+
if any(ms["blockers"] for ms in milestones): # the blockers get their own lane, so the column headers stay short
|
|
492
|
+
out.append('<div class="ln">Owner and outside</div>')
|
|
493
|
+
for ms in milestones:
|
|
494
|
+
cells = []
|
|
495
|
+
for b in ms["blockers"]:
|
|
496
|
+
reason = str(b["reason"])
|
|
497
|
+
short = reason if len(reason) <= 60 else reason[:60] + "…"
|
|
498
|
+
cells.append(f'<div class="gate" title="{_e(reason)}">{_e(b["type"])}: {_e(short)}</div>')
|
|
499
|
+
out.append(f"<div>{''.join(cells)}</div>")
|
|
500
|
+
out.append('</div></div><div class="lane-list">')
|
|
501
|
+
for ms in milestones: # the phone list: the grid is hidden on a narrow screen
|
|
502
|
+
out.append(f'<h3>{_e(ms["id"])} {_pill(ms["status"])}</h3>')
|
|
503
|
+
for ph in ms["phases"]:
|
|
504
|
+
hi = " hi" if "high" in ph.get("risk", "").lower() else ""
|
|
505
|
+
out.append(f'<div class="ltile {_e(ph["tile"])}{hi}"><b>{_e(ph["plan"])} {_e(ph["num"])}</b>'
|
|
506
|
+
f'<span>{_e(ph["name"] or ph["ref"])}</span></div>')
|
|
507
|
+
out.append('</div><div class="legend"><span>colour: to do · <b>in progress</b> · done · striped = moved · '
|
|
508
|
+
'amber = blocked</span><span>red dot = Risk high (stops for review)</span></div></section>')
|
|
459
509
|
return "".join(out)
|
|
460
510
|
|
|
461
511
|
|
|
@@ -466,14 +516,29 @@ def _summary(model: dict) -> str:
|
|
|
466
516
|
high = sum(1 for ph in phases if "high" in ph.get("risk", "").lower())
|
|
467
517
|
nxt = model.get("next") or {}
|
|
468
518
|
nxt_txt = f"{nxt.get('milestone', '')} {nxt.get('plan', '')}#{nxt.get('phase', '')}" if nxt else "none ready"
|
|
469
|
-
return ('<
|
|
470
|
-
'<div class="kpis">'
|
|
519
|
+
return ('<section class="kpis" id="overview" aria-label="Summary">'
|
|
471
520
|
f'<div class="kpi"><b>{_e(done)} / {_e(len(phases))}</b>phases done</div>'
|
|
472
521
|
f'<div class="kpi"><b>{_e(model["done"])} / {_e(model["total"])}</b>milestones done</div>'
|
|
473
522
|
f'<div class="kpi"><b>{_e(len(building))}</b>in progress: {_e(", ".join(building) or "none")}</div>'
|
|
474
523
|
f'<div class="kpi"><b>{_e(high)}</b>high-risk phases</div>'
|
|
475
524
|
f'<div class="kpi"><b>{_e(nxt_txt)}</b>next batch for the runner</div>'
|
|
476
|
-
"</
|
|
525
|
+
"</section>")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _done_phases(ms: dict) -> int:
|
|
529
|
+
return sum(1 for ph in ms["phases"] if ph["tile"] in ("done", "moved"))
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _flow(model: dict) -> str:
|
|
533
|
+
"""The milestones as a left-to-right chain in roadmap order; each node links to its milestone below."""
|
|
534
|
+
nodes = []
|
|
535
|
+
for ms in model["milestones"]:
|
|
536
|
+
dep = f"after {', '.join(ms['depends'])}" if ms["depends"] else "starts first"
|
|
537
|
+
nodes.append(f'<a class="fnode s-{_e(ms["status"])}" href="#ms-{_e(ms["id"])}"><b>{_e(ms["id"])}</b> '
|
|
538
|
+
f'{_pill(ms["status"])}<span>{_e(dep)} · {_e(_done_phases(ms))}/{_e(len(ms["phases"]))} ph</span></a>')
|
|
539
|
+
chain = '<span class="arrow" aria-hidden="true">→</span>'.join(nodes)
|
|
540
|
+
return (f'<section id="flow"><div class="sec-h"><h2>Flow</h2><span class="note">milestones in roadmap order</span></div>'
|
|
541
|
+
f'<div class="card scroll"><div class="flow">{chain}</div></div></section>')
|
|
477
542
|
|
|
478
543
|
|
|
479
544
|
def _now_next_later(model: dict) -> str:
|
|
@@ -492,26 +557,30 @@ def _now_next_later(model: dict) -> str:
|
|
|
492
557
|
groups["Later"].append(ms)
|
|
493
558
|
cols = []
|
|
494
559
|
for title, items in groups.items():
|
|
495
|
-
body = "".join(f'<
|
|
496
|
-
f'{
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
560
|
+
body = "".join(f'<a class="rcard" href="#ms-{_e(m["id"])}"><span><strong>{_e(m["id"])}</strong> {_pill(m["status"])}</span>'
|
|
561
|
+
f'{_bar(_done_phases(m), len(m["phases"]))}'
|
|
562
|
+
+ "".join(f'<span class="note">{_e(b["type"])}</span>' for b in m["blockers"])
|
|
563
|
+
+ "</a>" for m in items)
|
|
564
|
+
cols.append(f'<div><h3>{_e(title)}</h3>{body or "<p class=empty>none</p>"}</div>')
|
|
565
|
+
return ('<section id="board"><div class="sec-h"><h2>Now, next, later</h2>'
|
|
566
|
+
'<span class="note">which milestones can start</span></div>'
|
|
567
|
+
f'<div class="cols">{"".join(cols)}</div></section>')
|
|
501
568
|
|
|
502
569
|
|
|
503
570
|
def _gates(model: dict) -> str:
|
|
504
571
|
rows = [(ms["id"], b) for ms in model["milestones"] for b in ms["blockers"]]
|
|
505
|
-
body = "".join(f"<tr><td><strong>{_e(mid)}</strong></td
|
|
572
|
+
body = "".join(f"<tr><td><strong>{_e(mid)}</strong></td>"
|
|
573
|
+
f"<td><span class=\"kind k-{_e(b['type'])}\">{_e(b['type'])}</span></td><td>{_e(b['reason'])}</td>"
|
|
506
574
|
f"<td><code>{_e(b['check'])}</code></td></tr>" for mid, b in rows)
|
|
507
|
-
return (f'<
|
|
575
|
+
return (f'<section class="card" id="gates"><div class="sec-h"><h2>Gates and blockers</h2>'
|
|
576
|
+
f'<span class="note">{_e(len(rows))} open</span></div>'
|
|
508
577
|
+ (f'<div class="scroll"><table><tr><th>Milestone</th><th>Type</th><th>Reason</th><th>Check</th></tr>{body}'
|
|
509
|
-
"</table></div>" if rows else '<p class="empty">No blockers.</p>') + "</
|
|
578
|
+
"</table></div>" if rows else '<p class="empty">No blockers.</p>') + "</section>")
|
|
510
579
|
|
|
511
580
|
|
|
512
581
|
def _milestones(model: dict) -> str:
|
|
513
|
-
out = ['<
|
|
514
|
-
'Tracker status</span></
|
|
582
|
+
out = ['<section id="milestones"><div class="sec-h"><h2>Milestones and phases</h2><span class="note">every phase with '
|
|
583
|
+
'model, lane, risk and Tracker status</span></div>']
|
|
515
584
|
for ms in model["milestones"]:
|
|
516
585
|
rows = "".join(
|
|
517
586
|
f"<tr><td><code>{_e(ph['ref'])}</code></td><td>{_e(ph['name'])}</td><td>{_e(_short_model(ph['model']))}</td>"
|
|
@@ -519,13 +588,13 @@ def _milestones(model: dict) -> str:
|
|
|
519
588
|
f"<td>{_e(ph['risk'] or '-')}</td><td>{_pill(ph['tile'])} {_e(ph['tracker'])}</td></tr>"
|
|
520
589
|
for ph in ms["phases"])
|
|
521
590
|
target = f" · target {_e(ms['target'])}" if ms.get("target") else ""
|
|
522
|
-
out.append(f
|
|
523
|
-
f"<span class
|
|
524
|
-
f
|
|
525
|
-
|
|
591
|
+
out.append(f'<details class="card" id="ms-{_e(ms["id"])}"><summary>{_e(ms["id"])} '
|
|
592
|
+
f'{_pill(ms["status"])} <span class="note">owner {_e(ms["owner"])}{target} · '
|
|
593
|
+
f'after {_e(", ".join(ms["depends"]) or "-")}</span></summary>'
|
|
594
|
+
'<div class="scroll"><table><tr><th>Phase</th><th>Name</th><th>Model</th><th>Lane</th><th>Risk</th>'
|
|
526
595
|
f'<th>Tracker</th></tr>{rows or "<tr><td colspan=6 class=empty>no build phases</td></tr>"}'
|
|
527
|
-
"</table></div>")
|
|
528
|
-
out.append("</
|
|
596
|
+
"</table></div></details>")
|
|
597
|
+
out.append("</section>")
|
|
529
598
|
return "".join(out)
|
|
530
599
|
|
|
531
600
|
|
|
@@ -554,13 +623,13 @@ def _runner(model: dict) -> str:
|
|
|
554
623
|
if nxt:
|
|
555
624
|
parts.append(f"<p>Next batch: <code>{_e(nxt.get('milestone'))} {_e(nxt.get('plan'))}#{_e(nxt.get('phase'))}"
|
|
556
625
|
"</code></p>")
|
|
557
|
-
return ('<
|
|
558
|
-
+ "".join(parts) + "</
|
|
626
|
+
return ('<section class="card" id="runner"><div class="sec-h"><h2>Runner</h2>'
|
|
627
|
+
'<span class="note">roadmap-run state on this machine</span></div>' + "".join(parts) + "</section>")
|
|
559
628
|
|
|
560
629
|
|
|
561
|
-
#
|
|
562
|
-
_PAGE_ANCHORS = [("waiting", "Waiting"), ("
|
|
563
|
-
("milestones", "Milestones"), ("runner", "Runner")]
|
|
630
|
+
# The sections of a roadmap view, in page order: the nav sub-links. `render_body` writes them in this order.
|
|
631
|
+
_PAGE_ANCHORS = [("waiting", "Waiting"), ("overview", "Overview"), ("flow", "Flow"), ("board", "Board"),
|
|
632
|
+
("timeline", "Timeline"), ("gates", "Gates"), ("milestones", "Milestones"), ("runner", "Runner")]
|
|
564
633
|
|
|
565
634
|
def _rid(folder, slug) -> str | None:
|
|
566
635
|
"""The view id of a roadmap in `index.html`: `rm-<the folder's 6 hex>-<slug>` (the 6 hex keep two clones with
|
|
@@ -595,46 +664,59 @@ def render_html(model: dict) -> str:
|
|
|
595
664
|
body=render_body(model))
|
|
596
665
|
|
|
597
666
|
|
|
598
|
-
def render_body(model: dict) -> str:
|
|
667
|
+
def render_body(model: dict, site: bool = False) -> str:
|
|
599
668
|
"""Everything inside `<main>`: the header and the sections. `write_page` keeps it as `<slug>.body.html`; the
|
|
600
|
-
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."""
|
|
601
671
|
title = f"Roadmap {model['roadmap']}"
|
|
602
672
|
phases = [ph for ms in model["milestones"] for ph in ms["phases"]]
|
|
603
673
|
done = sum(1 for ph in phases if ph["tile"] in ("done", "moved"))
|
|
604
674
|
commit = f" · origin <code>{_e(model.get('origin_commit'))}</code>" if model.get("origin_commit") else ""
|
|
675
|
+
chips = "".join(
|
|
676
|
+
f'<span class="chip" style="--lc:var(--c{i % 6})"><b>{_e(plan)}</b> <span>'
|
|
677
|
+
f'{_e(sum(1 for ph in phases if ph["plan"] == plan and ph["tile"] in ("done", "moved")))}/'
|
|
678
|
+
f'{_e(sum(1 for ph in phases if ph["plan"] == plan))} phases</span></span>'
|
|
679
|
+
for i, plan in enumerate(model["lanes"]))
|
|
605
680
|
return "".join([
|
|
606
681
|
f'<header class="hdr"><div><h1>{_e(title)}</h1><p class="sub">{_e(model["done"])} of {_e(model["total"])} '
|
|
607
682
|
f'milestones · {_e(done)} of {_e(len(phases))} phases done · branch <code>{_e(model["branch"])}</code>'
|
|
608
|
-
f
|
|
609
|
-
|
|
610
|
-
|
|
683
|
+
f'{commit} · generated {_e(model["generated"])}</p>'
|
|
684
|
+
+ (f'<div class="chips">{chips}</div>' if chips else "") + "</div></header>",
|
|
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
|
|
611
688
|
'<p class="empty">Built from roadmap.md, the plan Trackers and headers on origin, the runner state and the '
|
|
612
689
|
"session-state folder. Re-rendered by the runner, /handoff, /pr-merge, /catchup and the waiting hooks.</p>"])
|
|
613
690
|
|
|
614
691
|
|
|
615
|
-
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:
|
|
616
693
|
"""`~/.caddis/roadmap-pages/<repo folder>-<6 hex>/<slug>.html`: outside every checkout, so a render never makes a
|
|
617
694
|
repo dirty (17 Oct: the in-repo `.local/` folder is not gitignored in every repo). The folder is the MAIN
|
|
618
|
-
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."""
|
|
619
697
|
main = main_checkout(root)
|
|
620
698
|
tag = hashlib.sha1(str(main).lower().encode("utf-8")).hexdigest()[:6]
|
|
621
|
-
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"
|
|
622
700
|
|
|
623
701
|
|
|
624
|
-
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:
|
|
625
703
|
"""At the default path: `<slug>.body.html` (the page body, which the hub shows as a view of `index.html`), a
|
|
626
704
|
one-line `<slug>.html` that opens that view, and `<slug>.json` (the one-row summary the hub is built from). An
|
|
627
705
|
`--out` page is a one-off self-contained copy and stays out of the hub. None: a newer caddis wrote this page,
|
|
628
|
-
so it is kept.
|
|
629
|
-
|
|
630
|
-
|
|
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
|
|
631
710
|
return None
|
|
632
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": []}
|
|
633
715
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
634
716
|
if out is not None:
|
|
635
717
|
_write_atomic(path, render_html(model))
|
|
636
718
|
return path
|
|
637
|
-
_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))
|
|
638
720
|
rid = _rid(path.parent.name, model["roadmap"])
|
|
639
721
|
_write_atomic(path, _stub(rid, model["roadmap"]) if rid else render_html(model))
|
|
640
722
|
try: # the summary feeds the hub only: the page is written, so a failure here never fails the render
|
|
@@ -677,17 +759,45 @@ def is_newer(theirs, mine) -> bool:
|
|
|
677
759
|
|
|
678
760
|
|
|
679
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
|
|
680
789
|
|
|
681
790
|
|
|
682
791
|
def _newer_owner(summary: Path) -> tuple[str, bool]:
|
|
683
|
-
"""(version, live): the newer caddis that wrote `summary` ("" when it is not newer), and whether it wrote it
|
|
684
|
-
|
|
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."""
|
|
685
795
|
data, _ = _read_json(summary)
|
|
686
796
|
theirs = str((data or {}).get("generator") or "")
|
|
687
797
|
if not is_newer(theirs, generator_version()):
|
|
688
798
|
return "", False
|
|
689
799
|
age_h = _age_h(str((data or {}).get("generated_utc") or ""), datetime.datetime.now(datetime.timezone.utc))
|
|
690
|
-
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)
|
|
691
801
|
|
|
692
802
|
|
|
693
803
|
def _gate(label: str, theirs: str, live: bool) -> bool:
|
|
@@ -697,7 +807,7 @@ def _gate(label: str, theirs: str, live: bool) -> bool:
|
|
|
697
807
|
return True
|
|
698
808
|
if theirs:
|
|
699
809
|
print(f"[ROADMAP] {label}: taking over from caddis {theirs} (it has not written for "
|
|
700
|
-
f"{
|
|
810
|
+
f"{_ownership_s(theirs) // 60} min)")
|
|
701
811
|
return False
|
|
702
812
|
|
|
703
813
|
|
|
@@ -887,26 +997,121 @@ def _int(value) -> int:
|
|
|
887
997
|
return 0
|
|
888
998
|
|
|
889
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
|
+
|
|
890
1089
|
def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
|
|
891
|
-
bodies: dict[str, str] | None = None) -> str:
|
|
1090
|
+
bodies: dict[str, str] | None = None, *, site: bool = False, banner: str = "") -> str:
|
|
892
1091
|
"""The whole app in one file: the inbox and overview view, then one view per roadmap (its body from `bodies`,
|
|
893
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
|
|
894
|
-
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."""
|
|
895
1096
|
bodies = bodies or {}
|
|
896
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]
|
|
897
1100
|
order, need, count = _group_rows(rows)
|
|
898
1101
|
phases_done = sum(_int(r.get("phases_done")) for r in rows)
|
|
899
1102
|
phases_total = sum(_int(r.get("phases_total")) for r in rows)
|
|
900
1103
|
live = sum(1 for r in rows if r.get("runner") == "running")
|
|
901
1104
|
blocked = sum(_int(r.get("blocked")) for r in rows)
|
|
902
|
-
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">',
|
|
903
1107
|
f'<div class="kpi"><b>{_e(count)}</b>Needs you</div>',
|
|
904
1108
|
f'<div class="kpi"><b>{_e(phases_done)} / {_e(phases_total)}</b>Phases done'
|
|
905
1109
|
f'{_bar(phases_done, phases_total)}</div>',
|
|
906
1110
|
f'<div class="kpi"><b>{_e(blocked)}</b>Blocked phases</div>',
|
|
907
|
-
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>",
|
|
908
1112
|
f'<p class="sub">{_e(len(order))} repos · {_e(len(rows))} roadmaps · {_e(count)} item(s) need you · '
|
|
909
|
-
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>",
|
|
910
1115
|
'<section id="needs" class="card wait"><h2>Needs you</h2>']
|
|
911
1116
|
if not need:
|
|
912
1117
|
out.append('<p class="empty">Nothing is waiting for you.</p>')
|
|
@@ -941,13 +1146,13 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
|
|
|
941
1146
|
f'<div>{_bar(_int(r.get("phases_done")), _int(r.get("phases_total")))} '
|
|
942
1147
|
f'{_e(r.get("milestones_done"))}/{_e(r.get("milestones_total"))} milestones</div>'
|
|
943
1148
|
f'<div class="dots">{dots}</div>'
|
|
944
|
-
f'<div class="empty">next: {_e(r.get("next") or "-")}
|
|
945
|
-
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>')
|
|
946
1151
|
out.append("</div>")
|
|
947
1152
|
for err in errors:
|
|
948
1153
|
out.append(f'<p class="empty">Could not read {_e(err)}.</p>')
|
|
949
|
-
out.append('<p class="empty">Built from the
|
|
950
|
-
"</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>")
|
|
951
1156
|
rids = []
|
|
952
1157
|
for r in sorted(rows, key=lambda r: (str(r.get("repo", "")).lower(), str(r["folder"]), str(r["slug"]))):
|
|
953
1158
|
rid = _rid(r.get("folder"), r.get("slug"))
|
|
@@ -960,27 +1165,33 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
|
|
|
960
1165
|
"in its repo.</p>")
|
|
961
1166
|
out.append(f'<section class="view" id="{rid}"><p class="view-crumbs"><a href="#inbox">Roadmaps</a> › '
|
|
962
1167
|
f'{_e(r.get("repo"))} › <strong>{_e(r["slug"])}</strong></p>{inner}</section>')
|
|
963
|
-
for
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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)
|
|
969
1174
|
|
|
970
1175
|
|
|
971
|
-
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:
|
|
972
1178
|
"""`~/.caddis/roadmap-pages/index.html`: every roadmap page on this machine, grouped per repo. None: a newer
|
|
973
|
-
caddis wrote the hub (its version is in `.hub-generator`), so it is kept.
|
|
974
|
-
|
|
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)
|
|
975
1183
|
root_dir.mkdir(parents=True, exist_ok=True)
|
|
976
1184
|
stamp = root_dir / ".hub-generator"
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
|
984
1195
|
try:
|
|
985
1196
|
install_assets(root_dir)
|
|
986
1197
|
except Exception as exc: # a missing font file must never stop the hub
|
|
@@ -992,6 +1203,103 @@ def write_hub(home: Path | None = None, now: datetime.datetime | None = None) ->
|
|
|
992
1203
|
print(f"[ROADMAP] page bodies not read: {exc}", file=sys.stderr)
|
|
993
1204
|
bodies = {}
|
|
994
1205
|
path = root_dir / "index.html"
|
|
995
|
-
|
|
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))
|
|
996
1211
|
_write_atomic(stamp, generator_version() + "\n")
|
|
997
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)
|