@caddis/cli 0.4.53 → 0.4.54

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.
Files changed (32) hide show
  1. package/bundles/antigravity-plugin/guard_agy.py +184 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/scripts/caddis_budget.py +283 -0
  4. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +5 -1
  5. package/bundles/antigravity-plugin/scripts/caddis_fleet.py +439 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_gate.py +29 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +127 -10
  8. package/bundles/antigravity-plugin/scripts/caddis_notify.py +5 -1
  9. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +56 -3
  10. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +676 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +39 -6
  12. package/bundles/antigravity-plugin/scripts/caddis_routing.py +4 -4
  13. package/bundles/antigravity-plugin/skills/_registry.md +2 -0
  14. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +4 -0
  15. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +6 -0
  16. package/bundles/antigravity-plugin/skills/fleet-close/SKILL.md +167 -0
  17. package/bundles/antigravity-plugin/skills/fleet-open/SKILL.md +110 -0
  18. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +2 -1
  19. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +101 -1
  20. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +3 -2
  21. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +20 -6
  22. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +2 -1
  23. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +1 -1
  24. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +1 -0
  25. package/bundles/antigravity-plugin/skills/ship/SKILL.md +2 -2
  26. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +9 -5
  27. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +3 -2
  28. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +2 -1
  29. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +2 -1
  30. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +1 -0
  31. package/bundles/manifest.json +2 -2
  32. package/package.json +1 -1
@@ -0,0 +1,676 @@
1
+ """A local, read-only roadmap page: one static HTML file built from the files that are already the truth.
2
+
3
+ `caddis_roadmap.py render --roadmap <slug> [--out <file>]` writes it (default `~/.caddis/roadmap-pages/<repo>/<slug>.html`,
4
+ outside every checkout). Plan: `.caddis/plans/roadmap-page.md` (owner decisions, 17 Oct).
5
+
6
+ The hub: each default render also writes `<slug>.json` (one summary row) beside the page, and rebuilds
7
+ `~/.caddis/roadmap-pages/index.html` from every summary on this machine, one section per repo. The hub links to
8
+ the pages by relative path, so it works as a local file and behind a static web server (Phase 4).
9
+
10
+ Sections, in order: Waiting for you (sessions that wait for input, and open owner-gate blockers), Plan on a page (a
11
+ swimlane: plans as lanes, milestones as columns, one tile per phase; open by default), then collapsed sections:
12
+ summary, now / next / later, gates and blockers, every milestone with its phase rows, and the runner.
13
+
14
+ The data is `status()` (roadmap.md and each plan's Tracker on origin), the plan headers (`### Phase <n> - <name>`,
15
+ `**Model:**`, `**Lane:**`, `**Risk:**`), the runner's local lock and last stop, and the local session-state folder
16
+ (`<sid>.waiting` markers from the Notification hook). No script, no network: collapsing uses `<details>`. Every text
17
+ from the files is escaped: a plan, a relay or a hook message can hold any text.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import datetime
22
+ import hashlib
23
+ import html
24
+ import json
25
+ import os
26
+ import re
27
+ import subprocess
28
+ import sys
29
+ import time
30
+ import urllib.parse
31
+ from pathlib import Path
32
+
33
+ PHASE_HEADER = re.compile(r"^###\s+Phase\s+(\d+)[a-z]?\s*[-–—:]\s*(.+?)\s*(?:[✅⏳\U0001F532].*)?$",
34
+ re.MULTILINE)
35
+ _FACT = re.compile(r"\*\*(Model|Lane|Risk):\*\*\s*([^\n]+?)\s*(?=\*\*(?:Model|Lane|Risk):\*\*|$)",
36
+ re.MULTILINE)
37
+ WAITING_MAX_AGE_H = 24 # a marker older than this, with no session end recorded, is shown as "stale?"
38
+
39
+
40
+ # ---- reading ---------------------------------------------------------------------------------------------------
41
+
42
+ def _read_json(path: Path | None):
43
+ """(data, error): data is a dict or None; error names a file that exists but cannot be read."""
44
+ if path is None:
45
+ return None, ""
46
+ try:
47
+ raw = Path(path).read_text(encoding="utf-8")
48
+ except FileNotFoundError:
49
+ return None, ""
50
+ except (OSError, UnicodeDecodeError) as exc:
51
+ return None, f"{Path(path).name}: {getattr(exc, 'strerror', None) or exc}"
52
+ try:
53
+ data = json.loads(raw)
54
+ except ValueError:
55
+ return None, f"{Path(path).name}: not JSON"
56
+ return (data, "") if isinstance(data, dict) else (None, f"{Path(path).name}: not a JSON object")
57
+
58
+
59
+ def phase_facts(plan_text: str | None) -> dict[int, dict]:
60
+ """Phase number -> {name, model, lane, risk} from the plan's `### Phase <n> - <name>` sections."""
61
+ if not plan_text:
62
+ return {}
63
+ heads = list(PHASE_HEADER.finditer(plan_text))
64
+ out: dict[int, dict] = {}
65
+ for i, m in enumerate(heads):
66
+ body = plan_text[m.end():heads[i + 1].start() if i + 1 < len(heads) else len(plan_text)]
67
+ body = body.split("\n## ", 1)[0] # stop at the next level-2 section (Tracker, Risks, ...)
68
+ facts = {k.lower(): v.strip() for k, v in _FACT.findall(body)}
69
+ out.setdefault(int(m.group(1)), {"name": m.group(2).strip(), "model": facts.get("model", ""),
70
+ "lane": facts.get("lane", ""), "risk": facts.get("risk", "")})
71
+ return out
72
+
73
+
74
+ def phase_names(plan_text: str | None) -> dict[int, str]:
75
+ return {n: f["name"] for n, f in phase_facts(plan_text).items()}
76
+
77
+
78
+ def tile_state(tracker_cell: str, state: str) -> str:
79
+ """todo | in-progress | done | moved | blocked, from the Tracker cell (origin), falling back to status()."""
80
+ cell = (tracker_cell or "").strip().lstrip("*").strip().lower()
81
+ if cell.startswith("moved") or "moved" in cell.split(" - ", 1)[0]:
82
+ return "moved"
83
+ if cell.startswith(("done", "✅", "merged")):
84
+ return "done"
85
+ if cell.startswith(("blocked", "halt", "waiting")):
86
+ return "blocked"
87
+ if cell.startswith(("in progress", "in-progress", "active", "building", "built", "⏳")):
88
+ return "in-progress"
89
+ return "done" if state == "done" else "todo"
90
+
91
+
92
+ def _short_model(model: str) -> str:
93
+ m = model.lower()
94
+ for key, label in (("opus", "Opus"), ("sonnet", "Sonnet"), ("haiku", "Haiku"), ("argon", "agy Argon"),
95
+ ("agy", "agy"), ("gemini", "agy")):
96
+ if key in m:
97
+ return label
98
+ return model.split(" - ", 1)[0].split("(", 1)[0].strip()[:24]
99
+
100
+
101
+ def waiting_sessions(state_dir: Path, now: datetime.datetime) -> list[dict]:
102
+ """Sessions whose Notification hook wrote `<sid>.waiting` and that have not ended (`<sid>.ended`)."""
103
+ out = []
104
+ if not state_dir.is_dir():
105
+ return out
106
+ for marker in sorted(state_dir.glob("*.waiting")):
107
+ sid = marker.stem
108
+ ended = state_dir / f"{sid}.ended"
109
+ try: # an end mark counts only when it is newer than the waiting mark (a resumed session can ask again)
110
+ if ended.exists() and ended.stat().st_mtime >= marker.stat().st_mtime:
111
+ continue
112
+ except OSError:
113
+ continue
114
+ data, err = _read_json(marker)
115
+ data = data or {}
116
+ name = ""
117
+ try:
118
+ name = (state_dir / f"{sid}.name").read_text(encoding="utf-8").split("\n", 1)[0].split("\t")[0].strip()
119
+ except (OSError, UnicodeDecodeError):
120
+ pass
121
+ since = str(data.get("utc") or "")
122
+ stale = False
123
+ try:
124
+ when = datetime.datetime.fromisoformat(since.replace("Z", "+00:00"))
125
+ if when.tzinfo is None:
126
+ when = when.replace(tzinfo=datetime.timezone.utc)
127
+ stale = (now - when).total_seconds() > WAITING_MAX_AGE_H * 3600
128
+ except (ValueError, TypeError):
129
+ pass
130
+ out.append({"session": name or sid[:8], "id": sid, "message": data.get("message") or err or "waiting",
131
+ "since": since, "cwd": data.get("cwd") or "", "kind": data.get("kind") or "", "stale": stale})
132
+ return out
133
+
134
+
135
+ def main_checkout(root: Path) -> Path:
136
+ """The main checkout of `root` (a linked worktree maps to it), so every worktree shares one page and one
137
+ session-state folder; the waiting hook writes its marks there too."""
138
+ try:
139
+ out = subprocess.run(["git", "-C", str(root), "rev-parse", "--path-format=absolute", "--git-common-dir"],
140
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30)
141
+ common = Path(out.stdout.strip()) if out.returncode == 0 and out.stdout.strip() else None
142
+ except (OSError, subprocess.SubprocessError):
143
+ common = None
144
+ if common is not None and common.name == ".git":
145
+ return common.parent.resolve()
146
+ return Path(root).resolve()
147
+
148
+
149
+ def _artifact_dir(root: Path) -> Path:
150
+ try:
151
+ import claudster_config
152
+ return Path(claudster_config.artifact_root(root))
153
+ except Exception: # the page must render without the harness on the path
154
+ return Path(root) / ".caddis"
155
+
156
+
157
+ # ---- the model -------------------------------------------------------------------------------------------------
158
+
159
+ def build(root: Path, slug: str, roadmap_mod, now: datetime.datetime | None = None) -> dict:
160
+ """status() plus per-phase Tracker cell, name, model, lane, risk and tile state; waiting sessions; the runner."""
161
+ root = Path(root)
162
+ now = now or datetime.datetime.now(datetime.timezone.utc)
163
+ model = roadmap_mod.status(root, slug)
164
+ plans: dict[str, tuple[dict[int, str], dict[int, dict]]] = {}
165
+ lanes: list[str] = []
166
+ for ms in model["milestones"]:
167
+ for ph in ms["phases"]:
168
+ m = roadmap_mod.REF_RE.match(ph["ref"])
169
+ ph.update(plan="", num=0, tracker="", name="", model="", lane="", risk="")
170
+ if not m:
171
+ ph["tile"] = "todo"
172
+ continue
173
+ plan, num = m.group(1), int(m.group(2))
174
+ if plan not in plans:
175
+ text = roadmap_mod.show(root, model["branch"], f".caddis/plans/{plan}.md")
176
+ if text is None:
177
+ text = roadmap_mod.show(root, model["branch"], f".caddis/plans/done/{plan}.md")
178
+ plans[plan] = (roadmap_mod.parse_tracker(text) if text else {}, phase_facts(text))
179
+ lanes.append(plan)
180
+ rows, facts = plans[plan]
181
+ ph.update(plan=plan, num=num, tracker=rows.get(num, ""), **{k: v for k, v in facts.get(num, {}).items()})
182
+ ph["tile"] = tile_state(ph["tracker"], ph["state"])
183
+ model["lanes"] = lanes
184
+ model["waiting"] = [{"milestone": ms["id"], "reason": b["reason"], "check": b["check"]}
185
+ for ms in model["milestones"] for b in ms["blockers"]
186
+ if b["type"] == "owner-gate" and ms["status"] not in roadmap_mod.FINISHED]
187
+ model["sessions"] = waiting_sessions(_artifact_dir(main_checkout(root)) / "session-state", now)
188
+
189
+ lock_path = roadmap_mod.runner_lock_path(root, "roadmap", slug)
190
+ lock, lock_err = _read_json(lock_path)
191
+ if lock is not None and not all(lock.get(k) not in (None, "") for k in ("batch", "pid", "started_utc")):
192
+ lock_err = lock_err or f"{Path(lock_path).name}: missing batch, pid or started_utc"
193
+ lock = None
194
+ live = False
195
+ if lock is not None and lock_path is not None:
196
+ try:
197
+ live = roadmap_mod.runner_lock_stale(lock_path) is False
198
+ except Exception: # a judgement that cannot be made is not "running"
199
+ live = False
200
+ stop, stop_err = _read_json(roadmap_mod.last_stop_path(root, "roadmap", slug))
201
+ model["runner"] = {"lock": lock if live else None, "stale_lock": lock if (lock and not live) else None,
202
+ "last_stop": stop, "errors": [e for e in (lock_err, stop_err) if e]}
203
+ model["generated"] = now.strftime("%Y-%m-%d %H:%M UTC")
204
+ try:
205
+ model["origin_commit"] = roadmap_mod._git(root, "rev-parse", "--short", f"origin/{model['branch']}",
206
+ check=False).stdout.strip()
207
+ except Exception:
208
+ model["origin_commit"] = ""
209
+ return model
210
+
211
+
212
+ # ---- rendering -------------------------------------------------------------------------------------------------
213
+
214
+ _CSS = """
215
+ :root{--bg:#f4f6f9;--panel:#fff;--panel2:#f8fafc;--fg:#0f172a;--muted:#475569;--line:#e2e8f0;--line2:#cbd5e1;
216
+ --accent:#2563eb;--todo-bg:#f1f5f9;--todo-fg:#334155;--prog:#2563eb;--prog-bg:#dbeafe;--prog-fg:#1d4ed8;
217
+ --done:#16a34a;--done-bg:#dcfce7;--done-fg:#15803d;--block:#d97706;--block-bg:#fef3c7;--block-fg:#92400e;
218
+ --high:#dc2626;--wait-bg:#fee2e2;--wait-fg:#b91c1c}
219
+ @media (prefers-color-scheme:dark){:root{--bg:#0b0f17;--panel:#111827;--panel2:#161f30;--fg:#f1f5f9;--muted:#94a3b8;
220
+ --line:#1e293b;--line2:#334155;--accent:#60a5fa;--todo-bg:#1e293b;--todo-fg:#cbd5e1;--prog:#60a5fa;--prog-bg:#172554;
221
+ --prog-fg:#93c5fd;--done:#22c55e;--done-bg:#052e16;--done-fg:#86efac;--block:#f59e0b;--block-bg:#451a03;
222
+ --block-fg:#fcd34d;--high:#f87171;--wait-bg:#450a0a;--wait-fg:#fca5a5}}
223
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);
224
+ font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}code{font:12px ui-monospace,Consolas,monospace}
225
+ main{max-width:1360px;margin:0 auto;padding:20px 16px 48px;display:grid;gap:16px}main>*{min-width:0}
226
+ h1{font-size:24px;margin:0}h2{font-size:16px;margin:0}.sub{color:var(--muted);margin:4px 0 0}
227
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
228
+ details.card>summary{cursor:pointer;font-weight:700;font-size:16px;list-style:none}
229
+ details.card>summary::-webkit-details-marker{display:none}details.card>summary::before{content:"\\25B8 ";color:var(--muted)}
230
+ details.card[open]>summary::before{content:"\\25BE "}details.card>summary span{color:var(--muted);font-weight:400;font-size:13px}
231
+ .wait{border-color:var(--high)}.wait h2{color:var(--wait-fg)}.empty{color:var(--muted)}
232
+ table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:7px 9px;border-top:1px solid var(--line);
233
+ vertical-align:top}th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);border-top:0}
234
+ .scroll{overflow-x:auto}.pill{display:inline-block;font-size:11px;font-weight:700;text-transform:uppercase;
235
+ padding:1px 8px;border-radius:999px;white-space:nowrap}
236
+ .s-todo{background:var(--todo-bg);color:var(--todo-fg)}.s-in-progress{background:var(--prog-bg);color:var(--prog-fg)}
237
+ .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)}
238
+ .s-code-done\\/data-pending{background:var(--block-bg);color:var(--block-fg)}
239
+ .lanes{display:grid;width:max-content}.lanes>div{border-top:1px solid var(--line);border-left:1px dashed var(--line);
240
+ padding:6px}.lanes .hd{background:var(--panel2);border-top:0;font-weight:700}.lanes .ln{position:sticky;left:0;z-index:1;
241
+ background:var(--panel);border-left:0;border-right:1px solid var(--line2);font-weight:600;min-width:170px}
242
+ .tile{display:flex;gap:6px;align-items:center;font-size:12px;padding:3px 6px;margin:0 0 4px;border-radius:5px;
243
+ border:1px solid var(--line2);background:var(--panel);max-width:100%}.tile b{font:600 11px ui-monospace,Consolas,monospace}
244
+ .tile span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile.in-progress{background:var(--prog-bg);
245
+ border-color:var(--prog);color:var(--prog-fg)}.tile.done{background:var(--done-bg);border-color:var(--done);
246
+ color:var(--done-fg)}.tile.moved{background:repeating-linear-gradient(135deg,var(--done-bg) 0 5px,var(--panel) 5px 10px);
247
+ border-color:var(--done)}.tile.blocked{background:var(--block-bg);border-color:var(--block);color:var(--block-fg)}
248
+ .hi::after{content:"";width:7px;height:7px;border-radius:50%;background:var(--high);flex:none;margin-left:auto}
249
+ .gate{font-size:11px;color:var(--block-fg);background:var(--block-bg);border:1px dashed var(--block);border-radius:6px;
250
+ 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}
251
+ .cols{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}@media (max-width:820px){.cols{grid-template-columns:1fr}}
252
+ .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}.kpi b{font-size:22px;display:block}
253
+ """
254
+
255
+ _LABEL = {"todo": "to do", "in-progress": "in progress", "done": "done", "moved": "moved", "blocked": "blocked"}
256
+
257
+
258
+ def _e(value) -> str:
259
+ return html.escape("" if value is None else str(value), quote=True)
260
+
261
+
262
+ def _pill(status: str) -> str:
263
+ return f'<span class="pill s-{_e(status)}">{_e(_LABEL.get(status, status))}</span>'
264
+
265
+
266
+ def _waiting_section(model: dict) -> str:
267
+ out = ['<section class="card wait"><h2>Waiting for you</h2>']
268
+ sessions, gates = model["sessions"], model["waiting"]
269
+ if not sessions and not gates:
270
+ out.append('<p class="empty">Nothing is waiting for you.</p></section>')
271
+ return "".join(out)
272
+ # A question or a permission first; a session that only finished its turn and sits idle comes after it.
273
+ asking = [s for s in sessions if not str(s.get("kind", "")).startswith("idle")]
274
+ idle = [s for s in sessions if str(s.get("kind", "")).startswith("idle")]
275
+ for title, group in (("Needs an answer", asking), ("Idle, waiting for your next message", idle)):
276
+ if not group:
277
+ continue
278
+ out.append(f'<div class="scroll"><table><tr><th>{_e(title)}</th><th>Message</th><th>Since</th><th>Folder</th></tr>')
279
+ for s in group:
280
+ stale = " (stale?)" if s["stale"] else ""
281
+ out.append(f"<tr><td><strong>{_e(s['session'])}</strong></td><td>{_e(s['message'])}</td>"
282
+ f"<td>{_e(s['since'])}{_e(stale)}</td><td><code>{_e(s['cwd'])}</code></td></tr>")
283
+ out.append("</table></div>")
284
+ if gates:
285
+ out.append('<div class="scroll"><table><tr><th>Owner gate</th><th>What it needs</th><th>Check</th></tr>')
286
+ for g in gates:
287
+ out.append(f"<tr><td><strong>{_e(g['milestone'])}</strong></td><td>{_e(g['reason'])}</td>"
288
+ f"<td><code>{_e(g['check'])}</code></td></tr>")
289
+ out.append("</table></div>")
290
+ out.append("</section>")
291
+ return "".join(out)
292
+
293
+
294
+ def _swimlane(model: dict) -> str:
295
+ milestones, lanes = model["milestones"], model["lanes"]
296
+ cols = len(milestones)
297
+ out = ['<details class="card" open><summary>Plan on a page <span>plans as lanes, milestones as columns in '
298
+ 'roadmap order, one tile per phase</span></summary><div class="scroll">',
299
+ f'<div class="lanes" style="grid-template-columns:170px repeat({cols},190px)">',
300
+ '<div class="hd ln">Plan / milestone</div>']
301
+ for ms in milestones:
302
+ dep = f"after {', '.join(ms['depends'])}" if ms["depends"] else "starts first"
303
+ gates = "".join(f'<div class="gate">{_e(b["type"])}: {_e(b["reason"])}</div>' for b in ms["blockers"])
304
+ out.append(f'<div class="hd">{_e(ms["id"])} {_pill(ms["status"])}<div class="empty" style="font-weight:400;'
305
+ f'font-size:11px">{_e(dep)} · {_e(ms["owner"])}</div>{gates}</div>')
306
+ for lane in lanes or ["-"]:
307
+ done = sum(1 for ms in milestones for ph in ms["phases"] if ph["plan"] == lane and ph["tile"] in ("done", "moved"))
308
+ total = sum(1 for ms in milestones for ph in ms["phases"] if ph["plan"] == lane)
309
+ out.append(f'<div class="ln">{_e(lane)}<div class="empty" style="font-weight:400;font-size:11px">'
310
+ f'{done}/{total} done</div></div>')
311
+ for ms in milestones:
312
+ tiles = []
313
+ for ph in ms["phases"]:
314
+ if ph["plan"] != lane:
315
+ continue
316
+ hi = " hi" if "high" in ph.get("risk", "").lower() else ""
317
+ title = f"{ph['ref']} {ph['name']} | {ph['tracker'] or ph['state']}"
318
+ tiles.append(f'<div class="tile {_e(ph["tile"])}{hi}" title="{_e(title)}"><b>{_e(ph["num"])}</b>'
319
+ f'<span>{_e(ph["name"] or ph["ref"])}</span></div>')
320
+ out.append(f"<div>{''.join(tiles)}</div>")
321
+ out.append('</div></div><div class="legend"><span>colour: to do · <b>in progress</b> · done · striped = moved · '
322
+ 'amber = blocked</span><span>red dot = Risk high (stops for review)</span></div></details>')
323
+ return "".join(out)
324
+
325
+
326
+ def _summary(model: dict) -> str:
327
+ phases = [ph for ms in model["milestones"] for ph in ms["phases"]]
328
+ done = sum(1 for ph in phases if ph["tile"] in ("done", "moved"))
329
+ building = [ph["ref"] for ph in phases if ph["tile"] == "in-progress"]
330
+ high = sum(1 for ph in phases if "high" in ph.get("risk", "").lower())
331
+ nxt = model.get("next") or {}
332
+ nxt_txt = f"{nxt.get('milestone', '')} {nxt.get('plan', '')}#{nxt.get('phase', '')}" if nxt else "none ready"
333
+ return ('<details class="card"><summary>Summary <span>progress, current work, next batch</span></summary>'
334
+ '<div class="kpis">'
335
+ f'<div class="kpi"><b>{_e(done)} / {_e(len(phases))}</b>phases done</div>'
336
+ f'<div class="kpi"><b>{_e(model["done"])} / {_e(model["total"])}</b>milestones done</div>'
337
+ f'<div class="kpi"><b>{_e(len(building))}</b>in progress: {_e(", ".join(building) or "none")}</div>'
338
+ f'<div class="kpi"><b>{_e(high)}</b>high-risk phases</div>'
339
+ f'<div class="kpi"><b>{_e(nxt_txt)}</b>next batch for the runner</div>'
340
+ "</div></details>")
341
+
342
+
343
+ def _now_next_later(model: dict) -> str:
344
+ ms_all = model["milestones"]
345
+ status = {ms["id"]: ms["status"] for ms in ms_all}
346
+ groups: dict[str, list[dict]] = {"Now": [], "Next": [], "Later": []}
347
+ for ms in ms_all:
348
+ if ms["status"] in ("done", "not-needed"):
349
+ continue
350
+ ready = all(status.get(d) in ("done", "not-needed") for d in ms["depends"])
351
+ if ms["status"] == "in-progress" or (ready and not ms["blockers"]):
352
+ groups["Now"].append(ms)
353
+ elif any(d in [m["id"] for m in groups["Now"]] for d in ms["depends"]):
354
+ groups["Next"].append(ms)
355
+ else:
356
+ groups["Later"].append(ms)
357
+ cols = []
358
+ for title, items in groups.items():
359
+ body = "".join(f'<p><strong>{_e(m["id"])}</strong> {_pill(m["status"])} '
360
+ f'{_e(sum(1 for p in m["phases"] if p["tile"] in ("done", "moved")))}/{_e(len(m["phases"]))}'
361
+ f' phases{"".join(" · " + _e(b["type"]) for b in m["blockers"])}</p>' for m in items)
362
+ cols.append(f'<div><h2>{_e(title)}</h2>{body or "<p class=empty>none</p>"}</div>')
363
+ return ('<details class="card"><summary>Now, next, later <span>which milestones can start</span></summary>'
364
+ f'<div class="cols">{"".join(cols)}</div></details>')
365
+
366
+
367
+ def _gates(model: dict) -> str:
368
+ rows = [(ms["id"], b) for ms in model["milestones"] for b in ms["blockers"]]
369
+ body = "".join(f"<tr><td><strong>{_e(mid)}</strong></td><td>{_e(b['type'])}</td><td>{_e(b['reason'])}</td>"
370
+ f"<td><code>{_e(b['check'])}</code></td></tr>" for mid, b in rows)
371
+ return (f'<details class="card"><summary>Gates and blockers <span>{_e(len(rows))} open</span></summary>'
372
+ + (f'<div class="scroll"><table><tr><th>Milestone</th><th>Type</th><th>Reason</th><th>Check</th></tr>{body}'
373
+ "</table></div>" if rows else '<p class="empty">No blockers.</p>') + "</details>")
374
+
375
+
376
+ def _milestones(model: dict) -> str:
377
+ out = ['<details class="card"><summary>Milestones and phases <span>every phase with model, lane, risk and '
378
+ 'Tracker status</span></summary>']
379
+ for ms in model["milestones"]:
380
+ rows = "".join(
381
+ f"<tr><td><code>{_e(ph['ref'])}</code></td><td>{_e(ph['name'])}</td><td>{_e(_short_model(ph['model']))}</td>"
382
+ f"<td>{_e(ph['lane'].split(' ', 1)[0] if ph['lane'] else '-')}</td>"
383
+ f"<td>{_e(ph['risk'] or '-')}</td><td>{_pill(ph['tile'])} {_e(ph['tracker'])}</td></tr>"
384
+ for ph in ms["phases"])
385
+ target = f" · target {_e(ms['target'])}" if ms.get("target") else ""
386
+ out.append(f"<h2 style=\"margin-top:12px\">{_e(ms['id'])} {_pill(ms['status'])} "
387
+ f"<span class=\"empty\" style=\"font-weight:400;font-size:13px\">owner {_e(ms['owner'])}{target} · "
388
+ f"after {_e(', '.join(ms['depends']) or '-')}</span></h2>")
389
+ out.append('<div class="scroll"><table><tr><th>Phase</th><th>Name</th><th>Model</th><th>Lane</th><th>Risk</th>'
390
+ f'<th>Tracker</th></tr>{rows or "<tr><td colspan=6 class=empty>no build phases</td></tr>"}'
391
+ "</table></div>")
392
+ out.append("</details>")
393
+ return "".join(out)
394
+
395
+
396
+ def _runner(model: dict) -> str:
397
+ r = model["runner"]
398
+ parts = []
399
+ if r["lock"]:
400
+ lk = r["lock"]
401
+ parts.append(f"<p>Running batch <code>{_e(lk.get('batch'))}</code> since {_e(lk.get('started_utc'))} "
402
+ f"(host {_e(lk.get('host'))}, pid {_e(lk.get('pid'))}).</p>")
403
+ else:
404
+ parts.append('<p class="empty">No runner holds this roadmap now.</p>')
405
+ if r["stale_lock"]:
406
+ parts.append(f"<p>A stale runner lock remains (batch <code>{_e(r['stale_lock'].get('batch'))}</code>): "
407
+ "the runner that held it is gone.</p>")
408
+ stop = r["last_stop"]
409
+ if stop:
410
+ if str(stop.get("reason")) == "running":
411
+ parts.append("<p>Last stop record: running now.</p>")
412
+ else:
413
+ code = f" (exit {_e(stop.get('exit'))})" if stop.get("exit") is not None else ""
414
+ parts.append(f"<p>Last stop {_e(stop.get('utc'))}: {_e(stop.get('reason'))}{code}.</p>")
415
+ for err in r["errors"]:
416
+ parts.append(f"<p>Could not read {_e(err)}.</p>")
417
+ nxt = model.get("next")
418
+ if nxt:
419
+ parts.append(f"<p>Next batch: <code>{_e(nxt.get('milestone'))} {_e(nxt.get('plan'))}#{_e(nxt.get('phase'))}"
420
+ "</code></p>")
421
+ return ('<details class="card"><summary>Runner <span>roadmap-run state on this machine</span></summary>'
422
+ + "".join(parts) + "</details>")
423
+
424
+
425
+ def render_html(model: dict) -> str:
426
+ """Self-contained HTML: no script, no network. Every value from the files is escaped."""
427
+ title = f"Roadmap {model['roadmap']}"
428
+ phases = [ph for ms in model["milestones"] for ph in ms["phases"]]
429
+ done = sum(1 for ph in phases if ph["tile"] in ("done", "moved"))
430
+ commit = f" · origin <code>{_e(model.get('origin_commit'))}</code>" if model.get("origin_commit") else ""
431
+ return "".join([
432
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">',
433
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
434
+ f"<title>{_e(title)}</title><style>{_CSS}</style></head><body><main>",
435
+ f'<header><h1>{_e(title)}</h1><p class="sub">{_e(model["done"])} of {_e(model["total"])} milestones · '
436
+ f'{_e(done)} of {_e(len(phases))} phases done · branch <code>{_e(model["branch"])}</code>{commit} · '
437
+ f"generated {_e(model['generated'])}</p></header>",
438
+ _waiting_section(model), _swimlane(model), _summary(model), _now_next_later(model), _gates(model),
439
+ _milestones(model), _runner(model),
440
+ '<p class="empty">Built from roadmap.md, the plan Trackers and headers on origin, the runner state and the '
441
+ "session-state folder. Re-rendered by the runner, /handoff, /pr-merge, /catchup and the waiting hooks.</p>",
442
+ "</main></body></html>\n"])
443
+
444
+
445
+ def default_page_path(root: Path, slug: str, home: Path | None = None) -> Path:
446
+ """`~/.caddis/roadmap-pages/<repo folder>-<6 hex>/<slug>.html`: outside every checkout, so a render never makes a
447
+ repo dirty (17 Oct: the in-repo `.local/` folder is not gitignored in every repo). The folder is the MAIN
448
+ checkout (a worktree shares its page); the 6 hex of its path keep two clones with one name apart."""
449
+ main = main_checkout(root)
450
+ tag = hashlib.sha1(str(main).lower().encode("utf-8")).hexdigest()[:6]
451
+ return pages_root(home) / f"{main.name}-{tag}" / f"{slug}.html"
452
+
453
+
454
+ def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path:
455
+ """The page. At the default path it also writes `<slug>.json`, the one-row summary the hub is built from; an
456
+ `--out` page is a one-off copy and stays out of the hub."""
457
+ model = build(root, slug, roadmap_mod)
458
+ path = Path(out) if out else default_page_path(root, slug)
459
+ path.parent.mkdir(parents=True, exist_ok=True)
460
+ _write_atomic(path, render_html(model))
461
+ if out is None:
462
+ try: # the summary feeds the hub only: the page is written, so a failure here never fails the render
463
+ _write_atomic(path.with_suffix(".json"),
464
+ json.dumps(page_summary(model, main_checkout(root), path), indent=1))
465
+ except Exception as exc:
466
+ print(f"[ROADMAP] hub summary not written: {exc}", file=sys.stderr)
467
+ return path
468
+
469
+
470
+ # ---- the hub: one page over every roadmap page on this machine, grouped per repo ---------------------------------
471
+
472
+ HUB_STALE_H = 24 # a summary older than this is marked "not refreshed"
473
+
474
+
475
+ def _write_atomic(path: Path, text: str) -> None:
476
+ """Write then rename, so a reader (a browser, nginx, a second render) never sees half a file."""
477
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
478
+ try:
479
+ tmp.write_text(text, encoding="utf-8")
480
+ for attempt in range(5): # Windows: a reader (browser, nginx, another render) can hold the target briefly
481
+ try:
482
+ os.replace(tmp, path)
483
+ return
484
+ except PermissionError:
485
+ if attempt == 4:
486
+ raise
487
+ time.sleep(0.1 * (attempt + 1))
488
+ finally:
489
+ try:
490
+ tmp.unlink()
491
+ except FileNotFoundError:
492
+ pass
493
+
494
+
495
+ def pages_root(home: Path | None = None) -> Path:
496
+ return Path(home or Path.home()) / ".caddis" / "roadmap-pages"
497
+
498
+
499
+ def page_summary(model: dict, main: Path, path: Path) -> dict:
500
+ """The hub's row for one roadmap. Waiting sessions belong to the checkout, not to one roadmap, so the hub shows
501
+ them once per repo."""
502
+ phases = [ph for ms in model["milestones"] for ph in ms["phases"]]
503
+ r = model["runner"]
504
+ stop = r.get("last_stop") or {}
505
+ runner = ("running" if r.get("lock") or str(stop.get("reason")) == "running"
506
+ else f"stopped: {stop.get('reason')}" if stop.get("reason") else "")
507
+ nxt = model.get("next") or {}
508
+ return {
509
+ "version": 1, "repo": main.name, "checkout": str(main), "folder": path.parent.name, "slug": model["roadmap"],
510
+ "branch": model["branch"], "origin_commit": model.get("origin_commit", ""),
511
+ "generated_utc": model["generated"],
512
+ "milestones_done": model["done"], "milestones_total": model["total"],
513
+ "phases_done": sum(1 for ph in phases if ph["tile"] in ("done", "moved")), "phases_total": len(phases),
514
+ "in_progress": [ph["ref"] for ph in phases if ph["tile"] == "in-progress"],
515
+ "blocked": sum(1 for ph in phases if ph["tile"] == "blocked"),
516
+ "owner_gates": [{"milestone": g["milestone"], "reason": g["reason"]} for g in model["waiting"]],
517
+ "sessions": [{"session": s["session"], "message": s["message"], "since": s["since"],
518
+ "asking": not str(s.get("kind", "")).startswith("idle"), "stale": s["stale"]}
519
+ for s in model["sessions"]],
520
+ "runner": runner,
521
+ "next": f"{nxt.get('milestone', '')} {nxt.get('plan', '')}#{nxt.get('phase', '')}" if nxt else "",
522
+ }
523
+
524
+
525
+ def prune_folder(folder: Path, keep: set[str]) -> int:
526
+ """Remove the page and summary of each roadmap that is no longer on origin. Only `<slug>.json` files and their
527
+ `<slug>.html` pages are touched. Returns how many roadmaps were removed."""
528
+ removed = 0
529
+ if not folder.is_dir():
530
+ return removed
531
+ for js in folder.glob("*.json"):
532
+ if js.stem in keep:
533
+ continue
534
+ for f in (js, js.with_suffix(".html")):
535
+ try:
536
+ f.unlink()
537
+ except FileNotFoundError:
538
+ pass
539
+ removed += 1
540
+ return removed
541
+
542
+
543
+ def read_summaries(root_dir: Path) -> tuple[list[dict], list[str]]:
544
+ """Every `<repo>-<hex>/<slug>.json` whose page still exists. An unreadable summary is named, not hidden."""
545
+ rows, errors = [], []
546
+ if not root_dir.is_dir():
547
+ return rows, errors
548
+ for js in sorted(root_dir.glob("*/*.json")):
549
+ if not js.with_suffix(".html").exists():
550
+ continue
551
+ data, err = _read_json(js)
552
+ if not isinstance(data, dict) or not data.get("slug"):
553
+ errors.append(err or f"{js.parent.name}/{js.name}: not a roadmap summary")
554
+ continue
555
+ data["page"] = f"{js.parent.name}/{js.with_suffix('.html').name}" # the link comes from the disk, not the file
556
+ data["folder"] = js.parent.name
557
+ data.setdefault("repo", js.parent.name.rsplit("-", 1)[0])
558
+ for key in ("sessions", "owner_gates"): # a hand-edited summary must not stop the hub for every repo
559
+ items = data.get(key)
560
+ data[key] = [i for i in items if isinstance(i, dict)] if isinstance(items, list) else []
561
+ if not isinstance(data.get("in_progress"), list):
562
+ data["in_progress"] = []
563
+ rows.append(data)
564
+ return rows, errors
565
+
566
+
567
+ def _age_h(utc_text: str, now: datetime.datetime) -> float | None:
568
+ try:
569
+ when = datetime.datetime.strptime(utc_text, "%Y-%m-%d %H:%M UTC").replace(tzinfo=datetime.timezone.utc)
570
+ except (ValueError, TypeError):
571
+ return None
572
+ return (now - when).total_seconds() / 3600
573
+
574
+
575
+ def _bar(done: int, total: int) -> str:
576
+ pct = int(round(100 * done / total)) if total else 0
577
+ return (f'<div class="bar" title="{_e(done)} of {_e(total)} phases done"><i style="width:{pct}%"></i></div>'
578
+ f'<span class="empty">{_e(done)}/{_e(total)} phases · {pct}%</span>')
579
+
580
+
581
+ # The hub's dark theme is Dracula (owner, 17 Oct): https://draculatheme.com/contribute - background #282a36,
582
+ # current line #44475a, foreground #f8f8f2, comment #6272a4, cyan, green, orange, pink, purple, red, yellow.
583
+ _HUB_CSS = _CSS + """
584
+ @media (prefers-color-scheme:dark){:root{--bg:#282a36;--panel:#21222c;--panel2:#343746;--fg:#f8f8f2;--muted:#6272a4;
585
+ --line:#44475a;--line2:#6272a4;--accent:#8be9fd;--todo-bg:#44475a;--todo-fg:#f8f8f2;--prog:#bd93f9;--prog-bg:#3c3352;
586
+ --prog-fg:#bd93f9;--done:#50fa7b;--done-bg:#2b4a36;--done-fg:#50fa7b;--block:#ffb86c;--block-bg:#4a3a2b;
587
+ --block-fg:#ffb86c;--high:#ff5555;--wait-bg:#4a2b33;--wait-fg:#ff5555}
588
+ h1{color:#bd93f9}.card summary{color:#ff79c6}code{color:#f1fa8c}}
589
+ .bar{height:8px;border-radius:99px;background:var(--todo-bg);overflow:hidden;min-width:120px;margin:4px 0}
590
+ .bar i{display:block;height:100%;background:var(--done)}a{color:var(--accent)}td.num{white-space:nowrap}
591
+ """
592
+
593
+
594
+ def _int(value) -> int:
595
+ try:
596
+ return int(value)
597
+ except (TypeError, ValueError):
598
+ return 0
599
+
600
+
601
+ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime) -> str:
602
+ """Self-contained HTML, no script. It reloads itself every 60 s, so an open tab follows the renders."""
603
+ by_repo: dict[str, list[dict]] = {}
604
+ for r in rows:
605
+ by_repo.setdefault(r["folder"], []).append(r)
606
+
607
+ def sessions_of(group): # the newest summary of a checkout has its newest session marks
608
+ return max(group, key=lambda r: str(r.get("generated_utc", ""))).get("sessions") or []
609
+
610
+ order = sorted(by_repo.items(), key=lambda kv: (str(kv[1][0].get("repo", "")).lower(), kv[0]))
611
+ need = []
612
+ for _folder, group in order:
613
+ asking = [s for s in sessions_of(group) if s.get("asking")]
614
+ gates = [(r, g) for r in group for g in r.get("owner_gates") or []]
615
+ if asking or gates:
616
+ need.append((group[0].get("repo"), asking, gates))
617
+ count = sum(len(a) + len(g) for _, a, g in need)
618
+ out = ['<!doctype html><html lang="en"><head><meta charset="utf-8">',
619
+ '<meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="60">',
620
+ f"<title>Roadmaps</title><style>{_HUB_CSS}</style></head><body><main>",
621
+ f'<header><h1>Roadmaps</h1><p class="sub">{_e(len(by_repo))} repos · {_e(len(rows))} roadmaps · '
622
+ f'{_e(count)} item(s) need you · generated {_e(now.strftime("%Y-%m-%d %H:%M UTC"))} · '
623
+ "reloads every minute</p></header>",
624
+ '<section class="card wait"><h2>Needs you</h2>']
625
+ if not need:
626
+ out.append('<p class="empty">Nothing is waiting for you.</p>')
627
+ else:
628
+ out.append('<div class="scroll"><table><tr><th>Repo</th><th>What</th><th>Detail</th></tr>')
629
+ for repo, asking, gates in need:
630
+ for s in asking:
631
+ stale = " (stale?)" if s.get("stale") else ""
632
+ out.append(f"<tr><td><strong>{_e(repo)}</strong></td><td>session {_e(s.get('session'))}</td>"
633
+ f"<td>{_e(s.get('message'))} · since {_e(s.get('since'))}{_e(stale)}</td></tr>")
634
+ for r, g in gates:
635
+ out.append(f"<tr><td><strong>{_e(repo)}</strong></td><td>owner gate {_e(r.get('slug'))} "
636
+ f"{_e(g.get('milestone'))}</td><td>{_e(g.get('reason'))}</td></tr>")
637
+ out.append("</table></div>")
638
+ out.append("</section>")
639
+ if not rows:
640
+ out.append('<p class="card empty">No roadmap page has been rendered on this machine yet. Run '
641
+ "<code>caddis_roadmap.py render --all</code> in a repo that has <code>.caddis/roadmaps/</code>.</p>")
642
+ for _folder, group in order:
643
+ first = group[0]
644
+ idle = sum(1 for s in sessions_of(group) if not s.get("asking"))
645
+ idle_txt = f" · {idle} idle session(s)" if idle else ""
646
+ out.append(f'<details class="card" open><summary>{_e(first.get("repo"))} <span>{_e(len(group))} roadmap(s)'
647
+ f'{_e(idle_txt)} · <code>{_e(first.get("checkout", ""))}</code></span></summary>'
648
+ '<div class="scroll"><table><tr><th>Roadmap</th><th>Progress</th><th>Milestones</th>'
649
+ "<th>In progress</th><th>Blocked</th><th>Runner</th><th>Next</th><th>Updated</th></tr>")
650
+ for r in sorted(group, key=lambda r: str(r["slug"])):
651
+ age = _age_h(str(r.get("generated_utc", "")), now)
652
+ old = " (not refreshed)" if age is not None and age > HUB_STALE_H else ""
653
+ href = _e(urllib.parse.quote(r["page"]))
654
+ out.append(f'<tr><td><a href="{href}"><strong>{_e(r["slug"])}</strong></a></td>'
655
+ f'<td>{_bar(_int(r.get("phases_done")), _int(r.get("phases_total")))}</td>'
656
+ f'<td class="num">{_e(r.get("milestones_done"))} / {_e(r.get("milestones_total"))}</td>'
657
+ f'<td>{_e(", ".join(map(str, r.get("in_progress") or [])) or "-")}</td>'
658
+ f'<td class="num">{_e(_int(r.get("blocked")))}</td><td>{_e(r.get("runner") or "-")}</td>'
659
+ f'<td><code>{_e(r.get("next") or "-")}</code></td>'
660
+ f'<td class="num">{_e(r.get("generated_utc"))}{_e(old)}</td></tr>')
661
+ out.append("</table></div></details>")
662
+ for err in errors:
663
+ out.append(f'<p class="empty">Could not read {_e(err)}.</p>')
664
+ out.append('<p class="empty">Built from the page summaries in this folder. Every roadmap page render rebuilds it.'
665
+ "</p></main></body></html>\n")
666
+ return "".join(out)
667
+
668
+
669
+ def write_hub(home: Path | None = None, now: datetime.datetime | None = None) -> Path:
670
+ """`~/.caddis/roadmap-pages/index.html`: every roadmap page on this machine, grouped per repo."""
671
+ root_dir = pages_root(home)
672
+ root_dir.mkdir(parents=True, exist_ok=True)
673
+ rows, errors = read_summaries(root_dir)
674
+ path = root_dir / "index.html"
675
+ _write_atomic(path, render_hub(rows, errors, now or datetime.datetime.now(datetime.timezone.utc)))
676
+ return path