@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.
@@ -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>summary span{color:var(--muted);font-weight:400;font-size:13px}
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;width:max-content}.lanes>div{border-top:1px solid var(--line);border-left:1px dashed var(--line);
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:0;border-right:1px solid var(--line2);font-weight:600;min-width:170px}
245
- .tile{display:flex;gap:6px;align-items:center;font-size:12px;padding:3px 6px;margin:0 0 4px;border-radius:5px;
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{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile.in-progress{background:var(--prog-bg);
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 = "") -> 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 (("inbox", "Inbox"), ("gates", "Gates"), ("sessions", "Sessions"), ("runners", "Runners")):
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 in ("inbox", "gates", "sessions", "runners"):
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.</p></section>')
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
- out = ['<details class="card" id="timeline" open><summary>Plan on a page <span>plans as lanes, milestones as columns in '
434
- 'roadmap order, one tile per phase</span></summary><div class="scroll">',
435
- f'<div class="lanes" style="grid-template-columns:170px repeat({cols},190px)">',
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
- gates = "".join(f'<div class="gate">{_e(b["type"])}: {_e(b["reason"])}</div>' for b in ms["blockers"])
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)} · {_e(ms["owner"])}</div>{gates}</div>')
442
- for lane in lanes or ["-"]:
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" style="font-weight:400;font-size:11px">'
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
- out.append('</div></div><div class="legend"><span>colour: to do · <b>in progress</b> · done · striped = moved · '
458
- 'amber = blocked</span><span>red dot = Risk high (stops for review)</span></div></details>')
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 ('<details class="card" id="overview"><summary>Summary <span>progress, current work, next batch</span></summary>'
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
- "</div></details>")
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'<p><strong>{_e(m["id"])}</strong> {_pill(m["status"])} '
496
- f'{_e(sum(1 for p in m["phases"] if p["tile"] in ("done", "moved")))}/{_e(len(m["phases"]))}'
497
- f' phases{"".join(" · " + _e(b["type"]) for b in m["blockers"])}</p>' for m in items)
498
- cols.append(f'<div><h2>{_e(title)}</h2>{body or "<p class=empty>none</p>"}</div>')
499
- return ('<details class="card"><summary>Now, next, later <span>which milestones can start</span></summary>'
500
- f'<div class="cols">{"".join(cols)}</div></details>')
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><td>{_e(b['type'])}</td><td>{_e(b['reason'])}</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'<details class="card" id="gates"><summary>Gates and blockers <span>{_e(len(rows))} open</span></summary>'
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>') + "</details>")
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 = ['<details class="card" id="milestones"><summary>Milestones and phases <span>every phase with model, lane, risk and '
514
- 'Tracker status</span></summary>']
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"<h2 style=\"margin-top:12px\">{_e(ms['id'])} {_pill(ms['status'])} "
523
- f"<span class=\"empty\" style=\"font-weight:400;font-size:13px\">owner {_e(ms['owner'])}{target} · "
524
- f"after {_e(', '.join(ms['depends']) or '-')}</span></h2>")
525
- out.append('<div class="scroll"><table><tr><th>Phase</th><th>Name</th><th>Model</th><th>Lane</th><th>Risk</th>'
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("</details>")
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 ('<details class="card" id="runner"><summary>Runner <span>roadmap-run state on this machine</span></summary>'
558
- + "".join(parts) + "</details>")
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
- # In page order (Phase 4 moves the overview above the timeline and updates this list).
562
- _PAGE_ANCHORS = [("waiting", "Waiting"), ("timeline", "Timeline"), ("overview", "Overview"), ("gates", "Gates"),
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"{commit} · generated {_e(model['generated'])}</p></div></header>",
609
- _waiting_section(model), _swimlane(model), _summary(model), _now_next_later(model), _gates(model),
610
- _milestones(model), _runner(model),
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
- path = Path(out) if out else default_page_path(root, slug)
630
- if out is None and _owned_by_newer(slug, path.with_suffix(".json")): # before build: a skip does no work
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 in
684
- the last GENERATOR_OWNERSHIP_S. An unreadable time is not live: a downgrade must never freeze a page."""
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 <= GENERATOR_OWNERSHIP_S
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"{GENERATOR_OWNERSHIP_S // 60} min)")
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 = ['<section class="view" id="inbox"><section class="kpis">',
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></section>',
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"))} · the inbox reloads every minute</p>',
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 "-")} · runner: {_e(r.get("runner") or "idle")}'
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 page summaries in this folder. Every roadmap page render rebuilds it.'
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 key, label in (("gates", "Gates"), ("sessions", "Sessions"), ("runners", "Runners")): # Phase 6 fills these
964
- out.append(f'<section class="view" id="{key}"><h2>{label}</h2><p class="card empty">The cross-repo {key} '
965
- 'view comes in a later update. For now, see <a href="#needs">Needs you</a> on the inbox and each '
966
- "roadmap's own page.</p></section>")
967
- return _shell(title="Roadmaps", base="", nav=_nav(rows, ""), crumbs="Roadmaps", body="".join(out),
968
- css=_view_css(rids))
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) -> Path | 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
- root_dir = pages_root(home)
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
- try:
978
- theirs = stamp.read_text(encoding="utf-8").strip()
979
- live = time.time() - stamp.stat().st_mtime <= GENERATOR_OWNERSHIP_S
980
- except (OSError, UnicodeDecodeError):
981
- theirs, live = "", False
982
- if _gate("hub", theirs if is_newer(theirs, generator_version()) else "", live):
983
- return None
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
- _write_atomic(path, render_hub(rows, errors, now or datetime.datetime.now(datetime.timezone.utc), bodies))
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)