@seanyao/roll 2026.528.2 → 2026.529.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 CHANGED
@@ -1,26 +1,39 @@
1
1
  # Changelog
2
2
 
3
- ## v2026.528.2
3
+ ## v2026.529.1
4
4
 
5
5
  ### Added
6
6
 
7
- - **loop 换机器跑不会再拿过期 backlog** 每轮自动拉最新项目元数据 `[loop]`
8
- - **CI 红了 loop 不再干等** — 先试着自己修,修不好再找人 `[loop]`
7
+ - **loop 运行时间可以按项目设**不用再全局迁就
9
8
 
10
9
  ### Fixed
11
10
 
12
- - **`roll loop log` 现在真的能看了**每轮 cycle 的留档修好了 `[loop]`
13
- - **loop 跑完的终端窗口不再瞬间清空**关闭前能看到本轮摘要 `[loop]`
11
+ - **`roll loop on` 不再显示全 00:00** 时间显示正常了
12
+ - **agent 检测不再误报**没装的工具不会被当成可用
14
13
 
15
- ## v2026.528.1
14
+ ## v2026.528.2
16
15
 
17
16
  ### Added
18
17
 
19
- - **`roll test`测试隔离运行,不再误伤本机 loop 服务** `[loop]`
18
+ - **loop 换机器跑不会再拿过期 backlog** 以前在 A 机器跑的 loop,搬到 B 机器继续时会用本地的旧 backlog,不知道有什么新待办;现在每轮开始前自动拉一次最新状态,多台机器始终看同一份 `[loop]`
19
+
20
+ - **CI 红了 loop 不再干等** — 主干测试挂掉时,loop 以前会停下等人去修;现在先自己分析失败原因、发一个修复,试满 3 次还没修好才发告警找人;自己开的 PR 被 CI 标红后也会自动续上去修,不会因为"本轮 loop 已经结束"就悄悄放弃 `[loop]`
21
+
22
+ - **`roll test` — 测试跑在独立环境里,不会再误伤本机** — 以前跑完整测试套件会触碰本机的 loop 调度服务,测试一过就把正在运行的 loop 打掉;现在测试在独立的 macOS VM 里跑,本机的 loop 完全不受影响 `[test]`
20
23
 
21
24
  ### Fixed
22
25
 
23
- - **Kimi CLI 升级改名为 kimi-code 后,roll 现在能正常识别** `[loop]`
26
+ - **Kimi CLI 改名后全链路都能识别了** — Kimi 把工具从 kimi-cli 改名为 kimi-code、安装目录也换了;Roll 现在新旧名字都能认出来,kimi-code 的安装路径也加进了自动查找范围,调度环境里也能找到,已设好的旧配置不需要动 `[agent]`
27
+
28
+ - **`roll loop log` 现在真的能看了** — 每轮 cycle 的日志存档修好了;以前文件根本没生成,现在用 `roll loop log` 能查到每一轮跑了什么 `[loop]`
29
+
30
+ - **loop 跑完的终端窗口不再瞬间消失** — 以前 cycle 结束 Terminal 窗口立刻关掉,来不及看本轮干了什么;现在窗口留着直到你自己关 `[loop]`
31
+
32
+ ### Improved
33
+
34
+ - **提交安全检查不会再被静默绕过** — Roll 的 TCR 要求每次提交前先过测试;以前在新终端或新机器上开工,这道检查会因为 git 配置没自动生效而悄悄失效,自动化环境尤其容易中招;现在每次打开 Claude Code 新会话、或跑 `roll setup` 都会自动配好,这个漏洞从源头堵死 `[infra]`
35
+
36
+ - **macOS 自带 bash 下中文命名的测试用例不再被静默跳过** — macOS 系统自带的旧版 bash 在处理中文或特殊字符测试名时会截断,导致这些测试根本没执行却也不报错,本地看起来全绿但实际上漏了一批用例;已修复,本地和 CI 结果现在一致 `[test]`
24
37
 
25
38
  ## v2026.527.1
26
39
 
package/README.md CHANGED
@@ -50,6 +50,7 @@ roll loop on # let AI work through the backlog (optional)
50
50
  | `roll status` | Show current state and drift |
51
51
  | `roll agent [use <name>]` | Per-project agent selection |
52
52
  | `roll ci [--wait]` | Show or wait for current commit's CI status |
53
+ | `roll test [--where] [--reset]` | Run the test suite (routes through isolation adapter; Tart VM on Apple Silicon) |
53
54
  | `roll release` | Run the release script (human-only) |
54
55
  | `roll review-pr <number>` | AI-powered code review for a PR |
55
56
  | **Machine · global** | |
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.528.2"
7
+ VERSION="2026.529.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"
@@ -83,31 +83,80 @@ lower_name() {
83
83
  }
84
84
 
85
85
 
86
- # Check if an AI tool is actually installed.
87
- # Most tools create their own config dir; Trae on macOS uses Library/Application Support
88
- # and expects roll to manage ~/.trae/ so we detect via the app directory instead.
89
- _is_ai_installed() {
90
- local ai_dir="$1"
91
- [[ -d "$ai_dir" ]] && return 0
92
- local bn
93
- bn="$(basename "$ai_dir" | sed 's/^\.//')"
94
- case "$bn" in
86
+ # FIX-128: agent binary-name(s) lookup. First binary found wins. Used by
87
+ # _agent_installed_by_name to enforce "CLI must exist on PATH" detection
88
+ # instead of the old "config dir exists" check (which Roll's own convention
89
+ # sync would fake — see FIX-128).
90
+ _agent_bin_names() {
91
+ case "$1" in
92
+ claude) echo "claude" ;;
93
+ codex|openai) echo "codex" ;; # openai is a Roll alias for codex
94
+ agy|gemini) echo "agy gemini" ;; # gemini reuses ~/.gemini for agy
95
+ kimi) echo "kimi-code kimi-cli kimi" ;; # FIX-126
96
+ deepseek) echo "deepseek" ;;
97
+ qwen) echo "qwen" ;;
98
+ pi) echo "pi" ;;
99
+ *) return 1 ;;
100
+ esac
101
+ }
102
+
103
+ # FIX-128: detect whether an AI agent (by canonical name) is actually
104
+ # usable on this machine. For CLI-only agents this means "binary on PATH";
105
+ # GUI / bundled-binary agents keep their special-case paths. Falls back
106
+ # to dir-existence only for unknown agents the operator has registered
107
+ # manually (forward-compatible with future additions).
108
+ _agent_installed_by_name() {
109
+ local agent="$1"
110
+ local dir="${2:-}"
111
+ case "$agent" in
95
112
  trae)
96
- [[ -d "$HOME/Library/Application Support/Trae" ]] ||
97
- [[ -d "$HOME/.config/Trae" ]]
113
+ [[ -d "$HOME/Library/Application Support/Trae" ]] || [[ -d "$HOME/.config/Trae" ]]
98
114
  return
99
115
  ;;
100
116
  opencode)
101
117
  [[ -x "$HOME/.opencode/bin/opencode" ]]
102
118
  return
103
119
  ;;
104
- agent)
105
- if [[ "$(basename "$(dirname "$ai_dir")")" == ".pi" ]]; then
106
- command -v pi &>/dev/null && return
107
- fi
120
+ cursor)
121
+ # cursor ships a GUI + an optional CLI; either path counts as "installed".
122
+ command -v cursor >/dev/null 2>&1 || [[ -d "$HOME/.cursor" ]]
123
+ return
124
+ ;;
125
+ openclaw)
126
+ [[ -d "$HOME/.openclaw/workspace" ]]
127
+ return
108
128
  ;;
109
129
  esac
110
- return 1
130
+ local bins
131
+ if bins=$(_agent_bin_names "$agent" 2>/dev/null); then
132
+ local b
133
+ for b in $bins; do
134
+ command -v "$b" >/dev/null 2>&1 && return 0
135
+ done
136
+ return 1
137
+ fi
138
+ # Unknown agent — fall back to dir presence so user-added entries still work.
139
+ [[ -n "$dir" && -d "$dir" ]]
140
+ }
141
+
142
+ # Check if an AI tool is actually installed (back-compat shim around
143
+ # _agent_installed_by_name; preserves the dir-path-based signature used
144
+ # throughout bin/roll).
145
+ _is_ai_installed() {
146
+ local ai_dir="$1"
147
+ local bn
148
+ bn="$(basename "$ai_dir" | sed 's/^\.//')"
149
+ # Nested-dir layouts collapse to their parent agent name.
150
+ case "$bn" in
151
+ agent|workspace)
152
+ bn="$(basename "$(dirname "$ai_dir")" | sed 's/^\.//')"
153
+ ;;
154
+ esac
155
+ # Mirror ai_tool_name's alias normalization so detection routes to the
156
+ # canonical agent record (e.g. ~/.gemini → agy, ~/.kimi-code → kimi).
157
+ [[ "$bn" == "gemini" ]] && bn="agy"
158
+ [[ "$bn" == "kimi-code" ]] && bn="kimi"
159
+ _agent_installed_by_name "$bn" "$ai_dir"
111
160
  }
112
161
 
113
162
  # ─── Spinner: TTY-aware status display for long-running steps (US-REL-003) ───
@@ -512,8 +561,7 @@ editor: ${EDITOR:-vim}
512
561
 
513
562
  # Loop schedule (24h format, machine local timezone)
514
563
  # Minute fields auto-derive from project path hash when omitted — avoids contention across projects.
515
- loop_active_start: 10 # loop only fires inside this window (after human reviews brief)
516
- loop_active_end: 18
564
+ # active_start/active_end moved to per-project .roll/local.yaml loop_schedule block (default 0/24).
517
565
  # loop_minute: 5 # omit to auto-derive from project hash
518
566
  loop_dream_hour: 3
519
567
  # loop_dream_minute: 10 # omit to auto-derive
@@ -522,6 +570,19 @@ loop_brief_hour: 9
522
570
  primary_agent: claude
523
571
  YAML
524
572
  ok "$(msg shared.created_roll_config_yaml)"
573
+
574
+ # FIX-128: the heredoc template hardcodes `primary_agent: claude` for
575
+ # the first-time case. Replace it with the first agent that actually
576
+ # has its CLI on PATH so users without Claude installed don't get a
577
+ # silently-broken default. If nothing detected, leave `claude` so the
578
+ # user still has a clear handle to fix manually.
579
+ local _detected_primary
580
+ _detected_primary="$(_first_installed_agent || true)"
581
+ if [[ -n "$_detected_primary" && "$_detected_primary" != "claude" ]]; then
582
+ _replace_primary_agent "$_detected_primary"
583
+ info "$(msg shared.primary_agent_auto_detected "$_detected_primary" 2>/dev/null \
584
+ || echo "primary_agent → $_detected_primary (auto-detected from installed CLIs)")"
585
+ fi
525
586
  fi
526
587
 
527
588
  # Ensure all expected ai_* keys exist (handles upgrades where new tools were added)
@@ -529,6 +590,32 @@ YAML
529
590
 
530
591
  }
531
592
 
593
+ # FIX-128: pick the first agent whose CLI is on PATH, scanning the same
594
+ # order the default config template lists them. Empty stdout when none
595
+ # detected; never errors.
596
+ _first_installed_agent() {
597
+ local agent
598
+ for agent in claude codex kimi deepseek qwen agy pi cursor opencode trae openclaw; do
599
+ if _agent_installed_by_name "$agent"; then
600
+ echo "$agent"
601
+ return 0
602
+ fi
603
+ done
604
+ return 1
605
+ }
606
+
607
+ # FIX-128: rewrite the `primary_agent:` line in $ROLL_CONFIG to the given
608
+ # value. Single-line in-place edit, preserves the rest of the file.
609
+ _replace_primary_agent() {
610
+ local new="$1"
611
+ [[ -f "$ROLL_CONFIG" && -n "$new" ]] || return 0
612
+ local tmp; tmp="$(mktemp)"
613
+ awk -v new="$new" '
614
+ /^primary_agent:/ { print "primary_agent: " new; next }
615
+ { print }
616
+ ' "$ROLL_CONFIG" > "$tmp" && mv "$tmp" "$ROLL_CONFIG"
617
+ }
618
+
532
619
  # ─── Internal: create or repair per-skill symlinks (non-destructive) ─────────
533
620
  _link_skills() {
534
621
  local force="${1:-false}"
@@ -539,7 +626,18 @@ _link_skills() {
539
626
  while IFS= read -r entry; do
540
627
  local ai_dir
541
628
  ai_dir="$(_ai_dir "$entry")"
542
- _is_ai_installed "$ai_dir" || continue
629
+ # FIX-128: detection is now binary-on-PATH, but skill linking keeps
630
+ # the same Claude-always-syncs semantics as _apply_conventions and
631
+ # tolerates pre-existing config dirs (an agent the user is mid-
632
+ # upgrade or installed via nvm/asdf still has its convention dir;
633
+ # we don't want to silently stop linking skills there). Strict
634
+ # binary detection drives chooser logic (primary_agent /
635
+ # _onboard_discover_agents) — see FIX-128.
636
+ if [[ "$ai_dir" != "$HOME/.claude" ]] \
637
+ && ! _is_ai_installed "$ai_dir" \
638
+ && [[ ! -d "$ai_dir" ]]; then
639
+ continue
640
+ fi
543
641
  mkdir -p "$ai_dir"
544
642
 
545
643
  local ai_name ai_dir_real skills_dir
@@ -639,8 +737,13 @@ _sync_convention_for_tool() {
639
737
  local dst_dir
640
738
  dst_dir="$(dirname "$main_dst")"
641
739
 
642
- # Only proceed if Claude (always) or the tool is installed
643
- if [[ "$dst_dir" != "$HOME/.claude" ]] && ! _is_ai_installed "$dst_dir"; then
740
+ # Only proceed if Claude (always), the tool is installed (binary-on-PATH
741
+ # per FIX-128), or the convention dir already exists (mid-upgrade /
742
+ # nvm-installed binaries that aren't on this shell's PATH still get
743
+ # their convention refresh).
744
+ if [[ "$dst_dir" != "$HOME/.claude" ]] \
745
+ && ! _is_ai_installed "$dst_dir" \
746
+ && [[ ! -d "$dst_dir" ]]; then
644
747
  return
645
748
  fi
646
749
  mkdir -p "$dst_dir"
@@ -927,10 +1030,46 @@ HINT
927
1030
  # prints install commands for the ones that aren't, so users who already opted
928
1031
  # in (or opted out) don't get spammed each upgrade.
929
1032
  cmd_doctor() {
1033
+ _doctor_agent_section
930
1034
  _doctor_pr_section
931
1035
  _doctor_launchd_stale_section
932
1036
  }
933
1037
 
1038
+ # FIX-128: list every ai_* entry from config, tag each with binary-on-PATH
1039
+ # status and config-dir existence so the user can see at a glance which
1040
+ # agents are actually usable vs only have Roll-maintained dirs.
1041
+ _doctor_agent_section() {
1042
+ [[ -f "$ROLL_CONFIG" ]] || return 0
1043
+ echo ""
1044
+ echo "$(ROLL_LANG_RESOLVED=en msg doctor.agent_detection)"
1045
+ echo "$(ROLL_LANG_RESOLVED=zh msg doctor.agent_detection)"
1046
+ echo ""
1047
+ local _key _value _name _dir _installed _dir_exists _is_primary
1048
+ _is_primary=$(grep -E '^primary_agent:' "$ROLL_CONFIG" 2>/dev/null | sed 's/^primary_agent: *//')
1049
+ while IFS=: read -r _key _value; do
1050
+ [[ "$_key" =~ ^ai_ ]] || continue
1051
+ _name="${_key#ai_}"
1052
+ [[ "$_name" == "kimi_code" ]] && continue # dedupe
1053
+ _dir="${_value%%|*}"
1054
+ _dir="${_dir# }"
1055
+ _dir="${_dir/#\~/$HOME}"
1056
+ if _agent_installed_by_name "$_name" "$_dir"; then
1057
+ _installed="$(msg doctor.agent_installed)"
1058
+ else
1059
+ _installed="$(msg doctor.agent_missing)"
1060
+ fi
1061
+ if [[ -d "$_dir" ]]; then
1062
+ _dir_exists="$(msg doctor.agent_dir_exists)"
1063
+ else
1064
+ _dir_exists="$(msg doctor.agent_dir_missing)"
1065
+ fi
1066
+ local _tag=""
1067
+ [[ "$_name" == "$_is_primary" ]] && _tag=" ($(msg doctor.agent_primary_label))"
1068
+ printf " %-10s %-14s %s%s\n" "$_name" "$_installed" "$_dir_exists" "$_tag"
1069
+ done < "$ROLL_CONFIG"
1070
+ return 0
1071
+ }
1072
+
934
1073
  # FIX-097: scan ${_LAUNCHD_DIR}/com.roll.*.plist for entries whose
935
1074
  # WorkingDirectory no longer exists on disk. These are the ghost agents left
936
1075
  # behind when a user manually reproduces a bug under /private/tmp/ or
@@ -1467,15 +1606,31 @@ _onboard_discover_agents() {
1467
1606
  while IFS=: read -r _key _value; do
1468
1607
  [[ "$_key" =~ ^ai_ ]] || continue
1469
1608
  _name="${_key#ai_}"
1609
+ # ai_kimi_code → kimi (avoid listing the same agent twice).
1610
+ [[ "$_name" == "kimi_code" ]] && _name="kimi"
1470
1611
  _dir="${_value%%|*}"
1471
1612
  _dir="${_dir# }"
1472
1613
  _dir="${_dir/#\~/$HOME}"
1473
- if [[ -d "$_dir" ]]; then
1474
- _ONBOARD_INSTALLED+=("$_name")
1614
+ # FIX-128: route via _agent_installed_by_name so "installed" means the
1615
+ # CLI is actually on PATH for known agents, not just the config dir
1616
+ # that Roll's own convention sync would have created.
1617
+ if _agent_installed_by_name "$_name" "$_dir"; then
1618
+ # Dedupe — kimi may appear under both ai_kimi and ai_kimi_code.
1619
+ # `${arr[@]+...}` keeps `set -u` happy when the array is still empty.
1620
+ local _already=0 _existing
1621
+ for _existing in ${_ONBOARD_INSTALLED[@]+"${_ONBOARD_INSTALLED[@]}"}; do
1622
+ if [[ "$_existing" == "$_name" ]]; then _already=1; break; fi
1623
+ done
1624
+ if [[ $_already -eq 0 ]]; then _ONBOARD_INSTALLED+=("$_name"); fi
1475
1625
  else
1476
- _ONBOARD_MISSING+=("$_name")
1626
+ local _already=0 _existing
1627
+ for _existing in ${_ONBOARD_MISSING[@]+"${_ONBOARD_MISSING[@]}"}; do
1628
+ if [[ "$_existing" == "$_name" ]]; then _already=1; break; fi
1629
+ done
1630
+ if [[ $_already -eq 0 ]]; then _ONBOARD_MISSING+=("$_name"); fi
1477
1631
  fi
1478
1632
  done < "$ROLL_CONFIG"
1633
+ return 0
1479
1634
  }
1480
1635
 
1481
1636
  # US-ONBOARD-018: pick an agent for the onboard flow.
@@ -4624,13 +4779,16 @@ _isolation_tart_check_binary() {
4624
4779
  # returns 1 silently otherwise. Caller decides what to do.
4625
4780
  _isolation_tart_vm_present() {
4626
4781
  local name; name=$(_isolation_tart_vm_name)
4627
- tart list 2>/dev/null | awk -v n="$name" '$1 == n { found=1 } END { exit !found }'
4782
+ tart list 2>/dev/null | awk -v n="$name" '$2 == n { found=1 } END { exit !found }'
4628
4783
  }
4629
4784
 
4630
4785
  # Returns the VM's IP on stdout when reachable; exit non-zero when the VM
4631
4786
  # is stopped or `tart ip` fails for any other reason.
4632
4787
  _isolation_tart_ip() {
4633
4788
  local name; name=$(_isolation_tart_vm_name)
4789
+ # FIX: tart ip returns a stale DHCP-cached IP even for stopped VMs.
4790
+ # Gate on tart list State field before trusting the IP.
4791
+ tart list 2>/dev/null | awk -v n="$name" '$2 == n && $NF == "running" { found=1 } END { exit !found }' || return 1
4634
4792
  local ip; ip=$(tart ip "$name" 2>/dev/null) || return 1
4635
4793
  [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1
4636
4794
  printf '%s\n' "$ip"
@@ -4680,7 +4838,7 @@ _isolation_tart_provision() {
4680
4838
  local ip; ip=$(_isolation_tart_ip) || { err "tart provision: VM not running"; return 1; }
4681
4839
  local user; user=$(_isolation_tart_ssh_user)
4682
4840
  ssh -o BatchMode=yes -o StrictHostKeyChecking=no \
4683
- "${user}@${ip}" "brew list bats >/dev/null 2>&1 || brew install bats-core; \
4841
+ "${user}@${ip}" "export PATH=/opt/homebrew/bin:/usr/local/bin:\$PATH; brew list bats >/dev/null 2>&1 || brew install bats-core; \
4684
4842
  brew list node >/dev/null 2>&1 || brew install node; \
4685
4843
  brew list bash >/dev/null 2>&1 || brew install bash"
4686
4844
  }
@@ -4695,7 +4853,7 @@ _isolation_tart_exec() {
4695
4853
  if ! ip=$(_isolation_tart_ip); then
4696
4854
  # VM stopped — start it in the background with the repo mounted.
4697
4855
  local repo_root; repo_root="$(pwd -P)"
4698
- tart run --dir="roll:${repo_root}" "$name" >/dev/null 2>&1 &
4856
+ tart run --no-graphics --dir="roll:${repo_root}" "$name" >/dev/null 2>&1 &
4699
4857
  # Wait up to ~30s for IP to come up.
4700
4858
  local i=0
4701
4859
  while (( i < 30 )); do
@@ -4706,7 +4864,9 @@ _isolation_tart_exec() {
4706
4864
  [[ -n "${ip:-}" ]] || { err "tart exec: VM failed to start in 30s"; return 1; }
4707
4865
  fi
4708
4866
  local user; user=$(_isolation_tart_ssh_user)
4709
- ssh -o BatchMode=yes -o StrictHostKeyChecking=no "${user}@${ip}" "$@"
4867
+ local remote_cmd
4868
+ remote_cmd=$(printf '%q ' "$@")
4869
+ ssh -o BatchMode=yes -o StrictHostKeyChecking=no "${user}@${ip}" "export PATH=/opt/homebrew/bin:/usr/local/bin:\$PATH; cd '/Volumes/My Shared Files/roll' && $remote_cmd"
4710
4870
  }
4711
4871
 
4712
4872
  # reset: stop, delete, re-clone from base image, then re-provision.
@@ -4810,7 +4970,8 @@ Flags:
4810
4970
  --help, -h Show this help.
4811
4971
 
4812
4972
  Examples:
4813
- roll test Run the suite in whatever the config says.
4973
+ roll test Run affected tests (default: --affected HEAD~1).
4974
+ roll test -- tests/ Run the full suite explicitly.
4814
4975
  roll test -- --tier=fast Forward arguments to npm test.
4815
4976
  roll test --where Don't run; just report routing.
4816
4977
  roll test --reset Rebuild the VM (or host no-op).
@@ -4855,7 +5016,16 @@ EOF
4855
5016
  fi
4856
5017
 
4857
5018
  # Pass remaining args through to npm test inside the configured adapter.
4858
- _isolation_dispatch exec npm test "$@"
5019
+ # Default to --affected (HEAD~1 base) when the caller passes no extra args —
5020
+ # mirrors the pre-commit hook's intent and keeps VM runs fast.
5021
+ # To run the full suite explicitly: roll test -- tests/
5022
+ local _npm_args=("$@")
5023
+ if [[ "${#_npm_args[@]}" -eq 0 ]]; then
5024
+ _npm_args=(--affected)
5025
+ fi
5026
+ # Always pass args via `--` so npm doesn't intercept flags like --affected
5027
+ # as npm config options (npm warns and silently drops them otherwise).
5028
+ _isolation_dispatch exec npm test -- "${_npm_args[@]}"
4859
5029
  }
4860
5030
 
4861
5031
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -5431,6 +5601,28 @@ _loop_schedule_spec() {
5431
5601
  echo "60 $offset"
5432
5602
  }
5433
5603
 
5604
+ # Read loop active window from .roll/local.yaml loop_schedule block.
5605
+ # Resolution order:
5606
+ # 1. .roll/local.yaml loop_schedule.{active_start,active_end}
5607
+ # 2. default 0 / 24 (full day)
5608
+ # Validation: both values must be integers 0–24, active_start < active_end.
5609
+ # Output: "<start> <end>" on stdout.
5610
+ _loop_read_active_window() {
5611
+ local project_path="${1:-$(pwd -P)}"
5612
+ local local_file="${project_path}/.roll/local.yaml"
5613
+ if [[ -f "$local_file" ]]; then
5614
+ local val_start val_end
5615
+ val_start=$(awk '/^loop_schedule:/{found=1;next} found && /^[[:space:]]+active_start:/{print $2; exit}' "$local_file")
5616
+ val_end=$(awk '/^loop_schedule:/{found=1;next} found && /^[[:space:]]+active_end:/{print $2; exit}' "$local_file")
5617
+ if [[ "$val_start" =~ ^[0-9]+$ && "$val_end" =~ ^[0-9]+$ ]] \
5618
+ && (( val_start < val_end && val_end <= 24 )); then
5619
+ echo "$val_start $val_end"
5620
+ return 0
5621
+ fi
5622
+ fi
5623
+ echo "0 24"
5624
+ }
5625
+
5434
5626
  # US-LOOP-032: human-readable schedule description.
5435
5627
  # Args: period offset [lang]
5436
5628
  # lang: en (default) or zh
@@ -6625,8 +6817,8 @@ _install_launchd_plists() {
6625
6817
  mkdir -p "${shared}/loop" "${shared}/dream" "${shared}/brief"
6626
6818
 
6627
6819
  local active_start active_end dream_hour dream_minute brief_hour brief_minute loop_period loop_offset
6628
- active_start=$(_config_read_int "loop_active_start" "10")
6629
- active_end=$(_config_read_int "loop_active_end" "18")
6820
+ local _aw; _aw=$(_loop_read_active_window "$project_path")
6821
+ active_start="${_aw%% *}"; active_end="${_aw##* }"
6630
6822
  # US-LOOP-012: use _loop_schedule_spec instead of raw loop_minute
6631
6823
  local loop_spec; loop_spec=$(_loop_schedule_spec "$project_path")
6632
6824
  loop_period="${loop_spec%% *}"
@@ -6802,8 +6994,8 @@ _loop_on() {
6802
6994
  local agent; agent=$(_project_agent)
6803
6995
 
6804
6996
  local active_start active_end loop_minute dream_hour dream_minute brief_hour brief_minute
6805
- active_start=$(_config_read_int "loop_active_start" "10")
6806
- active_end=$(_config_read_int "loop_active_end" "18")
6997
+ local _aw; _aw=$(_loop_read_active_window "$project_path")
6998
+ active_start="${_aw%% *}"; active_end="${_aw##* }"
6807
6999
  # US-LOOP-011: read schedule spec from project or global config
6808
7000
  local loop_spec loop_period loop_offset
6809
7001
  loop_spec=$(_loop_schedule_spec "$project_path")
@@ -6849,10 +7041,10 @@ _loop_on() {
6849
7041
  fi
6850
7042
 
6851
7043
  ok "$(msg loop.loop_enabled)"
6852
- printf "$(msg loop.roll_loop_s_active_02d_00)" \
7044
+ msg loop.roll_loop_s_active_02d_00 \
6853
7045
  "$loop_sched_en" "$active_start" "$active_end" "$loop_sched_zh" "$active_start" "$active_end"
6854
- printf "$(msg loop.roll_dream_daily_at_02d_02d)" "$dream_hour" "$dream_minute" "$dream_hour" "$dream_minute"
6855
- printf "$(msg loop.roll_brief_daily_at_02d_02d)" "$brief_hour" "$brief_minute" "$brief_hour" "$brief_minute"
7046
+ msg loop.roll_dream_daily_at_02d_02d "$dream_hour" "$dream_minute" "$dream_hour" "$dream_minute"
7047
+ msg loop.roll_brief_daily_at_02d_02d "$brief_hour" "$brief_minute" "$brief_hour" "$brief_minute"
6856
7048
  echo " • Agent: ${agent} (change: roll agent use <name>)"
6857
7049
  return 0
6858
7050
  fi
@@ -6880,10 +7072,10 @@ _loop_on() {
6880
7072
  ) | crontab -
6881
7073
 
6882
7074
  ok "$(msg loop.loop_enabled_2)"
6883
- printf "$(msg loop.roll_loop_s_active_02d_00_2)" \
7075
+ msg loop.roll_loop_s_active_02d_00_2 \
6884
7076
  "$loop_sched_en" "$active_start" "$active_end" "$loop_sched_zh" "$active_start" "$active_end"
6885
- printf "$(msg loop.roll_dream_daily_at_02d_02d_2)" "$dream_hour" "$dream_minute" "$dream_hour" "$dream_minute"
6886
- printf "$(msg loop.roll_brief_daily_at_02d_02d_2)" "$brief_hour" "$brief_minute" "$brief_hour" "$brief_minute"
7077
+ msg loop.roll_dream_daily_at_02d_02d_2 "$dream_hour" "$dream_minute" "$dream_hour" "$dream_minute"
7078
+ msg loop.roll_brief_daily_at_02d_02d_2 "$brief_hour" "$brief_minute" "$brief_hour" "$brief_minute"
6887
7079
  echo " • Agent: ${agent} (change: roll agent use <name>)"
6888
7080
  }
6889
7081
 
@@ -7023,8 +7215,8 @@ _loop_test() {
7023
7215
 
7024
7216
  # FIX-054: terminal preference removed — runner always uses Terminal.app.
7025
7217
  local active_start active_end
7026
- active_start=$(_config_read_int "loop_active_start" "10")
7027
- active_end=$(_config_read_int "loop_active_end" "18")
7218
+ local _aw; _aw=$(_loop_read_active_window "$project_path")
7219
+ active_start="${_aw%% *}"; active_end="${_aw##* }"
7028
7220
 
7029
7221
  info "$(msg loop.generating_test_runner_agent ${agent})"
7030
7222
  _write_loop_runner_script "$test_runner" "$project_path" \
@@ -9307,8 +9499,8 @@ _loop_monitor() {
9307
9499
  echo -e "$(msg loop.services ${BOLD} ${NC} ${CYAN} ${agent})"
9308
9500
  if [[ "$(uname)" == "Darwin" ]]; then
9309
9501
  local active_start active_end dream_hour dream_minute brief_hour brief_minute
9310
- active_start=$(_config_read_int "loop_active_start" "10")
9311
- active_end=$(_config_read_int "loop_active_end" "18")
9502
+ local _aw; _aw=$(_loop_read_active_window "$project_path")
9503
+ active_start="${_aw%% *}"; active_end="${_aw##* }"
9312
9504
  # US-LOOP-013: use schedule spec for display
9313
9505
  local loop_spec loop_period loop_offset
9314
9506
  loop_spec=$(_loop_schedule_spec "$project_path")
@@ -10178,8 +10370,8 @@ _legacy_home() {
10178
10370
  crontab -l 2>/dev/null | grep -q "${_LOOP_TAG}:${project_path}" && loop_state="enabled"
10179
10371
  fi
10180
10372
  local active_start active_end dream_hour dream_minute brief_hour brief_minute
10181
- active_start=$(_config_read_int "loop_active_start" "10")
10182
- active_end=$(_config_read_int "loop_active_end" "18")
10373
+ local _aw; _aw=$(_loop_read_active_window "$project_path")
10374
+ active_start="${_aw%% *}"; active_end="${_aw##* }"
10183
10375
  # US-LOOP-013: use schedule spec for display
10184
10376
  local loop_spec loop_period loop_offset
10185
10377
  loop_spec=$(_loop_schedule_spec "$project_path")
package/lib/README.md ADDED
@@ -0,0 +1,42 @@
1
+ > **Draft** — auto-generated by roll-doc on 2026-05-28. Review before treating as authoritative.
2
+
3
+ # lib/ — Python helpers and i18n runtime
4
+
5
+ Python scripts and shell libraries that `bin/roll` delegates to for rendering-heavy or data-processing tasks.
6
+
7
+ ## Key files
8
+
9
+ | File | Purpose |
10
+ |------|---------|
11
+ | `roll-loop-status.py` | Renders the `roll loop status` health dashboard — reads cycle event NDJSON, computes per-cycle rows, daily rollups, and phase-tracing breakdown |
12
+ | `roll-loop-story.py` | Per-story rollup: aggregates cycles, tokens, cost, and PR outcomes for `roll loop story <ID>` |
13
+ | `roll-status.py` | Renders the `roll status` one-screen sync health view |
14
+ | `roll-init.py` | Init-flow helpers called by `roll init` |
15
+ | `roll-setup.py` | Setup-flow helpers (convention sync, tool config write) |
16
+ | `roll-brief.py` | Brief generation: reads cycle records and produces the feature brief |
17
+ | `roll-backlog.py` | Backlog read/write helpers |
18
+ | `roll-peer.py` | Peer review coordination helpers |
19
+ | `roll-help.py` | Renders `roll --help` output |
20
+ | `roll-plan-validate.py` | Validates plan files before story execution |
21
+ | `model_prices.py` | List-price table for AI model API pricing (per MTok, native currency) |
22
+ | `prices_fetcher.py` | Fetches fresh price snapshots from vendor APIs |
23
+ | `roll_render.py` | Shared rendering utilities (tables, color, formatting) |
24
+ | `loop-fmt.py` | Loop log formatter (ANSI-strip, timestamp alignment) |
25
+ | `loop_unstick.py` | Diagnostic: detects and unsticks hung loop state |
26
+ | `backfill-pi-usage.py` | Backfills pi/deepseek token and cost data into existing cycle records |
27
+ | `changelog_audit.py` | Audits CHANGELOG.md against backlog entries |
28
+ | `i18n.sh` | Shell wrapper that delegates i18n string lookups to `lib/i18n/` |
29
+ | `slides-render.py` | Renders `.deck.md` → HTML slides |
30
+ | `slides-validate.py` | Validates deck file syntax and asset references |
31
+
32
+ ## Sub-directories
33
+
34
+ - `agent_usage/` — token-usage capture and cost attribution per agent invocation
35
+ - `i18n/` — localized string tables for all CLI output (EN + ZH)
36
+ - `prices/` — price snapshot JSON files (per-vendor, dated)
37
+ - `slides/` — slide component library for `roll deck`
38
+
39
+ ## Dependencies
40
+
41
+ Imported by `bin/roll` via subprocess calls (`python3 lib/<script>.py`).
42
+ No third-party pip dependencies — standard library only (json, sys, os, re, datetime).
@@ -0,0 +1,54 @@
1
+ > **Draft** — auto-generated by roll-doc on 2026-05-28. Review before treating as authoritative.
2
+
3
+ # lib/i18n/ — Localized string tables
4
+
5
+ All user-visible CLI output strings for both `en` and `zh` locales, organized by command domain.
6
+
7
+ ## Structure
8
+
9
+ Each `.sh` file under `lib/i18n/` is a shell associative-array fragment exporting a `MSG_*` namespace:
10
+
11
+ ```
12
+ lib/i18n/
13
+ ├── agent.sh # roll agent use / install messages
14
+ ├── alert.sh # ALERT lifecycle messages
15
+ ├── backlog.sh # backlog read/write output
16
+ ├── brief.sh # roll-brief generation output
17
+ ├── changelog.sh # changelog sync messages
18
+ ├── ci.sh # CI self-heal messages
19
+ ├── debug.sh # roll debug diagnostics
20
+ ├── doctor.sh # roll-doctor check output
21
+ ├── dream.sh # roll-.dream scan output
22
+ ├── init.sh # roll init setup messages
23
+ ├── lang.sh # locale detection + ROLL_LANG resolution
24
+ ├── loop.sh # roll loop subcommand output (largest file)
25
+ ├── migrate.sh # roll migrate messages
26
+ ├── offboard.sh # roll offboard output
27
+ ├── onboard.sh # roll onboard / legacy-onboard output
28
+ ├── peer.sh # roll peer review messages
29
+ ├── peer_help.sh # peer --help text
30
+ ├── peer_reset.sh # peer reset confirmation messages
31
+ ├── peer_status.sh # peer status output
32
+ ├── prices_refresh.sh # prices refresh output
33
+ └── skills/ # per-skill i18n overrides
34
+ ```
35
+
36
+ ## Locale selection
37
+
38
+ `ROLL_LANG` env var controls which locale is active. Resolved by `lang.sh`:
39
+
40
+ 1. `ROLL_LANG` explicit → use it
41
+ 2. `LC_ALL` / `LANG` contains `zh` → `zh`
42
+ 3. Default → `en`
43
+
44
+ Each `.sh` file branches on `ROLL_LANG` and exports the appropriate string set.
45
+
46
+ ## skills/
47
+
48
+ Per-skill message overrides for `roll-build`, `roll-design`, `roll-fix`, `roll-loop`, `roll-onboard`. Same structure as top-level files — sourced after the base file to allow skill-specific overrides without editing shared strings.
49
+
50
+ ## Adding a new string
51
+
52
+ 1. Add the key to both `en` and `zh` branches in the appropriate domain file.
53
+ 2. Reference via `msg <KEY>` in `bin/roll` or the relevant skill.
54
+ 3. Never hardcode user-facing strings in `bin/roll` directly — always go through i18n.
@@ -29,3 +29,16 @@ _i18n_set en doctor.pr_event_without_zh "contributors get AI feedback on PR open
29
29
  _i18n_set zh doctor.pr_event_without_zh "PR 一开即触发 AI 评审。"
30
30
  _i18n_set en doctor.pr_event_secret "Then set the API key secret for your configured agent in GitHub repo settings."
31
31
  _i18n_set zh doctor.pr_event_secret "然后在 GitHub 仓库设置中添加你配置的 agent 对应的 API key secret。"
32
+
33
+ _i18n_set en doctor.agent_detection "Agent detection"
34
+ _i18n_set zh doctor.agent_detection "Agent 检测"
35
+ _i18n_set en doctor.agent_installed "CLI on PATH"
36
+ _i18n_set zh doctor.agent_installed "CLI 可用"
37
+ _i18n_set en doctor.agent_missing "CLI not found"
38
+ _i18n_set zh doctor.agent_missing "CLI 未安装"
39
+ _i18n_set en doctor.agent_dir_exists "config dir exists"
40
+ _i18n_set zh doctor.agent_dir_exists "配置目录存在"
41
+ _i18n_set en doctor.agent_dir_missing "config dir missing"
42
+ _i18n_set zh doctor.agent_dir_missing "配置目录不存在"
43
+ _i18n_set en doctor.agent_primary_label "primary"
44
+ _i18n_set zh doctor.agent_primary_label "默认"
package/lib/i18n/loop.sh CHANGED
@@ -3,22 +3,22 @@ _i18n_set en loop.loop_already_enabled_for_this_project "Loop already enabled fo
3
3
  _i18n_set zh loop.loop_already_enabled_for_this_project "当前项目 loop 已启用"
4
4
  _i18n_set en loop.loop_enabled "Loop enabled"
5
5
  _i18n_set zh loop.loop_enabled "已启用"
6
- _i18n_set en loop.roll_loop_s_active_02d_00 " • roll-loop %s active %02d:00–%02d:00 %s"
7
- _i18n_set zh loop.roll_loop_s_active_02d_00 "窗口 %02d:00–%02d:00)\n"
8
- _i18n_set en loop.roll_dream_daily_at_02d_02d " • roll-.dream daily at %02d:%02d"
9
- _i18n_set zh loop.roll_dream_daily_at_02d_02d "每天 %02d:%02d\n"
10
- _i18n_set en loop.roll_brief_daily_at_02d_02d " • roll-brief daily at %02d:%02d"
11
- _i18n_set zh loop.roll_brief_daily_at_02d_02d "每天 %02d:%02d\n"
6
+ _i18n_set en loop.roll_loop_s_active_02d_00 " • roll-loop %s active %02d:00–%02d:00 %s(窗口 %02d:00–%02d:00)"
7
+ _i18n_set zh loop.roll_loop_s_active_02d_00 " roll-loop %s 有效窗口 %02d:00–%02d:00 %s(active %02d:00–%02d:00)"
8
+ _i18n_set en loop.roll_dream_daily_at_02d_02d " • roll-.dream daily at %02d:%02d 每天 %02d:%02d"
9
+ _i18n_set zh loop.roll_dream_daily_at_02d_02d " • roll-.dream daily at %02d:%02d 每天 %02d:%02d"
10
+ _i18n_set en loop.roll_brief_daily_at_02d_02d " • roll-brief daily at %02d:%02d 每天 %02d:%02d"
11
+ _i18n_set zh loop.roll_brief_daily_at_02d_02d " • roll-brief daily at %02d:%02d 每天 %02d:%02d"
12
12
  _i18n_set en loop.loop_already_enabled_for_this_project_2 "Loop already enabled for this project"
13
13
  _i18n_set zh loop.loop_already_enabled_for_this_project_2 "当前项目 loop 已启用"
14
14
  _i18n_set en loop.loop_enabled_2 "Loop enabled"
15
15
  _i18n_set zh loop.loop_enabled_2 "已启用"
16
- _i18n_set en loop.roll_loop_s_active_02d_00_2 " • roll-loop %s active %02d:00–%02d:00 %s"
17
- _i18n_set zh loop.roll_loop_s_active_02d_00_2 "窗口 %02d:00–%02d:00)\n"
18
- _i18n_set en loop.roll_dream_daily_at_02d_02d_2 " • roll-.dream daily at %02d:%02d"
19
- _i18n_set zh loop.roll_dream_daily_at_02d_02d_2 "每天 %02d:%02d\n"
20
- _i18n_set en loop.roll_brief_daily_at_02d_02d_2 " • roll-brief daily at %02d:%02d"
21
- _i18n_set zh loop.roll_brief_daily_at_02d_02d_2 "每天 %02d:%02d\n"
16
+ _i18n_set en loop.roll_loop_s_active_02d_00_2 " • roll-loop %s active %02d:00–%02d:00 %s(窗口 %02d:00–%02d:00)"
17
+ _i18n_set zh loop.roll_loop_s_active_02d_00_2 " roll-loop %s 有效窗口 %02d:00–%02d:00 %s(active %02d:00–%02d:00)"
18
+ _i18n_set en loop.roll_dream_daily_at_02d_02d_2 " • roll-.dream daily at %02d:%02d 每天 %02d:%02d"
19
+ _i18n_set zh loop.roll_dream_daily_at_02d_02d_2 " • roll-.dream daily at %02d:%02d 每天 %02d:%02d"
20
+ _i18n_set en loop.roll_brief_daily_at_02d_02d_2 " • roll-brief daily at %02d:%02d 每天 %02d:%02d"
21
+ _i18n_set zh loop.roll_brief_daily_at_02d_02d_2 " • roll-brief daily at %02d:%02d 每天 %02d:%02d"
22
22
  _i18n_set en loop.loop_not_enabled_for_this_project "Loop not enabled for this project"
23
23
  _i18n_set zh loop.loop_not_enabled_for_this_project "当前项目 loop 未启用"
24
24
  _i18n_set en loop.loop_disabled "Loop disabled"
@@ -0,0 +1,35 @@
1
+ > **Draft** — auto-generated by roll-doc on 2026-05-28. Review before treating as authoritative.
2
+
3
+ # lib/prices/ — Model price snapshots
4
+
5
+ Dated JSON snapshots of AI model list prices, used by `roll loop status` to compute per-cycle cost in both USD and native currency (CNY for pi/DeepSeek/Kimi).
6
+
7
+ ## Files
8
+
9
+ | File | Contents |
10
+ |------|---------|
11
+ | `snapshot-2026-05-22.json` | Multi-vendor snapshot (Claude, GPT, Gemini, DeepSeek, Kimi, pi) |
12
+ | `snapshot-2026-05-23-deepseek.json` | DeepSeek-specific refresh |
13
+ | `snapshot-2026-05-23-kimi.json` | Kimi-specific refresh |
14
+
15
+ ## Format
16
+
17
+ Each snapshot is a JSON object keyed by model ID:
18
+
19
+ ```json
20
+ {
21
+ "claude-opus-4-7": {
22
+ "input_per_mtok": 15.0,
23
+ "output_per_mtok": 75.0,
24
+ "cache_write_per_mtok": 18.75,
25
+ "cache_read_per_mtok": 1.5,
26
+ "currency": "USD"
27
+ }
28
+ }
29
+ ```
30
+
31
+ CNY-priced models (pi, DeepSeek, Kimi) use `"currency": "CNY"`.
32
+
33
+ ## Refresh
34
+
35
+ `prices_fetcher.py` fetches fresh snapshots from vendor pricing APIs and writes a new dated file here. Run via `roll prices refresh`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.528.2",
3
+ "version": "2026.529.1",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"