@seanyao/roll 2026.517.3 → 2026.517.4

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,15 @@
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
+
3
13
  ## v2026.517.3
4
14
  - **New**: dream 现在察觉功能目录过期 — 落后时不再悄悄无人知晓 `[dream]`
5
15
  - **New**: `roll loop events` — 查看 loop 每轮的详细事件流,任务选择、评审结果、CI 状态、合并全都有迹可查 `[loop]`
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.3"
7
+ VERSION="2026.517.4"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -872,167 +872,6 @@ cmd_init() {
872
872
  fi
873
873
  }
874
874
 
875
-
876
- # ─── Helper: merge global preamble + template into output ────────────────────
877
- merge_convention() {
878
- local filename="$1"
879
- local tpl_dir="$2"
880
- local out_dir="$3"
881
- local display="${4:-$filename}"
882
- local global_file="$ROLL_GLOBAL/$filename"
883
- local tpl_file="$tpl_dir/$filename"
884
- local out_file="$out_dir/$filename"
885
-
886
- if [[ ! -f "$tpl_file" ]]; then
887
- return
888
- fi
889
-
890
- local merged
891
- merged="$(cat "$global_file" 2>/dev/null || true)"
892
- merged+=$'\n\n'
893
- merged+="---"
894
- merged+=$'\n\n'
895
- merged+="$(cat "$tpl_file")"
896
-
897
- if [[ -f "$out_file" ]]; then
898
- if diff -q <(echo "$merged") "$out_file" &>/dev/null; then
899
- _ROLL_MERGE_SUMMARY+=("unchanged|$display")
900
- return # identical, nothing to do
901
- fi
902
- warn "File exists: $display 文件已存在: $display"
903
- echo -n " [o] Overwrite [k] Keep [M] Merge (default): "
904
- read -r answer
905
- answer="${answer:-M}"
906
- case "$answer" in
907
- o|O)
908
- echo "$merged" > "$out_file"
909
- ok "Overwritten: $display 已覆盖: $display"
910
- _ROLL_MERGE_SUMMARY+=("overwritten|$display")
911
- return
912
- ;;
913
- k|K)
914
- info "Kept: $display 已保留: $display"
915
- _ROLL_MERGE_SUMMARY+=("kept|$display")
916
- return
917
- ;;
918
- m|M|"")
919
- # Merge: for each ## section in template:
920
- # - if missing in output → append
921
- # - if present but content differs → show diff, prompt [u/k]
922
- # - if present and identical → skip
923
-
924
- # Helper: extract a section's body (lines after heading until next ## or EOF)
925
- # Usage: _extract_section "$file" "$heading"
926
- _extract_section() {
927
- local file="$1"
928
- local heading="$2"
929
- local in_section=false
930
- local body=""
931
- while IFS= read -r ln; do
932
- if [[ "$ln" == "$heading" ]]; then
933
- in_section=true
934
- continue
935
- fi
936
- if [[ "$in_section" == "true" ]]; then
937
- if [[ "$ln" =~ ^##\ ]]; then
938
- break
939
- fi
940
- body+="$ln"$'\n'
941
- fi
942
- done < "$file"
943
- printf '%s' "$body"
944
- }
945
-
946
- local current_heading=""
947
- local current_body=""
948
-
949
- # _process_section: check/append/update a completed section
950
- _process_section() {
951
- local heading="$1"
952
- local body="$2"
953
- local out="$3"
954
- if ! grep -qF "$heading" "$out" 2>/dev/null; then
955
- # Section missing → append
956
- printf '\n%s\n%s' "$heading" "$body" >> "$out"
957
- else
958
- # Section exists → compare content
959
- local existing
960
- existing="$(_extract_section "$out" "$heading")"
961
- if [[ "$body" != "$existing" ]]; then
962
- echo ""
963
- warn "Section \"$heading\" exists but content differs:"
964
- diff <(printf '%s' "$existing") <(printf '%s' "$body") || true
965
- echo -n " [u] update with template [k] keep mine (default): "
966
- local sec_ans
967
- read -r sec_ans
968
- sec_ans="${sec_ans:-k}"
969
- if [[ "$sec_ans" == "u" || "$sec_ans" == "U" ]]; then
970
- # Replace existing section with template section
971
- local tmp_out
972
- tmp_out="$(mktemp)"
973
- local skip=false
974
- while IFS= read -r fline; do
975
- if [[ "$fline" == "$heading" ]]; then
976
- skip=true
977
- printf '%s\n%s' "$heading" "$body" >> "$tmp_out"
978
- continue
979
- fi
980
- if [[ "$skip" == "true" ]]; then
981
- if [[ "$fline" =~ ^##\ ]]; then
982
- skip=false
983
- printf '%s\n' "$fline" >> "$tmp_out"
984
- fi
985
- continue
986
- fi
987
- printf '%s\n' "$fline" >> "$tmp_out"
988
- done < "$out"
989
- mv "$tmp_out" "$out"
990
- fi
991
- fi
992
- fi
993
- }
994
-
995
- # Parse template sections, reading from file (not herestring) to keep stdin free
996
- # for interactive user prompts inside _process_section
997
- local tpl_sections=()
998
- local tpl_bodies=()
999
- local cur_h="" cur_b=""
1000
- while IFS= read -r line; do
1001
- if [[ "$line" =~ ^##\ ]]; then
1002
- if [[ -n "$cur_h" ]]; then
1003
- tpl_sections+=("$cur_h")
1004
- tpl_bodies+=("$cur_b")
1005
- fi
1006
- cur_h="$line"
1007
- cur_b=""
1008
- elif [[ -n "$cur_h" ]]; then
1009
- cur_b+="$line"$'\n'
1010
- fi
1011
- done < "$tpl_file"
1012
- # Capture last section
1013
- if [[ -n "$cur_h" ]]; then
1014
- tpl_sections+=("$cur_h")
1015
- tpl_bodies+=("$cur_b")
1016
- fi
1017
-
1018
- # Now process sections (stdin is free for user input)
1019
- local i
1020
- for (( i=0; i<${#tpl_sections[@]}; i++ )); do
1021
- _process_section "${tpl_sections[$i]}" "${tpl_bodies[$i]}" "$out_file"
1022
- done
1023
-
1024
- ok "Merged: $display 已合并: $display"
1025
- _ROLL_MERGE_SUMMARY+=("merged|$display")
1026
- return
1027
- ;;
1028
- esac
1029
- fi
1030
-
1031
- echo "$merged" > "$out_file"
1032
- ok "Created: $display 已创建: $display"
1033
- _ROLL_MERGE_SUMMARY+=("created|$display")
1034
- }
1035
-
1036
875
  # ─── Helper: print a tidy summary of merge actions ───────────────────────────
1037
876
  print_merge_summary() {
1038
877
  if [[ ${#_ROLL_MERGE_SUMMARY[@]} -eq 0 ]]; then
@@ -2243,6 +2082,13 @@ _write_loop_runner_script() {
2243
2082
  # _install_launchd_plists prepend it). The runner now manages cwd itself
2244
2083
  # — pointing at the worktree when isolation succeeds, project_path otherwise.
2245
2084
  local claude_cmd; claude_cmd="${cmd_verbose#cd \"*\" && }"
2085
+ # FIX-048: Claude Code resolves project root from the worktree's .git file to
2086
+ # the main repo, placing worktree absolute paths outside its sandbox. Inject
2087
+ # --add-dir "$WT" so the worktree directory is explicitly allowed. Only applies
2088
+ # to claude (the --output-format stream-json flag is exclusive to claude runs).
2089
+ if [[ "$claude_cmd" == *"--output-format stream-json"* ]]; then
2090
+ claude_cmd="${claude_cmd/--output-format stream-json/--output-format stream-json --add-dir \"\$WT\"}"
2091
+ fi
2246
2092
  local slug; slug=$(_project_slug "$project_path")
2247
2093
  cat > "$inner_path" << INNER
2248
2094
  #!/bin/bash -l
@@ -2303,6 +2149,11 @@ for _orphan_wt in "\${_SHARED_ROOT}/worktrees/${slug}-cycle-"*; do
2303
2149
  _orphan_commits=\$(cd "\$_orphan_wt" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
2304
2150
  if [ "\$_orphan_commits" -gt 0 ]; then
2305
2151
  echo "[loop] FIX-040: recovering orphan worktree \$_orphan_wt (branch \$_orphan_branch, \${_orphan_commits} commits)"
2152
+ # FIX-045: rebase onto origin/main before publishing — avoids BEHIND state on GitHub
2153
+ if ! ( cd "\$_orphan_wt" && git fetch origin main 2>/dev/null && git rebase origin/main 2>/dev/null ); then
2154
+ echo "[loop] FIX-045: orphan \$_orphan_branch rebase failed — skipping recovery (conflict or network error)"
2155
+ continue
2156
+ fi
2306
2157
  _orphan_ok=0
2307
2158
  if ( cd "\$_orphan_wt" && _loop_is_doc_only_change ); then
2308
2159
  ( cd "\$_orphan_wt" && _loop_publish_doc_pr "\$_orphan_branch" "doc: recover orphan \${_orphan_branch}" ) && _orphan_ok=1
@@ -2393,13 +2244,21 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2393
2244
  _loop_event idle "\${CYCLE_ID}" "" "" || true
2394
2245
  echo "[loop] cycle \${CYCLE_ID}: idle (no new commits); worktree cleaned"
2395
2246
  else
2396
- if ( cd "\$WT" && _loop_is_doc_only_change ); then
2247
+ _is_doc_only=0
2248
+ ( cd "\$WT" && _loop_is_doc_only_change ) && _is_doc_only=1
2249
+ if [ "\$_is_doc_only" -eq 1 ]; then
2397
2250
  ( cd "\$WT" && _loop_publish_doc_pr "\$BRANCH" "doc: loop cycle \${CYCLE_ID}" )
2398
2251
  else
2399
2252
  ( cd "\$WT" && _loop_publish_pr "\$BRANCH" "loop cycle \${CYCLE_ID}" )
2400
2253
  fi
2401
2254
  _publish_status=\$?
2402
2255
  if [ "\$_publish_status" -eq 0 ]; then
2256
+ # FIX-047: CI green ≠ delivered — wait for actual PR merge before cycle complete
2257
+ if [ "\$_is_doc_only" -eq 0 ]; then
2258
+ if ! ( cd "\$WT" && _loop_wait_pr_merge "\$BRANCH" ); then
2259
+ _worktree_alert "cycle \${CYCLE_ID}: FIX-047: PR not merged within timeout — code may not be in main (BRANCH=\${BRANCH})"
2260
+ fi
2261
+ fi
2403
2262
  _worktree_cleanup "\$WT" "\$BRANCH"
2404
2263
  _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true
2405
2264
  echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
@@ -2731,8 +2590,10 @@ cmd_loop() {
2731
2590
  pause) _loop_pause ;;
2732
2591
  resume) _loop_resume ;;
2733
2592
  reset) _loop_reset ;;
2734
- notify) _notify "${1:-roll}" "${2:-}" ;;
2735
- *) err "Usage: roll loop <on|off|now|test|status|monitor|runs|events|attach|mute|unmute|pause|resume|reset|notify>"; exit 1 ;;
2593
+ notify) _notify "${1:-roll}" "${2:-}" ;;
2594
+ enforce-tcr) _loop_enforce_tcr "${1:-}" "${2:-}" ;;
2595
+ precheck-ci) _loop_precheck_ci ;;
2596
+ *) err "Usage: roll loop <on|off|now|test|status|monitor|runs|events|attach|mute|unmute|pause|resume|reset|notify|enforce-tcr|precheck-ci>"; exit 1 ;;
2736
2597
  esac
2737
2598
  }
2738
2599
 
@@ -3251,6 +3112,20 @@ _gh_repo_slug() {
3251
3112
  printf "%s\n" "$url"
3252
3113
  }
3253
3114
 
3115
+ # Returns 0 if gh CLI is installed and executable, 1 otherwise.
3116
+ _gh_available() { command -v gh >/dev/null 2>&1; }
3117
+
3118
+ # Resolve the GitHub owner/repo slug and set <outvar> to it.
3119
+ # Returns 0 on success. Returns 1 (no output) if gh is unavailable or the
3120
+ # remote is not a GitHub URL — caller decides how to handle failure.
3121
+ _gh_resolve() {
3122
+ local _outvar="$1"
3123
+ _gh_available || return 1
3124
+ local _slug
3125
+ _slug=$(_gh_repo_slug 2>/dev/null) || return 1
3126
+ printf -v "$_outvar" '%s' "$_slug"
3127
+ }
3128
+
3254
3129
  # Poll gh run list until current commit's CI completes.
3255
3130
  # Returns 0 on success (or when gh binary missing — graceful skip).
3256
3131
  # Returns 1 on CI failure, timeout, or any gh call failure.
@@ -3259,7 +3134,7 @@ _ci_wait() {
3259
3134
  local interval=15
3260
3135
  local elapsed=0
3261
3136
 
3262
- command -v gh &>/dev/null || { warn "gh not installed — skipping CI gate gh 未安装,跳过 CI 检查"; return 0; }
3137
+ _gh_available || { warn "gh not installed — skipping CI gate gh 未安装,跳过 CI 检查"; return 0; }
3263
3138
 
3264
3139
  local commit; commit=$(git rev-parse HEAD 2>/dev/null) || { err "Not a git repo 非 git 仓库"; return 1; }
3265
3140
  local short; short=$(git rev-parse --short HEAD 2>/dev/null)
@@ -3281,6 +3156,17 @@ _ci_wait() {
3281
3156
  }
3282
3157
 
3283
3158
  if [[ -z "$runs" || "$runs" == "[]" ]]; then
3159
+ # FIX-046: CI only fires on pull_request events — without a PR, runs will never appear.
3160
+ # Check if an open PR exists; if not, skip the gate gracefully.
3161
+ local _branch; _branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
3162
+ if [[ -n "$_branch" ]]; then
3163
+ local _pr_json; _pr_json=$(gh -R "$repo_slug" pr list --head "$_branch" --state open --json number 2>/dev/null || echo "1")
3164
+ local _pr_count; _pr_count=$(echo "$_pr_json" | jq 'length' 2>/dev/null || echo "1")
3165
+ if [[ "$_pr_count" == "0" ]]; then
3166
+ warn "No open PR for ${_branch} — CI not triggered; skipping CI gate 当前分支无 PR,CI 未触发,跳过"
3167
+ return 0
3168
+ fi
3169
+ fi
3284
3170
  (( elapsed == 0 )) && echo " No CI runs found yet, waiting... 尚无 CI 记录,等待触发..."
3285
3171
  sleep "$interval"
3286
3172
  elapsed=$(( elapsed + interval ))
@@ -3320,10 +3206,9 @@ _ci_wait() {
3320
3206
  # Returns 0: ok to proceed (green / pending / unknown / no gh).
3321
3207
  # Returns 1: HEAD CI is definitively red → ALERT written, do not build.
3322
3208
  _loop_precheck_ci() {
3323
- command -v gh &>/dev/null || return 0
3209
+ local slug; _gh_resolve slug || return 0
3324
3210
 
3325
3211
  local commit; commit=$(git rev-parse HEAD 2>/dev/null) || return 0
3326
- local slug; slug=$(_gh_repo_slug) || return 0
3327
3212
 
3328
3213
  local runs
3329
3214
  runs=$(gh -R "$slug" run list --commit "$commit" --json conclusion 2>/dev/null) || return 0
@@ -3458,13 +3343,22 @@ _loop_self_heal_ci() {
3458
3343
  [[ -z "$story_id" ]] && return 1
3459
3344
  [[ "${ROLL_LOOP_NO_HEAL:-0}" == "1" ]] && return 1
3460
3345
  local max="${ROLL_LOOP_HEAL_MAX:-2}"
3461
- local dir; dir=$(_loop_heal_dir)
3462
- local counter="${dir}/${story_id}.count"
3463
- mkdir -p "$dir"
3464
- local current; current=$(<"$counter") 2>/dev/null || current=0
3465
- [[ "$current" =~ ^[0-9]+$ ]] || current=0
3346
+ local state="${_LOOP_STATE:-${HOME}/.shared/roll/loop/state.yaml}"
3347
+ local current=0
3348
+ if [[ -f "$state" ]]; then
3349
+ local raw; raw=$(grep '^heal_count:' "$state" 2>/dev/null | awk '{print $2}')
3350
+ [[ "$raw" =~ ^[0-9]+$ ]] && current="$raw"
3351
+ fi
3466
3352
  [[ "$current" -ge "$max" ]] && return 1
3467
- echo $((current + 1)) > "$counter"
3353
+ local new=$((current + 1))
3354
+ mkdir -p "$(dirname "$state")"
3355
+ if grep -q '^heal_count:' "$state" 2>/dev/null; then
3356
+ local tmp; tmp=$(mktemp)
3357
+ sed "s/^heal_count:.*/heal_count: ${new}/" "$state" > "$tmp"
3358
+ mv "$tmp" "$state"
3359
+ else
3360
+ echo "heal_count: ${new}" >> "$state"
3361
+ fi
3468
3362
  return 0
3469
3363
  }
3470
3364
 
@@ -3473,7 +3367,11 @@ _loop_self_heal_ci() {
3473
3367
  _loop_clear_heal_state() {
3474
3368
  local story_id="$1"
3475
3369
  [[ -z "$story_id" ]] && return 0
3476
- rm -f "$(_loop_heal_dir)/${story_id}.count" 2>/dev/null
3370
+ local state="${_LOOP_STATE:-${HOME}/.shared/roll/loop/state.yaml}"
3371
+ [[ ! -f "$state" ]] && return 0
3372
+ local tmp; tmp=$(mktemp)
3373
+ grep -v '^heal_count:' "$state" > "$tmp"
3374
+ mv "$tmp" "$state"
3477
3375
  return 0
3478
3376
  }
3479
3377
 
@@ -3746,7 +3644,7 @@ _loop_pr_rebase_stale() {
3746
3644
  local pr="$1" head_ref="$2"
3747
3645
  [ -n "$pr" ] && [ -n "$head_ref" ] || return 0
3748
3646
 
3749
- local slug; slug=$(_gh_repo_slug 2>/dev/null) || return 0
3647
+ local slug; _gh_resolve slug || return 0
3750
3648
 
3751
3649
  local pr_json
3752
3650
  pr_json=$(gh -R "$slug" pr view "$pr" --json headRepository,headRepositoryOwner,isCrossRepository 2>/dev/null) || return 0
@@ -3780,9 +3678,7 @@ _loop_pr_rebase_stale() {
3780
3678
  # Walks open PRs and routes each by classification.
3781
3679
  # Lenient on gh unavailability — returns 0 so the loop continues to BACKLOG.
3782
3680
  _loop_pr_inbox() {
3783
- command -v gh >/dev/null 2>&1 || return 0
3784
-
3785
- local slug; slug=$(_gh_repo_slug 2>/dev/null) || return 0
3681
+ local slug; _gh_resolve slug || return 0
3786
3682
  local prs_json
3787
3683
  prs_json=$(gh -R "$slug" pr list --state open \
3788
3684
  --json number,headRefName,author,title \
@@ -4219,15 +4115,10 @@ _loop_cleanup_stale_cycle_branches() {
4219
4115
  _loop_publish_pr() {
4220
4116
  local branch="$1"
4221
4117
  local title="${2:-loop cycle ${branch#loop/}}"
4222
- if ! command -v gh >/dev/null 2>&1; then
4223
- _worktree_alert "_loop_publish_pr: gh not installed; cannot publish PR for ${branch}"
4118
+ local slug; _gh_resolve slug || {
4119
+ _worktree_alert "_loop_publish_pr: gh not installed or origin is not a github repo; cannot publish PR for ${branch}"
4224
4120
  return 2
4225
- fi
4226
- local slug; slug=$(_gh_repo_slug 2>/dev/null) || slug=""
4227
- if [ -z "$slug" ]; then
4228
- _worktree_alert "_loop_publish_pr: origin remote is not a github repo; cannot publish PR for ${branch}"
4229
- return 2
4230
- fi
4121
+ }
4231
4122
  local _push_err
4232
4123
  _push_err=$(git push origin "$branch" 2>&1) || {
4233
4124
  _worktree_alert "_loop_publish_pr: push origin ${branch} failed: ${_push_err}"
@@ -4253,6 +4144,29 @@ _loop_publish_pr() {
4253
4144
  return 0
4254
4145
  }
4255
4146
 
4147
+ # _loop_wait_pr_merge <branch>
4148
+ # FIX-047: poll GitHub until PR for <branch> is merged (confirms delivery).
4149
+ # Returns 0: merged. Returns 1: CLOSED or timeout.
4150
+ # Gracefully skips (returns 0) when gh is unavailable or slug unparseable.
4151
+ # Timeout: ROLL_PR_MERGE_TIMEOUT (default 600s).
4152
+ _loop_wait_pr_merge() {
4153
+ local branch="$1"
4154
+ local timeout="${ROLL_PR_MERGE_TIMEOUT:-600}"
4155
+ local interval=30
4156
+ local elapsed=0
4157
+ local slug; _gh_resolve slug || return 0
4158
+ while (( elapsed < timeout )); do
4159
+ local state; state=$(gh -R "$slug" pr view "$branch" --json state -q .state 2>/dev/null || echo "UNKNOWN")
4160
+ case "$state" in
4161
+ MERGED) return 0 ;;
4162
+ CLOSED) return 1 ;;
4163
+ esac
4164
+ sleep "$interval"
4165
+ elapsed=$(( elapsed + interval ))
4166
+ done
4167
+ return 1
4168
+ }
4169
+
4256
4170
  # _loop_is_doc_only_change
4257
4171
  # Returns 0 if every file changed since origin/main is doc-only
4258
4172
  # (BACKLOG.md, CHANGELOG.md, PROPOSALS.md, docs/, .claude/).
@@ -4271,15 +4185,10 @@ _loop_is_doc_only_change() {
4271
4185
  _loop_publish_doc_pr() {
4272
4186
  local branch="$1"
4273
4187
  local title="${2:-doc update ${branch#loop/}}"
4274
- if ! command -v gh >/dev/null 2>&1; then
4275
- _worktree_alert "_loop_publish_doc_pr: gh not installed; cannot publish PR for ${branch}"
4188
+ local slug; _gh_resolve slug || {
4189
+ _worktree_alert "_loop_publish_doc_pr: gh not installed or origin is not a github repo; cannot publish PR for ${branch}"
4276
4190
  return 2
4277
- fi
4278
- local slug; slug=$(_gh_repo_slug 2>/dev/null) || slug=""
4279
- if [ -z "$slug" ]; then
4280
- _worktree_alert "_loop_publish_doc_pr: origin remote is not a github repo; cannot publish PR for ${branch}"
4281
- return 2
4282
- fi
4191
+ }
4283
4192
  if ! git push origin "$branch" --quiet 2>/dev/null; then
4284
4193
  _worktree_alert "_loop_publish_doc_pr: push origin ${branch} failed"
4285
4194
  return 1
@@ -4657,7 +4566,7 @@ cmd_ci() {
4657
4566
  return
4658
4567
  fi
4659
4568
 
4660
- command -v gh &>/dev/null || { warn "gh not installed gh 未安装"; return 0; }
4569
+ _gh_available || { warn "gh not installed gh 未安装"; return 0; }
4661
4570
  local commit; commit=$(git rev-parse HEAD 2>/dev/null) || { err "Not a git repo 非 git 仓库"; return 1; }
4662
4571
  local runs
4663
4572
  runs=$(gh run list --commit "$commit" --json status,conclusion,name 2>/dev/null) || { warn "gh run list failed"; return 0; }
@@ -4962,7 +4871,7 @@ _dash_ac_completion() {
4962
4871
  # ④ DoD CI signal — query gh for HEAD's most-recent run conclusion.
4963
4872
  # Returns: success | pending | failure | none
4964
4873
  _dash_ci_status() {
4965
- command -v gh &>/dev/null || { echo "none"; return; }
4874
+ _gh_available || { echo "none"; return; }
4966
4875
  local commit; commit=$(git rev-parse HEAD 2>/dev/null) || { echo "none"; return; }
4967
4876
  local slug; slug=$(_gh_repo_slug 2>/dev/null) || true
4968
4877
  local out
package/lib/loop-fmt.py CHANGED
@@ -9,15 +9,77 @@ Tier 1 (signal): tcr commit, story skill, peer verdict, ci gate, pr merge, e
9
9
  import sys
10
10
  import json
11
11
  import re
12
+ import os
13
+ import threading
14
+ import time
12
15
  from datetime import datetime, timezone
13
16
 
17
+ _SPIN_ENABLED = os.environ.get("LOOP_FMT_NO_SPIN", "0") != "1"
18
+ SPIN_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
19
+
14
20
  DARK_GRAY = "\033[90m"
15
21
  CYAN = "\033[36m"
16
22
  WHITE = "\033[97m"
17
23
  GREEN = "\033[32m"
18
24
  RED = "\033[31m"
25
+ YELLOW = "\033[33m"
19
26
  RESET = "\033[0m"
20
27
 
28
+
29
+ class Spinner:
30
+ """Animated wait indicator for long-running operations.
31
+
32
+ In production (LOOP_FMT_NO_SPIN=0): background thread writes frames using \\r.
33
+ In test mode (LOOP_FMT_NO_SPIN=1): writes a static ⏳ line to stdout instead.
34
+ """
35
+ def __init__(self):
36
+ self._thread = None
37
+ self._running = False
38
+ self._label = ""
39
+ self._lock = threading.Lock()
40
+
41
+ @property
42
+ def active(self):
43
+ return self._running
44
+
45
+ def start(self, label):
46
+ with self._lock:
47
+ if self._running:
48
+ self._label = label # update without restart
49
+ return
50
+ self._label = label
51
+ self._running = True
52
+ if _SPIN_ENABLED:
53
+ self._thread = threading.Thread(target=self._run, daemon=True)
54
+ self._thread.start()
55
+ else:
56
+ sys.stdout.write(f" {YELLOW}⏳ {label}...{RESET}\n")
57
+ sys.stdout.flush()
58
+
59
+ def stop(self):
60
+ with self._lock:
61
+ was_running = self._running
62
+ self._running = False
63
+ if self._thread:
64
+ self._thread.join(timeout=0.3)
65
+ self._thread = None
66
+ if _SPIN_ENABLED and was_running:
67
+ sys.stdout.write(f"\r{' ' * 60}\r")
68
+ sys.stdout.flush()
69
+
70
+ def _run(self):
71
+ i = 0
72
+ while self._running:
73
+ with self._lock:
74
+ label = self._label
75
+ frame = SPIN_FRAMES[i % len(SPIN_FRAMES)]
76
+ sys.stdout.write(f"\r {YELLOW}{frame} {label}...{RESET}")
77
+ sys.stdout.flush()
78
+ time.sleep(0.12)
79
+ i += 1
80
+ sys.stdout.write(f"\r{' ' * 60}\r")
81
+ sys.stdout.flush()
82
+
21
83
  SUPPRESS_TOOLS = {"Read", "Glob", "Grep", "ReadMcpResourceTool", "ListMcpResourcesTool",
22
84
  "WebFetch", "WebSearch", "TaskCreate", "TaskGet", "TaskList",
23
85
  "TaskUpdate", "TaskOutput", "TaskStop"}
@@ -45,13 +107,15 @@ def stamp(text, muted=False):
45
107
 
46
108
  class LoopFmt:
47
109
  def __init__(self):
48
- self.last_bash_cmd = ""
49
- self.tcr_count = 0
110
+ self.last_bash_cmd = ""
111
+ self.tcr_count = 0
50
112
  self.last_test_count = None
51
- self.cycle_num = None
52
- self.pending_commit = False
53
- self.pending_pr = False
54
- self.pending_ci = False
113
+ self.cycle_num = None
114
+ self.pending_commit = False
115
+ self.pending_pr = False
116
+ self.pending_ci = False
117
+ self.pending_story = False
118
+ self.spinner = Spinner()
55
119
 
56
120
  def _extract_cycle_num(self, text):
57
121
  m = re.search(r'cycle[#\s]+(\d+)', text, re.IGNORECASE)
@@ -139,8 +203,10 @@ class LoopFmt:
139
203
  self.pending_commit = True
140
204
  elif re.search(r'gh pr (create|merge)', cmd):
141
205
  self.pending_pr = True
142
- elif re.search(r'(roll ci|npm run ci|ci:local)', cmd):
206
+ self.spinner.start("merging PR")
207
+ elif re.search(r'(roll ci|npm run ci|_ci_wait|ci:local)', cmd):
143
208
  self.pending_ci = True
209
+ self.spinner.start("waiting for CI")
144
210
  return # Wait for result
145
211
 
146
212
  if name == "Skill":
@@ -151,6 +217,8 @@ class LoopFmt:
151
217
  print()
152
218
  print(stamp(f"cycle #{self.cycle_num or '?'} — picking story"))
153
219
  print(step("story", us_id, trunc(args, 60)))
220
+ self.pending_story = True
221
+ self.spinner.start("executing story")
154
222
  return
155
223
 
156
224
  # All other tools (Agent, ToolSearch, etc.): suppress
@@ -189,7 +257,13 @@ class LoopFmt:
189
257
  print(step("tcr", commit_hash, f"{commit_msg}{test_part}"))
190
258
  return
191
259
 
260
+ if self.pending_story:
261
+ self.pending_story = False
262
+ self.spinner.stop()
263
+ return # story result content suppressed; TCR events showed the work
264
+
192
265
  if self.pending_pr:
266
+ self.spinner.stop()
193
267
  self.pending_pr = False
194
268
  m = re.search(r'#(\d+)', text)
195
269
  if m:
@@ -201,6 +275,7 @@ class LoopFmt:
201
275
  return
202
276
 
203
277
  if self.pending_ci:
278
+ self.spinner.stop()
204
279
  self.pending_ci = False
205
280
  has_green = re.search(r'(green|pass|success|all tests)', text, re.IGNORECASE)
206
281
  has_red = re.search(r'(red|fail|error)', text, re.IGNORECASE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.517.3",
3
+ "version": "2026.517.4",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -416,6 +416,10 @@ prompt 会包含:
416
416
  (即使没有 deep doc 也要列)
417
417
  - Feature 名跟 `docs/features/<file>.md` 文件名一致时,加链接到该 md
418
418
  - 没有对应 deep doc 的 Feature,**只写 plain text 不加链接**
419
+ - **Planning distinction(US-DOC-011)**:
420
+ - 该 Feature 下**所有** Story 均为 `📋 Todo` → 在描述末尾追加 `*(规划中)*`
421
+ - 只要有 **≥1 个** `✅ Done` Story → 正常展示,**不加**任何标记
422
+ - 一眼可见:规划中的 Feature 在每个 Epic 分组的末尾列出
419
423
  - 描述写 1 句话 **产品视角**:用户能用它做什么,避免实现细节
420
424
  - 分组用 BACKLOG 的 Epic 名,原序,不重排
421
425
  - Core Highlights 从所有 Features 里挑 3-5 个最能代表产品定位的,
@@ -45,7 +45,9 @@ $roll-.review unstaged
45
45
  $roll-.review files src/utils.ts
46
46
  ```
47
47
 
48
- ## Review Dimensions (6 Core Dimensions)
48
+ ## Review Dimensions (7 Core Dimensions)
49
+
50
+ Original 6 dimensions plus Reuse (added in REFACTOR-022, simplify three-axis integration):
49
51
 
50
52
  ```
51
53
  ┌─────────────────────────────────────────────────────────┐
@@ -54,13 +56,42 @@ $roll-.review files src/utils.ts
54
56
  │ ✅ Correctness - Logic is correct, no bugs │
55
57
  │ ✅ Security - No vulnerabilities, input valid. │
56
58
  │ ✅ Maintainability - Clear naming, sound structure │
59
+ │ Quality anti-patterns (check each): │
60
+ │ □ Redundant state / cached values that could be │
61
+ │ derived directly │
62
+ │ □ Parameter sprawl — new param vs. restructure │
63
+ │ □ Copy-paste with slight variation (near-dup) │
64
+ │ □ Leaky abstraction — exposes internal details │
65
+ │ □ Stringly-typed — raw string where constant │
66
+ │ / enum exists │
67
+ │ □ Unnecessary JSX nesting (no layout value) │
68
+ │ □ Nested conditionals ≥3 deep (ternary chains, │
69
+ │ nested if/else) — flatten with early return │
70
+ │ □ Unnecessary comments explaining WHAT │
57
71
  │ ✅ Performance - No performance pitfalls │
72
+ │ Efficiency anti-patterns (check each): │
73
+ │ □ Redundant computation / repeated file read / │
74
+ │ duplicate API call / N+1 pattern │
75
+ │ □ Missed concurrency — independent ops sequential │
76
+ │ □ Hot-path bloat — blocking work in startup or │
77
+ │ per-request path │
78
+ │ □ Loop no-op updates — missing change-detection │
79
+ │ guard │
80
+ │ □ TOCTOU existence pre-check — operate directly + │
81
+ │ handle error instead │
82
+ │ □ Memory — unbounded structures / missing cleanup │
83
+ │ □ Overly broad op — reading full file for a slice │
58
84
  │ ✅ Testability - Easy to test, edge cases covered │
59
85
  │ ✅ Scope - Focused on current task, no │
60
86
  │ unrelated changes │
87
+ │ ✅ Reuse - No new code duplicating existing │
88
+ │ □ New function duplicates existing utility/helper │
89
+ │ □ Inline logic replaceable by existing tool │
61
90
  └─────────────────────────────────────────────────────────┘
62
91
  ```
63
92
 
93
+ **Usage in TCR**: Each micro-step review is a lightweight self-check against this checklist — no sub-agents, zero extra token cost. The three-axis deep review with parallel agents runs once per Story in `$roll-build` Phase 7.
94
+
64
95
  ## Severity Levels and Decisions
65
96
 
66
97
  | Level | Definition | Decision |
@@ -378,33 +378,51 @@ EOF
378
378
  chmod +x .git/hooks/pre-push
379
379
  ```
380
380
 
381
- ### Phase 7: Pre-Push Code Review
381
+ ### Phase 7: Pre-Push Code Review (Three-Axis Deep Review)
382
+
383
+ This phase runs **once per Story** (not per micro-step) on the full accumulated diff.
384
+ Per-micro-step review uses `$roll-.review staged` inline checklist (zero extra cost).
385
+
386
+ **Phase 3.5 vs Phase 7 split**: Phase 3.5 (Peer Review) focuses on architectural direction
387
+ and approach before coding begins. Phase 7 focuses on implementation quality after all
388
+ micro-steps are done — catching issues that only appear at diff scale (parameter sprawl
389
+ across files, copy-paste patterns, cross-file N+1, etc.).
382
390
 
383
391
  ```bash
384
- $roll-.review staged
392
+ # Capture full Story diff
393
+ git diff main...HEAD
385
394
  ```
386
395
 
387
- **Review output:**
396
+ **Launch three review agents in parallel** (each receives the full diff):
397
+
388
398
  ```
389
- 🔍 Self Review Report
390
- ├── Scope: X files (+Y/-Z lines)
391
- ├── 🔴 Critical: N issues (must fix)
392
- ├── 🟡 Warnings: N issues (should fix)
393
- ├── 🟢 Suggestions: N items (optional)
394
- └── ✅ Passed dimensions: [Quality, Design, Scope, ...]
399
+ Agent 1: Reuse Review
400
+ Search for existing utilities / helpers the new code could use instead
401
+ Flag any new function that duplicates existing functionality
402
+ Flag inline logic replaceable by existing tools
403
+
404
+ Agent 2: Quality Review
405
+ → Redundant state, Parameter sprawl, Copy-paste near-duplicate,
406
+ Leaky abstraction, Stringly-typed, JSX nesting,
407
+ Nested conditionals ≥3 deep, Unnecessary comments
408
+
409
+ Agent 3: Efficiency Review
410
+ → Redundant computation / N+1, Missed concurrency,
411
+ Hot-path bloat, Loop no-op updates, TOCTOU existence pre-check,
412
+ Memory leaks, Overly broad operations
395
413
  ```
396
414
 
397
- **Review dimensions** (correctness guaranteed by TCR):
398
- - 🎯 **Quality**: Naming clarity, DRY, function size, readability
399
- - 📐 **Design**: Architecture, abstraction level, separation of concerns
400
- - ⚠️ **Scope**: No opportunistic changes
401
- - 📝 **Documentation**: Comments where needed
415
+ Wait for all three agents to complete. Aggregate findings → fix each issue
416
+ (false positives: note and skip, no debate) summarize what was fixed.
417
+
418
+ **Fallback**: If parallel agent invocation fails, run `$roll-.review staged` on
419
+ the full diff as a single-pass fallback do not skip review entirely.
402
420
 
403
421
  **Decision:**
404
422
  ```
405
423
  🔴 Critical > 0 → Fix via new TCR cycle → Re-review
406
424
  🟡 Warnings > 0 → Fix if quick (< 5 min) or document
407
- 🟢 Suggestions / ✅ All clear → Proceed to push
425
+ 🟢 Suggestions / ✅ All clear → Proceed to Phase 8
408
426
  ```
409
427
 
410
428
  ### Phase 8: Commit & Push
@@ -96,7 +96,7 @@ fi
96
96
 
97
97
  ### Step 1.5 — Pre-run CI Health Check
98
98
 
99
- Call `_loop_precheck_ci` before scanning BACKLOG. This is a **defensive gate**
99
+ Call `roll loop precheck-ci` before scanning BACKLOG. This is a **defensive gate**
100
100
  against building on a broken base — if the most recent commit on the branch
101
101
  has red CI, the loop must not stack new commits on top (which would create the
102
102
  exact stuck-red state FIX-026 traces to).
@@ -105,7 +105,7 @@ exact stuck-red state FIX-026 traces to).
105
105
  - HEAD CI red → write ALERT, **do not pick up any stories this cycle**,
106
106
  exit cleanly. The next cycle will retry; the human must fix CI manually
107
107
  (typically by reverting or pushing a green commit) before the loop resumes.
108
- - `gh` missing or repo unparseable → graceful skip (`_loop_precheck_ci`
108
+ - `gh` missing or repo unparseable → graceful skip (`roll loop precheck-ci`
109
109
  returns 0); the post-build `_loop_enforce_ci` remains the strict gate.
110
110
 
111
111
  ### Step 1.6 — PR Inbox (US-AUTO-034)
@@ -229,7 +229,7 @@ run_id: loop-20260510-0200
229
229
 
230
230
  After each item completes:
231
231
 
232
- 1. **TCR 硬校验** — call `_loop_enforce_tcr <story_id> <started_at>`:
232
+ 1. **TCR 硬校验** — call `roll loop enforce-tcr <story_id> <started_at>`:
233
233
  - Count `tcr:` prefix commits since `started_at` via `git log --oneline --since=<started_at>`
234
234
  - Count == 0 → revert story status in BACKLOG.md from ✅ Done → 📋 Todo; write ALERT to `~/.shared/roll/loop/ALERT.md` with story ID, time, reason "zero tcr: commits since story start", and suggested actions (`roll loop now` / `$roll-build <id>` / `roll loop reset`)
235
235
  - Count > 0 → continue normally
@@ -246,34 +246,36 @@ After each item completes:
246
246
  (return 0). Any other `gh` error is **not** "gh unavailable" — it is a
247
247
  hard failure and must block the gate.
248
248
 
249
- **CI self-heal (US-AUTO-041)** — bounded auto-fix before ALERT:
250
-
251
- ```
252
- shell: _loop_self_heal_ci <story_id>
253
- ├── exit 0 → heal attempt allowed (counter incremented)
254
- │ 1. Capture failure summary:
255
- │ gh run view --log-failed --repo <slug> $(gh run list --commit HEAD \
256
- │ --json databaseId,conclusion -L 5 | jq -r '.[] | select(.conclusion=="failure") | .databaseId' | head -1) \
257
- │ 2>/dev/null | head -200 > /tmp/roll-heal-<story_id>.log
258
- │ 2. Invoke Skill("roll-fix") with brief:
259
- │ "CI red after <story_id>. Failing run logs at /tmp/roll-heal-<story_id>.log.
260
- │ Diagnose root cause, fix via TCR, commit, push. Do NOT change <story_id>'s
261
- │ BACKLOG status — it stays ✅ Done. The fix is a follow-up."
262
- 3. After roll-fix completes, return to step 2 (CI Gate) re-run
263
- `roll ci --wait`. The counter prevents infinite loops.
264
-
265
- └── exit 1 heal exhausted (>=ROLL_LOOP_HEAL_MAX, default 2) or disabled
266
- (ROLL_LOOP_NO_HEAL=1):
267
- 1. Keep story as Done (commits are already on main — CI red is a
268
- follow-up problem, not a story-failure)
269
- 2. Write ALERT to `~/.shared/roll/loop/ALERT.md` with:
270
- - story ID, time, commit SHA
271
- - heal attempts made (read counter from
272
- `${ROLL_LOOP_DIR:-~/.shared/roll/loop}/heal/<story_id>.count`)
273
- - last failure summary (head of /tmp/roll-heal-<story_id>.log)
274
- - suggested actions: `$roll-fix` manually / inspect CI / `roll loop reset`
275
- 3. Skip to next story.
276
- ```
249
+ **CI self-heal (US-AUTO-041)** — bounded auto-fix before ALERT.
250
+
251
+ Call `_loop_self_heal_ci <story_id>` to check if another attempt is permitted.
252
+
253
+ **Path A attempt allowed (exit 0, counter incremented in `state.yaml`):**
254
+
255
+ 1. Capture failure summary:
256
+ ```
257
+ gh run view --log-failed --repo <slug> \
258
+ $(gh run list --commit HEAD --json databaseId,conclusion -L 5 \
259
+ | jq -r '.[] | select(.conclusion=="failure") | .databaseId' | head -1) \
260
+ 2>/dev/null | head -200 > /tmp/roll-heal-<story_id>.log
261
+ ```
262
+ 2. Invoke `Skill("roll-fix")` with brief:
263
+ `"CI red after <story_id>. Failing run logs at /tmp/roll-heal-<story_id>.log.
264
+ Diagnose root cause, fix via TCR, commit, push. Do NOT change <story_id>'s
265
+ BACKLOG status it stays Done. The fix is a follow-up."`
266
+ 3. After `roll-fix` completes, return to step 2 (CI Gate) — re-run `roll ci --wait`.
267
+ The counter in `state.yaml` prevents infinite loops.
268
+
269
+ **Path B — heal exhausted (≥`ROLL_LOOP_HEAL_MAX`, default 2) or disabled (`ROLL_LOOP_NO_HEAL=1`) (exit 1):**
270
+
271
+ 1. Keep story as Done — commits are already on main; CI red is a follow-up
272
+ problem, not a story failure.
273
+ 2. Write ALERT to `~/.shared/roll/loop/ALERT.md` with:
274
+ - story ID, time, commit SHA
275
+ - heal attempts made (read `heal_count:` from `state.yaml`)
276
+ - last failure summary (head of `/tmp/roll-heal-<story_id>.log`)
277
+ - suggested actions: `$roll-fix` manually / inspect CI / `roll loop reset`
278
+ 3. Skip to next story.
277
279
 
278
280
  **Bypass for debugging / cost control:** set `ROLL_LOOP_NO_HEAL=1` to restore
279
281
  pre-US-AUTO-041 fail-fast behaviour.