@seanyao/roll 2026.517.9 → 2026.518.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.
- package/CHANGELOG.md +27 -0
- package/bin/roll +97 -7
- package/lib/loop-fmt.py +51 -0
- package/lib/model_prices.py +64 -0
- package/lib/roll-loop-status.py +794 -0
- package/lib/roll_render.py +326 -0
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2026.518.1
|
|
4
|
+
|
|
5
|
+
### Improved
|
|
6
|
+
|
|
7
|
+
- **`roll loop status` 焕新** — 重设计的 dashboard 替换原本的扁平列表:按天 Today / Yesterday / −2d 分列总账(轮次、PR、耗时、tokens、成本、失败计数),下面每天分段列出 cycle 详情;idle / done / fail 用 `·` / `✓` / `✗` 区分,不再统统挂"运行中";时间统一显示 UTC+8;同一 cycle 的 pr 事件与 cycle_start 不再被错切成两条;多 story cycle 用 `|` 连起来一行展示;走 `ROLL_UI=v2` 默认开,`ROLL_UI=v1` 一键回退到旧实现 `[loop]`
|
|
8
|
+
- **loop 每轮成本和 token 真实可见** — 每个 cycle 结束时把模型用量(input / output / cache_creation / cache_read tokens、claude 上报的折后价、耗时)写进永久事件流,dashboard 按模型公开单价(list price)算出真实成本、按 k / m / b 显示 token,多机器 / 多项目可横向对比;历史 cycle 没写过这条事件的,自动从 claude 自己的会话日志里回灌一份 `[loop]`
|
|
9
|
+
- **`roll update` 不再每次刷 PR 评审两档安装提示** — 两段安装命令挪到 `roll doctor`,在 git repo 内探测分支保护和事件 workflow 的当前状态,只对未启用项显示安装指令 `[pr]`
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **发版管道空版本雪球** — release.sh AI changelog 调用失败时硬退出(不再静默发空 tag);release.yml 同日合并检测到 fallback notes 时跳过合并,避免把前一个 release body 累积到新版本里 `[release]`
|
|
14
|
+
|
|
15
|
+
## v2026.517.9
|
|
16
|
+
|
|
17
|
+
### Improved
|
|
18
|
+
|
|
19
|
+
- **`roll-loop` 环境变量文档化** — `ROLL_LOOP_FORCE` / `ROLL_LOOP_NO_HEAL` / `ROLL_LOOP_HEAL_MAX` / `ROLL_LOOP_PR_MERGE_TIMEOUT` 四个配置项补入中英双语 configuration 指南,并加 bats 测试守护 `[loop]`
|
|
20
|
+
- **BACKLOG 四种条目渲染折叠** — Story / FIX / REFACTOR / IDEA 四组解析循环结构完全一样,合并为单一渲染函数,格式变更不再需要同步四处 `[refactor]`
|
|
21
|
+
|
|
22
|
+
## v2026.517.8
|
|
23
|
+
|
|
24
|
+
> 空版本:发版脚本 AI 调用失败 fallback 未拦截,导致无实际内容的 tag 被推出。缺陷已在后续 commit `005601a` 修复(release.sh 和 release.yml 双重校验),见 Unreleased 段。
|
|
25
|
+
|
|
26
|
+
## v2026.517.7
|
|
27
|
+
|
|
28
|
+
> 空版本:原因同 v2026.517.8。
|
|
29
|
+
|
|
3
30
|
## v2026.517.6
|
|
4
31
|
|
|
5
32
|
### Fixed
|
package/bin/roll
CHANGED
|
@@ -4,7 +4,7 @@ set -euo pipefail
|
|
|
4
4
|
# Roll — AI Agent Convention Manager
|
|
5
5
|
# Single source of truth for how all AI coding agents behave.
|
|
6
6
|
|
|
7
|
-
VERSION="2026.
|
|
7
|
+
VERSION="2026.518.1"
|
|
8
8
|
ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
|
|
9
9
|
ROLL_CONFIG="${ROLL_HOME}/config.yaml"
|
|
10
10
|
ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
|
|
@@ -611,10 +611,7 @@ cmd_setup() {
|
|
|
611
611
|
|
|
612
612
|
echo ""
|
|
613
613
|
info "Next: run ${BOLD}roll init${NC} inside a project to initialize it. 下一步:在项目目录运行 roll init"
|
|
614
|
-
|
|
615
|
-
echo ""
|
|
616
|
-
_print_pr_pipeline_hint
|
|
617
|
-
_print_pr_event_hint
|
|
614
|
+
info "Optional gates: run ${BOLD}roll doctor${NC} inside a repo to see which PR review extras are still off. 可选闸门:在仓库内运行 roll doctor 查看 PR 评审两档开关状态"
|
|
618
615
|
}
|
|
619
616
|
|
|
620
617
|
# ─── PR pipeline hint ────────────────────────────────────────────────────────
|
|
@@ -644,6 +641,82 @@ _print_pr_pipeline_hint() {
|
|
|
644
641
|
HINT
|
|
645
642
|
}
|
|
646
643
|
|
|
644
|
+
# ─── Doctor: PR review extras section (US-PR-004) ────────────────────────────
|
|
645
|
+
# `roll doctor` is the single home for "things you could tune". The PR review
|
|
646
|
+
# extras section probes whether the two optional gates are enabled and only
|
|
647
|
+
# prints install commands for the ones that aren't, so users who already opted
|
|
648
|
+
# in (or opted out) don't get spammed each upgrade.
|
|
649
|
+
cmd_doctor() {
|
|
650
|
+
_doctor_pr_section
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
_doctor_pr_section() {
|
|
654
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
|
|
655
|
+
|
|
656
|
+
echo ""
|
|
657
|
+
echo "PR review extras PR 评审两档开关"
|
|
658
|
+
echo ""
|
|
659
|
+
|
|
660
|
+
local protection_state event_state
|
|
661
|
+
protection_state="$(_doctor_branch_protection_state)"
|
|
662
|
+
event_state="$(_doctor_event_workflow_state)"
|
|
663
|
+
|
|
664
|
+
case "$protection_state" in
|
|
665
|
+
enabled)
|
|
666
|
+
echo " ✅ AI review double gate enabled AI 评审双门已启用"
|
|
667
|
+
;;
|
|
668
|
+
disabled)
|
|
669
|
+
echo " ⚪ AI review double gate not enabled 双门未启用"
|
|
670
|
+
_print_pr_pipeline_hint
|
|
671
|
+
;;
|
|
672
|
+
*)
|
|
673
|
+
echo " ⚪ AI review double gate state unknown — requires gh auth 状态未知(需要 gh auth)"
|
|
674
|
+
_print_pr_pipeline_hint
|
|
675
|
+
;;
|
|
676
|
+
esac
|
|
677
|
+
|
|
678
|
+
case "$event_state" in
|
|
679
|
+
present)
|
|
680
|
+
echo " ✅ Event-driven PR review installed 事件驱动 PR 评审已安装"
|
|
681
|
+
;;
|
|
682
|
+
*)
|
|
683
|
+
echo " ⚪ Event-driven PR review not installed 事件驱动 PR 评审未安装"
|
|
684
|
+
_print_pr_event_hint
|
|
685
|
+
;;
|
|
686
|
+
esac
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
# Returns one of: enabled | disabled | unknown
|
|
690
|
+
_doctor_branch_protection_state() {
|
|
691
|
+
command -v gh >/dev/null 2>&1 || { echo unknown; return; }
|
|
692
|
+
|
|
693
|
+
local slug
|
|
694
|
+
slug="$(gh repo view --json owner,name --jq '.owner.login + "/" + .name' 2>/dev/null)"
|
|
695
|
+
[[ -n "$slug" ]] || { echo unknown; return; }
|
|
696
|
+
|
|
697
|
+
local required
|
|
698
|
+
required="$(gh api "repos/${slug}/branches/main/protection" \
|
|
699
|
+
--jq '.required_pull_request_reviews.required_approving_review_count // 0' \
|
|
700
|
+
2>/dev/null)"
|
|
701
|
+
|
|
702
|
+
if [[ -z "$required" ]]; then
|
|
703
|
+
echo unknown
|
|
704
|
+
elif (( required >= 1 )); then
|
|
705
|
+
echo enabled
|
|
706
|
+
else
|
|
707
|
+
echo disabled
|
|
708
|
+
fi
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
# Returns one of: present | absent
|
|
712
|
+
_doctor_event_workflow_state() {
|
|
713
|
+
if [[ -f ".github/workflows/pr-review-event.yml" ]]; then
|
|
714
|
+
echo present
|
|
715
|
+
else
|
|
716
|
+
echo absent
|
|
717
|
+
fi
|
|
718
|
+
}
|
|
719
|
+
|
|
647
720
|
_print_pr_event_hint() {
|
|
648
721
|
cat <<'HINT'
|
|
649
722
|
|
|
@@ -982,7 +1055,6 @@ _write_features_md() {
|
|
|
982
1055
|
# Features
|
|
983
1056
|
|
|
984
1057
|
> 产品视角的功能索引。每次发版时更新,使之与 BACKLOG 保持一致。
|
|
985
|
-
> Product-level feature index. Updated at release to stay in sync with BACKLOG.
|
|
986
1058
|
|
|
987
1059
|
---
|
|
988
1060
|
|
|
@@ -2270,6 +2342,13 @@ else
|
|
|
2270
2342
|
fi
|
|
2271
2343
|
|
|
2272
2344
|
FMT="${fmt_script}"
|
|
2345
|
+
# US-LOOP-004: hand loop-fmt the slug + cycle id + shared root so it can
|
|
2346
|
+
# append a per-cycle 'usage' event into events-<slug>.ndjson with
|
|
2347
|
+
# tokens / cost / model / duration. Reader (roll loop status) consumes
|
|
2348
|
+
# that instead of having to scrape the overwritten cron.log.
|
|
2349
|
+
export LOOP_PROJECT_SLUG="${slug}"
|
|
2350
|
+
export LOOP_CYCLE_ID="\${CYCLE_ID}"
|
|
2351
|
+
export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
|
|
2273
2352
|
for _attempt in 1 2 3; do
|
|
2274
2353
|
if [ -f "\$FMT" ]; then
|
|
2275
2354
|
( cd "\$WT" && ${claude_cmd} ) | python3 "\$FMT"
|
|
@@ -2511,7 +2590,7 @@ if command -v tmux >/dev/null 2>&1; then
|
|
|
2511
2590
|
# Auto-attach popup: when not muted, spawn a Terminal window attached to the
|
|
2512
2591
|
# tmux session so the user can watch the loop work in real time. Best-effort
|
|
2513
2592
|
# focus retention: capture the current frontmost app and re-activate after.
|
|
2514
|
-
if [ ! -f "\$HOME/.shared/roll/loop/mute-${slug}" ] && [ "\$(uname)" = "Darwin" ]; then
|
|
2593
|
+
if [ -z "\${ROLL_LOOP_NO_POPUP:-}" ] && [ ! -f "\$HOME/.shared/roll/loop/mute-${slug}" ] && [ "\$(uname)" = "Darwin" ]; then
|
|
2515
2594
|
# Runtime terminal detection: try preferred first, fallback through installed apps.
|
|
2516
2595
|
# open -na returns non-zero when app not found, so || chain works as fallback.
|
|
2517
2596
|
_launched=0
|
|
@@ -2893,6 +2972,16 @@ _loop_test() {
|
|
|
2893
2972
|
}
|
|
2894
2973
|
|
|
2895
2974
|
_loop_status() {
|
|
2975
|
+
# ROLL_UI=v2 (default) routes to the redesigned Python view.
|
|
2976
|
+
# Set ROLL_UI=v1 to fall back to the legacy bash implementation.
|
|
2977
|
+
if [[ "${ROLL_UI:-v2}" == "v2" ]]; then
|
|
2978
|
+
python3 "${ROLL_PKG_DIR}/lib/roll-loop-status.py" "$@"
|
|
2979
|
+
return
|
|
2980
|
+
fi
|
|
2981
|
+
_legacy_loop_status "$@"
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
_legacy_loop_status() {
|
|
2896
2985
|
local project_path; project_path=$(pwd -P)
|
|
2897
2986
|
local agent; agent=$(_project_agent)
|
|
2898
2987
|
local _is_paused=false
|
|
@@ -5303,6 +5392,7 @@ main() {
|
|
|
5303
5392
|
alert) cmd_alert "$@" ;;
|
|
5304
5393
|
agent) cmd_agent "$@" ;;
|
|
5305
5394
|
ci) cmd_ci "$@" ;;
|
|
5395
|
+
doctor) cmd_doctor "$@" ;;
|
|
5306
5396
|
review-pr) cmd_review_pr "$@" ;;
|
|
5307
5397
|
version|--version|-v) echo "roll v${VERSION}" ;;
|
|
5308
5398
|
help|--help|-h) usage ;;
|
package/lib/loop-fmt.py
CHANGED
|
@@ -116,6 +116,11 @@ class LoopFmt:
|
|
|
116
116
|
self.pending_ci = False
|
|
117
117
|
self.pending_story = False
|
|
118
118
|
self.spinner = Spinner()
|
|
119
|
+
# Track the most recent usage / model seen on assistant turns so
|
|
120
|
+
# the result event handler can emit a 'usage' event even when
|
|
121
|
+
# result.usage is missing.
|
|
122
|
+
self._last_usage = None
|
|
123
|
+
self._last_model = None
|
|
119
124
|
|
|
120
125
|
def _extract_cycle_num(self, text):
|
|
121
126
|
m = re.search(r'cycle[#\s]+(\d+)', text, re.IGNORECASE)
|
|
@@ -156,6 +161,12 @@ class LoopFmt:
|
|
|
156
161
|
|
|
157
162
|
def _handle_assistant(self, ev):
|
|
158
163
|
msg = ev.get("message", {})
|
|
164
|
+
# Remember the latest usage / model so the trailing result event
|
|
165
|
+
# can emit a 'usage' event even if result.usage is empty.
|
|
166
|
+
if msg.get("usage"):
|
|
167
|
+
self._last_usage = msg["usage"]
|
|
168
|
+
if msg.get("model"):
|
|
169
|
+
self._last_model = msg["model"]
|
|
159
170
|
for blk in msg.get("content", []):
|
|
160
171
|
btype = blk.get("type", "")
|
|
161
172
|
if btype == "thinking":
|
|
@@ -318,6 +329,46 @@ class LoopFmt:
|
|
|
318
329
|
cycle_str = f"cycle #{self.cycle_num}" if self.cycle_num else "cycle done"
|
|
319
330
|
print(stamp(f"{cycle_str} — done · {detail}" if detail else f"{cycle_str} — done", muted=True))
|
|
320
331
|
|
|
332
|
+
# US-LOOP-004 partial: emit a per-cycle 'usage' event into the
|
|
333
|
+
# durable events.ndjson so dashboards don't have to rely on the
|
|
334
|
+
# cron.log (overwritten every cycle). Skips silently when the
|
|
335
|
+
# required env vars aren't set (e.g. running outside roll loop).
|
|
336
|
+
self._emit_usage_event(ev, dur_ms, cost_usd)
|
|
337
|
+
|
|
338
|
+
def _emit_usage_event(self, result_ev, dur_ms, cost_usd):
|
|
339
|
+
slug = os.environ.get("LOOP_PROJECT_SLUG")
|
|
340
|
+
cycle = os.environ.get("LOOP_CYCLE_ID")
|
|
341
|
+
shared = os.environ.get("LOOP_SHARED_ROOT") or os.path.expanduser("~/.shared/roll")
|
|
342
|
+
if not (slug and cycle):
|
|
343
|
+
return
|
|
344
|
+
# Pull usage off the result event itself if present, otherwise off
|
|
345
|
+
# the most recent assistant turn we observed.
|
|
346
|
+
usage = (result_ev.get("usage") or self._last_usage or {})
|
|
347
|
+
model = result_ev.get("model") or self._last_model or ""
|
|
348
|
+
payload = {
|
|
349
|
+
"model": model,
|
|
350
|
+
"input_tokens": int(usage.get("input_tokens") or 0),
|
|
351
|
+
"output_tokens": int(usage.get("output_tokens") or 0),
|
|
352
|
+
"cache_creation_tokens": int(usage.get("cache_creation_input_tokens") or 0),
|
|
353
|
+
"cache_read_tokens": int(usage.get("cache_read_input_tokens") or 0),
|
|
354
|
+
"cost_reported_usd": float(cost_usd or 0),
|
|
355
|
+
"duration_ms": int(dur_ms or 0),
|
|
356
|
+
}
|
|
357
|
+
evfile = os.path.join(shared, "loop", f"events-{slug}.ndjson")
|
|
358
|
+
line = json.dumps({
|
|
359
|
+
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
360
|
+
"stage": "usage",
|
|
361
|
+
"label": cycle,
|
|
362
|
+
"detail": payload,
|
|
363
|
+
"outcome": "ok",
|
|
364
|
+
}) + "\n"
|
|
365
|
+
try:
|
|
366
|
+
os.makedirs(os.path.dirname(evfile), exist_ok=True)
|
|
367
|
+
with open(evfile, "a") as f:
|
|
368
|
+
f.write(line)
|
|
369
|
+
except Exception:
|
|
370
|
+
pass # best-effort; never break tmux output
|
|
371
|
+
|
|
321
372
|
|
|
322
373
|
def main():
|
|
323
374
|
fmt = LoopFmt()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
model_prices — list-price table for Anthropic Claude API models.
|
|
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.
|
|
7
|
+
|
|
8
|
+
Update this table when Anthropic changes pricing. Unknown models fall back
|
|
9
|
+
to sonnet rates with a stderr warning so dashboards don't blank out.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Dict, Optional
|
|
14
|
+
|
|
15
|
+
# Rates per million tokens (USD).
|
|
16
|
+
PRICES: Dict[str, Dict[str, float]] = {
|
|
17
|
+
# Claude 4.x family (current as of 2026-05).
|
|
18
|
+
"claude-opus-4-7": {"in": 15.00, "out": 75.00, "cache_create": 18.75, "cache_read": 1.50},
|
|
19
|
+
"claude-opus-4-6": {"in": 15.00, "out": 75.00, "cache_create": 18.75, "cache_read": 1.50},
|
|
20
|
+
"claude-sonnet-4-6": {"in": 3.00, "out": 15.00, "cache_create": 3.75, "cache_read": 0.30},
|
|
21
|
+
"claude-sonnet-4": {"in": 3.00, "out": 15.00, "cache_create": 3.75, "cache_read": 0.30},
|
|
22
|
+
"claude-haiku-4-5": {"in": 1.00, "out": 5.00, "cache_create": 1.25, "cache_read": 0.10},
|
|
23
|
+
# Older fallbacks
|
|
24
|
+
"claude-3-5-sonnet": {"in": 3.00, "out": 15.00, "cache_create": 3.75, "cache_read": 0.30},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
DEFAULT = "claude-sonnet-4-6"
|
|
28
|
+
_warned: set = set()
|
|
29
|
+
|
|
30
|
+
def _resolve(model: Optional[str]) -> Dict[str, float]:
|
|
31
|
+
if not model:
|
|
32
|
+
return PRICES[DEFAULT]
|
|
33
|
+
# Strip date suffixes like '-20251001' or '[1m]' context tags.
|
|
34
|
+
base = model.split("[")[0].rstrip("0123456789-")
|
|
35
|
+
# Try a prefix match against the table; longest match wins.
|
|
36
|
+
candidates = [k for k in PRICES if model.startswith(k) or base.startswith(k)]
|
|
37
|
+
if candidates:
|
|
38
|
+
return PRICES[max(candidates, key=len)]
|
|
39
|
+
if model not in _warned:
|
|
40
|
+
_warned.add(model)
|
|
41
|
+
print(f"[model_prices] warn: unknown model {model!r}, falling back to {DEFAULT}",
|
|
42
|
+
file=sys.stderr)
|
|
43
|
+
return PRICES[DEFAULT]
|
|
44
|
+
|
|
45
|
+
def compute_list_cost(model: Optional[str],
|
|
46
|
+
*,
|
|
47
|
+
input_tokens: int = 0,
|
|
48
|
+
output_tokens: int = 0,
|
|
49
|
+
cache_creation_tokens: int = 0,
|
|
50
|
+
cache_read_tokens: int = 0) -> float:
|
|
51
|
+
"""Return USD cost at list price for one cycle's token usage."""
|
|
52
|
+
p = _resolve(model)
|
|
53
|
+
total = (input_tokens * p["in"]
|
|
54
|
+
+ output_tokens * p["out"]
|
|
55
|
+
+ cache_creation_tokens * p["cache_create"]
|
|
56
|
+
+ cache_read_tokens * p["cache_read"]) / 1_000_000
|
|
57
|
+
return round(total, 4)
|
|
58
|
+
|
|
59
|
+
def total_tokens(*,
|
|
60
|
+
input_tokens: int = 0,
|
|
61
|
+
output_tokens: int = 0,
|
|
62
|
+
cache_creation_tokens: int = 0,
|
|
63
|
+
cache_read_tokens: int = 0) -> int:
|
|
64
|
+
return int(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens)
|