@seanyao/roll 2026.524.2 → 2026.526.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 +38 -8
- package/README.md +4 -2
- package/bin/roll +1022 -442
- package/conventions/config.yaml +9 -0
- package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
- package/lib/i18n/agent.sh +21 -0
- package/lib/i18n/alert.sh +20 -0
- package/lib/i18n/backlog.sh +96 -0
- package/lib/i18n/brief.sh +5 -0
- package/lib/i18n/changelog.sh +3 -0
- package/lib/i18n/ci.sh +15 -0
- package/lib/i18n/init.sh +52 -0
- package/lib/i18n/lang.sh +10 -0
- package/lib/i18n/loop.sh +140 -0
- package/lib/i18n/migrate.sh +74 -0
- package/lib/i18n/offboard.sh +16 -0
- package/lib/i18n/peer.sh +34 -0
- package/lib/i18n/peer_help.sh +21 -0
- package/lib/i18n/peer_reset.sh +7 -0
- package/lib/i18n/peer_status.sh +5 -0
- package/lib/i18n/prices.sh +3 -0
- package/lib/i18n/prices_refresh.sh +17 -0
- package/lib/i18n/prices_show.sh +7 -0
- package/lib/i18n/setup.sh +3 -0
- package/lib/i18n/shared.sh +74 -0
- package/lib/i18n/skills/roll-brief.sh +27 -0
- package/lib/i18n/skills/roll-fix.sh +39 -0
- package/lib/i18n/skills/roll-onboard.sh +17 -0
- package/lib/i18n/slides.sh +3 -0
- package/lib/i18n/slides_build.sh +38 -0
- package/lib/i18n/slides_delete.sh +19 -0
- package/lib/i18n/slides_list.sh +14 -0
- package/lib/i18n/slides_logs.sh +12 -0
- package/lib/i18n/slides_new.sh +15 -0
- package/lib/i18n/slides_preview.sh +14 -0
- package/lib/i18n/slides_templates.sh +7 -0
- package/lib/i18n/status.sh +19 -0
- package/lib/i18n/update.sh +7 -0
- package/lib/i18n.sh +85 -4
- package/lib/roll-home.py +55 -0
- package/lib/roll-loop-status.py +196 -19
- package/lib/roll-loop-story.py +191 -0
- package/lib/roll_render.py +15 -1
- package/lib/slides/components/README.md +117 -0
- package/lib/slides/components/cards-2.html +9 -0
- package/lib/slides/components/cards-3.html +9 -0
- package/lib/slides/components/cards-4.html +9 -0
- package/lib/slides/components/compare.html +22 -0
- package/lib/slides/components/highlight.html +9 -0
- package/lib/slides/components/pipeline.html +12 -0
- package/lib/slides/components/plain.html +7 -0
- package/lib/slides/components/quote.html +4 -0
- package/lib/slides/components/timeline.html +9 -0
- package/lib/slides/templates/pitch.html +0 -0
- package/package.json +1 -1
- package/skills/roll-brief/SKILL.md +2 -2
- package/skills/roll-build/SKILL.md +40 -40
- package/skills/roll-fix/SKILL.md +22 -22
- package/skills/roll-loop/SKILL.md +26 -15
- package/skills/roll-onboard/SKILL.md +6 -6
package/lib/roll-loop-status.py
CHANGED
|
@@ -52,6 +52,11 @@ from roll_render import (
|
|
|
52
52
|
# Paths — must match bin/roll's _project_slug + _SHARED_ROOT defaults
|
|
53
53
|
# ════════════════════════════════════════════════════════════════════════════
|
|
54
54
|
def project_slug(path: Optional[str] = None) -> str:
|
|
55
|
+
# US-LOOP-006: cycle wrapper exports ROLL_MAIN_SLUG — honour it.
|
|
56
|
+
env_slug = os.environ.get("ROLL_MAIN_SLUG", "").strip()
|
|
57
|
+
if env_slug:
|
|
58
|
+
return env_slug
|
|
59
|
+
|
|
55
60
|
path = os.path.realpath(path or os.getcwd())
|
|
56
61
|
try: # resolve git worktree → main tree (FIX-034 in bin/roll)
|
|
57
62
|
common = subprocess.check_output(
|
|
@@ -62,10 +67,57 @@ def project_slug(path: Optional[str] = None) -> str:
|
|
|
62
67
|
path = common[:-5]
|
|
63
68
|
except Exception:
|
|
64
69
|
pass
|
|
70
|
+
|
|
71
|
+
# US-OBS-010: derive slug from git remote URL for stable cross-machine
|
|
72
|
+
# identity. Normalize: strip .git, git@HOST:PATH → https://HOST/PATH,
|
|
73
|
+
# lowercase. Fallback chain: origin → first remote → path-based.
|
|
74
|
+
remote_url = _git_remote_url(path)
|
|
75
|
+
if remote_url:
|
|
76
|
+
# Normalize
|
|
77
|
+
remote_url = remote_url.rstrip("/")
|
|
78
|
+
if remote_url.endswith(".git"):
|
|
79
|
+
remote_url = remote_url[:-4]
|
|
80
|
+
m = re.match(r"^git@([^:]+):(.+)$", remote_url)
|
|
81
|
+
if m:
|
|
82
|
+
remote_url = f"https://{m.group(1)}/{m.group(2)}"
|
|
83
|
+
remote_url = remote_url.lower()
|
|
84
|
+
base = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(remote_url)).strip("-")
|
|
85
|
+
h = hashlib.md5(remote_url.encode()).hexdigest()[:6]
|
|
86
|
+
return f"{base}-{h}"
|
|
87
|
+
|
|
65
88
|
base = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(path)).strip("-")
|
|
66
89
|
h = hashlib.md5(path.encode()).hexdigest()[:6]
|
|
67
90
|
return f"{base}-{h}"
|
|
68
91
|
|
|
92
|
+
|
|
93
|
+
def _git_remote_url(repo_path: str) -> Optional[str]:
|
|
94
|
+
"""Return the normalized remote URL for a git repo, or None."""
|
|
95
|
+
try:
|
|
96
|
+
url = subprocess.check_output(
|
|
97
|
+
["git", "-C", repo_path, "remote", "get-url", "origin"],
|
|
98
|
+
stderr=subprocess.DEVNULL, text=True
|
|
99
|
+
).strip()
|
|
100
|
+
if url:
|
|
101
|
+
return url
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
# Fallback: first available remote
|
|
105
|
+
try:
|
|
106
|
+
remotes = subprocess.check_output(
|
|
107
|
+
["git", "-C", repo_path, "remote"],
|
|
108
|
+
stderr=subprocess.DEVNULL, text=True
|
|
109
|
+
).strip().splitlines()
|
|
110
|
+
if remotes:
|
|
111
|
+
url = subprocess.check_output(
|
|
112
|
+
["git", "-C", repo_path, "remote", "get-url", remotes[0]],
|
|
113
|
+
stderr=subprocess.DEVNULL, text=True
|
|
114
|
+
).strip()
|
|
115
|
+
if url:
|
|
116
|
+
return url
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
return None
|
|
120
|
+
|
|
69
121
|
def shared_root() -> Path:
|
|
70
122
|
return Path(os.environ.get("ROLL_SHARED_ROOT") or os.path.expanduser("~/.shared/roll"))
|
|
71
123
|
|
|
@@ -73,23 +125,39 @@ def shared_root() -> Path:
|
|
|
73
125
|
# Loaders
|
|
74
126
|
# ════════════════════════════════════════════════════════════════════════════
|
|
75
127
|
def load_events(slug: str, days: int) -> List[Dict[str, Any]]:
|
|
76
|
-
|
|
77
|
-
|
|
128
|
+
# US-LOOP-023: read the head NDJSON plus its rotated siblings .1..4.
|
|
129
|
+
# bin/roll rotates events-<slug>.ndjson at 10MB keeping 4 archives; without
|
|
130
|
+
# this loop the dashboard silently dropped any cycle whose events landed in
|
|
131
|
+
# a rotated file (the "永久留存" promise of US-LOOP-004 only held on disk).
|
|
132
|
+
head = shared_root() / "loop" / f"events-{slug}.ndjson"
|
|
133
|
+
candidates = [head] + [head.with_suffix(f".ndjson.{i}") for i in range(1, 5)]
|
|
134
|
+
existing = [p for p in candidates if p.exists()]
|
|
135
|
+
if not existing:
|
|
78
136
|
return []
|
|
79
137
|
cutoff = datetime.now(timezone.utc) - timedelta(days=days + 1) # +1 for grace
|
|
80
138
|
out: List[Dict[str, Any]] = []
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
139
|
+
seen: set[str] = set() # dedup on the raw JSON line (rotation is mv, so
|
|
140
|
+
# duplicates only appear from manual ops — defensive)
|
|
141
|
+
for p in existing:
|
|
142
|
+
with p.open() as f:
|
|
143
|
+
for line in f:
|
|
144
|
+
line = line.strip()
|
|
145
|
+
if not line:
|
|
146
|
+
continue
|
|
147
|
+
if line in seen:
|
|
148
|
+
continue
|
|
149
|
+
seen.add(line)
|
|
150
|
+
try:
|
|
151
|
+
e = json.loads(line)
|
|
152
|
+
e["_ts"] = datetime.fromisoformat(e["ts"].replace("Z", "+00:00"))
|
|
153
|
+
if e["_ts"] >= cutoff:
|
|
154
|
+
out.append(e)
|
|
155
|
+
except Exception:
|
|
156
|
+
continue
|
|
157
|
+
out.sort(key=lambda e: e["_ts"])
|
|
158
|
+
if os.environ.get("ROLL_DEBUG_LOAD"):
|
|
159
|
+
print(f"roll-loop-status: loaded {len(out)} events from {len(existing)} files",
|
|
160
|
+
file=sys.stderr)
|
|
93
161
|
return out
|
|
94
162
|
|
|
95
163
|
# cron.log entry format (from bin/roll):
|
|
@@ -587,6 +655,57 @@ def bucket_by_day(cycles: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]
|
|
|
587
655
|
out[day].append(cy)
|
|
588
656
|
return out
|
|
589
657
|
|
|
658
|
+
def rollup_for_story(cycles: List[Dict[str, Any]], story_id: str) -> Dict[str, Any]:
|
|
659
|
+
"""US-LOOP-024: aggregate cycles belonging to a single story.
|
|
660
|
+
|
|
661
|
+
Case-insensitive match on cy["story"]. Sums duration / tokens / cost,
|
|
662
|
+
splits outcomes into ✓ (done|idle) / ✗ (fail) / ⏵ (running), collects
|
|
663
|
+
PR landings, captures the model from the first matching cycle.
|
|
664
|
+
"""
|
|
665
|
+
sid_lower = (story_id or "").lower()
|
|
666
|
+
matched = [cy for cy in cycles if (cy.get("story") or "").lower() == sid_lower]
|
|
667
|
+
r: Dict[str, Any] = {
|
|
668
|
+
"story_id": story_id,
|
|
669
|
+
"cycles": matched,
|
|
670
|
+
"count": len(matched),
|
|
671
|
+
"ok_count": 0, "fail_count": 0, "running_count": 0,
|
|
672
|
+
"span_start": None, "span_end": None,
|
|
673
|
+
"duration_s": 0, "cost": 0.0,
|
|
674
|
+
"input_tokens": 0, "output_tokens": 0,
|
|
675
|
+
"cache_creation_tokens": 0, "cache_read_tokens": 0,
|
|
676
|
+
"prs": [], "model": None,
|
|
677
|
+
}
|
|
678
|
+
for cy in matched:
|
|
679
|
+
outcome = cy.get("outcome") or ""
|
|
680
|
+
if outcome == "fail":
|
|
681
|
+
r["fail_count"] += 1
|
|
682
|
+
elif outcome == "running":
|
|
683
|
+
r["running_count"] += 1
|
|
684
|
+
else:
|
|
685
|
+
r["ok_count"] += 1
|
|
686
|
+
if cy.get("start"):
|
|
687
|
+
if r["span_start"] is None or cy["start"] < r["span_start"]:
|
|
688
|
+
r["span_start"] = cy["start"]
|
|
689
|
+
if cy.get("end"):
|
|
690
|
+
if r["span_end"] is None or cy["end"] > r["span_end"]:
|
|
691
|
+
r["span_end"] = cy["end"]
|
|
692
|
+
if cy.get("duration_s"):
|
|
693
|
+
r["duration_s"] += cy["duration_s"]
|
|
694
|
+
for tk in ("input_tokens", "output_tokens",
|
|
695
|
+
"cache_creation_tokens", "cache_read_tokens"):
|
|
696
|
+
if cy.get(tk):
|
|
697
|
+
r[tk] += cy[tk]
|
|
698
|
+
if cy.get("cost_list") is not None:
|
|
699
|
+
r["cost"] += cy["cost_list"]
|
|
700
|
+
elif cy.get("cron"):
|
|
701
|
+
r["cost"] += cy["cron"]["cost"]
|
|
702
|
+
if cy.get("pr_num"):
|
|
703
|
+
r["prs"].append({"num": cy["pr_num"],
|
|
704
|
+
"outcome": cy.get("pr_outcome") or "open"})
|
|
705
|
+
if cy.get("model") and not r["model"]:
|
|
706
|
+
r["model"] = cy["model"]
|
|
707
|
+
return r
|
|
708
|
+
|
|
590
709
|
def rollup_for_day(day_cycles: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
591
710
|
# US-VIEW-012: track input + output separately so the daily summary can
|
|
592
711
|
# show two metric rows. cache_read tokens deliberately excluded — they're
|
|
@@ -812,6 +931,56 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
|
|
|
812
931
|
c("muted", " ") +
|
|
813
932
|
c("dim", "more ") + c("blue", "roll loop status --days 7"))
|
|
814
933
|
|
|
934
|
+
# US-LOOP-013: valid schedule periods (must divide 60)
|
|
935
|
+
_VALID_SCHEDULE_PERIODS = {60, 30, 20, 15, 12, 10, 6, 5}
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _schedule_valid(period: int, offset: int) -> bool:
|
|
939
|
+
"""Validate schedule spec: period must divide 60, offset in [0, period)."""
|
|
940
|
+
return period in _VALID_SCHEDULE_PERIODS and 0 <= offset < period
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _read_schedule_spec(project_root: Optional[Path] = None) -> Tuple[int, int]:
|
|
944
|
+
"""US-LOOP-013: read loop schedule spec, mirroring bin/roll's _loop_schedule_spec.
|
|
945
|
+
|
|
946
|
+
Returns (period_minutes, offset_minute).
|
|
947
|
+
Priority: .roll/local.yaml → ~/.roll/config.yaml → default (60, hash-derived)
|
|
948
|
+
"""
|
|
949
|
+
project_root = (project_root or Path()).resolve()
|
|
950
|
+
|
|
951
|
+
# 1. Try project-level .roll/local.yaml
|
|
952
|
+
local_file = project_root / ".roll" / "local.yaml"
|
|
953
|
+
if local_file.exists():
|
|
954
|
+
try:
|
|
955
|
+
text = local_file.read_text(errors="ignore")
|
|
956
|
+
# Parse loop_schedule block: loop_schedule:\n period_minutes: N\n offset_minute: N
|
|
957
|
+
period_m = re.search(r'period_minutes:\s*(\d+)', text)
|
|
958
|
+
offset_m = re.search(r'offset_minute:\s*(\d+)', text)
|
|
959
|
+
if period_m and offset_m:
|
|
960
|
+
period = int(period_m.group(1))
|
|
961
|
+
offset = int(offset_m.group(1))
|
|
962
|
+
if _schedule_valid(period, offset):
|
|
963
|
+
return (period, offset)
|
|
964
|
+
except Exception:
|
|
965
|
+
pass
|
|
966
|
+
|
|
967
|
+
# 2. Try global ~/.roll/config.yaml loop_minute (backward compat)
|
|
968
|
+
config_file = Path(os.path.expanduser("~/.roll/config.yaml"))
|
|
969
|
+
if config_file.exists():
|
|
970
|
+
try:
|
|
971
|
+
text = config_file.read_text(errors="ignore")
|
|
972
|
+
m = re.search(r'^loop_minute:\s*(\d+)', text, re.MULTILINE)
|
|
973
|
+
if m:
|
|
974
|
+
offset = int(m.group(1))
|
|
975
|
+
return (60, offset)
|
|
976
|
+
except Exception:
|
|
977
|
+
pass
|
|
978
|
+
|
|
979
|
+
# 3. Default: derive offset from project path hash (matches bin/roll)
|
|
980
|
+
h = int(hashlib.md5(str(project_root).encode()).hexdigest()[:2], 16) % 60
|
|
981
|
+
return (60, h)
|
|
982
|
+
|
|
983
|
+
|
|
815
984
|
def _read_plist_loop_minute() -> int:
|
|
816
985
|
"""FIX-063: read actual loop Minute from launchd plist (truth source).
|
|
817
986
|
Falls back to 48 only when plist missing/unparseable.
|
|
@@ -864,12 +1033,20 @@ def _detect_install_state() -> str:
|
|
|
864
1033
|
|
|
865
1034
|
|
|
866
1035
|
def _next_cron_hint(state: Dict[str, str], zh: bool = False) -> str:
|
|
867
|
-
"""
|
|
1036
|
+
"""US-LOOP-013: compute next cron fire time from schedule spec.
|
|
1037
|
+
|
|
1038
|
+
Handles multi-trigger schedules (period < 60) by scanning forward
|
|
1039
|
+
from the current hour's offset minute.
|
|
1040
|
+
"""
|
|
868
1041
|
now = datetime.now().astimezone()
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1042
|
+
period, offset = _read_schedule_spec()
|
|
1043
|
+
|
|
1044
|
+
# Start at offset minute within the current hour, then advance
|
|
1045
|
+
# by 'period' minutes until we find a slot after 'now'.
|
|
1046
|
+
nxt = now.replace(minute=offset, second=0, microsecond=0)
|
|
1047
|
+
while nxt <= now:
|
|
1048
|
+
nxt += timedelta(minutes=period)
|
|
1049
|
+
|
|
873
1050
|
delta = nxt - now
|
|
874
1051
|
mins = int(delta.total_seconds() // 60)
|
|
875
1052
|
secs = int(delta.total_seconds() % 60)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
roll-loop-story — compact per-story rollup for `roll loop story <ID>`.
|
|
4
|
+
|
|
5
|
+
Loads the same event / cron / runs / git-merge sources as roll-loop-status,
|
|
6
|
+
filters cycles to one story id (case-insensitive), and renders a single
|
|
7
|
+
panel covering cycles count, span, duration, tokens, cost, model, PR list,
|
|
8
|
+
and recent cycle lines.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
roll loop story US-LOOP-004
|
|
12
|
+
roll loop story us-loop-004 # case-insensitive
|
|
13
|
+
roll loop story US-LOOP-004 --json # machine-readable
|
|
14
|
+
roll loop story US-LOOP-004 --days 30 # widen window (default 30)
|
|
15
|
+
|
|
16
|
+
Exit codes:
|
|
17
|
+
0 — at least one cycle found
|
|
18
|
+
2 — story id has no cycles in the event window
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
import argparse, importlib.util, json, os, sys
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List
|
|
25
|
+
|
|
26
|
+
_LIB_DIR = os.path.dirname(os.path.realpath(__file__))
|
|
27
|
+
|
|
28
|
+
# Reuse the loaders + aggregator from roll-loop-status.py. importlib because the
|
|
29
|
+
# filename has a hyphen and isn't import-safe.
|
|
30
|
+
_spec = importlib.util.spec_from_file_location(
|
|
31
|
+
"_rls", os.path.join(_LIB_DIR, "roll-loop-status.py")
|
|
32
|
+
)
|
|
33
|
+
rls = importlib.util.module_from_spec(_spec)
|
|
34
|
+
_spec.loader.exec_module(rls)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def collect_cycles(days: int) -> List[Dict[str, Any]]:
|
|
38
|
+
slug = rls.project_slug()
|
|
39
|
+
events = rls.load_events(slug, days)
|
|
40
|
+
cron = rls.load_cron_log(slug)
|
|
41
|
+
runs = rls.load_runs(slug)
|
|
42
|
+
git_merges = rls.load_pr_merges_from_git(days)
|
|
43
|
+
cycles = rls.aggregate(events, cron)
|
|
44
|
+
if runs:
|
|
45
|
+
rls.merge_runs_into_cycles(cycles, runs)
|
|
46
|
+
if git_merges:
|
|
47
|
+
rls.repair_orphan_cycles_from_git(cycles, git_merges)
|
|
48
|
+
rls.backfill_usage_from_claude_sessions(cycles, slug)
|
|
49
|
+
return cycles
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _outcome_glyph(o: str) -> str:
|
|
53
|
+
return {"fail": "✗", "running": "⏵", "idle": "·"}.get(o, "✓")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fmt_dt(dt: datetime) -> str:
|
|
57
|
+
return dt.astimezone().strftime("%Y-%m-%d %H:%M")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fmt_dur(s: int) -> str:
|
|
61
|
+
if not s:
|
|
62
|
+
return "—"
|
|
63
|
+
h, rem = divmod(s, 3600)
|
|
64
|
+
m, _ = divmod(rem, 60)
|
|
65
|
+
return f"{h}h {m:02d}m" if h else f"{m}m"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _fmt_tokens(n: int) -> str:
|
|
69
|
+
if not n:
|
|
70
|
+
return "0"
|
|
71
|
+
if n >= 1_000_000:
|
|
72
|
+
return f"{n/1_000_000:.1f}M"
|
|
73
|
+
if n >= 1_000:
|
|
74
|
+
return f"{n/1_000:.0f}k"
|
|
75
|
+
return str(n)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fmt_pr(p: Dict[str, Any]) -> str:
|
|
79
|
+
g = {"merged": "✓", "closed": "✗"}.get(p["outcome"], "⏵")
|
|
80
|
+
return f"#{p['num']} {g}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def render_panel(r: Dict[str, Any], description: str = "") -> str:
|
|
84
|
+
head = f"── {r['story_id']}"
|
|
85
|
+
if description:
|
|
86
|
+
head += f" · {description}"
|
|
87
|
+
head += " " + "─" * max(0, 78 - len(head))
|
|
88
|
+
|
|
89
|
+
cycles = r["cycles"]
|
|
90
|
+
span = "—"
|
|
91
|
+
if r.get("span_start") and r.get("span_end"):
|
|
92
|
+
span = f"{_fmt_dt(r['span_start'])} → {_fmt_dt(r['span_end'])}"
|
|
93
|
+
elif r.get("span_start"):
|
|
94
|
+
span = f"{_fmt_dt(r['span_start'])} → (running)"
|
|
95
|
+
|
|
96
|
+
counts = f" cycles {r['count']} (✓ {r['ok_count']} ✗ {r['fail_count']} ⏵ {r['running_count']})"
|
|
97
|
+
line_span = f" span {span}"
|
|
98
|
+
line_dur = (f" duration {_fmt_dur(r['duration_s'])}"
|
|
99
|
+
f" tokens in {_fmt_tokens(r['input_tokens'])}"
|
|
100
|
+
f" out {_fmt_tokens(r['output_tokens'])}"
|
|
101
|
+
f" cache w {_fmt_tokens(r['cache_creation_tokens'])}"
|
|
102
|
+
f" r {_fmt_tokens(r['cache_read_tokens'])}")
|
|
103
|
+
model = r.get("model") or "—"
|
|
104
|
+
line_cost = f" cost ${r['cost']:.2f} model {model}"
|
|
105
|
+
|
|
106
|
+
prs = r.get("prs") or []
|
|
107
|
+
line_prs = " PRs " + (" ".join(_fmt_pr(p) for p in prs[:8]) if prs else "—")
|
|
108
|
+
|
|
109
|
+
# Recent 3 cycles (oldest → newest of the matched set; mirror confirmed layout).
|
|
110
|
+
recent = sorted(cycles, key=lambda c: c.get("start") or datetime.min.replace(tzinfo=timezone.utc))[-3:]
|
|
111
|
+
recent_lines: List[str] = []
|
|
112
|
+
for i, cy in enumerate(recent):
|
|
113
|
+
label = cy.get("label", "—")
|
|
114
|
+
glyph = _outcome_glyph(cy.get("outcome", ""))
|
|
115
|
+
cost = cy.get("cost_list")
|
|
116
|
+
cost_s = f"${cost:.2f}" if cost is not None else "—"
|
|
117
|
+
prefix = " recent " if i == 0 else " "
|
|
118
|
+
recent_lines.append(f"{prefix} {label} {glyph} {cost_s}")
|
|
119
|
+
if not recent_lines:
|
|
120
|
+
recent_lines.append(" recent —")
|
|
121
|
+
|
|
122
|
+
return "\n".join([head, counts, line_span, line_dur, line_cost, line_prs] + recent_lines)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def to_json(r: Dict[str, Any]) -> str:
|
|
126
|
+
def conv(o: Any) -> Any:
|
|
127
|
+
if isinstance(o, datetime):
|
|
128
|
+
return o.astimezone(timezone.utc).isoformat()
|
|
129
|
+
raise TypeError(f"{type(o)} not serializable")
|
|
130
|
+
|
|
131
|
+
payload = {k: v for k, v in r.items() if k != "cycles"}
|
|
132
|
+
payload["cycles"] = [
|
|
133
|
+
{
|
|
134
|
+
"label": cy.get("label"),
|
|
135
|
+
"start": cy.get("start"),
|
|
136
|
+
"end": cy.get("end"),
|
|
137
|
+
"outcome": cy.get("outcome"),
|
|
138
|
+
"duration_s": cy.get("duration_s"),
|
|
139
|
+
"input_tokens": cy.get("input_tokens"),
|
|
140
|
+
"output_tokens": cy.get("output_tokens"),
|
|
141
|
+
"cache_creation_tokens": cy.get("cache_creation_tokens"),
|
|
142
|
+
"cache_read_tokens": cy.get("cache_read_tokens"),
|
|
143
|
+
"cost_list": cy.get("cost_list"),
|
|
144
|
+
"model": cy.get("model"),
|
|
145
|
+
"pr_num": cy.get("pr_num"),
|
|
146
|
+
"pr_outcome": cy.get("pr_outcome"),
|
|
147
|
+
}
|
|
148
|
+
for cy in r.get("cycles", [])
|
|
149
|
+
]
|
|
150
|
+
return json.dumps(payload, default=conv, indent=2)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _backlog_description(story_id: str) -> str:
|
|
154
|
+
bl = rls.load_backlog()
|
|
155
|
+
return bl.get(story_id.upper(), "")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main(argv=None) -> int:
|
|
159
|
+
p = argparse.ArgumentParser(
|
|
160
|
+
description="roll loop story — per-story cycle rollup")
|
|
161
|
+
p.add_argument("story_id", help="Story ID (case-insensitive, e.g. US-LOOP-004)")
|
|
162
|
+
p.add_argument("--days", type=int, default=30,
|
|
163
|
+
help="event window in days (default 30)")
|
|
164
|
+
p.add_argument("--json", action="store_true",
|
|
165
|
+
help="emit machine-readable JSON instead of the panel")
|
|
166
|
+
args = p.parse_args(argv)
|
|
167
|
+
|
|
168
|
+
cycles = collect_cycles(args.days)
|
|
169
|
+
r = rls.rollup_for_story(cycles, args.story_id)
|
|
170
|
+
|
|
171
|
+
if args.json:
|
|
172
|
+
print(to_json(r))
|
|
173
|
+
return 0 if r["count"] > 0 else 2
|
|
174
|
+
|
|
175
|
+
if r["count"] == 0:
|
|
176
|
+
sys.stderr.write(
|
|
177
|
+
f"roll loop story: no cycles found for {args.story_id} "
|
|
178
|
+
f"in the last {args.days} days\n"
|
|
179
|
+
f"未找到 {args.story_id} 在最近 {args.days} 天内的循环\n"
|
|
180
|
+
)
|
|
181
|
+
return 2
|
|
182
|
+
|
|
183
|
+
print(render_panel(r, _backlog_description(args.story_id.upper())))
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
try:
|
|
189
|
+
sys.exit(main())
|
|
190
|
+
except BrokenPipeError:
|
|
191
|
+
pass
|
package/lib/roll_render.py
CHANGED
|
@@ -88,6 +88,17 @@ def fmt_dur(s: int) -> str:
|
|
|
88
88
|
return f"{s // 60}m"
|
|
89
89
|
return f"{s // 3600}h {(s % 3600) // 60}m"
|
|
90
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
|
+
|
|
91
102
|
def fmt_model(model) -> str:
|
|
92
103
|
"""Short label for the cycle row's model column.
|
|
93
104
|
|
|
@@ -339,8 +350,11 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
|
|
|
339
350
|
# FIX-119: fall back to cy["agent"] (from agent_used event) when model
|
|
340
351
|
# is unknown — non-claude agents (pi, deepseek, kimi) don't expose model
|
|
341
352
|
# info in stream-json, leaving a "—" or "?" on the dashboard.
|
|
353
|
+
# FIX-121: map agent → its configured primary model so the column shows
|
|
354
|
+
# the actual model name (e.g. "deepseek-v4-pro") consistently with
|
|
355
|
+
# claude's "opus-4-7", not the bare agent name ("pi").
|
|
342
356
|
if model_label in ("—", "?") and cy.get("agent"):
|
|
343
|
-
model_label = cy["agent"]
|
|
357
|
+
model_label = _AGENT_PRIMARY_MODEL.get(cy["agent"], cy["agent"])
|
|
344
358
|
# Auto-hide model column on narrow screens — keeps the dashboard readable
|
|
345
359
|
# when terminal is < 100 cols (cost / story IDs are higher-priority).
|
|
346
360
|
show_model = COLS >= 100
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Slide Components Library
|
|
2
|
+
|
|
3
|
+
Reusable Mustache partials for slide layouts. Each partial is a self-contained
|
|
4
|
+
HTML fragment consumed by `lib/slides-render.py`. Partials use CSS classes from
|
|
5
|
+
the introduction-v3 template and require no additional stylesheets.
|
|
6
|
+
|
|
7
|
+
## Layout Reference
|
|
8
|
+
|
|
9
|
+
| Layout | Partial File | Use When | Avoid When |
|
|
10
|
+
|-------------|--------------------|--------------------------------------------|-------------------------------------|
|
|
11
|
+
| `plain` | `plain.html` | Free-form text, no structure needed | Data has compare / flow / timeline |
|
|
12
|
+
| `cards-2` | `cards-2.html` | 2 parallel concepts, side-by-side feature | 3+ items (use cards-3/cards-4) |
|
|
13
|
+
| `cards-3` | `cards-3.html` | 3 pillars, triple option, 3-step summary | 2 items (use cards-2) |
|
|
14
|
+
| `cards-4` | `cards-4.html` | 4 quadrants, pricing tiers, team roles | <4 items (too sparse) |
|
|
15
|
+
| `compare` | `compare.html` | Before/after, problem/solution, old/new | Unrelated items (use cards) |
|
|
16
|
+
| `pipeline` | `pipeline.html` | Sequential flow, CI/CD, process steps | Unordered items (use cards) |
|
|
17
|
+
| `timeline` | `timeline.html` | Chronological events, history, roadmap | Single event (use highlight) |
|
|
18
|
+
| `quote` | `quote.html` | Testimonial, key takeaway, memorable line | Multi-paragraph prose (use plain) |
|
|
19
|
+
| `highlight` | `highlight.html` | Callout, warning, important note | Normal body text (use plain) |
|
|
20
|
+
|
|
21
|
+
## Field Tables
|
|
22
|
+
|
|
23
|
+
### cards-2 / cards-3 / cards-4
|
|
24
|
+
|
|
25
|
+
| Variable | Required | Type | Description |
|
|
26
|
+
|-----------------|----------|--------|---------------------------------|
|
|
27
|
+
| `cards` | yes | array | Array of card objects |
|
|
28
|
+
| `cards[].title_en` | yes | string | Card title (English) |
|
|
29
|
+
| `cards[].title_zh` | yes | string | Card title (Chinese) |
|
|
30
|
+
| `cards[].body_en` | yes | string | Card body HTML (English, raw) |
|
|
31
|
+
| `cards[].body_zh` | yes | string | Card body HTML (Chinese, raw) |
|
|
32
|
+
| `accent_color` | no | string | Unused — reserved for future |
|
|
33
|
+
|
|
34
|
+
### compare
|
|
35
|
+
|
|
36
|
+
| Variable | Required | Type | Description |
|
|
37
|
+
|----------------------|----------|--------|---------------------------------|
|
|
38
|
+
| `left_title_en` | yes | string | Left column heading (EN) |
|
|
39
|
+
| `left_title_zh` | yes | string | Left column heading (ZH) |
|
|
40
|
+
| `right_title_en` | yes | string | Right column heading (EN) |
|
|
41
|
+
| `right_title_zh` | yes | string | Right column heading (ZH) |
|
|
42
|
+
| `left_items` | yes | array | Left column items |
|
|
43
|
+
| `left_items[].text_en` | yes | string | Item text (EN) |
|
|
44
|
+
| `left_items[].text_zh` | yes | string | Item text (ZH) |
|
|
45
|
+
| `right_items` | yes | array | Right column items |
|
|
46
|
+
| `right_items[].text_en` | yes | string | Item text (EN) |
|
|
47
|
+
| `right_items[].text_zh` | yes | string | Item text (ZH) |
|
|
48
|
+
|
|
49
|
+
### pipeline
|
|
50
|
+
|
|
51
|
+
| Variable | Required | Type | Description |
|
|
52
|
+
|---------------------|----------|--------|---------------------------------|
|
|
53
|
+
| `stages` | yes | array | Pipeline stages in order |
|
|
54
|
+
| `stages[].title_en` | yes | string | Stage title (EN) |
|
|
55
|
+
| `stages[].title_zh` | yes | string | Stage title (ZH) |
|
|
56
|
+
| `stages[].desc_en` | yes | string | Stage description (EN) |
|
|
57
|
+
| `stages[].desc_zh` | yes | string | Stage description (ZH) |
|
|
58
|
+
| `stages[].css_class` | yes | string | CSS class: `pipe-idea`, `pipe-backlog`, `pipe-build`, `pipe-verify`, or `pipe-release` |
|
|
59
|
+
|
|
60
|
+
### timeline
|
|
61
|
+
|
|
62
|
+
| Variable | Required | Type | Description |
|
|
63
|
+
|---------------------|----------|--------|---------------------------------|
|
|
64
|
+
| `items` | yes | array | Timeline entries (chronological)|
|
|
65
|
+
| `items[].title_en` | yes | string | Entry title (EN) |
|
|
66
|
+
| `items[].title_zh` | yes | string | Entry title (ZH) |
|
|
67
|
+
| `items[].body_en` | yes | string | Entry body HTML (EN, raw) |
|
|
68
|
+
| `items[].body_zh` | yes | string | Entry body HTML (ZH, raw) |
|
|
69
|
+
|
|
70
|
+
### quote
|
|
71
|
+
|
|
72
|
+
| Variable | Required | Type | Description |
|
|
73
|
+
|-----------|----------|--------|---------------------------------|
|
|
74
|
+
| `text_en` | yes | string | Quote text (EN) |
|
|
75
|
+
| `text_zh` | yes | string | Quote text (ZH) |
|
|
76
|
+
|
|
77
|
+
### highlight
|
|
78
|
+
|
|
79
|
+
| Variable | Required | Type | Description |
|
|
80
|
+
|-----------|----------|--------|---------------------------------|
|
|
81
|
+
| `body_en` | yes | string | Body HTML (EN, raw) |
|
|
82
|
+
| `body_zh` | yes | string | Body HTML (ZH, raw) |
|
|
83
|
+
|
|
84
|
+
### plain
|
|
85
|
+
|
|
86
|
+
| Variable | Required | Type | Description |
|
|
87
|
+
|-----------|----------|--------|---------------------------------|
|
|
88
|
+
| `body_en` | yes | string | Body HTML (EN, raw) |
|
|
89
|
+
| `body_zh` | yes | string | Body HTML (ZH, raw) |
|
|
90
|
+
|
|
91
|
+
## CSS Classes
|
|
92
|
+
|
|
93
|
+
Every class name in these partials is copied verbatim from the introduction-v3
|
|
94
|
+
template (`lib/slides/templates/introduction-v3.html`). Do **not** introduce
|
|
95
|
+
new class names — the template's CSS is the single source of truth.
|
|
96
|
+
|
|
97
|
+
## Usage
|
|
98
|
+
|
|
99
|
+
Partials are consumed by `lib/slides-render.py` when a `deck.md` slide declares
|
|
100
|
+
a `layout` field (US-DECK-017). Until then, all partials render the same
|
|
101
|
+
`plain` layout by default.
|
|
102
|
+
|
|
103
|
+
```markdown
|
|
104
|
+
### Slide 3: Architecture Overview
|
|
105
|
+
|
|
106
|
+
layout: cards-3
|
|
107
|
+
title_en: Three Layers
|
|
108
|
+
title_zh: 三层架构
|
|
109
|
+
|
|
110
|
+
body_en: |
|
|
111
|
+
1. {{#cards}}...{{/cards}}
|
|
112
|
+
body_zh: |
|
|
113
|
+
1. {{#cards}}...{{/cards}}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The renderer inlines the partial's HTML into the template and resolves
|
|
117
|
+
Mustache variables and sections from the slide context.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- cards-2: requires cards[].{title_en,title_zh,body_en,body_zh}, optional accent_color -->
|
|
2
|
+
<div class="cards cards-2">
|
|
3
|
+
{{#cards}}
|
|
4
|
+
<div class="card">
|
|
5
|
+
<h3><span class="lang-en">{{title_en}}</span><span class="lang-zh">{{title_zh}}</span></h3>
|
|
6
|
+
<p><span class="lang-en">{{{body_en}}}</span><span class="lang-zh">{{{body_zh}}}</span></p>
|
|
7
|
+
</div>
|
|
8
|
+
{{/cards}}
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- cards-3: requires cards[].{title_en,title_zh,body_en,body_zh}, optional accent_color -->
|
|
2
|
+
<div class="cards cards-3">
|
|
3
|
+
{{#cards}}
|
|
4
|
+
<div class="card">
|
|
5
|
+
<h3><span class="lang-en">{{title_en}}</span><span class="lang-zh">{{title_zh}}</span></h3>
|
|
6
|
+
<p><span class="lang-en">{{{body_en}}}</span><span class="lang-zh">{{{body_zh}}}</span></p>
|
|
7
|
+
</div>
|
|
8
|
+
{{/cards}}
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- cards-4: requires cards[].{title_en,title_zh,body_en,body_zh}, optional accent_color -->
|
|
2
|
+
<div class="cards cards-4">
|
|
3
|
+
{{#cards}}
|
|
4
|
+
<div class="card">
|
|
5
|
+
<h3><span class="lang-en">{{title_en}}</span><span class="lang-zh">{{title_zh}}</span></h3>
|
|
6
|
+
<p><span class="lang-en">{{{body_en}}}</span><span class="lang-zh">{{{body_zh}}}</span></p>
|
|
7
|
+
</div>
|
|
8
|
+
{{/cards}}
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!-- compare: requires left_title_en,left_title_zh,right_title_en,right_title_zh, left_items[].{text_en,text_zh}, right_items[].{text_en,text_zh} -->
|
|
2
|
+
<div class="compare">
|
|
3
|
+
<div class="compare-col compare-before">
|
|
4
|
+
<h3><span class="lang-en">{{left_title_en}}</span><span class="lang-zh">{{left_title_zh}}</span></h3>
|
|
5
|
+
{{#left_items}}
|
|
6
|
+
<div class="compare-item">
|
|
7
|
+
<span class="icon">✗</span>
|
|
8
|
+
<span class="lang-en">{{text_en}}</span><span class="lang-zh">{{text_zh}}</span>
|
|
9
|
+
</div>
|
|
10
|
+
{{/left_items}}
|
|
11
|
+
</div>
|
|
12
|
+
<div class="compare-arrow">→</div>
|
|
13
|
+
<div class="compare-col compare-after">
|
|
14
|
+
<h3><span class="lang-en">{{right_title_en}}</span><span class="lang-zh">{{right_title_zh}}</span></h3>
|
|
15
|
+
{{#right_items}}
|
|
16
|
+
<div class="compare-item">
|
|
17
|
+
<span class="icon">✓</span>
|
|
18
|
+
<span class="lang-en">{{text_en}}</span><span class="lang-zh">{{text_zh}}</span>
|
|
19
|
+
</div>
|
|
20
|
+
{{/right_items}}
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|