@seanyao/roll 3.608.1 → 3.610.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -0
- package/README.md +6 -5
- package/dist/roll.mjs +21533 -14095
- package/package.json +1 -2
- package/skills/roll-build/SKILL.md +22 -10
- package/skills/roll-design/SKILL.md +3 -2
- package/skills/roll-fix/SKILL.md +18 -7
- package/bin/roll +0 -15361
- package/lib/backfill-pi-usage.py +0 -243
- package/lib/changelog_audit.py +0 -149
- package/lib/changelog_generate.py +0 -470
- package/lib/consistency_check.py +0 -409
- package/lib/context_feed_budget.sh +0 -194
- package/lib/github_sync.py +0 -876
- package/lib/i18n.sh +0 -211
- package/lib/loop-exit-summary.py +0 -393
- package/lib/loop-fmt.py +0 -589
- package/lib/loop_pick_agent.py +0 -316
- package/lib/loop_result_eval.py +0 -469
- package/lib/loop_unstick.py +0 -180
- package/lib/model_prices.py +0 -194
- package/lib/prices_fetcher.py +0 -534
- package/lib/roll-backlog.py +0 -225
- package/lib/roll-brief.py +0 -286
- package/lib/roll-help.py +0 -158
- package/lib/roll-home.py +0 -556
- package/lib/roll-init.py +0 -156
- package/lib/roll-loop-status.py +0 -1691
- package/lib/roll-loop-story.py +0 -191
- package/lib/roll-peer.py +0 -252
- package/lib/roll-setup.py +0 -102
- package/lib/roll-status.py +0 -367
- package/lib/roll_git.py +0 -41
- package/lib/roll_render.py +0 -414
- package/lib/slides-render.py +0 -778
- package/lib/slides-validate.py +0 -357
- package/lib/test_quality_gate.py +0 -143
package/lib/roll_render.py
DELETED
|
@@ -1,414 +0,0 @@
|
|
|
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']}")
|