@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/roll +546 -97
  3. package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
  4. package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
  5. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  6. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  7. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  8. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  9. package/lib/changelog_audit.py +155 -0
  10. package/lib/i18n/agent.sh +0 -0
  11. package/lib/i18n/alert.sh +0 -0
  12. package/lib/i18n/backlog.sh +0 -0
  13. package/lib/i18n/brief.sh +0 -0
  14. package/lib/i18n/changelog.sh +0 -0
  15. package/lib/i18n/ci.sh +0 -0
  16. package/lib/i18n/debug.sh +0 -0
  17. package/lib/i18n/doctor.sh +31 -0
  18. package/lib/i18n/dream.sh +0 -0
  19. package/lib/i18n/init.sh +17 -0
  20. package/lib/i18n/lang.sh +0 -0
  21. package/lib/i18n/loop.sh +0 -0
  22. package/lib/i18n/migrate.sh +0 -0
  23. package/lib/i18n/offboard.sh +15 -0
  24. package/lib/i18n/onboard.sh +0 -0
  25. package/lib/i18n/peer.sh +0 -0
  26. package/lib/i18n/propose.sh +0 -0
  27. package/lib/i18n/release.sh +0 -0
  28. package/lib/i18n/research.sh +0 -0
  29. package/lib/i18n/review_pr.sh +0 -0
  30. package/lib/i18n/sentinel.sh +0 -0
  31. package/lib/i18n/setup.sh +0 -0
  32. package/lib/i18n/shared.sh +83 -0
  33. package/lib/i18n/skills/roll-brief.sh +20 -0
  34. package/lib/i18n/skills/roll-build.sh +97 -0
  35. package/lib/i18n/skills/roll-design.sh +18 -0
  36. package/lib/i18n/skills/roll-fix.sh +14 -0
  37. package/lib/i18n/skills/roll-loop.sh +28 -0
  38. package/lib/i18n/skills/roll-onboard.sh +16 -0
  39. package/lib/i18n/slides.sh +0 -0
  40. package/lib/i18n/status.sh +0 -0
  41. package/lib/i18n/update.sh +9 -0
  42. package/lib/i18n.sh +25 -0
  43. package/lib/loop-fmt.py +77 -11
  44. package/lib/loop_unstick.py +180 -0
  45. package/lib/model_prices.py +93 -12
  46. package/lib/prices/snapshot-2026-05-22.json +2 -0
  47. package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
  48. package/lib/prices/snapshot-2026-05-23-kimi.json +14 -0
  49. package/lib/roll-home.py +17 -1
  50. package/lib/roll-loop-status.py +9 -0
  51. package/lib/roll_render.py +10 -2
  52. package/package.json +1 -1
  53. package/skills/roll-.dream/SKILL.md +4 -4
  54. 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 (cost_list_usd, prices_version) from the active price snapshot.
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, cost_list_usd is None.
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
- cost_list_usd, prices_version = self._price_at_snapshot(
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 main():
441
- fmt = LoopFmt()
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
- fmt.process(line)
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())
@@ -1,14 +1,21 @@
1
1
  """
2
- model_prices — list-price table for Anthropic Claude API models.
2
+ model_prices — list-price table for AI model API pricing.
3
3
 
4
- Pricing is per million tokens (MTok), USD. These are the public list rates;
5
- discounts (Pro subscription, prepay credits, etc.) are intentionally not
6
- modeled — IDEA-025 is about cross-account / cross-project comparable cost.
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 the latest one.
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
- _SNAPSHOT: Dict[str, Any] = load_latest_snapshot()
62
- PRICES: Dict[str, Dict[str, float]] = _SNAPSHOT["prices"]
63
- DEFAULT: str = _SNAPSHOT["default_model"]
64
- VERSION: str = _SNAPSHOT["version"]
65
- EFFECTIVE_AT: str = _SNAPSHOT["effective_at"]
66
- SOURCE_URL: str = _SNAPSHOT["source_url"]
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 USD cost at list price for one cycle's token usage."""
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.get("primary_agent") or "claude",
501
+ agent = _resolve_project_agent(config),
486
502
  git_branch = bra,
487
503
  git_status = gs,
488
504
  timestamp = datetime.now().strftime("%H:%M"),
@@ -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
@@ -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"${cy['cost_list']:.2f}"
320
+ cost = f"{symbol}{cy['cost_list']:.2f}"
318
321
  elif cr:
319
- cost = f"${cr.get('cost', 0):.2f}"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.523.2",
3
+ "version": "2026.524.2",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -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 (文档新鲜度). Outputs REFACTOR entries
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 six scans every night. Each scan is independent.
37
+ Run all scans every night. Each scan is independent.
38
38
 
39
39
  ### Scan 1 — Dead Code
40
40