@seanyao/roll 2026.522.1 → 2026.522.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 +17 -0
- package/bin/roll +210 -42
- package/conventions/config.yaml +1 -1
- package/conventions/global/AGENTS.md +1 -1
- package/conventions/global/GEMINI.md +8 -3
- package/conventions/templates/backend-service/GEMINI.md +3 -3
- package/conventions/templates/cli/GEMINI.md +3 -3
- package/conventions/templates/frontend-only/GEMINI.md +3 -3
- package/conventions/templates/fullstack/GEMINI.md +3 -3
- package/lib/__pycache__/model_prices.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/model_prices.py +16 -5
- package/lib/roll-loop-status.py +76 -21
- package/lib/roll-peer.py +1 -1
- package/lib/roll-status.py +1 -1
- package/lib/roll_render.py +9 -3
- package/lib/slides/templates/introduction-v3.html +576 -0
- package/package.json +1 -1
- package/skills/roll-deck/SKILL.md +22 -14
- package/skills/roll-design/SKILL.md +86 -0
- package/skills/roll-doctor/SKILL.md +1 -1
- package/skills/roll-onboard/SKILL.md +1 -1
package/lib/roll-loop-status.py
CHANGED
|
@@ -336,10 +336,16 @@ def load_claude_session_usage(label: str, slug: str) -> Optional[Dict[str, Any]]
|
|
|
336
336
|
"cost_reported_usd": cost, "duration_ms": duration_ms}
|
|
337
337
|
|
|
338
338
|
def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str) -> None:
|
|
339
|
-
"""Populate cy['
|
|
339
|
+
"""Populate cy['input_tokens'], cy['output_tokens'], cy['cost_list'],
|
|
340
|
+
cy['model']. Two paths:
|
|
340
341
|
1. usage_event from events stream (US-LOOP-004 writer side) — authoritative
|
|
341
342
|
2. claude session JSONL backfill — for cycles that ran before the
|
|
342
343
|
writer existed, or on machines where events.ndjson got truncated
|
|
344
|
+
|
|
345
|
+
US-VIEW-012: dashboard exposes input + output only (the model's actual
|
|
346
|
+
work). cache_creation / cache_read remain in the usage_event for
|
|
347
|
+
compute_list_cost — they're still part of true API cost — but no longer
|
|
348
|
+
surface in the UI where they previously inflated visible token totals.
|
|
343
349
|
"""
|
|
344
350
|
import importlib.util
|
|
345
351
|
spec = importlib.util.spec_from_file_location("model_prices",
|
|
@@ -350,12 +356,8 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
|
|
|
350
356
|
# Path 1: usage event written by loop-fmt at result time.
|
|
351
357
|
ue = cy.get("usage_event")
|
|
352
358
|
if isinstance(ue, dict) and (ue.get("input_tokens") or ue.get("output_tokens")):
|
|
353
|
-
cy["
|
|
354
|
-
|
|
355
|
-
output_tokens=ue.get("output_tokens", 0),
|
|
356
|
-
cache_creation_tokens=ue.get("cache_creation_tokens", 0),
|
|
357
|
-
cache_read_tokens=ue.get("cache_read_tokens", 0),
|
|
358
|
-
)
|
|
359
|
+
cy["input_tokens"] = int(ue.get("input_tokens") or 0)
|
|
360
|
+
cy["output_tokens"] = int(ue.get("output_tokens") or 0)
|
|
359
361
|
cy["model"] = ue.get("model")
|
|
360
362
|
# US-VIEW-010: aggregate now sums per-turn usage tokens, so the
|
|
361
363
|
# totals in `ue` reflect the whole cycle. Always compute cost at
|
|
@@ -373,17 +375,13 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
|
|
|
373
375
|
cy["duration_s"] = int(ue["duration_ms"] / 1000)
|
|
374
376
|
continue
|
|
375
377
|
# Path 2: salvage from claude's own session log.
|
|
376
|
-
if cy.get("
|
|
378
|
+
if cy.get("input_tokens") or cy.get("output_tokens"):
|
|
377
379
|
continue
|
|
378
380
|
u = load_claude_session_usage(cy.get("label", ""), slug)
|
|
379
381
|
if not u:
|
|
380
382
|
continue
|
|
381
|
-
cy["
|
|
382
|
-
|
|
383
|
-
output_tokens=u["output_tokens"],
|
|
384
|
-
cache_creation_tokens=u["cache_creation_tokens"],
|
|
385
|
-
cache_read_tokens=u["cache_read_tokens"],
|
|
386
|
-
)
|
|
383
|
+
cy["input_tokens"] = int(u.get("input_tokens") or 0)
|
|
384
|
+
cy["output_tokens"] = int(u.get("output_tokens") or 0)
|
|
387
385
|
cy["model"] = u["model"]
|
|
388
386
|
cy["cost_list"] = mp.compute_list_cost(
|
|
389
387
|
u["model"],
|
|
@@ -553,15 +551,22 @@ def bucket_by_day(cycles: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]
|
|
|
553
551
|
return out
|
|
554
552
|
|
|
555
553
|
def rollup_for_day(day_cycles: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
554
|
+
# US-VIEW-012: track input + output separately so the daily summary can
|
|
555
|
+
# show two metric rows. cache_read tokens deliberately excluded — they're
|
|
556
|
+
# already captured in cy["cost_list"] via list-price math (compute_list_cost
|
|
557
|
+
# reads all 4 fields), but they don't represent the model's actual work.
|
|
556
558
|
r = {"cycles": len(day_cycles), "prs": 0, "failed": 0,
|
|
557
|
-
"duration_s": 0, "cost": 0.0,
|
|
559
|
+
"duration_s": 0, "cost": 0.0,
|
|
560
|
+
"input_tokens": 0, "output_tokens": 0}
|
|
558
561
|
for cy in day_cycles:
|
|
559
562
|
if cy.get("outcome") == "fail":
|
|
560
563
|
r["failed"] += 1
|
|
561
564
|
if cy.get("duration_s"):
|
|
562
565
|
r["duration_s"] += cy["duration_s"]
|
|
563
|
-
if cy.get("
|
|
564
|
-
r["
|
|
566
|
+
if cy.get("input_tokens"):
|
|
567
|
+
r["input_tokens"] += cy["input_tokens"]
|
|
568
|
+
if cy.get("output_tokens"):
|
|
569
|
+
r["output_tokens"] += cy["output_tokens"]
|
|
565
570
|
# US-VIEW-011: rollup only counts cycles whose PR actually merged.
|
|
566
571
|
# Backward compat: rows where pr_outcome is missing but pr URL exists
|
|
567
572
|
# (no `pr` event after the writer upgrade ran for that cycle) are
|
|
@@ -620,9 +625,23 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
|
|
|
620
625
|
c("muted", " · ") + c("dim", state.get("paused_reason", "")))
|
|
621
626
|
eb_zh = c("dim", " 已暂停 · run: roll loop resume")
|
|
622
627
|
else:
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
628
|
+
# FIX-095: surface three-state install/enable status. Pre-FIX, every
|
|
629
|
+
# case fell through to '● IDLE' which hid 'not installed' and
|
|
630
|
+
# 'installed/off' from the user.
|
|
631
|
+
install_state = _detect_install_state()
|
|
632
|
+
if install_state == "not-installed":
|
|
633
|
+
eb_l = (c("muted", "○ not installed", bold=True) + c("muted", " ") +
|
|
634
|
+
c("dim", "run ") + c("fg", "roll loop on", bold=True) +
|
|
635
|
+
c("dim", " to enable"))
|
|
636
|
+
eb_zh = c("dim", " 未安装 · 运行 ") + c("fg", "roll loop on") + c("dim", " 启用")
|
|
637
|
+
elif install_state == "disabled":
|
|
638
|
+
eb_l = (c("amber", "◌ installed/off", bold=True) + c("muted", " ") +
|
|
639
|
+
c("dim", "loop disabled — run ") + c("fg", "roll loop on", bold=True))
|
|
640
|
+
eb_zh = c("dim", " 未启用 · 运行 ") + c("fg", "roll loop on") + c("dim", " 启用")
|
|
641
|
+
else:
|
|
642
|
+
eb_l = (c("blue", "● IDLE", bold=True) + c("muted", " · ") +
|
|
643
|
+
c("dim", "enabled · next run ") + c("fg", _next_cron_hint(state), bold=True))
|
|
644
|
+
eb_zh = c("dim", f" 已启用 · 闲置 · 距下一轮 {_next_cron_hint(state, zh=True)}")
|
|
626
645
|
|
|
627
646
|
# 'last' = the most recent cycle the user can act on — skip cycles that
|
|
628
647
|
# are still running (the running banner already announces those) and skip
|
|
@@ -704,7 +723,12 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
|
|
|
704
723
|
yest_color="amber" if yest["failed"] > 0 else "dim",
|
|
705
724
|
yest_suffix="⚠" if yest["failed"] > 0 else "")
|
|
706
725
|
metric_dur("duration", today["duration_s"], yest["duration_s"], d2["duration_s"], partial=is_partial)
|
|
707
|
-
|
|
726
|
+
# US-VIEW-012: input + output as two separate rows. cache_read no longer
|
|
727
|
+
# surfaces here — true cost is on the "cost" line below (computed from all
|
|
728
|
+
# 4 token kinds via list price). This row labels what the model actually
|
|
729
|
+
# processed and generated for this cycle.
|
|
730
|
+
metric_tokens("input tokens", today["input_tokens"], yest["input_tokens"], d2["input_tokens"], partial=is_partial)
|
|
731
|
+
metric_tokens("output tokens", today["output_tokens"], yest["output_tokens"], d2["output_tokens"], partial=is_partial)
|
|
708
732
|
metric_dollar("cost", today["cost"], yest["cost"], d2["cost"], partial=is_partial)
|
|
709
733
|
|
|
710
734
|
print()
|
|
@@ -759,6 +783,37 @@ def _read_plist_loop_minute() -> int:
|
|
|
759
783
|
return int(m.group(1)) if m else 48
|
|
760
784
|
|
|
761
785
|
|
|
786
|
+
def _detect_install_state() -> str:
|
|
787
|
+
"""FIX-095: classify the launchd install state of the loop service.
|
|
788
|
+
|
|
789
|
+
Returns one of:
|
|
790
|
+
'not-installed' — no plist for com.roll.loop.<slug> in ~/Library/LaunchAgents/
|
|
791
|
+
'disabled' — plist exists but launchctl print-disabled shows '=> disabled'
|
|
792
|
+
'enabled' — plist exists and no disable override is set
|
|
793
|
+
|
|
794
|
+
Pre-FIX-095, the v2 view rendered '● IDLE' for all three states, leaving
|
|
795
|
+
users unable to tell whether the loop was actually installed/enabled.
|
|
796
|
+
"""
|
|
797
|
+
slug = project_slug()
|
|
798
|
+
label = f"com.roll.loop.{slug}"
|
|
799
|
+
plist = Path(os.path.expanduser("~/Library/LaunchAgents")) / f"{label}.plist"
|
|
800
|
+
if not plist.exists():
|
|
801
|
+
return "not-installed"
|
|
802
|
+
try:
|
|
803
|
+
uid = os.getuid()
|
|
804
|
+
out = subprocess.run(
|
|
805
|
+
["launchctl", "print-disabled", f"gui/{uid}"],
|
|
806
|
+
capture_output=True, text=True, timeout=2,
|
|
807
|
+
).stdout or ""
|
|
808
|
+
for line in out.splitlines():
|
|
809
|
+
if f'"{label}"' in line and "=> disabled" in line:
|
|
810
|
+
return "disabled"
|
|
811
|
+
except Exception:
|
|
812
|
+
# launchctl missing or timed out — best-effort fall through to enabled.
|
|
813
|
+
pass
|
|
814
|
+
return "enabled"
|
|
815
|
+
|
|
816
|
+
|
|
762
817
|
def _next_cron_hint(state: Dict[str, str], zh: bool = False) -> str:
|
|
763
818
|
"""Compute next cron fire time from the actual launchd plist Minute (FIX-063)."""
|
|
764
819
|
now = datetime.now().astimezone()
|
package/lib/roll-peer.py
CHANGED
package/lib/roll-status.py
CHANGED
|
@@ -160,7 +160,7 @@ def _fixture_data() -> Dict[str, Any]:
|
|
|
160
160
|
ai_clients=[
|
|
161
161
|
{"name": "claude", "cfg_file": "CLAUDE.md", "path": "~/.claude/CLAUDE.md", "sync": "sync", "skills": 12},
|
|
162
162
|
{"name": "cursor", "cfg_file": "AGENTS.md", "path": "~/.cursor/AGENTS.md", "sync": "out-of-sync", "skills": 12},
|
|
163
|
-
{"name": "
|
|
163
|
+
{"name": "agy", "cfg_file": "GEMINI.md", "path": "~/.gemini/GEMINI.md", "sync": "missing", "skills": 0},
|
|
164
164
|
],
|
|
165
165
|
templates=[
|
|
166
166
|
("fullstack", 14), ("frontend-only", 9), ("backend-service", 11), ("cli", 7),
|
package/lib/roll_render.py
CHANGED
|
@@ -159,7 +159,8 @@ def trunc(s: str, n: int) -> str:
|
|
|
159
159
|
return out
|
|
160
160
|
|
|
161
161
|
def empty_rollup() -> Dict[str, Any]:
|
|
162
|
-
return {"cycles": 0, "prs": 0, "failed": 0, "duration_s": 0, "cost": 0.0,
|
|
162
|
+
return {"cycles": 0, "prs": 0, "failed": 0, "duration_s": 0, "cost": 0.0,
|
|
163
|
+
"input_tokens": 0, "output_tokens": 0}
|
|
163
164
|
|
|
164
165
|
# ════════════════════════════════════════════════════════════════════════════
|
|
165
166
|
# Section / metric / cycle rows — printers used by all dashboards
|
|
@@ -297,7 +298,12 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
|
|
|
297
298
|
from datetime import datetime as _dt, timezone as _tz
|
|
298
299
|
dur_s = int((_dt.now(_tz.utc) - cy["start"]).total_seconds())
|
|
299
300
|
dur = fmt_dur(dur_s) if dur_s else "—"
|
|
300
|
-
|
|
301
|
+
# US-VIEW-012: token column shows model's real work as input/output. Cache
|
|
302
|
+
# creation / cache read are kept in events.ndjson for cost math but never
|
|
303
|
+
# surface in the UI — they would inflate the visible number to 10–100× the
|
|
304
|
+
# "real" work done by the model on this cycle. fmt_tokens(0) already
|
|
305
|
+
# returns "—", so a cycle missing usage_event prints as "—/—".
|
|
306
|
+
tok = f"{fmt_tokens(cy.get('input_tokens') or 0)}/{fmt_tokens(cy.get('output_tokens') or 0)}"
|
|
301
307
|
# cost prefers the backfilled list-price; falls back to cron.log when
|
|
302
308
|
# the claude session log isn't available (only the latest cycle).
|
|
303
309
|
if cy.get("cost_list") is not None:
|
|
@@ -341,7 +347,7 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
|
|
|
341
347
|
" " + c(glyph_c, glyph, bold=True) + " " +
|
|
342
348
|
c(time_c, pad(time_str, 5), bold=(outcome == "fail")) + " " +
|
|
343
349
|
c("muted", pad(dur, 4, "r")) + " " +
|
|
344
|
-
c("muted", pad(tok,
|
|
350
|
+
c("muted", pad(tok, 11, "r")) + " " +
|
|
345
351
|
model_seg +
|
|
346
352
|
c("muted", pad(cost, 7, "r")) + " " +
|
|
347
353
|
c(sid_c, ids_str, bold=True) + pr_marker
|