@caddis/cli 0.4.60 → 0.4.61

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.146",
3
+ "version": "1.3.147",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -217,8 +217,8 @@ def build(root: Path, slug: str, roadmap_mod, now: datetime.datetime | None = No
217
217
 
218
218
  # One colour set for every page: light = slate + blue, dark = Dracula (owner, 17 Oct): https://draculatheme.com/contribute
219
219
  # 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;'
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);--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;'
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;--c0:#8be9fd;--c1:#bd93f9;--c2:#50fa7b;--c3:#ffb86c;--c4:#ff79c6;--c5:#6272a4;color-scheme:dark;'
222
222
  _THEME_CSS = (":root{" + _PALETTE_LIGHT + "}" + '@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){'
223
223
  + _PALETTE_DARK + "}}" + ':root[data-theme="dark"]{' + _PALETTE_DARK + "}")
224
224
 
@@ -230,7 +230,7 @@ h1{font-size:24px;margin:0;color:var(--title)}h2{font-size:16px;margin:0}.sub{co
230
230
  .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
231
231
  details.card>summary{cursor:pointer;font-weight:700;font-size:16px;list-style:none;color:var(--summary)}
232
232
  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}
233
+ details.card[open]>summary::before{content:"\\25BE "}details.card+details.card{margin-top:8px}
234
234
  .wait{border-color:var(--high)}.wait h2{color:var(--wait-fg)}.empty{color:var(--muted)}
235
235
  table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:7px 9px;border-top:1px solid var(--line);
236
236
  vertical-align:top}th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);border-top:0}
@@ -239,15 +239,21 @@ padding:1px 8px;border-radius:999px;white-space:nowrap}
239
239
  .s-todo{background:var(--todo-bg);color:var(--todo-fg)}.s-in-progress{background:var(--prog-bg);color:var(--prog-fg)}
240
240
  .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
241
  .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);
242
+ .lanes{display:grid}.lanes>div{border-top:1px solid var(--line);border-left:1px dashed var(--line);
243
243
  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;
244
+ background:var(--panel);border-left:4px solid var(--lc,var(--line2));border-right:1px solid var(--line2);font-weight:600;overflow-wrap:anywhere}
245
+ .tile{display:flex;gap:5px;align-items:center;font-size:12px;padding:3px 5px;position:relative;margin:0 0 4px;border-radius:5px;
246
246
  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);
247
+ .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
248
  border-color:var(--prog);color:var(--prog-fg)}.tile.done{background:var(--done-bg);border-color:var(--done);
249
249
  color:var(--done-fg)}.tile.moved{background:repeating-linear-gradient(135deg,var(--done-bg) 0 5px,var(--panel) 5px 10px);
250
250
  border-color:var(--done)}.tile.blocked{background:var(--block-bg);border-color:var(--block);color:var(--block-fg)}
251
+ .tile.hi::after{position:absolute;top:3px;right:3px;margin:0}
252
+ .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}
253
+ .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)}
254
+ .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)}
255
+ .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}
256
+ .lane-list{display:none}.lane-list h3{margin:10px 0 6px}@media (max-width:900px){.timeline .scroll{display:none}.lane-list{display:block}}
251
257
  .hi::after{content:"";width:7px;height:7px;border-radius:50%;background:var(--high);flex:none;margin-left:auto}
252
258
  .gate{font-size:11px;color:var(--block-fg);background:var(--block-bg);border:1px dashed var(--block);border-radius:6px;
253
259
  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 +263,11 @@ padding:3px 6px;margin:2px 0;white-space:normal;overflow-wrap:anywhere}.legend{d
257
263
  .bar i{display:block;height:100%;background:var(--done)}a{color:var(--accent)}td.num{white-space:nowrap}
258
264
  .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
265
  .hdr{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
266
+ .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)}
267
+ .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)}
268
+ .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)}
269
+ .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}
270
+ .cols .rcard{margin-bottom:8px}.cols .rcard .bar{margin:0}
260
271
  """
261
272
 
262
273
  _CSS = _THEME_CSS + _BASE_CSS
@@ -285,7 +296,7 @@ _SHELL_CSS = """
285
296
  .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
297
  .crumbs{flex:1;min-width:0;font-size:13px;color:var(--muted)}.crumbs a{color:var(--accent);text-decoration:none}
287
298
  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}
299
+ .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
300
  .nav-sub{display:none}.view-crumbs{font-size:13px;color:var(--muted);margin:0 0 8px}
290
301
  .nav-cb{display:none}.nav-btn,.nav-close,.nav-scrim{display:none}
291
302
  .nav-cb:focus-visible~.app .nav-btn{outline:2px solid var(--accent);outline-offset:2px}
@@ -430,20 +441,24 @@ def _waiting_section(model: dict) -> str:
430
441
  def _swimlane(model: dict) -> str:
431
442
  milestones, lanes = model["milestones"], model["lanes"]
432
443
  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)">',
444
+ # The section has the class `timeline` as well as the id: the hub prefixes every id, so the phone CSS selects by class.
445
+ out = ['<section class="card timeline" id="timeline"><div class="sec-h"><h2>Timeline</h2><span class="note">plans as '
446
+ 'lanes, milestones as columns in roadmap order, one tile per phase</span></div><div class="scroll">',
447
+ f'<div class="lanes" style="grid-template-columns:150px repeat({cols},minmax(110px,1fr));width:100%;'
448
+ f'min-width:{150 + cols * 110}px">',
436
449
  '<div class="hd ln">Plan / milestone</div>']
437
450
  for ms in milestones:
438
451
  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"])
452
+ n = len(ms["blockers"])
453
+ count = f' <span class="bcount">{n} gate(s)</span>' if n else ""
440
454
  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 ["-"]:
455
+ f'font-size:11px">{_e(dep)}</div>{count}</div>')
456
+ plan_lanes = lanes or ["-"]
457
+ for i, lane in enumerate(plan_lanes):
443
458
  done = sum(1 for ms in milestones for ph in ms["phases"] if ph["plan"] == lane and ph["tile"] in ("done", "moved"))
444
459
  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>')
460
+ out.append(f'<div class="ln" style="--lc:var(--c{i % 6})">{_e(lane)}<div class="empty" '
461
+ f'style="font-weight:400;font-size:11px">{done}/{total} done</div></div>')
447
462
  for ms in milestones:
448
463
  tiles = []
449
464
  for ph in ms["phases"]:
@@ -454,8 +469,24 @@ def _swimlane(model: dict) -> str:
454
469
  tiles.append(f'<div class="tile {_e(ph["tile"])}{hi}" title="{_e(title)}"><b>{_e(ph["num"])}</b>'
455
470
  f'<span>{_e(ph["name"] or ph["ref"])}</span></div>')
456
471
  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>')
472
+ if any(ms["blockers"] for ms in milestones): # the blockers get their own lane, so the column headers stay short
473
+ out.append('<div class="ln">Owner and outside</div>')
474
+ for ms in milestones:
475
+ cells = []
476
+ for b in ms["blockers"]:
477
+ reason = str(b["reason"])
478
+ short = reason if len(reason) <= 60 else reason[:60] + "…"
479
+ cells.append(f'<div class="gate" title="{_e(reason)}">{_e(b["type"])}: {_e(short)}</div>')
480
+ out.append(f"<div>{''.join(cells)}</div>")
481
+ out.append('</div></div><div class="lane-list">')
482
+ for ms in milestones: # the phone list: the grid is hidden on a narrow screen
483
+ out.append(f'<h3>{_e(ms["id"])} {_pill(ms["status"])}</h3>')
484
+ for ph in ms["phases"]:
485
+ hi = " hi" if "high" in ph.get("risk", "").lower() else ""
486
+ out.append(f'<div class="ltile {_e(ph["tile"])}{hi}"><b>{_e(ph["plan"])} {_e(ph["num"])}</b>'
487
+ f'<span>{_e(ph["name"] or ph["ref"])}</span></div>')
488
+ out.append('</div><div class="legend"><span>colour: to do · <b>in progress</b> · done · striped = moved · '
489
+ 'amber = blocked</span><span>red dot = Risk high (stops for review)</span></div></section>')
459
490
  return "".join(out)
460
491
 
461
492
 
@@ -466,14 +497,29 @@ def _summary(model: dict) -> str:
466
497
  high = sum(1 for ph in phases if "high" in ph.get("risk", "").lower())
467
498
  nxt = model.get("next") or {}
468
499
  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">'
500
+ return ('<section class="kpis" id="overview" aria-label="Summary">'
471
501
  f'<div class="kpi"><b>{_e(done)} / {_e(len(phases))}</b>phases done</div>'
472
502
  f'<div class="kpi"><b>{_e(model["done"])} / {_e(model["total"])}</b>milestones done</div>'
473
503
  f'<div class="kpi"><b>{_e(len(building))}</b>in progress: {_e(", ".join(building) or "none")}</div>'
474
504
  f'<div class="kpi"><b>{_e(high)}</b>high-risk phases</div>'
475
505
  f'<div class="kpi"><b>{_e(nxt_txt)}</b>next batch for the runner</div>'
476
- "</div></details>")
506
+ "</section>")
507
+
508
+
509
+ def _done_phases(ms: dict) -> int:
510
+ return sum(1 for ph in ms["phases"] if ph["tile"] in ("done", "moved"))
511
+
512
+
513
+ def _flow(model: dict) -> str:
514
+ """The milestones as a left-to-right chain in roadmap order; each node links to its milestone below."""
515
+ nodes = []
516
+ for ms in model["milestones"]:
517
+ dep = f"after {', '.join(ms['depends'])}" if ms["depends"] else "starts first"
518
+ nodes.append(f'<a class="fnode s-{_e(ms["status"])}" href="#ms-{_e(ms["id"])}"><b>{_e(ms["id"])}</b> '
519
+ f'{_pill(ms["status"])}<span>{_e(dep)} · {_e(_done_phases(ms))}/{_e(len(ms["phases"]))} ph</span></a>')
520
+ chain = '<span class="arrow" aria-hidden="true">→</span>'.join(nodes)
521
+ return (f'<section id="flow"><div class="sec-h"><h2>Flow</h2><span class="note">milestones in roadmap order</span></div>'
522
+ f'<div class="card scroll"><div class="flow">{chain}</div></div></section>')
477
523
 
478
524
 
479
525
  def _now_next_later(model: dict) -> str:
@@ -492,26 +538,30 @@ def _now_next_later(model: dict) -> str:
492
538
  groups["Later"].append(ms)
493
539
  cols = []
494
540
  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>')
541
+ body = "".join(f'<a class="rcard" href="#ms-{_e(m["id"])}"><span><strong>{_e(m["id"])}</strong> {_pill(m["status"])}</span>'
542
+ f'{_bar(_done_phases(m), len(m["phases"]))}'
543
+ + "".join(f'<span class="note">{_e(b["type"])}</span>' for b in m["blockers"])
544
+ + "</a>" for m in items)
545
+ cols.append(f'<div><h3>{_e(title)}</h3>{body or "<p class=empty>none</p>"}</div>')
546
+ return ('<section id="board"><div class="sec-h"><h2>Now, next, later</h2>'
547
+ '<span class="note">which milestones can start</span></div>'
548
+ f'<div class="cols">{"".join(cols)}</div></section>')
501
549
 
502
550
 
503
551
  def _gates(model: dict) -> str:
504
552
  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>"
553
+ body = "".join(f"<tr><td><strong>{_e(mid)}</strong></td>"
554
+ f"<td><span class=\"kind k-{_e(b['type'])}\">{_e(b['type'])}</span></td><td>{_e(b['reason'])}</td>"
506
555
  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>'
556
+ return (f'<section class="card" id="gates"><div class="sec-h"><h2>Gates and blockers</h2>'
557
+ f'<span class="note">{_e(len(rows))} open</span></div>'
508
558
  + (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>")
559
+ "</table></div>" if rows else '<p class="empty">No blockers.</p>') + "</section>")
510
560
 
511
561
 
512
562
  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>']
563
+ out = ['<section id="milestones"><div class="sec-h"><h2>Milestones and phases</h2><span class="note">every phase with '
564
+ 'model, lane, risk and Tracker status</span></div>']
515
565
  for ms in model["milestones"]:
516
566
  rows = "".join(
517
567
  f"<tr><td><code>{_e(ph['ref'])}</code></td><td>{_e(ph['name'])}</td><td>{_e(_short_model(ph['model']))}</td>"
@@ -519,13 +569,13 @@ def _milestones(model: dict) -> str:
519
569
  f"<td>{_e(ph['risk'] or '-')}</td><td>{_pill(ph['tile'])} {_e(ph['tracker'])}</td></tr>"
520
570
  for ph in ms["phases"])
521
571
  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>'
572
+ out.append(f'<details class="card" id="ms-{_e(ms["id"])}"><summary>{_e(ms["id"])} '
573
+ f'{_pill(ms["status"])} <span class="note">owner {_e(ms["owner"])}{target} · '
574
+ f'after {_e(", ".join(ms["depends"]) or "-")}</span></summary>'
575
+ '<div class="scroll"><table><tr><th>Phase</th><th>Name</th><th>Model</th><th>Lane</th><th>Risk</th>'
526
576
  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>")
577
+ "</table></div></details>")
578
+ out.append("</section>")
529
579
  return "".join(out)
530
580
 
531
581
 
@@ -554,13 +604,13 @@ def _runner(model: dict) -> str:
554
604
  if nxt:
555
605
  parts.append(f"<p>Next batch: <code>{_e(nxt.get('milestone'))} {_e(nxt.get('plan'))}#{_e(nxt.get('phase'))}"
556
606
  "</code></p>")
557
- return ('<details class="card" id="runner"><summary>Runner <span>roadmap-run state on this machine</span></summary>'
558
- + "".join(parts) + "</details>")
607
+ return ('<section class="card" id="runner"><div class="sec-h"><h2>Runner</h2>'
608
+ '<span class="note">roadmap-run state on this machine</span></div>' + "".join(parts) + "</section>")
559
609
 
560
610
 
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")]
611
+ # The sections of a roadmap view, in page order: the nav sub-links. `render_body` writes them in this order.
612
+ _PAGE_ANCHORS = [("waiting", "Waiting"), ("overview", "Overview"), ("flow", "Flow"), ("board", "Board"),
613
+ ("timeline", "Timeline"), ("gates", "Gates"), ("milestones", "Milestones"), ("runner", "Runner")]
564
614
 
565
615
  def _rid(folder, slug) -> str | None:
566
616
  """The view id of a roadmap in `index.html`: `rm-<the folder's 6 hex>-<slug>` (the 6 hex keep two clones with
@@ -602,12 +652,18 @@ def render_body(model: dict) -> str:
602
652
  phases = [ph for ms in model["milestones"] for ph in ms["phases"]]
603
653
  done = sum(1 for ph in phases if ph["tile"] in ("done", "moved"))
604
654
  commit = f" · origin <code>{_e(model.get('origin_commit'))}</code>" if model.get("origin_commit") else ""
655
+ chips = "".join(
656
+ f'<span class="chip" style="--lc:var(--c{i % 6})"><b>{_e(plan)}</b> <span>'
657
+ f'{_e(sum(1 for ph in phases if ph["plan"] == plan and ph["tile"] in ("done", "moved")))}/'
658
+ f'{_e(sum(1 for ph in phases if ph["plan"] == plan))} phases</span></span>'
659
+ for i, plan in enumerate(model["lanes"]))
605
660
  return "".join([
606
661
  f'<header class="hdr"><div><h1>{_e(title)}</h1><p class="sub">{_e(model["done"])} of {_e(model["total"])} '
607
662
  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),
663
+ f'{commit} · generated {_e(model["generated"])}</p>'
664
+ + (f'<div class="chips">{chips}</div>' if chips else "") + "</div></header>",
665
+ _waiting_section(model), _summary(model), _flow(model), _now_next_later(model), _swimlane(model),
666
+ _gates(model), _milestones(model), _runner(model),
611
667
  '<p class="empty">Built from roadmap.md, the plan Trackers and headers on origin, the runner state and the '
612
668
  "session-state folder. Re-rendered by the runner, /handoff, /pr-merge, /catchup and the waiting hooks.</p>"])
613
669
 
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.146",
2
+ "poolVersion": "1.3.147",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.146",
4
+ "antigravity-plugin": "1.3.147",
5
5
  "antigravity-plugin-extras": "1.3.45",
6
6
  "codex": "unknown"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.60",
3
+ "version": "0.4.61",
4
4
  "description": "Install, update and diagnose the caddis dev harness across every coding agent you use (Claude Code, agy/Antigravity, and more).",
5
5
  "keywords": [
6
6
  "caddis",