@seanyao/roll 2026.518.2 → 2026.518.4

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.
@@ -315,7 +315,8 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
315
315
  for cy in cycles:
316
316
  # Path 1: usage event written by loop-fmt at result time.
317
317
  ue = cy.get("usage_event")
318
- if isinstance(ue, dict) and (ue.get("input_tokens") or ue.get("output_tokens")):
318
+ if isinstance(ue, dict) and (ue.get("input_tokens") or ue.get("output_tokens")
319
+ or ue.get("cost_reported_usd")):
319
320
  cy["tokens"] = mp.total_tokens(
320
321
  input_tokens=ue.get("input_tokens", 0),
321
322
  output_tokens=ue.get("output_tokens", 0),
@@ -323,13 +324,20 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
323
324
  cache_read_tokens=ue.get("cache_read_tokens", 0),
324
325
  )
325
326
  cy["model"] = ue.get("model")
326
- cy["cost_list"] = mp.compute_list_cost(
327
- ue.get("model"),
328
- input_tokens=ue.get("input_tokens", 0),
329
- output_tokens=ue.get("output_tokens", 0),
330
- cache_creation_tokens=ue.get("cache_creation_tokens", 0),
331
- cache_read_tokens=ue.get("cache_read_tokens", 0),
332
- )
327
+ # FIX-060: cost_reported_usd is the authoritative cumulative session
328
+ # cost written by loop-fmt; use it directly instead of recomputing
329
+ # from the last individual API-call token counts (which would only
330
+ # reflect one small call and badly undercount the true cycle cost).
331
+ if ue.get("cost_reported_usd"):
332
+ cy["cost_list"] = float(ue["cost_reported_usd"])
333
+ else:
334
+ cy["cost_list"] = mp.compute_list_cost(
335
+ ue.get("model"),
336
+ input_tokens=ue.get("input_tokens", 0),
337
+ output_tokens=ue.get("output_tokens", 0),
338
+ cache_creation_tokens=ue.get("cache_creation_tokens", 0),
339
+ cache_read_tokens=ue.get("cache_read_tokens", 0),
340
+ )
333
341
  if ue.get("duration_ms") and not cy.get("duration_s"):
334
342
  cy["duration_s"] = int(ue["duration_ms"] / 1000)
335
343
  continue
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+ """roll-setup — v2 terminal view for `roll setup`."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ _LIB_DIR = os.path.dirname(os.path.realpath(__file__))
9
+ if _LIB_DIR not in sys.path:
10
+ sys.path.insert(0, _LIB_DIR)
11
+ from roll_render import c, row, COLS
12
+
13
+ # ════════════════════════════════════════════════════════════════════════════
14
+ # Demo data
15
+ # ════════════════════════════════════════════════════════════════════════════
16
+
17
+ _DEMO_STEPS = [
18
+ "Detect platform & shell",
19
+ "Fetch latest roll version",
20
+ "Install skills to ~/.claude",
21
+ "Symlink bin/roll to PATH",
22
+ "Check for config drift",
23
+ "Apply convention templates",
24
+ ]
25
+
26
+ # ════════════════════════════════════════════════════════════════════════════
27
+ # Rendering
28
+ # ════════════════════════════════════════════════════════════════════════════
29
+
30
+ def _divider(char: str = "─") -> None:
31
+ print(c("dim", char * min(COLS, 80)))
32
+
33
+
34
+ def render_demo() -> None:
35
+ left = " " + c("blue", "SETUP", bold=True) + c("dim", " · ") + c("dim", "初始化")
36
+ right = c("dim", "--demo")
37
+ print(row(left, " " + right))
38
+ _divider()
39
+ print()
40
+
41
+ for i, label in enumerate(_DEMO_STEPS, start=1):
42
+ num = c("dim", f" {i}.")
43
+ icon = c("green", "✓")
44
+ print(f"{num} {icon} {label}")
45
+
46
+ print()
47
+ _divider()
48
+ msg = c("green", "Setup complete")
49
+ print(f" {msg} — run {c('fg', 'roll init', bold=True)} inside a project to begin")
50
+ _divider("═")
51
+
52
+
53
+ # ════════════════════════════════════════════════════════════════════════════
54
+ # Entry point
55
+ # ════════════════════════════════════════════════════════════════════════════
56
+
57
+ def main() -> None:
58
+ render_demo()
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ roll-status — render the `roll status` page.
4
+
5
+ One-screen sync health: global conventions, AI clients table (with drift fix hints),
6
+ project templates, and this-project metrics.
7
+
8
+ Usage:
9
+ python3 lib/roll-status.py # live data
10
+ python3 lib/roll-status.py --no-color
11
+ python3 lib/roll-status.py --demo # render with fixture data
12
+ """
13
+
14
+ from __future__ import annotations
15
+ import argparse, os, re, subprocess, sys
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ _LIB_DIR = os.path.dirname(os.path.realpath(__file__))
20
+ if _LIB_DIR not in sys.path:
21
+ sys.path.insert(0, _LIB_DIR)
22
+ import roll_render
23
+ from roll_render import COLS, c, row, section_head, strw, pad
24
+
25
+ # ════════════════════════════════════════════════════════════════════════════
26
+ # Paths
27
+ # ════════════════════════════════════════════════════════════════════════════
28
+ def _roll_home() -> Path:
29
+ return Path(os.environ.get("ROLL_HOME") or os.path.expanduser("~/.roll"))
30
+
31
+ def _global_dir() -> Path:
32
+ return _roll_home() / "conventions" / "global"
33
+
34
+ def _templates_dir() -> Path:
35
+ return _roll_home() / "conventions" / "templates"
36
+
37
+ def _config_path() -> Path:
38
+ return _roll_home() / "config.yaml"
39
+
40
+ def _shared_root() -> Path:
41
+ return Path(os.environ.get("ROLL_SHARED_ROOT") or os.path.expanduser("~/.shared/roll"))
42
+
43
+ def _project_slug() -> str:
44
+ path = os.path.realpath(os.getcwd())
45
+ try:
46
+ common = subprocess.check_output(
47
+ ["git", "-C", path, "rev-parse", "--git-common-dir"],
48
+ stderr=subprocess.DEVNULL, text=True,
49
+ ).strip()
50
+ if common.endswith("/.git"):
51
+ path = common[:-5]
52
+ except Exception:
53
+ pass
54
+ import hashlib
55
+ base = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(path)).strip("-")
56
+ h = hashlib.md5(path.encode()).hexdigest()[:6]
57
+ return f"{base}-{h}"
58
+
59
+ # ════════════════════════════════════════════════════════════════════════════
60
+ # Data loaders
61
+ # ════════════════════════════════════════════════════════════════════════════
62
+ CONVENTION_FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md", ".cursor-rules", "project_rules.md"]
63
+ TEMPLATES = ["fullstack", "frontend-only", "backend-service", "cli"]
64
+
65
+ def _global_conventions() -> List[Tuple[str, bool]]:
66
+ gd = _global_dir()
67
+ return [(f, (gd / f).exists()) for f in CONVENTION_FILES]
68
+
69
+ def _parse_ai_entries() -> List[Dict[str, str]]:
70
+ cfg = _config_path()
71
+ if not cfg.exists():
72
+ return []
73
+ entries = []
74
+ for line in cfg.read_text(errors="ignore").splitlines():
75
+ m = re.match(r"^ai_[a-z]+:\s*(.+)", line)
76
+ if not m:
77
+ continue
78
+ val = m.group(1).strip().replace("~", str(Path.home()))
79
+ parts = val.split("|")
80
+ if len(parts) < 3:
81
+ continue
82
+ ai_dir, cfg_file, src_file = parts[0].strip(), parts[1].strip(), parts[2].strip()
83
+ name = os.path.basename(ai_dir).lstrip(".")
84
+ if name in ("workspace", "agent"):
85
+ name = os.path.basename(os.path.dirname(ai_dir)).lstrip(".")
86
+ entries.append({"name": name, "ai_dir": ai_dir, "cfg_file": cfg_file, "src_file": src_file})
87
+ return entries
88
+
89
+ def _ai_sync_status(entry: Dict[str, str]) -> str:
90
+ ai_dir = Path(entry["ai_dir"])
91
+ cfg_file = ai_dir / entry["cfg_file"]
92
+ roll_md = ai_dir / "roll.md"
93
+ src = _global_dir() / entry["src_file"]
94
+
95
+ if not cfg_file.exists():
96
+ return "missing"
97
+ if not roll_md.exists():
98
+ return "out-of-sync"
99
+ try:
100
+ if src.exists() and roll_md.read_bytes() != src.read_bytes():
101
+ return "out-of-sync"
102
+ except Exception:
103
+ return "out-of-sync"
104
+ try:
105
+ if "@roll.md" not in cfg_file.read_text(errors="ignore"):
106
+ return "out-of-sync"
107
+ except Exception:
108
+ return "out-of-sync"
109
+ return "sync"
110
+
111
+ def _ai_skill_count(entry: Dict[str, str]) -> int:
112
+ skills_dir = Path(entry["ai_dir"]) / "skills"
113
+ if not skills_dir.exists():
114
+ return 0
115
+ try:
116
+ return sum(1 for p in skills_dir.iterdir() if p.name.startswith("roll-") and (p.is_symlink() or p.is_dir()))
117
+ except Exception:
118
+ return 0
119
+
120
+ def _template_count(tpl: str) -> int:
121
+ d = _templates_dir() / tpl
122
+ if not d.exists():
123
+ return 0
124
+ try:
125
+ return sum(1 for p in d.rglob("*") if p.is_file())
126
+ except Exception:
127
+ return 0
128
+
129
+ def _skills_installed() -> int:
130
+ sd = _roll_home() / "skills"
131
+ if not sd.exists():
132
+ return 0
133
+ try:
134
+ return sum(1 for p in sd.iterdir() if p.is_dir())
135
+ except Exception:
136
+ return 0
137
+
138
+ def _launchd_state(service: str, slug: str) -> str:
139
+ label = f"com.roll.{service}.{slug}"
140
+ plist = Path(os.path.expanduser("~/Library/LaunchAgents")) / f"{label}.plist"
141
+ if not plist.exists():
142
+ return "not-installed"
143
+ try:
144
+ out = subprocess.check_output(
145
+ ["launchctl", "list", label], stderr=subprocess.DEVNULL, text=True,
146
+ )
147
+ return "enabled" if out.strip() else "installed-off"
148
+ except Exception:
149
+ return "installed-off"
150
+
151
+ # ════════════════════════════════════════════════════════════════════════════
152
+ # Demo fixture
153
+ # ════════════════════════════════════════════════════════════════════════════
154
+ def _demo_data() -> Dict[str, Any]:
155
+ return dict(
156
+ conventions=[
157
+ ("AGENTS.md", True), ("CLAUDE.md", True), ("GEMINI.md", False),
158
+ (".cursor-rules", True), ("project_rules.md", False),
159
+ ],
160
+ ai_clients=[
161
+ {"name": "claude", "cfg_file": "CLAUDE.md", "path": "~/.claude/CLAUDE.md", "sync": "sync", "skills": 12},
162
+ {"name": "cursor", "cfg_file": "AGENTS.md", "path": "~/.cursor/AGENTS.md", "sync": "out-of-sync", "skills": 12},
163
+ {"name": "gemini", "cfg_file": "GEMINI.md", "path": "~/.gemini/GEMINI.md", "sync": "missing", "skills": 0},
164
+ ],
165
+ templates=[
166
+ ("fullstack", 14), ("frontend-only", 9), ("backend-service", 11), ("cli", 7),
167
+ ],
168
+ skills_installed=12,
169
+ project_has_agents=True,
170
+ project_has_backlog=True,
171
+ project_features_count=23,
172
+ loop_state="enabled",
173
+ dream_state="not-installed",
174
+ )
175
+
176
+ # ════════════════════════════════════════════════════════════════════════════
177
+ # Render helpers
178
+ # ════════════════════════════════════════════════════════════════════════════
179
+ def _hr() -> None:
180
+ print(c("faint", "─" * COLS))
181
+
182
+ def _render_health(d: Dict[str, Any]) -> None:
183
+ clients = d["ai_clients"]
184
+ synced = sum(1 for x in clients if x["sync"] == "sync")
185
+ total = len(clients)
186
+ skills = d["skills_installed"]
187
+ tpls = len([t for t in d["templates"] if t[1] > 0])
188
+
189
+ has_drift = synced < total
190
+ if has_drift:
191
+ dot = c("amber", "!")
192
+ word = c("amber", "drift", bold=True)
193
+ detail = (c("dim", f" {synced}/{total} AI clients in sync") + c("muted", " · ") +
194
+ c("dim", f"{skills} skills") + c("muted", " · ") +
195
+ c("dim", f"{tpls} templates"))
196
+ else:
197
+ dot = c("green", "●")
198
+ word = c("green", "healthy", bold=True)
199
+ detail = (c("dim", f" {synced}/{total} AI clients in sync") + c("muted", " · ") +
200
+ c("dim", f"{skills} skills mounted") + c("muted", " · ") +
201
+ c("dim", f"{tpls} templates present"))
202
+
203
+ print()
204
+ print(" " + dot + " " + word + detail)
205
+ print()
206
+ _hr()
207
+ print()
208
+
209
+ def _render_global_conventions(conventions: list) -> None:
210
+ section_head("GLOBAL CONVENTIONS", "全局约定", "~/.roll/conventions/global/")
211
+ print()
212
+ for fname, exists in conventions:
213
+ if exists:
214
+ print(" " + c("green", "+") + " " + c("fg", fname))
215
+ else:
216
+ print(" " + c("red", "−") + " " + c("dim", fname) + " " + c("red", "missing"))
217
+ print()
218
+ _hr()
219
+ print()
220
+
221
+ def _render_ai_clients(clients: list) -> None:
222
+ section_head("AI CLIENTS", "AI 客户端同步", "convention · path · sync · skills")
223
+ print()
224
+
225
+ # Header
226
+ hdr = (" " + pad(c("dim", "name"), 14) +
227
+ pad(c("dim", "convention"), 14) +
228
+ pad(c("dim", "sync"), 14) +
229
+ c("dim", "skills"))
230
+ print(hdr)
231
+ print(" " + c("faint", "─" * (COLS - 4)))
232
+
233
+ for cl in clients:
234
+ sync_s = cl["sync"]
235
+ name = cl["name"]
236
+ cfg = cl["cfg_file"]
237
+ path = cl.get("path", "")
238
+ sk = cl.get("skills", 0)
239
+
240
+ if sync_s == "sync":
241
+ sync_col = c("green", "✓ in sync")
242
+ name_col = c("fg", name)
243
+ elif sync_s == "out-of-sync":
244
+ sync_col = c("amber", "~ out of sync")
245
+ name_col = c("amber", name)
246
+ else:
247
+ sync_col = c("red", "− missing")
248
+ name_col = c("red", name)
249
+
250
+ row_line = (" " + pad(name_col, 14) +
251
+ pad(c("dim", cfg), 14) +
252
+ pad(sync_col, 14) +
253
+ c("dim", str(sk)))
254
+ print(row_line)
255
+
256
+ if sync_s in ("out-of-sync", "missing"):
257
+ hint = (" " + c("dim", "fix: ") +
258
+ c("blue", f"roll setup -f {name}"))
259
+ print(hint)
260
+
261
+ print()
262
+ _hr()
263
+ print()
264
+
265
+ def _render_templates(templates: list) -> None:
266
+ section_head("PROJECT TEMPLATES", "项目模板", "~/.roll/conventions/templates/")
267
+ print()
268
+ parts = []
269
+ for tpl, count in templates:
270
+ if count > 0:
271
+ parts.append(c("fg", tpl) + c("dim", f" {count}f"))
272
+ else:
273
+ parts.append(c("red", "−") + " " + c("dim", tpl + " missing"))
274
+ print(" " + c("muted", " · ").join(parts))
275
+ print()
276
+ _hr()
277
+ print()
278
+
279
+ def _render_this_project(d: Dict[str, Any]) -> None:
280
+ section_head("THIS PROJECT", "本项目", os.path.basename(os.getcwd()))
281
+ print()
282
+
283
+ def _file_row(label: str, exists: bool, detail: str = "") -> None:
284
+ if exists:
285
+ sym = c("green", "+")
286
+ lbl = c("fg", label)
287
+ else:
288
+ sym = c("red", "−")
289
+ lbl = c("dim", label) + " " + c("red", "missing")
290
+ line = " " + sym + " " + lbl
291
+ if detail and exists:
292
+ line += c("dim", f" {detail}")
293
+ print(line)
294
+
295
+ _file_row("AGENTS.md", d["project_has_agents"])
296
+ _file_row("BACKLOG.md", d["project_has_backlog"])
297
+ _file_row("docs/features/", d["project_features_count"] > 0,
298
+ f"{d['project_features_count']} feature docs")
299
+
300
+ # Loop & dream launchd
301
+ for svc, state_key in [("loop", "loop_state"), ("dream", "dream_state")]:
302
+ state = d.get(state_key, "not-installed")
303
+ if state == "enabled":
304
+ dot = c("green", "●")
305
+ word = c("green", f"{svc} · launchd enabled")
306
+ elif state == "installed-off":
307
+ dot = c("amber", "⚠")
308
+ word = c("amber", f"{svc} · launchd off")
309
+ else:
310
+ dot = c("red", "○")
311
+ word = c("dim", f"{svc} · launchd not installed")
312
+ print(" " + dot + " " + word)
313
+
314
+ print()
315
+
316
+ # ════════════════════════════════════════════════════════════════════════════
317
+ # Live data collection
318
+ # ════════════════════════════════════════════════════════════════════════════
319
+ def _live_data() -> Dict[str, Any]:
320
+ slug = _project_slug()
321
+ entries = _parse_ai_entries()
322
+ ai_clients = []
323
+ for e in entries:
324
+ ai_clients.append({
325
+ "name": e["name"],
326
+ "cfg_file": e["cfg_file"],
327
+ "path": str(Path(e["ai_dir"]) / e["cfg_file"]).replace(str(Path.home()), "~"),
328
+ "sync": _ai_sync_status(e),
329
+ "skills": _ai_skill_count(e),
330
+ })
331
+ templates = [(t, _template_count(t)) for t in TEMPLATES]
332
+ feat_dir = Path("docs/features")
333
+ return dict(
334
+ conventions = _global_conventions(),
335
+ ai_clients = ai_clients,
336
+ templates = templates,
337
+ skills_installed = _skills_installed(),
338
+ project_has_agents = Path("AGENTS.md").exists(),
339
+ project_has_backlog = Path("BACKLOG.md").exists(),
340
+ project_features_count = sum(1 for _ in feat_dir.glob("*.md")) if feat_dir.exists() else 0,
341
+ loop_state = _launchd_state("loop", slug),
342
+ dream_state = _launchd_state("dream", slug),
343
+ )
344
+
345
+ # ════════════════════════════════════════════════════════════════════════════
346
+ # Entry
347
+ # ════════════════════════════════════════════════════════════════════════════
348
+ def main() -> None:
349
+ ap = argparse.ArgumentParser(add_help=False)
350
+ ap.add_argument("--demo", action="store_true")
351
+ ap.add_argument("--no-color", dest="no_color", action="store_true")
352
+ ap.add_argument("--en", action="store_true")
353
+ ap.add_argument("--zh", action="store_true")
354
+ args, _ = ap.parse_known_args()
355
+
356
+ if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
357
+ roll_render.USE_COLOR = False
358
+
359
+ d = _demo_data() if args.demo else _live_data()
360
+
361
+ _render_health(d)
362
+ _render_global_conventions(d["conventions"])
363
+ _render_ai_clients(d["ai_clients"])
364
+ _render_templates(d["templates"])
365
+ _render_this_project(d)
366
+
367
+ if __name__ == "__main__":
368
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.518.2",
3
+ "version": "2026.518.4",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -367,6 +367,31 @@ A minor change is only "done" when all are true:
367
367
  - [ ] Online verification performed
368
368
  - [ ] **Verification Gate passed** (fresh evidence for tests, build, fix confirmation, no regression)
369
369
 
370
+ ## Rubric
371
+
372
+ Quality evaluation for a completed fix. Score each dimension independently.
373
+
374
+ | 维度 | ❌ Miss (0) | ⚠️ Partial (1) | ✅ Hit (2) |
375
+ |------|------------|----------------|-----------|
376
+ | **根因定位** | 只修了表象,未说明根因 | 描述了直接原因 | 追溯根本原因并有代码/日志证据 |
377
+ | **最小范围** | 改动超出 fix 边界,含机会主义修改 | 范围合理但有冗余改动 | 最小改动,非 fix 相关代码零触碰 |
378
+ | **回归测试** | 无测试,或测试与 bug 无关 | 有测试但未复现原始 bug | 先写复现测试(RED)再修复(GREEN) |
379
+ | **验证证据** | 仅声称通过,无实际输出 | 有部分截图/日志但不完整 | 贴出完整命令输出,覆盖 fix + 回归 |
380
+ | **无新破坏** | CI 红,或已知回归未处理 | CI 绿但有 warning 未说明 | CI 全绿,覆盖率不降,warning 清零 |
381
+
382
+ **评分解读**
383
+
384
+ | 总分 | 结论 |
385
+ |------|------|
386
+ | 9–10 | Exemplary — 可作为参考案例 |
387
+ | 7–8 | Acceptable — 可交付,有小瑕疵 |
388
+ | 5–6 | Needs Work — 需补充证据或补测试 |
389
+ | ≤ 4 | Redo — 根因或验证存在根本缺失 |
390
+
391
+ > 用法:fix 完成后由 `$roll-eval`(或人工)对照此表打分,结果写入 `roll-notes`。
392
+
393
+ ---
394
+
370
395
  ## TCR Patterns for Common Fixes
371
396
 
372
397
  ### Pattern: Bug Fix with Regression Test