@seanyao/roll 2026.526.1 → 2026.528.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ backfill-pi-usage — one-time, idempotent recovery of pi/deepseek token+cost
4
+ into an existing loop events file.
5
+
6
+ Why this exists
7
+ ---------------
8
+ Before US-LOOP-026 the loop ran pi via ``pi -p`` (text mode), which prints no
9
+ usage. loop-fmt's old passthrough still appended a ``stage=="usage"`` event on
10
+ every retry attempt, each with ``model=="pi"`` and null tokens — so a single
11
+ cycle accumulated up to ~180 empty usage events. The dashboard SUMS token
12
+ fields across same-label usage events; with all-null tokens the SUM was 0
13
+ (harmless), but it means every affected cycle shows ``—/—``.
14
+
15
+ pi persists every session to ``~/.pi/agent/sessions/<enc-cwd>/<ts>_<uuid>.jsonl``
16
+ with real per-message usage. This script recovers that, and rewrites the events
17
+ file so each affected cycle is left with **exactly one** authoritative usage
18
+ event (real tokens, cost frozen in native CNY) — collapsing the N null events to
19
+ avoid the dashboard ×N inflation.
20
+
21
+ Safety / idempotency
22
+ --------------------
23
+ - Backs up the events file to ``<file>.bak-<UTC>`` first; aborts if backup fails.
24
+ - Only touches labels whose usage events are all pi-vendor (``model`` in
25
+ {"pi", "deepseek-v4-pro"}) AND carry null tokens AND match a pi session.
26
+ claude cycles, already-real cycles, and unmatched-null cycles are passed
27
+ through untouched.
28
+ - Re-runnable: once a label has a real-token usage event it is no longer a
29
+ candidate, so a second run is a no-op.
30
+ - FIX-065 tripwire: refuses to rewrite a production ``~/.shared/roll`` events
31
+ file from a test context (BATS / temp cwd) unless HOME itself is sandboxed.
32
+
33
+ Usage
34
+ -----
35
+ python3 lib/backfill-pi-usage.py --slug roll-ecf079
36
+ python3 lib/backfill-pi-usage.py --events /path/to/events.ndjson --dry-run
37
+ """
38
+
39
+ import argparse
40
+ import importlib.util
41
+ import json
42
+ import os
43
+ import shutil
44
+ import sys
45
+ from datetime import datetime, timezone
46
+
47
+ _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
48
+
49
+ PI_VENDOR_MODELS = ("pi", "deepseek-v4-pro")
50
+
51
+
52
+ def _load_pi_emit():
53
+ spec = importlib.util.spec_from_file_location(
54
+ "pi_emit", os.path.join(_THIS_DIR, "agent_usage", "pi_emit.py")
55
+ )
56
+ m = importlib.util.module_from_spec(spec)
57
+ spec.loader.exec_module(m)
58
+ return m
59
+
60
+
61
+ def _default_events_path(slug, shared=None):
62
+ base = shared or os.environ.get("LOOP_SHARED_ROOT") \
63
+ or os.path.expanduser("~/.shared/roll")
64
+ return os.path.join(base, "loop", "events-%s.ndjson" % slug)
65
+
66
+
67
+ def _is_test_context():
68
+ return bool(os.environ.get("BATS_TEST_FILENAME")) or _cwd_is_temp()
69
+
70
+
71
+ def _cwd_is_temp():
72
+ p = os.environ.get("PWD") or os.getcwd()
73
+ return any(seg in p for seg in ("/tmp/", "/private/tmp/", "/var/folders/", "/tmp."))
74
+
75
+
76
+ def _home_is_sandbox():
77
+ home = os.environ.get("HOME") or ""
78
+ return any(seg in home for seg in ("/tmp/", "/private/tmp/", "/var/folders/", "/tmp."))
79
+
80
+
81
+ def _tripwire(evfile):
82
+ """FIX-065: refuse a prod write from a test context."""
83
+ home = os.environ.get("HOME") or ""
84
+ if not home or _home_is_sandbox():
85
+ return
86
+ prod = os.path.join(home, ".shared", "roll") + os.sep
87
+ if os.path.abspath(evfile).startswith(os.path.abspath(prod)) and _is_test_context():
88
+ raise SystemExit(
89
+ "[FIX-065] refusing to rewrite prod events file from test context: %s" % evfile
90
+ )
91
+
92
+
93
+ def _scan(lines):
94
+ """Parse lines → (events_or_None list, per-label usage summary).
95
+
96
+ Returns (parsed, labels) where parsed is a list of (raw_line, obj_or_None)
97
+ preserving order, and labels maps label → {"pi": bool, "real": bool}.
98
+ """
99
+ parsed = []
100
+ labels = {}
101
+ for raw in lines:
102
+ obj = None
103
+ try:
104
+ obj = json.loads(raw)
105
+ except (ValueError, TypeError):
106
+ obj = None
107
+ parsed.append((raw, obj))
108
+ if not obj or obj.get("stage") != "usage":
109
+ continue
110
+ lab = obj.get("label")
111
+ d = obj.get("detail") or {}
112
+ rec = labels.setdefault(lab, {"pi": False, "real": False})
113
+ if d.get("model") in PI_VENDOR_MODELS:
114
+ rec["pi"] = True
115
+ if d.get("input_tokens"):
116
+ rec["real"] = True
117
+ return parsed, labels
118
+
119
+
120
+ def backfill(evfile, slug=None, shared=None, base_dir=None, dry_run=False):
121
+ """Rewrite evfile so each recoverable pi cycle nets one real usage event.
122
+
123
+ Returns a stats dict.
124
+ """
125
+ _tripwire(evfile)
126
+ pi_emit = _load_pi_emit()
127
+
128
+ with open(evfile) as f:
129
+ lines = f.readlines()
130
+
131
+ parsed, labels = _scan(lines)
132
+
133
+ # Candidate = pi-vendor, all-null, and a session match yields real usage.
134
+ candidates = [l for l, r in labels.items() if r["pi"] and not r["real"]]
135
+ replacement = {} # label -> detail payload
136
+ matched, unmatched = [], []
137
+ for lab in candidates:
138
+ cwd = os.path.join(
139
+ (shared or os.environ.get("LOOP_SHARED_ROOT")
140
+ or os.path.expanduser("~/.shared/roll")),
141
+ "worktrees", "%s-cycle-%s" % (slug, lab),
142
+ )
143
+ ev = pi_emit.build_event(cwd=cwd, cycle_id=lab, slug=slug, base_dir=base_dir)
144
+ if ev is None:
145
+ unmatched.append(lab)
146
+ continue
147
+ replacement[lab] = ev["detail"]
148
+ matched.append(lab)
149
+
150
+ if dry_run or not matched:
151
+ # Nothing recoverable to rewrite → no backup, no write (keeps re-runs
152
+ # a true no-op instead of spawning empty .bak files).
153
+ return {
154
+ "candidates": len(candidates),
155
+ "matched": len(matched),
156
+ "unmatched": len(unmatched),
157
+ "matched_labels": sorted(matched),
158
+ "unmatched_labels": sorted(unmatched),
159
+ "written": False,
160
+ }
161
+
162
+ # Backup before any write; abort the whole run if backup fails.
163
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
164
+ bak = "%s.bak-%s" % (evfile, stamp)
165
+ shutil.copy2(evfile, bak)
166
+
167
+ # Stream rewrite: for an affected label, the FIRST usage line becomes the
168
+ # real event (original ts preserved so it stays in its day bucket), every
169
+ # subsequent same-label usage line is dropped → exactly one per label.
170
+ emitted = set()
171
+ out = []
172
+ for raw, obj in parsed:
173
+ if not obj or obj.get("stage") != "usage":
174
+ out.append(raw)
175
+ continue
176
+ lab = obj.get("label")
177
+ if lab not in replacement:
178
+ out.append(raw) # claude / already-real / unmatched-null: untouched
179
+ continue
180
+ if lab in emitted:
181
+ continue # collapse the remaining null duplicates away
182
+ new_ev = {
183
+ "ts": obj.get("ts"),
184
+ "stage": "usage",
185
+ "label": lab,
186
+ "detail": replacement[lab],
187
+ "outcome": "ok",
188
+ }
189
+ out.append(json.dumps(new_ev) + "\n")
190
+ emitted.add(lab)
191
+
192
+ tmp = evfile + ".tmp-%s" % stamp
193
+ with open(tmp, "w") as f:
194
+ f.writelines(out)
195
+ os.replace(tmp, evfile)
196
+
197
+ return {
198
+ "candidates": len(candidates),
199
+ "matched": len(matched),
200
+ "unmatched": len(unmatched),
201
+ "matched_labels": sorted(matched),
202
+ "unmatched_labels": sorted(unmatched),
203
+ "backup": bak,
204
+ "written": True,
205
+ }
206
+
207
+
208
+ def main(argv=None):
209
+ ap = argparse.ArgumentParser(description="backfill pi/deepseek usage into events file")
210
+ ap.add_argument("--slug", help="project slug (resolves default events path + session cwd)")
211
+ ap.add_argument("--events", help="explicit events file path (overrides --slug default)")
212
+ ap.add_argument("--shared", help="shared root (default ~/.shared/roll)")
213
+ ap.add_argument("--base-dir", help="pi sessions root override (tests)")
214
+ ap.add_argument("--dry-run", action="store_true", help="report only, write nothing")
215
+ args = ap.parse_args(argv)
216
+
217
+ evfile = args.events or _default_events_path(args.slug, args.shared)
218
+ if not os.path.isfile(evfile):
219
+ print("[backfill] no events file: %s" % evfile, file=sys.stderr)
220
+ return 1
221
+ if not args.slug:
222
+ # slug is needed to reconstruct session cwd; derive from filename.
223
+ base = os.path.basename(evfile)
224
+ if base.startswith("events-") and base.endswith(".ndjson"):
225
+ args.slug = base[len("events-"):-len(".ndjson")]
226
+
227
+ stats = backfill(
228
+ evfile, slug=args.slug, shared=args.shared,
229
+ base_dir=args.base_dir, dry_run=args.dry_run,
230
+ )
231
+ mode = "DRY-RUN" if args.dry_run else "WROTE"
232
+ print("[backfill] %s %s" % (mode, evfile))
233
+ print(" candidates=%d matched=%d unmatched=%d"
234
+ % (stats["candidates"], stats["matched"], stats["unmatched"]))
235
+ if stats.get("backup"):
236
+ print(" backup=%s" % stats["backup"])
237
+ if stats["unmatched_labels"]:
238
+ print(" unmatched (left as null): %s" % ", ".join(stats["unmatched_labels"]))
239
+ return 0
240
+
241
+
242
+ if __name__ == "__main__":
243
+ sys.exit(main())
package/lib/i18n.sh CHANGED
@@ -15,31 +15,19 @@
15
15
  # > (macOS) AppleLanguages > 'en'.
16
16
  # Decision: value starting with `zh` → "zh", everything else → "en".
17
17
 
18
- # Sanitize a free-form key into a variable-safe suffix. Anything that isn't a
19
- # letter, digit, or underscore becomes an underscore so callers can use natural
20
- # dotted keys like "loop.cycle_start" without exploding bash syntax.
21
- _i18n_safe_key() {
22
- echo "${1//[^A-Za-z0-9_]/_}"
23
- }
24
-
25
- # Uppercase a lang code without forking for the EN/ZH common case.
26
- # macOS still ships bash 3.2 which doesn't have ${var^^}; this helper keeps
27
- # the no-subshell goal for the languages we actually ship.
28
- _i18n_upper() {
29
- case "$1" in
30
- en|EN) printf 'EN' ;;
31
- zh|ZH) printf 'ZH' ;;
32
- *) printf '%s' "$1" | tr '[:lower:]' '[:upper:]' ;;
33
- esac
34
- }
35
-
36
18
  # Fill the catalog. Modules call this at source-time:
37
19
  # _i18n_set en hello "Hello, %s!"
38
20
  # _i18n_set zh hello "你好,%s!"
21
+ # No subshell forks: lang uppercasing uses a case statement (bash 3.2-safe;
22
+ # ${var^^} requires bash 4+), key sanitization uses inline param-expansion.
39
23
  _i18n_set() {
40
24
  local lang="$1" key="$2" val="$3"
41
25
  local upper safe varname
42
- upper="$(_i18n_upper "$lang")"
26
+ case "$lang" in
27
+ en|EN) upper=EN ;;
28
+ zh|ZH) upper=ZH ;;
29
+ *) upper="$(printf '%s' "$lang" | tr '[:lower:]' '[:upper:]')" ;;
30
+ esac
43
31
  safe="${key//[^A-Za-z0-9_]/_}" # inline param-expansion — no subshell fork
44
32
  varname="MSG_${upper}_${safe}"
45
33
  printf -v "$varname" '%s' "$val"
@@ -135,7 +123,11 @@ msg() {
135
123
  msg_lang() {
136
124
  local lang="$1" key="$2"; shift 2 || true
137
125
  local upper safe varname tmpl
138
- upper="$(_i18n_upper "$lang")"
126
+ case "$lang" in
127
+ en|EN) upper=EN ;;
128
+ zh|ZH) upper=ZH ;;
129
+ *) upper="$(printf '%s' "$lang" | tr '[:lower:]' '[:upper:]')" ;;
130
+ esac
139
131
  safe="${key//[^A-Za-z0-9_]/_}"
140
132
  varname="MSG_${upper}_${safe}"
141
133
  tmpl="${!varname:-}"
package/lib/loop-fmt.py CHANGED
@@ -445,9 +445,10 @@ def _passthrough_main(agent):
445
445
  """Transparent forwarding for non-claude agents (pi, deepseek, kimi, …).
446
446
 
447
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).
448
+ tmux shows real-time progress. Accumulates all lines; at cycle end,
449
+ dispatches to the agent_usage plugin registry (US-LOOP-026). If a plugin
450
+ returns real token/cost data, emits a single usage event with it;
451
+ otherwise falls back to a single null-payload event (US-LOOP-010 compat).
451
452
  """
452
453
  slug = os.environ.get("LOOP_PROJECT_SLUG")
453
454
  cycle = os.environ.get("LOOP_CYCLE_ID")
@@ -460,24 +461,35 @@ def _passthrough_main(agent):
460
461
  except Exception:
461
462
  evfile = None
462
463
 
464
+ # Accumulate all lines for end-of-cycle usage extraction.
465
+ accumulated: list[str] = []
466
+
463
467
  for line in sys.stdin:
464
468
  if not line.rstrip():
465
469
  continue
470
+ accumulated.append(line.rstrip())
466
471
  # Timestamp prefix so tmux shows activity (even if agent output has
467
472
  # no timestamps of its own).
468
473
  ts = datetime.now(timezone.utc).strftime("%H:%M:%S")
469
474
  out = f"{DARK_GRAY}{ts}{RESET} {line.rstrip()}"
470
475
  sys.stdout.write(out + "\n")
471
476
  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
+ # Passthrough is display-only. Usage is NOT emitted from here:
479
+ # - pi -p text mode carries no usage in stdout (nothing to extract), and
480
+ # - this runs once per retry attempt, so emitting here wrote N usage
481
+ # events per cycle and the dashboard SUMS same-label usage → ×N.
482
+ # Instead bin/roll calls agent_usage/pi_emit.py exactly once after the
483
+ # agent phase, recovering real usage from pi's session files. The
484
+ # _emit_* helpers below are retained for US-LOOP-010 unit tests.
485
+ _ = (accumulated, evfile) # intentionally unused now
477
486
 
478
487
 
479
488
  def _emit_passthrough_event(evfile, cycle, agent, text):
480
- """Best-effort append a usage-type event to evfile."""
489
+ """Best-effort append a usage-type event to evfile (null payload).
490
+
491
+ Kept for backward-compat with US-LOOP-010 tests.
492
+ """
481
493
  payload = {
482
494
  "model": agent,
483
495
  "input_tokens": None,
@@ -499,6 +511,52 @@ def _emit_passthrough_event(evfile, cycle, agent, text):
499
511
  pass
500
512
 
501
513
 
514
+ def _emit_final_usage_event(evfile, cycle, agent, accumulated_lines):
515
+ """Try plugin extraction; emit one usage event (real or null).
516
+
517
+ US-LOOP-026: at cycle end, dispatches accumulated stdout to the
518
+ agent_usage plugin registry. If a plugin returns real data, emits
519
+ a usage event with it. Otherwise emits a single null-payload event
520
+ (US-LOOP-010 backward-compat).
521
+ """
522
+ payload = None
523
+ try:
524
+ from agent_usage import extract_usage
525
+ usage = extract_usage(agent, accumulated_lines)
526
+ if usage is not None:
527
+ payload = {
528
+ "model": usage.get("model", agent),
529
+ "input_tokens": usage.get("input_tokens"),
530
+ "output_tokens": usage.get("output_tokens"),
531
+ "cost_list_usd": usage.get("cost_list_usd"),
532
+ "duration_ms": usage.get("duration_ms"),
533
+ }
534
+ except Exception:
535
+ pass
536
+
537
+ if payload is None:
538
+ payload = {
539
+ "model": agent,
540
+ "input_tokens": None,
541
+ "output_tokens": None,
542
+ "cost_list_usd": None,
543
+ "duration_ms": None,
544
+ }
545
+
546
+ record = json.dumps({
547
+ "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
548
+ "stage": "usage",
549
+ "label": cycle,
550
+ "detail": payload,
551
+ "outcome": "ok",
552
+ }) + "\n"
553
+ try:
554
+ with open(evfile, "a") as f:
555
+ f.write(record)
556
+ except Exception:
557
+ pass
558
+
559
+
502
560
  def main():
503
561
  agent = os.environ.get("ROLL_LOOP_AGENT", "claude")
504
562
  if agent == "claude":
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "version": "2026-05-23",
3
3
  "effective_at": "2026-05-23",
4
- "source_url": "https://api-docs.deepseek.com/quick_start/pricing",
4
+ "source_url": "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
5
5
  "vendor": "deepseek",
6
- "currency": "USD",
6
+ "currency": "CNY",
7
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.",
8
+ "notes": "Rates per million tokens in CNY (¥) — DeepSeek's native billing currency; we never convert to USD (the dashboard already shows the currency symbol). deepseek-chat and deepseek-reasoner are both deepseek-v4-flash with different thinking modes — same pricing. deepseek-v4-pro is under a 2.5折 (75% off) promo until Beijing time 2026-05-31 23:59 (normal: in 12 / out 24); after expiry, refresh this snapshot. cache_read is the official cache-hit input price (reduced to 1/10 of launch price since 2026-04-26). cache_create = cache-miss input rate: DeepSeek levies no separate cache-write surcharge, and pi reports cacheWrite cost as 0, so this rate only ever applies to (near-zero) cacheWrite tokens. pi's own per-message cost.total is computed in USD and is kept as cost_reported_usd for audit, NOT used for the authoritative cost.",
9
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}
10
+ "deepseek-chat": {"in": 1, "out": 2, "cache_create": 1, "cache_read": 0.02},
11
+ "deepseek-reasoner": {"in": 1, "out": 2, "cache_create": 1, "cache_read": 0.02},
12
+ "deepseek-v4-flash": {"in": 1, "out": 2, "cache_create": 1, "cache_read": 0.02},
13
+ "deepseek-v4-pro": {"in": 3, "out": 6, "cache_create": 3, "cache_read": 0.025}
14
14
  }
15
15
  }
@@ -711,8 +711,13 @@ def rollup_for_day(day_cycles: List[Dict[str, Any]]) -> Dict[str, Any]:
711
711
  # show two metric rows. cache_read tokens deliberately excluded — they're
712
712
  # already captured in cy["cost_list"] via list-price math (compute_list_cost
713
713
  # reads all 4 fields), but they don't represent the model's actual work.
714
+ # FIX-126: cost is tracked per-currency. deepseek bills in native CNY (¥),
715
+ # claude in USD ($) — summing them into one number (and stamping it "$")
716
+ # is meaningless. `cost` stays as a legacy scalar sum for back-compat with
717
+ # callers that don't care about currency; `cost_by_cur` is the currency-
718
+ # aware breakdown the dashboard ROLLUP renders (one row per currency).
714
719
  r = {"cycles": len(day_cycles), "prs": 0, "failed": 0,
715
- "duration_s": 0, "cost": 0.0,
720
+ "duration_s": 0, "cost": 0.0, "cost_by_cur": {},
716
721
  "input_tokens": 0, "output_tokens": 0,
717
722
  "cache_creation_tokens": 0, "cache_read_tokens": 0}
718
723
  for cy in day_cycles:
@@ -736,10 +741,14 @@ def rollup_for_day(day_cycles: List[Dict[str, Any]]) -> Dict[str, Any]:
736
741
  r["prs"] += 1
737
742
  if cy.get("cost_list") is not None:
738
743
  r["cost"] += cy["cost_list"]
744
+ cur = cy.get("cost_currency") or "USD"
745
+ r["cost_by_cur"][cur] = r["cost_by_cur"].get(cur, 0.0) + cy["cost_list"]
739
746
  elif cy.get("cron"):
740
747
  # No claude session backfill available — fall back to whatever
741
- # cron.log carries (best-effort, only the latest cycle).
748
+ # cron.log carries (best-effort, only the latest cycle). cron.log
749
+ # cost is claude's USD figure.
742
750
  r["cost"] += cy["cron"]["cost"]
751
+ r["cost_by_cur"]["USD"] = r["cost_by_cur"].get("USD", 0.0) + cy["cron"]["cost"]
743
752
  return r
744
753
 
745
754
  # ════════════════════════════════════════════════════════════════════════════
@@ -753,8 +762,12 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
753
762
  merge_runs_into_cycles(cycles, runs)
754
763
  if git_merges:
755
764
  repair_orphan_cycles_from_git(cycles, git_merges)
756
- if claude_slug:
757
- backfill_usage_from_claude_sessions(cycles, claude_slug)
765
+ # Path 1 (usage_event from the events stream) is authoritative and needs no
766
+ # slug; path 2 (claude session-log salvage) self-guards on the worktree dir
767
+ # existing, so it's a no-op when claude_slug is empty. Always run both — the
768
+ # old `if claude_slug:` gate dropped real per-currency cost for any caller
769
+ # that didn't pass a slug (FIX-126).
770
+ backfill_usage_from_claude_sessions(cycles, claude_slug or "")
758
771
  by_day = bucket_by_day(cycles)
759
772
  days_keys = sorted(by_day.keys(), reverse=True)[:days]
760
773
 
@@ -894,7 +907,28 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
894
907
  metric_tokens("cache writes", today["cache_creation_tokens"], yest["cache_creation_tokens"], d2["cache_creation_tokens"], partial=is_partial)
895
908
  metric_tokens("cache reads", today["cache_read_tokens"], yest["cache_read_tokens"], d2["cache_read_tokens"], partial=is_partial)
896
909
  metric_tokens("output tokens", today["output_tokens"], yest["output_tokens"], d2["output_tokens"], partial=is_partial)
897
- metric_dollar("cost", today["cost"], yest["cost"], d2["cost"], partial=is_partial)
910
+ # FIX-126: one cost row per currency (deepseek ¥, claude $) — never summed
911
+ # across currencies. Show a currency only if it has spend in any of the 3
912
+ # days; default to a single USD row when there's no cost at all.
913
+ _cost_days = (today, yest, d2)
914
+ _currencies = []
915
+ for _cur in ["USD", "CNY"]:
916
+ if any(r["cost_by_cur"].get(_cur) for r in _cost_days):
917
+ _currencies.append(_cur)
918
+ for r in _cost_days:
919
+ for _cur in r["cost_by_cur"]:
920
+ if _cur not in _currencies and r["cost_by_cur"][_cur]:
921
+ _currencies.append(_cur)
922
+ if not _currencies:
923
+ _currencies = ["USD"]
924
+ for _cur in _currencies:
925
+ _sym = "¥" if _cur == "CNY" else "$"
926
+ _label = "cost" if len(_currencies) == 1 else "cost " + _sym
927
+ metric_dollar(_label,
928
+ today["cost_by_cur"].get(_cur, 0.0),
929
+ yest["cost_by_cur"].get(_cur, 0.0),
930
+ d2["cost_by_cur"].get(_cur, 0.0),
931
+ partial=is_partial, symbol=_sym)
898
932
 
899
933
  print()
900
934
  print(c("faint", "─" * COLS))
@@ -931,13 +965,10 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
931
965
  c("muted", " ") +
932
966
  c("dim", "more ") + c("blue", "roll loop status --days 7"))
933
967
 
934
- # US-LOOP-013: valid schedule periods (must divide 60)
935
- _VALID_SCHEDULE_PERIODS = {60, 30, 20, 15, 12, 10, 6, 5}
936
-
937
-
968
+ # US-LOOP-032: period 1–1440; offset 0–59 (deprecated, kept for backward compat)
938
969
  def _schedule_valid(period: int, offset: int) -> bool:
939
- """Validate schedule spec: period must divide 60, offset in [0, period)."""
940
- return period in _VALID_SCHEDULE_PERIODS and 0 <= offset < period
970
+ """Validate schedule spec: period 1–1440, offset in [0, 60)."""
971
+ return 1 <= period <= 1440 and 0 <= offset < 60
941
972
 
942
973
 
943
974
  def _read_schedule_spec(project_root: Optional[Path] = None) -> Tuple[int, int]:
@@ -146,8 +146,8 @@ def fmt_delta(today: float, yest: float, *, kind: str, unit: str = "") -> Tuple[
146
146
  arrow = "▲" if diff > 0 else "▼"
147
147
  sign = "+" if diff > 0 else "−"
148
148
  mag = abs(diff)
149
- if unit == "$":
150
- body = f"{sign}${mag:.2f}"
149
+ if unit in ("$", "¥"):
150
+ body = f"{sign}{unit}{mag:.2f}"
151
151
  elif unit == "m":
152
152
  body = f"{sign}{int(round(mag))}m"
153
153
  else:
@@ -215,16 +215,20 @@ def metric_dur(name: str, t: int, y: int, d2: int, *, partial: bool = False) ->
215
215
  c("dim", pad(fmt_dur(y), 10)) +
216
216
  c("muted", pad(fmt_dur(d2), 8)))
217
217
 
218
- def metric_dollar(name: str, t: float, y: float, d2: float, *, partial: bool = False) -> None:
219
- delta_text, delta_c = fmt_delta(t, y, kind="up_bad", unit="$")
218
+ def metric_dollar(name: str, t: float, y: float, d2: float, *,
219
+ partial: bool = False, symbol: str = "$") -> None:
220
+ # FIX-126: currency-aware — deepseek cost is native CNY (¥), claude USD ($).
221
+ # We never convert; the rollup shows one row per currency with its own
222
+ # symbol, so a ¥-row and a $-row are never summed into a meaningless total.
223
+ delta_text, delta_c = fmt_delta(t, y, kind="up_bad", unit=symbol)
220
224
  if partial and delta_c not in ("muted",):
221
225
  delta_c = "muted"
222
226
  print(" " +
223
227
  c("dim", pad(name, 14)) +
224
- c("fg", pad(f"${t:.2f}", 8, "r"), bold=True) + " " +
228
+ c("fg", pad(f"{symbol}{t:.2f}", 8, "r"), bold=True) + " " +
225
229
  c(delta_c, pad(delta_text, 12), bold=(delta_c != "muted")) +
226
- c("dim", pad(f"${y:.2f}", 10)) +
227
- c("muted", pad(f"${d2:.2f}", 8)))
230
+ c("dim", pad(f"{symbol}{y:.2f}", 10)) +
231
+ c("muted", pad(f"{symbol}{d2:.2f}", 8)))
228
232
 
229
233
  def metric_tokens(name: str, t: int, y: int, d2: int, *, partial: bool = False) -> None:
230
234
  # Compose the delta string with token-unit scaling so a 200M increase
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.526.1",
3
+ "version": "2026.528.1",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -121,7 +121,7 @@ Document structure (two-layer separation):
121
121
  3. **FIX / IDEA detail files use ID-prefixed filenames**: `.roll/features/<epic>/FIX-097.md`, not `.roll/features/<epic>/some-descriptive-slug.md`. Reason: a single FIX is one card, not a long-lived feature; the ID is the most stable handle, descriptive slugs date quickly and break links. US can keep feature-slug naming (US lives inside a multi-Story feature file). Quick lookup: `ls .roll/features/<epic>/FIX-*.md` finds all bugs in that area without grepping content.
122
122
  4. .roll/backlog.md only contains index rows (one row per US), **do not write** AC / Files / Notes
123
123
  5. Domain model files go in `.roll/domain/` — create on first greenfield design, update incrementally
124
- 6. **Do not** write to `~/.kimi/` or any global config directory
124
+ 6. **Do not** write to `~/.kimi/`, `~/.kimi-code/`, or any global config directory
125
125
 
126
126
  **File path resolution order:**
127
127
  1. Determine Feature ownership (based on the requirement domain: compiler / ingest / qa / ...)
@@ -11,12 +11,12 @@ jobs:
11
11
  runs-on: ubuntu-latest
12
12
 
13
13
  steps:
14
- - uses: actions/checkout@v4
14
+ - uses: actions/checkout@v6
15
15
 
16
16
  - name: Setup Node.js
17
17
  uses: actions/setup-node@v4
18
18
  with:
19
- node-version: '20'
19
+ node-version: '24'
20
20
  cache: 'npm'
21
21
 
22
22
  - name: Install dependencies