@seanyao/roll 2026.517.5 → 2026.517.9

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,37 +1,47 @@
1
1
  # Changelog
2
2
 
3
- ## v2026.517.4
4
- - **Improved**: `roll-build` story 收尾三角度并行深审(重用/质量/效率),自检清单新增参数膨胀、N+1 等具体反模式
5
- - **New**: 七个功能区补上中英双语用户指南 — 现在都能从文档里找到了 `[dream]`
6
- - **Improved**: 官网首屏动画 — 6 秒内演示装好到自动交付的完整流程 `[loop]`
7
- - **Added**: `features.md` 现在区分已上线和规划中的功能一眼看出哪些能用
8
- - **Improved**: loop attach 输出不再像卡住 — 执行 story、等 CI、合 PR 三个等待点都有动态反馈 `[loop]`
9
- - **Fixed**: 恢复孤儿 worktree 时 PR 不再被 BEHIND 状态卡住 `[loop]`
10
- - **Fixed**: 无 PR 时 `roll ci --wait` 不再一直等到超时 `[loop]`
11
- - **Fixed**: loop 现在等 PR 合入 main 才算交付,不再 CI 绿就认为代码进了主干 `[loop]`
12
-
13
- ## v2026.517.3
14
- - **New**: dream 现在察觉功能目录过期 — 落后时不再悄悄无人知晓 `[dream]`
15
- - **New**: `roll loop events`查看 loop 每轮的详细事件流,任务选择、评审结果、CI 状态、合并全都有迹可查 `[loop]`
16
- - **Improved**: loop 实时输出不再一眼看不出重点TCR 纪律、评审决议、CI gate 突出显示,工具日志不再喧宾夺主 `[loop]`
17
-
18
- ## v2026.517.2
19
- - **Fixed**: `roll loop runs` 现在能看到刚跑完的循环记录(不再无历史) `[loop]`
20
- - **Fixed**: `roll dream`、`roll brief`、`roll loop` 的定时任务不再被 Claude 升级后的弹窗拦住,悄悄失效
21
-
22
- ## v2026.517.1
23
-
24
- - **New**: loop 自动修复 story 引入的 CI 红 — 不再每次 CI 红都停下等人,修不好才写 ALERT `[loop]`
25
- - **New**: Roll 官网上线 装、用、原理一站讲清楚
26
- - **Fixed**: mac 休眠不再打断 loop cycle 全程保持唤醒 `[loop]`
27
- - **Fixed**: agent 假死时 loop 自动接管,不再无限挂起 `[loop]`
28
- - **Fixed**: PR / 合并失败时 — loop 仍能把代码备份到独立分支不丢失 `[loop]`
29
- - **Fixed**: loop 启动时自动恢复上一轮中断的工作,意外中断的代码不再失踪 `[loop]`
30
- - **Fixed**: `roll loop now` 现在卡住状态也会先自愈再启动 `[loop]`
31
- - **Fixed**: 自治 loop 不再被权限弹窗卡住 `[loop]`
32
- - **Fixed**: `roll peer` 多轮 review 不再中途断线 `[peer]`
33
- - **Fixed**: `roll loop runs` 现在跨子目录都能显示历史 `[loop]`
34
- - **Fixed**: loop 空跑也会清理 worktree,不再随时间堆积 `[loop]`
3
+ ## v2026.517.6
4
+
5
+ ### Fixed
6
+
7
+ - **`features.md` 规划中标记不再依赖 AI 自觉** 发版脚本 AI 重写后跑机械校验自动补齐 `*(规划中)*`,规则落到 shell 里不再可能被 prompt 漂移悄悄抹掉
8
+
9
+ ## v2026.517.5
10
+
11
+ 合并 v2026.517.1 v2026.517.5 全部更新。
12
+
13
+ ### New
14
+
15
+ - **`loop` CI 自愈**story 引入的 CI 红自动修,修不好才写 ALERT,不再每次都停下等人 `[loop]`
16
+ - **`roll loop events`**查看每轮详细事件流:任务选择、评审、CI、合并全都有迹可查 `[loop]`
17
+ - **`features.md` 区分已上线和规划中** — 一眼看出哪些能用
18
+ - **七个功能区补上中英双语用户指南** `[dream]`
19
+ - **dream 检测功能目录过期** 文档落后时不再悄悄无人知晓 `[dream]`
20
+ - **Roll 官网上线** 装、用、原理一站讲清楚
21
+
22
+ ### Improved
23
+
24
+ - **`roll-build` 收尾三角度并行深审**(重用 / 质量 / 效率),自检清单新增参数膨胀、N+1 等反模式
25
+ - **loop 实时输出突出重点**TCR、评审、CI gate 高亮,工具日志不再喧宾夺主 `[loop]`
26
+ - **loop attach 三个等待点动态反馈** story 执行、CI、PR 合入不再像卡住 `[loop]`
27
+ - **官网首屏动画** 6 秒内演示装好到自动交付的完整流程
28
+
29
+ ### Fixed
30
+
31
+ - **多 cycle 并行不再双取同一 Todo** 新 cycle 启动前扫 OPEN 的 loop PR 跳过已认领故事 `[loop]`
32
+ - **loop PR 合入 main 才算交付** — 不再 CI 绿就以为代码进了主干 `[loop]`
33
+ - **孤儿 worktree 恢复的 PR 不再被 BEHIND 状态卡住** `[loop]`
34
+ - **无 PR `roll ci --wait` 不再死等超时** `[loop]`
35
+ - **`roll loop runs` 看得到刚跑完的记录**,且跨子目录可见 `[loop]`
36
+ - **`roll dream` / `roll brief` / `roll loop` 定时任务不再被 Claude 升级弹窗拦住悄悄失效**
37
+ - **mac 休眠不再打断 loop cycle** — 全程保持唤醒 `[loop]`
38
+ - **agent 假死时 loop 自动接管**,不再无限挂起 `[loop]`
39
+ - **PR 合并失败时 loop 仍把代码备份到独立分支不丢失** `[loop]`
40
+ - **loop 启动时自动恢复上一轮中断工作**,意外中断的代码不再失踪 `[loop]`
41
+ - **`roll loop now` 卡住状态会先自愈再启动** `[loop]`
42
+ - **自治 loop 不再被权限弹窗卡住** `[loop]`
43
+ - **`roll peer` 多轮 review 不再中途断线** `[peer]`
44
+ - **loop 空跑也清理 worktree**,不再随时间堆积 `[loop]`
35
45
 
36
46
  ## v2026.515.1
37
47
 
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.5"
7
+ VERSION="2026.517.9"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -856,6 +856,7 @@ cmd_init() {
856
856
  _merge_claude_to_project "$project_dir"
857
857
  _write_backlog "$project_dir/BACKLOG.md"
858
858
  _ensure_features_dir "$project_dir/docs/features"
859
+ _write_features_md "$project_dir/docs/features.md"
859
860
  print_merge_summary
860
861
 
861
862
  echo ""
@@ -970,6 +971,29 @@ _ensure_features_dir() {
970
971
  _ROLL_MERGE_SUMMARY+=("created|docs/features/")
971
972
  }
972
973
 
974
+ # ─── Helper: write starter docs/features.md (no-op if exists) ────────────────
975
+ _write_features_md() {
976
+ if [[ -f "$1" ]]; then
977
+ _ROLL_MERGE_SUMMARY+=("unchanged|docs/features.md")
978
+ return
979
+ fi
980
+ mkdir -p "$(dirname "$1")"
981
+ cat > "$1" << 'EOF'
982
+ # Features
983
+
984
+ > 产品视角的功能索引。每次发版时更新,使之与 BACKLOG 保持一致。
985
+ > Product-level feature index. Updated at release to stay in sync with BACKLOG.
986
+
987
+ ---
988
+
989
+ ## Features by Epic
990
+
991
+ <!-- Add feature entries here as epics are completed -->
992
+ EOF
993
+ ok "Created: docs/features.md"
994
+ _ROLL_MERGE_SUMMARY+=("created|docs/features.md")
995
+ }
996
+
973
997
  # ═══════════════════════════════════════════════════════════════════════════════
974
998
  # COMMAND: status
975
999
  # Show current state of conventions
@@ -1302,7 +1326,10 @@ _peer_dispatch_in_tmux() {
1302
1326
  inner=$(mktemp /tmp/roll-peer-inner-XXXXXX.sh)
1303
1327
  {
1304
1328
  printf '#!/bin/bash -l\n'
1305
- printf 'export PATH="/opt/homebrew/bin:$PATH"\n'
1329
+ # FIX-050: portable PATH assembly (was hardcoded /opt/homebrew/bin)
1330
+ printf 'for _d in /opt/homebrew/bin /usr/local/bin /opt/local/bin "$HOME/.local/bin"; do\n'
1331
+ printf ' case ":$PATH:" in *":$_d:"*) ;; *) [ -d "$_d" ] && PATH="$_d:$PATH" ;; esac\n'
1332
+ printf 'done; export PATH\n'
1306
1333
  printf '%s > %q 2> %q || true\n' "$cmd_str" "$out_file" "$err_file"
1307
1334
  printf 'touch %q\n' "$done_file"
1308
1335
  } > "$inner"
@@ -1842,7 +1869,8 @@ cmd_review_pr() {
1842
1869
  ;;
1843
1870
  UNCERTAIN)
1844
1871
  warn "PR #${pr_number}: UNCERTAIN — ${vreason}"
1845
- local alert_file="${ROLL_LOOP_DIR:-${HOME}/.shared/roll/loop}/ALERT.md"
1872
+ # FIX-052: write to per-project ALERT (was global ALERT.md).
1873
+ local alert_file="$_LOOP_ALERT"
1846
1874
  mkdir -p "$(dirname "$alert_file")"
1847
1875
  printf '[%s] PR #%s: AI review UNCERTAIN — %s\n' \
1848
1876
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$pr_number" "$vreason" >> "$alert_file"
@@ -1901,14 +1929,6 @@ cmd_agent() {
1901
1929
  # LOOP — autonomous BACKLOG executor management
1902
1930
  # ═══════════════════════════════════════════════════════════════════════════════
1903
1931
 
1904
- _LOOP_TAG="# roll-loop"
1905
- : "${_SHARED_ROOT:=${HOME}/.shared/roll}"
1906
- : "${_LOOP_STATE:=${_SHARED_ROOT}/loop/state.yaml}"
1907
- : "${_LOOP_ALERT:=${_SHARED_ROOT}/loop/ALERT.md}"
1908
- _LOOP_RUNS="${HOME}/.shared/roll/loop/runs.jsonl"
1909
- _LOOP_MUTE_FILE="${HOME}/.shared/roll/mute"
1910
- _LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
1911
-
1912
1932
  # Returns a filesystem-safe slug combining the project basename and a 6-char
1913
1933
  # hash of the full path, ensuring uniqueness across sibling dirs with same name.
1914
1934
  _project_slug() {
@@ -1932,6 +1952,19 @@ _project_slug() {
1932
1952
  printf '%s' "${base}-${hash}"
1933
1953
  }
1934
1954
 
1955
+ _LOOP_TAG="# roll-loop"
1956
+ : "${_SHARED_ROOT:=${HOME}/.shared/roll}"
1957
+ # FIX-052: per-project loop state — ALERT/state/mute were globally shared,
1958
+ # causing one project's alerts to surface in another project's session and
1959
+ # letting concurrent cycles overwrite each other's state. Align with the
1960
+ # existing events-/run-/LOCK-/heartbeat-<slug> namespacing.
1961
+ : "${_LOOP_PROJ_SLUG:=$(_project_slug 2>/dev/null || echo default)}"
1962
+ : "${_LOOP_STATE:=${_SHARED_ROOT}/loop/state-${_LOOP_PROJ_SLUG}.yaml}"
1963
+ : "${_LOOP_ALERT:=${_SHARED_ROOT}/loop/ALERT-${_LOOP_PROJ_SLUG}.md}"
1964
+ _LOOP_RUNS="${HOME}/.shared/roll/loop/runs.jsonl"
1965
+ : "${_LOOP_MUTE_FILE:=${_SHARED_ROOT}/loop/mute-${_LOOP_PROJ_SLUG}}"
1966
+ _LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
1967
+
1935
1968
  _config_read_int() {
1936
1969
  local key="$1" default="$2"
1937
1970
  local val
@@ -2003,6 +2036,28 @@ _loop_event_rotate() {
2003
2036
  fi
2004
2037
  }
2005
2038
 
2039
+ # FIX-050: probe brew prefix + common tool dirs to build a PATH that survives
2040
+ # launchd/cron's bare-env launch. Setup-time companion to the runtime
2041
+ # assembly snippet embedded in runner scripts.
2042
+ _detect_path_prepend() {
2043
+ local dirs=() seen="" d out=""
2044
+ if command -v brew >/dev/null 2>&1; then
2045
+ local bp; bp=$(brew --prefix 2>/dev/null || true)
2046
+ [[ -n "$bp" && -d "$bp/bin" ]] && dirs+=("$bp/bin")
2047
+ fi
2048
+ [[ -d /opt/homebrew/bin ]] && dirs+=("/opt/homebrew/bin")
2049
+ [[ -d /usr/local/bin ]] && dirs+=("/usr/local/bin")
2050
+ [[ -d /opt/local/bin ]] && dirs+=("/opt/local/bin")
2051
+ [[ -d "$HOME/.local/bin" ]] && dirs+=("$HOME/.local/bin")
2052
+ dirs+=("/usr/bin" "/bin" "/usr/sbin" "/sbin")
2053
+ for d in "${dirs[@]}"; do
2054
+ case ":$seen:" in *":$d:"*) continue ;; esac
2055
+ seen="$seen:$d"
2056
+ [[ -z "$out" ]] && out="$d" || out="$out:$d"
2057
+ done
2058
+ printf '%s' "$out"
2059
+ }
2060
+
2006
2061
  _launchd_label() {
2007
2062
  local service="$1" project_path="$2"
2008
2063
  printf 'com.roll.%s.%s' "$service" "$(_project_slug "$project_path")"
@@ -2022,6 +2077,11 @@ _write_launchd_plist() {
2022
2077
  <integer>${hour}</integer>
2023
2078
  "
2024
2079
 
2080
+ # FIX-050: bake PATH into the plist so launchd-spawned bash can find tmux,
2081
+ # claude, node, etc. The runner script also re-asserts PATH at runtime as
2082
+ # a second layer (covers stale plists where brew was installed after setup).
2083
+ local path_value; path_value=$(_detect_path_prepend)
2084
+
2025
2085
  local content
2026
2086
  content="<?xml version=\"1.0\" encoding=\"UTF-8\"?>
2027
2087
  <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
@@ -2035,6 +2095,11 @@ _write_launchd_plist() {
2035
2095
  <string>-l</string>
2036
2096
  <string>${runner_script}</string>
2037
2097
  </array>
2098
+ <key>EnvironmentVariables</key>
2099
+ <dict>
2100
+ <key>PATH</key>
2101
+ <string>${path_value}</string>
2102
+ </dict>
2038
2103
  <key>StartCalendarInterval</key>
2039
2104
  <dict>
2040
2105
  <key>Minute</key>
@@ -2093,7 +2158,13 @@ _write_loop_runner_script() {
2093
2158
  cat > "$inner_path" << INNER
2094
2159
  #!/bin/bash -l
2095
2160
  set -o pipefail
2096
- export PATH="/opt/homebrew/bin:\$PATH"
2161
+ # FIX-050: portable PATH assembly — launchd/cron deliver a bare PATH that
2162
+ # misses brew-installed tools (tmux, claude, node, …). Iterate candidate
2163
+ # dirs; only prepend when present and not already in PATH. Idempotent.
2164
+ for _d in /opt/homebrew/bin /usr/local/bin /opt/local/bin "\$HOME/.local/bin"; do
2165
+ case ":\$PATH:" in *":\$_d:"*) ;; *) [ -d "\$_d" ] && PATH="\$_d:\$PATH" ;; esac
2166
+ done
2167
+ export PATH
2097
2168
  # FIX-031: inner-level LOCK (PID + start-ts) — outer runner.sh LOCK can be
2098
2169
  # bypassed (recovery / retry / direct invocation); this guards the actual
2099
2170
  # claude invocation so a second session can't run under the same project.
@@ -2128,6 +2199,15 @@ trap 'kill "\${_HEARTBEAT_PID}" 2>/dev/null; rm -f "\$INNER_LOCK" "\$HEARTBEAT_F
2128
2199
  source "${roll_bin}"
2129
2200
  set +e
2130
2201
 
2202
+ # FIX-052: bin/roll initializes loop state paths from cwd at source time, but
2203
+ # the inner script may be launched from anywhere. Override to this project's
2204
+ # slug (baked at template generation) so helpers like _worktree_alert write
2205
+ # to the correct project's ALERT-<slug>.md / state-<slug>.yaml / mute-<slug>.
2206
+ _LOOP_PROJ_SLUG="${slug}"
2207
+ _LOOP_ALERT="\${_SHARED_ROOT}/loop/ALERT-${slug}.md"
2208
+ _LOOP_STATE="\${_SHARED_ROOT}/loop/state-${slug}.yaml"
2209
+ _LOOP_MUTE_FILE="\${_SHARED_ROOT}/loop/mute-${slug}"
2210
+
2131
2211
  # Pre-claude: try to create a per-cycle isolated worktree on origin/main.
2132
2212
  # On any failure (no remote, no main, etc.) fall back to running in the
2133
2213
  # project's main tree (degraded — no isolation, like pre-037 behavior).
@@ -2336,6 +2416,13 @@ INNER
2336
2416
 
2337
2417
  cat > "$script_path" << SCRIPT
2338
2418
  #!/bin/bash -l
2419
+ # FIX-050: portable PATH assembly before any brew-tool lookup (tmux, caffeinate
2420
+ # on some systems, claude). Mirrors the inner script's bootstrap so even when
2421
+ # launchd's plist EnvironmentVariables is stale, the runner self-repairs.
2422
+ for _d in /opt/homebrew/bin /usr/local/bin /opt/local/bin "\$HOME/.local/bin"; do
2423
+ case ":\$PATH:" in *":\$_d:"*) ;; *) [ -d "\$_d" ] && PATH="\$_d:\$PATH" ;; esac
2424
+ done
2425
+ export PATH
2339
2426
  # caffeinate: prevent idle sleep from killing claude during cycles
2340
2427
  caffeinate -i -w \$\$ &
2341
2428
  # Active-window check — skipped when ROLL_LOOP_FORCE is set (manual 'roll loop now')
@@ -2353,7 +2440,9 @@ if [ -z "\$ROLL_LOOP_FORCE" ] && [ -f "\$PAUSE" ]; then exit 0; fi
2353
2440
  # FIX-038: heartbeat is the primary liveness signal (avoids PID reuse race);
2354
2441
  # LOCK pid check is secondary fallback for backward compatibility.
2355
2442
  HEARTBEAT_TIMEOUT="\${ROLL_HEARTBEAT_TIMEOUT:-1800}"
2356
- STATE_FILE="${HOME}/.shared/roll/loop/state.yaml"
2443
+ # FIX-052: per-project STATE_FILE (was global state.yaml — caused two projects
2444
+ # to clobber each other's cycle state).
2445
+ STATE_FILE="${HOME}/.shared/roll/loop/state-${slug}.yaml"
2357
2446
  if [ -f "\$STATE_FILE" ]; then
2358
2447
  _state=\$(grep '^status:' "\$STATE_FILE" | awk '{print \$2}' 2>/dev/null || echo "")
2359
2448
  if [ "\$_state" = "running" ]; then
@@ -2384,7 +2473,9 @@ if [ -f "\$STATE_FILE" ]; then
2384
2473
  echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] FIX-037: orphan state detected (status=running, heartbeat stale or missing) — healing to idle" >> "\$LOG"
2385
2474
  echo "status: idle" > "\${STATE_FILE}.tmp" && mv "\${STATE_FILE}.tmp" "\$STATE_FILE"
2386
2475
  rm -f "\$_lock_file" 2>/dev/null || true
2387
- _alert_file="\$(dirname "\$0")/ALERT.md"
2476
+ # FIX-052: per-project ALERT file (was shared ALERT.md — caused R0
2477
+ # auto-heal entries to surface in Roll's alert view).
2478
+ _alert_file="\$(dirname "\$0")/ALERT-${slug}.md"
2388
2479
  echo "\$(date '+%Y-%m-%dT%H:%M:%S%z') | FIX-037 auto-heal | Orphan state detected and cleared (status=running → idle)" >> "\$_alert_file" 2>/dev/null || true
2389
2480
  echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] FIX-037: healed to idle, ALERT written" >> "\$LOG"
2390
2481
  fi
@@ -2420,7 +2511,7 @@ if command -v tmux >/dev/null 2>&1; then
2420
2511
  # Auto-attach popup: when not muted, spawn a Terminal window attached to the
2421
2512
  # tmux session so the user can watch the loop work in real time. Best-effort
2422
2513
  # focus retention: capture the current frontmost app and re-activate after.
2423
- if [ ! -f "\$HOME/.shared/roll/mute" ] && [ "\$(uname)" = "Darwin" ]; then
2514
+ if [ ! -f "\$HOME/.shared/roll/loop/mute-${slug}" ] && [ "\$(uname)" = "Darwin" ]; then
2424
2515
  # Runtime terminal detection: try preferred first, fallback through installed apps.
2425
2516
  # open -na returns non-zero when app not found, so || chain works as fallback.
2426
2517
  _launched=0
@@ -2515,7 +2606,8 @@ _install_launchd_plists() {
2515
2606
  local label; label=$(_launchd_label "$svc" "$project_path")
2516
2607
  local plist; plist=$(_launchd_plist_path "$svc" "$project_path")
2517
2608
  local runner="${shared}/${svc}/run-${slug}.sh"
2518
- local log="${shared}/${svc}/cron.log"
2609
+ # FIX-052: per-project cron log so concurrent projects don't interleave.
2610
+ local log="${shared}/${svc}/cron-${slug}.log"
2519
2611
  local cmd; cmd=$(_agent_skill_cmd "${sd}/${skill}/SKILL.md" 2>/dev/null || echo "roll loop now")
2520
2612
 
2521
2613
  if [[ "$svc" == "loop" ]]; then
@@ -2643,10 +2735,12 @@ _loop_on() {
2643
2735
 
2644
2736
  mkdir -p "${_SHARED_ROOT}/loop" "${_SHARED_ROOT}/dream" "${_SHARED_ROOT}/brief"
2645
2737
 
2738
+ # FIX-052: per-project cron logs so concurrent projects don't interleave.
2739
+ local slug; slug=$(_project_slug "$project_path")
2646
2740
  local loop_cmd dream_cmd brief_cmd
2647
- loop_cmd="cd \"${project_path}\" && $(_agent_skill_cmd "${sd}/roll-loop/SKILL.md") >> ${_SHARED_ROOT}/loop/cron.log 2>&1"
2648
- dream_cmd="cd \"${project_path}\" && $(_agent_skill_cmd "${sd}/roll-.dream/SKILL.md") >> ${_SHARED_ROOT}/dream/cron.log 2>&1"
2649
- brief_cmd="cd \"${project_path}\" && $(_agent_skill_cmd "${sd}/roll-brief/SKILL.md") >> ${_SHARED_ROOT}/brief/cron.log 2>&1"
2741
+ loop_cmd="cd \"${project_path}\" && $(_agent_skill_cmd "${sd}/roll-loop/SKILL.md") >> ${_SHARED_ROOT}/loop/cron-${slug}.log 2>&1"
2742
+ dream_cmd="cd \"${project_path}\" && $(_agent_skill_cmd "${sd}/roll-.dream/SKILL.md") >> ${_SHARED_ROOT}/dream/cron-${slug}.log 2>&1"
2743
+ brief_cmd="cd \"${project_path}\" && $(_agent_skill_cmd "${sd}/roll-brief/SKILL.md") >> ${_SHARED_ROOT}/brief/cron-${slug}.log 2>&1"
2650
2744
 
2651
2745
  (
2652
2746
  crontab -l 2>/dev/null
@@ -2757,7 +2851,8 @@ _loop_test() {
2757
2851
  err "Run 'roll loop on' first to generate it."
2758
2852
  return 1
2759
2853
  fi
2760
- local log="${_SHARED_ROOT}/loop/cron.log"
2854
+ # FIX-052: per-project log so test cycle output doesn't mix with other projects'.
2855
+ local log="${_SHARED_ROOT}/loop/cron-${slug}.log"
2761
2856
  local test_runner="${_SHARED_ROOT}/loop/run-${slug}-test.sh"
2762
2857
 
2763
2858
  # Detect terminal pref same way _install_launchd_plists does
@@ -3343,7 +3438,7 @@ _loop_self_heal_ci() {
3343
3438
  [[ -z "$story_id" ]] && return 1
3344
3439
  [[ "${ROLL_LOOP_NO_HEAL:-0}" == "1" ]] && return 1
3345
3440
  local max="${ROLL_LOOP_HEAL_MAX:-2}"
3346
- local state="${_LOOP_STATE:-${HOME}/.shared/roll/loop/state.yaml}"
3441
+ local state="$_LOOP_STATE"
3347
3442
  local current=0
3348
3443
  if [[ -f "$state" ]]; then
3349
3444
  local raw; raw=$(grep '^heal_count:' "$state" 2>/dev/null | awk '{print $2}')
@@ -3367,7 +3462,7 @@ _loop_self_heal_ci() {
3367
3462
  _loop_clear_heal_state() {
3368
3463
  local story_id="$1"
3369
3464
  [[ -z "$story_id" ]] && return 0
3370
- local state="${_LOOP_STATE:-${HOME}/.shared/roll/loop/state.yaml}"
3465
+ local state="$_LOOP_STATE"
3371
3466
  [[ ! -f "$state" ]] && return 0
3372
3467
  local tmp; tmp=$(mktemp)
3373
3468
  grep -v '^heal_count:' "$state" > "$tmp"
@@ -3533,7 +3628,7 @@ _loop_pr_rebase_circuit() {
3533
3628
  local pr="$1"
3534
3629
  [ -n "$pr" ] || return 1
3535
3630
 
3536
- local state="${_LOOP_STATE:-${HOME}/.shared/roll/loop/state.yaml}"
3631
+ local state="$_LOOP_STATE"
3537
3632
  local now; now=$(date -u +%s)
3538
3633
  local cutoff=$((now - 86400))
3539
3634
 
@@ -3651,7 +3746,7 @@ _loop_pr_rebase_stale() {
3651
3746
  local is_fork
3652
3747
  is_fork=$(echo "$pr_json" | jq -r '.isCrossRepository // false' 2>/dev/null)
3653
3748
  if [ "$is_fork" = "true" ]; then
3654
- local alert="${_LOOP_ALERT:-${HOME}/.shared/roll/loop/ALERT.md}"
3749
+ local alert="$_LOOP_ALERT"
3655
3750
  mkdir -p "$(dirname "$alert")" 2>/dev/null || true
3656
3751
  printf '[%s] PR #%s: fork PR — cannot rebase (no write access)\n' \
3657
3752
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$pr" >> "$alert"
@@ -3666,7 +3761,7 @@ _loop_pr_rebase_stale() {
3666
3761
  else
3667
3762
  git rebase --abort 2>/dev/null || true
3668
3763
  git checkout - 2>/dev/null || true
3669
- local alert="${_LOOP_ALERT:-${HOME}/.shared/roll/loop/ALERT.md}"
3764
+ local alert="$_LOOP_ALERT"
3670
3765
  mkdir -p "$(dirname "$alert")" 2>/dev/null || true
3671
3766
  printf '[%s] PR #%s: rebase conflict on %s — please rebase manually\n' \
3672
3767
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$pr" "$head_ref" >> "$alert"
@@ -3717,9 +3812,17 @@ _loop_pr_inbox() {
3717
3812
 
3718
3813
  # Bot review gate: if a GHA workflow already handled this PR, defer to it.
3719
3814
  if [ "$bot_review" = "APPROVED" ]; then
3815
+ # All gates cleared (bot-approved + CI green + no conflicts) → merge directly.
3816
+ # Relying on repo-level auto-merge being configured is not reliable; loop
3817
+ # owns the decision here since it already ran the review.
3818
+ if [ "$ci_state" = "success" ] && [ "$mergeable" = "MERGEABLE" ]; then
3819
+ gh -R "$slug" pr merge "$num" --squash --delete-branch >/dev/null 2>&1 \
3820
+ && info "PR #${num}: bot-approved + CI green — merged" \
3821
+ || warn "PR #${num}: merge failed (bot-approved + CI green) — left open"
3822
+ fi
3720
3823
  i=$((i + 1)); continue
3721
3824
  elif [ "$bot_review" = "CHANGES_REQUESTED" ]; then
3722
- local alert="${_LOOP_ALERT:-${HOME}/.shared/roll/loop/ALERT.md}"
3825
+ local alert="$_LOOP_ALERT"
3723
3826
  mkdir -p "$(dirname "$alert")" 2>/dev/null || true
3724
3827
  printf '[%s] PR #%s: bot review CHANGES_REQUESTED — loop PR rejected by GHA reviewer\n' \
3725
3828
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$num" >> "$alert"
@@ -3747,6 +3850,45 @@ _loop_pr_inbox() {
3747
3850
  return 0
3748
3851
  }
3749
3852
 
3853
+ # FIX-048: report story IDs already claimed by open loop/* PRs so a new cycle
3854
+ # can skip them before scanning BACKLOG. Without this gate, a cycle launched
3855
+ # before the previous cycle's PR merges would re-pick the same Todo story
3856
+ # (its worktree branches from main, where the 🔨 mark is not yet visible).
3857
+ #
3858
+ # _loop_pr_claimed_stories
3859
+ # Stdout: one story ID per line, deduped. Empty when nothing claimed.
3860
+ # Exit: 0 always (lenient: gh missing / API failure → empty output).
3861
+ _loop_pr_claimed_stories() {
3862
+ local slug; _gh_resolve slug || return 0
3863
+ local branches
3864
+ branches=$(gh -R "$slug" pr list --state open \
3865
+ --json headRefName \
3866
+ --jq '.[] | select(.headRefName | startswith("loop/")) | .headRefName' \
3867
+ 2>/dev/null) || return 0
3868
+ [ -n "$branches" ] || return 0
3869
+
3870
+ local branch claimed=""
3871
+ while IFS= read -r branch; do
3872
+ [ -n "$branch" ] || continue
3873
+ local content
3874
+ content=$(gh -R "$slug" api \
3875
+ "repos/${slug}/contents/BACKLOG.md?ref=${branch}" \
3876
+ -H "Accept: application/vnd.github.raw" 2>/dev/null) || continue
3877
+ [ -n "$content" ] || continue
3878
+ local ids
3879
+ ids=$(printf '%s\n' "$content" \
3880
+ | awk -F'|' '/🔨 In Progress/ {
3881
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
3882
+ sub(/^\[/, "", $2)
3883
+ sub(/\].*$/, "", $2)
3884
+ if ($2 != "") print $2
3885
+ }')
3886
+ [ -n "$ids" ] && claimed="${claimed}${ids}"$'\n'
3887
+ done <<< "$branches"
3888
+
3889
+ printf '%s' "$claimed" | awk 'NF' | sort -u
3890
+ }
3891
+
3750
3892
  # US-CL-004: changelog 风格守门 Phase 1 — mechanical linter.
3751
3893
  #
3752
3894
  # _changelog_lint_bullet <bullet-text>
@@ -4495,6 +4637,23 @@ _backlog_extract_id() {
4495
4637
  fi
4496
4638
  }
4497
4639
 
4640
+ # Render one pending-group section (FIX / US / REFACTOR / IDEA) — all four
4641
+ # types share identical row structure, so they share one render path. Format
4642
+ # changes only need to happen here.
4643
+ # $1 title (EN + ZH) $2 ANSI color $3 count $4 id column width $5 items text
4644
+ _backlog_render_group() {
4645
+ local title="$1" color="$2" count="$3" width="$4" items="$5"
4646
+ echo -e " ${color}${title} (${count})${NC}"
4647
+ while IFS= read -r line; do
4648
+ [[ -z "$line" ]] && continue
4649
+ local id desc
4650
+ id=$(_backlog_extract_id "$line")
4651
+ desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//')
4652
+ printf " %-${width}s %s\n" "$id" "$desc"
4653
+ done <<< "$items"
4654
+ echo ""
4655
+ }
4656
+
4498
4657
  # ═══════════════════════════════════════════════════════════════════════════════
4499
4658
  # CI — check or wait for current commit's CI status
4500
4659
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -4650,49 +4809,10 @@ cmd_backlog() {
4650
4809
  echo -e " ${BOLD}Pending Backlog 待处理任务${NC} (${total} items)"
4651
4810
  echo ""
4652
4811
 
4653
- if [[ $fix_count -gt 0 ]]; then
4654
- echo -e " ${RED}Bug Fixes 缺陷修复 (${fix_count})${NC}"
4655
- while IFS= read -r line; do
4656
- local id desc
4657
- id=$(echo "$line" | awk -F'|' '{print $2}' | tr -d ' ')
4658
- desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//')
4659
- printf " %-12s %s\n" "$id" "$desc"
4660
- done <<< "$fix_items"
4661
- echo ""
4662
- fi
4663
-
4664
- if [[ $us_count -gt 0 ]]; then
4665
- echo -e " ${CYAN}User Stories 用户故事 (${us_count})${NC}"
4666
- while IFS= read -r line; do
4667
- local id desc
4668
- id=$(echo "$line" | sed 's/.*\[\(US-[^]]*\)\].*/\1/')
4669
- desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//')
4670
- printf " %-14s %s\n" "$id" "$desc"
4671
- done <<< "$us_items"
4672
- echo ""
4673
- fi
4674
-
4675
- if [[ $refactor_count -gt 0 ]]; then
4676
- echo -e " ${YELLOW}Refactors 重构 (${refactor_count})${NC}"
4677
- while IFS= read -r line; do
4678
- local id desc
4679
- id=$(echo "$line" | awk -F'|' '{print $2}' | tr -d ' ')
4680
- desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//')
4681
- printf " %-16s %s\n" "$id" "$desc"
4682
- done <<< "$refactor_items"
4683
- echo ""
4684
- fi
4685
-
4686
- if [[ $idea_count -gt 0 ]]; then
4687
- echo -e " ${NC}Ideas 创意 (${idea_count})"
4688
- while IFS= read -r line; do
4689
- local id desc
4690
- id=$(echo "$line" | awk -F'|' '{print $2}' | tr -d ' ')
4691
- desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//')
4692
- printf " %-14s %s\n" "$id" "$desc"
4693
- done <<< "$idea_items"
4694
- echo ""
4695
- fi
4812
+ [[ $fix_count -gt 0 ]] && _backlog_render_group "Bug Fixes 缺陷修复" "$RED" "$fix_count" 12 "$fix_items"
4813
+ [[ $us_count -gt 0 ]] && _backlog_render_group "User Stories 用户故事" "$CYAN" "$us_count" 14 "$us_items"
4814
+ [[ $refactor_count -gt 0 ]] && _backlog_render_group "Refactors 重构" "$YELLOW" "$refactor_count" 16 "$refactor_items"
4815
+ [[ $idea_count -gt 0 ]] && _backlog_render_group "Ideas 创意" "$NC" "$idea_count" 14 "$idea_items"
4696
4816
 
4697
4817
  if [[ $total -eq 0 ]]; then
4698
4818
  echo -e " ${GREEN}✓ Nothing pending — backlog is clear 暂无待处理任务${NC}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.517.5",
3
+ "version": "2026.517.9",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -420,6 +420,9 @@ prompt 会包含:
420
420
  - 该 Feature 下**所有** Story 均为 `📋 Todo` → 在描述末尾追加 `*(规划中)*`
421
421
  - 只要有 **≥1 个** `✅ Done` Story → 正常展示,**不加**任何标记
422
422
  - 一眼可见:规划中的 Feature 在每个 Epic 分组的末尾列出
423
+ - **FIX-051 兜底**:`scripts/release.sh` 在 AI 重写后会跑机械校验
424
+ `_enforce_planning_markers`,即使本规则被 AI 漏掉也会自动补 `*(规划中)*`;
425
+ 规则的权威实现是 release.sh 里的纯 shell 函数,prompt 这条只是软提示
423
426
  - 描述写 1 句话 **产品视角**:用户能用它做什么,避免实现细节
424
427
  - 分组用 BACKLOG 的 Epic 名,原序,不重排
425
428
  - Core Highlights 从所有 Features 里挑 3-5 个最能代表产品定位的,
@@ -146,6 +146,19 @@ Priority: FIX-XXX first (bugs block progress), then US-XXX, then REFACTOR-XXX.
146
146
  - An earlier loop iteration that hasn't finished yet (rare; should be guarded by LOCK)
147
147
  - A previous interrupted run (the resume logic in Step 1 will pick these up)
148
148
 
149
+ **In-flight PR gate** (FIX-048). Before picking, also exclude stories already
150
+ claimed by an **open `loop/*` PR**. Each cycle's worktree is branched from
151
+ `origin/main`, so a story another cycle has marked 🔨 In Progress is invisible
152
+ locally until that cycle's PR merges. Without this gate, two cycles started
153
+ back-to-back will both pick the same Todo row and produce duplicate PRs.
154
+
155
+ ```bash
156
+ bash -c 'source "$(command -v roll)"; _loop_pr_claimed_stories'
157
+ # stdout: one story ID per line (deduped) — these are claimed by open
158
+ # loop/* PRs on the remote. SKIP any candidate whose ID appears.
159
+ # exit 0 always (lenient: gh missing / API error → empty output).
160
+ ```
161
+
149
162
  **Dependency gate** (FIX-032). For each `📋 Todo` candidate, before picking:
150
163
 
151
164
  ```bash