@seanyao/roll 0.5.0 → 2.602.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +736 -0
- package/LICENSE +21 -0
- package/README.md +65 -165
- package/bin/dream-test-quality-scan +110 -0
- package/bin/roll +15030 -814
- package/conventions/config.yaml +17 -1
- package/conventions/global/AGENTS.md +146 -100
- package/conventions/global/CLAUDE.md +1 -21
- package/conventions/global/GEMINI.md +8 -22
- package/conventions/global/project_rules.md +9 -0
- package/conventions/templates/backend-service/AGENTS.md +30 -81
- package/conventions/templates/backend-service/GEMINI.md +3 -3
- package/conventions/templates/backend-service/project_rules.md +16 -0
- package/conventions/templates/cli/AGENTS.md +31 -58
- package/conventions/templates/cli/CLAUDE.md +3 -5
- package/conventions/templates/cli/GEMINI.md +3 -3
- package/conventions/templates/cli/project_rules.md +16 -0
- package/conventions/templates/frontend-only/AGENTS.md +29 -64
- package/conventions/templates/frontend-only/GEMINI.md +3 -3
- package/conventions/templates/frontend-only/project_rules.md +14 -0
- package/conventions/templates/fullstack/AGENTS.md +31 -79
- package/conventions/templates/fullstack/CLAUDE.md +1 -1
- package/conventions/templates/fullstack/GEMINI.md +3 -3
- package/conventions/templates/fullstack/project_rules.md +15 -0
- package/lib/README.md +42 -0
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -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_git.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
- package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/README.md +49 -0
- package/lib/agent_usage/__init__.py +108 -0
- package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
- package/lib/agent_usage/gemini.py +127 -0
- package/lib/agent_usage/kimi.py +278 -0
- package/lib/agent_usage/kimi_emit.py +123 -0
- package/lib/agent_usage/openai.py +126 -0
- package/lib/agent_usage/pi.py +200 -0
- package/lib/agent_usage/pi_emit.py +135 -0
- package/lib/agent_usage/qwen.py +128 -0
- package/lib/backfill-pi-usage.py +243 -0
- package/lib/changelog_audit.py +155 -0
- package/lib/changelog_generate.py +263 -0
- package/lib/context_feed_budget.sh +194 -0
- package/lib/github_sync.py +876 -0
- package/lib/i18n/README.md +54 -0
- package/lib/i18n/agent.sh +75 -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 +5 -0
- package/lib/i18n/ci.sh +15 -0
- package/lib/i18n/debug.sh +0 -0
- package/lib/i18n/doctor.sh +44 -0
- package/lib/i18n/dream.sh +0 -0
- package/lib/i18n/init.sh +91 -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 +31 -0
- package/lib/i18n/onboard.sh +0 -0
- package/lib/i18n/peer.sh +41 -0
- package/lib/i18n/peer_help.sh +25 -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/propose.sh +0 -0
- package/lib/i18n/release.sh +0 -0
- package/lib/i18n/research.sh +0 -0
- package/lib/i18n/review_pr.sh +0 -0
- package/lib/i18n/sentinel.sh +0 -0
- package/lib/i18n/setup.sh +3 -0
- package/lib/i18n/shared.sh +157 -0
- package/lib/i18n/skills/roll-brief.sh +47 -0
- package/lib/i18n/skills/roll-build.sh +97 -0
- package/lib/i18n/skills/roll-design.sh +18 -0
- package/lib/i18n/skills/roll-fix.sh +53 -0
- package/lib/i18n/skills/roll-loop.sh +28 -0
- package/lib/i18n/skills/roll-onboard.sh +33 -0
- package/lib/i18n/skills_catalog.sh +30 -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 +21 -0
- package/lib/i18n/update.sh +24 -0
- package/lib/i18n.sh +211 -0
- package/lib/loop-exit-summary.py +393 -0
- package/lib/loop-fmt.py +589 -0
- package/lib/loop_pick_agent.py +316 -0
- package/lib/loop_result_eval.py +469 -0
- package/lib/loop_unstick.py +180 -0
- package/lib/model_prices.py +194 -0
- package/lib/prices/README.md +35 -0
- package/lib/prices/snapshot-2026-05-22.json +22 -0
- package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
- package/lib/prices/snapshot-2026-05-23-kimi.json +15 -0
- package/lib/prices_fetcher.py +285 -0
- package/lib/roll-backlog.py +225 -0
- package/lib/roll-brief.py +286 -0
- package/lib/roll-help.py +158 -0
- package/lib/roll-home.py +556 -0
- package/lib/roll-init.py +156 -0
- package/lib/roll-loop-status.py +1683 -0
- package/lib/roll-loop-story.py +191 -0
- package/lib/roll-onboard-render.py +378 -0
- package/lib/roll-peer.py +252 -0
- package/lib/roll-plan-validate.py +386 -0
- package/lib/roll-setup.py +102 -0
- package/lib/roll-status.py +367 -0
- package/lib/roll_git.py +41 -0
- package/lib/roll_render.py +414 -0
- package/lib/slides/components/README.md +123 -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/introduction-v3.html +571 -0
- package/lib/slides/templates/pitch.html +0 -0
- package/lib/slides-render.py +778 -0
- package/lib/slides-validate.py +357 -0
- package/lib/test_quality_gate.py +143 -0
- package/package.json +8 -7
- package/skills/roll-.changelog/SKILL.md +406 -33
- package/skills/roll-.clarify/SKILL.md +5 -2
- package/skills/roll-.dream/SKILL.md +374 -0
- package/skills/roll-.echo/SKILL.md +5 -2
- package/skills/roll-.qa/SKILL.md +57 -3
- package/skills/roll-.review/SKILL.md +42 -3
- package/skills/roll-brief/SKILL.md +209 -0
- package/skills/roll-build/SKILL.md +308 -63
- package/skills/roll-debug/SKILL.md +341 -162
- package/skills/roll-debug/injectable-bb.js +263 -0
- package/skills/roll-deck/SKILL.md +296 -0
- package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
- package/skills/roll-design/SKILL.md +733 -94
- package/skills/roll-doc/SKILL.md +595 -0
- package/skills/roll-doctor/SKILL.md +192 -0
- package/skills/roll-fix/SKILL.md +149 -32
- package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
- package/skills/roll-loop/SKILL.md +579 -0
- package/skills/roll-notes/SKILL.md +103 -0
- package/skills/roll-onboard/SKILL.md +234 -0
- package/skills/roll-peer/SKILL.md +336 -0
- package/skills/roll-propose/SKILL.md +157 -0
- package/skills/roll-review-pr/SKILL.md +58 -0
- package/skills/roll-sentinel/SKILL.md +11 -2
- package/skills/roll-spar/SKILL.md +8 -6
- package/template/.github/workflows/ci.yml +5 -2
- package/template/AGENTS.md +20 -74
- package/skills/roll-research/SKILL.md +0 -307
- package/skills/roll-research/references/schema.json +0 -162
- package/skills/roll-research/scripts/md_to_pdf.py +0 -289
- package/tools/roll-fetch/SKILL.md +0 -182
- package/tools/roll-fetch/package.json +0 -15
- package/tools/roll-fetch/smart-web-fetch.js +0 -558
- package/tools/roll-probe/SKILL.md +0 -84
- /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
package/lib/loop-fmt.py
ADDED
|
@@ -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()
|