@seanyao/roll 2026.514.5 → 2026.516.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,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.515.1
4
+
5
+ - **New**: `roll brief` / `roll dream` 生成文档后自动提交推送 — 每次晨报和夜检不再需要手动 commit `[loop]`
6
+ - **New**: 双语 FAQ 指南 — 10 个自治交付常见场景(loop 卡住、PR 冲突、agent 切换、权限问题等),每条含原因和原理,EN + ZH 对照 `[docs]`
7
+ - **Fixed**: loop 孤儿状态自愈 — cycle 启动时检测 state.yaml 残留 running,若无活跃进程则自动重置为 idle,防止 loop 因中断永久卡死 `[loop]`
8
+ - **New**: 可选的事件驱动 PR 评审模板 — `cp templates/workflows/pr-review-event.yml .github/workflows/`,PR 开即触发 AI 评审,不装也行(loop 每轮兜底) `[pr]`
9
+ - **New**: loop PR inbox 从"分类但空转"升级到"分类+执行" — eligible PR 自动调 AI 评审,stale PR 自动 rebase,fork 和冲突写 ALERT;bot 已评审的 PR 自动让步 `[loop]`
10
+ - **New**: `roll review-pr <number>` — agent-agnostic AI 代码评审,任意 agent(Claude/Kimi/DeepSeek 等)均可评审任意 git 平台的 PR;PR body 加 `[skip-ai-review]` 可跳过 `[pr]`
11
+ - **Fixed**: `roll peer` 终态后 tmux session 不再残留 — AGREE/ESCALATE/UNKNOWN/round≥3 自动 kill,round<3 保留复用 `[peer]`
12
+ - **New**: `loop/cycle-*` 远程僵尸分支兜底 GC — 每轮 cycle 结尾扫描已合入 main 的 `loop/cycle-*` 分支并删除,弥补 PR auto-merge 失败时的清理盲区 `[loop]`
13
+
3
14
  ## v2026.514.5
4
15
 
5
16
  - **Fixed**: 上版 `claude/*` 临时分支清理意外失效 — 现已恢复 `[loop]`
package/README.md CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  **[中文版 README](README_CN.md)**
13
13
 
14
+ [![Website](https://img.shields.io/badge/Website-seanyao.github.io%2FRoll-blue)](https://seanyao.github.io/Roll/)
14
15
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
15
16
  [![npm version](https://img.shields.io/npm/v/@seanyao/roll.svg)](https://www.npmjs.com/package/@seanyao/roll)
16
17
  [![CI](https://github.com/seanyao/roll/actions/workflows/ci.yml/badge.svg)](https://github.com/seanyao/roll/actions/workflows/ci.yml)
@@ -64,6 +65,7 @@ roll loop on # optional: let the agent work unattended
64
65
  | Peer (cross-agent review) | [guide/en/peer.md](docs/guide/en/peer.md) | [guide/zh/peer.md](docs/guide/zh/peer.md) |
65
66
  | Configuration (env vars) | [guide/en/configuration.md](docs/guide/en/configuration.md) | [guide/zh/configuration.md](docs/guide/zh/configuration.md) |
66
67
  | Skill selection guide | [guide/en/skills.md](docs/guide/en/skills.md) | [guide/zh/skills.md](docs/guide/zh/skills.md) |
68
+ | FAQ (troubleshooting) | [guide/en/faq.md](docs/guide/en/faq.md) | [guide/zh/faq.md](docs/guide/zh/faq.md) |
67
69
  | Domain model (DDD) | [domain/context-map.md](docs/domain/context-map.md) | — |
68
70
  | Engineering common sense | [practices/engineering-common-sense.md](docs/practices/engineering-common-sense.md) | — |
69
71
 
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.514.5"
7
+ VERSION="2026.516.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"
@@ -614,6 +614,7 @@ cmd_setup() {
614
614
 
615
615
  echo ""
616
616
  _print_pr_pipeline_hint
617
+ _print_pr_event_hint
617
618
  }
618
619
 
619
620
  # ─── PR pipeline hint ────────────────────────────────────────────────────────
@@ -643,6 +644,24 @@ _print_pr_pipeline_hint() {
643
644
  HINT
644
645
  }
645
646
 
647
+ _print_pr_event_hint() {
648
+ cat <<'HINT'
649
+
650
+ Optional — enable event-driven PR review (seconds-fast, GitHub only).
651
+ 可选 —— 启用事件驱动 PR 评审(秒级响应,仅限 GitHub)。
652
+
653
+ Without this, Roll reviews PRs each loop cycle (~1h). With it,
654
+ contributors get AI feedback on PR open/update immediately.
655
+ 不安装也行 — loop 每轮会兜底评审。安装后 PR 一开即触发 AI 评审。
656
+
657
+ cp templates/workflows/pr-review-event.yml .github/workflows/
658
+
659
+ Then set the API key secret for your configured agent in GitHub repo settings.
660
+ 然后在 GitHub 仓库设置中添加你配置的 agent 对应的 API key secret。
661
+
662
+ HINT
663
+ }
664
+
646
665
  # ═══════════════════════════════════════════════════════════════════════════════
647
666
  # COMMAND: update
648
667
  # Thin wrapper: upgrade the npm-installed package, then re-sync via setup.
@@ -1112,60 +1131,6 @@ _ensure_features_dir() {
1112
1131
  _ROLL_MERGE_SUMMARY+=("created|docs/features/")
1113
1132
  }
1114
1133
 
1115
- # ─── Helper: write starter .gitignore (no-op if exists) ──────────────────────
1116
- _write_gitignore() {
1117
- [[ -f "$1" ]] && return
1118
- cat > "$1" << 'EOF'
1119
- node_modules/
1120
- dist/
1121
- build/
1122
- .env
1123
- *.local
1124
- .DS_Store
1125
- *.log
1126
- EOF
1127
- }
1128
-
1129
- # ─── Helper: write starter .env.example (no-op if exists) ────────────────────
1130
- _write_env_example() {
1131
- [[ -f "$1" ]] && return
1132
- cat > "$1" << 'EOF'
1133
- # Environment Variables — copy to .env and fill in values
1134
-
1135
- # Application
1136
- # NODE_ENV=development
1137
- # PORT=3000
1138
-
1139
- # Database
1140
- # DATABASE_URL=postgresql://user:pass@localhost:5432/db
1141
-
1142
- # Auth
1143
- # JWT_SECRET=your-secret-key
1144
- EOF
1145
- }
1146
-
1147
- # ─── Helper: detect project type from existing AGENTS.md ─────────────────────
1148
- detect_project_type() {
1149
- local agents_file="$1/AGENTS.md"
1150
- [[ -f "$agents_file" ]] || { echo "unknown"; return; }
1151
-
1152
- local content
1153
- content="$(cat "$agents_file")"
1154
-
1155
- if echo "$content" | grep -qi "Fullstack Web"; then
1156
- echo "fullstack"
1157
- elif echo "$content" | grep -qi "Backend Service"; then
1158
- echo "backend-service"
1159
- elif echo "$content" | grep -qi "Frontend Only"; then
1160
- echo "frontend-only"
1161
- elif echo "$content" | grep -qi "CLI Tool"; then
1162
- echo "cli"
1163
- else
1164
- # AGENTS.md exists but has no type marker — fall back to file-based scan
1165
- scan_project_type_from_files "$1"
1166
- fi
1167
- }
1168
-
1169
1134
  # ═══════════════════════════════════════════════════════════════════════════════
1170
1135
  # COMMAND: status
1171
1136
  # Show current state of conventions
@@ -1459,7 +1424,7 @@ _peer_auto_attach() {
1459
1424
  [ "$(uname)" = "Darwin" ] || return 0
1460
1425
  [ -f "$_LOOP_MUTE_FILE" ] && return 0
1461
1426
  local terminal_pref
1462
- terminal_pref=$(_config_read_string "loop_attach_terminal" "")
1427
+ terminal_pref=$(config_get "loop_attach_terminal" "")
1463
1428
  if [[ -z "$terminal_pref" ]]; then
1464
1429
  case "${TERM_PROGRAM:-}" in
1465
1430
  ghostty) terminal_pref="ghostty" ;;
@@ -1777,6 +1742,16 @@ cmd_peer() {
1777
1742
  echo ""
1778
1743
  info "Log: $log_file"
1779
1744
 
1745
+ local _should_kill=true
1746
+ case "$resolution" in
1747
+ REFINE|OBJECT) [[ "$round" -lt 3 ]] && _should_kill=false ;;
1748
+ esac
1749
+ if [[ "$_should_kill" == "true" ]] && [[ -n "$peer_session" ]] \
1750
+ && command -v tmux >/dev/null 2>&1 \
1751
+ && tmux has-session -t "$peer_session" 2>/dev/null; then
1752
+ tmux kill-session -t "$peer_session" 2>/dev/null || true
1753
+ fi
1754
+
1780
1755
  case "$resolution" in
1781
1756
  AGREE) exit 0 ;;
1782
1757
  REFINE|OBJECT) exit 2 ;;
@@ -1898,6 +1873,17 @@ _skill_content() {
1898
1873
  awk 'NR==1 && /^---$/{skip=1;next} skip && /^---$/{skip=0;next} !skip{print}' "$1"
1899
1874
  }
1900
1875
 
1876
+ _parse_review_verdict() {
1877
+ local output="$1"
1878
+ local line
1879
+ line=$(echo "$output" | grep -o '<!--VERDICT:[^>]*-->' | tail -1)
1880
+ [ -n "$line" ] || return 0
1881
+ local type reason
1882
+ type=$(echo "$line" | sed -E 's/<!--VERDICT:([A-Z_]+)(:.*)?-->/\1/')
1883
+ reason=$(echo "$line" | sed -E 's/<!--VERDICT:[A-Z_]+:?//; s/-->//' | sed 's/^ *//')
1884
+ echo "${type}${reason:+:${reason}}"
1885
+ }
1886
+
1901
1887
  _agent_run_skill() {
1902
1888
  local skill="$1"
1903
1889
  local agent; agent=$(_project_agent)
@@ -1915,6 +1901,102 @@ _agent_run_skill() {
1915
1901
  esac
1916
1902
  }
1917
1903
 
1904
+ cmd_review_pr() {
1905
+ local pr_number="${1:-}"
1906
+ [ -n "$pr_number" ] || { err "Usage: roll review-pr <number>"; return 1; }
1907
+
1908
+ local slug; slug=$(_gh_repo_slug) || { err "Not a GitHub repo — review-pr requires GitHub remote"; return 1; }
1909
+
1910
+ local pr_json
1911
+ pr_json=$(gh -R "$slug" pr view "$pr_number" --json title,body,diff 2>&1) \
1912
+ || { err "gh pr view failed: ${pr_json}"; return 1; }
1913
+
1914
+ local title body diff
1915
+ title=$(echo "$pr_json" | jq -r '.title // ""')
1916
+ body=$(echo "$pr_json" | jq -r '.body // ""')
1917
+ diff=$(echo "$pr_json" | jq -r '.diff // ""')
1918
+
1919
+ if echo "$body" | grep -qF '[skip-ai-review]'; then
1920
+ gh -R "$slug" pr review "$pr_number" --approve -b "Auto-approved: [skip-ai-review] detected" 2>/dev/null || true
1921
+ info "PR #${pr_number}: [skip-ai-review] — auto-approved"
1922
+ return 0
1923
+ fi
1924
+
1925
+ local template="${ROLL_PKG_DIR}/skills/roll-review-pr/SKILL.md"
1926
+ [ -f "$template" ] || { err "Skill template not found: ${template}"; return 1; }
1927
+
1928
+ local content; content=$(_skill_content "$template")
1929
+
1930
+ local tmp; tmp=$(mktemp)
1931
+ # shellcheck disable=SC2064
1932
+ trap "rm -f '$tmp'" EXIT
1933
+
1934
+ echo "$content" > "$tmp"
1935
+ sed -i '' "s|{{PR_TITLE}}|${title}|g" "$tmp" 2>/dev/null \
1936
+ || sed -i "s|{{PR_TITLE}}|${title}|g" "$tmp"
1937
+
1938
+ local body_escaped; body_escaped=$(printf '%s' "$body" | sed 's/[&/\]/\\&/g; s/$/\\/' | sed '$ s/\\$//')
1939
+ sed -i '' "s|{{PR_BODY}}|${body_escaped}|g" "$tmp" 2>/dev/null \
1940
+ || sed -i "s|{{PR_BODY}}|${body_escaped}|g" "$tmp"
1941
+
1942
+ local diff_truncated
1943
+ diff_truncated=$(echo "$diff" | head -500)
1944
+ local diff_file; diff_file=$(mktemp)
1945
+ echo "$diff_truncated" > "$diff_file"
1946
+ awk -v f="$diff_file" '{
1947
+ if (index($0, "{{PR_DIFF}}")) {
1948
+ while ((getline line < f) > 0) print line
1949
+ close(f)
1950
+ } else print
1951
+ }' "$tmp" > "${tmp}.out" && mv "${tmp}.out" "$tmp"
1952
+ rm -f "$diff_file"
1953
+
1954
+ local prompt; prompt=$(cat "$tmp")
1955
+ rm -f "$tmp"
1956
+ trap - EXIT
1957
+
1958
+ local agent; agent=$(_project_agent)
1959
+ local output
1960
+ info "Reviewing PR #${pr_number} with ${agent}..."
1961
+ case "$agent" in
1962
+ claude) output=$(claude -p --output-format text "$prompt" 2>/dev/null) ;;
1963
+ kimi) output=$(kimi --quiet -p "$prompt" 2>/dev/null) ;;
1964
+ deepseek) output=$(deepseek "$prompt" 2>/dev/null) ;;
1965
+ pi) output=$(pi -p "$prompt" 2>/dev/null) ;;
1966
+ codex) output=$(codex exec "$prompt" 2>/dev/null) ;;
1967
+ opencode) output=$(opencode run "$prompt" 2>/dev/null) ;;
1968
+ *) err "Unknown agent '${agent}'"; return 1 ;;
1969
+ esac
1970
+
1971
+ echo "$output"
1972
+
1973
+ local verdict; verdict=$(_parse_review_verdict "$output")
1974
+ local vtype; vtype="${verdict%%:*}"
1975
+ local vreason; vreason="${verdict#*:}"
1976
+ [ "$vreason" = "$vtype" ] && vreason=""
1977
+
1978
+ case "$vtype" in
1979
+ APPROVE)
1980
+ gh -R "$slug" pr review "$pr_number" --approve -b "AI review: approved" 2>/dev/null || true
1981
+ info "PR #${pr_number}: APPROVED"
1982
+ ;;
1983
+ REQUEST_CHANGES)
1984
+ gh -R "$slug" pr review "$pr_number" --request-changes -b "${vreason:-AI review requested changes}" 2>/dev/null || true
1985
+ info "PR #${pr_number}: REQUEST_CHANGES — ${vreason}"
1986
+ ;;
1987
+ UNCERTAIN)
1988
+ warn "PR #${pr_number}: UNCERTAIN — ${vreason}"
1989
+ local alert_file="${ROLL_LOOP_DIR:-${HOME}/.shared/roll/loop}/ALERT.md"
1990
+ mkdir -p "$(dirname "$alert_file")"
1991
+ printf '[%s] PR #%s: AI review UNCERTAIN — %s\n' \
1992
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$pr_number" "$vreason" >> "$alert_file"
1993
+ ;;
1994
+ *)
1995
+ warn "PR #${pr_number}: no verdict parsed from agent output"
1996
+ ;;
1997
+ esac
1998
+ }
1999
+
1918
2000
  cmd_agent() {
1919
2001
  local subcmd="${1:-}"; shift || true
1920
2002
  case "$subcmd" in
@@ -1929,8 +2011,12 @@ cmd_agent() {
1929
2011
  fi
1930
2012
  ok "Agent set to ${name} for this project 当前项目 agent 已设为 ${name}"
1931
2013
  local project_path; project_path=$(pwd -P)
1932
- crontab -l 2>/dev/null | grep -q "roll-loop:${project_path}" && \
1933
- info "Takes effect on next scheduled run — or: roll loop now"
2014
+ local slug; slug=$(_project_slug "$project_path")
2015
+ local runner="${_SHARED_ROOT}/loop/run-${slug}.sh"
2016
+ if [[ -f "$runner" ]]; then
2017
+ _install_launchd_plists "$project_path" >/dev/null
2018
+ ok "Loop runner scripts regenerated for new agent 已为新 agent 重新生成 loop 脚本"
2019
+ fi
1934
2020
  ;;
1935
2021
  list)
1936
2022
  echo ""; echo " Available agents 可用 agent:"; echo ""
@@ -1971,6 +2057,14 @@ _LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
1971
2057
  # hash of the full path, ensuring uniqueness across sibling dirs with same name.
1972
2058
  _project_slug() {
1973
2059
  local path="$1"
2060
+ # FIX-034: when inside a git worktree, git-common-dir returns the main tree's
2061
+ # absolute .git path; resolve to the main tree so worktree and main-tree runs
2062
+ # produce the same slug.
2063
+ local _common
2064
+ _common=$(git -C "$path" rev-parse --git-common-dir 2>/dev/null)
2065
+ if [[ -n "$_common" && "$_common" == *"/.git" ]]; then
2066
+ path="${_common%/.git}"
2067
+ fi
1974
2068
  local base; base=$(basename "$path")
1975
2069
  local hash
1976
2070
  if command -v md5 &>/dev/null; then
@@ -1985,17 +2079,10 @@ _project_slug() {
1985
2079
  _config_read_int() {
1986
2080
  local key="$1" default="$2"
1987
2081
  local val
1988
- val=$(grep "^${key}:" "$ROLL_CONFIG" 2>/dev/null | awk '{print $2}' | tr -d '"' | head -1)
2082
+ val=$(config_get "$key" "")
1989
2083
  if [[ "$val" =~ ^[0-9]+$ ]]; then echo "$val"; else echo "$default"; fi
1990
2084
  }
1991
2085
 
1992
- _config_read_string() {
1993
- local key="$1" default="$2"
1994
- local val
1995
- val=$(grep "^${key}:" "$ROLL_CONFIG" 2>/dev/null | awk '{print $2}' | tr -d '"' | head -1)
1996
- if [[ -n "$val" ]]; then echo "$val"; else echo "$default"; fi
1997
- }
1998
-
1999
2086
  # Derive a minute in [1,55] from project path hash + offset so different projects
2000
2087
  # and different services within a project don't fire at the same time.
2001
2088
  # Offsets used: loop=0, dream=2, brief=4 → always three distinct values (2<55).
@@ -2108,7 +2195,15 @@ if [ -f "\$INNER_LOCK" ]; then
2108
2195
  rm -f "\$INNER_LOCK"
2109
2196
  fi
2110
2197
  printf '%s:%s\n' "\$\$" "\$(date -u +%s)" > "\$INNER_LOCK"
2111
- trap 'rm -f "\$INNER_LOCK"' EXIT
2198
+ # FIX-038: background heartbeat writer — outer script uses this as primary liveness signal
2199
+ # to detect stale execution without relying on PID reuse heuristics.
2200
+ HEARTBEAT_FILE="${HOME}/.shared/roll/loop/.heartbeat-${slug}"
2201
+ _heartbeat_writer() {
2202
+ while true; do echo "\$(date -u +%s)" > "\$HEARTBEAT_FILE"; sleep 60; done
2203
+ }
2204
+ _heartbeat_writer &
2205
+ _HEARTBEAT_PID=\$!
2206
+ trap 'kill "\${_HEARTBEAT_PID}" 2>/dev/null; rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE"' EXIT
2112
2207
 
2113
2208
  # US-AUTO-037: pull in worktree helpers (US-AUTO-036). Sourcing bin/roll is
2114
2209
  # safe — its main() only runs when invoked directly (BASH_SOURCE == \$0).
@@ -2166,37 +2261,50 @@ _claude_cleanup_stale_worktrees "${project_path}" || true
2166
2261
  # When \`gh\` is unavailable, fall back to the legacy ff-merge path.
2167
2262
  if [ "\$_USE_WORKTREE" = "1" ]; then
2168
2263
  if [ "\$_exit" -eq 0 ]; then
2169
- if ( cd "\$WT" && _loop_is_doc_only_change ); then
2170
- ( cd "\$WT" && _loop_publish_doc_pr "\$BRANCH" "doc: loop cycle \${CYCLE_ID}" )
2171
- else
2172
- ( cd "\$WT" && _loop_publish_pr "\$BRANCH" "loop cycle \${CYCLE_ID}" )
2173
- fi
2174
- _publish_status=\$?
2175
- if [ "\$_publish_status" -eq 0 ]; then
2264
+ # Idle cycle no commits ahead of origin/main means nothing was built;
2265
+ # skip publish and reclaim the worktree immediately.
2266
+ _cycle_commits=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
2267
+ if [ "\$_cycle_commits" -eq 0 ]; then
2176
2268
  _worktree_cleanup "\$WT" "\$BRANCH"
2177
- echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
2178
- elif [ "\$_publish_status" -eq 2 ]; then
2179
- if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
2269
+ echo "[loop] cycle \${CYCLE_ID}: idle (no new commits); worktree cleaned"
2270
+ else
2271
+ if ( cd "\$WT" && _loop_is_doc_only_change ); then
2272
+ ( cd "\$WT" && _loop_publish_doc_pr "\$BRANCH" "doc: loop cycle \${CYCLE_ID}" )
2273
+ else
2274
+ ( cd "\$WT" && _loop_publish_pr "\$BRANCH" "loop cycle \${CYCLE_ID}" )
2275
+ fi
2276
+ _publish_status=\$?
2277
+ if [ "\$_publish_status" -eq 0 ]; then
2180
2278
  _worktree_cleanup "\$WT" "\$BRANCH"
2181
- echo "[loop] cycle \${CYCLE_ID}: gh unavailable; merged via ff and cleaned up"
2279
+ echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
2280
+ elif [ "\$_publish_status" -eq 2 ]; then
2281
+ if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
2282
+ _worktree_cleanup "\$WT" "\$BRANCH"
2283
+ echo "[loop] cycle \${CYCLE_ID}: gh unavailable; merged via ff and cleaned up"
2284
+ else
2285
+ _worktree_alert "cycle \${CYCLE_ID}: gh unavailable AND merge_back failed; worktree preserved at \$WT"
2286
+ echo "[loop] cycle \${CYCLE_ID}: gh+merge_back both failed; worktree preserved at \$WT"
2287
+ fi
2182
2288
  else
2183
- _worktree_alert "cycle \${CYCLE_ID}: gh unavailable AND merge_back failed; worktree preserved at \$WT"
2184
- echo "[loop] cycle \${CYCLE_ID}: gh+merge_back both failed; worktree preserved at \$WT"
2289
+ _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
2290
+ echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
2185
2291
  fi
2186
- else
2187
- _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
2188
- echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
2189
2292
  fi
2190
2293
  else
2191
2294
  _worktree_alert "cycle \${CYCLE_ID}: claude exited \$_exit; worktree preserved at \$WT (branch \$BRANCH)"
2192
2295
  echo "[loop] cycle \${CYCLE_ID}: claude failed (exit \$_exit); worktree preserved at \$WT"
2193
2296
  fi
2194
2297
  fi
2298
+
2299
+ # US-AUTO-040: fallback GC — delete remote loop/cycle-* branches already merged to main.
2300
+ _loop_cleanup_stale_cycle_branches "${project_path}" || true
2195
2301
  INNER
2196
2302
  chmod +x "$inner_path"
2197
2303
 
2198
2304
  cat > "$script_path" << SCRIPT
2199
2305
  #!/bin/bash -l
2306
+ # caffeinate: prevent idle sleep from killing claude during cycles
2307
+ caffeinate -i -w \$\$ &
2200
2308
  # Active-window check — skipped when ROLL_LOOP_FORCE is set (manual 'roll loop now')
2201
2309
  if [ -z "\$ROLL_LOOP_FORCE" ]; then
2202
2310
  h=\$(printf '%d' "\$(date +%H)")
@@ -2205,6 +2313,50 @@ fi
2205
2313
  # Pause check — 'roll loop pause' creates this marker to suspend scheduling
2206
2314
  PAUSE="\$HOME/.shared/roll/loop/PAUSE-${slug}"
2207
2315
  if [ -z "\$ROLL_LOOP_FORCE" ] && [ -f "\$PAUSE" ]; then exit 0; fi
2316
+ # FIX-037: orphan state detection & self-heal — if state.yaml says running
2317
+ # but no LOCK process or tmux session exists, the previous cycle was killed
2318
+ # (e.g. SIGKILL / sleep / terminal close). Heal state to idle so the next
2319
+ # cycle can proceed normally; write ALERT for transparency.
2320
+ # FIX-038: heartbeat is the primary liveness signal (avoids PID reuse race);
2321
+ # LOCK pid check is secondary fallback for backward compatibility.
2322
+ HEARTBEAT_TIMEOUT="\${ROLL_HEARTBEAT_TIMEOUT:-1800}"
2323
+ STATE_FILE="${HOME}/.shared/roll/loop/state.yaml"
2324
+ if [ -f "\$STATE_FILE" ]; then
2325
+ _state=\$(grep '^status:' "\$STATE_FILE" | awk '{print \$2}' 2>/dev/null || echo "")
2326
+ if [ "\$_state" = "running" ]; then
2327
+ _still_active=false
2328
+ # FIX-038: heartbeat is primary signal
2329
+ _heartbeat_file="${HOME}/.shared/roll/loop/.heartbeat-${slug}"
2330
+ if [ -f "\$_heartbeat_file" ]; then
2331
+ _hb_ts=\$(cat "\$_heartbeat_file" 2>/dev/null || echo "0")
2332
+ _now=\$(date -u +%s)
2333
+ _hb_age=\$(( _now - _hb_ts ))
2334
+ if [ "\$_hb_age" -lt "\$HEARTBEAT_TIMEOUT" ]; then
2335
+ _still_active=true
2336
+ fi
2337
+ fi
2338
+ # Fallback: LOCK pid check (for cycles without heartbeat, e.g. pre-FIX-038)
2339
+ if [ "\$_still_active" = false ]; then
2340
+ _lock_file="\$(dirname "\$0")/.LOCK-\$(basename "\$0" .sh | sed 's/^run-//')"
2341
+ if [ -f "\$_lock_file" ]; then
2342
+ _lock_pid=\$(head -1 "\$_lock_file" 2>/dev/null || echo "")
2343
+ [ -n "\$_lock_pid" ] && kill -0 "\$_lock_pid" 2>/dev/null && _still_active=true
2344
+ fi
2345
+ fi
2346
+ # Final: tmux session check
2347
+ if [ "\$_still_active" = false ]; then
2348
+ command -v tmux >/dev/null 2>&1 && tmux has-session -t "roll-loop-\$(basename "\$0" .sh | sed 's/^run-//')" 2>/dev/null && _still_active=true
2349
+ fi
2350
+ if [ "\$_still_active" = false ]; then
2351
+ echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] FIX-037: orphan state detected (status=running, heartbeat stale or missing) — healing to idle" >> "\$LOG"
2352
+ echo "status: idle" > "\${STATE_FILE}.tmp" && mv "\${STATE_FILE}.tmp" "\$STATE_FILE"
2353
+ rm -f "\$_lock_file" 2>/dev/null || true
2354
+ _alert_file="\$(dirname "\$0")/ALERT.md"
2355
+ 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
2356
+ echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] FIX-037: healed to idle, ALERT written" >> "\$LOG"
2357
+ fi
2358
+ fi
2359
+ fi
2208
2360
  LOCK="\$(dirname "\$0")/.LOCK-\$(basename "\$0" .sh | sed 's/^run-//')"
2209
2361
  SESSION="roll-loop-\$(basename "\$0" .sh | sed 's/^run-//')"
2210
2362
  INNER_SCRIPT="${inner_path}"
@@ -2227,7 +2379,9 @@ fi
2227
2379
  echo "\$\$" > "\$LOCK"
2228
2380
  trap 'rm -f "\$LOCK"' EXIT
2229
2381
  if command -v tmux >/dev/null 2>&1; then
2230
- tmux kill-session -t "\$SESSION" 2>/dev/null || true
2382
+ tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^roll-loop-" | while read _s; do
2383
+ tmux kill-session -t "\$_s" 2>/dev/null || true
2384
+ done
2231
2385
  tmux new-session -d -s "\$SESSION" -x 200 -y 50 "bash \"\$INNER_SCRIPT\""
2232
2386
  tmux pipe-pane -t "\$SESSION" "cat >> \"\$LOG\""
2233
2387
  # Auto-attach popup: when not muted, spawn a Terminal window attached to the
@@ -2304,7 +2458,7 @@ _install_launchd_plists() {
2304
2458
 
2305
2459
  # Terminal preference: config wins, then TERM_PROGRAM env, then "Terminal"
2306
2460
  local terminal_pref
2307
- terminal_pref=$(_config_read_string "loop_attach_terminal" "")
2461
+ terminal_pref=$(config_get "loop_attach_terminal" "")
2308
2462
  if [[ -z "$terminal_pref" ]]; then
2309
2463
  case "${TERM_PROGRAM:-}" in
2310
2464
  ghostty) terminal_pref="ghostty" ;;
@@ -2534,7 +2688,7 @@ _loop_test() {
2534
2688
 
2535
2689
  # Detect terminal pref same way _install_launchd_plists does
2536
2690
  local terminal_pref
2537
- terminal_pref=$(_config_read_string "loop_attach_terminal" "")
2691
+ terminal_pref=$(config_get "loop_attach_terminal" "")
2538
2692
  if [[ -z "$terminal_pref" ]]; then
2539
2693
  case "${TERM_PROGRAM:-}" in
2540
2694
  ghostty) terminal_pref="ghostty" ;;
@@ -3319,6 +3473,55 @@ _loop_pr_state_write() {
3319
3473
  ' "$state" > "$tmp" && mv "$tmp" "$state"
3320
3474
  }
3321
3475
 
3476
+ # _loop_pr_review_external <pr_number>
3477
+ # Calls cmd_review_pr (US-PR-001) to run AI review on an eligible external PR.
3478
+ # Lenient: errors are logged but do not fail the loop.
3479
+ _loop_pr_review_external() {
3480
+ local pr="$1"
3481
+ [ -n "$pr" ] || return 0
3482
+ cmd_review_pr "$pr" 2>&1 || {
3483
+ warn "review-pr failed for PR #${pr} (non-fatal)"
3484
+ return 0
3485
+ }
3486
+ }
3487
+
3488
+ # _loop_pr_rebase_stale <pr_number> <head_ref>
3489
+ # Attempts to rebase a stale PR onto origin/main and push.
3490
+ # Fork PRs are skipped (no write access). Conflicts write ALERT.
3491
+ _loop_pr_rebase_stale() {
3492
+ local pr="$1" head_ref="$2"
3493
+ [ -n "$pr" ] && [ -n "$head_ref" ] || return 0
3494
+
3495
+ local slug; slug=$(_gh_repo_slug 2>/dev/null) || return 0
3496
+
3497
+ local pr_json
3498
+ pr_json=$(gh -R "$slug" pr view "$pr" --json headRepository,headRepositoryOwner,isCrossRepository 2>/dev/null) || return 0
3499
+ local is_fork
3500
+ is_fork=$(echo "$pr_json" | jq -r '.isCrossRepository // false' 2>/dev/null)
3501
+ if [ "$is_fork" = "true" ]; then
3502
+ local alert="${_LOOP_ALERT:-${HOME}/.shared/roll/loop/ALERT.md}"
3503
+ mkdir -p "$(dirname "$alert")" 2>/dev/null || true
3504
+ printf '[%s] PR #%s: fork PR — cannot rebase (no write access)\n' \
3505
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$pr" >> "$alert"
3506
+ return 0
3507
+ fi
3508
+
3509
+ git fetch origin "$head_ref" 2>/dev/null || return 0
3510
+ if git checkout "$head_ref" 2>/dev/null \
3511
+ && git rebase origin/main 2>/dev/null \
3512
+ && git push origin "$head_ref" 2>/dev/null; then
3513
+ info "PR #${pr}: rebased ${head_ref} onto origin/main"
3514
+ else
3515
+ git rebase --abort 2>/dev/null || true
3516
+ git checkout - 2>/dev/null || true
3517
+ local alert="${_LOOP_ALERT:-${HOME}/.shared/roll/loop/ALERT.md}"
3518
+ mkdir -p "$(dirname "$alert")" 2>/dev/null || true
3519
+ printf '[%s] PR #%s: rebase conflict on %s — please rebase manually\n' \
3520
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$pr" "$head_ref" >> "$alert"
3521
+ fi
3522
+ return 0
3523
+ }
3524
+
3322
3525
  # _loop_pr_inbox
3323
3526
  # Walks open PRs and routes each by classification.
3324
3527
  # Lenient on gh unavailability — returns 0 so the loop continues to BACKLOG.
@@ -3348,10 +3551,13 @@ _loop_pr_inbox() {
3348
3551
  --json reviews,mergeStateStatus,statusCheckRollup \
3349
3552
  2>/dev/null) || { i=$((i + 1)); continue; }
3350
3553
 
3351
- local human_review ci_state mergeable
3554
+ local human_review ci_state mergeable bot_review
3352
3555
  human_review=$(echo "$view_json" | jq -r '
3353
3556
  [.reviews[]? | select(.authorAssociation != "BOT" and .authorAssociation != "APP")]
3354
3557
  | last // {} | .state // ""' 2>/dev/null)
3558
+ bot_review=$(echo "$view_json" | jq -r '
3559
+ [.reviews[]? | select(.authorAssociation == "BOT" or .authorAssociation == "APP")]
3560
+ | last // {} | .state // ""' 2>/dev/null)
3355
3561
  mergeable=$(echo "$view_json" | jq -r '.mergeStateStatus // ""' 2>/dev/null)
3356
3562
  ci_state=$(echo "$view_json" | jq -r '
3357
3563
  if (.statusCheckRollup | length) == 0 then ""
@@ -3359,6 +3565,17 @@ _loop_pr_inbox() {
3359
3565
  elif all(.statusCheckRollup[]?; .conclusion == "SUCCESS" or .conclusion == "SKIPPED") then "success"
3360
3566
  else "pending" end' 2>/dev/null)
3361
3567
 
3568
+ # Bot review gate: if a GHA workflow already handled this PR, defer to it.
3569
+ if [ "$bot_review" = "APPROVED" ]; then
3570
+ i=$((i + 1)); continue
3571
+ elif [ "$bot_review" = "CHANGES_REQUESTED" ]; then
3572
+ local alert="${_LOOP_ALERT:-${HOME}/.shared/roll/loop/ALERT.md}"
3573
+ mkdir -p "$(dirname "$alert")" 2>/dev/null || true
3574
+ printf '[%s] PR #%s: bot review CHANGES_REQUESTED — loop PR rejected by GHA reviewer\n' \
3575
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$num" >> "$alert"
3576
+ i=$((i + 1)); continue
3577
+ fi
3578
+
3362
3579
  local verdict
3363
3580
  verdict=$(_loop_pr_classify "$head_ref" "$human_review" "$ci_state" "$mergeable")
3364
3581
 
@@ -3368,15 +3585,10 @@ _loop_pr_inbox() {
3368
3585
  ;;
3369
3586
  stale)
3370
3587
  _loop_pr_rebase_circuit "$num" || true
3371
- # Actual rebase work delegated to _loop_pr_rebase_stale when present
3372
- # (kept out of this cycle to avoid touching git remote in tests).
3373
- command -v _loop_pr_rebase_stale >/dev/null 2>&1 \
3374
- && _loop_pr_rebase_stale "$num" "$head_ref" || true
3588
+ _loop_pr_rebase_stale "$num" "$head_ref" || true
3375
3589
  ;;
3376
3590
  eligible)
3377
- # Hand off to review (US-AUTO-035 supplies the actual decision).
3378
- command -v _loop_pr_review_external >/dev/null 2>&1 \
3379
- && _loop_pr_review_external "$num" || true
3591
+ _loop_pr_review_external "$num" || true
3380
3592
  ;;
3381
3593
  esac
3382
3594
 
@@ -3715,6 +3927,28 @@ _claude_cleanup_stale_worktrees() {
3715
3927
  return 0
3716
3928
  }
3717
3929
 
3930
+ _loop_cleanup_stale_cycle_branches() {
3931
+ local project_path="${1:-.}"
3932
+ local url; url=$(git -C "$project_path" remote get-url origin 2>/dev/null) || return 0
3933
+ [[ "$url" == *github.com* ]] || return 0
3934
+
3935
+ local branches
3936
+ branches=$(git -C "$project_path" ls-remote --heads origin 'refs/heads/loop/cycle-*' 2>/dev/null \
3937
+ | awk '{print $2}' | sed 's|^refs/heads/||')
3938
+ [ -z "$branches" ] && return 0
3939
+
3940
+ while IFS= read -r branch; do
3941
+ [ -z "$branch" ] && continue
3942
+ if ! git -C "$project_path" merge-base --is-ancestor "$branch" origin/main 2>/dev/null; then
3943
+ continue
3944
+ fi
3945
+ if git -C "$project_path" push origin --delete "$branch" 2>/dev/null; then
3946
+ echo "[loop] deleted stale cycle branch: $branch"
3947
+ fi
3948
+ done <<< "$branches"
3949
+ return 0
3950
+ }
3951
+
3718
3952
  # US-AUTO-033: publish a loop cycle branch as a GitHub PR with auto-merge.
3719
3953
  #
3720
3954
  # _loop_publish_pr <branch> [title]
@@ -4719,6 +4953,7 @@ usage() {
4719
4953
  echo " agent [use <name>|list] [Config] Per-project agent selection 切换项目 agent"
4720
4954
  echo " release [Publish] Sync changelog + version bump + npm publish 同步日志并发版"
4721
4955
  echo " ci [--wait] [CI] Show or wait for current commit's CI status 查看/等待 CI 状态"
4956
+ echo " review-pr <number> [PR Review] AI-powered code review for a PR AI 代码评审"
4722
4957
  echo ""
4723
4958
  echo "Examples / 示例:"
4724
4959
  echo " roll setup # New machine: first-time install 新机器首次安装"
@@ -4750,6 +4985,7 @@ main() {
4750
4985
  agent) cmd_agent "$@" ;;
4751
4986
  release) cmd_release "$@" ;;
4752
4987
  ci) cmd_ci "$@" ;;
4988
+ review-pr) cmd_review_pr "$@" ;;
4753
4989
  version|--version|-v) echo "roll v${VERSION}" ;;
4754
4990
  help|--help|-h) usage ;;
4755
4991
  "") [[ -f "BACKLOG.md" ]] && _dashboard || { usage; _show_changelog; } ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.514.5",
3
+ "version": "2026.516.1",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -27,7 +27,6 @@
27
27
  "conventions/",
28
28
  "lib/",
29
29
  "skills/",
30
- "tools/",
31
30
  "template/",
32
31
  "README.md",
33
32
  "CHANGELOG.md"
@@ -275,6 +275,7 @@ git add BACKLOG.md docs/dream/YYYY-MM-DD.md
275
275
  git commit -m "chore: dream scan YYYY-MM-DD — {N} REFACTOR entries"
276
276
  # 无发现时:
277
277
  git commit -m "chore: dream scan YYYY-MM-DD — no findings"
278
+ git push origin main
278
279
  ```
279
280
 
280
281
  - BACKLOG.md 和 dream 日志必须在**同一个 commit** 里入库,避免出现"REFACTOR 已加但日志找不到"或反过来的撕裂状态