@seanyao/roll 2026.517.9 → 2026.518.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 CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.518.2
4
+
5
+ ### Fixed
6
+
7
+ - **`roll loop status` dashboard 崩溃 / 列对齐** — `_dash_release_ready` 用 `grep -c` 在零匹配时返回 "0" + 非零退出码再被 `|| echo 0` 追加一个 "0",拼成 "0\n0" 让 `[[ -gt ]]` 报语法错;改用 `grep | wc -l | tr -d ' '` 单值返回。Today 表头去掉 "(in progress)" 后缀(曾溢出到 Yesterday 列),数值列宽 6→8 让指标行对齐表头 `[loop]`
8
+ - **v2026.518.1 在刚发版的仓库里 dashboard 不可用** — 上面那个崩溃在 HEAD == latest tag、或 tag 之后只有 docs/chore commit 的仓库中必触发;本版作为追加修复,升到 v2026.518.2 即恢复 `[release]`
9
+
10
+ ## v2026.518.1
11
+
12
+ ### Improved
13
+
14
+ - **`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]`
15
+ - **loop 每轮成本和 token 真实可见** — 每个 cycle 结束时把模型用量(input / output / cache_creation / cache_read tokens、claude 上报的折后价、耗时)写进永久事件流,dashboard 按模型公开单价(list price)算出真实成本、按 k / m / b 显示 token,多机器 / 多项目可横向对比;历史 cycle 没写过这条事件的,自动从 claude 自己的会话日志里回灌一份 `[loop]`
16
+ - **`roll update` 不再每次刷 PR 评审两档安装提示** — 两段安装命令挪到 `roll doctor`,在 git repo 内探测分支保护和事件 workflow 的当前状态,只对未启用项显示安装指令 `[pr]`
17
+
18
+ ### Fixed
19
+
20
+ - **发版管道空版本雪球** — release.sh AI changelog 调用失败时硬退出(不再静默发空 tag);release.yml 同日合并检测到 fallback notes 时跳过合并,避免把前一个 release body 累积到新版本里 `[release]`
21
+
22
+ ## v2026.517.9
23
+
24
+ ### Improved
25
+
26
+ - **`roll-loop` 环境变量文档化** — `ROLL_LOOP_FORCE` / `ROLL_LOOP_NO_HEAL` / `ROLL_LOOP_HEAL_MAX` / `ROLL_LOOP_PR_MERGE_TIMEOUT` 四个配置项补入中英双语 configuration 指南,并加 bats 测试守护 `[loop]`
27
+ - **BACKLOG 四种条目渲染折叠** — Story / FIX / REFACTOR / IDEA 四组解析循环结构完全一样,合并为单一渲染函数,格式变更不再需要同步四处 `[refactor]`
28
+
29
+ ## v2026.517.8
30
+
31
+ > 空版本:发版脚本 AI 调用失败 fallback 未拦截,导致无实际内容的 tag 被推出。缺陷已在后续 commit `005601a` 修复(release.sh 和 release.yml 双重校验),见 Unreleased 段。
32
+
33
+ ## v2026.517.7
34
+
35
+ > 空版本:原因同 v2026.517.8。
36
+
3
37
  ## v2026.517.6
4
38
 
5
39
  ### 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.517.9"
7
+ VERSION="2026.518.2"
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
@@ -5038,8 +5127,8 @@ _dash_release_ready() {
5038
5127
  latest_tag=$(git describe --tags --abbrev=0 2>/dev/null) || return 1
5039
5128
  local commits_with_code
5040
5129
  commits_with_code=$(git log "${latest_tag}..HEAD" --pretty=format:%s 2>/dev/null \
5041
- | grep -cvE '^(docs|chore)(\([^)]*\))?:[[:space:]]' 2>/dev/null \
5042
- || echo 0)
5130
+ | grep -vE '^(docs|chore)(\([^)]*\))?:[[:space:]]' \
5131
+ | wc -l | tr -d ' ')
5043
5132
  [[ "${commits_with_code:-0}" -gt 0 ]] || return 1
5044
5133
  local latest
5045
5134
  latest=$(ls docs/briefs/*.md 2>/dev/null | sort | tail -1 || true)
@@ -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)