@seanyao/roll 0.5.0 → 2.602.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 (181) hide show
  1. package/CHANGELOG.md +717 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -165
  4. package/bin/dream-test-quality-scan +110 -0
  5. package/bin/roll +14897 -815
  6. package/conventions/config.yaml +17 -1
  7. package/conventions/global/AGENTS.md +146 -100
  8. package/conventions/global/CLAUDE.md +1 -21
  9. package/conventions/global/GEMINI.md +8 -22
  10. package/conventions/global/project_rules.md +9 -0
  11. package/conventions/templates/backend-service/AGENTS.md +30 -81
  12. package/conventions/templates/backend-service/GEMINI.md +3 -3
  13. package/conventions/templates/backend-service/project_rules.md +16 -0
  14. package/conventions/templates/cli/AGENTS.md +31 -58
  15. package/conventions/templates/cli/CLAUDE.md +3 -5
  16. package/conventions/templates/cli/GEMINI.md +3 -3
  17. package/conventions/templates/cli/project_rules.md +16 -0
  18. package/conventions/templates/frontend-only/AGENTS.md +29 -64
  19. package/conventions/templates/frontend-only/GEMINI.md +3 -3
  20. package/conventions/templates/frontend-only/project_rules.md +14 -0
  21. package/conventions/templates/fullstack/AGENTS.md +31 -79
  22. package/conventions/templates/fullstack/CLAUDE.md +1 -1
  23. package/conventions/templates/fullstack/GEMINI.md +3 -3
  24. package/conventions/templates/fullstack/project_rules.md +15 -0
  25. package/lib/README.md +42 -0
  26. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  37. package/lib/agent_usage/README.md +49 -0
  38. package/lib/agent_usage/__init__.py +108 -0
  39. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  41. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  42. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  43. package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
  44. package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
  45. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
  46. package/lib/agent_usage/gemini.py +127 -0
  47. package/lib/agent_usage/kimi.py +278 -0
  48. package/lib/agent_usage/kimi_emit.py +123 -0
  49. package/lib/agent_usage/openai.py +126 -0
  50. package/lib/agent_usage/pi.py +200 -0
  51. package/lib/agent_usage/pi_emit.py +135 -0
  52. package/lib/agent_usage/qwen.py +128 -0
  53. package/lib/backfill-pi-usage.py +243 -0
  54. package/lib/changelog_audit.py +155 -0
  55. package/lib/changelog_generate.py +263 -0
  56. package/lib/context_feed_budget.sh +194 -0
  57. package/lib/github_sync.py +876 -0
  58. package/lib/i18n/README.md +54 -0
  59. package/lib/i18n/agent.sh +75 -0
  60. package/lib/i18n/alert.sh +20 -0
  61. package/lib/i18n/backlog.sh +96 -0
  62. package/lib/i18n/brief.sh +5 -0
  63. package/lib/i18n/changelog.sh +5 -0
  64. package/lib/i18n/ci.sh +15 -0
  65. package/lib/i18n/debug.sh +0 -0
  66. package/lib/i18n/doctor.sh +44 -0
  67. package/lib/i18n/dream.sh +0 -0
  68. package/lib/i18n/init.sh +91 -0
  69. package/lib/i18n/lang.sh +10 -0
  70. package/lib/i18n/loop.sh +140 -0
  71. package/lib/i18n/migrate.sh +74 -0
  72. package/lib/i18n/offboard.sh +31 -0
  73. package/lib/i18n/onboard.sh +0 -0
  74. package/lib/i18n/peer.sh +41 -0
  75. package/lib/i18n/peer_help.sh +25 -0
  76. package/lib/i18n/peer_reset.sh +7 -0
  77. package/lib/i18n/peer_status.sh +5 -0
  78. package/lib/i18n/prices.sh +3 -0
  79. package/lib/i18n/prices_refresh.sh +17 -0
  80. package/lib/i18n/prices_show.sh +7 -0
  81. package/lib/i18n/propose.sh +0 -0
  82. package/lib/i18n/release.sh +0 -0
  83. package/lib/i18n/research.sh +0 -0
  84. package/lib/i18n/review_pr.sh +0 -0
  85. package/lib/i18n/sentinel.sh +0 -0
  86. package/lib/i18n/setup.sh +3 -0
  87. package/lib/i18n/shared.sh +157 -0
  88. package/lib/i18n/skills/roll-brief.sh +47 -0
  89. package/lib/i18n/skills/roll-build.sh +97 -0
  90. package/lib/i18n/skills/roll-design.sh +18 -0
  91. package/lib/i18n/skills/roll-fix.sh +53 -0
  92. package/lib/i18n/skills/roll-loop.sh +28 -0
  93. package/lib/i18n/skills/roll-onboard.sh +33 -0
  94. package/lib/i18n/skills_catalog.sh +30 -0
  95. package/lib/i18n/slides.sh +3 -0
  96. package/lib/i18n/slides_build.sh +38 -0
  97. package/lib/i18n/slides_delete.sh +19 -0
  98. package/lib/i18n/slides_list.sh +14 -0
  99. package/lib/i18n/slides_logs.sh +12 -0
  100. package/lib/i18n/slides_new.sh +15 -0
  101. package/lib/i18n/slides_preview.sh +14 -0
  102. package/lib/i18n/slides_templates.sh +7 -0
  103. package/lib/i18n/status.sh +21 -0
  104. package/lib/i18n/update.sh +24 -0
  105. package/lib/i18n.sh +211 -0
  106. package/lib/loop-exit-summary.py +393 -0
  107. package/lib/loop-fmt.py +589 -0
  108. package/lib/loop_pick_agent.py +316 -0
  109. package/lib/loop_result_eval.py +469 -0
  110. package/lib/loop_unstick.py +180 -0
  111. package/lib/model_prices.py +186 -0
  112. package/lib/prices/README.md +35 -0
  113. package/lib/prices/snapshot-2026-05-22.json +22 -0
  114. package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
  115. package/lib/prices/snapshot-2026-05-23-kimi.json +14 -0
  116. package/lib/prices_fetcher.py +285 -0
  117. package/lib/roll-backlog.py +225 -0
  118. package/lib/roll-brief.py +286 -0
  119. package/lib/roll-help.py +158 -0
  120. package/lib/roll-home.py +556 -0
  121. package/lib/roll-init.py +156 -0
  122. package/lib/roll-loop-status.py +1683 -0
  123. package/lib/roll-loop-story.py +191 -0
  124. package/lib/roll-onboard-render.py +378 -0
  125. package/lib/roll-peer.py +252 -0
  126. package/lib/roll-plan-validate.py +386 -0
  127. package/lib/roll-setup.py +102 -0
  128. package/lib/roll-status.py +367 -0
  129. package/lib/roll_git.py +41 -0
  130. package/lib/roll_render.py +414 -0
  131. package/lib/slides/components/README.md +123 -0
  132. package/lib/slides/components/cards-2.html +9 -0
  133. package/lib/slides/components/cards-3.html +9 -0
  134. package/lib/slides/components/cards-4.html +9 -0
  135. package/lib/slides/components/compare.html +22 -0
  136. package/lib/slides/components/highlight.html +9 -0
  137. package/lib/slides/components/pipeline.html +12 -0
  138. package/lib/slides/components/plain.html +7 -0
  139. package/lib/slides/components/quote.html +4 -0
  140. package/lib/slides/components/timeline.html +9 -0
  141. package/lib/slides/templates/introduction-v3.html +571 -0
  142. package/lib/slides/templates/pitch.html +0 -0
  143. package/lib/slides-render.py +778 -0
  144. package/lib/slides-validate.py +357 -0
  145. package/lib/test_quality_gate.py +143 -0
  146. package/package.json +8 -7
  147. package/skills/roll-.changelog/SKILL.md +406 -33
  148. package/skills/roll-.clarify/SKILL.md +5 -2
  149. package/skills/roll-.dream/SKILL.md +374 -0
  150. package/skills/roll-.echo/SKILL.md +5 -2
  151. package/skills/roll-.qa/SKILL.md +57 -3
  152. package/skills/roll-.review/SKILL.md +42 -3
  153. package/skills/roll-brief/SKILL.md +209 -0
  154. package/skills/roll-build/SKILL.md +308 -63
  155. package/skills/roll-debug/SKILL.md +341 -162
  156. package/skills/roll-debug/injectable-bb.js +263 -0
  157. package/skills/roll-deck/SKILL.md +296 -0
  158. package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
  159. package/skills/roll-design/SKILL.md +727 -94
  160. package/skills/roll-doc/SKILL.md +595 -0
  161. package/skills/roll-doctor/SKILL.md +192 -0
  162. package/skills/roll-fix/SKILL.md +149 -32
  163. package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
  164. package/skills/roll-loop/SKILL.md +578 -0
  165. package/skills/roll-notes/SKILL.md +103 -0
  166. package/skills/roll-onboard/SKILL.md +234 -0
  167. package/skills/roll-peer/SKILL.md +336 -0
  168. package/skills/roll-propose/SKILL.md +157 -0
  169. package/skills/roll-review-pr/SKILL.md +58 -0
  170. package/skills/roll-sentinel/SKILL.md +11 -2
  171. package/skills/roll-spar/SKILL.md +8 -6
  172. package/template/.github/workflows/ci.yml +5 -2
  173. package/template/AGENTS.md +20 -74
  174. package/skills/roll-research/SKILL.md +0 -307
  175. package/skills/roll-research/references/schema.json +0 -162
  176. package/skills/roll-research/scripts/md_to_pdf.py +0 -289
  177. package/tools/roll-fetch/SKILL.md +0 -182
  178. package/tools/roll-fetch/package.json +0 -15
  179. package/tools/roll-fetch/smart-web-fetch.js +0 -558
  180. package/tools/roll-probe/SKILL.md +0 -84
  181. /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
@@ -0,0 +1,589 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ loop-fmt.py — 3-tier stream-json → tmux formatter for roll loop.
4
+
5
+ Tier 3 (suppressed): init, thinking, Read/Glob/Grep, non-error results, plain Bash
6
+ Tier 2 (muted): Edit/Write → ✏ path
7
+ Tier 1 (signal): tcr commit, story skill, peer verdict, ci gate, pr merge, errors
8
+ """
9
+ import sys
10
+ import json
11
+ import re
12
+ import os
13
+ import threading
14
+ import time
15
+ from datetime import datetime, timezone
16
+
17
+ _SPIN_ENABLED = os.environ.get("LOOP_FMT_NO_SPIN", "0") != "1"
18
+ SPIN_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
19
+
20
+ DARK_GRAY = "\033[90m"
21
+ CYAN = "\033[36m"
22
+ WHITE = "\033[97m"
23
+ GREEN = "\033[32m"
24
+ RED = "\033[31m"
25
+ YELLOW = "\033[33m"
26
+ RESET = "\033[0m"
27
+
28
+
29
+ class Spinner:
30
+ """Animated wait indicator for long-running operations.
31
+
32
+ In production (LOOP_FMT_NO_SPIN=0): background thread writes frames using \\r.
33
+ In test mode (LOOP_FMT_NO_SPIN=1): writes a static ⏳ line to stdout instead.
34
+ """
35
+ def __init__(self):
36
+ self._thread = None
37
+ self._running = False
38
+ self._label = ""
39
+ self._lock = threading.Lock()
40
+
41
+ @property
42
+ def active(self):
43
+ return self._running
44
+
45
+ def start(self, label):
46
+ with self._lock:
47
+ if self._running:
48
+ self._label = label # update without restart
49
+ return
50
+ self._label = label
51
+ self._running = True
52
+ if _SPIN_ENABLED:
53
+ self._thread = threading.Thread(target=self._run, daemon=True)
54
+ self._thread.start()
55
+ else:
56
+ sys.stdout.write(f" {YELLOW}⏳ {label}...{RESET}\n")
57
+ sys.stdout.flush()
58
+
59
+ def stop(self):
60
+ with self._lock:
61
+ was_running = self._running
62
+ self._running = False
63
+ if self._thread:
64
+ self._thread.join(timeout=0.3)
65
+ self._thread = None
66
+ if _SPIN_ENABLED and was_running:
67
+ sys.stdout.write(f"\r{' ' * 60}\r")
68
+ sys.stdout.flush()
69
+
70
+ def _run(self):
71
+ i = 0
72
+ while self._running:
73
+ with self._lock:
74
+ label = self._label
75
+ frame = SPIN_FRAMES[i % len(SPIN_FRAMES)]
76
+ sys.stdout.write(f"\r {YELLOW}{frame} {label}...{RESET}")
77
+ sys.stdout.flush()
78
+ time.sleep(0.12)
79
+ i += 1
80
+ sys.stdout.write(f"\r{' ' * 60}\r")
81
+ sys.stdout.flush()
82
+
83
+ SUPPRESS_TOOLS = {"Read", "Glob", "Grep", "ReadMcpResourceTool", "ListMcpResourcesTool",
84
+ "WebFetch", "WebSearch", "TaskCreate", "TaskGet", "TaskList",
85
+ "TaskUpdate", "TaskOutput", "TaskStop"}
86
+
87
+ def now_hms():
88
+ return datetime.now(timezone.utc).strftime("%H:%M:%S")
89
+
90
+ def trunc(s, n=60):
91
+ s = str(s).replace("\n", " ").strip()
92
+ return s[:n] + "…" if len(s) > n else s
93
+
94
+ def step(category, label, detail="", ok=True):
95
+ cat_color = CYAN
96
+ label_color = GREEN if ok and category in ("ci", "pr") else (RED if not ok else WHITE)
97
+ arrow = f"{DARK_GRAY}→{RESET}"
98
+ cat = f" {cat_color}{category:<6}{RESET}"
99
+ lbl = f" {label_color}{label:<14}{RESET}"
100
+ det = f" {DARK_GRAY}{detail}{RESET}" if detail else ""
101
+ return f"{arrow}{cat}{lbl}{det}"
102
+
103
+ def stamp(text, muted=False):
104
+ ts = f"{DARK_GRAY}{now_hms()}{RESET}"
105
+ body = f"{DARK_GRAY}{text}{RESET}" if muted else text
106
+ return f"{ts} {body}"
107
+
108
+ class LoopFmt:
109
+ def __init__(self):
110
+ self.last_bash_cmd = ""
111
+ self.tcr_count = 0
112
+ self.last_test_count = None
113
+ self.cycle_num = None
114
+ self.pending_commit = False
115
+ self.pending_pr = False
116
+ self.pending_ci = False
117
+ self.pending_story = False
118
+ self.spinner = Spinner()
119
+ # US-VIEW-020: consecutive same-file Edit/Write streak. Tracks the last
120
+ # file path and how many times in a row it's been edited so the renderer
121
+ # can collapse N identical lines into one `✏ <basename> ×N` line.
122
+ self._edit_streak = (None, 0) # (last_file_path, count)
123
+ # Accumulate token usage across all assistant turns in the cycle so
124
+ # the trailing result event can emit a 'usage' event carrying the
125
+ # cumulative totals (result.usage only carries the last turn's).
126
+ self._usage_totals = {
127
+ "input_tokens": 0,
128
+ "output_tokens": 0,
129
+ "cache_creation_tokens": 0,
130
+ "cache_read_tokens": 0,
131
+ }
132
+ self._last_model = None
133
+
134
+ def _extract_cycle_num(self, text):
135
+ m = re.search(r'cycle[#\s]+(\d+)', text, re.IGNORECASE)
136
+ return m.group(1) if m else "?"
137
+
138
+ def process(self, line):
139
+ line = line.rstrip()
140
+ if not line:
141
+ return
142
+
143
+ # Plain text passthrough
144
+ try:
145
+ ev = json.loads(line)
146
+ except json.JSONDecodeError:
147
+ self._handle_plain(line)
148
+ return
149
+
150
+ etype = ev.get("type", "")
151
+ if etype == "system":
152
+ return # Tier 3: suppress all system events
153
+ if etype == "assistant":
154
+ self._handle_assistant(ev)
155
+ elif etype == "user":
156
+ self._handle_user(ev)
157
+ elif etype == "result":
158
+ self._handle_result(ev)
159
+ # All other types: suppress
160
+
161
+ def _handle_plain(self, line):
162
+ # [loop] cycle N: ... → Tier 1 stamp
163
+ m = re.search(r'\[loop\]\s+cycle\s+(\d+)[:\s]', line)
164
+ if m:
165
+ self.cycle_num = m.group(1)
166
+ self.tcr_count = 0
167
+ print(stamp(f"cycle #{self.cycle_num} — picking story"))
168
+ return
169
+ # Other plain text: suppress
170
+
171
+ def _handle_assistant(self, ev):
172
+ msg = ev.get("message", {})
173
+ # Sum token usage across turns; result.usage only carries the last
174
+ # turn so accumulating here is the only way to get cumulative totals.
175
+ u = msg.get("usage") or {}
176
+ if u:
177
+ self._usage_totals["input_tokens"] += int(u.get("input_tokens") or 0)
178
+ self._usage_totals["output_tokens"] += int(u.get("output_tokens") or 0)
179
+ self._usage_totals["cache_creation_tokens"] += int(u.get("cache_creation_input_tokens") or 0)
180
+ self._usage_totals["cache_read_tokens"] += int(u.get("cache_read_input_tokens") or 0)
181
+ if msg.get("model"):
182
+ self._last_model = msg["model"]
183
+ for blk in msg.get("content", []):
184
+ btype = blk.get("type", "")
185
+ if btype == "thinking":
186
+ return # Tier 3
187
+ elif btype == "text":
188
+ self._handle_text(blk.get("text", ""))
189
+ elif btype == "tool_use":
190
+ self._handle_tool_use(blk)
191
+
192
+ def _handle_text(self, text):
193
+ text = text.strip()
194
+ if not text:
195
+ return
196
+ # Peer verdict detection
197
+ for verdict in ("AGREE", "REFINE", "OBJECT", "ESCALATE"):
198
+ if verdict in text:
199
+ m = re.search(r'round\s+(\d+)[/\\](\d+)', text, re.IGNORECASE)
200
+ round_str = f"round {m.group(1)}/{m.group(2)}" if m else "round ?"
201
+ # agent names — look for common patterns
202
+ agents = "claude → peer"
203
+ m2 = re.search(r'(\w+)\s*→\s*(\w+)', text)
204
+ if m2:
205
+ agents = f"{m2.group(1)} → {m2.group(2)}"
206
+ self._flush_edit_streak()
207
+ print(step("peer", agents, f"{round_str} · {verdict}"))
208
+ return
209
+ # All other text: Tier 3, suppress
210
+
211
+ @staticmethod
212
+ def _edit_hint(inp):
213
+ """US-VIEW-021: derive a ≤20-char change feature from an Edit/Write input.
214
+
215
+ Priority:
216
+ 1. replace_all=true → "replace-all"
217
+ 2. else first non-blank token of new_string's first line, with leading
218
+ whitespace / comment markers stripped.
219
+ Truncates to 20 chars (unicode chars, not bytes) with a trailing "…".
220
+ Empty / all-whitespace new_string → "" (caller omits the ` | ` segment).
221
+ """
222
+ if inp.get("replace_all") is True:
223
+ return "replace-all"
224
+ new_string = inp.get("new_string") or ""
225
+ if not isinstance(new_string, str):
226
+ return ""
227
+ # first non-blank line
228
+ first_line = next((l for l in new_string.splitlines() if l.strip()), "")
229
+ s = first_line.strip()
230
+ # strip leading comment markers (#, //, /*, *, --, ;) then re-strip
231
+ s = re.sub(r'^(#+|//+|/\*+|\*+|--+|;+)\s*', '', s).strip()
232
+ # first token (non-whitespace run)
233
+ token = s.split()[0] if s.split() else ""
234
+ if not token:
235
+ return ""
236
+ if len(token) > 20:
237
+ return token[:20] + "…"
238
+ return token
239
+
240
+ def _edit_streak_line(self, path, count, hint=""):
241
+ """Render the streak line for `path` at `count`. basename only.
242
+
243
+ US-VIEW-021: when `hint` is non-empty, insert a ` | <hint>` segment
244
+ before the ×N suffix: `✏ <basename> | <hint> ×N`.
245
+ """
246
+ base = os.path.basename(path) or path
247
+ hint_part = f" | {hint}" if hint else ""
248
+ suffix = f" ×{count}" if count >= 2 else ""
249
+ return f" {DARK_GRAY}✏ {base}{hint_part}{suffix}{RESET}"
250
+
251
+ def _flush_edit_streak(self):
252
+ """Finalize the current Edit/Write streak (if any), leaving its last
253
+ line in place, and reset the streak state. In production the streak
254
+ used `\\r` in-place refresh, so we end with a newline to keep the final
255
+ line; in test mode each line was already printed standalone."""
256
+ path, count = self._edit_streak
257
+ if path is None:
258
+ return
259
+ if _SPIN_ENABLED:
260
+ # finish the in-place line so subsequent output starts fresh
261
+ sys.stdout.write("\n")
262
+ sys.stdout.flush()
263
+ self._edit_streak = (None, 0)
264
+
265
+ def _handle_edit(self, path, hint=""):
266
+ last_path, count = self._edit_streak
267
+ if path == last_path:
268
+ count += 1
269
+ self._edit_streak = (path, count)
270
+ line = self._edit_streak_line(path, count, hint)
271
+ if _SPIN_ENABLED:
272
+ # in-place refresh: overwrite the current line with the new ×N
273
+ sys.stdout.write("\r" + line)
274
+ sys.stdout.flush()
275
+ else:
276
+ # deterministic test mode: static line per Spinner-style convention
277
+ print(line)
278
+ else:
279
+ # different file: flush previous streak, then start a new one
280
+ self._flush_edit_streak()
281
+ self._edit_streak = (path, 1)
282
+ line = self._edit_streak_line(path, 1, hint)
283
+ if _SPIN_ENABLED:
284
+ sys.stdout.write(line)
285
+ sys.stdout.flush()
286
+ else:
287
+ print(line)
288
+
289
+ def _handle_tool_use(self, blk):
290
+ name = blk.get("name", "")
291
+ inp = blk.get("input", {})
292
+
293
+ if name in SUPPRESS_TOOLS:
294
+ return # Tier 3
295
+
296
+ if name in ("Edit", "Write"):
297
+ path = inp.get("file_path") or inp.get("path", "")
298
+ hint = self._edit_hint(inp)
299
+ self._handle_edit(path, hint)
300
+ return # Tier 2
301
+
302
+ # Any non-Edit tool_use breaks the streak (don't collapse across them).
303
+ self._flush_edit_streak()
304
+
305
+ if name == "Bash":
306
+ cmd = inp.get("command", "")
307
+ first_line = next((l.strip() for l in cmd.splitlines() if l.strip()), cmd)
308
+ self.last_bash_cmd = first_line
309
+ if re.search(r'git commit.*tcr:', cmd):
310
+ self.pending_commit = True
311
+ elif re.search(r'gh pr (create|merge)', cmd):
312
+ self.pending_pr = True
313
+ self.spinner.start("merging PR")
314
+ elif re.search(r'(roll ci|npm run ci|_ci_wait|ci:local)', cmd):
315
+ self.pending_ci = True
316
+ self.spinner.start("waiting for CI")
317
+ return # Wait for result
318
+
319
+ if name == "Skill":
320
+ skill = inp.get("skill", "")
321
+ args = inp.get("args", "").strip()
322
+ if skill in ("roll-build", "roll-fix"):
323
+ us_id = args.split()[0] if args else "?"
324
+ print()
325
+ print(stamp(f"cycle #{self.cycle_num or '?'} — picking story"))
326
+ print(step("story", us_id, trunc(args, 60)))
327
+ self.pending_story = True
328
+ self.spinner.start("executing story")
329
+ return
330
+
331
+ # All other tools (Agent, ToolSearch, etc.): suppress
332
+
333
+ def _handle_user(self, ev):
334
+ msg = ev.get("message", {})
335
+ for blk in msg.get("content", []):
336
+ if blk.get("type") != "tool_result":
337
+ continue
338
+ is_err = blk.get("is_error", False)
339
+ content = blk.get("content", "")
340
+ text = self._extract_text(content)
341
+
342
+ # Scan for test count (bats ok N pattern)
343
+ m = re.search(r'\bok\s+(\d+)', text)
344
+ if m:
345
+ self.last_test_count = int(m.group(1))
346
+
347
+ if is_err:
348
+ self._flush_edit_streak() # don't stack an error onto a streak
349
+ tool_name = "tool"
350
+ lines = [l for l in text.splitlines() if l.strip()][:3]
351
+ detail = " | ".join(lines)
352
+ print(step("error", tool_name, trunc(detail, 80), ok=False))
353
+ self.pending_commit = self.pending_pr = self.pending_ci = False
354
+ return
355
+
356
+ if self.pending_commit:
357
+ self.pending_commit = False
358
+ # Extract hash and message from git commit output: [branch hash] msg
359
+ m = re.search(r'\[[\w/\-]+ ([0-9a-f]{7,})\]\s*tcr:\s*(.+)', text)
360
+ if m:
361
+ commit_hash = m.group(1)[:7]
362
+ commit_msg = m.group(2).strip()
363
+ self.tcr_count += 1
364
+ test_part = f" · {self.last_test_count} tests" if self.last_test_count else ""
365
+ print(step("tcr", commit_hash, f"{commit_msg}{test_part}"))
366
+ return
367
+
368
+ if self.pending_story:
369
+ self.pending_story = False
370
+ self.spinner.stop()
371
+ return # story result content suppressed; TCR events showed the work
372
+
373
+ if self.pending_pr:
374
+ self.spinner.stop()
375
+ self.pending_pr = False
376
+ m = re.search(r'#(\d+)', text)
377
+ if m:
378
+ pr_num = f"#{m.group(1)}"
379
+ branch = re.search(r'loop/[\w\-]+', self.last_bash_cmd)
380
+ branch_str = branch.group(0) if branch else ""
381
+ detail = f"auto-merged · {branch_str}" if branch_str else "auto-merged"
382
+ print(step("pr", pr_num, detail, ok=True))
383
+ return
384
+
385
+ if self.pending_ci:
386
+ self.spinner.stop()
387
+ self.pending_ci = False
388
+ has_green = re.search(r'(green|pass|success|all tests)', text, re.IGNORECASE)
389
+ has_red = re.search(r'(red|fail|error)', text, re.IGNORECASE)
390
+ m_dur = re.search(r'(\d+(?:\.\d+)?)\s*s\b', text)
391
+ m_test = re.search(r'(\d+)\s+tests?', text)
392
+ dur_str = f"{m_dur.group(1)}s" if m_dur else ""
393
+ test_str = f"{m_test.group(1)} tests" if m_test else (f"{self.last_test_count} tests" if self.last_test_count else "")
394
+ detail = " · ".join(filter(None, [dur_str, test_str]))
395
+ if has_green and not has_red:
396
+ print(step("ci", "green", detail, ok=True))
397
+ else:
398
+ print(step("ci", "red", detail, ok=False))
399
+ return
400
+
401
+ # Non-matching result: suppress (Tier 3)
402
+
403
+ def _extract_text(self, content):
404
+ if isinstance(content, str):
405
+ return content
406
+ if isinstance(content, list):
407
+ parts = []
408
+ for c in content:
409
+ if isinstance(c, dict) and c.get("type") == "text":
410
+ parts.append(c.get("text", ""))
411
+ return "\n".join(parts)
412
+ return str(content) if content else ""
413
+
414
+ def _handle_result(self, ev):
415
+ # US-VIEW-020: flush any residual Edit streak before the cycle summary
416
+ # so the final ✏ line isn't lost / left mid-`\r`.
417
+ self._flush_edit_streak()
418
+ dur_ms = ev.get("duration_ms", 0)
419
+ cost_usd = ev.get("total_cost_usd", 0)
420
+ dur_s = dur_ms / 1000
421
+ cost_str = f"${cost_usd:.2f}" if cost_usd else ""
422
+ tcr_str = f"{self.tcr_count} tcr" if self.tcr_count else ""
423
+ parts = [p for p in [tcr_str, f"{dur_s:.0f}s", cost_str] if p]
424
+ detail = " · ".join(parts)
425
+ subtype = ev.get("subtype", "")
426
+ if subtype == "error_max_turns":
427
+ print(step("error", "max-turns", f"{dur_s:.0f}s", ok=False))
428
+ else:
429
+ cycle_str = f"cycle #{self.cycle_num}" if self.cycle_num else "cycle done"
430
+ print(stamp(f"{cycle_str} — done · {detail}" if detail else f"{cycle_str} — done", muted=True))
431
+
432
+ # US-LOOP-004 partial: emit a per-cycle 'usage' event into the
433
+ # durable events.ndjson so dashboards don't have to rely on the
434
+ # cron.log (overwritten every cycle). Skips silently when the
435
+ # required env vars aren't set (e.g. running outside roll loop).
436
+ self._emit_usage_event(ev, dur_ms, cost_usd)
437
+
438
+ @staticmethod
439
+ def _price_at_snapshot(model, totals):
440
+ """Resolve (cost_list, currency, prices_version) from the active price snapshot.
441
+
442
+ Returns (None, None, None) when model_prices isn't loadable or the snapshot
443
+ has no usable prices — callers still emit the event so token data and
444
+ duration aren't lost. When tokens are all zero, cost_list is None.
445
+ """
446
+ try:
447
+ import importlib.util
448
+ lib_dir = os.path.dirname(os.path.abspath(__file__))
449
+ spec = importlib.util.spec_from_file_location(
450
+ "model_prices", os.path.join(lib_dir, "model_prices.py")
451
+ )
452
+ mp = importlib.util.module_from_spec(spec)
453
+ spec.loader.exec_module(mp)
454
+ except Exception:
455
+ return None, None, None
456
+ prices_version = getattr(mp, "VERSION", None)
457
+ has_tokens = any(int(totals.get(k) or 0) > 0 for k in totals)
458
+ if not has_tokens:
459
+ return None, None, prices_version
460
+ try:
461
+ cost = mp.compute_list_cost(
462
+ model,
463
+ input_tokens=int(totals.get("input_tokens") or 0),
464
+ output_tokens=int(totals.get("output_tokens") or 0),
465
+ cache_creation_tokens=int(totals.get("cache_creation_tokens") or 0),
466
+ cache_read_tokens=int(totals.get("cache_read_tokens") or 0),
467
+ )
468
+ currency = mp.currency_for(model) if model else "USD"
469
+ except Exception:
470
+ return None, None, prices_version
471
+ return float(cost), currency, prices_version
472
+
473
+ def _emit_usage_event(self, result_ev, dur_ms, cost_usd):
474
+ slug = os.environ.get("LOOP_PROJECT_SLUG")
475
+ cycle = os.environ.get("LOOP_CYCLE_ID")
476
+ shared = os.environ.get("LOOP_SHARED_ROOT") or os.path.expanduser("~/.shared/roll")
477
+ if not (slug and cycle):
478
+ return
479
+ # Use the cumulative totals accumulated across all assistant turns;
480
+ # result.usage is per-turn (last only) so it would under-count badly.
481
+ model = result_ev.get("model") or self._last_model or ""
482
+
483
+ # FIX-099: skip writing the usage event when claude returned no real
484
+ # usage data (model empty AND cost/duration both zero). This prevents
485
+ # stale/placeholder values from leaking into the events stream and
486
+ # showing up as "cost=$1.24 dur=372s" in three consecutive cycles when
487
+ # the real cycle had no token data (the default-value fallback).
488
+ # The dashboard can render "n/a" for missing usage rather than false data.
489
+ has_model = bool(model)
490
+ has_tokens = any(self._usage_totals[k] > 0 for k in self._usage_totals)
491
+ has_cost = bool(cost_usd)
492
+ has_dur = bool(dur_ms)
493
+ if not has_model and not has_tokens and not has_cost and not has_dur:
494
+ return # nothing real to report — skip rather than persist zeros
495
+
496
+ # US-VIEW-014: freeze cost at the current snapshot's list price so a
497
+ # later prices refresh (or roll upgrade) never rewrites history. The
498
+ # dashboard reads cost_list_usd first; only legacy events without it
499
+ # fall back to recomputing and get tagged [legacy].
500
+ # FIX-116: also capture cost_currency so the dashboard shows the
501
+ # correct currency symbol (e.g. $ for USD, ¥ for CNY).
502
+ cost_list_usd, cost_currency, prices_version = self._price_at_snapshot(
503
+ model if has_model else None,
504
+ self._usage_totals,
505
+ )
506
+
507
+ payload = {
508
+ "model": model if has_model else None,
509
+ "input_tokens": self._usage_totals["input_tokens"],
510
+ "output_tokens": self._usage_totals["output_tokens"],
511
+ "cache_creation_tokens": self._usage_totals["cache_creation_tokens"],
512
+ "cache_read_tokens": self._usage_totals["cache_read_tokens"],
513
+ "cost_reported_usd": float(cost_usd) if has_cost else None,
514
+ "duration_ms": int(dur_ms) if has_dur else None,
515
+ "cost_list_usd": cost_list_usd,
516
+ "cost_currency": cost_currency,
517
+ "prices_version": prices_version,
518
+ }
519
+ evfile = os.path.join(shared, "loop", f"events-{slug}.ndjson")
520
+ line = json.dumps({
521
+ "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
522
+ "stage": "usage",
523
+ "label": cycle,
524
+ "detail": payload,
525
+ "outcome": "ok",
526
+ }) + "\n"
527
+ try:
528
+ os.makedirs(os.path.dirname(evfile), exist_ok=True)
529
+ with open(evfile, "a") as f:
530
+ f.write(line)
531
+ except Exception:
532
+ pass # best-effort; never break tmux output
533
+
534
+
535
+ def _passthrough_main(agent):
536
+ """Transparent forwarding for non-claude agents (pi, deepseek, kimi, …).
537
+
538
+ Writes every stdin line to stdout with a HH:MM:SS timestamp prefix so
539
+ tmux shows real-time progress. Accumulates all lines; at cycle end,
540
+ dispatches to the agent_usage plugin registry (US-LOOP-026). If a plugin
541
+ returns real token/cost data, emits a single usage event with it;
542
+ otherwise falls back to a single null-payload event (US-LOOP-010 compat).
543
+ """
544
+ slug = os.environ.get("LOOP_PROJECT_SLUG")
545
+ cycle = os.environ.get("LOOP_CYCLE_ID")
546
+ shared = os.environ.get("LOOP_SHARED_ROOT") or os.path.expanduser("~/.shared/roll")
547
+ evfile = None
548
+ if slug and cycle:
549
+ evfile = os.path.join(shared, "loop", f"events-{slug}.ndjson")
550
+ try:
551
+ os.makedirs(os.path.dirname(evfile), exist_ok=True)
552
+ except Exception:
553
+ evfile = None
554
+
555
+ # Accumulate all lines for end-of-cycle usage extraction.
556
+ accumulated: list[str] = []
557
+
558
+ for line in sys.stdin:
559
+ if not line.rstrip():
560
+ continue
561
+ accumulated.append(line.rstrip())
562
+ # Timestamp prefix so tmux shows activity (even if agent output has
563
+ # no timestamps of its own).
564
+ ts = datetime.now(timezone.utc).strftime("%H:%M:%S")
565
+ out = f"{DARK_GRAY}{ts}{RESET} {line.rstrip()}"
566
+ sys.stdout.write(out + "\n")
567
+ sys.stdout.flush()
568
+
569
+ # Passthrough is display-only. Usage is NOT emitted from here:
570
+ # - pi -p text mode carries no usage in stdout (nothing to extract), and
571
+ # - this runs once per retry attempt, so emitting here wrote N usage
572
+ # events per cycle and the dashboard SUMS same-label usage → ×N.
573
+ # Instead bin/roll calls agent_usage/pi_emit.py exactly once after the
574
+ # agent phase, recovering real usage from pi's session files.
575
+ _ = (accumulated, evfile) # intentionally unused now
576
+
577
+
578
+ def main():
579
+ agent = os.environ.get("ROLL_LOOP_AGENT", "claude")
580
+ if agent == "claude":
581
+ fmt = LoopFmt()
582
+ for line in sys.stdin:
583
+ fmt.process(line)
584
+ sys.stdout.flush()
585
+ else:
586
+ _passthrough_main(agent)
587
+
588
+ if __name__ == "__main__":
589
+ main()