@seanyao/roll 2026.523.2 → 2026.524.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 +28 -0
- package/bin/roll +546 -97
- package/lib/__pycache__/loop-fmt.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__/roll-home.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
- package/lib/changelog_audit.py +155 -0
- package/lib/i18n/agent.sh +0 -0
- package/lib/i18n/alert.sh +0 -0
- package/lib/i18n/backlog.sh +0 -0
- package/lib/i18n/brief.sh +0 -0
- package/lib/i18n/changelog.sh +0 -0
- package/lib/i18n/ci.sh +0 -0
- package/lib/i18n/debug.sh +0 -0
- package/lib/i18n/doctor.sh +31 -0
- package/lib/i18n/dream.sh +0 -0
- package/lib/i18n/init.sh +17 -0
- package/lib/i18n/lang.sh +0 -0
- package/lib/i18n/loop.sh +0 -0
- package/lib/i18n/migrate.sh +0 -0
- package/lib/i18n/offboard.sh +15 -0
- package/lib/i18n/onboard.sh +0 -0
- package/lib/i18n/peer.sh +0 -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 +0 -0
- package/lib/i18n/shared.sh +83 -0
- package/lib/i18n/skills/roll-brief.sh +20 -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 +14 -0
- package/lib/i18n/skills/roll-loop.sh +28 -0
- package/lib/i18n/skills/roll-onboard.sh +16 -0
- package/lib/i18n/slides.sh +0 -0
- package/lib/i18n/status.sh +0 -0
- package/lib/i18n/update.sh +9 -0
- package/lib/i18n.sh +25 -0
- package/lib/loop-fmt.py +77 -11
- package/lib/loop_unstick.py +180 -0
- package/lib/model_prices.py +93 -12
- package/lib/prices/snapshot-2026-05-22.json +2 -0
- package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
- package/lib/prices/snapshot-2026-05-23-kimi.json +14 -0
- package/lib/roll-home.py +17 -1
- package/lib/roll-loop-status.py +9 -0
- package/lib/roll_render.py +10 -2
- package/package.json +1 -1
- package/skills/roll-.dream/SKILL.md +4 -4
- package/skills/roll-loop/SKILL.md +15 -1
package/lib/loop-fmt.py
CHANGED
|
@@ -346,11 +346,11 @@ class LoopFmt:
|
|
|
346
346
|
|
|
347
347
|
@staticmethod
|
|
348
348
|
def _price_at_snapshot(model, totals):
|
|
349
|
-
"""Resolve (
|
|
349
|
+
"""Resolve (cost_list, currency, prices_version) from the active price snapshot.
|
|
350
350
|
|
|
351
|
-
Returns (None, None) when model_prices isn't loadable or the snapshot
|
|
351
|
+
Returns (None, None, None) when model_prices isn't loadable or the snapshot
|
|
352
352
|
has no usable prices — callers still emit the event so token data and
|
|
353
|
-
duration aren't lost. When tokens are all zero,
|
|
353
|
+
duration aren't lost. When tokens are all zero, cost_list is None.
|
|
354
354
|
"""
|
|
355
355
|
try:
|
|
356
356
|
import importlib.util
|
|
@@ -361,11 +361,11 @@ class LoopFmt:
|
|
|
361
361
|
mp = importlib.util.module_from_spec(spec)
|
|
362
362
|
spec.loader.exec_module(mp)
|
|
363
363
|
except Exception:
|
|
364
|
-
return None, None
|
|
364
|
+
return None, None, None
|
|
365
365
|
prices_version = getattr(mp, "VERSION", None)
|
|
366
366
|
has_tokens = any(int(totals.get(k) or 0) > 0 for k in totals)
|
|
367
367
|
if not has_tokens:
|
|
368
|
-
return None, prices_version
|
|
368
|
+
return None, None, prices_version
|
|
369
369
|
try:
|
|
370
370
|
cost = mp.compute_list_cost(
|
|
371
371
|
model,
|
|
@@ -374,9 +374,10 @@ class LoopFmt:
|
|
|
374
374
|
cache_creation_tokens=int(totals.get("cache_creation_tokens") or 0),
|
|
375
375
|
cache_read_tokens=int(totals.get("cache_read_tokens") or 0),
|
|
376
376
|
)
|
|
377
|
+
currency = mp.currency_for(model) if model else "USD"
|
|
377
378
|
except Exception:
|
|
378
|
-
return None, prices_version
|
|
379
|
-
return float(cost), prices_version
|
|
379
|
+
return None, None, prices_version
|
|
380
|
+
return float(cost), currency, prices_version
|
|
380
381
|
|
|
381
382
|
def _emit_usage_event(self, result_ev, dur_ms, cost_usd):
|
|
382
383
|
slug = os.environ.get("LOOP_PROJECT_SLUG")
|
|
@@ -405,7 +406,9 @@ class LoopFmt:
|
|
|
405
406
|
# later prices refresh (or roll upgrade) never rewrites history. The
|
|
406
407
|
# dashboard reads cost_list_usd first; only legacy events without it
|
|
407
408
|
# fall back to recomputing and get tagged [legacy].
|
|
408
|
-
|
|
409
|
+
# FIX-116: also capture cost_currency so the dashboard shows the
|
|
410
|
+
# correct currency symbol (e.g. $ for USD, ¥ for CNY).
|
|
411
|
+
cost_list_usd, cost_currency, prices_version = self._price_at_snapshot(
|
|
409
412
|
model if has_model else None,
|
|
410
413
|
self._usage_totals,
|
|
411
414
|
)
|
|
@@ -419,6 +422,7 @@ class LoopFmt:
|
|
|
419
422
|
"cost_reported_usd": float(cost_usd) if has_cost else None,
|
|
420
423
|
"duration_ms": int(dur_ms) if has_dur else None,
|
|
421
424
|
"cost_list_usd": cost_list_usd,
|
|
425
|
+
"cost_currency": cost_currency,
|
|
422
426
|
"prices_version": prices_version,
|
|
423
427
|
}
|
|
424
428
|
evfile = os.path.join(shared, "loop", f"events-{slug}.ndjson")
|
|
@@ -437,11 +441,73 @@ class LoopFmt:
|
|
|
437
441
|
pass # best-effort; never break tmux output
|
|
438
442
|
|
|
439
443
|
|
|
440
|
-
def
|
|
441
|
-
|
|
444
|
+
def _passthrough_main(agent):
|
|
445
|
+
"""Transparent forwarding for non-claude agents (pi, deepseek, kimi, …).
|
|
446
|
+
|
|
447
|
+
Writes every stdin line to stdout with a HH:MM:SS timestamp prefix so
|
|
448
|
+
tmux shows real-time progress. Also appends each line as a lightweight
|
|
449
|
+
'usage'-type event to the per-slug events ndjson — token / cost fields
|
|
450
|
+
are set to null (agent-specific parsing is out of scope for this US).
|
|
451
|
+
"""
|
|
452
|
+
slug = os.environ.get("LOOP_PROJECT_SLUG")
|
|
453
|
+
cycle = os.environ.get("LOOP_CYCLE_ID")
|
|
454
|
+
shared = os.environ.get("LOOP_SHARED_ROOT") or os.path.expanduser("~/.shared/roll")
|
|
455
|
+
evfile = None
|
|
456
|
+
if slug and cycle:
|
|
457
|
+
evfile = os.path.join(shared, "loop", f"events-{slug}.ndjson")
|
|
458
|
+
try:
|
|
459
|
+
os.makedirs(os.path.dirname(evfile), exist_ok=True)
|
|
460
|
+
except Exception:
|
|
461
|
+
evfile = None
|
|
462
|
+
|
|
442
463
|
for line in sys.stdin:
|
|
443
|
-
|
|
464
|
+
if not line.rstrip():
|
|
465
|
+
continue
|
|
466
|
+
# Timestamp prefix so tmux shows activity (even if agent output has
|
|
467
|
+
# no timestamps of its own).
|
|
468
|
+
ts = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|
469
|
+
out = f"{DARK_GRAY}{ts}{RESET} {line.rstrip()}"
|
|
470
|
+
sys.stdout.write(out + "\n")
|
|
444
471
|
sys.stdout.flush()
|
|
472
|
+
# Emit a lightweight usage event so the cycle has *some* event trace
|
|
473
|
+
# (token/cost are null — parsing those is agent-specific and out of
|
|
474
|
+
# scope for the minimal transparent-passthrough US).
|
|
475
|
+
if evfile:
|
|
476
|
+
_emit_passthrough_event(evfile, cycle, agent, line.rstrip())
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _emit_passthrough_event(evfile, cycle, agent, text):
|
|
480
|
+
"""Best-effort append a usage-type event to evfile."""
|
|
481
|
+
payload = {
|
|
482
|
+
"model": agent,
|
|
483
|
+
"input_tokens": None,
|
|
484
|
+
"output_tokens": None,
|
|
485
|
+
"cost_list_usd": None,
|
|
486
|
+
"duration_ms": None,
|
|
487
|
+
}
|
|
488
|
+
record = json.dumps({
|
|
489
|
+
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
490
|
+
"stage": "usage",
|
|
491
|
+
"label": cycle,
|
|
492
|
+
"detail": payload,
|
|
493
|
+
"outcome": "ok",
|
|
494
|
+
}) + "\n"
|
|
495
|
+
try:
|
|
496
|
+
with open(evfile, "a") as f:
|
|
497
|
+
f.write(record)
|
|
498
|
+
except Exception:
|
|
499
|
+
pass
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def main():
|
|
503
|
+
agent = os.environ.get("ROLL_LOOP_AGENT", "claude")
|
|
504
|
+
if agent == "claude":
|
|
505
|
+
fmt = LoopFmt()
|
|
506
|
+
for line in sys.stdin:
|
|
507
|
+
fmt.process(line)
|
|
508
|
+
sys.stdout.flush()
|
|
509
|
+
else:
|
|
510
|
+
_passthrough_main(agent)
|
|
445
511
|
|
|
446
512
|
if __name__ == "__main__":
|
|
447
513
|
main()
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""FIX-112: revert 🔨 In Progress stories whose latest cycle definitively
|
|
3
|
+
failed and has been quiet for a while. Default safe gate is conservative:
|
|
4
|
+
|
|
5
|
+
- Story row is currently 🔨 In Progress in backlog
|
|
6
|
+
- Most recent `pick_todo <story_id>` event in events-<slug>.ndjson lives in
|
|
7
|
+
a cycle whose `cycle_end` outcome is one of: failed | aborted | blocked
|
|
8
|
+
- That cycle_end timestamp is at least N hours ago (default 4)
|
|
9
|
+
|
|
10
|
+
Stories that match are flipped back to 📋 Todo and an ALERT note is appended
|
|
11
|
+
to the per-project ALERT file. Stories still actively running, or claimed
|
|
12
|
+
by a human / agent for legitimate work (no failed cycle_end), stay alone.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python3 lib/loop_unstick.py # apply (writes backlog + ALERT)
|
|
16
|
+
python3 lib/loop_unstick.py --dry-run # report what would change, write nothing
|
|
17
|
+
python3 lib/loop_unstick.py --ttl-hours 8
|
|
18
|
+
|
|
19
|
+
Returns 0 always (idempotent). Prints one line per reverted story.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
import argparse, json, os, re, sys, time
|
|
23
|
+
from datetime import datetime, timezone, timedelta
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
_LIB_DIR = os.path.dirname(os.path.realpath(__file__))
|
|
27
|
+
if _LIB_DIR not in sys.path:
|
|
28
|
+
sys.path.insert(0, _LIB_DIR)
|
|
29
|
+
|
|
30
|
+
# FIX-108-compatible: accept multi-segment story IDs (US-VIEW-011, US-I18N-001)
|
|
31
|
+
# and alphanumeric segments (K8S, D2, 2FA-ish layouts within rules).
|
|
32
|
+
ID_RE = re.compile(r"^\s*\[?([A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-\d+)")
|
|
33
|
+
TICK = chr(96)
|
|
34
|
+
|
|
35
|
+
def _shared_root() -> Path:
|
|
36
|
+
# bin/roll uses _SHARED_ROOT, lib/roll-home.py uses ROLL_SHARED_ROOT.
|
|
37
|
+
# Honor both so tests that sandbox either name work transparently.
|
|
38
|
+
root = os.environ.get("ROLL_SHARED_ROOT") or os.environ.get("_SHARED_ROOT")
|
|
39
|
+
return Path(root or os.path.expanduser("~/.shared/roll"))
|
|
40
|
+
|
|
41
|
+
def _project_slug() -> str:
|
|
42
|
+
try:
|
|
43
|
+
import subprocess, hashlib
|
|
44
|
+
path = os.path.realpath(os.getcwd())
|
|
45
|
+
common = subprocess.check_output(
|
|
46
|
+
["git", "-C", path, "rev-parse", "--git-common-dir"],
|
|
47
|
+
stderr=subprocess.DEVNULL, text=True,
|
|
48
|
+
).strip()
|
|
49
|
+
if common.endswith("/.git"):
|
|
50
|
+
path = common[:-5]
|
|
51
|
+
except Exception:
|
|
52
|
+
path = os.path.realpath(os.getcwd())
|
|
53
|
+
import hashlib
|
|
54
|
+
base = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(path)).strip("-")
|
|
55
|
+
h = hashlib.md5(path.encode()).hexdigest()[:6]
|
|
56
|
+
return f"{base}-{h}"
|
|
57
|
+
|
|
58
|
+
def _read_events(slug: str) -> list:
|
|
59
|
+
path = _shared_root() / "loop" / f"events-{slug}.ndjson"
|
|
60
|
+
out = []
|
|
61
|
+
if not path.exists():
|
|
62
|
+
return out
|
|
63
|
+
with path.open(errors="ignore") as f:
|
|
64
|
+
for line in f:
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if not line:
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
ev = json.loads(line)
|
|
70
|
+
ts = ev.get("ts", "")
|
|
71
|
+
ev["_ts"] = datetime.fromisoformat(ts.replace("Z", "+00:00")) if ts else None
|
|
72
|
+
out.append(ev)
|
|
73
|
+
except Exception:
|
|
74
|
+
continue
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
def _scan_in_progress(backlog: Path) -> list:
|
|
78
|
+
"""Return list of (line_index, story_id, raw_line) for rows that are 🔨 In Progress."""
|
|
79
|
+
if not backlog.exists():
|
|
80
|
+
return []
|
|
81
|
+
rows = []
|
|
82
|
+
for i, line in enumerate(backlog.open(errors="ignore")):
|
|
83
|
+
if "| 🔨 In Progress |" not in line:
|
|
84
|
+
continue
|
|
85
|
+
if not line.startswith("|"):
|
|
86
|
+
continue
|
|
87
|
+
parts = [p.strip() for p in line.split("|")]
|
|
88
|
+
if len(parts) < 4:
|
|
89
|
+
continue
|
|
90
|
+
m = ID_RE.match(parts[1])
|
|
91
|
+
if not m:
|
|
92
|
+
continue
|
|
93
|
+
rows.append((i, m.group(1), line.rstrip("\n")))
|
|
94
|
+
return rows
|
|
95
|
+
|
|
96
|
+
def _cycle_end_for_pick(events: list, story_id: str):
|
|
97
|
+
"""Return (cycle_end_ts, outcome) of the latest cycle that picked
|
|
98
|
+
story_id, or None if no such cycle / cycle still running."""
|
|
99
|
+
# Walk events back to front looking for the latest pick_todo matching story_id
|
|
100
|
+
latest_pick = None
|
|
101
|
+
for ev in reversed(events):
|
|
102
|
+
if ev.get("stage") == "pick_todo" and ev.get("detail") == story_id:
|
|
103
|
+
latest_pick = ev
|
|
104
|
+
break
|
|
105
|
+
if not latest_pick:
|
|
106
|
+
return None
|
|
107
|
+
label = latest_pick.get("label", "")
|
|
108
|
+
# Look forward (from the pick) for cycle_end with the same label
|
|
109
|
+
pick_idx = events.index(latest_pick)
|
|
110
|
+
for ev in events[pick_idx + 1:]:
|
|
111
|
+
if ev.get("stage") == "cycle_end" and ev.get("label", "").endswith(label):
|
|
112
|
+
return ev.get("_ts"), ev.get("outcome", "")
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def main():
|
|
116
|
+
ap = argparse.ArgumentParser()
|
|
117
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
118
|
+
ap.add_argument("--ttl-hours", type=float, default=4.0,
|
|
119
|
+
help="Minimum hours since failed cycle_end before reverting (default 4)")
|
|
120
|
+
ap.add_argument("--backlog", default=".roll/backlog.md")
|
|
121
|
+
args = ap.parse_args()
|
|
122
|
+
|
|
123
|
+
backlog = Path(args.backlog)
|
|
124
|
+
if not backlog.exists():
|
|
125
|
+
print(f"backlog not found: {backlog}", file=sys.stderr)
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
slug = _project_slug()
|
|
129
|
+
events = _read_events(slug)
|
|
130
|
+
in_progress = _scan_in_progress(backlog)
|
|
131
|
+
if not in_progress:
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
now = datetime.now(timezone.utc)
|
|
135
|
+
cutoff = now - timedelta(hours=args.ttl_hours)
|
|
136
|
+
candidates_to_revert = []
|
|
137
|
+
|
|
138
|
+
failed_outcomes = {"failed", "aborted", "blocked"}
|
|
139
|
+
for line_idx, sid, raw in in_progress:
|
|
140
|
+
result = _cycle_end_for_pick(events, sid)
|
|
141
|
+
if not result:
|
|
142
|
+
continue # still running OR no failed cycle yet — leave alone
|
|
143
|
+
end_ts, outcome = result
|
|
144
|
+
if outcome not in failed_outcomes:
|
|
145
|
+
continue
|
|
146
|
+
if not end_ts or end_ts > cutoff:
|
|
147
|
+
continue # too recent
|
|
148
|
+
age_hours = (now - end_ts).total_seconds() / 3600
|
|
149
|
+
candidates_to_revert.append((line_idx, sid, raw, outcome, age_hours))
|
|
150
|
+
|
|
151
|
+
if not candidates_to_revert:
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
if args.dry_run:
|
|
155
|
+
for line_idx, sid, raw, outcome, age in candidates_to_revert:
|
|
156
|
+
print(f"would-revert {sid} (cycle ended {outcome} {age:.1f}h ago)")
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
# Apply: read backlog, flip status, write back.
|
|
160
|
+
lines = backlog.read_text(errors="ignore").splitlines(keepends=True)
|
|
161
|
+
for line_idx, sid, raw, outcome, age in candidates_to_revert:
|
|
162
|
+
lines[line_idx] = lines[line_idx].replace("| 🔨 In Progress |", "| 📋 Todo |")
|
|
163
|
+
|
|
164
|
+
backlog.write_text("".join(lines))
|
|
165
|
+
|
|
166
|
+
# Append ALERT
|
|
167
|
+
alert_file = _shared_root() / "loop" / f"ALERT-{slug}.md"
|
|
168
|
+
alert_file.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
with alert_file.open("a") as f:
|
|
170
|
+
for line_idx, sid, raw, outcome, age in candidates_to_revert:
|
|
171
|
+
ts = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
172
|
+
f.write(f"[{ts}] unstick: reverted {sid} (cycle ended {outcome} {age:.1f}h ago, > {args.ttl_hours}h TTL)\n")
|
|
173
|
+
|
|
174
|
+
for line_idx, sid, raw, outcome, age in candidates_to_revert:
|
|
175
|
+
print(f"reverted {sid} (cycle ended {outcome} {age:.1f}h ago)")
|
|
176
|
+
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
sys.exit(main())
|
package/lib/model_prices.py
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
"""
|
|
2
|
-
model_prices — list-price table for
|
|
2
|
+
model_prices — list-price table for AI model API pricing.
|
|
3
3
|
|
|
4
|
-
Pricing is per million tokens (MTok),
|
|
5
|
-
discounts (Pro subscription, prepay
|
|
6
|
-
modeled — IDEA-025 is about
|
|
4
|
+
Pricing is per million tokens (MTok), in the vendor's native currency.
|
|
5
|
+
These are the public list rates; discounts (Pro subscription, prepay
|
|
6
|
+
credits, etc.) are intentionally not modeled — IDEA-025 is about
|
|
7
|
+
cross-account / cross-project comparable cost.
|
|
7
8
|
|
|
8
9
|
US-VIEW-013: prices are no longer hardcoded here. They live in versioned
|
|
9
10
|
snapshot files under ``lib/prices/snapshot-YYYY-MM-DD.json`` and are loaded
|
|
10
11
|
at module import time. ``roll prices refresh`` produces new snapshots; this
|
|
11
|
-
module never writes — it only loads
|
|
12
|
+
module never writes — it only loads all snapshots and merges them.
|
|
13
|
+
|
|
14
|
+
FIX-116: multi-vendor support — snapshots carry ``vendor`` and ``currency``
|
|
15
|
+
fields. All snapshots are loaded and merged into a single PRICES map, with
|
|
16
|
+
each model entry carrying its native ``currency``. Vendor-prefixed model
|
|
17
|
+
names (``deepseek/deepseek-chat``) are resolved by stripping the vendor
|
|
18
|
+
segment when no exact match exists.
|
|
12
19
|
|
|
13
20
|
Unknown models fall back to the snapshot's ``default_model`` with a stderr
|
|
14
21
|
warning so dashboards don't blank out.
|
|
@@ -45,6 +52,8 @@ def load_snapshot(path: str) -> Dict[str, Any]:
|
|
|
45
52
|
if not isinstance(data["prices"], dict) or not data["prices"]:
|
|
46
53
|
raise ValueError(f"snapshot {path!r} has empty or invalid prices map")
|
|
47
54
|
data.setdefault("default_model", next(iter(data["prices"])))
|
|
55
|
+
data.setdefault("vendor", "anthropic")
|
|
56
|
+
data.setdefault("currency", "USD")
|
|
48
57
|
return data
|
|
49
58
|
|
|
50
59
|
|
|
@@ -58,12 +67,34 @@ def load_latest_snapshot(snapshot_dir: str = SNAPSHOT_DIR) -> Dict[str, Any]:
|
|
|
58
67
|
return load_snapshot(snaps[-1])
|
|
59
68
|
|
|
60
69
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
def load_all_snapshots(snapshot_dir: str = SNAPSHOT_DIR) -> List[Dict[str, Any]]:
|
|
71
|
+
"""Load all snapshots, sorted oldest → newest. Raises FileNotFoundError if none."""
|
|
72
|
+
snaps = list_snapshots(snapshot_dir)
|
|
73
|
+
if not snaps:
|
|
74
|
+
raise FileNotFoundError(
|
|
75
|
+
f"no price snapshots found in {snapshot_dir}; run `roll prices refresh`"
|
|
76
|
+
)
|
|
77
|
+
return [load_snapshot(p) for p in snaps]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_SNAPSHOTS: List[Dict[str, Any]] = load_all_snapshots()
|
|
81
|
+
_DEFAULT_SNAP: Dict[str, Any] = _SNAPSHOTS[-1]
|
|
82
|
+
|
|
83
|
+
# Merge PRICES from all snapshots, injecting currency per model.
|
|
84
|
+
# Later snapshots override earlier ones for the same model name.
|
|
85
|
+
PRICES: Dict[str, Dict[str, float]] = {}
|
|
86
|
+
_CURRENCY: Dict[str, str] = {}
|
|
87
|
+
for _snap in _SNAPSHOTS:
|
|
88
|
+
_snap_currency = _snap.get("currency", "USD")
|
|
89
|
+
for _model, _rates in _snap["prices"].items():
|
|
90
|
+
PRICES[_model] = dict(_rates)
|
|
91
|
+
PRICES[_model]["currency"] = _snap_currency
|
|
92
|
+
_CURRENCY[_model] = _snap_currency
|
|
93
|
+
|
|
94
|
+
DEFAULT: str = _DEFAULT_SNAP["default_model"]
|
|
95
|
+
VERSION: str = _DEFAULT_SNAP["version"]
|
|
96
|
+
EFFECTIVE_AT: str = _DEFAULT_SNAP["effective_at"]
|
|
97
|
+
SOURCE_URL: str = _DEFAULT_SNAP["source_url"]
|
|
67
98
|
|
|
68
99
|
_warned: set = set()
|
|
69
100
|
|
|
@@ -80,9 +111,20 @@ def _resolve(model: Optional[str], prices: Optional[Dict[str, Dict[str, float]]]
|
|
|
80
111
|
if not model:
|
|
81
112
|
return table[fallback]
|
|
82
113
|
base = model.split("[")[0].rstrip("0123456789-")
|
|
114
|
+
|
|
115
|
+
# Direct match: model starts with a known key
|
|
83
116
|
candidates = [k for k in table if model.startswith(k) or base.startswith(k)]
|
|
84
117
|
if candidates:
|
|
85
118
|
return table[max(candidates, key=len)]
|
|
119
|
+
|
|
120
|
+
# Vendor prefix: try stripping "vendor/" segment for proxy tools (pi, etc.)
|
|
121
|
+
if "/" in model:
|
|
122
|
+
inner = model.split("/", 1)[1]
|
|
123
|
+
inner_base = inner.split("[")[0].rstrip("0123456789-")
|
|
124
|
+
for k in table:
|
|
125
|
+
if inner == k or inner_base == k or inner.startswith(k) or inner_base.startswith(k):
|
|
126
|
+
return table[k]
|
|
127
|
+
|
|
86
128
|
if model not in _warned:
|
|
87
129
|
_warned.add(model)
|
|
88
130
|
print(f"[model_prices] warn: unknown model {model!r}, falling back to {fallback}",
|
|
@@ -90,6 +132,45 @@ def _resolve(model: Optional[str], prices: Optional[Dict[str, Dict[str, float]]]
|
|
|
90
132
|
return table[fallback]
|
|
91
133
|
|
|
92
134
|
|
|
135
|
+
def _resolve_name(model: Optional[str],
|
|
136
|
+
prices: Optional[Dict[str, Dict[str, float]]] = None,
|
|
137
|
+
default: Optional[str] = None) -> str:
|
|
138
|
+
"""Return the canonical model name (key in PRICES) for a given model string.
|
|
139
|
+
|
|
140
|
+
Same resolution logic as _resolve, but returns the matched key name
|
|
141
|
+
instead of the rate dict. Used by currency_for() to find the currency.
|
|
142
|
+
"""
|
|
143
|
+
table = prices if prices is not None else PRICES
|
|
144
|
+
fallback = default if default is not None else DEFAULT
|
|
145
|
+
if not model:
|
|
146
|
+
return fallback
|
|
147
|
+
base = model.split("[")[0].rstrip("0123456789-")
|
|
148
|
+
|
|
149
|
+
# Direct match: model starts with a known key
|
|
150
|
+
candidates = [k for k in table if model.startswith(k) or base.startswith(k)]
|
|
151
|
+
if candidates:
|
|
152
|
+
return max(candidates, key=len)
|
|
153
|
+
|
|
154
|
+
# Vendor prefix: try stripping "vendor/" segment
|
|
155
|
+
if "/" in model:
|
|
156
|
+
inner = model.split("/", 1)[1]
|
|
157
|
+
inner_base = inner.split("[")[0].rstrip("0123456789-")
|
|
158
|
+
for k in table:
|
|
159
|
+
if inner == k or inner_base == k or inner.startswith(k) or inner_base.startswith(k):
|
|
160
|
+
return k
|
|
161
|
+
|
|
162
|
+
return fallback
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def currency_for(model: Optional[str]) -> str:
|
|
166
|
+
"""Return the native currency code (USD/CNY) for a model.
|
|
167
|
+
|
|
168
|
+
Falls back to 'USD' when the model isn't in any snapshot.
|
|
169
|
+
"""
|
|
170
|
+
name = _resolve_name(model)
|
|
171
|
+
return _CURRENCY.get(name, "USD")
|
|
172
|
+
|
|
173
|
+
|
|
93
174
|
def compute_list_cost(model: Optional[str],
|
|
94
175
|
*,
|
|
95
176
|
input_tokens: int = 0,
|
|
@@ -98,7 +179,7 @@ def compute_list_cost(model: Optional[str],
|
|
|
98
179
|
cache_read_tokens: int = 0,
|
|
99
180
|
prices: Optional[Dict[str, Dict[str, float]]] = None,
|
|
100
181
|
default: Optional[str] = None) -> float:
|
|
101
|
-
"""Return
|
|
182
|
+
"""Return cost (in native currency) at list price for one cycle's token usage."""
|
|
102
183
|
p = _resolve(model, prices=prices, default=default)
|
|
103
184
|
total = (input_tokens * p["in"]
|
|
104
185
|
+ output_tokens * p["out"]
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
"version": "2026-05-22",
|
|
3
3
|
"effective_at": "2026-05-22",
|
|
4
4
|
"source_url": "https://platform.claude.com/docs/en/about-claude/pricing",
|
|
5
|
+
"vendor": "anthropic",
|
|
6
|
+
"currency": "USD",
|
|
5
7
|
"default_model": "claude-sonnet-4-6",
|
|
6
8
|
"notes": "Rates per million tokens (USD). cache_create = 5-minute cache write (1.25x input). 1-hour cache writes (2x input) are not modeled — Roll loop uses the default 5m caching only. 2026-05 repricing: Opus 4.5+ moved to $5/$25 base (3x cheaper than Opus 4/4.1).",
|
|
7
9
|
"prices": {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2026-05-23",
|
|
3
|
+
"effective_at": "2026-05-23",
|
|
4
|
+
"source_url": "https://api-docs.deepseek.com/quick_start/pricing",
|
|
5
|
+
"vendor": "deepseek",
|
|
6
|
+
"currency": "USD",
|
|
7
|
+
"default_model": "deepseek-chat",
|
|
8
|
+
"notes": "Rates per million tokens (USD). deepseek-chat and deepseek-reasoner are both deepseek-v4-flash with different thinking modes — same pricing. deepseek-v4-pro has 75% promotional discount until 2026-05-31. cache_create estimated at 1.25x input (same convention as Claude snapshot). cache_hit discount per official docs: 1/50 for flash ($0.14→$0.0028), 1/120 for pro ($0.435→$0.003625). Actual cache_create not published by DeepSeek — using 1.25x input convention.",
|
|
9
|
+
"prices": {
|
|
10
|
+
"deepseek-chat": {"in": 0.14, "out": 0.28, "cache_create": 0.175, "cache_read": 0.0028},
|
|
11
|
+
"deepseek-reasoner": {"in": 0.14, "out": 0.28, "cache_create": 0.175, "cache_read": 0.0028},
|
|
12
|
+
"deepseek-v4-flash": {"in": 0.14, "out": 0.28, "cache_create": 0.175, "cache_read": 0.0028},
|
|
13
|
+
"deepseek-v4-pro": {"in": 0.435, "out": 0.87, "cache_create": 0.54375, "cache_read": 0.003625}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2026-05-23",
|
|
3
|
+
"effective_at": "2026-05-23",
|
|
4
|
+
"source_url": "https://platform.kimi.com/docs/pricing/chat",
|
|
5
|
+
"vendor": "kimi",
|
|
6
|
+
"currency": "USD",
|
|
7
|
+
"default_model": "kimi-k2.5",
|
|
8
|
+
"notes": "Rates per million tokens (USD). cache_create estimated at 1.25x input. Prices from public Kimi API platform docs — verify with `roll prices refresh` if page layout changes. Model names: kimi-k2 (prior gen), kimi-k2.5 (current), kimi-k2.6 (latest).",
|
|
9
|
+
"prices": {
|
|
10
|
+
"kimi-k2": {"in": 1.00, "out": 4.00, "cache_create": 1.25, "cache_read": 0.25},
|
|
11
|
+
"kimi-k2.5": {"in": 1.00, "out": 4.00, "cache_create": 1.25, "cache_read": 0.25},
|
|
12
|
+
"kimi-k2.6": {"in": 1.00, "out": 4.00, "cache_create": 1.25, "cache_read": 0.25}
|
|
13
|
+
}
|
|
14
|
+
}
|
package/lib/roll-home.py
CHANGED
|
@@ -76,6 +76,22 @@ def _load_config() -> Dict[str, str]:
|
|
|
76
76
|
return d
|
|
77
77
|
return {}
|
|
78
78
|
|
|
79
|
+
|
|
80
|
+
def _resolve_project_agent(config: Dict[str, str]) -> str:
|
|
81
|
+
"""FIX-117: home banner agent label must honor project-level override
|
|
82
|
+
in `.roll/local.yaml` (set by `roll agent use`), not just the global
|
|
83
|
+
`~/.roll/config.yaml#primary_agent`. Mirror bin/roll _project_agent
|
|
84
|
+
precedence: local.yaml > .roll.yaml > config.primary_agent > 'claude'."""
|
|
85
|
+
for path in (
|
|
86
|
+
Path(".roll/local.yaml"),
|
|
87
|
+
Path(".roll.yaml"),
|
|
88
|
+
):
|
|
89
|
+
if path.exists():
|
|
90
|
+
local = _load_yaml_flat(path)
|
|
91
|
+
if local.get("agent"):
|
|
92
|
+
return local["agent"]
|
|
93
|
+
return config.get("primary_agent") or "claude"
|
|
94
|
+
|
|
79
95
|
def _git_info() -> Tuple[str, str]:
|
|
80
96
|
try:
|
|
81
97
|
branch = subprocess.check_output(
|
|
@@ -482,7 +498,7 @@ def main() -> None:
|
|
|
482
498
|
d = dict(
|
|
483
499
|
project_name = os.path.basename(os.getcwd()),
|
|
484
500
|
version = _roll_version(),
|
|
485
|
-
agent = config
|
|
501
|
+
agent = _resolve_project_agent(config),
|
|
486
502
|
git_branch = bra,
|
|
487
503
|
git_status = gs,
|
|
488
504
|
timestamp = datetime.now().strftime("%H:%M"),
|
package/lib/roll-loop-status.py
CHANGED
|
@@ -231,6 +231,12 @@ def aggregate(events: List[Dict[str, Any]], cron: List[Dict[str, Any]]) -> List[
|
|
|
231
231
|
sid = _extract_story_id(detail)
|
|
232
232
|
if sid:
|
|
233
233
|
cy["story"] = sid
|
|
234
|
+
elif stage == "agent_used":
|
|
235
|
+
# FIX-119: non-claude agents don't expose model in stream-json.
|
|
236
|
+
# The inner runner emits an agent_used event with the agent name
|
|
237
|
+
# so the dashboard can show it when cy["model"] is None.
|
|
238
|
+
if detail:
|
|
239
|
+
cy["agent"] = detail
|
|
234
240
|
elif stage == "usage":
|
|
235
241
|
# US-LOOP-004: loop-fmt emits this with full token / cost data.
|
|
236
242
|
# Detail is a dict (not the legacy string form).
|
|
@@ -373,6 +379,7 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
|
|
|
373
379
|
persisted = ue.get("cost_list_usd")
|
|
374
380
|
if persisted is not None:
|
|
375
381
|
cy["cost_list"] = float(persisted)
|
|
382
|
+
cy["cost_currency"] = ue.get("cost_currency") or "USD"
|
|
376
383
|
cy["cost_list_legacy"] = False
|
|
377
384
|
else:
|
|
378
385
|
cy["cost_list"] = mp.compute_list_cost(
|
|
@@ -382,6 +389,7 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
|
|
|
382
389
|
cache_creation_tokens=ue.get("cache_creation_tokens", 0),
|
|
383
390
|
cache_read_tokens=ue.get("cache_read_tokens", 0),
|
|
384
391
|
)
|
|
392
|
+
cy["cost_currency"] = mp.currency_for(ue.get("model")) or "USD"
|
|
385
393
|
cy["cost_list_legacy"] = True
|
|
386
394
|
if ue.get("duration_ms") and not cy.get("duration_s"):
|
|
387
395
|
cy["duration_s"] = int(ue["duration_ms"] / 1000)
|
|
@@ -404,6 +412,7 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
|
|
|
404
412
|
cache_creation_tokens=u["cache_creation_tokens"],
|
|
405
413
|
cache_read_tokens=u["cache_read_tokens"],
|
|
406
414
|
)
|
|
415
|
+
cy["cost_currency"] = mp.currency_for(u["model"]) or "USD"
|
|
407
416
|
# US-VIEW-014: session salvage never has a frozen cycle_end cost, so
|
|
408
417
|
# this path is always legacy.
|
|
409
418
|
cy["cost_list_legacy"] = True
|
package/lib/roll_render.py
CHANGED
|
@@ -313,10 +313,13 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
|
|
|
313
313
|
tok = f"{fmt_tokens(inp)}/{fmt_tokens(out_tok)}"
|
|
314
314
|
# cost prefers the backfilled list-price; falls back to cron.log when
|
|
315
315
|
# the claude session log isn't available (only the latest cycle).
|
|
316
|
+
# FIX-116: use the model's native currency symbol.
|
|
317
|
+
cur = cy.get("cost_currency", "USD")
|
|
318
|
+
symbol = "¥" if cur == "CNY" else "$"
|
|
316
319
|
if cy.get("cost_list") is not None:
|
|
317
|
-
cost = f"
|
|
320
|
+
cost = f"{symbol}{cy['cost_list']:.2f}"
|
|
318
321
|
elif cr:
|
|
319
|
-
cost = f"
|
|
322
|
+
cost = f"{symbol}{cr.get('cost', 0):.2f}"
|
|
320
323
|
else:
|
|
321
324
|
cost = "—"
|
|
322
325
|
sid = cy.get("story") or "—"
|
|
@@ -333,6 +336,11 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
|
|
|
333
336
|
sid_c = "red" if outcome == "fail" else "blue"
|
|
334
337
|
|
|
335
338
|
model_label = fmt_model(cy.get("model"))
|
|
339
|
+
# FIX-119: fall back to cy["agent"] (from agent_used event) when model
|
|
340
|
+
# is unknown — non-claude agents (pi, deepseek, kimi) don't expose model
|
|
341
|
+
# info in stream-json, leaving a "—" or "?" on the dashboard.
|
|
342
|
+
if model_label in ("—", "?") and cy.get("agent"):
|
|
343
|
+
model_label = cy["agent"]
|
|
336
344
|
# Auto-hide model column on narrow screens — keeps the dashboard readable
|
|
337
345
|
# when terminal is < 100 cols (cost / story IDs are higher-priority).
|
|
338
346
|
show_model = COLS >= 100
|
package/package.json
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
hidden: true
|
|
3
3
|
name: roll-.dream
|
|
4
4
|
license: MIT
|
|
5
|
-
allowed-tools: "Read, Glob, Grep, Bash(git:*), Write, Edit"
|
|
5
|
+
allowed-tools: "Read, Glob, Grep, Bash(git:*, curl:*, claude:*), Write, Edit"
|
|
6
6
|
description: |
|
|
7
7
|
Nightly code and architecture health scan. Passively triggered by scheduler
|
|
8
8
|
(cron or GitHub Actions), not invoked by users directly. Detects dead code,
|
|
9
9
|
architectural drift from domain model, pruning candidates, emerging patterns,
|
|
10
|
-
doc coverage gaps, and doc staleness (文档新鲜度).
|
|
11
|
-
to .roll/backlog.md and a daily log to .roll/dream/.
|
|
10
|
+
doc coverage gaps, and doc staleness (文档新鲜度).
|
|
11
|
+
Outputs REFACTOR entries to .roll/backlog.md and a daily log to .roll/dream/.
|
|
12
12
|
Distinct from roll-sentinel: sentinel monitors runtime behavior; dream reviews
|
|
13
13
|
code structure and architectural health.
|
|
14
14
|
---
|
|
@@ -34,7 +34,7 @@ in the morning brief.
|
|
|
34
34
|
|
|
35
35
|
## Scan Logic
|
|
36
36
|
|
|
37
|
-
Run all
|
|
37
|
+
Run all scans every night. Each scan is independent.
|
|
38
38
|
|
|
39
39
|
### Scan 1 — Dead Code
|
|
40
40
|
|