@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.
@@ -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.