@seanyao/roll 0.5.0 → 2.602.2

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 (181) hide show
  1. package/CHANGELOG.md +736 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -165
  4. package/bin/dream-test-quality-scan +110 -0
  5. package/bin/roll +15030 -814
  6. package/conventions/config.yaml +17 -1
  7. package/conventions/global/AGENTS.md +146 -100
  8. package/conventions/global/CLAUDE.md +1 -21
  9. package/conventions/global/GEMINI.md +8 -22
  10. package/conventions/global/project_rules.md +9 -0
  11. package/conventions/templates/backend-service/AGENTS.md +30 -81
  12. package/conventions/templates/backend-service/GEMINI.md +3 -3
  13. package/conventions/templates/backend-service/project_rules.md +16 -0
  14. package/conventions/templates/cli/AGENTS.md +31 -58
  15. package/conventions/templates/cli/CLAUDE.md +3 -5
  16. package/conventions/templates/cli/GEMINI.md +3 -3
  17. package/conventions/templates/cli/project_rules.md +16 -0
  18. package/conventions/templates/frontend-only/AGENTS.md +29 -64
  19. package/conventions/templates/frontend-only/GEMINI.md +3 -3
  20. package/conventions/templates/frontend-only/project_rules.md +14 -0
  21. package/conventions/templates/fullstack/AGENTS.md +31 -79
  22. package/conventions/templates/fullstack/CLAUDE.md +1 -1
  23. package/conventions/templates/fullstack/GEMINI.md +3 -3
  24. package/conventions/templates/fullstack/project_rules.md +15 -0
  25. package/lib/README.md +42 -0
  26. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  37. package/lib/agent_usage/README.md +49 -0
  38. package/lib/agent_usage/__init__.py +108 -0
  39. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  41. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  42. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  43. package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
  44. package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
  45. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
  46. package/lib/agent_usage/gemini.py +127 -0
  47. package/lib/agent_usage/kimi.py +278 -0
  48. package/lib/agent_usage/kimi_emit.py +123 -0
  49. package/lib/agent_usage/openai.py +126 -0
  50. package/lib/agent_usage/pi.py +200 -0
  51. package/lib/agent_usage/pi_emit.py +135 -0
  52. package/lib/agent_usage/qwen.py +128 -0
  53. package/lib/backfill-pi-usage.py +243 -0
  54. package/lib/changelog_audit.py +155 -0
  55. package/lib/changelog_generate.py +263 -0
  56. package/lib/context_feed_budget.sh +194 -0
  57. package/lib/github_sync.py +876 -0
  58. package/lib/i18n/README.md +54 -0
  59. package/lib/i18n/agent.sh +75 -0
  60. package/lib/i18n/alert.sh +20 -0
  61. package/lib/i18n/backlog.sh +96 -0
  62. package/lib/i18n/brief.sh +5 -0
  63. package/lib/i18n/changelog.sh +5 -0
  64. package/lib/i18n/ci.sh +15 -0
  65. package/lib/i18n/debug.sh +0 -0
  66. package/lib/i18n/doctor.sh +44 -0
  67. package/lib/i18n/dream.sh +0 -0
  68. package/lib/i18n/init.sh +91 -0
  69. package/lib/i18n/lang.sh +10 -0
  70. package/lib/i18n/loop.sh +140 -0
  71. package/lib/i18n/migrate.sh +74 -0
  72. package/lib/i18n/offboard.sh +31 -0
  73. package/lib/i18n/onboard.sh +0 -0
  74. package/lib/i18n/peer.sh +41 -0
  75. package/lib/i18n/peer_help.sh +25 -0
  76. package/lib/i18n/peer_reset.sh +7 -0
  77. package/lib/i18n/peer_status.sh +5 -0
  78. package/lib/i18n/prices.sh +3 -0
  79. package/lib/i18n/prices_refresh.sh +17 -0
  80. package/lib/i18n/prices_show.sh +7 -0
  81. package/lib/i18n/propose.sh +0 -0
  82. package/lib/i18n/release.sh +0 -0
  83. package/lib/i18n/research.sh +0 -0
  84. package/lib/i18n/review_pr.sh +0 -0
  85. package/lib/i18n/sentinel.sh +0 -0
  86. package/lib/i18n/setup.sh +3 -0
  87. package/lib/i18n/shared.sh +157 -0
  88. package/lib/i18n/skills/roll-brief.sh +47 -0
  89. package/lib/i18n/skills/roll-build.sh +97 -0
  90. package/lib/i18n/skills/roll-design.sh +18 -0
  91. package/lib/i18n/skills/roll-fix.sh +53 -0
  92. package/lib/i18n/skills/roll-loop.sh +28 -0
  93. package/lib/i18n/skills/roll-onboard.sh +33 -0
  94. package/lib/i18n/skills_catalog.sh +30 -0
  95. package/lib/i18n/slides.sh +3 -0
  96. package/lib/i18n/slides_build.sh +38 -0
  97. package/lib/i18n/slides_delete.sh +19 -0
  98. package/lib/i18n/slides_list.sh +14 -0
  99. package/lib/i18n/slides_logs.sh +12 -0
  100. package/lib/i18n/slides_new.sh +15 -0
  101. package/lib/i18n/slides_preview.sh +14 -0
  102. package/lib/i18n/slides_templates.sh +7 -0
  103. package/lib/i18n/status.sh +21 -0
  104. package/lib/i18n/update.sh +24 -0
  105. package/lib/i18n.sh +211 -0
  106. package/lib/loop-exit-summary.py +393 -0
  107. package/lib/loop-fmt.py +589 -0
  108. package/lib/loop_pick_agent.py +316 -0
  109. package/lib/loop_result_eval.py +469 -0
  110. package/lib/loop_unstick.py +180 -0
  111. package/lib/model_prices.py +194 -0
  112. package/lib/prices/README.md +35 -0
  113. package/lib/prices/snapshot-2026-05-22.json +22 -0
  114. package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
  115. package/lib/prices/snapshot-2026-05-23-kimi.json +15 -0
  116. package/lib/prices_fetcher.py +285 -0
  117. package/lib/roll-backlog.py +225 -0
  118. package/lib/roll-brief.py +286 -0
  119. package/lib/roll-help.py +158 -0
  120. package/lib/roll-home.py +556 -0
  121. package/lib/roll-init.py +156 -0
  122. package/lib/roll-loop-status.py +1683 -0
  123. package/lib/roll-loop-story.py +191 -0
  124. package/lib/roll-onboard-render.py +378 -0
  125. package/lib/roll-peer.py +252 -0
  126. package/lib/roll-plan-validate.py +386 -0
  127. package/lib/roll-setup.py +102 -0
  128. package/lib/roll-status.py +367 -0
  129. package/lib/roll_git.py +41 -0
  130. package/lib/roll_render.py +414 -0
  131. package/lib/slides/components/README.md +123 -0
  132. package/lib/slides/components/cards-2.html +9 -0
  133. package/lib/slides/components/cards-3.html +9 -0
  134. package/lib/slides/components/cards-4.html +9 -0
  135. package/lib/slides/components/compare.html +22 -0
  136. package/lib/slides/components/highlight.html +9 -0
  137. package/lib/slides/components/pipeline.html +12 -0
  138. package/lib/slides/components/plain.html +7 -0
  139. package/lib/slides/components/quote.html +4 -0
  140. package/lib/slides/components/timeline.html +9 -0
  141. package/lib/slides/templates/introduction-v3.html +571 -0
  142. package/lib/slides/templates/pitch.html +0 -0
  143. package/lib/slides-render.py +778 -0
  144. package/lib/slides-validate.py +357 -0
  145. package/lib/test_quality_gate.py +143 -0
  146. package/package.json +8 -7
  147. package/skills/roll-.changelog/SKILL.md +406 -33
  148. package/skills/roll-.clarify/SKILL.md +5 -2
  149. package/skills/roll-.dream/SKILL.md +374 -0
  150. package/skills/roll-.echo/SKILL.md +5 -2
  151. package/skills/roll-.qa/SKILL.md +57 -3
  152. package/skills/roll-.review/SKILL.md +42 -3
  153. package/skills/roll-brief/SKILL.md +209 -0
  154. package/skills/roll-build/SKILL.md +308 -63
  155. package/skills/roll-debug/SKILL.md +341 -162
  156. package/skills/roll-debug/injectable-bb.js +263 -0
  157. package/skills/roll-deck/SKILL.md +296 -0
  158. package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
  159. package/skills/roll-design/SKILL.md +733 -94
  160. package/skills/roll-doc/SKILL.md +595 -0
  161. package/skills/roll-doctor/SKILL.md +192 -0
  162. package/skills/roll-fix/SKILL.md +149 -32
  163. package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
  164. package/skills/roll-loop/SKILL.md +579 -0
  165. package/skills/roll-notes/SKILL.md +103 -0
  166. package/skills/roll-onboard/SKILL.md +234 -0
  167. package/skills/roll-peer/SKILL.md +336 -0
  168. package/skills/roll-propose/SKILL.md +157 -0
  169. package/skills/roll-review-pr/SKILL.md +58 -0
  170. package/skills/roll-sentinel/SKILL.md +11 -2
  171. package/skills/roll-spar/SKILL.md +8 -6
  172. package/template/.github/workflows/ci.yml +5 -2
  173. package/template/AGENTS.md +20 -74
  174. package/skills/roll-research/SKILL.md +0 -307
  175. package/skills/roll-research/references/schema.json +0 -162
  176. package/skills/roll-research/scripts/md_to_pdf.py +0 -289
  177. package/tools/roll-fetch/SKILL.md +0 -182
  178. package/tools/roll-fetch/package.json +0 -15
  179. package/tools/roll-fetch/smart-web-fetch.js +0 -558
  180. package/tools/roll-probe/SKILL.md +0 -84
  181. /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
@@ -0,0 +1,414 @@
1
+ """
2
+ roll_render — shared terminal rendering primitives for roll CLI views.
3
+
4
+ Color palette, glyphs, padding/alignment, semantic deltas, and the layout
5
+ helpers used to print the static CLI dashboards (loop status, home, status,
6
+ backlog, brief, setup, init, peer). Every visible color lives in PAL;
7
+ NO_COLOR=1 falls through to glyph + weight + spacing only.
8
+
9
+ CJK display width is honored via strw() — CJK and fullwidth glyphs occupy
10
+ 2 cells; this is what keeps EN/ZH paired rows aligned.
11
+
12
+ Set roll_render.USE_COLOR from the entry script after parsing flags / TTY.
13
+ """
14
+
15
+ from __future__ import annotations
16
+ import re
17
+ from datetime import datetime
18
+ from typing import Any, Dict, List, Tuple
19
+ from unicodedata import east_asian_width
20
+
21
+ # ════════════════════════════════════════════════════════════════════════════
22
+ # ANSI / color
23
+ # ════════════════════════════════════════════════════════════════════════════
24
+ USE_COLOR = True
25
+ COLS = 100 # fixed 100-col grid; auto-narrow handled in caller's render
26
+
27
+ def _rgb(hexstr: str) -> str:
28
+ h = hexstr.lstrip("#")
29
+ return f"\033[38;2;{int(h[0:2],16)};{int(h[2:4],16)};{int(h[4:6],16)}m"
30
+
31
+ # Single source of truth — every visible color in the design lives here.
32
+ PAL = {
33
+ "fg": _rgb("e6edf3"),
34
+ "dim": _rgb("8b949e"),
35
+ "muted": _rgb("6e7681"),
36
+ "faint": _rgb("484f58"),
37
+ "blue": _rgb("58a6ff"),
38
+ "green": _rgb("3fb950"),
39
+ "amber": _rgb("d29922"),
40
+ "red": _rgb("f85149"),
41
+ "purple": _rgb("bc8cff"),
42
+ "pink": _rgb("f778ba"),
43
+ "yellow": _rgb("e3b341"),
44
+ }
45
+ BOLD = "\033[1m"
46
+ RESET = "\033[0m"
47
+
48
+ def c(color: str, s: str, *, bold: bool = False) -> str:
49
+ if not USE_COLOR:
50
+ return s
51
+ return f"{PAL.get(color, '')}{BOLD if bold else ''}{s}{RESET}"
52
+
53
+ # ════════════════════════════════════════════════════════════════════════════
54
+ # East-Asian display width — CJK and fullwidth glyphs occupy 2 cells.
55
+ # This is what keeps EN/ZH paired rows column-aligned in the terminal.
56
+ # ════════════════════════════════════════════════════════════════════════════
57
+ _ANSI_RE = re.compile(r"\033\[[\d;]*m")
58
+
59
+ def strip_ansi(s: str) -> str:
60
+ """Strip ANSI escape sequences (CSI SGR) from a string."""
61
+ return _ANSI_RE.sub("", s)
62
+
63
+ def strw(s: str) -> int:
64
+ """Display width of a string after stripping ANSI escapes."""
65
+ bare = _ANSI_RE.sub("", s)
66
+ w = 0
67
+ for ch in bare:
68
+ w += 2 if east_asian_width(ch) in ("F", "W") else 1
69
+ return w
70
+
71
+ def pad(s: str, w: int, align: str = "l") -> str:
72
+ sw = strw(s)
73
+ if sw >= w:
74
+ return s
75
+ fill = " " * (w - sw)
76
+ return fill + s if align == "r" else s + fill
77
+
78
+ def row(left: str, right: str, width: int = COLS) -> str:
79
+ """Two-end-flush row at `width` columns."""
80
+ gap = max(1, width - strw(left) - strw(right))
81
+ return left + " " * gap + right
82
+
83
+ # ════════════════════════════════════════════════════════════════════════════
84
+ # Formatters
85
+ # ════════════════════════════════════════════════════════════════════════════
86
+ def fmt_dur(s: int) -> str:
87
+ if s < 3600:
88
+ return f"{s // 60}m"
89
+ return f"{s // 3600}h {(s % 3600) // 60}m"
90
+
91
+ # FIX-121: agent → primary model used by `roll loop` dashboard's fallback
92
+ # when an event stream lacks an explicit model name (non-claude agents'
93
+ # stdout isn't stream-json so loop-fmt can't extract model). Keeps the
94
+ # model column consistent with claude's "opus-4-7" style.
95
+ _AGENT_PRIMARY_MODEL = {
96
+ "pi": "deepseek-v4-pro",
97
+ "deepseek": "deepseek-v4-pro",
98
+ "kimi": "kimi-k2-0905",
99
+ }
100
+
101
+
102
+ def fmt_model(model) -> str:
103
+ """Short label for the cycle row's model column.
104
+
105
+ `claude-opus-4-7-20251001` → `opus-4-7`
106
+ None / empty → `—`
107
+ Non-claude vendor → `?`
108
+ """
109
+ if not model:
110
+ return "—"
111
+ if not model.startswith("claude-"):
112
+ return "?"
113
+ s = model[len("claude-"):]
114
+ s = re.sub(r"-\d{6,8}$", "", s)
115
+ return s if s else "?"
116
+
117
+ def fmt_tokens(n: int) -> str:
118
+ """Format a token count with K / M / B unit scaling, 1 decimal place.
119
+ Uppercase suffix disambiguates from duration's lowercase m / h on the
120
+ same row (e.g. "19m 6.7M" reads cleanly as 19 minutes + 6.7M tokens)."""
121
+ if not n:
122
+ return "—"
123
+ if n < 1_000:
124
+ return str(int(n))
125
+ if n < 1_000_000:
126
+ return f"{n / 1_000:.1f}K".replace(".0K", "K")
127
+ if n < 1_000_000_000:
128
+ return f"{n / 1_000_000:.1f}M".replace(".0M", "M")
129
+ return f"{n / 1_000_000_000:.1f}B".replace(".0B", "B")
130
+
131
+ # Subtle red wash for the entire failure row — doubles up the color signal
132
+ # so a fail can't be missed even when scanning at 2x speed. Used by
133
+ # cycle_row when outcome=fail.
134
+ BG_FAIL = "\033[48;2;55;15;15m"
135
+
136
+ def fmt_delta(today: float, yest: float, *, kind: str, unit: str = "") -> Tuple[str, str]:
137
+ """Return (delta_string, semantic_color). kind ∈ {'up_good','up_bad','any'}.
138
+ `unit`: '' → plain int, '$' → currency, 'm' → minutes (caller pre-converts)."""
139
+ if yest == 0 and today == 0:
140
+ return ("—", "muted")
141
+ if yest == 0:
142
+ return ("▲ new", "amber")
143
+ diff = today - yest
144
+ if abs(diff) < 1e-9:
145
+ return ("=", "muted")
146
+ arrow = "▲" if diff > 0 else "▼"
147
+ sign = "+" if diff > 0 else "−"
148
+ mag = abs(diff)
149
+ if unit in ("$", "¥"):
150
+ body = f"{sign}{unit}{mag:.2f}"
151
+ elif unit == "m":
152
+ body = f"{sign}{int(round(mag))}m"
153
+ else:
154
+ body = f"{sign}{int(round(mag))}"
155
+ color = {
156
+ "up_good": "green" if diff > 0 else "amber",
157
+ "up_bad": "red" if diff > 0 else "green",
158
+ "any": "amber",
159
+ }[kind]
160
+ return (f"{arrow} {body}", color)
161
+
162
+ def trunc(s: str, n: int) -> str:
163
+ if strw(s) <= n:
164
+ return s
165
+ out = ""
166
+ for ch in s:
167
+ if strw(out) + strw(ch) + 1 > n:
168
+ return out + "…"
169
+ out += ch
170
+ return out
171
+
172
+ def empty_rollup() -> Dict[str, Any]:
173
+ return {"cycles": 0, "prs": 0, "failed": 0, "duration_s": 0, "cost": 0.0,
174
+ "input_tokens": 0, "output_tokens": 0}
175
+
176
+ # ════════════════════════════════════════════════════════════════════════════
177
+ # Section / metric / cycle rows — printers used by all dashboards
178
+ # ════════════════════════════════════════════════════════════════════════════
179
+ def section_head(en: str, zh: str, hint: str) -> None:
180
+ left = " " + c("pink", en, bold=True) + c("muted", " · ") + c("dim", zh)
181
+ print(row(left, c("muted", hint)))
182
+
183
+ def metric(name: str, t: int, y: int, d2: int, kind: str, *,
184
+ yest_color: str = "dim", yest_suffix: str = "",
185
+ partial: bool = False) -> None:
186
+ """Print one metric row. When `partial=True` the delta is rendered in
187
+ muted gray instead of green/red — today's incomplete, so a 'down −23'
188
+ against yesterday's full day would otherwise read as an alarm.
189
+
190
+ Column geometry (kept in lockstep with the header in roll-loop-status):
191
+ indent 2 · name 14 · today_value 8 · gap 2 · delta 12 · yest 10 · d2 8
192
+ """
193
+ delta_text, delta_c = fmt_delta(float(t), float(y), kind=kind)
194
+ if partial and delta_c not in ("muted",):
195
+ delta_c = "muted"
196
+ yest_str = f"{y}" + (f" {yest_suffix}" if yest_suffix else "")
197
+ print(" " +
198
+ c("dim", pad(name, 14)) +
199
+ c("fg", pad(str(t), 8, "r"), bold=True) + " " +
200
+ c(delta_c, pad(delta_text, 12), bold=(delta_c != "muted")) +
201
+ c(yest_color, pad(yest_str, 10), bold=bool(yest_suffix)) +
202
+ c("muted", pad(str(d2), 8)))
203
+
204
+ def metric_dur(name: str, t: int, y: int, d2: int, *, partial: bool = False) -> None:
205
+ # work in whole minutes for the delta so it reads naturally (▲ +14m)
206
+ t_m = t // 60
207
+ y_m = y // 60
208
+ delta_text, delta_c = fmt_delta(float(t_m), float(y_m), kind="up_bad", unit="m")
209
+ if partial and delta_c not in ("muted",):
210
+ delta_c = "muted"
211
+ print(" " +
212
+ c("dim", pad(name, 14)) +
213
+ c("fg", pad(fmt_dur(t), 8, "r"), bold=True) + " " +
214
+ c(delta_c, pad(delta_text, 12), bold=(delta_c != "muted")) +
215
+ c("dim", pad(fmt_dur(y), 10)) +
216
+ c("muted", pad(fmt_dur(d2), 8)))
217
+
218
+ def metric_dollar(name: str, t: float, y: float, d2: float, *,
219
+ partial: bool = False, symbol: str = "$") -> None:
220
+ # FIX-126: currency-aware — deepseek cost is native CNY (¥), claude USD ($).
221
+ # We never convert; the rollup shows one row per currency with its own
222
+ # symbol, so a ¥-row and a $-row are never summed into a meaningless total.
223
+ delta_text, delta_c = fmt_delta(t, y, kind="up_bad", unit=symbol)
224
+ if partial and delta_c not in ("muted",):
225
+ delta_c = "muted"
226
+ print(" " +
227
+ c("dim", pad(name, 14)) +
228
+ c("fg", pad(f"{symbol}{t:.2f}", 8, "r"), bold=True) + " " +
229
+ c(delta_c, pad(delta_text, 12), bold=(delta_c != "muted")) +
230
+ c("dim", pad(f"{symbol}{y:.2f}", 10)) +
231
+ c("muted", pad(f"{symbol}{d2:.2f}", 8)))
232
+
233
+ def metric_tokens(name: str, t: int, y: int, d2: int, *, partial: bool = False) -> None:
234
+ # Compose the delta string with token-unit scaling so a 200M increase
235
+ # doesn't print '+200000000'.
236
+ if y == 0 and t == 0:
237
+ delta_text, delta_c = "—", "muted"
238
+ elif y == 0:
239
+ delta_text, delta_c = "▲ new", "amber"
240
+ elif t == y:
241
+ delta_text, delta_c = "=", "muted"
242
+ else:
243
+ diff = t - y
244
+ arrow = "▲" if diff > 0 else "▼"
245
+ sign = "+" if diff > 0 else "−"
246
+ delta_text = f"{arrow} {sign}{fmt_tokens(abs(diff))}"
247
+ delta_c = "red" if diff > 0 else "green"
248
+ if partial and delta_c not in ("muted",):
249
+ delta_c = "muted"
250
+ print(" " +
251
+ c("dim", pad(name, 14)) +
252
+ c("fg", pad(fmt_tokens(t), 8, "r"), bold=True) + " " +
253
+ c(delta_c, pad(delta_text, 12), bold=(delta_c != "muted")) +
254
+ c("dim", pad(fmt_tokens(y), 10)) +
255
+ c("muted", pad(fmt_tokens(d2), 8)))
256
+
257
+ def day_band(day_key: str, n_total: int, n_failed: int, now: datetime, *,
258
+ in_progress: bool = False) -> None:
259
+ from datetime import timedelta
260
+ today = now.strftime("%Y-%m-%d")
261
+ yest = (now - timedelta(days=1)).strftime("%Y-%m-%d")
262
+ if day_key == today:
263
+ label = "Today · 今日"
264
+ elif day_key == yest:
265
+ label = "Yesterday · 昨日"
266
+ else:
267
+ n = (now.date() - datetime.strptime(day_key, "%Y-%m-%d").date()).days
268
+ label = f"−{n} days · 前 {n} 天"
269
+ weekday = datetime.strptime(day_key, "%Y-%m-%d").strftime("%a")
270
+ weekday_zh = ["周一","周二","周三","周四","周五","周六","周日"][
271
+ datetime.strptime(day_key, "%Y-%m-%d").weekday()]
272
+ count_str = f"{n_total} cycles" + (f" · {n_failed} failed" if n_failed else " · 0 failed")
273
+ if in_progress:
274
+ count_str += " · " + "in progress"
275
+ left = (" " + c("faint", "─ ") +
276
+ c("fg", label, bold=True) +
277
+ c("muted", " · ") + c("dim", day_key) +
278
+ c("muted", " · ") + c("dim", f"{weekday} · {weekday_zh}") + " ")
279
+ if in_progress:
280
+ right_inner = (c("dim", f"{n_total} cycles") +
281
+ (c("dim", f" · {n_failed} failed") if n_failed
282
+ else c("dim", " · 0 failed")) +
283
+ c("muted", " · ") + c("amber", "in progress"))
284
+ else:
285
+ right_inner = c("dim", count_str)
286
+ right = " " + right_inner
287
+ dashes = max(2, COLS - strw(left) - strw(right))
288
+ print(left + c("faint", "─" * dashes) + right)
289
+
290
+ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
291
+ outcome = cy.get("outcome", "done")
292
+ pr_outcome = cy.get("pr_outcome")
293
+ glyph_c, glyph = {
294
+ "done": ("green", "✓"),
295
+ "ok": ("green", "✓"),
296
+ "fail": ("red", "✗"),
297
+ "running": ("purple", "⏵"),
298
+ "idle": ("muted", "·"),
299
+ }.get(outcome, ("muted", "·"))
300
+ # US-VIEW-011: a completed cycle whose PR was closed without merging is
301
+ # a "wasted run" — flip the green ✓ to an amber ⊘ so it can't be
302
+ # mistaken for a real delivery when scanning the dashboard.
303
+ if outcome in ("done", "ok") and pr_outcome == "closed":
304
+ glyph_c, glyph = "amber", "⊘"
305
+ time_str = cy["start"].astimezone().strftime("%H:%M")
306
+ cr = cy.get("cron") or {}
307
+ # duration prefers the explicit cy["duration_s"] (computed from event
308
+ # timestamps or runs.jsonl) so it shows for all completed cycles, not
309
+ # only the one that happens to be in the latest cron.log dump.
310
+ # For a currently-running cycle, show wall-clock elapsed (now - start).
311
+ dur_s = cy.get("duration_s") or cr.get("duration_s") or 0
312
+ if outcome == "running" and not dur_s and cy.get("start"):
313
+ from datetime import datetime as _dt, timezone as _tz
314
+ dur_s = int((_dt.now(_tz.utc) - cy["start"]).total_seconds())
315
+ dur = fmt_dur(dur_s) if dur_s else "—"
316
+ # US-VIEW-017: show all 4 token components when cache data is available.
317
+ # Format: "in/cw↑ cr↓/out" (cache writes ↑, cache reads ↓).
318
+ # Falls back to "in/out" for cycles that predate cache tracking.
319
+ inp = cy.get('input_tokens') or 0
320
+ out_tok = cy.get('output_tokens') or 0
321
+ cw = cy.get('cache_creation_tokens') or 0
322
+ cr = cy.get('cache_read_tokens') or 0
323
+ if cw or cr:
324
+ tok = (f"{fmt_tokens(inp)}"
325
+ f"/{fmt_tokens(cw)}↑ {fmt_tokens(cr)}↓"
326
+ f"/{fmt_tokens(out_tok)}")
327
+ else:
328
+ tok = f"{fmt_tokens(inp)}/{fmt_tokens(out_tok)}"
329
+ # cost prefers the backfilled list-price; falls back to cron.log when
330
+ # the claude session log isn't available (only the latest cycle).
331
+ # FIX-116: use the model's native currency symbol.
332
+ cur = cy.get("cost_currency", "USD")
333
+ symbol = "¥" if cur == "CNY" else "$"
334
+ if cy.get("cost_list") is not None:
335
+ cost = f"{symbol}{cy['cost_list']:.2f}"
336
+ elif cr:
337
+ cost = f"{symbol}{cr.get('cost', 0):.2f}"
338
+ else:
339
+ cost = "—"
340
+ sid = cy.get("story") or "—"
341
+ built = cy.get("built") or ([sid] if sid != "—" else [])
342
+ # Join multiple stories with " | ". Drop empties and dedupe in order.
343
+ seen = set()
344
+ ids = []
345
+ for s in built:
346
+ if s and s not in seen:
347
+ seen.add(s)
348
+ ids.append(s)
349
+ ids_str = " | ".join(ids) if ids else sid
350
+ time_c = "red" if outcome == "fail" else "fg"
351
+ sid_c = "red" if outcome == "fail" else "blue"
352
+
353
+ model_label = fmt_model(cy.get("model"))
354
+ # FIX-119: fall back to cy["agent"] (from agent_used event) when model
355
+ # is unknown — non-claude agents (pi, deepseek, kimi) don't expose model
356
+ # info in stream-json, leaving a "—" or "?" on the dashboard.
357
+ # FIX-121: map agent → its configured primary model so the column shows
358
+ # the actual model name (e.g. "deepseek-v4-pro") consistently with
359
+ # claude's "opus-4-7", not the bare agent name ("pi").
360
+ if model_label in ("—", "?") and cy.get("agent"):
361
+ model_label = _AGENT_PRIMARY_MODEL.get(cy["agent"], cy["agent"])
362
+ # Auto-hide model column on narrow screens — keeps the dashboard readable
363
+ # when terminal is < 100 cols (cost / story IDs are higher-priority).
364
+ show_model = COLS >= 100
365
+ model_seg = c("muted", pad(model_label, 11)) + " " if show_model else ""
366
+ # US-VIEW-011: PR landing marker after the story id(s).
367
+ # merged → "#NN ✓" green
368
+ # closed → "#NN ↩" amber (paired with ⊘ glyph above)
369
+ # open → "#NN …" dim (still landing; auto-merge or human pending)
370
+ pr_marker = ""
371
+ pr_num = cy.get("pr_num")
372
+ if pr_num is not None and pr_outcome:
373
+ mark_c, mark_sym = {
374
+ "merged": ("green", "✓"),
375
+ "closed": ("amber", "↩"),
376
+ "open": ("dim", "…"),
377
+ }.get(pr_outcome, ("dim", "…"))
378
+ pr_marker = " " + c(mark_c, f"#{pr_num} {mark_sym}")
379
+ # US-VIEW-014: pre-US-VIEW-014 events (no frozen cost_list_usd at
380
+ # cycle_end) get a muted [legacy] suffix — the number is recomputed on
381
+ # the fly and can shift with future price changes, unlike the frozen
382
+ # values written by current loop-fmt.
383
+ legacy_marker = " " + c("muted", "[legacy]") if cy.get("cost_list_legacy") else ""
384
+ inner = (
385
+ " " + c(glyph_c, glyph, bold=True) + " " +
386
+ c(time_c, pad(time_str, 5), bold=(outcome == "fail")) + " " +
387
+ c("muted", pad(dur, 4, "r")) + " " +
388
+ c("muted", pad(tok, 26)) + " " +
389
+ model_seg +
390
+ c("muted", pad(cost, 7, "r")) + " " +
391
+ c(sid_c, ids_str, bold=True) + pr_marker + legacy_marker
392
+ )
393
+ # Subtle red bg on failure rows so a fail can't be missed at a glance.
394
+ if outcome == "fail" and USE_COLOR:
395
+ # Every inner c(...) span ends with \033[0m which terminates the bg
396
+ # too. Re-paint the bg after every internal reset so the wash spans
397
+ # the whole row, not just the first colored cell. Then pad to full
398
+ # width so the bg extends edge-to-edge before the final reset.
399
+ line_pad = max(0, COLS - strw(inner))
400
+ inner_padded = inner + " " * line_pad
401
+ print(BG_FAIL + inner_padded.replace(RESET, RESET + BG_FAIL) + RESET)
402
+ # Always emit the drill hint for fails — fail_detail is often missing
403
+ # because not every fail path goes through the test / build stages.
404
+ hint = " " * 8 + c("dim", "→ ") + c("amber", f"roll loop show {cy['label']}")
405
+ if cy.get("fail_detail"):
406
+ hint += c("muted", " ") + c("dim", cy["fail_detail"])
407
+ hint_pad = max(0, COLS - strw(hint))
408
+ hint_padded = hint + " " * hint_pad
409
+ print(BG_FAIL + hint_padded.replace(RESET, RESET + BG_FAIL) + RESET)
410
+ else:
411
+ print(inner)
412
+ if outcome == "fail" and cy.get("fail_detail"):
413
+ # NO_COLOR path: drill hint still useful for diagnosis.
414
+ print(" " * 8 + "→ " + f"roll loop show {cy['label']}")
@@ -0,0 +1,123 @@
1
+ # Slide Components Library
2
+
3
+ Reusable Mustache partials for slide layouts. Each partial is a self-contained
4
+ HTML fragment consumed by `lib/slides-render.py`. Partials use CSS classes from
5
+ the introduction-v3 template and require no additional stylesheets.
6
+
7
+ ## Layout Reference
8
+
9
+ | Layout | Partial File | Use When | Avoid When |
10
+ |-------------|--------------------|--------------------------------------------|-------------------------------------|
11
+ | `plain` | `plain.html` | Free-form text, no structure needed | Data has compare / flow / timeline |
12
+ | `cards-2` | `cards-2.html` | 2 parallel concepts, side-by-side feature | 3+ items (use cards-3/cards-4) |
13
+ | `cards-3` | `cards-3.html` | 3 pillars, triple option, 3-step summary | 2 items (use cards-2) |
14
+ | `cards-4` | `cards-4.html` | 4 quadrants, pricing tiers, team roles | <4 items (too sparse) |
15
+ | `compare` | `compare.html` | Before/after, problem/solution, old/new | Unrelated items (use cards) |
16
+ | `pipeline` | `pipeline.html` | Sequential flow, CI/CD, process steps | Unordered items (use cards) |
17
+ | `timeline` | `timeline.html` | Chronological events, history, roadmap | Single event (use highlight) |
18
+ | `quote` | `quote.html` | Testimonial, key takeaway, memorable line | Multi-paragraph prose (use plain) |
19
+ | `highlight` | `highlight.html` | Callout, warning, important note | Normal body text (use plain) |
20
+
21
+ ## Field Tables
22
+
23
+ ### cards-2 / cards-3 / cards-4
24
+
25
+ | Variable | Required | Type | Description |
26
+ |-----------------|----------|--------|---------------------------------|
27
+ | `cards` | yes | array | Array of card objects |
28
+ | `cards[].title_en` | yes | string | Card title (English) |
29
+ | `cards[].title_zh` | yes | string | Card title (Chinese) |
30
+ | `cards[].body_en` | yes | string | Card body HTML (English, raw) |
31
+ | `cards[].body_zh` | yes | string | Card body HTML (Chinese, raw) |
32
+ | `accent_color` | no | string | Unused — reserved for future |
33
+
34
+ ### compare
35
+
36
+ | Variable | Required | Type | Description |
37
+ |----------------------|----------|--------|---------------------------------|
38
+ | `left_title_en` | yes | string | Left column heading (EN) |
39
+ | `left_title_zh` | yes | string | Left column heading (ZH) |
40
+ | `right_title_en` | yes | string | Right column heading (EN) |
41
+ | `right_title_zh` | yes | string | Right column heading (ZH) |
42
+ | `left_items` | yes | array | Left column items |
43
+ | `left_items[].text_en` | yes | string | Item text (EN) |
44
+ | `left_items[].text_zh` | yes | string | Item text (ZH) |
45
+ | `right_items` | yes | array | Right column items |
46
+ | `right_items[].text_en` | yes | string | Item text (EN) |
47
+ | `right_items[].text_zh` | yes | string | Item text (ZH) |
48
+
49
+ ### pipeline
50
+
51
+ | Variable | Required | Type | Description |
52
+ |---------------------|----------|--------|---------------------------------|
53
+ | `stages` | yes | array | Pipeline stages in order |
54
+ | `stages[].title_en` | yes | string | Stage title (EN) |
55
+ | `stages[].title_zh` | yes | string | Stage title (ZH) |
56
+ | `stages[].desc_en` | yes | string | Stage description (EN) |
57
+ | `stages[].desc_zh` | yes | string | Stage description (ZH) |
58
+ | `stages[].css_class` | yes | string | CSS class: `pipe-idea`, `pipe-backlog`, `pipe-build`, `pipe-verify`, or `pipe-release` |
59
+
60
+ ### timeline
61
+
62
+ | Variable | Required | Type | Description |
63
+ |---------------------|----------|--------|---------------------------------|
64
+ | `items` | yes | array | Timeline entries (chronological)|
65
+ | `items[].title_en` | yes | string | Entry title (EN) |
66
+ | `items[].title_zh` | yes | string | Entry title (ZH) |
67
+ | `items[].body_en` | yes | string | Entry body HTML (EN, raw) |
68
+ | `items[].body_zh` | yes | string | Entry body HTML (ZH, raw) |
69
+
70
+ ### quote
71
+
72
+ | Variable | Required | Type | Description |
73
+ |-----------|----------|--------|---------------------------------|
74
+ | `text_en` | yes | string | Quote text (EN) |
75
+ | `text_zh` | yes | string | Quote text (ZH) |
76
+
77
+ ### highlight
78
+
79
+ | Variable | Required | Type | Description |
80
+ |-----------|----------|--------|---------------------------------|
81
+ | `body_en` | yes | string | Body HTML (EN, raw) |
82
+ | `body_zh` | yes | string | Body HTML (ZH, raw) |
83
+
84
+ ### plain
85
+
86
+ | Variable | Required | Type | Description |
87
+ |-----------|----------|--------|---------------------------------|
88
+ | `body_en` | yes | string | Body HTML (EN, raw) |
89
+ | `body_zh` | yes | string | Body HTML (ZH, raw) |
90
+
91
+ ## CSS Classes
92
+
93
+ Every class name in these partials is copied verbatim from the introduction-v3
94
+ template (`lib/slides/templates/introduction-v3.html`). Do **not** introduce
95
+ new class names — the template's CSS is the single source of truth.
96
+
97
+ ## Usage
98
+
99
+ Partials are consumed by `lib/slides-render.py` when a `deck.md` slide declares
100
+ a `layout` field. A slide that omits `layout` renders as `plain`.
101
+
102
+ For the user-facing walkthrough — per-layout `deck.md` examples, rendered
103
+ screenshots, and how `$roll-deck` picks a layout — see the Layouts section of
104
+ the slides guide: [`guide/en/slides.md`](../../../guide/en/slides.md#layouts)
105
+ ([中文](../../../guide/zh/slides.md#layouts布局)). Field names in this file,
106
+ that guide, and `skills/roll-deck/SKILL.md` are kept in sync — change one,
107
+ change all three.
108
+
109
+ ```markdown
110
+ ### Slide 3: Architecture Overview
111
+
112
+ layout: cards-3
113
+ title_en: Three Layers
114
+ title_zh: 三层架构
115
+
116
+ body_en: |
117
+ 1. {{#cards}}...{{/cards}}
118
+ body_zh: |
119
+ 1. {{#cards}}...{{/cards}}
120
+ ```
121
+
122
+ The renderer inlines the partial's HTML into the template and resolves
123
+ Mustache variables and sections from the slide context.
@@ -0,0 +1,9 @@
1
+ <!-- cards-2: requires cards[].{title_en,title_zh,body_en,body_zh}, optional accent_color -->
2
+ <div class="cards cards-2">
3
+ {{#cards}}
4
+ <div class="card">
5
+ <h3><span class="lang-en">{{title_en}}</span><span class="lang-zh">{{title_zh}}</span></h3>
6
+ <p><span class="lang-en">{{{body_en}}}</span><span class="lang-zh">{{{body_zh}}}</span></p>
7
+ </div>
8
+ {{/cards}}
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- cards-3: requires cards[].{title_en,title_zh,body_en,body_zh}, optional accent_color -->
2
+ <div class="cards cards-3">
3
+ {{#cards}}
4
+ <div class="card">
5
+ <h3><span class="lang-en">{{title_en}}</span><span class="lang-zh">{{title_zh}}</span></h3>
6
+ <p><span class="lang-en">{{{body_en}}}</span><span class="lang-zh">{{{body_zh}}}</span></p>
7
+ </div>
8
+ {{/cards}}
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- cards-4: requires cards[].{title_en,title_zh,body_en,body_zh}, optional accent_color -->
2
+ <div class="cards cards-4">
3
+ {{#cards}}
4
+ <div class="card">
5
+ <h3><span class="lang-en">{{title_en}}</span><span class="lang-zh">{{title_zh}}</span></h3>
6
+ <p><span class="lang-en">{{{body_en}}}</span><span class="lang-zh">{{{body_zh}}}</span></p>
7
+ </div>
8
+ {{/cards}}
9
+ </div>
@@ -0,0 +1,22 @@
1
+ <!-- compare: requires left_title_en,left_title_zh,right_title_en,right_title_zh, left_items[].{text_en,text_zh}, right_items[].{text_en,text_zh} -->
2
+ <div class="compare">
3
+ <div class="compare-col compare-before">
4
+ <h3><span class="lang-en">{{left_title_en}}</span><span class="lang-zh">{{left_title_zh}}</span></h3>
5
+ {{#left_items}}
6
+ <div class="compare-item">
7
+ <span class="icon">✗</span>
8
+ <span class="lang-en">{{text_en}}</span><span class="lang-zh">{{text_zh}}</span>
9
+ </div>
10
+ {{/left_items}}
11
+ </div>
12
+ <div class="compare-arrow">→</div>
13
+ <div class="compare-col compare-after">
14
+ <h3><span class="lang-en">{{right_title_en}}</span><span class="lang-zh">{{right_title_zh}}</span></h3>
15
+ {{#right_items}}
16
+ <div class="compare-item">
17
+ <span class="icon">✓</span>
18
+ <span class="lang-en">{{text_en}}</span><span class="lang-zh">{{text_zh}}</span>
19
+ </div>
20
+ {{/right_items}}
21
+ </div>
22
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- highlight: requires body_en (raw HTML), body_zh (raw HTML) -->
2
+ <div class="highlight-box">
3
+ <div class="lang-en">
4
+ {{{body_en}}}
5
+ </div>
6
+ <div class="lang-zh">
7
+ {{{body_zh}}}
8
+ </div>
9
+ </div>
@@ -0,0 +1,12 @@
1
+ <!-- pipeline: requires stages[].{title_en,title_zh,desc_en,desc_zh,css_class} -->
2
+ <div class="pipeline-bar">
3
+ {{#stages}}
4
+ <div class="pipe-stage {{css_class}}">
5
+ <h4><span class="lang-en">{{title_en}}</span><span class="lang-zh">{{title_zh}}</span></h4>
6
+ <p><span class="lang-en">{{desc_en}}</span><span class="lang-zh">{{desc_zh}}</span></p>
7
+ </div>
8
+ {{^last}}
9
+ <div class="pipe-arrow">→</div>
10
+ {{/last}}
11
+ {{/stages}}
12
+ </div>
@@ -0,0 +1,7 @@
1
+ <!-- plain: requires body_en (raw HTML), body_zh (raw HTML) -->
2
+ <div class="lang-en">
3
+ {{{body_en}}}
4
+ </div>
5
+ <div class="lang-zh">
6
+ {{{body_zh}}}
7
+ </div>
@@ -0,0 +1,4 @@
1
+ <!-- quote: requires text_en, text_zh -->
2
+ <div class="quote-block">
3
+ <p><span class="lang-en">{{text_en}}</span><span class="lang-zh">{{text_zh}}</span></p>
4
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- timeline: requires items[].{title_en,title_zh,body_en,body_zh} -->
2
+ <div class="timeline">
3
+ {{#items}}
4
+ <div class="timeline-item">
5
+ <h4><span class="lang-en">{{title_en}}</span><span class="lang-zh">{{title_zh}}</span></h4>
6
+ <p><span class="lang-en">{{{body_en}}}</span><span class="lang-zh">{{{body_zh}}}</span></p>
7
+ </div>
8
+ {{/items}}
9
+ </div>