@seanyao/roll 2026.517.9 → 2026.518.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.
- package/CHANGELOG.md +34 -0
- package/bin/roll +99 -9
- package/lib/loop-fmt.py +51 -0
- package/lib/model_prices.py +64 -0
- package/lib/roll-loop-status.py +797 -0
- package/lib/roll_render.py +330 -0
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +1 -0
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
roll-loop-status — render the `roll loop` health dashboard.
|
|
4
|
+
|
|
5
|
+
Reads (all per-project, slug = <basename>-<md5_6chars> of project root):
|
|
6
|
+
$ROLL_SHARED_ROOT/loop/events-<slug>.ndjson structured per-cycle events
|
|
7
|
+
$ROLL_SHARED_ROOT/loop/cron-<slug>.log wall-clock dur + cost per cycle
|
|
8
|
+
$ROLL_SHARED_ROOT/loop/state-<slug>.yaml idle | running | paused
|
|
9
|
+
./BACKLOG.md story id → description
|
|
10
|
+
|
|
11
|
+
Writes (stdout):
|
|
12
|
+
Static 100-col colored print, EN/ZH paired rows. Designed for a 5-10s glance,
|
|
13
|
+
leaves the dashboard in scrollback. Honors NO_COLOR; degrades to 80 cols.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
python3 lib/roll-loop-status.py # default 3-day window
|
|
17
|
+
python3 lib/roll-loop-status.py --days 7
|
|
18
|
+
python3 lib/roll-loop-status.py --no-color
|
|
19
|
+
python3 lib/roll-loop-status.py --en | --zh # collapse bilingual rows
|
|
20
|
+
python3 lib/roll-loop-status.py --demo # render with fixture data
|
|
21
|
+
|
|
22
|
+
Wire it in bin/roll under `loop status` (replace _loop_status with a call to
|
|
23
|
+
this script).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
import argparse, hashlib, json, os, re, subprocess, sys, time
|
|
28
|
+
from collections import defaultdict
|
|
29
|
+
from datetime import datetime, timedelta, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
32
|
+
|
|
33
|
+
# Display TZ is fixed to Asia/Shanghai (UTC+8). Internal datetimes stay UTC;
|
|
34
|
+
# only display conversions honor this. Set the process TZ so .astimezone()
|
|
35
|
+
# without args resolves to Beijing time across all renderers.
|
|
36
|
+
os.environ.setdefault("TZ", "Asia/Shanghai")
|
|
37
|
+
time.tzset()
|
|
38
|
+
|
|
39
|
+
# Shared rendering primitives — see lib/roll_render.py for the design system.
|
|
40
|
+
_LIB_DIR = os.path.dirname(os.path.realpath(__file__))
|
|
41
|
+
if _LIB_DIR not in sys.path:
|
|
42
|
+
sys.path.insert(0, _LIB_DIR)
|
|
43
|
+
import roll_render
|
|
44
|
+
from roll_render import (
|
|
45
|
+
PAL, BOLD, RESET, COLS, c, strw, pad, row,
|
|
46
|
+
fmt_dur, fmt_delta, fmt_tokens, trunc, empty_rollup,
|
|
47
|
+
section_head, metric, metric_dur, metric_dollar, metric_tokens,
|
|
48
|
+
day_band, cycle_row,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
52
|
+
# Paths — must match bin/roll's _project_slug + _SHARED_ROOT defaults
|
|
53
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
54
|
+
def project_slug(path: Optional[str] = None) -> str:
|
|
55
|
+
path = os.path.realpath(path or os.getcwd())
|
|
56
|
+
try: # resolve git worktree → main tree (FIX-034 in bin/roll)
|
|
57
|
+
common = subprocess.check_output(
|
|
58
|
+
["git", "-C", path, "rev-parse", "--git-common-dir"],
|
|
59
|
+
stderr=subprocess.DEVNULL, text=True
|
|
60
|
+
).strip()
|
|
61
|
+
if common.endswith("/.git"):
|
|
62
|
+
path = common[:-5]
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
base = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(path)).strip("-")
|
|
66
|
+
h = hashlib.md5(path.encode()).hexdigest()[:6]
|
|
67
|
+
return f"{base}-{h}"
|
|
68
|
+
|
|
69
|
+
def shared_root() -> Path:
|
|
70
|
+
return Path(os.environ.get("ROLL_SHARED_ROOT") or os.path.expanduser("~/.shared/roll"))
|
|
71
|
+
|
|
72
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
73
|
+
# Loaders
|
|
74
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
75
|
+
def load_events(slug: str, days: int) -> List[Dict[str, Any]]:
|
|
76
|
+
path = shared_root() / "loop" / f"events-{slug}.ndjson"
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return []
|
|
79
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=days + 1) # +1 for grace
|
|
80
|
+
out: List[Dict[str, Any]] = []
|
|
81
|
+
with path.open() as f:
|
|
82
|
+
for line in f:
|
|
83
|
+
line = line.strip()
|
|
84
|
+
if not line:
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
e = json.loads(line)
|
|
88
|
+
e["_ts"] = datetime.fromisoformat(e["ts"].replace("Z", "+00:00"))
|
|
89
|
+
if e["_ts"] >= cutoff:
|
|
90
|
+
out.append(e)
|
|
91
|
+
except Exception:
|
|
92
|
+
continue
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
# cron.log entry format (from bin/roll):
|
|
96
|
+
# "03:49:25 cycle done — done · 981s · $4.53"
|
|
97
|
+
# "03:57:35 cycle done — done · 1 tcr · 538s · $3.20"
|
|
98
|
+
_CRON_PAT = re.compile(
|
|
99
|
+
r"^(\d{2}:\d{2}):(\d{2})\s+cycle done — (\w+)"
|
|
100
|
+
r"(?:\s*·\s*(\d+)\s+tcr)?"
|
|
101
|
+
r"\s*·\s*(\d+)s"
|
|
102
|
+
r"\s*·\s*\$([\d.]+)"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def load_cron_log(slug: str) -> List[Dict[str, Any]]:
|
|
106
|
+
"""Return ordered list of cron entries with local HH:MM:SS + extracted fields."""
|
|
107
|
+
path = shared_root() / "loop" / f"cron-{slug}.log"
|
|
108
|
+
if not path.exists():
|
|
109
|
+
return []
|
|
110
|
+
out: List[Dict[str, Any]] = []
|
|
111
|
+
with path.open(errors="ignore") as f:
|
|
112
|
+
for line in f:
|
|
113
|
+
# Bug D: cron.log lines are written with ANSI color escapes
|
|
114
|
+
# (\033[90m...\033[0m). Strip them before regex matching.
|
|
115
|
+
m = _CRON_PAT.match(roll_render.strip_ansi(line).strip())
|
|
116
|
+
if m:
|
|
117
|
+
out.append({
|
|
118
|
+
"hhmm": m.group(1),
|
|
119
|
+
"ss": int(m.group(2)),
|
|
120
|
+
"outcome": m.group(3),
|
|
121
|
+
"tcr": int(m.group(4) or 0),
|
|
122
|
+
"duration_s": int(m.group(5)),
|
|
123
|
+
"cost": float(m.group(6)),
|
|
124
|
+
})
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
def load_state(slug: str) -> Dict[str, str]:
|
|
128
|
+
"""Tiny YAML reader — only the flat keys bin/roll writes."""
|
|
129
|
+
path = shared_root() / "loop" / f"state-{slug}.yaml"
|
|
130
|
+
if not path.exists():
|
|
131
|
+
return {}
|
|
132
|
+
out: Dict[str, str] = {}
|
|
133
|
+
for line in path.open(errors="ignore"):
|
|
134
|
+
m = re.match(r"^([\w_]+):\s*(.*?)\s*$", line)
|
|
135
|
+
if m:
|
|
136
|
+
out[m.group(1)] = m.group(2).strip().strip('"').strip("'")
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
def load_backlog(project_root: Optional[Path] = None) -> Dict[str, str]:
|
|
140
|
+
"""Map story id → description from BACKLOG.md table rows."""
|
|
141
|
+
path = (project_root or Path()) / "BACKLOG.md"
|
|
142
|
+
if not path.exists():
|
|
143
|
+
return {}
|
|
144
|
+
out: Dict[str, str] = {}
|
|
145
|
+
pat = re.compile(r"^\|\s*(?:\[)?([A-Z]+-\d+)(?:\]\([^)]+\))?\s*\|\s*([^|]+?)\s*\|")
|
|
146
|
+
with path.open() as f:
|
|
147
|
+
for line in f:
|
|
148
|
+
m = pat.match(line)
|
|
149
|
+
if m:
|
|
150
|
+
out[m.group(1)] = m.group(2)
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
154
|
+
# Cycle aggregation — group events by cycle label; attach cron + story id
|
|
155
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
156
|
+
_STORY_ID_PAT = re.compile(r"\b([A-Z]+-\d+)\b")
|
|
157
|
+
|
|
158
|
+
def _extract_story_id(ev_detail: str) -> Optional[str]:
|
|
159
|
+
if not ev_detail:
|
|
160
|
+
return None
|
|
161
|
+
m = _STORY_ID_PAT.search(ev_detail)
|
|
162
|
+
return m.group(1) if m else None
|
|
163
|
+
|
|
164
|
+
def normalize_cycle_label(lbl: str) -> str:
|
|
165
|
+
"""Strip the 'loop/cycle-' branch-name prefix so pr events bucket with
|
|
166
|
+
their cycle_start/end siblings (Bug A — see plan §3)."""
|
|
167
|
+
if lbl.startswith("loop/cycle-"):
|
|
168
|
+
return lbl[len("loop/cycle-"):]
|
|
169
|
+
return lbl
|
|
170
|
+
|
|
171
|
+
def aggregate(events: List[Dict[str, Any]], cron: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
172
|
+
"""Build a per-cycle list (newest first), tmp-* filtered."""
|
|
173
|
+
by_label: Dict[str, Dict[str, Any]] = defaultdict(
|
|
174
|
+
lambda: {"start": None, "end": None, "outcome": None, "story": None,
|
|
175
|
+
"pr": None, "label": None, "fail_detail": None}
|
|
176
|
+
)
|
|
177
|
+
for e in events:
|
|
178
|
+
lbl = normalize_cycle_label(e.get("label", ""))
|
|
179
|
+
if not lbl or lbl.startswith("tmp-"):
|
|
180
|
+
continue
|
|
181
|
+
cy = by_label[lbl]
|
|
182
|
+
cy["label"] = lbl
|
|
183
|
+
stage = e.get("stage", "")
|
|
184
|
+
detail = e.get("detail", "")
|
|
185
|
+
if stage == "cycle_start":
|
|
186
|
+
cy["start"] = e["_ts"]
|
|
187
|
+
elif stage == "cycle_end":
|
|
188
|
+
cy["end"] = e["_ts"]
|
|
189
|
+
cy["outcome"] = e.get("outcome", "done")
|
|
190
|
+
elif stage == "idle":
|
|
191
|
+
# Bug B: cycles that find no Todo emit 'idle' instead of 'cycle_end'.
|
|
192
|
+
# Treat as terminal with a distinct outcome so they stop showing
|
|
193
|
+
# as 'still running' forever.
|
|
194
|
+
cy["end"] = e["_ts"]
|
|
195
|
+
cy["outcome"] = "idle"
|
|
196
|
+
elif stage == "pr":
|
|
197
|
+
cy["pr"] = detail
|
|
198
|
+
cy["pr_ts"] = e["_ts"] # used to match cron-log lines (inner cycle done)
|
|
199
|
+
sid = _extract_story_id(detail) or _extract_story_id(lbl)
|
|
200
|
+
if sid and not cy.get("story"):
|
|
201
|
+
cy["story"] = sid
|
|
202
|
+
elif stage == "pick_todo":
|
|
203
|
+
sid = _extract_story_id(detail)
|
|
204
|
+
if sid:
|
|
205
|
+
cy["story"] = sid
|
|
206
|
+
elif stage == "usage":
|
|
207
|
+
# US-LOOP-004: loop-fmt emits this with full token / cost data.
|
|
208
|
+
# Detail is a dict (not the legacy string form).
|
|
209
|
+
d = e.get("detail") or {}
|
|
210
|
+
if isinstance(d, dict):
|
|
211
|
+
cy["usage_event"] = d
|
|
212
|
+
elif stage in ("test", "build") and e.get("outcome") == "fail":
|
|
213
|
+
cy["fail_detail"] = detail or stage
|
|
214
|
+
|
|
215
|
+
# Drop incomplete entries; sort newest-first by start time.
|
|
216
|
+
cycles = [v for v in by_label.values() if v["start"]]
|
|
217
|
+
cycles.sort(key=lambda x: x["start"], reverse=True)
|
|
218
|
+
|
|
219
|
+
# Match cron-log entries by HH:MM:SS proximity to the inner cycle-done
|
|
220
|
+
# signal (within ±120s). cron.log is overwritten each cycle, so only the
|
|
221
|
+
# most recent cycle gets a cron entry — but it carries the only cost we
|
|
222
|
+
# have. duration_s falls back to (end - start) for every other cycle.
|
|
223
|
+
for cy in cycles:
|
|
224
|
+
anchor = cy.get("pr_ts") or cy.get("end") or cy.get("start")
|
|
225
|
+
target = anchor.hour * 3600 + anchor.minute * 60 + anchor.second
|
|
226
|
+
best = None
|
|
227
|
+
best_dt = 999
|
|
228
|
+
for cr in cron:
|
|
229
|
+
ch, cm = cr["hhmm"].split(":")
|
|
230
|
+
csec = int(ch) * 3600 + int(cm) * 60 + cr["ss"]
|
|
231
|
+
dt = abs(csec - target)
|
|
232
|
+
if dt < best_dt:
|
|
233
|
+
best_dt = dt
|
|
234
|
+
best = cr
|
|
235
|
+
if best and best_dt <= 120:
|
|
236
|
+
cy["cron"] = best
|
|
237
|
+
|
|
238
|
+
# Compute duration from event timestamps when cron didn't match.
|
|
239
|
+
if cy.get("end") and cy.get("start"):
|
|
240
|
+
cy["duration_s"] = int((cy["end"] - cy["start"]).total_seconds())
|
|
241
|
+
elif cy.get("cron"):
|
|
242
|
+
cy["duration_s"] = cy["cron"]["duration_s"]
|
|
243
|
+
|
|
244
|
+
# Default outcome if missing (e.g. cycle never ended → still running, or crashed).
|
|
245
|
+
if not cy.get("outcome"):
|
|
246
|
+
cy["outcome"] = "running" if not cy.get("end") else "unknown"
|
|
247
|
+
return cycles
|
|
248
|
+
|
|
249
|
+
def load_claude_session_usage(label: str, slug: str) -> Optional[Dict[str, Any]]:
|
|
250
|
+
"""Backfill from claude's own session log when events stream lacks
|
|
251
|
+
token / cost data. Each cycle runs in a worktree whose path Claude maps
|
|
252
|
+
to ~/.claude/projects/-<escaped-worktree-path>/<uuid>.jsonl. Sum tokens
|
|
253
|
+
across all assistant turns; pick model from any; pull total_cost_usd
|
|
254
|
+
from the trailing result event.
|
|
255
|
+
|
|
256
|
+
Returns {model, input_tokens, output_tokens, cache_creation_tokens,
|
|
257
|
+
cache_read_tokens, cost_reported_usd, duration_ms} or None."""
|
|
258
|
+
# Worktree path: /Users/seanyao/.shared/roll/worktrees/<slug>-cycle-<label>/
|
|
259
|
+
# Claude project dir mirrors that path with '/' → '-' + leading '-'.
|
|
260
|
+
worktree_path = f"/Users/{os.environ.get('USER', 'seanyao')}/.shared/roll/worktrees/{slug}-cycle-{label}"
|
|
261
|
+
# Claude escapes both '/' and '.' to '-' in the project dir name.
|
|
262
|
+
proj_name = "-" + worktree_path.replace("/", "-").replace(".", "-").lstrip("-")
|
|
263
|
+
proj_dir = Path.home() / ".claude" / "projects" / proj_name
|
|
264
|
+
if not proj_dir.exists():
|
|
265
|
+
return None
|
|
266
|
+
# Take the largest .jsonl in that dir (one cycle = one session).
|
|
267
|
+
jsonls = sorted(proj_dir.glob("*.jsonl"), key=lambda p: p.stat().st_size, reverse=True)
|
|
268
|
+
if not jsonls:
|
|
269
|
+
return None
|
|
270
|
+
path = jsonls[0]
|
|
271
|
+
|
|
272
|
+
sums = {"input_tokens": 0, "output_tokens": 0,
|
|
273
|
+
"cache_creation_tokens": 0, "cache_read_tokens": 0}
|
|
274
|
+
model = None
|
|
275
|
+
cost = None
|
|
276
|
+
duration_ms = None
|
|
277
|
+
with path.open(errors="ignore") as f:
|
|
278
|
+
for line in f:
|
|
279
|
+
try:
|
|
280
|
+
e = json.loads(line)
|
|
281
|
+
except Exception:
|
|
282
|
+
continue
|
|
283
|
+
# result event has total_cost_usd + duration_ms
|
|
284
|
+
if e.get("type") == "result":
|
|
285
|
+
cost = e.get("total_cost_usd") or cost
|
|
286
|
+
duration_ms = e.get("duration_ms") or duration_ms
|
|
287
|
+
continue
|
|
288
|
+
# assistant turns carry per-message usage
|
|
289
|
+
msg = e.get("message") or {}
|
|
290
|
+
usage = msg.get("usage") or {}
|
|
291
|
+
if not usage:
|
|
292
|
+
continue
|
|
293
|
+
if msg.get("model") and not model:
|
|
294
|
+
model = msg["model"]
|
|
295
|
+
sums["input_tokens"] += int(usage.get("input_tokens") or 0)
|
|
296
|
+
sums["output_tokens"] += int(usage.get("output_tokens") or 0)
|
|
297
|
+
sums["cache_creation_tokens"] += int(usage.get("cache_creation_input_tokens") or 0)
|
|
298
|
+
sums["cache_read_tokens"] += int(usage.get("cache_read_input_tokens") or 0)
|
|
299
|
+
if sums["input_tokens"] == 0 and sums["output_tokens"] == 0:
|
|
300
|
+
return None
|
|
301
|
+
return {"model": model, **sums,
|
|
302
|
+
"cost_reported_usd": cost, "duration_ms": duration_ms}
|
|
303
|
+
|
|
304
|
+
def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str) -> None:
|
|
305
|
+
"""Populate cy['tokens'], cy['cost_list'], cy['model']. Two paths:
|
|
306
|
+
1. usage_event from events stream (US-LOOP-004 writer side) — authoritative
|
|
307
|
+
2. claude session JSONL backfill — for cycles that ran before the
|
|
308
|
+
writer existed, or on machines where events.ndjson got truncated
|
|
309
|
+
"""
|
|
310
|
+
import importlib.util
|
|
311
|
+
spec = importlib.util.spec_from_file_location("model_prices",
|
|
312
|
+
os.path.join(_LIB_DIR, "model_prices.py"))
|
|
313
|
+
mp = importlib.util.module_from_spec(spec)
|
|
314
|
+
spec.loader.exec_module(mp)
|
|
315
|
+
for cy in cycles:
|
|
316
|
+
# Path 1: usage event written by loop-fmt at result time.
|
|
317
|
+
ue = cy.get("usage_event")
|
|
318
|
+
if isinstance(ue, dict) and (ue.get("input_tokens") or ue.get("output_tokens")):
|
|
319
|
+
cy["tokens"] = mp.total_tokens(
|
|
320
|
+
input_tokens=ue.get("input_tokens", 0),
|
|
321
|
+
output_tokens=ue.get("output_tokens", 0),
|
|
322
|
+
cache_creation_tokens=ue.get("cache_creation_tokens", 0),
|
|
323
|
+
cache_read_tokens=ue.get("cache_read_tokens", 0),
|
|
324
|
+
)
|
|
325
|
+
cy["model"] = ue.get("model")
|
|
326
|
+
cy["cost_list"] = mp.compute_list_cost(
|
|
327
|
+
ue.get("model"),
|
|
328
|
+
input_tokens=ue.get("input_tokens", 0),
|
|
329
|
+
output_tokens=ue.get("output_tokens", 0),
|
|
330
|
+
cache_creation_tokens=ue.get("cache_creation_tokens", 0),
|
|
331
|
+
cache_read_tokens=ue.get("cache_read_tokens", 0),
|
|
332
|
+
)
|
|
333
|
+
if ue.get("duration_ms") and not cy.get("duration_s"):
|
|
334
|
+
cy["duration_s"] = int(ue["duration_ms"] / 1000)
|
|
335
|
+
continue
|
|
336
|
+
# Path 2: salvage from claude's own session log.
|
|
337
|
+
if cy.get("tokens"):
|
|
338
|
+
continue
|
|
339
|
+
u = load_claude_session_usage(cy.get("label", ""), slug)
|
|
340
|
+
if not u:
|
|
341
|
+
continue
|
|
342
|
+
cy["tokens"] = mp.total_tokens(
|
|
343
|
+
input_tokens=u["input_tokens"],
|
|
344
|
+
output_tokens=u["output_tokens"],
|
|
345
|
+
cache_creation_tokens=u["cache_creation_tokens"],
|
|
346
|
+
cache_read_tokens=u["cache_read_tokens"],
|
|
347
|
+
)
|
|
348
|
+
cy["model"] = u["model"]
|
|
349
|
+
cy["cost_list"] = mp.compute_list_cost(
|
|
350
|
+
u["model"],
|
|
351
|
+
input_tokens=u["input_tokens"],
|
|
352
|
+
output_tokens=u["output_tokens"],
|
|
353
|
+
cache_creation_tokens=u["cache_creation_tokens"],
|
|
354
|
+
cache_read_tokens=u["cache_read_tokens"],
|
|
355
|
+
)
|
|
356
|
+
if u.get("duration_ms") and not cy.get("duration_s"):
|
|
357
|
+
cy["duration_s"] = int(u["duration_ms"] / 1000)
|
|
358
|
+
|
|
359
|
+
def load_pr_merges_from_git(days: int) -> Dict[str, Dict[str, Any]]:
|
|
360
|
+
"""Repair fallback: when events.ndjson dropped the pr / cycle_end events
|
|
361
|
+
for a cycle (events writer regressions), git log still has the merge
|
|
362
|
+
commit `Merge pull request #N from seanyao/loop/cycle-LABEL`. Extract
|
|
363
|
+
PR number + story IDs from the merge subject + body so orphan cycles
|
|
364
|
+
can be reclassified done instead of permanently '⏵ running'."""
|
|
365
|
+
try:
|
|
366
|
+
out = subprocess.check_output(
|
|
367
|
+
["git", "log", f"--since={days + 1} days ago",
|
|
368
|
+
"--grep=loop/cycle-", "--format=%H|||%s|||%b<<<END>>>"],
|
|
369
|
+
text=True, errors="ignore"
|
|
370
|
+
)
|
|
371
|
+
except Exception:
|
|
372
|
+
return {}
|
|
373
|
+
result: Dict[str, Dict[str, Any]] = {}
|
|
374
|
+
label_re = re.compile(r"loop/cycle-([A-Za-z0-9-]+)")
|
|
375
|
+
pr_re = re.compile(r"#(\d+)")
|
|
376
|
+
story_re = re.compile(r"\b([A-Z]+-\d+)\b")
|
|
377
|
+
for chunk in out.split("<<<END>>>"):
|
|
378
|
+
chunk = chunk.strip()
|
|
379
|
+
if not chunk:
|
|
380
|
+
continue
|
|
381
|
+
try:
|
|
382
|
+
_, subj, body = chunk.split("|||", 2)
|
|
383
|
+
except ValueError:
|
|
384
|
+
continue
|
|
385
|
+
text = f"{subj}\n{body}"
|
|
386
|
+
m = label_re.search(text)
|
|
387
|
+
if not m:
|
|
388
|
+
continue
|
|
389
|
+
label = m.group(1)
|
|
390
|
+
pr_m = pr_re.search(subj)
|
|
391
|
+
stories = []
|
|
392
|
+
for s in story_re.findall(text):
|
|
393
|
+
if s not in stories:
|
|
394
|
+
stories.append(s)
|
|
395
|
+
result[label] = {"pr": pr_m.group(1) if pr_m else None, "stories": stories}
|
|
396
|
+
return result
|
|
397
|
+
|
|
398
|
+
def repair_orphan_cycles_from_git(cycles: List[Dict[str, Any]], git_merges: Dict[str, Dict[str, Any]]) -> None:
|
|
399
|
+
"""Salvage data from git merges: for any cycle whose branch was merged,
|
|
400
|
+
promote 'running'/'unknown' outcomes to 'done' and back-fill the
|
|
401
|
+
built[] story list when events + runs.jsonl came up empty."""
|
|
402
|
+
for cy in cycles:
|
|
403
|
+
m = git_merges.get(cy.get("label", ""))
|
|
404
|
+
if not m:
|
|
405
|
+
continue
|
|
406
|
+
if cy.get("outcome") in ("running", "unknown"):
|
|
407
|
+
cy["outcome"] = "done"
|
|
408
|
+
if m["pr"] and not cy.get("pr"):
|
|
409
|
+
cy["pr"] = f"https://github.com/seanyao/Roll/pull/{m['pr']}"
|
|
410
|
+
# Fill stories when our existing sources didn't carry them. Filter
|
|
411
|
+
# to ones that actually appear in BACKLOG so we don't pull in stray
|
|
412
|
+
# tokens from the merge body (PR numbers, file paths, etc.).
|
|
413
|
+
if m["stories"] and not cy.get("built"):
|
|
414
|
+
cy["built"] = m["stories"]
|
|
415
|
+
cy["story"] = m["stories"][0]
|
|
416
|
+
|
|
417
|
+
def load_runs(slug: str) -> Dict[str, Dict[str, Any]]:
|
|
418
|
+
"""Map run_id → run row for the current project (filters out other slugs
|
|
419
|
+
sharing ~/.shared/roll/loop/runs.jsonl). Lenient slug matching salvages
|
|
420
|
+
entries written under buggy slugs (FIX-053): the bare project basename
|
|
421
|
+
(e.g. 'Roll') or worktree paths (e.g. '{slug}-cycle-XXX')."""
|
|
422
|
+
path = shared_root() / "loop" / "runs.jsonl"
|
|
423
|
+
if not path.exists():
|
|
424
|
+
return {}
|
|
425
|
+
base = slug.split("-")[0] # 'Roll-a43d1b' → 'Roll'
|
|
426
|
+
out: Dict[str, Dict[str, Any]] = {}
|
|
427
|
+
with path.open(errors="ignore") as f:
|
|
428
|
+
for line in f:
|
|
429
|
+
try:
|
|
430
|
+
r = json.loads(line)
|
|
431
|
+
except Exception:
|
|
432
|
+
continue
|
|
433
|
+
p = r.get("project", "")
|
|
434
|
+
if p != slug and p != base and not p.startswith(f"{slug}-cycle-"):
|
|
435
|
+
continue
|
|
436
|
+
rid = r.get("run_id", "")
|
|
437
|
+
if rid:
|
|
438
|
+
out[rid] = r
|
|
439
|
+
return out
|
|
440
|
+
|
|
441
|
+
def merge_runs_into_cycles(cycles: List[Dict[str, Any]], runs: Dict[str, Dict[str, Any]]) -> None:
|
|
442
|
+
"""Attach tcr_count + built stories from runs.jsonl onto matching cycles.
|
|
443
|
+
|
|
444
|
+
The runs.jsonl `run_id` field has inconsistent time format across writer
|
|
445
|
+
versions (sometimes UTC, sometimes Beijing local, sometimes with PID
|
|
446
|
+
suffix), so string matching is unreliable. Match by `ts` proximity
|
|
447
|
+
instead: each cycle gets the closest run whose ts is between this
|
|
448
|
+
cycle's start and the next-newer cycle's start (i.e. the run wrote out
|
|
449
|
+
before the next cycle began). Each run consumed exactly once."""
|
|
450
|
+
# Parse run timestamps once.
|
|
451
|
+
runs_list = []
|
|
452
|
+
for rid, r in runs.items():
|
|
453
|
+
try:
|
|
454
|
+
ts = datetime.fromisoformat(r["ts"].replace("Z", "+00:00"))
|
|
455
|
+
runs_list.append((ts, rid, r))
|
|
456
|
+
except Exception:
|
|
457
|
+
continue
|
|
458
|
+
runs_list.sort(key=lambda x: x[0])
|
|
459
|
+
consumed = set()
|
|
460
|
+
|
|
461
|
+
# Cycles arrive newest-first; pair each with the next-older to bound
|
|
462
|
+
# the matching window (so a cycle's run doesn't steal the next idle's).
|
|
463
|
+
for i, cy in enumerate(cycles):
|
|
464
|
+
start = cy["start"]
|
|
465
|
+
# next newer cycle in real time = the cycle just above us in list
|
|
466
|
+
next_start = cycles[i - 1]["start"] if i > 0 else start + timedelta(hours=2)
|
|
467
|
+
# If there's a cycle_end, also clamp to end + 30min as upper bound.
|
|
468
|
+
if cy.get("end"):
|
|
469
|
+
clamp = cy["end"] + timedelta(minutes=30)
|
|
470
|
+
window_end = min(next_start, clamp)
|
|
471
|
+
else:
|
|
472
|
+
window_end = next_start
|
|
473
|
+
best = None
|
|
474
|
+
for ts, rid, r in runs_list:
|
|
475
|
+
if rid in consumed:
|
|
476
|
+
continue
|
|
477
|
+
if ts < start:
|
|
478
|
+
continue
|
|
479
|
+
if ts >= window_end:
|
|
480
|
+
break
|
|
481
|
+
if best is None or ts < best[0]:
|
|
482
|
+
best = (ts, rid, r)
|
|
483
|
+
if not best:
|
|
484
|
+
continue
|
|
485
|
+
ts, rid, r = best
|
|
486
|
+
consumed.add(rid)
|
|
487
|
+
cy["tcr_count"] = r.get("tcr_count", 0)
|
|
488
|
+
cy["built"] = r.get("built", []) or []
|
|
489
|
+
# Duration: cap runs.jsonl's reported duration_sec by (runs_ts -
|
|
490
|
+
# cycle_start) since the field has been seen with garbage values.
|
|
491
|
+
if r.get("duration_sec"):
|
|
492
|
+
cap = int((ts - start).total_seconds())
|
|
493
|
+
cy["duration_s"] = min(r["duration_sec"], cap) if cap > 0 else r["duration_sec"]
|
|
494
|
+
# Outcome: runs.jsonl wins when events stream was vacuous.
|
|
495
|
+
if cy.get("outcome") in ("unknown", "running") and r.get("status"):
|
|
496
|
+
cy["outcome"] = {"built": "done", "interrupted": "fail"}.get(r["status"], r["status"])
|
|
497
|
+
if not cy.get("story") and r["built"]:
|
|
498
|
+
cy["story"] = r["built"][0]
|
|
499
|
+
|
|
500
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
501
|
+
# Rollup math — by day buckets in LOCAL time
|
|
502
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
503
|
+
def bucket_by_day(cycles: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
|
|
504
|
+
out: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
|
505
|
+
for cy in cycles:
|
|
506
|
+
day = cy["start"].astimezone().strftime("%Y-%m-%d")
|
|
507
|
+
out[day].append(cy)
|
|
508
|
+
return out
|
|
509
|
+
|
|
510
|
+
def rollup_for_day(day_cycles: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
511
|
+
r = {"cycles": len(day_cycles), "prs": 0, "failed": 0,
|
|
512
|
+
"duration_s": 0, "cost": 0.0, "tokens": 0}
|
|
513
|
+
for cy in day_cycles:
|
|
514
|
+
if cy.get("outcome") == "fail":
|
|
515
|
+
r["failed"] += 1
|
|
516
|
+
if cy.get("duration_s"):
|
|
517
|
+
r["duration_s"] += cy["duration_s"]
|
|
518
|
+
if cy.get("tokens"):
|
|
519
|
+
r["tokens"] += cy["tokens"]
|
|
520
|
+
if cy.get("pr") and cy["pr"].startswith("http"):
|
|
521
|
+
r["prs"] += 1
|
|
522
|
+
if cy.get("cost_list") is not None:
|
|
523
|
+
r["cost"] += cy["cost_list"]
|
|
524
|
+
elif cy.get("cron"):
|
|
525
|
+
# No claude session backfill available — fall back to whatever
|
|
526
|
+
# cron.log carries (best-effort, only the latest cycle).
|
|
527
|
+
r["cost"] += cy["cron"]["cost"]
|
|
528
|
+
return r
|
|
529
|
+
|
|
530
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
531
|
+
# Render
|
|
532
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
533
|
+
def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
|
|
534
|
+
runs=None, git_merges=None, claude_slug=None):
|
|
535
|
+
now = now or datetime.now(timezone.utc).astimezone()
|
|
536
|
+
cycles = aggregate(events, cron)
|
|
537
|
+
if runs:
|
|
538
|
+
merge_runs_into_cycles(cycles, runs)
|
|
539
|
+
if git_merges:
|
|
540
|
+
repair_orphan_cycles_from_git(cycles, git_merges)
|
|
541
|
+
if claude_slug:
|
|
542
|
+
backfill_usage_from_claude_sessions(cycles, claude_slug)
|
|
543
|
+
by_day = bucket_by_day(cycles)
|
|
544
|
+
days_keys = sorted(by_day.keys(), reverse=True)[:days]
|
|
545
|
+
|
|
546
|
+
def bilingual(en_line, zh_line):
|
|
547
|
+
"""Emit EN row then ZH row, honoring --en / --zh."""
|
|
548
|
+
if lang in ("both", "en"):
|
|
549
|
+
print(en_line)
|
|
550
|
+
if lang in ("both", "zh") and zh_line is not None:
|
|
551
|
+
print(zh_line)
|
|
552
|
+
|
|
553
|
+
# ── Title row ───────────────────────────────────────────────────────────
|
|
554
|
+
n_cycles = len(cycles)
|
|
555
|
+
title_l = c("fg", "roll loop", bold=True) + c("muted", " · ") + c("dim", "health")
|
|
556
|
+
title_r = c("dim", now.strftime("%Y-%m-%d %H:%M")) + c("muted", " · ") + c("muted", f"{n_cycles} cycles / {days*24}h")
|
|
557
|
+
print(row(title_l, title_r))
|
|
558
|
+
print()
|
|
559
|
+
|
|
560
|
+
# ── Status eyebrow ─────────────────────────────────────────────────────
|
|
561
|
+
status_word = (state.get("status") or "idle").lower()
|
|
562
|
+
if status_word == "running":
|
|
563
|
+
item = state.get("current_item") or "—"
|
|
564
|
+
eb_l = (c("purple", "⏵", bold=True) + " " +
|
|
565
|
+
c("purple", "RUNNING", bold=True) + c("muted", " ") +
|
|
566
|
+
c("dim", "story ") + c("blue", item, bold=True))
|
|
567
|
+
eb_zh = (c("dim", " 正在运行 · 当前 ") + c("blue", item))
|
|
568
|
+
elif status_word == "paused":
|
|
569
|
+
eb_l = (c("amber", "⏸ PAUSED", bold=True) + c("muted", " ") +
|
|
570
|
+
c("dim", "since ") + c("fg", state.get("paused_at", "—")) +
|
|
571
|
+
c("muted", " · ") + c("dim", state.get("paused_reason", "")))
|
|
572
|
+
eb_zh = c("dim", " 已暂停 · run: roll loop resume")
|
|
573
|
+
else:
|
|
574
|
+
eb_l = (c("blue", "● IDLE", bold=True) + c("muted", " ") +
|
|
575
|
+
c("dim", "next run ") + c("fg", _next_cron_hint(state), bold=True))
|
|
576
|
+
eb_zh = c("dim", f" 闲置 · 距下一轮 {_next_cron_hint(state, zh=True)}")
|
|
577
|
+
|
|
578
|
+
# 'last' = the most recent cycle the user can act on — skip cycles that
|
|
579
|
+
# are still running (the running banner already announces those) and skip
|
|
580
|
+
# idle cycles (they picked no story, so 'last · 23:48 —' carries no info).
|
|
581
|
+
last = next(
|
|
582
|
+
(cy for cy in cycles if cy.get("outcome") not in ("running", "idle")),
|
|
583
|
+
None,
|
|
584
|
+
) or (cycles[0] if cycles else None)
|
|
585
|
+
if last:
|
|
586
|
+
story = last.get("story") or "—"
|
|
587
|
+
title = backlog.get(story, "") if story != "—" else ""
|
|
588
|
+
glyph_c, glyph_ch = {
|
|
589
|
+
"done": ("green", "✓"),
|
|
590
|
+
"ok": ("green", "✓"),
|
|
591
|
+
"idle": ("muted", "·"),
|
|
592
|
+
"fail": ("red", "✗"),
|
|
593
|
+
"running": ("purple", "⏵"),
|
|
594
|
+
}.get(last["outcome"], ("muted", "·"))
|
|
595
|
+
glyph = c(glyph_c, glyph_ch, bold=True)
|
|
596
|
+
eb_r = (c("dim", "last ") + glyph + " " +
|
|
597
|
+
c("fg", last["start"].astimezone().strftime("%H:%M")) + " " +
|
|
598
|
+
c("blue", story, bold=True) + " " +
|
|
599
|
+
c("fg", trunc(title, 32)))
|
|
600
|
+
else:
|
|
601
|
+
eb_r = c("muted", "no cycles yet")
|
|
602
|
+
print(row(eb_l, eb_r))
|
|
603
|
+
if lang != "en" and last:
|
|
604
|
+
# ZH eyebrow row is left-aligned only — mirroring the EN right side
|
|
605
|
+
# would duplicate signal without adding info.
|
|
606
|
+
print(eb_zh)
|
|
607
|
+
print()
|
|
608
|
+
|
|
609
|
+
print(c("faint", "─" * COLS))
|
|
610
|
+
print()
|
|
611
|
+
|
|
612
|
+
# ── 3-day rollup ────────────────────────────────────────────────────────
|
|
613
|
+
section_head("ROLLUP", "近 " + str(days) + " 天", "↑ today vs yesterday · 今日 vs 昨日")
|
|
614
|
+
print()
|
|
615
|
+
|
|
616
|
+
# Bug C: today_key is derived from `now` (real today in local TZ), not
|
|
617
|
+
# from sorted(by_day)[0]. If today has 0 cycles, the Today column shows 0
|
|
618
|
+
# and yesterday's data stays under Yesterday — matching the day-band below.
|
|
619
|
+
today_key = now.strftime("%Y-%m-%d")
|
|
620
|
+
yest_key = (now - timedelta(days=1)).strftime("%Y-%m-%d")
|
|
621
|
+
d2_key = (now - timedelta(days=2)).strftime("%Y-%m-%d")
|
|
622
|
+
|
|
623
|
+
today = rollup_for_day(by_day.get(today_key, []))
|
|
624
|
+
yest = rollup_for_day(by_day.get(yest_key, []))
|
|
625
|
+
d2 = rollup_for_day(by_day.get(d2_key, []))
|
|
626
|
+
|
|
627
|
+
# 'partial' = today is still in progress — today's cycle count is under
|
|
628
|
+
# yesterday's, so a 'down −23' delta against yesterday's full-day count
|
|
629
|
+
# would otherwise read as a regression. Mute delta colors when partial;
|
|
630
|
+
# 'failed' stays loud because a fail is a real alert regardless.
|
|
631
|
+
is_partial = today["cycles"] < yest["cycles"]
|
|
632
|
+
|
|
633
|
+
# column headers — 'trend' hint removed (we don't emit a trend column).
|
|
634
|
+
# 'in progress' indicator stays on the day band + muted deltas, not the
|
|
635
|
+
# column header (cramming '(in progress)' into 18 chars collides with
|
|
636
|
+
# the Yesterday column).
|
|
637
|
+
# Today column spans 22 cols = value(8) + gap(2) + delta(12), matching
|
|
638
|
+
# the metric row geometry exactly so Yesterday and −2d line up under
|
|
639
|
+
# their data — fixes the "yesterday/−2d squished" misalignment.
|
|
640
|
+
hdr_en = (" " + c("muted", pad("", 14)) +
|
|
641
|
+
c("fg", pad("Today", 22), bold=True) +
|
|
642
|
+
c("dim", pad("Yesterday", 10)) +
|
|
643
|
+
c("muted", pad("−2d", 8)))
|
|
644
|
+
hdr_zh = (" " + c("muted", pad("", 14)) +
|
|
645
|
+
c("dim", pad("今日", 22)) +
|
|
646
|
+
c("muted", pad("昨日", 10)) +
|
|
647
|
+
c("muted", pad("前天", 8)))
|
|
648
|
+
bilingual(hdr_en, hdr_zh)
|
|
649
|
+
|
|
650
|
+
metric("cycles", today["cycles"], yest["cycles"], d2["cycles"], "up_good", partial=is_partial)
|
|
651
|
+
metric("merged PRs", today["prs"], yest["prs"], d2["prs"], "up_good", partial=is_partial)
|
|
652
|
+
# Failures stay loud — do NOT pass partial=True. A regression today is
|
|
653
|
+
# a real alert even when comparing to a full yesterday.
|
|
654
|
+
metric("failed", today["failed"], yest["failed"], d2["failed"], "up_bad",
|
|
655
|
+
yest_color="amber" if yest["failed"] > 0 else "dim",
|
|
656
|
+
yest_suffix="⚠" if yest["failed"] > 0 else "")
|
|
657
|
+
metric_dur("duration", today["duration_s"], yest["duration_s"], d2["duration_s"], partial=is_partial)
|
|
658
|
+
metric_tokens("tokens", today["tokens"], yest["tokens"], d2["tokens"], partial=is_partial)
|
|
659
|
+
metric_dollar("cost", today["cost"], yest["cost"], d2["cost"], partial=is_partial)
|
|
660
|
+
|
|
661
|
+
print()
|
|
662
|
+
print(c("faint", "─" * COLS))
|
|
663
|
+
print()
|
|
664
|
+
|
|
665
|
+
# ── Recent cycles ───────────────────────────────────────────────────────
|
|
666
|
+
section_head("RECENT", f"最近 {len(cycles)} 个 cycle",
|
|
667
|
+
"t · time Δ · duration tok · tokens $ · cost id · backlog")
|
|
668
|
+
print()
|
|
669
|
+
|
|
670
|
+
if not cycles:
|
|
671
|
+
print(" " + c("dim", "no cycles yet — first run fires on next cron tick"))
|
|
672
|
+
print(" " + c("dim", "尚无 cycle · 等待下一次 cron 触发"))
|
|
673
|
+
return
|
|
674
|
+
|
|
675
|
+
for day_key in days_keys:
|
|
676
|
+
day_cycles = by_day[day_key]
|
|
677
|
+
if not day_cycles:
|
|
678
|
+
continue
|
|
679
|
+
day_band(day_key, len(day_cycles),
|
|
680
|
+
sum(1 for c0 in day_cycles if c0["outcome"] == "fail"),
|
|
681
|
+
now,
|
|
682
|
+
in_progress=(day_key == today_key and is_partial))
|
|
683
|
+
for cy in day_cycles:
|
|
684
|
+
cycle_row(cy, backlog)
|
|
685
|
+
print()
|
|
686
|
+
|
|
687
|
+
print(c("faint", "─" * COLS))
|
|
688
|
+
print()
|
|
689
|
+
print(" " +
|
|
690
|
+
c("dim", "drill ") + c("blue", "roll loop show <cycle>") +
|
|
691
|
+
c("muted", " ") +
|
|
692
|
+
c("dim", "watch ") + c("blue", "roll loop --watch") +
|
|
693
|
+
c("muted", " ") +
|
|
694
|
+
c("dim", "more ") + c("blue", "roll loop --days 7"))
|
|
695
|
+
|
|
696
|
+
def _next_cron_hint(state: Dict[str, str], zh: bool = False) -> str:
|
|
697
|
+
"""Best-effort next-cron string. The real schedule lives in launchd/cron;
|
|
698
|
+
we only have access to last_run here, so we approximate to the next :48."""
|
|
699
|
+
now = datetime.now().astimezone()
|
|
700
|
+
minute_target = 48 # bin/roll default; per-project may differ
|
|
701
|
+
nxt = now.replace(minute=minute_target, second=0, microsecond=0)
|
|
702
|
+
if nxt <= now:
|
|
703
|
+
nxt += timedelta(hours=1)
|
|
704
|
+
delta = nxt - now
|
|
705
|
+
mins = int(delta.total_seconds() // 60)
|
|
706
|
+
secs = int(delta.total_seconds() % 60)
|
|
707
|
+
if zh:
|
|
708
|
+
return f"{mins} 分 {secs:02d} 秒"
|
|
709
|
+
return nxt.strftime("%H:%M") + f" · in {mins}m {secs:02d}s"
|
|
710
|
+
|
|
711
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
712
|
+
# Demo fixture — lets you preview the output without real data
|
|
713
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
714
|
+
def _demo_data():
|
|
715
|
+
now = datetime.now(timezone.utc)
|
|
716
|
+
events, cron = [], []
|
|
717
|
+
cycle_id = 0
|
|
718
|
+
for d in (2, 1, 0):
|
|
719
|
+
day = now - timedelta(days=d)
|
|
720
|
+
n_cycles = [3, 4, 5][2 - d]
|
|
721
|
+
for i in range(n_cycles):
|
|
722
|
+
hour = 0 + i * 5
|
|
723
|
+
start = day.replace(hour=hour, minute=48, second=0, microsecond=0)
|
|
724
|
+
end = start + timedelta(seconds=540 + i * 120)
|
|
725
|
+
label = start.strftime("%Y%m%d-%H%M%S-30585")
|
|
726
|
+
story = ["FIX-048", "US-112", "FIX-047", "REFACT-9", "FIX-040"][i % 5]
|
|
727
|
+
outcome = "fail" if (d == 1 and i == 2) else "done"
|
|
728
|
+
events.extend([
|
|
729
|
+
{"ts": start.isoformat().replace("+00:00", "Z"), "stage": "cycle_start",
|
|
730
|
+
"label": label, "detail": "", "outcome": "", "_ts": start},
|
|
731
|
+
{"ts": start.isoformat().replace("+00:00", "Z"), "stage": "pick_todo",
|
|
732
|
+
"label": label, "detail": f"{story} picked", "outcome": "ok",
|
|
733
|
+
"_ts": start + timedelta(seconds=2)},
|
|
734
|
+
{"ts": end.isoformat().replace("+00:00", "Z"), "stage": "cycle_end",
|
|
735
|
+
"label": label, "detail": "", "outcome": outcome, "_ts": end},
|
|
736
|
+
])
|
|
737
|
+
if outcome == "done":
|
|
738
|
+
events.append({"ts": end.isoformat().replace("+00:00", "Z"),
|
|
739
|
+
"stage": "pr", "label": label,
|
|
740
|
+
"detail": f"https://github.com/x/y/pull/{50 + cycle_id}",
|
|
741
|
+
"outcome": "ok", "_ts": end - timedelta(seconds=1)})
|
|
742
|
+
local = end.astimezone()
|
|
743
|
+
cron.append({"hhmm": local.strftime("%H:%M"), "ss": local.second,
|
|
744
|
+
"outcome": outcome, "tcr": 1 if outcome == "done" else 0,
|
|
745
|
+
"duration_s": int((end - start).total_seconds()),
|
|
746
|
+
"cost": 3.20 + i * 0.32})
|
|
747
|
+
cycle_id += 1
|
|
748
|
+
state = {"status": "idle", "last_run_outcome": "success"}
|
|
749
|
+
backlog = {
|
|
750
|
+
"FIX-048": "Dedupe Todo across cycles",
|
|
751
|
+
"US-112": "Loop run summary report",
|
|
752
|
+
"FIX-047": "Cycle log rotation by day",
|
|
753
|
+
"REFACT-9": "Extract stage runner module",
|
|
754
|
+
"FIX-040": "8/12 tests failed → bail",
|
|
755
|
+
}
|
|
756
|
+
return events, cron, state, backlog
|
|
757
|
+
|
|
758
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
759
|
+
# CLI
|
|
760
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
761
|
+
def main(argv=None):
|
|
762
|
+
p = argparse.ArgumentParser(description="roll loop status — health dashboard")
|
|
763
|
+
p.add_argument("--days", type=int, default=3, help="window in days (default 3)")
|
|
764
|
+
p.add_argument("--no-color", action="store_true", help="strip ANSI (also honors NO_COLOR=1)")
|
|
765
|
+
p.add_argument("--en", action="store_true", help="EN rows only")
|
|
766
|
+
p.add_argument("--zh", action="store_true", help="ZH rows only")
|
|
767
|
+
p.add_argument("--demo", action="store_true", help="render with fixture data")
|
|
768
|
+
args = p.parse_args(argv)
|
|
769
|
+
|
|
770
|
+
roll_render.USE_COLOR = (not args.no_color
|
|
771
|
+
and not os.environ.get("NO_COLOR")
|
|
772
|
+
and (sys.stdout.isatty() or os.environ.get("FORCE_COLOR")))
|
|
773
|
+
|
|
774
|
+
lang = "en" if args.en else ("zh" if args.zh else "both")
|
|
775
|
+
|
|
776
|
+
if args.demo:
|
|
777
|
+
events, cron, state, backlog = _demo_data()
|
|
778
|
+
runs = {}
|
|
779
|
+
git_merges = {}
|
|
780
|
+
else:
|
|
781
|
+
slug = project_slug()
|
|
782
|
+
events = load_events(slug, args.days)
|
|
783
|
+
cron = load_cron_log(slug)
|
|
784
|
+
state = load_state(slug)
|
|
785
|
+
backlog = load_backlog()
|
|
786
|
+
runs = load_runs(slug)
|
|
787
|
+
git_merges = load_pr_merges_from_git(args.days)
|
|
788
|
+
|
|
789
|
+
render(events, cron, state, backlog, days=args.days, lang=lang,
|
|
790
|
+
runs=runs, git_merges=git_merges,
|
|
791
|
+
claude_slug=None if args.demo else slug)
|
|
792
|
+
|
|
793
|
+
if __name__ == "__main__":
|
|
794
|
+
try:
|
|
795
|
+
main()
|
|
796
|
+
except BrokenPipeError:
|
|
797
|
+
pass # piped to `less` etc.
|