@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +38 -8
  2. package/README.md +4 -2
  3. package/bin/roll +1022 -442
  4. package/conventions/config.yaml +9 -0
  5. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  6. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  7. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  8. package/lib/i18n/agent.sh +21 -0
  9. package/lib/i18n/alert.sh +20 -0
  10. package/lib/i18n/backlog.sh +96 -0
  11. package/lib/i18n/brief.sh +5 -0
  12. package/lib/i18n/changelog.sh +3 -0
  13. package/lib/i18n/ci.sh +15 -0
  14. package/lib/i18n/init.sh +52 -0
  15. package/lib/i18n/lang.sh +10 -0
  16. package/lib/i18n/loop.sh +140 -0
  17. package/lib/i18n/migrate.sh +74 -0
  18. package/lib/i18n/offboard.sh +16 -0
  19. package/lib/i18n/peer.sh +34 -0
  20. package/lib/i18n/peer_help.sh +21 -0
  21. package/lib/i18n/peer_reset.sh +7 -0
  22. package/lib/i18n/peer_status.sh +5 -0
  23. package/lib/i18n/prices.sh +3 -0
  24. package/lib/i18n/prices_refresh.sh +17 -0
  25. package/lib/i18n/prices_show.sh +7 -0
  26. package/lib/i18n/setup.sh +3 -0
  27. package/lib/i18n/shared.sh +74 -0
  28. package/lib/i18n/skills/roll-brief.sh +27 -0
  29. package/lib/i18n/skills/roll-fix.sh +39 -0
  30. package/lib/i18n/skills/roll-onboard.sh +17 -0
  31. package/lib/i18n/slides.sh +3 -0
  32. package/lib/i18n/slides_build.sh +38 -0
  33. package/lib/i18n/slides_delete.sh +19 -0
  34. package/lib/i18n/slides_list.sh +14 -0
  35. package/lib/i18n/slides_logs.sh +12 -0
  36. package/lib/i18n/slides_new.sh +15 -0
  37. package/lib/i18n/slides_preview.sh +14 -0
  38. package/lib/i18n/slides_templates.sh +7 -0
  39. package/lib/i18n/status.sh +19 -0
  40. package/lib/i18n/update.sh +7 -0
  41. package/lib/i18n.sh +85 -4
  42. package/lib/roll-home.py +55 -0
  43. package/lib/roll-loop-status.py +196 -19
  44. package/lib/roll-loop-story.py +191 -0
  45. package/lib/roll_render.py +15 -1
  46. package/lib/slides/components/README.md +117 -0
  47. package/lib/slides/components/cards-2.html +9 -0
  48. package/lib/slides/components/cards-3.html +9 -0
  49. package/lib/slides/components/cards-4.html +9 -0
  50. package/lib/slides/components/compare.html +22 -0
  51. package/lib/slides/components/highlight.html +9 -0
  52. package/lib/slides/components/pipeline.html +12 -0
  53. package/lib/slides/components/plain.html +7 -0
  54. package/lib/slides/components/quote.html +4 -0
  55. package/lib/slides/components/timeline.html +9 -0
  56. package/lib/slides/templates/pitch.html +0 -0
  57. package/package.json +1 -1
  58. package/skills/roll-brief/SKILL.md +2 -2
  59. package/skills/roll-build/SKILL.md +40 -40
  60. package/skills/roll-fix/SKILL.md +22 -22
  61. package/skills/roll-loop/SKILL.md +26 -15
  62. package/skills/roll-onboard/SKILL.md +6 -6
@@ -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
- path = shared_root() / "loop" / f"events-{slug}.ndjson"
77
- if not path.exists():
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
- 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
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
- """Compute next cron fire time from the actual launchd plist Minute (FIX-063)."""
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
- minute_target = _read_plist_loop_minute()
870
- nxt = now.replace(minute=minute_target, second=0, microsecond=0)
871
- if nxt <= now:
872
- nxt += timedelta(hours=1)
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
@@ -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>
@@ -0,0 +1,9 @@
1
+ <!-- highlight: requires body_en (raw HTML), body_zh (raw HTML) -->
2
+ <div class="highlight-box">
3
+ <div class="lang-en">
4
+ {{{body_en}}}
5
+ </div>
6
+ <div class="lang-zh">
7
+ {{{body_zh}}}
8
+ </div>
9
+ </div>