@seanyao/roll 2026.523.2 → 2026.524.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/roll +546 -97
  3. package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
  4. package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
  5. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  6. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  7. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  8. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  9. package/lib/changelog_audit.py +155 -0
  10. package/lib/i18n/agent.sh +0 -0
  11. package/lib/i18n/alert.sh +0 -0
  12. package/lib/i18n/backlog.sh +0 -0
  13. package/lib/i18n/brief.sh +0 -0
  14. package/lib/i18n/changelog.sh +0 -0
  15. package/lib/i18n/ci.sh +0 -0
  16. package/lib/i18n/debug.sh +0 -0
  17. package/lib/i18n/doctor.sh +31 -0
  18. package/lib/i18n/dream.sh +0 -0
  19. package/lib/i18n/init.sh +17 -0
  20. package/lib/i18n/lang.sh +0 -0
  21. package/lib/i18n/loop.sh +0 -0
  22. package/lib/i18n/migrate.sh +0 -0
  23. package/lib/i18n/offboard.sh +15 -0
  24. package/lib/i18n/onboard.sh +0 -0
  25. package/lib/i18n/peer.sh +0 -0
  26. package/lib/i18n/propose.sh +0 -0
  27. package/lib/i18n/release.sh +0 -0
  28. package/lib/i18n/research.sh +0 -0
  29. package/lib/i18n/review_pr.sh +0 -0
  30. package/lib/i18n/sentinel.sh +0 -0
  31. package/lib/i18n/setup.sh +0 -0
  32. package/lib/i18n/shared.sh +83 -0
  33. package/lib/i18n/skills/roll-brief.sh +20 -0
  34. package/lib/i18n/skills/roll-build.sh +97 -0
  35. package/lib/i18n/skills/roll-design.sh +18 -0
  36. package/lib/i18n/skills/roll-fix.sh +14 -0
  37. package/lib/i18n/skills/roll-loop.sh +28 -0
  38. package/lib/i18n/skills/roll-onboard.sh +16 -0
  39. package/lib/i18n/slides.sh +0 -0
  40. package/lib/i18n/status.sh +0 -0
  41. package/lib/i18n/update.sh +9 -0
  42. package/lib/i18n.sh +25 -0
  43. package/lib/loop-fmt.py +77 -11
  44. package/lib/loop_unstick.py +180 -0
  45. package/lib/model_prices.py +93 -12
  46. package/lib/prices/snapshot-2026-05-22.json +2 -0
  47. package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
  48. package/lib/prices/snapshot-2026-05-23-kimi.json +14 -0
  49. package/lib/roll-home.py +17 -1
  50. package/lib/roll-loop-status.py +9 -0
  51. package/lib/roll_render.py +10 -2
  52. package/package.json +1 -1
  53. package/skills/roll-.dream/SKILL.md +4 -4
  54. package/skills/roll-loop/SKILL.md +15 -1
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.523.2"
7
+ VERSION="2026.524.2"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -920,14 +920,14 @@ _doctor_launchd_stale_section() {
920
920
  [[ -d "$wd" ]] && continue
921
921
  if [[ "$found" -eq 0 ]]; then
922
922
  echo ""
923
- echo "Stale launchd plists 无效的 launchd 服务"
923
+ echo "$(msg doctor.stale_plists)"
924
924
  echo ""
925
925
  found=1
926
926
  fi
927
927
  label=$(basename "$plist" .plist)
928
928
  echo " ⚠ ${label}"
929
929
  echo " WorkingDirectory missing: ${wd}"
930
- echo " 路径已失效,可清理: launchctl bootout gui/$(id -u)/${label}; rm '${plist}'"
930
+ echo " $(msg doctor.stale_plists_cleanup): launchctl bootout gui/$(id -u)/${label}; rm '${plist}'"
931
931
  done
932
932
  return 0
933
933
  }
@@ -936,7 +936,8 @@ _doctor_pr_section() {
936
936
  git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
937
937
 
938
938
  echo ""
939
- echo "PR review extras PR 评审两档开关"
939
+ echo "$(ROLL_LANG_RESOLVED=en msg doctor.pr_review_extras)"
940
+ echo "$(ROLL_LANG_RESOLVED=zh msg doctor.pr_review_extras)"
940
941
  echo ""
941
942
 
942
943
  local protection_state event_state
@@ -945,24 +946,24 @@ _doctor_pr_section() {
945
946
 
946
947
  case "$protection_state" in
947
948
  enabled)
948
- echo " AI review double gate enabled AI 评审双门已启用"
949
+ echo " $(msg doctor.pr_double_gate_enabled)"
949
950
  ;;
950
951
  disabled)
951
- echo " AI review double gate not enabled 双门未启用"
952
+ echo " $(msg doctor.pr_double_gate_disabled)"
952
953
  _print_pr_pipeline_hint
953
954
  ;;
954
955
  *)
955
- echo " AI review double gate state unknown — requires gh auth 状态未知(需要 gh auth)"
956
+ echo " $(msg doctor.pr_double_gate_unknown)"
956
957
  _print_pr_pipeline_hint
957
958
  ;;
958
959
  esac
959
960
 
960
961
  case "$event_state" in
961
962
  present)
962
- echo " Event-driven PR review installed 事件驱动 PR 评审已安装"
963
+ echo " $(msg doctor.pr_event_enabled)"
963
964
  ;;
964
965
  *)
965
- echo " Event-driven PR review not installed 事件驱动 PR 评审未安装"
966
+ echo " $(msg doctor.pr_event_disabled)"
966
967
  _print_pr_event_hint
967
968
  ;;
968
969
  esac
@@ -1000,21 +1001,18 @@ _doctor_event_workflow_state() {
1000
1001
  }
1001
1002
 
1002
1003
  _print_pr_event_hint() {
1003
- cat <<'HINT'
1004
-
1005
- Optional — enable event-driven PR review (seconds-fast, GitHub only).
1006
- 可选 —— 启用事件驱动 PR 评审(秒级响应,仅限 GitHub)。
1007
-
1008
- Without this, Roll reviews PRs each loop cycle (~1h). With it,
1009
- contributors get AI feedback on PR open/update immediately.
1010
- 不安装也行 loop 每轮会兜底评审。安装后 PR 一开即触发 AI 评审。
1011
-
1012
- cp templates/workflows/pr-review-event.yml .github/workflows/
1013
-
1014
- Then set the API key secret for your configured agent in GitHub repo settings.
1015
- 然后在 GitHub 仓库设置中添加你配置的 agent 对应的 API key secret。
1016
-
1017
- HINT
1004
+ echo ""
1005
+ echo " $(msg doctor.pr_event_optional)"
1006
+ echo " $(msg doctor.pr_event_optional_zh)"
1007
+ echo ""
1008
+ echo " $(msg doctor.pr_event_without)"
1009
+ echo " $(msg doctor.pr_event_without_zh)"
1010
+ echo ""
1011
+ echo " cp templates/workflows/pr-review-event.yml .github/workflows/"
1012
+ echo ""
1013
+ echo " $(msg doctor.pr_event_secret)"
1014
+ echo " $(msg doctor.pr_event_secret_zh)"
1015
+ echo ""
1018
1016
  }
1019
1017
 
1020
1018
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -1031,19 +1029,19 @@ _check_installed_version_or_retry() {
1031
1029
  [[ -z "$expected" || -z "$installed" ]] && return 0
1032
1030
 
1033
1031
  if [[ "$installed" != "$expected" ]]; then
1034
- warn "Version mismatch: installed ${installed}, expected ${expected} — CDN propagation lag, clearing cache and retrying... 版本不一致(已安装 ${installed},期望 ${expected}),疑似 CDN 未同步,清理缓存后重试..."
1032
+ warn "$(msg update.version_mismatch "$installed" "$expected")"
1035
1033
  npm cache clean --force &>/dev/null || true
1036
1034
  npm install -g @seanyao/roll@latest &>/dev/null || true
1037
1035
  local after
1038
1036
  after="$(grep "^VERSION=" "${pkg_dir}/@seanyao/roll/bin/roll" 2>/dev/null | sed 's/VERSION="\([^"]*\)"/\1/' || true)"
1039
1037
  if [[ -n "$after" && "$after" != "$expected" ]]; then
1040
- warn "Still on ${after} after retry — registry may not have propagated yet, try again in a minute. 重试后仍为 ${after},注册表可能尚未同步,请稍后再试。"
1038
+ warn "$(msg update.still_mismatch "$after")"
1041
1039
  fi
1042
1040
  fi
1043
1041
  }
1044
1042
 
1045
1043
  cmd_update() {
1046
- info "Current version: roll v${VERSION} 当前版本: roll v${VERSION}"
1044
+ info "$(msg update.current_version "$VERSION")"
1047
1045
  info "Upgrading via npm... 正在通过 npm 升级..."
1048
1046
  echo ""
1049
1047
 
@@ -1520,16 +1518,16 @@ _onboard_failure_hint() {
1520
1518
  local agent="$1" code="$2"
1521
1519
  echo "" >&2
1522
1520
  if [[ "$code" == "130" ]]; then
1523
- err "Onboard cancelled (Ctrl-C). Onboard 已取消(Ctrl-C)。" >&2
1521
+ err "$(msg init.onboard_cancelled)" >&2
1524
1522
  else
1525
- err "Agent '${agent}' exited with code ${code}. agent '${agent}' 异常退出 (code ${code})" >&2
1523
+ err "$(msg init.onboard_agent_exited "$agent" "$code")" >&2
1526
1524
  fi
1527
1525
  echo "" >&2
1528
- echo " 下一步 Next step:" >&2
1529
- echo " - 再试一次同一个 agent: roll init" >&2
1530
- echo " - retry the same agent: roll init" >&2
1531
- echo " - 换一个 agent: ROLL_ONBOARD_AGENT=<name> roll init" >&2
1532
- echo " - switch to another: ROLL_ONBOARD_AGENT=<name> roll init" >&2
1526
+ echo " $(msg init.onboard_next_step)" >&2
1527
+ echo " - $(msg init.onboard_retry)" >&2
1528
+ echo " - $(msg init.onboard_retry_en)" >&2
1529
+ echo " - $(msg init.onboard_switch)" >&2
1530
+ echo " - $(msg init.onboard_switch_en)" >&2
1533
1531
  echo "" >&2
1534
1532
  }
1535
1533
 
@@ -1821,13 +1819,13 @@ cmd_offboard() {
1821
1819
  local changeset; changeset=$(_onboard_changeset_path "$project_dir")
1822
1820
 
1823
1821
  if [[ ! -f "$changeset" ]]; then
1824
- err "No onboard changeset found at .roll/onboard-changeset.yaml"
1825
- err "未找到 onboard 变更清单 .roll/onboard-changeset.yaml"
1822
+ err "$(msg offboard.no_changeset_en)"
1823
+ err "$(msg offboard.no_changeset_zh)"
1826
1824
  echo "" >&2
1827
- echo " Manual offboard — remove these by hand if they came from Roll:" >&2
1828
- echo " rm -rf .roll/ # all process artefacts" >&2
1829
- echo " rm -f AGENTS.md CLAUDE.md # only if they were generated by roll init" >&2
1830
- echo " Edit .gitignore to remove any '.roll/' entry" >&2
1825
+ echo " $(msg offboard.manual_offboard)" >&2
1826
+ echo " $(msg offboard.manual_rm_roll)" >&2
1827
+ echo " $(msg offboard.manual_rm_agents)" >&2
1828
+ echo " $(msg offboard.manual_edit_gitignore)" >&2
1831
1829
  return 1
1832
1830
  fi
1833
1831
 
@@ -3086,6 +3084,18 @@ _project_agent() {
3086
3084
  fi
3087
3085
  }
3088
3086
 
3087
+ _fallback_agent() {
3088
+ local pref new_pref
3089
+ new_pref=$(_project_agent_pref_file)
3090
+ if [[ -f "$new_pref" ]] && grep -q "^fallback_agent:" "$new_pref" 2>/dev/null; then
3091
+ grep "^fallback_agent:" "$new_pref" | awk '{print $2}' | tr -d '"' | head -1
3092
+ elif [[ -f ".roll.yaml" ]] && grep -q "^fallback_agent:" .roll.yaml 2>/dev/null; then
3093
+ grep "^fallback_agent:" .roll.yaml | awk '{print $2}' | tr -d '"' | head -1
3094
+ elif [[ -f "$ROLL_CONFIG" ]] && grep -q "fallback_agent:" "$ROLL_CONFIG" 2>/dev/null; then
3095
+ grep "fallback_agent:" "$ROLL_CONFIG" | awk '{print $2}' | tr -d '"' | head -1
3096
+ fi
3097
+ }
3098
+
3089
3099
  _skill_content() {
3090
3100
  # Strip YAML frontmatter (---...---) — it's roll-internal metadata, not agent instructions
3091
3101
  awk 'NR==1 && /^---$/{skip=1;next} skip && /^---$/{skip=0;next} !skip{print}' "$1"
@@ -3211,6 +3221,115 @@ _agent_run_skill() {
3211
3221
  # `list` / `preview` (DECK-005). The AI authoring step happens in `new`;
3212
3222
  # everything else is pure bash.
3213
3223
  # ═══════════════════════════════════════════════════════════════════════════════
3224
+ # ─── Progress helpers (US-DECK-010) ──────────────────────────────────────────
3225
+ # Shared state for slides progress reporting.
3226
+
3227
+ _SLIDES_PROGRESS_PHASES=()
3228
+ _SLIDES_PROGRESS_START_TIME=0
3229
+ _SLIDES_PROGRESS_CURRENT=""
3230
+ _SLIDES_PROGRESS_PHASE_START_TIME=0
3231
+ _SLIDES_PROGRESS_SPINNER_PID=""
3232
+ _SLIDES_PROGRESS_QUIET=0
3233
+
3234
+ _slides_progress_init() {
3235
+ _SLIDES_PROGRESS_PHASES=("$@")
3236
+ _SLIDES_PROGRESS_START_TIME=$(date +%s)
3237
+ _SLIDES_PROGRESS_CURRENT=""
3238
+ _SLIDES_PROGRESS_PHASE_START_TIME=0
3239
+ _SLIDES_PROGRESS_SPINNER_PID=""
3240
+ _SLIDES_PROGRESS_QUIET=0
3241
+ }
3242
+
3243
+ _slides_progress_quiet() {
3244
+ _SLIDES_PROGRESS_QUIET=1
3245
+ }
3246
+
3247
+ _slides_progress_elapsed_str() {
3248
+ local elapsed="${1:-0}"
3249
+ local min=$(( elapsed / 60 ))
3250
+ local sec=$(( elapsed % 60 ))
3251
+ if [[ $min -gt 0 ]]; then
3252
+ printf '%dm %ds' "$min" "$sec"
3253
+ else
3254
+ printf '%ds' "$sec"
3255
+ fi
3256
+ }
3257
+
3258
+ _slides_progress_detect_tty() {
3259
+ if [[ ! -t 1 ]]; then
3260
+ _slides_progress_quiet
3261
+ fi
3262
+ }
3263
+
3264
+ _slides_progress_phase_enter() {
3265
+ local phase="$1"
3266
+ local now
3267
+ now=$(date +%s)
3268
+
3269
+ # Stop any running spinner
3270
+ if [[ -n "${_SLIDES_PROGRESS_SPINNER_PID:-}" ]]; then
3271
+ kill "${_SLIDES_PROGRESS_SPINNER_PID}" 2>/dev/null || true
3272
+ wait "${_SLIDES_PROGRESS_SPINNER_PID}" 2>/dev/null || true
3273
+ _SLIDES_PROGRESS_SPINNER_PID=""
3274
+ if [[ $_SLIDES_PROGRESS_QUIET -eq 0 ]]; then
3275
+ printf '\r\033[K' >&2
3276
+ fi
3277
+ fi
3278
+
3279
+ # Print previous phase completion
3280
+ if [[ -n "$_SLIDES_PROGRESS_CURRENT" && $_SLIDES_PROGRESS_PHASE_START_TIME -gt 0 ]]; then
3281
+ local prev_elapsed=$(( now - _SLIDES_PROGRESS_PHASE_START_TIME ))
3282
+ local elapsed_str
3283
+ elapsed_str=$(_slides_progress_elapsed_str "$prev_elapsed")
3284
+ if [[ $_SLIDES_PROGRESS_QUIET -eq 0 ]]; then
3285
+ printf ' ✅ %s (%s)\n' "$_SLIDES_PROGRESS_CURRENT" "$elapsed_str" >&2
3286
+ fi
3287
+ fi
3288
+
3289
+ _SLIDES_PROGRESS_CURRENT="$phase"
3290
+ _SLIDES_PROGRESS_PHASE_START_TIME="$now"
3291
+
3292
+ if [[ $_SLIDES_PROGRESS_QUIET -eq 0 ]]; then
3293
+ printf '→ %s ...\n' "$phase" >&2
3294
+ fi
3295
+ return 0
3296
+ }
3297
+
3298
+ _slides_progress_spinner_start() {
3299
+ [[ $_SLIDES_PROGRESS_QUIET -eq 1 ]] && return 0
3300
+
3301
+ local chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
3302
+ local delay=0.1
3303
+ local label_file="${TEST_TMP:-/tmp}/roll-slides-spinner-label-$$"
3304
+ printf '%s' "$_SLIDES_PROGRESS_CURRENT" > "$label_file"
3305
+ (
3306
+ local i=0
3307
+ while true; do
3308
+ local label
3309
+ label=$(cat "$label_file" 2>/dev/null || echo "$_SLIDES_PROGRESS_CURRENT")
3310
+ printf '\r\033[K%s %s' "${chars:$i:1}" "$label" >&2
3311
+ i=$(( (i+1) % ${#chars} ))
3312
+ sleep "$delay"
3313
+ done
3314
+ ) &
3315
+ _SLIDES_PROGRESS_SPINNER_PID=$!
3316
+ }
3317
+
3318
+ _slides_progress_spinner_label() {
3319
+ local label="$1"
3320
+ local label_file="${TEST_TMP:-/tmp}/roll-slides-spinner-label-$$"
3321
+ printf '%s' "$label" > "$label_file"
3322
+ }
3323
+
3324
+ _slides_progress_ok() {
3325
+ local phase="$1"
3326
+ local elapsed="${2:-0}"
3327
+ local elapsed_str
3328
+ elapsed_str=$(_slides_progress_elapsed_str "$elapsed")
3329
+ [[ $_SLIDES_PROGRESS_QUIET -eq 0 ]] && printf ' ✅ %s (%s)\n' "$phase" "$elapsed_str" >&2
3330
+ }
3331
+
3332
+ # ─── slides help ─────────────────────────────────────────────────────────────
3214
3333
  _slides_help() {
3215
3334
  cat <<'EOF'
3216
3335
  roll slides — deck.md → HTML rendering
@@ -3220,18 +3339,22 @@ USAGE 用法
3220
3339
  roll slides build <slug> [--no-open]
3221
3340
  Render .roll/slides/<slug>/deck.md → .roll/slides/<slug>.html
3222
3341
  渲染 deck.md 为 HTML 并自动打开浏览器
3223
- roll slides new "<topic>" [--template <name>]
3224
- Generate a new deck.md from a topic via the selected AI agent
3225
- 通过所选 AI agent 根据主题生成新的 deck.md
3226
- roll slides list List all decks under .roll/slides/ as a table
3227
- 列出 .roll/slides/ 下所有幻灯片
3342
+ roll slides new "<topic>" [--template <name>] [--no-build]
3343
+ Generate deck.md via AI, then auto-build + open HTML
3344
+ 通过 AI 生成 deck.md,自动渲染并打开 HTML
3345
+ roll slides list List all decks (built / stale / failed / unbuilt)
3346
+ 列出 .roll/slides/ 下所有幻灯片(四态)
3228
3347
  roll slides preview <slug> [--no-open]
3229
3348
  Open .roll/slides/<slug>.html in the default browser
3230
3349
  在浏览器中打开已渲染的幻灯片
3350
+ roll slides logs <slug> Show the last build failure log for a deck
3351
+ 显示幻灯片上次构建失败日志
3231
3352
 
3232
3353
  OPTIONS 选项
3233
3354
  --no-open Skip auto-opening the rendered HTML in a browser
3234
3355
  渲染后不自动打开浏览器
3356
+ --no-build Skip auto-build after agent completes (deck.md only)
3357
+ 仅生成 deck.md,不自动渲染
3235
3358
  --help, -h Show this help
3236
3359
  显示本帮助
3237
3360
  EOF
@@ -3246,6 +3369,12 @@ _slides_lib() {
3246
3369
  # Returns 0 + prints the path if the template exists, else returns 1.
3247
3370
  _slides_template_path() {
3248
3371
  local name="$1"
3372
+ # US-DECK-013: project-level template override takes priority over built-in.
3373
+ local proj_tpl=".roll/slides/templates/${name}.html"
3374
+ if [[ -f "$proj_tpl" ]]; then
3375
+ printf '%s' "$proj_tpl"
3376
+ return 0
3377
+ fi
3249
3378
  local tpl="${ROLL_PKG_DIR}/lib/slides/templates/${name}.html"
3250
3379
  if [[ -f "$tpl" ]]; then
3251
3380
  printf '%s' "$tpl"
@@ -3338,8 +3467,19 @@ cmd_slides_build() {
3338
3467
  return 1
3339
3468
  fi
3340
3469
 
3470
+ # US-DECK-011: capture failure to .last-build.err for slides logs
3471
+ local err_file=".roll/slides/${slug}/.last-build.err"
3472
+
3341
3473
  # 1. Validate first (fail-fast on AI-generated decks).
3342
- if ! python3 "$validator" "$deck"; then
3474
+ # Exit 2 = grounding warning (schema OK, evidence below threshold) — warn but don't fail.
3475
+ local val_out val_exit
3476
+ val_out=$(python3 "$validator" "$deck" 2>&1) || val_exit=$?
3477
+ if [[ "${val_exit:-0}" -eq 2 ]]; then
3478
+ echo "[roll] ${val_out}" >&2
3479
+ elif [[ "${val_exit:-0}" -ne 0 ]]; then
3480
+ local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
3481
+ mkdir -p ".roll/slides/${slug}"
3482
+ printf '[%s] stage=validate\n%s\n' "$ts" "$val_out" > "$err_file"
3343
3483
  err "deck.md validation failed — fix the issues above before building."
3344
3484
  echo " deck.md 校验失败,请先修复上方提示再重试。" >&2
3345
3485
  return 1
@@ -3349,16 +3489,25 @@ cmd_slides_build() {
3349
3489
  local tpl_name; tpl_name=$(_slides_template_for_deck "$deck")
3350
3490
  local tpl_path
3351
3491
  if ! tpl_path=$(_slides_template_path "$tpl_name"); then
3492
+ local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
3493
+ mkdir -p ".roll/slides/${slug}"
3494
+ printf '[%s] stage=template\ntemplate not found: %s\n' "$ts" "$tpl_name" > "$err_file"
3352
3495
  err "Template not found: ${tpl_name} 未找到模板:${tpl_name}"
3353
3496
  return 1
3354
3497
  fi
3355
3498
 
3356
3499
  local out=".roll/slides/${slug}.html"
3357
3500
  mkdir -p ".roll/slides"
3358
- if ! python3 "$renderer" "$deck" "$tpl_path" "$out"; then
3501
+ local render_out; render_out=$(python3 "$renderer" "$deck" "$tpl_path" "$out" 2>&1) || {
3502
+ local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
3503
+ mkdir -p ".roll/slides/${slug}"
3504
+ printf '[%s] stage=render\n%s\n' "$ts" "$render_out" > "$err_file"
3359
3505
  err "Render failed for ${deck} 渲染失败:${deck}"
3360
3506
  return 1
3361
- fi
3507
+ }
3508
+
3509
+ # US-DECK-011: build succeeded — remove any stale .last-build.err
3510
+ rm -f "$err_file" 2>/dev/null || true
3362
3511
 
3363
3512
  # 3. Default-ignore the HTML artefact so it doesn't accidentally get committed.
3364
3513
  _slides_ensure_gitignore
@@ -3450,31 +3599,42 @@ cmd_slides_list() {
3450
3599
  IFS=$'\n' sorted_slugs=($(printf '%s\n' "${slugs[@]}" | sort))
3451
3600
  unset IFS
3452
3601
 
3453
- printf '%-20s %-20s %-12s %-12s %-5s %s\n' \
3602
+ printf '%-20s %-20s %-12s %-12s %-8s %s\n' \
3454
3603
  "slug" "template" "total_slides" "created" "built" "size"
3455
- printf '%-20s %-20s %-12s %-12s %-5s %s\n' \
3456
- "----" "--------" "------------" "-------" "-----" "----"
3604
+ printf '%-20s %-20s %-12s %-12s %-8s %s\n' \
3605
+ "----" "--------" "------------" "-------" "------" "----"
3457
3606
 
3458
- local s deck html template total created built size bytes
3607
+ local s deck html err_file template total created built size bytes
3459
3608
  for s in "${sorted_slugs[@]}"; do
3460
3609
  deck="${slides_dir}/${s}/deck.md"
3461
3610
  html="${slides_dir}/${s}.html"
3611
+ err_file="${slides_dir}/${s}/.last-build.err"
3462
3612
  template=$(_slides_frontmatter_field "$deck" "template")
3463
3613
  [[ -z "$template" ]] && template="-"
3464
3614
  total=$(_slides_frontmatter_field "$deck" "total_slides")
3465
3615
  [[ -z "$total" ]] && total="-"
3466
3616
  created=$(_slides_frontmatter_field "$deck" "created")
3467
3617
  [[ -z "$created" ]] && created="-"
3468
- if [[ -f "$html" ]]; then
3469
- built=""
3470
- bytes=$(wc -c <"$html" 2>/dev/null | tr -d ' ')
3471
- [[ -z "$bytes" ]] && bytes=0
3472
- size=$(_slides_human_size "$bytes")
3618
+ # US-DECK-011: 4-state built column
3619
+ if [[ -f "$err_file" ]]; then
3620
+ built="⚠ failed"
3621
+ size="-"
3622
+ elif [[ -f "$html" ]]; then
3623
+ # Check if deck.md is newer than the HTML (stale)
3624
+ if [[ "$deck" -nt "$html" ]]; then
3625
+ built="≈ stale"
3626
+ size="-"
3627
+ else
3628
+ built="✓ built"
3629
+ bytes=$(wc -c <"$html" 2>/dev/null | tr -d ' ')
3630
+ [[ -z "$bytes" ]] && bytes=0
3631
+ size=$(_slides_human_size "$bytes")
3632
+ fi
3473
3633
  else
3474
- built="✗"
3634
+ built="✗ unbuilt"
3475
3635
  size="-"
3476
3636
  fi
3477
- printf '%-20s %-20s %-12s %-12s %-5s %s\n' \
3637
+ printf '%-20s %-20s %-12s %-12s %-8s %s\n' \
3478
3638
  "$s" "$template" "$total" "$created" "$built" "$size"
3479
3639
  done
3480
3640
  return 0
@@ -3524,6 +3684,46 @@ cmd_slides_preview() {
3524
3684
  return 0
3525
3685
  }
3526
3686
 
3687
+ # ─── US-DECK-011 ─────────────────────────────────────────────────────────────
3688
+ cmd_slides_logs() {
3689
+ local slug=""
3690
+ while [[ $# -gt 0 ]]; do
3691
+ case "$1" in
3692
+ --help|-h) _slides_help; return 0 ;;
3693
+ --*) err "Unknown option: $1 未知选项: $1"; return 1 ;;
3694
+ *)
3695
+ if [[ -z "$slug" ]]; then
3696
+ slug="$1"; shift
3697
+ else
3698
+ err "Unexpected argument: $1 多余参数: $1"; return 1
3699
+ fi
3700
+ ;;
3701
+ esac
3702
+ done
3703
+
3704
+ if [[ -z "$slug" ]]; then
3705
+ err "Usage: roll slides logs <slug>"
3706
+ echo "用法: roll slides logs <slug>" >&2
3707
+ return 1
3708
+ fi
3709
+
3710
+ local deck_dir=".roll/slides/${slug}"
3711
+ local err_file="${deck_dir}/.last-build.err"
3712
+
3713
+ if [[ ! -d "$deck_dir" ]] || [[ ! -f "${deck_dir}/deck.md" ]]; then
3714
+ err "Deck not found: ${slug} 未找到幻灯片:${slug}"
3715
+ return 1
3716
+ fi
3717
+
3718
+ if [[ ! -f "$err_file" ]]; then
3719
+ info "No failure records for ${slug} 该幻灯片没有失败记录"
3720
+ return 0
3721
+ fi
3722
+
3723
+ cat "$err_file"
3724
+ return 0
3725
+ }
3726
+
3527
3727
  # ─── US-DECK-004 ─────────────────────────────────────────────────────────────
3528
3728
  # Turn a topic string into a kebab-case slug.
3529
3729
  # Lower-cases, replaces any run of non-alphanumerics with a single dash,
@@ -3546,13 +3746,15 @@ _slides_topic_slug() {
3546
3746
  # `.roll/slides/<slug>/deck.md` (and nothing else). After the agent exits,
3547
3747
  # we print a bilingual hint pointing at `roll slides build <slug>`.
3548
3748
  cmd_slides_new() {
3549
- local topic="" template="introduction-v3"
3749
+ local topic="" template="introduction-v3" quiet=0 no_build=0
3550
3750
  while [[ $# -gt 0 ]]; do
3551
3751
  case "$1" in
3552
3752
  --template)
3553
3753
  [[ -n "${2:-}" ]] || { err "--template requires a value --template 需要一个值"; return 1; }
3554
3754
  template="$2"; shift 2 ;;
3555
3755
  --template=*) template="${1#--template=}"; shift ;;
3756
+ --quiet) quiet=1; shift ;;
3757
+ --no-build) no_build=1; shift ;;
3556
3758
  --help|-h) _slides_help; return 0 ;;
3557
3759
  --*) err "Unknown option: $1 未知选项: $1"; return 1 ;;
3558
3760
  *)
@@ -3566,8 +3768,8 @@ cmd_slides_new() {
3566
3768
  done
3567
3769
 
3568
3770
  if [[ -z "$topic" ]]; then
3569
- err "Usage: roll slides new \"<topic>\" [--template <name>]"
3570
- echo " 用法:roll slides new \"<主题>\" [--template <模板名>]" >&2
3771
+ err "Usage: roll slides new \"<topic>\" [--template <name>] [--quiet] [--no-build]"
3772
+ echo " 用法:roll slides new \"<主题>\" [--template <模板名>] [--quiet] [--no-build]" >&2
3571
3773
  return 1
3572
3774
  fi
3573
3775
 
@@ -3578,14 +3780,26 @@ cmd_slides_new() {
3578
3780
  return 1
3579
3781
  fi
3580
3782
 
3783
+ # Progress: init with 5 phases, detect quiet mode
3784
+ _slides_progress_init launching generating validating rendering opening
3785
+ _slides_progress_detect_tty
3786
+ if [[ "$quiet" -eq 1 ]]; then
3787
+ _slides_progress_quiet
3788
+ fi
3789
+
3790
+ # Ctrl-C trap: stop spinner and clean cursor
3791
+ trap '_slides_progress_phase_enter "cancelled" 2>/dev/null; exit 130' INT TERM
3792
+
3793
+ # Phase: launching
3794
+ _slides_progress_phase_enter "launching"
3795
+
3581
3796
  local skill_file="${ROLL_PKG_DIR}/skills/roll-deck/SKILL.md"
3582
3797
  [[ -f "$skill_file" ]] || { err "Skill not found: ${skill_file}"; return 1; }
3583
3798
  local skill_body; skill_body=$(_skill_content "$skill_file")
3584
3799
 
3585
3800
  local agent; agent=$(_project_agent)
3586
3801
 
3587
- # Compose the full prompt: skill body + concrete task context. The agent
3588
- # reads the skill, then sees the topic / slug / template it must use.
3802
+ # Compose the full prompt
3589
3803
  local prompt
3590
3804
  prompt="$(cat <<EOF
3591
3805
  ${skill_body}
@@ -3607,16 +3821,81 @@ EOF
3607
3821
 
3608
3822
  _agent_argv "$agent" text "$prompt" || {
3609
3823
  err "Unknown agent '${agent}'. Run: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"
3824
+ trap - INT TERM
3610
3825
  return 1
3611
3826
  }
3612
3827
 
3613
- info "Launching ${agent} with roll-deck skill for topic: ${topic}"
3614
- info "启动 ${agent} 处理主题:${topic}"
3615
- "${_AGENT_ARGV[@]}"
3828
+ # Phase: generating — launch agent in background + spinner + file-watch
3829
+ _slides_progress_phase_enter "generating"
3830
+
3831
+ local deck_file=".roll/slides/${slug}/deck.md"
3832
+ local deck_dir; deck_dir="$(dirname "$deck_file")"
3833
+ mkdir -p "$deck_dir"
3834
+
3835
+ # Start spinner
3836
+ _slides_progress_spinner_start
3837
+
3838
+ # Launch agent in background
3839
+ "${_AGENT_ARGV[@]}" &
3840
+ local agent_pid=$!
3841
+
3842
+ # File-watch: poll deck.md for ## Slide N count and update spinner label
3843
+ local slide_count=0 last_slide_count=-1
3844
+ while kill -0 "$agent_pid" 2>/dev/null; do
3845
+ if [[ -f "$deck_file" ]]; then
3846
+ slide_count=$(grep -c '^## Slide ' "$deck_file" 2>/dev/null || echo 0)
3847
+ if [[ "$slide_count" != "$last_slide_count" ]]; then
3848
+ last_slide_count="$slide_count"
3849
+ if [[ "$slide_count" -gt 0 ]]; then
3850
+ _slides_progress_spinner_label "generating slide ${slide_count}/18"
3851
+ fi
3852
+ fi
3853
+ fi
3854
+ sleep 1
3855
+ done
3856
+
3857
+ # Agent finished — stop spinner
3858
+ if [[ -n "${_SLIDES_PROGRESS_SPINNER_PID:-}" ]]; then
3859
+ kill "${_SLIDES_PROGRESS_SPINNER_PID}" 2>/dev/null || true
3860
+ wait "${_SLIDES_PROGRESS_SPINNER_PID}" 2>/dev/null || true
3861
+ _SLIDES_PROGRESS_SPINNER_PID=""
3862
+ [[ $_SLIDES_PROGRESS_QUIET -eq 0 ]] && printf '\r\033[K' >&2
3863
+ fi
3864
+
3865
+ wait "$agent_pid"
3616
3866
  local rc=$?
3617
3867
 
3618
- # Whether the agent succeeded or not, point the user at the next bash step.
3619
- # (Build will surface its own validation errors if deck.md is malformed.)
3868
+ # Phase: validating
3869
+ _slides_progress_phase_enter "validating"
3870
+
3871
+ # If deck.md exists, validate it
3872
+ if [[ -f "$deck_file" ]]; then
3873
+ local lib_dir; lib_dir=$(_slides_lib)
3874
+ if python3 "${lib_dir}/slides-validate.py" "$deck_file" >/dev/null 2>&1; then
3875
+ : # validation passed
3876
+ else
3877
+ [[ $_SLIDES_PROGRESS_QUIET -eq 0 ]] && printf ' ⚠️ validation warnings (build may surface details)\n' >&2
3878
+ fi
3879
+ fi
3880
+
3881
+ # Phase: rendering + opening (unless --no-build)
3882
+ if [[ "$no_build" -eq 0 ]] && [[ -f "$deck_file" ]]; then
3883
+ _slides_progress_phase_enter "rendering"
3884
+ cmd_slides_build "$slug" --no-open
3885
+ local build_rc=$?
3886
+
3887
+ if [[ $build_rc -eq 0 ]]; then
3888
+ _slides_progress_phase_enter "opening"
3889
+ local opener
3890
+ if opener=$(_slides_open_cmd); then
3891
+ "$opener" ".roll/slides/${slug}.html" >/dev/null 2>&1 || true
3892
+ fi
3893
+ fi
3894
+ fi
3895
+
3896
+ trap - INT TERM
3897
+
3898
+ # Print Next hint (unless --quiet flag was explicitly passed)
3620
3899
  echo
3621
3900
  echo "Next: roll slides build ${slug}"
3622
3901
  echo "下一步:roll slides build ${slug}"
@@ -3624,14 +3903,15 @@ EOF
3624
3903
  return "$rc"
3625
3904
  }
3626
3905
 
3627
- # ─── cmd_prices (US-VIEW-013) ─────────────────────────────────────────────────
3906
+ # ─── cmd_prices (US-VIEW-013 / FIX-116) ───────────────────────────────────────
3628
3907
  # `roll prices show` — print the current price snapshot table.
3629
3908
  # `roll prices refresh` — fetch live pricing docs, diff vs latest snapshot,
3630
3909
  # write a new snapshot when rates have changed.
3910
+ # FIX-116: --vendor flag for multi-vendor support.
3631
3911
  _prices_help() {
3632
3912
  cat <<'EOF'
3633
- Usage: roll prices <subcommand> [--url URL]
3634
- roll prices <子命令> [--url 网址]
3913
+ Usage: roll prices <subcommand> [--url URL] [--vendor VENDOR]
3914
+ roll prices <子命令> [--url 网址] [--vendor 厂商]
3635
3915
 
3636
3916
  Subcommands:
3637
3917
  show Print the current price snapshot table.
@@ -3639,6 +3919,10 @@ Subcommands:
3639
3919
  refresh Fetch the official pricing docs, diff against the latest snapshot,
3640
3920
  and write a new snapshot only when rates have changed.
3641
3921
  拉取官方价格文档与最新快照对比,有变化才落新快照。
3922
+
3923
+ Options:
3924
+ --vendor anthropic|deepseek|kimi Target vendor for refresh (default: anthropic).
3925
+ 指定拉取价格的厂商(默认:anthropic)。
3642
3926
  EOF
3643
3927
  }
3644
3928
 
@@ -3654,36 +3938,60 @@ version, effective_at, source_url = mp.snapshot_meta()
3654
3938
  print(f"price snapshot 价格快照")
3655
3939
  print(f" version {version}")
3656
3940
  print(f" effective_at {effective_at}")
3657
- print(f" source {source_url}")
3658
- print(f" default_model {mp.DEFAULT}")
3941
+ print(f" snapshots {len(mp._SNAPSHOTS)} loaded 已加载")
3942
+ for snap in mp._SNAPSHOTS:
3943
+ v = snap.get("vendor", "—")
3944
+ c = snap.get("currency", "—")
3945
+ print(f" {v:<12} {c:>4} {snap['source_url']}")
3659
3946
  print()
3660
- print(f" {'model':<22}{'in':>8}{'out':>8}{'cw':>8}{'cr':>8}")
3947
+ print(f" {'model':<24}{'cur':>4}{'in':>10}{'out':>10}{'cw':>10}{'cr':>10}")
3661
3948
  for model in sorted(mp.PRICES):
3662
3949
  p = mp.PRICES[model]
3663
- print(f" {model:<22}{p['in']:>8.2f}{p['out']:>8.2f}{p['cache_create']:>8.2f}{p['cache_read']:>8.2f}")
3950
+ cur = mp.currency_for(model)
3951
+ print(f" {model:<24}{cur:>4}{p['in']:>10.4f}{p['out']:>10.4f}{p['cache_create']:>10.4f}{p['cache_read']:>10.4f}")
3664
3952
  print()
3665
- print("rates per million tokens, USD 单位为每百万 token 美元")
3953
+ print("rates per million tokens 每百万 token 单价")
3666
3954
  PY
3667
3955
  }
3668
3956
 
3669
3957
  cmd_prices_refresh() {
3670
3958
  local url=""
3959
+ local vendor=""
3671
3960
  while (( $# > 0 )); do
3672
3961
  case "$1" in
3673
3962
  --url) url="$2"; shift 2 ;;
3963
+ --vendor) vendor="$2"; shift 2 ;;
3674
3964
  *) err "Unknown flag: $1 未知参数: $1"; return 1 ;;
3675
3965
  esac
3676
3966
  done
3677
3967
  local lib_dir="${ROLL_PKG_DIR}/lib"
3678
- python3 - "$lib_dir" "$url" <<'PY'
3968
+ python3 - "$lib_dir" "$url" "$vendor" <<'PY'
3679
3969
  import os, sys
3680
3970
  lib_dir = sys.argv[1]
3681
3971
  override_url = sys.argv[2] if len(sys.argv) > 2 else ""
3972
+ vendor = sys.argv[3] if len(sys.argv) > 3 else ""
3682
3973
  sys.path.insert(0, lib_dir)
3683
3974
  import prices_fetcher as pf
3684
3975
 
3685
3976
  snapshot_dir = os.path.join(lib_dir, "prices")
3686
- url = override_url or pf.DEFAULT_SOURCE_URL
3977
+
3978
+ # FIX-116: vendor-specific default URLs.
3979
+ VENDOR_URLS = {
3980
+ "anthropic": "https://platform.claude.com/docs/en/about-claude/pricing",
3981
+ "deepseek": "https://api-docs.deepseek.com/quick_start/pricing",
3982
+ "kimi": "https://platform.kimi.com/docs/pricing/chat",
3983
+ }
3984
+
3985
+ if vendor:
3986
+ default_url = VENDOR_URLS.get(vendor)
3987
+ if not default_url:
3988
+ print(f"[roll] unknown vendor: {vendor} 未知厂商", file=sys.stderr)
3989
+ print(f"[roll] known vendors: {', '.join(sorted(VENDOR_URLS))} 已知厂商", file=sys.stderr)
3990
+ sys.exit(1)
3991
+ url = override_url or default_url
3992
+ pf.DEFAULT_SOURCE_URL = url
3993
+ else:
3994
+ url = override_url or pf.DEFAULT_SOURCE_URL
3687
3995
 
3688
3996
  try:
3689
3997
  action, changes = pf.refresh(snapshot_dir=snapshot_dir, url=url)
@@ -3725,6 +4033,37 @@ cmd_prices() {
3725
4033
  esac
3726
4034
  }
3727
4035
 
4036
+ # FIX-113: changelog audit — list PRs merged to main since latest release
4037
+ # tag that don't appear in CHANGELOG.md's ## Unreleased section.
4038
+ cmd_changelog() {
4039
+ local subcmd="${1:-audit}"
4040
+ shift || true
4041
+ case "$subcmd" in
4042
+ audit)
4043
+ python3 "${ROLL_PKG_DIR}/lib/changelog_audit.py" "$@"
4044
+ ;;
4045
+ --help|-h|help)
4046
+ cat <<EOF
4047
+ Usage: roll changelog audit [--since <tag>] [--json]
4048
+ 发版前体检
4049
+ Scan PRs merged to main since latest v* tag; flag ones that look user-visible
4050
+ (carry US-/FIX-/REFACTOR- id) but are missing from CHANGELOG.md '## Unreleased'.
4051
+
4052
+ Read-only — never edits CHANGELOG. Use to catch drift before release.
4053
+
4054
+ roll changelog audit # check against latest v* tag
4055
+ roll changelog audit --since v2026.520.1
4056
+ roll changelog audit --json # machine-readable
4057
+ EOF
4058
+ ;;
4059
+ *)
4060
+ err "Unknown subcommand: ${subcmd} 未知子命令:${subcmd}"
4061
+ err "Try: roll changelog audit"
4062
+ return 1
4063
+ ;;
4064
+ esac
4065
+ }
4066
+
3728
4067
  cmd_slides() {
3729
4068
  local subcmd="${1:-}"
3730
4069
  shift || true
@@ -3741,6 +4080,9 @@ cmd_slides() {
3741
4080
  preview)
3742
4081
  cmd_slides_preview "$@"
3743
4082
  ;;
4083
+ logs)
4084
+ cmd_slides_logs "$@"
4085
+ ;;
3744
4086
  --help|-h|help)
3745
4087
  _slides_help
3746
4088
  return 0
@@ -4428,6 +4770,21 @@ _write_loop_runner_script() {
4428
4770
  claude_cmd="${claude_cmd/--output-format stream-json/--output-format stream-json --add-dir \"\$WT\"}"
4429
4771
  fi
4430
4772
  local slug; slug=$(_project_slug "$project_path")
4773
+ # FIX-115: build fallback agent command. When primary agent fails all 3
4774
+ # attempts, the inner runner switches to this command for 3 more attempts.
4775
+ local fallback_agent; fallback_agent=$(_fallback_agent)
4776
+ local fallback_cmd=""
4777
+ if [[ -n "$fallback_agent" ]]; then
4778
+ local fallback_skill_file="${ROLL_HOME}/skills/roll-loop/SKILL.md"
4779
+ if [[ -f "$fallback_skill_file" ]]; then
4780
+ local fallback_prompt; fallback_prompt=$(_skill_content "$fallback_skill_file")
4781
+ if _agent_argv "$fallback_agent" plain "$fallback_prompt" 2>/dev/null; then
4782
+ _AGENT_ARGV[0]=$(command -v "${_AGENT_ARGV[0]}" 2>/dev/null || echo "${_AGENT_ARGV[0]}")
4783
+ printf -v fallback_cmd '%q ' "${_AGENT_ARGV[@]}"
4784
+ fallback_cmd="${fallback_cmd% }"
4785
+ fi
4786
+ fi
4787
+ fi
4431
4788
  cat > "$inner_path" << INNER
4432
4789
  #!/bin/bash -l
4433
4790
  set -o pipefail
@@ -4755,6 +5112,18 @@ for _orphan_wt in "\${_SHARED_ROOT}/worktrees/${slug}-cycle-"*; do
4755
5112
  [ -d "\${_orphan_wt}/.git" ] || [ -f "\${_orphan_wt}/.git" ] || continue
4756
5113
  _orphan_branch=\$(cd "\$_orphan_wt" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
4757
5114
  [ -z "\$_orphan_branch" ] && continue
5115
+ # FIX-114: PR for this branch may already be squash-merged externally. In
5116
+ # that case origin/main..HEAD still shows commits (squash created new SHA)
5117
+ # so the old "needs republish" path tries to recreate the PR and fails.
5118
+ # Ask gh first; if MERGED → drop the worktree clean.
5119
+ if command -v gh >/dev/null 2>&1; then
5120
+ _orphan_pr_state=\$(gh pr view "\$_orphan_branch" --json state -q .state 2>/dev/null || echo "")
5121
+ if [ "\$_orphan_pr_state" = "MERGED" ]; then
5122
+ echo "[loop] FIX-114: orphan worktree \$_orphan_wt branch \$_orphan_branch already merged remotely; cleaning up"
5123
+ _worktree_cleanup "\$_orphan_wt" "\$_orphan_branch"
5124
+ continue
5125
+ fi
5126
+ fi
4758
5127
  _orphan_commits=\$(cd "\$_orphan_wt" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
4759
5128
  if [ "\$_orphan_commits" -gt 0 ]; then
4760
5129
  echo "[loop] FIX-040: recovering orphan worktree \$_orphan_wt (branch \$_orphan_branch, \${_orphan_commits} commits)"
@@ -4796,6 +5165,7 @@ if _worktree_fetch_origin main \\
4796
5165
  _worktree_sync_meta "\$WT" 2>/dev/null || true
4797
5166
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
4798
5167
  _loop_event cycle_start "\${CYCLE_ID}" "" "" || true
5168
+ _loop_event agent_used "\${CYCLE_ID}" "\$(_project_agent)" "primary" || true
4799
5169
  _phase_end worktree_setup ok
4800
5170
  else
4801
5171
  # P3 fix: skip the cycle entirely when worktree isolation fails.
@@ -4820,7 +5190,14 @@ FMT="${fmt_script}"
4820
5190
  export LOOP_PROJECT_SLUG="${slug}"
4821
5191
  export LOOP_CYCLE_ID="\${CYCLE_ID}"
4822
5192
  export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
5193
+ # US-LOOP-010: tell loop-fmt.py which agent is running so it can branch
5194
+ # rendering: claude → stream-json parser, others → transparent passthrough.
5195
+ export ROLL_LOOP_AGENT="\$(_project_agent)"
4823
5196
  _phase_begin claude_invoke
5197
+ # FIX-115: fallback agent support — when primary fails 3 attempts, try fallback 3 more
5198
+ FALLBACK_AGENT_NAME="${fallback_agent}"
5199
+ FALLBACK_CMD="${fallback_cmd}"
5200
+ _USE_FALLBACK=0
4824
5201
  for _attempt in 1 2 3; do
4825
5202
  # FIX-068: defensive reset before each attempt — _CYCLE_TIMED_OUT carries
4826
5203
  # the SIGTERM result of the previous attempt and would otherwise force an
@@ -4858,6 +5235,44 @@ for _attempt in 1 2 3; do
4858
5235
  fi
4859
5236
  done
4860
5237
 
5238
+ # FIX-115: fallback agent retry — when primary fails all 3 attempts and a
5239
+ # fallback_agent is configured, try the fallback for 3 more attempts.
5240
+ if [ "\$_CYCLE_TIMED_OUT" -eq 0 ] && [ "\$_exit" -ne 0 ] && [ -n "\$FALLBACK_AGENT_NAME" ] && [ -n "\$FALLBACK_CMD" ]; then
5241
+ _loop_event agent_switch "\$FALLBACK_AGENT_NAME" "" "primary failed after 3 attempts" || true
5242
+ _loop_event agent_used "\${CYCLE_ID}" "\$FALLBACK_AGENT_NAME" "fallback" || true
5243
+ echo "[loop] primary agent failed after 3 attempts — switching to fallback: \$FALLBACK_AGENT_NAME"
5244
+ _USE_FALLBACK=1
5245
+ # US-LOOP-010: tell loop-fmt which fallback agent is running so it renders
5246
+ # correctly (passthrough for non-claude, stream-json for claude).
5247
+ export ROLL_LOOP_AGENT="\$FALLBACK_AGENT_NAME"
5248
+ for _attempt in 1 2 3; do
5249
+ _CYCLE_TIMED_OUT=0
5250
+ ( sleep "\$LOOP_CYCLE_TIMEOUT_SEC" && {
5251
+ kill -TERM \$\$ 2>/dev/null
5252
+ pkill -TERM -P \$\$ 2>/dev/null
5253
+ pkill -TERM -f "\$WT" 2>/dev/null
5254
+ sleep 5
5255
+ pkill -KILL -P \$\$ 2>/dev/null
5256
+ pkill -KILL -f "\$WT" 2>/dev/null
5257
+ } ) &
5258
+ _WATCHDOG_PID=\$!
5259
+ if [ -f "\$FMT" ]; then
5260
+ ( cd "\$WT" && \$FALLBACK_CMD ) | python3 "\$FMT"
5261
+ else
5262
+ ( cd "\$WT" && \$FALLBACK_CMD )
5263
+ fi
5264
+ _exit=\$?
5265
+ kill "\$_WATCHDOG_PID" 2>/dev/null
5266
+ wait "\$_WATCHDOG_PID" 2>/dev/null
5267
+ [ "\$_CYCLE_TIMED_OUT" -eq 1 ] && break
5268
+ [ "\$_exit" -eq 0 ] && break
5269
+ if [ "\$_attempt" -lt 3 ]; then
5270
+ echo "[loop] \$FALLBACK_AGENT_NAME exited \$_exit (attempt \$_attempt/3) — retrying in 30s..."
5271
+ sleep 30
5272
+ fi
5273
+ done
5274
+ fi
5275
+
4861
5276
  if [ "\$_CYCLE_TIMED_OUT" -eq 1 ] || [ "\$_exit" -ne 0 ]; then
4862
5277
  _phase_end claude_invoke fail
4863
5278
  else
@@ -5316,7 +5731,7 @@ cmd_loop() {
5316
5731
  on) _loop_on ;;
5317
5732
  off) _loop_off ;;
5318
5733
  now) _loop_now ;;
5319
- test) _loop_test ;;
5734
+ test) shift; _loop_test "$@" ;;
5320
5735
  status) _loop_status "$@" ;;
5321
5736
  monitor) _loop_monitor "${1:-3}" ;;
5322
5737
  runs) _loop_runs "$@" ;;
@@ -5531,17 +5946,36 @@ _loop_test() {
5531
5946
  local log="${_SHARED_ROOT}/loop/cron-${slug}.log"
5532
5947
  local test_runner="${_SHARED_ROOT}/loop/run-${slug}-test.sh"
5533
5948
 
5949
+ # US-LOOP-010: --agent <name> lets integration tests exercise the
5950
+ # multi-agent passthrough path without needing a real pi binary.
5951
+ local agent="claude"
5952
+ local agent_cmd=""
5953
+ while [[ $# -gt 0 ]]; do
5954
+ case "$1" in
5955
+ --agent) agent="${2:-claude}"; shift 2 ;;
5956
+ --cmd) agent_cmd="${2}"; shift 2 ;;
5957
+ *) shift ;;
5958
+ esac
5959
+ done
5960
+ if [[ -z "$agent_cmd" ]]; then
5961
+ if [[ "$agent" == "claude" ]]; then
5962
+ agent_cmd='claude -p "Reply with a single word: hello"; sleep 10'
5963
+ else
5964
+ agent_cmd="echo 'mock ${agent} output line 1'; echo 'mock ${agent} output line 2'"
5965
+ fi
5966
+ fi
5967
+
5534
5968
  # FIX-054: terminal preference removed — runner always uses Terminal.app.
5535
5969
  local active_start active_end
5536
5970
  active_start=$(_config_read_int "loop_active_start" "10")
5537
5971
  active_end=$(_config_read_int "loop_active_end" "18")
5538
5972
 
5539
- info "Generating test runner... 正在生成测试启动脚本..."
5973
+ info "Generating test runner (agent: ${agent})... 正在生成测试启动脚本 (agent: ${agent})..."
5540
5974
  _write_loop_runner_script "$test_runner" "$project_path" \
5541
- 'claude -p "Reply with a single word: hello"; sleep 10' \
5975
+ "${agent_cmd}" \
5542
5976
  "$log" "$active_start" "$active_end"
5543
5977
 
5544
- info "Starting smoke test (real claude, trivial prompt)... 正在运行 smoke 测试(真实 claude)..."
5978
+ info "Starting smoke test (agent: ${agent})... 正在运行 smoke 测试 (agent: ${agent})..."
5545
5979
  info "Watch for: tmux session + terminal popup + stream-json events flowing"
5546
5980
  info "观察:tmux 会话 + 终端弹窗 + stream-json 事件流"
5547
5981
 
@@ -5551,9 +5985,9 @@ _loop_test() {
5551
5985
  local elapsed=$(( $(date +%s) - start_time ))
5552
5986
 
5553
5987
  if [[ $exit_code -eq 0 ]]; then
5554
- ok "Smoke test passed (${elapsed}s) smoke 测试通过 (${elapsed})"
5988
+ ok "Smoke test passed (${elapsed}s, agent: ${agent}) smoke 测试通过 (${elapsed}秒, agent: ${agent})"
5555
5989
  else
5556
- err "Smoke test failed (exit ${exit_code}, ${elapsed}s) smoke 测试失败 (退出码 ${exit_code}, ${elapsed})"
5990
+ err "Smoke test failed (exit ${exit_code}, ${elapsed}s, agent: ${agent}) smoke 测试失败 (退出码 ${exit_code}, ${elapsed}s, agent: ${agent})"
5557
5991
  return 1
5558
5992
  fi
5559
5993
  }
@@ -6296,8 +6730,13 @@ _loop_check_depends_on() {
6296
6730
  }
6297
6731
 
6298
6732
  # _loop_is_manual_only <story-id> [backlog-path]
6299
- # Exit 0: story's own row carries `manual-only:true`.
6733
+ # Exit 0: story's own row carries any manual-only:<value> tag.
6300
6734
  # Exit 1: tag absent, story-id not found, or backlog missing.
6735
+ #
6736
+ # FIX-109: loop used to accept only the literal `manual-only:true` value;
6737
+ # anything else (e.g. `manual-only:roll-meta`, `manual-only:sean-yao`) was
6738
+ # ignored and the story got picked. Now any non-empty `manual-only:<value>`
6739
+ # qualifies — lets users tag rows reserved for a specific human or scope.
6301
6740
  _loop_is_manual_only() {
6302
6741
  local id="$1"
6303
6742
  local backlog="${2:-.roll/backlog.md}"
@@ -6308,7 +6747,7 @@ _loop_is_manual_only() {
6308
6747
  row=$(grep -E "^\| \[?${id}[]| ]" "$backlog" | head -1)
6309
6748
  [ -n "$row" ] || return 1
6310
6749
 
6311
- echo "$row" | grep -qE 'manual-only:true'
6750
+ echo "$row" | grep -qE 'manual-only:[A-Za-z0-9_.-]+'
6312
6751
  }
6313
6752
 
6314
6753
  # US-AUTO-034: PR-first inbox — loop processes open PRs before scanning BACKLOG.
@@ -7865,6 +8304,14 @@ cmd_backlog() {
7865
8304
  _backlog_lint "$@" "$backlog"
7866
8305
  return
7867
8306
  ;;
8307
+ unstick)
8308
+ # FIX-112: revert 🔨 In Progress stories whose latest cycle ended
8309
+ # failed / aborted / blocked > N hours ago (default 4). Conservative
8310
+ # gate so it never undoes legitimately-in-progress work.
8311
+ shift
8312
+ python3 "${ROLL_PKG_DIR}/lib/loop_unstick.py" "$@"
8313
+ return
8314
+ ;;
7868
8315
  block|defer|unblock|promote)
7869
8316
  local pattern="${2:-}"
7870
8317
  local reason="${3:-}"
@@ -8401,6 +8848,7 @@ _legacy_help() {
8401
8848
  echo " backlog block <pat> [reason] Mark matching items as 🔒 Blocked 标记为已阻塞"
8402
8849
  echo " backlog defer <pat> [reason] Mark matching items as ⏸ Deferred 标记为已推迟"
8403
8850
  echo " backlog unblock <pat> Restore matching items to 📋 Todo 恢复为待处理"
8851
+ echo " backlog unstick [--dry-run] Revert In Progress whose cycle failed >4h ago 自愈卡住的进行中任务"
8404
8852
  echo " backlog lint Check descriptions for path/function/filename violations 检查描述合规"
8405
8853
  echo " agent [use <name>|list] [Config] Per-project agent selection 切换项目 agent"
8406
8854
  echo " ci [--wait] [CI] Show or wait for current commit's CI status 查看/等待 CI 状态"
@@ -8481,17 +8929,17 @@ _check_structure() {
8481
8929
  fi
8482
8930
 
8483
8931
  if [[ "$_has_old_path" == "true" ]] && _has_roll_signature "$root"; then
8484
- err "Legacy structure detected at: $root 发现老结构目录"
8932
+ err "$(msg check_structure.detected "$root")"
8485
8933
  echo "" >&2
8486
- echo " This project uses the pre-2.0 layout (BACKLOG.md / docs/*). Roll 2.0 requires" >&2
8487
- echo " process artifacts to live in .roll/. Run the migration to upgrade:" >&2
8934
+ echo " $(msg check_structure.pre_2_0_layout)" >&2
8935
+ echo " $(msg check_structure.run_migration)" >&2
8488
8936
  echo "" >&2
8489
- echo " roll migrate --dry-run # Preview changes" >&2
8490
- echo " roll migrate # Execute (single atomic commit)" >&2
8937
+ echo " roll migrate --dry-run $(msg check_structure.preview_changes)" >&2
8938
+ echo " roll migrate $(msg check_structure.execute)" >&2
8491
8939
  echo "" >&2
8492
- echo " Migration guide: ${ROLL_PKG_DIR}/guide/en/migration-2.0.md" >&2
8940
+ echo " $(msg check_structure.migration_guide): ${ROLL_PKG_DIR}/guide/en/migration-2.0.md" >&2
8493
8941
  echo "" >&2
8494
- echo " To roll back to Roll 1.x temporarily:" >&2
8942
+ echo " $(msg check_structure.roll_back):" >&2
8495
8943
  echo " npm install -g @seanyao/roll@1" >&2
8496
8944
  exit 1
8497
8945
  fi
@@ -8526,11 +8974,12 @@ main() {
8526
8974
  review-pr) cmd_review_pr "$@" ;;
8527
8975
  slides) cmd_slides "$@" ;;
8528
8976
  prices) cmd_prices "$@" ;;
8977
+ changelog) cmd_changelog "$@" ;;
8529
8978
  version|--version|-v) echo "roll v${VERSION}" ;;
8530
8979
  help|--help|-h) _help "$@" ;;
8531
8980
  "") [[ -f ".roll/backlog.md" ]] && _home || { _help; _show_changelog; } ;;
8532
8981
  *)
8533
- err "Unknown command: $cmd 未知命令: $cmd"
8982
+ err "$(msg main.unknown_command "$cmd")"
8534
8983
  echo ""
8535
8984
  usage
8536
8985
  exit 1
@@ -8543,7 +8992,7 @@ _show_changelog() {
8543
8992
  local changelog="${ROLL_PKG_DIR}/CHANGELOG.md"
8544
8993
  [[ -f "$changelog" ]] || return 0
8545
8994
 
8546
- echo -e "${BOLD}Recent Changes 最近更新:${NC}"
8995
+ echo -e "${BOLD}$(msg changelog.heading):${NC}"
8547
8996
 
8548
8997
  local count=0 in_section=false
8549
8998
  while IFS= read -r line; do
@@ -8589,7 +9038,7 @@ _notify_update() {
8589
9038
  return
8590
9039
  fi
8591
9040
  echo ""
8592
- warn "v${latest} available — run 'roll update' to upgrade 有新版本 v${latest} — 运行 'roll update' 升级"
9041
+ warn "$(msg update.available "$latest")"
8593
9042
  }
8594
9043
 
8595
9044
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then