@seanyao/roll 2026.524.2 → 2026.526.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +38 -8
  2. package/README.md +4 -2
  3. package/bin/roll +1022 -442
  4. package/conventions/config.yaml +9 -0
  5. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  6. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  7. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  8. package/lib/i18n/agent.sh +21 -0
  9. package/lib/i18n/alert.sh +20 -0
  10. package/lib/i18n/backlog.sh +96 -0
  11. package/lib/i18n/brief.sh +5 -0
  12. package/lib/i18n/changelog.sh +3 -0
  13. package/lib/i18n/ci.sh +15 -0
  14. package/lib/i18n/init.sh +52 -0
  15. package/lib/i18n/lang.sh +10 -0
  16. package/lib/i18n/loop.sh +140 -0
  17. package/lib/i18n/migrate.sh +74 -0
  18. package/lib/i18n/offboard.sh +16 -0
  19. package/lib/i18n/peer.sh +34 -0
  20. package/lib/i18n/peer_help.sh +21 -0
  21. package/lib/i18n/peer_reset.sh +7 -0
  22. package/lib/i18n/peer_status.sh +5 -0
  23. package/lib/i18n/prices.sh +3 -0
  24. package/lib/i18n/prices_refresh.sh +17 -0
  25. package/lib/i18n/prices_show.sh +7 -0
  26. package/lib/i18n/setup.sh +3 -0
  27. package/lib/i18n/shared.sh +74 -0
  28. package/lib/i18n/skills/roll-brief.sh +27 -0
  29. package/lib/i18n/skills/roll-fix.sh +39 -0
  30. package/lib/i18n/skills/roll-onboard.sh +17 -0
  31. package/lib/i18n/slides.sh +3 -0
  32. package/lib/i18n/slides_build.sh +38 -0
  33. package/lib/i18n/slides_delete.sh +19 -0
  34. package/lib/i18n/slides_list.sh +14 -0
  35. package/lib/i18n/slides_logs.sh +12 -0
  36. package/lib/i18n/slides_new.sh +15 -0
  37. package/lib/i18n/slides_preview.sh +14 -0
  38. package/lib/i18n/slides_templates.sh +7 -0
  39. package/lib/i18n/status.sh +19 -0
  40. package/lib/i18n/update.sh +7 -0
  41. package/lib/i18n.sh +85 -4
  42. package/lib/roll-home.py +55 -0
  43. package/lib/roll-loop-status.py +196 -19
  44. package/lib/roll-loop-story.py +191 -0
  45. package/lib/roll_render.py +15 -1
  46. package/lib/slides/components/README.md +117 -0
  47. package/lib/slides/components/cards-2.html +9 -0
  48. package/lib/slides/components/cards-3.html +9 -0
  49. package/lib/slides/components/cards-4.html +9 -0
  50. package/lib/slides/components/compare.html +22 -0
  51. package/lib/slides/components/highlight.html +9 -0
  52. package/lib/slides/components/pipeline.html +12 -0
  53. package/lib/slides/components/plain.html +7 -0
  54. package/lib/slides/components/quote.html +4 -0
  55. package/lib/slides/components/timeline.html +9 -0
  56. package/lib/slides/templates/pitch.html +0 -0
  57. package/package.json +1 -1
  58. package/skills/roll-brief/SKILL.md +2 -2
  59. package/skills/roll-build/SKILL.md +40 -40
  60. package/skills/roll-fix/SKILL.md +22 -22
  61. package/skills/roll-loop/SKILL.md +26 -15
  62. package/skills/roll-onboard/SKILL.md +6 -6
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.524.2"
7
+ VERSION="2026.526.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"
@@ -25,6 +25,10 @@ ROLL_PKG_CONVENTIONS="${ROLL_PKG_DIR}/conventions"
25
25
  # shellcheck source=../lib/i18n.sh
26
26
  [[ -f "${ROLL_PKG_DIR}/lib/i18n.sh" ]] && source "${ROLL_PKG_DIR}/lib/i18n.sh"
27
27
 
28
+ # US-WATCH-001: upstream compatibility watch helpers.
29
+ # shellcheck source=../lib/watch.sh
30
+ [[ -f "${ROLL_PKG_DIR}/lib/watch.sh" ]] && source "${ROLL_PKG_DIR}/lib/watch.sh"
31
+
28
32
  # Colors
29
33
  RED=$'\033[0;31m'
30
34
  GREEN=$'\033[0;32m'
@@ -292,13 +296,13 @@ _ensure_config_entries() {
292
296
  echo "${key}: ${val}" >> "$tmp"
293
297
  fi
294
298
  added=$((added + 1))
295
- warn "Added missing config entry: ${key} 已添加缺失配置项: ${key}"
299
+ warn "$(msg shared.added_missing_config_entry ${key})"
296
300
  fi
297
301
  done
298
302
 
299
303
  if [[ $added -gt 0 ]]; then
300
304
  cp "$tmp" "$ROLL_CONFIG"
301
- ok "Config updated with $added new entries 配置已更新,新增 $added 条目"
305
+ ok "$(msg shared.config_updated_with_new_entries $added)"
302
306
  fi
303
307
  rm -f "$tmp"
304
308
  }
@@ -332,11 +336,11 @@ safe_copy() {
332
336
  # hidden read or silently default to overwrite. Be explicit.
333
337
  if [[ ! -t 0 ]]; then
334
338
  cp "$src" "$dst"
335
- ok "Wrote: ${dst/#$HOME/~} 已写入: ${dst/#$HOME/~}"
339
+ ok "$(msg shared.wrote ${dst/#$HOME/~})"
336
340
  return
337
341
  fi
338
342
  echo ""
339
- warn "File exists and differs: ${dst/#$HOME/~} 文件已存在且内容不同: ${dst/#$HOME/~}"
343
+ warn "$(msg shared.file_exists_and_differs ${dst/#$HOME/~})"
340
344
  echo -e " ${BOLD}Overwrite?${NC} [Y/n/d(iff)] "
341
345
  read -r answer || answer="Y"
342
346
  case "$answer" in
@@ -345,15 +349,15 @@ safe_copy() {
345
349
  echo ""
346
350
  echo -e " ${BOLD}Overwrite?${NC} [Y/n] "
347
351
  read -r answer2 || answer2="Y"
348
- [[ "$answer2" =~ ^[Nn]$ ]] && { info "Skipped: ${dst/#$HOME/\~} 已跳过: ${dst/#$HOME/\~}"; return; }
352
+ [[ "$answer2" =~ ^[Nn]$ ]] && { info "$(msg shared.skipped ${dst/#$HOME/~})"; return; }
349
353
  ;;
350
- n|N) info "Skipped: ${dst/#$HOME/~} 已跳过: ${dst/#$HOME/~}"; return ;;
354
+ n|N) info "$(msg shared.skipped ${dst/#$HOME/~})"; return ;;
351
355
  *) ;; # empty answer or 'y' / 'Y' → overwrite (default Yes)
352
356
  esac
353
357
  fi
354
358
 
355
359
  cp "$src" "$dst"
356
- ok "Wrote: ${dst/#$HOME/~} 已写入: ${dst/#$HOME/~}"
360
+ ok "$(msg shared.wrote_2 ${dst/#$HOME/~})"
357
361
  }
358
362
 
359
363
  # ─── Internal: prune files in $1 that no longer exist in $2 ──────────────────
@@ -372,7 +376,7 @@ _prune_dir() {
372
376
  [[ "$installed_fname" == "." || "$installed_fname" == ".." ]] && continue
373
377
  if [[ ! -f "$source_dir/$installed_fname" ]]; then
374
378
  rm -f "$installed_f"
375
- info "Removed stale $label: ${installed_dir##*/}/$installed_fname 已删除过时$label: ${installed_dir##*/}/$installed_fname"
379
+ info "$(msg shared.removed_stale $label ${installed_dir##*/} $installed_fname)"
376
380
  fi
377
381
  done
378
382
  }
@@ -380,7 +384,7 @@ _prune_dir() {
380
384
  # ─── Internal: pull skills from repo → ~/.roll/skills ──────────────────────
381
385
  _pull_skills() {
382
386
  if [[ ! -d "$ROLL_PKG_DIR/skills" ]]; then
383
- err "Skills source not found at: $ROLL_PKG_DIR/skills 技能源目录未找到: $ROLL_PKG_DIR/skills"
387
+ err "$(msg shared.skills_source_not_found_at_skills $ROLL_PKG_DIR)"
384
388
  return 1
385
389
  fi
386
390
 
@@ -408,7 +412,7 @@ _pull_skills() {
408
412
  installed_name="$(basename "$installed_dir")"
409
413
  if [[ ! -d "$ROLL_PKG_DIR/skills/$installed_name" ]]; then
410
414
  rm -rf "$installed_dir"
411
- info "Removed stale skill: $installed_name 已删除过时技能: $installed_name"
415
+ info "$(msg shared.removed_stale_skill $installed_name)"
412
416
  fi
413
417
  done
414
418
  }
@@ -418,14 +422,14 @@ _pull_conventions() {
418
422
  local force="${1:-false}"
419
423
 
420
424
  if [[ ! -d "$ROLL_PKG_CONVENTIONS" ]]; then
421
- err "Convention source not found at: $ROLL_PKG_CONVENTIONS 约定源文件未找到: $ROLL_PKG_CONVENTIONS"
425
+ err "$(msg shared.convention_source_not_found_at $ROLL_PKG_CONVENTIONS)"
422
426
  return 1
423
427
  fi
424
428
 
425
429
  mkdir -p "$ROLL_GLOBAL"
426
430
  mkdir -p "$ROLL_TEMPLATES"/{fullstack,frontend-only,backend-service,cli}
427
431
 
428
- info "Copying global conventions... 正在复制全局约定..."
432
+ info "$(msg shared.copying_global_conventions)"
429
433
  for f in "$ROLL_PKG_CONVENTIONS"/global/*; do
430
434
  [[ -f "$f" ]] && safe_copy "$f" "$ROLL_GLOBAL/$(basename "$f")" "$force"
431
435
  done
@@ -436,7 +440,7 @@ _pull_conventions() {
436
440
  # Prune stale files in ~/.roll/conventions/global/
437
441
  _prune_dir "$ROLL_GLOBAL" "$ROLL_PKG_CONVENTIONS/global" "convention"
438
442
 
439
- info "Copying project templates... 正在复制项目模板..."
443
+ info "$(msg shared.copying_project_templates)"
440
444
  for tpl_dir in "$ROLL_PKG_CONVENTIONS"/templates/*/; do
441
445
  local tpl_name
442
446
  tpl_name="$(basename "$tpl_dir")"
@@ -457,8 +461,8 @@ _install_local() {
457
461
  local force="${1:-false}"
458
462
 
459
463
  if [[ ! -d "$ROLL_PKG_CONVENTIONS" ]]; then
460
- err "Convention source not found at: $ROLL_PKG_CONVENTIONS 约定源文件未找到: $ROLL_PKG_CONVENTIONS"
461
- err "Run this from the roll repo, or symlink bin/roll to PATH. 请在 roll 仓库目录下运行,或将 bin/roll 软链接到 PATH。"
464
+ err "$(msg shared.convention_source_not_found_at_2 $ROLL_PKG_CONVENTIONS)"
465
+ err "$(msg shared.run_this_from_the_roll_repo)"
462
466
  exit 1
463
467
  fi
464
468
 
@@ -467,15 +471,15 @@ _install_local() {
467
471
 
468
472
  # Recreate config if it has no ai_* entries (covers legacy sync_* format and blank/broken configs)
469
473
  if [[ -f "$ROLL_CONFIG" ]] && ! grep -qE "^ai_[a-z]+:" "$ROLL_CONFIG" 2>/dev/null; then
470
- warn "Config has no ai_* entries — recreating with defaults (backup saved) 配置无 ai_* 条目,将重建(已备份)"
474
+ warn "$(msg shared.config_has_no_ai_entries_recreating)"
471
475
  cp "$ROLL_CONFIG" "${ROLL_CONFIG}.bak"
472
- info "Backup saved: ~/.roll/config.yaml.bak 备份已保存: ~/.roll/config.yaml.bak"
476
+ info "$(msg shared.backup_saved_roll_config_yaml_bak)"
473
477
  rm "$ROLL_CONFIG"
474
478
  fi
475
479
 
476
480
  # Create config if it doesn't exist
477
481
  if [[ ! -f "$ROLL_CONFIG" ]]; then
478
- info "Creating default config... 正在创建默认配置..."
482
+ info "$(msg shared.creating_default_config)"
479
483
  cat > "$ROLL_CONFIG" << 'YAML'
480
484
  # Roll Configuration
481
485
  # Edit this file, then run `roll setup` to apply.
@@ -509,7 +513,7 @@ loop_brief_hour: 9
509
513
  # loop_brief_minute: 15 # omit to auto-derive
510
514
  primary_agent: claude
511
515
  YAML
512
- ok "Created: ~/.roll/config.yaml 已创建: ~/.roll/config.yaml"
516
+ ok "$(msg shared.created_roll_config_yaml)"
513
517
  fi
514
518
 
515
519
  # Ensure all expected ai_* keys exist (handles upgrades where new tools were added)
@@ -537,7 +541,7 @@ _link_skills() {
537
541
 
538
542
  if [[ -n "$ai_dir_real" && \
539
543
  ( "$ai_dir_real" == "$ROLL_PKG_DIR" || "$ai_dir_real" == "$ROLL_PKG_DIR"/* ) ]]; then
540
- warn "Skipped ~/${ai_name} (resolves to repo — refusing to manage skills inside roll worktree) 已跳过 ~/${ai_name}(解析到仓库目录 — 拒绝在 roll worktree 内管理技能)"
544
+ warn "$(msg shared.skipped_resolves_to_repo_refusing_to ${ai_name})"
541
545
  continue
542
546
  fi
543
547
 
@@ -546,7 +550,7 @@ _link_skills() {
546
550
  skills_real="$(canonical_dir "$skills_dir" 2>/dev/null || true)"
547
551
  if [[ -n "$skills_real" && -n "$pkg_skills_real" && \
548
552
  ( "$skills_real" == "$pkg_skills_real" || "$skills_real" == "$pkg_skills_real"/* ) ]]; then
549
- warn "Skipped ~/${ai_name}/skills (resolves to repo — check if ~/$(lower_name "$ai_name") symlinks to roll repo) 已跳过 ~/${ai_name}/skills(解析到仓库 — 检查 ~/$(lower_name "$ai_name") 是否软链接到 roll 仓库)"
553
+ warn "$(msg shared.skipped_resolves_to_repo "${ai_name}" "$(lower_name "$ai_name")")"
550
554
  continue
551
555
  fi
552
556
 
@@ -559,10 +563,10 @@ _link_skills() {
559
563
  fi
560
564
  # Dangling whole-dir symlink — remove and recreate as per-skill links
561
565
  if [[ -z "$skills_real" ]]; then
562
- info "Removing legacy symlink ~/${ai_name}/skills -> ${skills_target/#$HOME/~} 正在移除遗留软链接 ~/${ai_name}/skills -> ${skills_target/#$HOME/~}"
566
+ info "$(msg shared.removing_legacy_symlink_skills ${ai_name} ${skills_target/#$HOME/~})"
563
567
  rm "$skills_dir"
564
568
  else
565
- warn "Skipped ~/${ai_name}/skills -> ${skills_target/#$HOME/~} (unknown symlink target) 已跳过 ~/${ai_name}/skills -> ${skills_target/#$HOME/~}(未知软链接目标)"
569
+ warn "$(msg shared.skipped_skills_unknown_symlink_target ${ai_name} ${skills_target/#$HOME/~})"
566
570
  continue
567
571
  fi
568
572
  fi
@@ -571,7 +575,7 @@ _link_skills() {
571
575
  skills_real="$(canonical_dir "$skills_dir" 2>/dev/null || true)"
572
576
  if [[ -n "$skills_real" && -n "$pkg_skills_real" && \
573
577
  ( "$skills_real" == "$pkg_skills_real" || "$skills_real" == "$pkg_skills_real"/* ) ]]; then
574
- warn "Skipped ~/${ai_name}/skills (created path resolves to repo — refusing to write) 已跳过 ~/${ai_name}/skills(创建路径解析到仓库 — 拒绝写入)"
578
+ warn "$(msg shared.skipped_skills_created_path_resolves_to ${ai_name})"
575
579
  continue
576
580
  fi
577
581
  local linked=0 repaired=0 pruned=0
@@ -611,7 +615,7 @@ _link_skills() {
611
615
  # real file/dir at that path: skip — never touch user content
612
616
  done
613
617
  if [[ $((linked + repaired + pruned)) -gt 0 ]]; then
614
- ok "Skills linked in ~/${ai_name}/skills (+${linked} new, ~${repaired} repaired, -${pruned} pruned) 已在 ~/${ai_name}/skills 中创建软链接(新增 +${linked},修复 ~${repaired},清理 -${pruned})"
618
+ ok "$(msg shared.skills_linked_in_skills_new_repaired ${ai_name} ${linked} ${repaired} ${pruned})"
615
619
  fi
616
620
  done < <(_get_ai_tools)
617
621
  }
@@ -637,18 +641,18 @@ _sync_convention_for_tool() {
637
641
  local wk_file="$dst_dir/roll.md"
638
642
  if [[ "$force" == "true" ]] || ! diff -q "$src" "$wk_file" &>/dev/null 2>&1; then
639
643
  cp "$src" "$wk_file"
640
- ok "Wrote: ${wk_file/#$HOME/~} 已写入: ${wk_file/#$HOME/~}"
644
+ ok "$(msg shared.wrote_3 ${wk_file/#$HOME/~})"
641
645
  fi
642
646
 
643
647
  # Append @roll.md include to main config — never overwrite existing content
644
648
  if [[ ! -f "$main_dst" ]]; then
645
649
  printf '@roll.md\n' > "$main_dst"
646
- ok "Created: ${main_dst/#$HOME/~} 已创建: ${main_dst/#$HOME/~}"
650
+ ok "$(msg shared.created ${main_dst/#$HOME/~})"
647
651
  elif ! grep -qF "@roll.md" "$main_dst" 2>/dev/null; then
648
652
  printf '\n@roll.md\n' >> "$main_dst"
649
- ok "Appended @roll.md to: ${main_dst/#$HOME/~} 已将 @roll.md 追加至: ${main_dst/#$HOME/~}"
653
+ ok "$(msg shared.appended_roll_md_to ${main_dst/#$HOME/~})"
650
654
  else
651
- ok "Already included: ${main_dst/#$HOME/~} 已包含: ${main_dst/#$HOME/~}"
655
+ ok "$(msg shared.already_included ${main_dst/#$HOME/~})"
652
656
  fi
653
657
  }
654
658
 
@@ -665,10 +669,10 @@ _sync_conventions() {
665
669
  # ─── Internal: sync skills (pull + link) ──────────────────────────────────────
666
670
  _sync_skills() {
667
671
  local force="${1:-false}"
668
- info "Updating skills... 正在更新技能..."
672
+ info "$(msg shared.updating_skills)"
669
673
  _pull_skills
670
- ok "Skills updated in ~/.roll/skills 技能已更新至 ~/.roll/skills"
671
- info "Creating skill symlinks for AI tools... 正在为 AI 工具创建技能软链接..."
674
+ ok "$(msg shared.skills_updated_in_roll_skills)"
675
+ info "$(msg shared.creating_skill_symlinks_for_ai_tools)"
672
676
  _link_skills "$force"
673
677
  }
674
678
 
@@ -689,19 +693,19 @@ _ensure_tmux() {
689
693
  local os; os="$(uname)"
690
694
  if [[ "$os" == "Darwin" ]]; then
691
695
  if command -v brew >/dev/null 2>&1; then
692
- info "tmux not found — installing via brew... 未安装 tmux,正在通过 brew 安装..."
696
+ info "$(msg shared.tmux_not_found_installing_via_brew)"
693
697
  if brew install tmux >/dev/null 2>&1; then
694
- ok "tmux installed. tmux 已安装。"
698
+ ok "$(msg shared.tmux_installed_tmux)"
695
699
  return 0
696
700
  fi
697
- warn "brew install tmux failed — install manually: brew install tmux brew 安装失败,请手动 'brew install tmux'"
701
+ warn "$(msg shared.brew_install_tmux_failed_install_manually)"
698
702
  return 0
699
703
  fi
700
- warn "tmux required but brew not available — install manually: brew install tmux 缺少 brew,请手动 'brew install tmux'"
704
+ warn "$(msg shared.tmux_required_but_brew_not_available)"
701
705
  return 0
702
706
  fi
703
707
 
704
- warn "tmux required — install via your package manager (e.g. apt install tmux / pacman -S tmux) 请用系统包管理器安装 tmux"
708
+ warn "$(msg shared.tmux_required_install_via_your_package)"
705
709
  return 0
706
710
  }
707
711
 
@@ -756,7 +760,7 @@ cmd_setup() {
756
760
  while [[ $# -gt 0 ]]; do
757
761
  case "$1" in
758
762
  --force|-f) force=true; shift ;;
759
- *) err "Unknown argument: $1 未知参数: $1"; exit 1 ;;
763
+ *) err "$(msg setup.unknown_argument_1)"; exit 1 ;;
760
764
  esac
761
765
  done
762
766
 
@@ -1042,18 +1046,18 @@ _check_installed_version_or_retry() {
1042
1046
 
1043
1047
  cmd_update() {
1044
1048
  info "$(msg update.current_version "$VERSION")"
1045
- info "Upgrading via npm... 正在通过 npm 升级..."
1049
+ info "$(msg update.upgrading_via_npm)"
1046
1050
  echo ""
1047
1051
 
1048
1052
  if ! npm install -g @seanyao/roll@latest; then
1049
- err "npm install failed. Check network/proxy and try again. npm 安装失败,请检查网络/代理后重试。"
1053
+ err "$(msg update.npm_install_failed_check_network_proxy)"
1050
1054
  exit 1
1051
1055
  fi
1052
1056
 
1053
1057
  _check_installed_version_or_retry
1054
1058
 
1055
1059
  echo ""
1056
- info "Re-syncing to AI tools... 正在重新同步到 AI 工具..."
1060
+ info "$(msg update.re_syncing_to_ai_tools)"
1057
1061
  echo ""
1058
1062
  cmd_setup
1059
1063
 
@@ -1193,7 +1197,7 @@ cmd_init() {
1193
1197
  # US-ONBOARD-009: --apply consumes onboard-plan.yaml produced by $roll-onboard
1194
1198
  if [[ "${1:-}" == "--apply" ]]; then
1195
1199
  if [[ ! -d "$ROLL_TEMPLATES" ]]; then
1196
- err "No templates found. Run 'roll setup' first. 未找到模板,请先运行 'roll setup'。"
1200
+ err "$(msg init.no_templates_found_run_roll_setup)"
1197
1201
  exit 1
1198
1202
  fi
1199
1203
  shift
@@ -1202,12 +1206,12 @@ cmd_init() {
1202
1206
  fi
1203
1207
 
1204
1208
  if [[ "${1:-}" == -* ]]; then
1205
- err "Unknown flag: $1 未知参数: $1"
1209
+ err "$(msg init.unknown_flag_1)"
1206
1210
  exit 1
1207
1211
  fi
1208
1212
 
1209
1213
  if [[ ! -d "$ROLL_TEMPLATES" ]]; then
1210
- err "No templates found. Run 'roll setup' first. 未找到模板,请先运行 'roll setup'。"
1214
+ err "$(msg init.no_templates_found_run_roll_setup_2)"
1211
1215
  exit 1
1212
1216
  fi
1213
1217
 
@@ -1372,13 +1376,13 @@ _init_legacy_onboard_guide() {
1372
1376
  local count_summary
1373
1377
  count_summary=$(_init_legacy_file_summary "$project_dir")
1374
1378
 
1375
- info "Detected: legacy project (${count_summary}) 检测到遗留项目"
1379
+ info "$(msg init.detected_legacy_project ${count_summary})"
1376
1380
  echo ""
1377
1381
 
1378
1382
  # Discover installed agents (writes to globals: _ONBOARD_INSTALLED, _ONBOARD_MISSING).
1379
1383
  _onboard_discover_agents
1380
1384
 
1381
- echo " Onboarding 需要一个 AI agent 来读懂这个项目。检测到:"
1385
+ echo "$(msg init.onboarding)"
1382
1386
  echo " Onboarding requires an AI agent to read your code. Detected:"
1383
1387
  echo ""
1384
1388
  local n
@@ -1396,15 +1400,15 @@ _init_legacy_onboard_guide() {
1396
1400
  if [[ ${#_ONBOARD_INSTALLED[@]} -eq 0 ]]; then
1397
1401
  echo ""
1398
1402
  err "No AI agent detected. Install one (e.g., 'claude', 'codex', 'kimi') and try again."
1399
- err "未检测到 AI agent。请先安装 ( claude / codex / kimi) 后重试。"
1403
+ err "$(msg init.no_ai_agent_detected_install_one)"
1400
1404
  return 1
1401
1405
  fi
1402
1406
 
1403
1407
  echo ""
1404
- echo " 后续过程会使用你的 agent 调用模型,token 消耗在你自己的账户上。"
1408
+ echo "$(msg init.the_process_will_use_your_agent)"
1405
1409
  echo " Onboarding uses your agent to call models — tokens are billed to your account."
1406
1410
  echo ""
1407
- echo " 代码与对话都留在你的 agent 工具里 —— Roll 本身不上传任何内容。"
1411
+ echo "$(msg init.code_and_conversations_stay_in_your)"
1408
1412
  echo " Your code and conversation stay in your agent — Roll never uploads anything."
1409
1413
  echo ""
1410
1414
 
@@ -1416,9 +1420,9 @@ _init_legacy_onboard_guide() {
1416
1420
  [[ -n "$chosen" ]] || return 1
1417
1421
 
1418
1422
  echo ""
1419
- info "Launching ${chosen}… 正在启动 ${chosen}"
1423
+ info "$(msg init.launching ${chosen})"
1420
1424
  echo " Conversation ends with /exit (or Ctrl-C). On exit Roll will run apply for you."
1421
- echo " agent 内用 /exit 结束(或 Ctrl-C)。退出后 Roll 会自动衔接 apply。"
1425
+ echo "$(msg init.use_exit_to_end_or_ctrl)"
1422
1426
  echo ""
1423
1427
 
1424
1428
  # US-ONBOARD-018: actually run the agent with the onboard prompt pre-loaded.
@@ -1475,7 +1479,7 @@ _onboard_select_agent() {
1475
1479
 
1476
1480
  # Multi-agent: prompt the user. To stderr so stdout stays clean for the caller.
1477
1481
  {
1478
- echo " 选一个 agent Pick an agent:"
1482
+ echo "$(msg init.pick_an_agent)"
1479
1483
  local i=1
1480
1484
  for c in "${candidates[@]}"; do
1481
1485
  printf " %d) %s\n" "$i" "$c"
@@ -1486,11 +1490,11 @@ _onboard_select_agent() {
1486
1490
 
1487
1491
  local choice
1488
1492
  if ! IFS= read -r choice; then
1489
- err "No input received. Aborting onboard. 未收到输入,已取消 onboard。" >&2
1493
+ err "$(msg init.no_input_received_aborting_onboard)" >&2
1490
1494
  return 1
1491
1495
  fi
1492
1496
  if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#candidates[@]} )); then
1493
- err "Invalid choice: '${choice}'. 无效选择。" >&2
1497
+ err "$(msg init.invalid_choice ${choice})" >&2
1494
1498
  return 1
1495
1499
  fi
1496
1500
  printf '%s\n' "${candidates[$((choice - 1))]}"
@@ -1542,7 +1546,7 @@ _run_onboard_agent() {
1542
1546
  local prompt
1543
1547
  prompt=$(_onboard_initial_prompt) || return 1
1544
1548
  _agent_argv "$agent" interactive "$prompt" || {
1545
- err "Agent '${agent}' has no interactive mode wired up. agent '${agent}' 暂未接入 interactive 模式。" >&2
1549
+ err "$(msg init.agent_has_no_interactive_mode_wired ${agent})" >&2
1546
1550
  return 1
1547
1551
  }
1548
1552
 
@@ -1560,15 +1564,15 @@ _run_onboard_agent() {
1560
1564
  if [[ ! -f "${project_dir}/.roll/onboard-plan.yaml" ]]; then
1561
1565
  echo "" >&2
1562
1566
  err "Agent exited cleanly but did not write .roll/onboard-plan.yaml." >&2
1563
- err "agent 正常退出但未生成 .roll/onboard-plan.yaml。" >&2
1567
+ err "$(msg init.agent)" >&2
1564
1568
  echo " Re-run \`roll init\` once you've completed the conversation." >&2
1565
- echo " 对话完成后再次运行 \`roll init\` 即可。" >&2
1569
+ echo "$(msg init.en_roll_init)" >&2
1566
1570
  return 1
1567
1571
  fi
1568
1572
 
1569
1573
  # Plan present → chain into apply automatically.
1570
1574
  echo "" >&2
1571
- info "Plan written. Running apply… 已写入 plan,正在执行 apply…"
1575
+ info "$(msg init.plan_written_running_apply)"
1572
1576
  ( cd "$project_dir" && _init_apply )
1573
1577
  }
1574
1578
 
@@ -1687,27 +1691,27 @@ _init_apply() {
1687
1691
  local validator="${ROLL_PKG_DIR}/lib/roll-plan-validate.py"
1688
1692
 
1689
1693
  if [[ ! -f "$plan" ]]; then
1690
- err "No onboard plan found at .roll/onboard-plan.yaml 未找到 onboard 计划。"
1694
+ err "$(msg init.no_onboard_plan_found_at_roll)"
1691
1695
  echo "" >&2
1692
1696
  echo " Run \$roll-onboard in your AI agent first to generate the plan." >&2
1693
- echo " 请先在 AI agent 里运行 \$roll-onboard 生成 plan,再回来执行 apply。" >&2
1697
+ echo "$(msg init.en_ai_agent_onboard_plan_ap $roll)" >&2
1694
1698
  return 1
1695
1699
  fi
1696
1700
 
1697
1701
  if [[ ! -f "$validator" ]]; then
1698
- err "Plan validator missing: $validator 校验器缺失。"
1702
+ err "$(msg init.plan_validator_missing $validator)"
1699
1703
  return 1
1700
1704
  fi
1701
1705
 
1702
1706
  # Validate plan (schema + generated_at freshness + version)
1703
1707
  if ! python3 "$validator" "$plan"; then
1704
- err "Plan validation failed. See errors above. Plan 校验失败。"
1708
+ err "$(msg init.plan_validation_failed_see_errors_above)"
1705
1709
  echo "" >&2
1706
1710
  echo " If the plan is stale (>24h), regenerate by running \$roll-onboard again." >&2
1707
1711
  return 1
1708
1712
  fi
1709
1713
 
1710
- info "Applying onboard plan... 正在应用 onboard 计划..."
1714
+ info "$(msg init.applying_onboard_plan)"
1711
1715
  _ROLL_MERGE_SUMMARY=()
1712
1716
 
1713
1717
  # US-ONBOARD-013: start a fresh changeset record so offboard can reverse.
@@ -1774,16 +1778,16 @@ print('true' if p.get('privacy', {}).get('gitignore_dot_roll', False) else 'fals
1774
1778
  if ! grep -qFx ".roll/" "$gi" 2>/dev/null; then
1775
1779
  echo ".roll/" >> "$gi"
1776
1780
  _onboard_changeset_record "$project_dir" "gitignore_entries_added" ".roll/"
1777
- ok "Added .roll/ to .gitignore 已将 .roll/ 加入 .gitignore"
1781
+ ok "$(msg init.added_roll_to_gitignore)"
1778
1782
  fi
1779
1783
  fi
1780
1784
 
1781
1785
  echo ""
1782
- info "Syncing conventions to AI tools... 正在同步约定到 AI 工具..."
1786
+ info "$(msg init.syncing_conventions_to_ai_tools)"
1783
1787
  _sync_conventions
1784
1788
  echo ""
1785
1789
 
1786
- ok "Onboard apply complete. Onboard 应用完成。"
1790
+ ok "$(msg init.onboard_apply_complete_onboard)"
1787
1791
  }
1788
1792
 
1789
1793
  # US-ONBOARD-014: roll offboard
@@ -1809,7 +1813,7 @@ cmd_offboard() {
1809
1813
  return 0
1810
1814
  ;;
1811
1815
  *)
1812
- err "Unknown flag: $arg 未知参数"
1816
+ err "$(msg offboard.unknown_flag $arg)"
1813
1817
  return 1
1814
1818
  ;;
1815
1819
  esac
@@ -1848,7 +1852,7 @@ pr("launchd_plists_installed")
1848
1852
  PY
1849
1853
  )
1850
1854
  if [[ $? -ne 0 ]]; then
1851
- err "Failed to parse changeset 解析变更清单失败"
1855
+ err "$(msg offboard.failed_to_parse_changeset)"
1852
1856
  return 1
1853
1857
  fi
1854
1858
 
@@ -1879,7 +1883,7 @@ PY
1879
1883
  "$project_dir"|"$project_dir"/*) ;;
1880
1884
  *)
1881
1885
  err "Refusing to act on '$item' — it does not resolve under $project_dir"
1882
- err "拒绝处理 '$item' — 路径不在当前项目下,可能是误用"
1886
+ err "$(msg offboard.en $item)"
1883
1887
  echo " This usually means the changeset was copied from another project." >&2
1884
1888
  echo " Remove .roll/onboard-changeset.yaml manually, or rerun in the right dir." >&2
1885
1889
  return 1
@@ -1913,19 +1917,19 @@ PY
1913
1917
  fi
1914
1918
  if [[ ${#files[@]} -eq 0 && ${#dirs[@]} -eq 0 && ${#gi_entries[@]} -eq 0 && ${#plists[@]} -eq 0 ]]; then
1915
1919
  info "Changeset is empty — nothing to offboard."
1916
- info "变更清单为空,无需 offboard"
1920
+ info "$(msg offboard.change_list_is_empty_nothing_to)"
1917
1921
  return 0
1918
1922
  fi
1919
1923
 
1920
1924
  if [[ "$confirm" -ne 1 ]]; then
1921
1925
  echo " This is a dry-run. Re-run with --confirm to apply."
1922
- echo " 以上为预演结果。加 --confirm 后才会真正执行。"
1926
+ echo "$(msg offboard.above_is_a_dry_run_preview)"
1923
1927
  return 0
1924
1928
  fi
1925
1929
 
1926
1930
  # Apply. Guard every loop with a count check — `set -u` upstream makes
1927
1931
  # naked `"${arr[@]}"` over an empty array a hard error on bash 5.0.
1928
- echo " Applying offboard... 执行 offboard..."
1932
+ echo "$(msg offboard.applying_offboard)"
1929
1933
  if [ "${#files[@]}" -gt 0 ]; then
1930
1934
  for item in "${files[@]}"; do
1931
1935
  rm -f "$project_dir/$item" 2>/dev/null && echo " removed file $item"
@@ -1955,7 +1959,7 @@ PY
1955
1959
  fi
1956
1960
  # Finally, remove the changeset file itself.
1957
1961
  rm -f "$changeset"
1958
- ok "Offboard complete. Offboard 完成。"
1962
+ ok "$(msg offboard.offboard_complete_offboard)"
1959
1963
  }
1960
1964
 
1961
1965
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -1978,13 +1982,13 @@ cmd_migrate() {
1978
1982
  case "$1" in
1979
1983
  --dry-run|-n) dry_run=true; shift ;;
1980
1984
  -h|--help) _migrate_help; return 0 ;;
1981
- *) err "Unknown arg: $1 未知参数: $1"; return 1 ;;
1985
+ *) err "$(msg migrate.unknown_arg_1 "$1")"; return 1 ;;
1982
1986
  esac
1983
1987
  done
1984
1988
 
1985
1989
  # Must be in a git repo (git mv preserves history)
1986
1990
  if ! git rev-parse --git-dir >/dev/null 2>&1; then
1987
- err "Not a git repository. roll migrate requires git. 当前目录不是 git 仓库。"
1991
+ err "$(msg migrate.not_a_git_repository_roll_migrate)"
1988
1992
  return 1
1989
1993
  fi
1990
1994
 
@@ -2006,9 +2010,9 @@ cmd_migrate() {
2006
2010
 
2007
2011
  # Three-state dispatch
2008
2012
  if [[ "$has_new" == "true" && "$has_old" == "true" ]]; then
2009
- err "Both old and new structures exist (partial migration detected). 老结构与新结构同时存在(部分迁移)。"
2013
+ err "$(msg migrate.both_old_and_new_structures_exist)"
2010
2014
  echo "" >&2
2011
- echo "Conflicting paths: 冲突路径:" >&2
2015
+ echo "$(msg migrate.conflicting_paths)" >&2
2012
2016
  for m in "${moves[@]}"; do
2013
2017
  src="${m%%|*}"
2014
2018
  local tgt="${m##*|}"
@@ -2017,17 +2021,17 @@ cmd_migrate() {
2017
2021
  fi
2018
2022
  done
2019
2023
  echo "" >&2
2020
- err "Resolve manually then re-run. 请手动解决冲突后重新运行。"
2024
+ err "$(msg migrate.resolve_manually_then_re_run)"
2021
2025
  return 1
2022
2026
  fi
2023
2027
 
2024
2028
  if [[ "$has_new" == "true" && "$has_old" == "false" ]]; then
2025
- ok "Already migrated. .roll/ exists, no old paths found. 已迁移,无需重复操作。"
2029
+ ok "$(msg migrate.already_migrated_roll_exists_no_old)"
2026
2030
  return 0
2027
2031
  fi
2028
2032
 
2029
2033
  if [[ "$has_old" == "false" ]]; then
2030
- info "No old structure detected. Nothing to migrate. 未发现老结构,无需迁移。"
2034
+ info "$(msg migrate.no_old_structure_detected_nothing_to)"
2031
2035
  return 0
2032
2036
  fi
2033
2037
 
@@ -2039,7 +2043,7 @@ cmd_migrate() {
2039
2043
  done
2040
2044
 
2041
2045
  if [[ ${#active_moves[@]} -eq 0 ]]; then
2042
- warn "Old structure markers found but no migratable files. 未找到可迁移文件。"
2046
+ warn "$(msg migrate.old_structure_markers_found_but_no)"
2043
2047
  return 0
2044
2048
  fi
2045
2049
 
@@ -2050,7 +2054,7 @@ cmd_migrate() {
2050
2054
 
2051
2055
  # Real execution requires clean working tree (we'll create a single commit)
2052
2056
  if ! git diff --quiet --ignore-submodules HEAD 2>/dev/null; then
2053
- err "Working tree not clean. Commit or stash changes before running migrate. 工作区有未提交改动,请先 commit 或 stash。"
2057
+ err "$(msg migrate.working_tree_not_clean_commit_or)"
2054
2058
  return 1
2055
2059
  fi
2056
2060
 
@@ -2089,9 +2093,9 @@ EOF
2089
2093
  }
2090
2094
 
2091
2095
  _migrate_preview() {
2092
- info "Migration preview (dry-run): 迁移预览(dry-run)"
2096
+ info "$(msg migrate.migration_preview_dry_run)"
2093
2097
  echo ""
2094
- printf " %-60s → %s\n" "Old path 老路径" "New path 新路径"
2098
+ printf " %-60s → %s\n" "$(msg migrate.old_path)" "$(msg migrate.new_path)"
2095
2099
  local sep; sep=$(printf '─%.0s' {1..100})
2096
2100
  printf " %s\n" "$sep"
2097
2101
  local m
@@ -2100,11 +2104,11 @@ _migrate_preview() {
2100
2104
  printf " %-60s → %s\n" "$src" "$tgt"
2101
2105
  done
2102
2106
  echo ""
2103
- info "Run without --dry-run to execute. 去掉 --dry-run 即可真实执行。"
2107
+ info "$(msg migrate.run_without_dry_run_to_execute)"
2104
2108
  }
2105
2109
 
2106
2110
  _migrate_execute() {
2107
- info "Migrating ${#@} paths via git mv... 正在通过 git mv 迁移 ${#@} 个路径..."
2111
+ info "$(msg migrate.migrating_paths_via_git_mv ${#@})"
2108
2112
  local moved=0 m
2109
2113
  for m in "$@"; do
2110
2114
  local src="${m%%|*}" tgt="${m##*|}"
@@ -2112,7 +2116,7 @@ _migrate_execute() {
2112
2116
  [[ -d "$target_dir" ]] || mkdir -p "$target_dir"
2113
2117
  git mv "$src" "$tgt" || {
2114
2118
  err "git mv failed: $src → $tgt"
2115
- err "Aborting; previous moves are staged but not committed. Run 'git reset --hard' to undo. 已 stage 但未 commit,运行 'git reset --hard' 回滚。"
2119
+ err "$(msg migrate.aborting_previous_moves_are_staged_but)"
2116
2120
  return 1
2117
2121
  }
2118
2122
  moved=$((moved + 1))
@@ -2128,9 +2132,9 @@ Atomic migration via 'roll migrate' command. Process artifacts moved
2128
2132
  from root and docs/ into .roll/; user docs relocated to guide/ and site/.
2129
2133
 
2130
2134
  Paths migrated: ${moved}"
2131
- ok "Migrated ${moved} paths in a single commit. 已在单 commit 中迁移 ${moved} 个路径。"
2135
+ ok "$(msg migrate.migrated_paths_in_a_single_commit ${moved})"
2132
2136
  echo ""
2133
- echo " Next steps 下一步:"
2137
+ echo "$(msg migrate.next_steps)"
2134
2138
  echo " git log -1 # Inspect the migration commit"
2135
2139
  echo " roll status # Verify new structure"
2136
2140
  }
@@ -2168,7 +2172,7 @@ print_merge_summary() {
2168
2172
  return
2169
2173
  fi
2170
2174
  echo ""
2171
- echo " ┌─ 操作摘要 Summary ──────────────────────────────────┐"
2175
+ echo "$(msg migrate.summary)"
2172
2176
  for entry in "${_ROLL_MERGE_SUMMARY[@]}"; do
2173
2177
  local action="${entry%%|*}"
2174
2178
  local file="${entry##*|}"
@@ -2370,39 +2374,39 @@ EOF
2370
2374
  # Show current state of conventions
2371
2375
  # ═══════════════════════════════════════════════════════════════════════════════
2372
2376
  _legacy_status() {
2373
- echo -e "${BOLD}Roll Convention Status Roll 约定状态${NC}"
2377
+ echo -e "$(msg migrate.roll_convention_status_roll ${BOLD} ${NC})"
2374
2378
  echo ""
2375
2379
 
2376
2380
  if [[ -d "$ROLL_HOME" ]]; then
2377
- ok "~/.roll/ exists ~/.roll/ 已存在"
2381
+ ok "$(msg migrate.roll_exists_roll)"
2378
2382
  else
2379
- err "~/.roll/ not found — run 'roll setup' ~/.roll/ 不存在 — 请运行 'roll setup'"
2383
+ err "$(msg migrate.roll_not_found_run_roll_setup)"
2380
2384
  return
2381
2385
  fi
2382
2386
 
2383
2387
  echo ""
2384
- echo -e "${BOLD}Global conventions: 全局约定${NC}"
2388
+ echo -e "$(msg migrate.global_conventions ${BOLD} ${NC})"
2385
2389
  for f in AGENTS.md CLAUDE.md GEMINI.md .cursor-rules project_rules.md; do
2386
2390
  if [[ -f "$ROLL_GLOBAL/$f" ]]; then
2387
2391
  echo -e " ${GREEN}+${NC} $f"
2388
2392
  else
2389
- echo -e " ${RED}-${NC} $f (missing / 缺失)"
2393
+ echo -e "$(msg migrate.missing ${RED} ${NC} $f)"
2390
2394
  fi
2391
2395
  done
2392
2396
 
2393
2397
  echo ""
2394
- echo -e "${BOLD}Global skills: 全局技能${NC}"
2398
+ echo -e "$(msg migrate.global_skills ${BOLD} ${NC})"
2395
2399
  if [[ -d "$ROLL_HOME/skills" ]]; then
2396
2400
  local count
2397
2401
  count=$(find "$ROLL_HOME/skills" -maxdepth 1 -type d | wc -l | tr -d ' ')
2398
2402
  count=$((count - 1))
2399
- echo -e " ${GREEN}+${NC} ~/.roll/skills ($count skills installed / 已安装 $count 个技能)"
2403
+ echo -e "$(msg migrate.roll_skills_skills_installed ${GREEN} ${NC} $count)"
2400
2404
  else
2401
- echo -e " ${RED}-${NC} ~/.roll/skills (missing / 缺失)"
2405
+ echo -e "$(msg migrate.roll_skills_missing ${RED} ${NC})"
2402
2406
  fi
2403
2407
 
2404
2408
  echo ""
2405
- echo -e "${BOLD}Sync targets: 同步目标${NC}"
2409
+ echo -e "$(msg migrate.sync_targets ${BOLD} ${NC})"
2406
2410
 
2407
2411
  local _sync_found=0
2408
2412
  while IFS= read -r _entry; do
@@ -2415,12 +2419,12 @@ _legacy_status() {
2415
2419
  check_sync_status "$_tool_name" "$ROLL_GLOBAL/$_src" "$_ai_d/$_cfg"
2416
2420
  done < <(_get_ai_tools)
2417
2421
  if [[ "$_sync_found" -eq 0 ]]; then
2418
- warn "No AI tools configured — check ~/.roll/config.yaml 未配置 AI 工具 — 请检查 ~/.roll/config.yaml"
2419
- info "Add ai_* entries or run 'roll setup' to restore defaults. 添加 ai_* 条目或运行 'roll setup' 恢复默认配置。"
2422
+ warn "$(msg migrate.no_ai_tools_configured_check_roll)"
2423
+ info "$(msg migrate.add_ai_entries_or_run_roll)"
2420
2424
  fi
2421
2425
 
2422
2426
  echo ""
2423
- echo -e "${BOLD}Skill symlinks: 技能软链接${NC}"
2427
+ echo -e "$(msg migrate.skill_symlinks ${BOLD} ${NC})"
2424
2428
  local total_skills=0
2425
2429
  local wk_skills_real
2426
2430
  if [[ -d "$ROLL_HOME/skills" ]]; then
@@ -2446,7 +2450,7 @@ _legacy_status() {
2446
2450
  skills_real="$(canonical_dir "$skills_dir" 2>/dev/null || true)"
2447
2451
  local skills_display="${skills_dir/#$HOME/~}"
2448
2452
  if [[ -n "$skills_real" && "$skills_real" == "$wk_skills_real" ]]; then
2449
- echo -e " ${GREEN}=${NC} $name: $skills_display -> ~/.roll/skills (mounted / 已挂载)"
2453
+ echo -e "$(msg migrate.roll_skills_mounted ${GREEN} ${NC} $name $skills_display)"
2450
2454
  else
2451
2455
  echo -e " ${YELLOW}~${NC} $name: $skills_display -> ${skills_target/#$HOME/~} (symlinked dir)"
2452
2456
  fi
@@ -2461,25 +2465,25 @@ _legacy_status() {
2461
2465
  elif [[ "$linked_count" -gt 0 ]]; then
2462
2466
  echo -e " ${YELLOW}~${NC} $name: $skills_display ($linked_count/$total_skills skills linked)"
2463
2467
  else
2464
- echo -e " ${RED}-${NC} $name: $skills_display (no roll-* skills linked / 未链接 roll-* 技能)"
2468
+ echo -e "$(msg migrate.no_roll_skills_linked ${RED} ${NC} $name $skills_display)"
2465
2469
  fi
2466
2470
  else
2467
- echo -e " ${RED}-${NC} $name: ${skills_dir/#$HOME/~} (not found / 未找到)"
2471
+ echo -e "$(msg migrate.not_found ${RED} ${NC} $name ${skills_dir/#$HOME/~})"
2468
2472
  fi
2469
2473
  done < <(_get_ai_tools)
2470
2474
  if [[ "$_skills_found" -eq 0 ]]; then
2471
- warn "No AI tools configured — check ~/.roll/config.yaml 未配置 AI 工具 — 请检查 ~/.roll/config.yaml"
2475
+ warn "$(msg migrate.no_ai_tools_configured_check_roll_2)"
2472
2476
  fi
2473
2477
 
2474
2478
  echo ""
2475
- echo -e "${BOLD}Templates: 模板${NC}"
2479
+ echo -e "$(msg migrate.templates ${BOLD} ${NC})"
2476
2480
  for tpl in fullstack frontend-only backend-service cli; do
2477
2481
  if [[ -d "$ROLL_TEMPLATES/$tpl" ]]; then
2478
2482
  local count
2479
2483
  count=$(find "$ROLL_TEMPLATES/$tpl" -type f | wc -l | tr -d ' ')
2480
2484
  echo -e " ${GREEN}+${NC} $tpl ($count files)"
2481
2485
  else
2482
- echo -e " ${RED}-${NC} $tpl (missing / 缺失)"
2486
+ echo -e "$(msg migrate.missing_2 ${RED} ${NC} $tpl)"
2483
2487
  fi
2484
2488
  done
2485
2489
 
@@ -2505,7 +2509,7 @@ _status_loop_overview() {
2505
2509
  [[ "${#plists[@]}" -eq 0 ]] && return 0
2506
2510
 
2507
2511
  echo ""
2508
- echo -e "${BOLD}Loop Overview: 所有项目 loop 状态${NC}"
2512
+ echo -e "$(msg status.loop_overview ${BOLD} ${NC})"
2509
2513
 
2510
2514
  for plist in "${plists[@]}"; do
2511
2515
  local label; label=$(basename "$plist" .plist)
@@ -2563,15 +2567,15 @@ check_sync_status() {
2563
2567
  # Sync writes content to {dir}/roll.md and appends @roll.md to the main config.
2564
2568
  # So "in sync" means: roll.md exists + matches source + main config contains @roll.md.
2565
2569
  if [[ ! -f "$dst" ]]; then
2566
- echo -e " ${RED}-${NC} $name: $display (not synced / 未同步)"
2570
+ echo -e "$(msg status.not_synced ${RED} ${NC} $name $display)"
2567
2571
  elif [[ ! -f "$wk_file" ]]; then
2568
- echo -e " ${YELLOW}~${NC} $name: $display (out of sync — roll.md missing / roll.md 缺失)"
2572
+ echo -e "$(msg status.out_of_sync_roll_md_missing ${YELLOW} ${NC} $name $display)"
2569
2573
  elif ! diff -q "$src" "$wk_file" &>/dev/null 2>&1; then
2570
- echo -e " ${YELLOW}~${NC} $name: $display (out of sync — roll.md outdated / roll.md 已过期)"
2574
+ echo -e "$(msg status.out_of_sync_roll_md_outdated ${YELLOW} ${NC} $name $display)"
2571
2575
  elif ! grep -qF "@roll.md" "$dst" 2>/dev/null; then
2572
- echo -e " ${YELLOW}~${NC} $name: $display (out of sync — @roll.md not in config / 未包含 @roll.md)"
2576
+ echo -e "$(msg status.out_of_sync_roll_md_not ${YELLOW} ${NC} $name $display)"
2573
2577
  else
2574
- echo -e " ${GREEN}=${NC} $name: $display (in sync / 已同步)"
2578
+ echo -e "$(msg status.in_sync ${GREEN} ${NC} $name $display)"
2575
2579
  fi
2576
2580
  }
2577
2581
 
@@ -2674,7 +2678,13 @@ _peer_auto_attach() {
2674
2678
  [ "$(uname)" = "Darwin" ] || return 0
2675
2679
  [ -f "$_LOOP_MUTE_FILE" ] && return 0
2676
2680
  local attach_cmd="${_SHARED_ROOT}/loop/attach-${session}.command"
2677
- printf '#!/bin/bash\nexec tmux attach -t %s\n' "$session" > "$attach_cmd" 2>/dev/null || return 0
2681
+ # Drop `exec` so the wrapping shell survives `tmux attach` exiting; pause
2682
+ # on `read` afterwards so the user can scroll back through the session's
2683
+ # output before closing the Terminal window. Without this the window
2684
+ # closes the instant the tmux session ends and the entire scrollback
2685
+ # disappears with it.
2686
+ printf '#!/bin/bash\ntmux attach -t %s\necho\necho "================================================================"\necho " session ended. press enter to close this window."\necho "================================================================"\nread _\n' \
2687
+ "$session" > "$attach_cmd" 2>/dev/null || return 0
2678
2688
  chmod +x "$attach_cmd" 2>/dev/null || return 0
2679
2689
  open -g -a Terminal "$attach_cmd" >/dev/null 2>&1 || true
2680
2690
  }
@@ -2715,14 +2725,14 @@ _peer_call() {
2715
2725
  local call_timeout
2716
2726
  call_timeout="$(config_get "peer_call_timeout" "180")"
2717
2727
 
2718
- info "Peer call timeout: ${call_timeout}s Peer 调用超时: ${call_timeout}s"
2728
+ info "$(msg status.peer_call_timeout_s_peer ${call_timeout})"
2719
2729
 
2720
2730
  if [[ -n "$session" ]] && command -v tmux >/dev/null 2>&1 && tmux has-session -t "$session" 2>/dev/null; then
2721
2731
  local out_file
2722
2732
  out_file=$(mktemp)
2723
2733
  local cmd_str
2724
2734
  cmd_str=$(_agent_cmd_str "$to" peer "$prompt") || {
2725
- err "Unsupported peer: $to 不支持的 peer: $to"
2735
+ err "$(msg status.unsupported_peer $to)"
2726
2736
  return 1
2727
2737
  }
2728
2738
  _peer_dispatch_in_tmux "$session" "$cmd_str" "$out_file" "$stderr_log" "$call_timeout"
@@ -2730,7 +2740,7 @@ _peer_call() {
2730
2740
  rm -f "$out_file"
2731
2741
  else
2732
2742
  _agent_argv "$to" peer "$prompt" || {
2733
- err "Unsupported peer: $to 不支持的 peer: $to"
2743
+ err "$(msg status.unsupported_peer_2 $to)"
2734
2744
  return 1
2735
2745
  }
2736
2746
  output="$("${_AGENT_ARGV[@]}" 2>"$stderr_log" || true)"
@@ -2796,7 +2806,7 @@ cmd_peer() {
2796
2806
  status) subcmd="status"; shift ;;
2797
2807
  reset) subcmd="reset"; shift; break ;;
2798
2808
  help|--help|-h) subcmd="help"; shift ;;
2799
- *) err "Unknown option: $1 未知选项: $1"; exit 1 ;;
2809
+ *) err "$(msg peer.unknown_option_1)"; exit 1 ;;
2800
2810
  esac
2801
2811
  done
2802
2812
 
@@ -2807,7 +2817,7 @@ cmd_peer() {
2807
2817
  esac
2808
2818
 
2809
2819
  if [[ -z "$from_tool" ]]; then
2810
- err "--from is required. 必须指定 --from。"
2820
+ err "$(msg peer.from_is_required)"
2811
2821
  echo ""
2812
2822
  cmd_peer_help
2813
2823
  exit 1
@@ -2816,13 +2826,13 @@ cmd_peer() {
2816
2826
  if [[ -z "$to_tool" ]]; then
2817
2827
  to_tool="$(_peer_route "$from_tool" "$tag")"
2818
2828
  if [[ -z "$to_tool" ]]; then
2819
- err "No available peer found for tag '$tag'. 未找到 tag '$tag' 的可用 peer。"
2829
+ err "$(msg peer.no_available_peer_found_for_tag $tag)"
2820
2830
  echo ""
2821
2831
  info "Installed peers: $(_peer_detect_peers)"
2822
2832
  info "Capability map: $(config_get "peer_capability_map_${tag}" "$(config_get "peer_capability_map_default" "kimi claude pi")")"
2823
2833
  exit 1
2824
2834
  fi
2825
- info "Auto-selected peer: $to_tool 自动选择 peer: $to_tool"
2835
+ info "$(msg peer.auto_selected_peer $to_tool)"
2826
2836
  fi
2827
2837
 
2828
2838
  local pair
@@ -2831,7 +2841,7 @@ cmd_peer() {
2831
2841
  local status
2832
2842
  status="$(_peer_get_state "$pair" "status")"
2833
2843
  if [[ "$status" == "abandoned" ]]; then
2834
- err "Peer pair $pair is abandoned. Run 'roll peer reset $from_tool $to_tool' to restore. Peer $pair 已废弃。运行 'roll peer reset $from_tool $to_tool' 恢复。"
2844
+ err "$(msg peer.peer_pair_is_abandoned_run_roll $pair $from_tool $to_tool)"
2835
2845
  exit 1
2836
2846
  fi
2837
2847
 
@@ -2840,13 +2850,13 @@ cmd_peer() {
2840
2850
  opt_out="$(config_get "peer_opt_out_seconds" "10")"
2841
2851
  info "Launching peer review: $from_tool → $to_tool (round $round, tag: $tag)"
2842
2852
  info "Press Enter to proceed or type 'n' to abort. Auto-executing in ${opt_out}s..."
2843
- info "启动 peer review: $from_tool $to_tool (第 $round 轮, tag: $tag)"
2844
- info " Enter 执行或输入 n 取消。${opt_out} 秒后自动执行..."
2853
+ info "$(msg peer.en_peer_review $from_tool $to_tool $round $tag)"
2854
+ info "$(msg peer.en_enter_n ${opt_out})"
2845
2855
 
2846
2856
  local answer=""
2847
2857
  if IFS= read -r -t "$opt_out" answer 2>/dev/null; then
2848
2858
  if [[ "$answer" == "n" || "$answer" == "N" ]]; then
2849
- info "Peer review aborted by user. 用户取消 peer review。"
2859
+ info "$(msg peer.peer_review_aborted_by_user)"
2850
2860
  exit 0
2851
2861
  fi
2852
2862
  fi
@@ -2893,7 +2903,7 @@ cmd_peer() {
2893
2903
  echo ""
2894
2904
  } > "$log_file"
2895
2905
 
2896
- info "Calling $to_tool... 调用 $to_tool..."
2906
+ info "$(msg peer.calling $to_tool)"
2897
2907
  local response
2898
2908
  response="$(_peer_call "$to_tool" "$prompt" "$peer_session")"
2899
2909
 
@@ -2901,7 +2911,7 @@ cmd_peer() {
2901
2911
  stderr_log="${_PEER_STATE_DIR}/logs/.last_stderr.log"
2902
2912
  if [[ -f "$stderr_log" && -s "$stderr_log" ]]; then
2903
2913
  echo ""
2904
- echo -e "${BOLD}Peer stderr Peer 标准错误:${NC}"
2914
+ echo -e "$(msg peer.peer_stderr_peer ${BOLD} ${NC})"
2905
2915
  cat "$stderr_log"
2906
2916
  echo ""
2907
2917
  fi
@@ -2912,14 +2922,14 @@ cmd_peer() {
2912
2922
  resolution="$(_peer_parse_resolution "$response")"
2913
2923
 
2914
2924
  if [[ -z "$resolution" ]]; then
2915
- warn "Could not parse resolution from peer response. 无法解析 peer 响应中的决议状态。"
2925
+ warn "$(msg peer.could_not_parse_resolution_from_peer)"
2916
2926
  resolution="UNKNOWN"
2917
2927
  fi
2918
2928
 
2919
2929
  _peer_update_state "$pair" "$resolution"
2920
2930
 
2921
2931
  echo ""
2922
- echo -e "${BOLD}Peer Review Result Peer Review 结果${NC}"
2932
+ echo -e "$(msg peer.peer_review_result_peer_review ${BOLD} ${NC})"
2923
2933
  echo " Pair: $pair"
2924
2934
  echo " Round: $round"
2925
2935
  echo " Resolution: $resolution"
@@ -2927,17 +2937,17 @@ cmd_peer() {
2927
2937
 
2928
2938
  case "$resolution" in
2929
2939
  AGREE)
2930
- ok "Consensus reached. Proceed with execution. 达成共识,继续执行。"
2940
+ ok "$(msg peer.consensus_reached_proceed_with_execution)"
2931
2941
  ;;
2932
2942
  REFINE|OBJECT)
2933
2943
  if [[ "$round" -ge 3 ]]; then
2934
- warn "Max rounds reached. Escalating to user. 达到最大轮数,升级给用户。"
2944
+ warn "$(msg peer.max_rounds_reached_escalating_to_user)"
2935
2945
  else
2936
- info "Peer requests ${resolution}. Continue to round $((round + 1)). Peer 请求 ${resolution},继续第 $((round + 1)) 轮。"
2946
+ info "$(msg peer.peer_requests_continue_to_round_round "${resolution}" "$((round + 1))")"
2937
2947
  fi
2938
2948
  ;;
2939
2949
  ESCALATE|UNKNOWN)
2940
- warn "Peer review escalated or failed. Human decision required. Peer review 升级或失败,需要人类决策。"
2950
+ warn "$(msg peer.peer_review_escalated_or_failed_human)"
2941
2951
  ;;
2942
2952
  esac
2943
2953
 
@@ -2963,7 +2973,7 @@ cmd_peer() {
2963
2973
 
2964
2974
  cmd_peer_status() {
2965
2975
  _peer_ensure_state_dir
2966
- echo -e "${BOLD}Peer Review Status Peer Review 状态${NC}"
2976
+ echo -e "$(msg peer_status.peer_review_status_peer_review ${BOLD} ${NC})"
2967
2977
  echo ""
2968
2978
 
2969
2979
  local found=0
@@ -2990,7 +3000,7 @@ cmd_peer_status() {
2990
3000
  done
2991
3001
 
2992
3002
  if [[ "$found" -eq 0 ]]; then
2993
- info "No peer review history yet. 暂无 peer review 记录。"
3003
+ info "$(msg peer_status.no_peer_review_history_yet)"
2994
3004
  fi
2995
3005
 
2996
3006
  echo ""
@@ -3021,12 +3031,12 @@ cmd_peer_reset() {
3021
3031
  rm -f "$_PEER_STATE_DIR"/*_streak
3022
3032
  rm -f "$_PEER_STATE_DIR"/*_last_outcome
3023
3033
  rm -f "$_PEER_STATE_DIR"/*_last_time
3024
- ok "All peer states reset. 所有 peer 状态已重置。"
3034
+ ok "$(msg peer_reset.all_peer_states_reset)"
3025
3035
  return
3026
3036
  fi
3027
3037
 
3028
3038
  if [[ -z "$target_pair" ]]; then
3029
- err "Usage: roll peer reset <from>→<to> | --all 用法: roll peer reset <from>→<to> | --all"
3039
+ err "$(msg peer_reset.usage_roll_peer_reset_from_to)"
3030
3040
  exit 1
3031
3041
  fi
3032
3042
 
@@ -3034,26 +3044,26 @@ cmd_peer_reset() {
3034
3044
  rm -f "$(_peer_state_file "$target_pair" "streak")"
3035
3045
  rm -f "$(_peer_state_file "$target_pair" "last_outcome")"
3036
3046
  rm -f "$(_peer_state_file "$target_pair" "last_time")"
3037
- ok "Peer state reset: $target_pair Peer 状态已重置: $target_pair"
3047
+ ok "$(msg peer_reset.peer_state_reset_peer $target_pair)"
3038
3048
  }
3039
3049
 
3040
3050
  cmd_peer_help() {
3041
3051
  echo -e "${BOLD}roll peer — Cross-Agent Peer Review${NC}"
3042
3052
  echo ""
3043
- echo "Usage: roll peer [options] 用法: roll peer [选项]"
3053
+ echo "$(msg peer_help.usage_roll_peer_options)"
3044
3054
  echo ""
3045
3055
  echo "Options:"
3046
- echo " --from <tool> Originating agent (kimi, claude, pi) 发起方"
3047
- echo " --to <tool> Target peer (auto-detected if omitted) 对端 peer(省略则自动选择)"
3048
- echo " --round <N> Current round (default: 1) 当前轮数"
3049
- echo " --tag <type> Task type for routing (architecture, security, test...) 任务类型"
3050
- echo " --context <file> Context file to send to peer 上下文文件"
3051
- echo " --yes, --yolo Skip opt-out prompt 跳过确认提示"
3056
+ echo "$(msg peer_help.from_tool_originating_agent_kimi_claude)"
3057
+ echo "$(msg peer_help.to_tool_target_peer_auto_detected)"
3058
+ echo "$(msg peer_help.round_n_current_round_default_1)"
3059
+ echo "$(msg peer_help.tag_type_task_type_for_routing)"
3060
+ echo "$(msg peer_help.context_file_context_file_to_send)"
3061
+ echo "$(msg peer_help.yes_yolo_skip_opt_out_prompt)"
3052
3062
  echo ""
3053
3063
  echo "Subcommands:"
3054
- echo " status Show peer review state 显示状态"
3055
- echo " reset <pair|--all> Reset peer state 重置状态"
3056
- echo " help Show this help 显示帮助"
3064
+ echo "$(msg peer_help.status_show_peer_review_state)"
3065
+ echo "$(msg peer_help.reset_pair_all_reset_peer_state)"
3066
+ echo "$(msg peer_help.help_show_this_help)"
3057
3067
  }
3058
3068
 
3059
3069
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -3084,18 +3094,6 @@ _project_agent() {
3084
3094
  fi
3085
3095
  }
3086
3096
 
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
-
3099
3097
  _skill_content() {
3100
3098
  # Strip YAML frontmatter (---...---) — it's roll-internal metadata, not agent instructions
3101
3099
  awk 'NR==1 && /^---$/{skip=1;next} skip && /^---$/{skip=0;next} !skip{print}' "$1"
@@ -3349,12 +3347,19 @@ USAGE 用法
3349
3347
  在浏览器中打开已渲染的幻灯片
3350
3348
  roll slides logs <slug> Show the last build failure log for a deck
3351
3349
  显示幻灯片上次构建失败日志
3350
+ roll slides templates List available slide templates (built-in + project)
3351
+ 列出可用模板(内置 + 项目自定义)
3352
+ roll slides delete <slug> [--force]
3353
+ Delete a deck (dir + HTML) with confirmation prompt
3354
+ 删除幻灯片(含目录与 HTML),需确认
3352
3355
 
3353
3356
  OPTIONS 选项
3354
3357
  --no-open Skip auto-opening the rendered HTML in a browser
3355
3358
  渲染后不自动打开浏览器
3356
3359
  --no-build Skip auto-build after agent completes (deck.md only)
3357
3360
  仅生成 deck.md,不自动渲染
3361
+ --force Skip confirmation prompt (delete subcommand)
3362
+ 跳过确认提示(delete 子命令)
3358
3363
  --help, -h Show this help
3359
3364
  显示本帮助
3360
3365
  EOF
@@ -3433,12 +3438,12 @@ cmd_slides_build() {
3433
3438
  case "$1" in
3434
3439
  --no-open) no_open=1; shift ;;
3435
3440
  --help|-h) _slides_help; return 0 ;;
3436
- --*) err "Unknown option: $1 未知选项: $1"; return 1 ;;
3441
+ --*) err "$(msg slides_build.unknown_option_1)"; return 1 ;;
3437
3442
  *)
3438
3443
  if [[ -z "$slug" ]]; then
3439
3444
  slug="$1"; shift
3440
3445
  else
3441
- err "Unexpected argument: $1 多余参数: $1"; return 1
3446
+ err "$(msg slides_build.unexpected_argument_1)"; return 1
3442
3447
  fi
3443
3448
  ;;
3444
3449
  esac
@@ -3446,16 +3451,16 @@ cmd_slides_build() {
3446
3451
 
3447
3452
  if [[ -z "$slug" ]]; then
3448
3453
  err "Usage: roll slides build <slug> [--no-open]"
3449
- echo "用法: roll slides build <slug> [--no-open]" >&2
3454
+ echo "$(msg slides_build.usage_roll_slides_build_slug_no)" >&2
3450
3455
  return 1
3451
3456
  fi
3452
3457
 
3453
3458
  local deck=".roll/slides/${slug}/deck.md"
3454
3459
  if [[ ! -f "$deck" ]]; then
3455
3460
  err "Deck not found: ${deck}"
3456
- echo " 未找到 deck 文件:${deck}" >&2
3461
+ echo "$(msg slides_build.en_deck ${deck})" >&2
3457
3462
  echo " Hint: run 'roll slides new \"<topic>\"' to generate a new deck." >&2
3458
- echo " 提示:先运行 'roll slides new \"<主题>\"' 生成新的幻灯片。" >&2
3463
+ echo "$(msg slides_build.en_roll_slides_new)" >&2
3459
3464
  return 1
3460
3465
  fi
3461
3466
 
@@ -3463,7 +3468,7 @@ cmd_slides_build() {
3463
3468
  local validator="${lib_dir}/slides-validate.py"
3464
3469
  local renderer="${lib_dir}/slides-render.py"
3465
3470
  if [[ ! -f "$validator" || ! -f "$renderer" ]]; then
3466
- err "Slides toolchain missing — re-run 'roll setup' 渲染工具缺失,请运行 roll setup"
3471
+ err "$(msg slides_build.slides_toolchain_missing_re_run_roll)"
3467
3472
  return 1
3468
3473
  fi
3469
3474
 
@@ -3480,8 +3485,10 @@ cmd_slides_build() {
3480
3485
  local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
3481
3486
  mkdir -p ".roll/slides/${slug}"
3482
3487
  printf '[%s] stage=validate\n%s\n' "$ts" "$val_out" > "$err_file"
3483
- err "deck.md validation failed fix the issues above before building."
3484
- echo " deck.md 校验失败,请先修复上方提示再重试。" >&2
3488
+ # Print validator output so user can see what failed.
3489
+ printf '%s\n' "$val_out" >&2
3490
+ echo -e "${RED}[FAIL]${NC} $(msg slides_build.validation_failed_for "${deck}")" >&2
3491
+ echo " $(msg slides_build.hint_fix_and_rerun "${deck}" "${slug}")" >&2
3485
3492
  return 1
3486
3493
  fi
3487
3494
 
@@ -3492,7 +3499,26 @@ cmd_slides_build() {
3492
3499
  local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
3493
3500
  mkdir -p ".roll/slides/${slug}"
3494
3501
  printf '[%s] stage=template\ntemplate not found: %s\n' "$ts" "$tpl_name" > "$err_file"
3495
- err "Template not found: ${tpl_name} 未找到模板:${tpl_name}"
3502
+ echo -e "${RED}[FAIL]${NC} $(msg slides_build.template_not_found "${tpl_name}")" >&2
3503
+ # List available templates (built-in + project overrides).
3504
+ echo " $(msg slides_build.available_templates)" >&2
3505
+ local builtin_dir="${ROLL_PKG_DIR}/lib/slides/templates"
3506
+ if [[ -d "$builtin_dir" ]]; then
3507
+ local t
3508
+ for t in "$builtin_dir"/*.html; do
3509
+ local n="${t##*/}"; n="${n%.html}"
3510
+ printf ' %-20s (builtin)\n' "$n" >&2
3511
+ done
3512
+ fi
3513
+ local proj_dir=".roll/slides/templates"
3514
+ if [[ -d "$proj_dir" ]]; then
3515
+ local t
3516
+ for t in "$proj_dir"/*.html; do
3517
+ local n="${t##*/}"; n="${n%.html}"
3518
+ printf ' %-20s (project)\n' "$n" >&2
3519
+ done
3520
+ fi
3521
+ echo " $(msg slides_build.templates_list_hint)" >&2
3496
3522
  return 1
3497
3523
  fi
3498
3524
 
@@ -3502,7 +3528,13 @@ cmd_slides_build() {
3502
3528
  local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
3503
3529
  mkdir -p ".roll/slides/${slug}"
3504
3530
  printf '[%s] stage=render\n%s\n' "$ts" "$render_out" > "$err_file"
3505
- err "Render failed for ${deck} 渲染失败:${deck}"
3531
+ echo -e "${RED}[FAIL]${NC} $(msg slides_build.renderer_crashed_for "${deck}")" >&2
3532
+ echo " $(msg slides_build.see_full_error_logs "${slug}")" >&2
3533
+ local last_lines; last_lines=$(printf '%s' "$render_out" | tail -n 5)
3534
+ if [[ -n "$last_lines" ]]; then
3535
+ echo " $(msg slides_build.last_5_lines_of_renderer_output)" >&2
3536
+ printf '%s\n' "$last_lines" >&2
3537
+ fi
3506
3538
  return 1
3507
3539
  }
3508
3540
 
@@ -3512,7 +3544,7 @@ cmd_slides_build() {
3512
3544
  # 3. Default-ignore the HTML artefact so it doesn't accidentally get committed.
3513
3545
  _slides_ensure_gitignore
3514
3546
 
3515
- ok "Rendered → ${out} 渲染完成 ${out}"
3547
+ ok "$(msg slides_build.rendered ${out})"
3516
3548
 
3517
3549
  # 4. Auto-open browser unless suppressed (or running inside bats tests).
3518
3550
  if [[ "$no_open" -eq 1 ]] || [[ -n "${BATS_TEST_NUMBER:-}" ]] || [[ -n "${ROLL_SLIDES_NO_OPEN:-}" ]]; then
@@ -3563,16 +3595,16 @@ cmd_slides_list() {
3563
3595
  while [[ $# -gt 0 ]]; do
3564
3596
  case "$1" in
3565
3597
  --help|-h) _slides_help; return 0 ;;
3566
- --*) err "Unknown option: $1 未知选项: $1"; return 1 ;;
3567
- *) err "Unexpected argument: $1 多余参数: $1"; return 1 ;;
3598
+ --*) err "$(msg slides_list.unknown_option_1)"; return 1 ;;
3599
+ *) err "$(msg slides_list.unexpected_argument_1)"; return 1 ;;
3568
3600
  esac
3569
3601
  done
3570
3602
 
3571
3603
  local slides_dir=".roll/slides"
3572
3604
  if [[ ! -d "$slides_dir" ]]; then
3573
- info "No decks found under .roll/slides/ 无幻灯片"
3605
+ info "$(msg slides_list.no_decks_found_under_roll_slides)"
3574
3606
  echo " Hint: run 'roll slides new \"<topic>\"' to create one."
3575
- echo " 提示:运行 'roll slides new \"<主题>\"' 创建第一个幻灯片。"
3607
+ echo "$(msg slides_list.en_roll_slides_new)"
3576
3608
  return 0
3577
3609
  fi
3578
3610
 
@@ -3589,9 +3621,9 @@ cmd_slides_list() {
3589
3621
  shopt -u nullglob
3590
3622
 
3591
3623
  if [[ "${#slugs[@]}" -eq 0 ]]; then
3592
- info "No decks found under .roll/slides/ 无幻灯片"
3624
+ info "$(msg slides_list.no_decks_found_under_roll_slides_2)"
3593
3625
  echo " Hint: run 'roll slides new \"<topic>\"' to create one."
3594
- echo " 提示:运行 'roll slides new \"<主题>\"' 创建第一个幻灯片。"
3626
+ echo "$(msg slides_list.en_roll_slides_new_2)"
3595
3627
  return 0
3596
3628
  fi
3597
3629
 
@@ -3646,12 +3678,12 @@ cmd_slides_preview() {
3646
3678
  case "$1" in
3647
3679
  --no-open) no_open=1; shift ;;
3648
3680
  --help|-h) _slides_help; return 0 ;;
3649
- --*) err "Unknown option: $1 未知选项: $1"; return 1 ;;
3681
+ --*) err "$(msg slides_preview.unknown_option_1)"; return 1 ;;
3650
3682
  *)
3651
3683
  if [[ -z "$slug" ]]; then
3652
3684
  slug="$1"; shift
3653
3685
  else
3654
- err "Unexpected argument: $1 多余参数: $1"; return 1
3686
+ err "$(msg slides_preview.unexpected_argument_1)"; return 1
3655
3687
  fi
3656
3688
  ;;
3657
3689
  esac
@@ -3659,20 +3691,20 @@ cmd_slides_preview() {
3659
3691
 
3660
3692
  if [[ -z "$slug" ]]; then
3661
3693
  err "Usage: roll slides preview <slug> [--no-open]"
3662
- echo "用法: roll slides preview <slug> [--no-open]" >&2
3694
+ echo "$(msg slides_preview.usage_roll_slides_preview_slug_no)" >&2
3663
3695
  return 1
3664
3696
  fi
3665
3697
 
3666
3698
  local html=".roll/slides/${slug}.html"
3667
3699
  if [[ ! -f "$html" ]]; then
3668
3700
  err "Rendered HTML not found: ${html}"
3669
- echo " 未找到已渲染的 HTML:${html}" >&2
3701
+ echo "$(msg slides_preview.en_html ${html})" >&2
3670
3702
  echo " Hint: run 'roll slides build ${slug}' first to render it." >&2
3671
- echo " 提示:先运行 'roll slides build ${slug}' 渲染幻灯片。" >&2
3703
+ echo "$(msg slides_preview.en_roll_slides_build ${slug})" >&2
3672
3704
  return 1
3673
3705
  fi
3674
3706
 
3675
- ok "Preview → ${html} 打开预览 ${html}"
3707
+ ok "$(msg slides_preview.preview ${html})"
3676
3708
 
3677
3709
  if [[ "$no_open" -eq 1 ]] || [[ -n "${BATS_TEST_NUMBER:-}" ]] || [[ -n "${ROLL_SLIDES_NO_OPEN:-}" ]]; then
3678
3710
  return 0
@@ -3690,12 +3722,12 @@ cmd_slides_logs() {
3690
3722
  while [[ $# -gt 0 ]]; do
3691
3723
  case "$1" in
3692
3724
  --help|-h) _slides_help; return 0 ;;
3693
- --*) err "Unknown option: $1 未知选项: $1"; return 1 ;;
3725
+ --*) err "$(msg slides_logs.unknown_option_1)"; return 1 ;;
3694
3726
  *)
3695
3727
  if [[ -z "$slug" ]]; then
3696
3728
  slug="$1"; shift
3697
3729
  else
3698
- err "Unexpected argument: $1 多余参数: $1"; return 1
3730
+ err "$(msg slides_logs.unexpected_argument_1)"; return 1
3699
3731
  fi
3700
3732
  ;;
3701
3733
  esac
@@ -3703,7 +3735,7 @@ cmd_slides_logs() {
3703
3735
 
3704
3736
  if [[ -z "$slug" ]]; then
3705
3737
  err "Usage: roll slides logs <slug>"
3706
- echo "用法: roll slides logs <slug>" >&2
3738
+ echo "$(msg slides_logs.usage_roll_slides_logs_slug)" >&2
3707
3739
  return 1
3708
3740
  fi
3709
3741
 
@@ -3711,12 +3743,12 @@ cmd_slides_logs() {
3711
3743
  local err_file="${deck_dir}/.last-build.err"
3712
3744
 
3713
3745
  if [[ ! -d "$deck_dir" ]] || [[ ! -f "${deck_dir}/deck.md" ]]; then
3714
- err "Deck not found: ${slug} 未找到幻灯片:${slug}"
3746
+ err "$(msg slides_logs.deck_not_found ${slug})"
3715
3747
  return 1
3716
3748
  fi
3717
3749
 
3718
3750
  if [[ ! -f "$err_file" ]]; then
3719
- info "No failure records for ${slug} 该幻灯片没有失败记录"
3751
+ info "$(msg slides_logs.no_failure_records_for ${slug})"
3720
3752
  return 0
3721
3753
  fi
3722
3754
 
@@ -3724,6 +3756,114 @@ cmd_slides_logs() {
3724
3756
  return 0
3725
3757
  }
3726
3758
 
3759
+ # ─── US-DECK-014 ─────────────────────────────────────────────────────────────
3760
+ cmd_slides_delete() {
3761
+ local slug="" force=0
3762
+ while [[ $# -gt 0 ]]; do
3763
+ case "$1" in
3764
+ --force) force=1; shift ;;
3765
+ --help|-h) _slides_help; return 0 ;;
3766
+ --*) err "$(msg slides_delete.unknown_option_1)"; return 1 ;;
3767
+ *)
3768
+ if [[ -z "$slug" ]]; then
3769
+ slug="$1"; shift
3770
+ else
3771
+ err "$(msg slides_delete.unexpected_argument_1)"; return 1
3772
+ fi
3773
+ ;;
3774
+ esac
3775
+ done
3776
+
3777
+ if [[ -z "$slug" ]]; then
3778
+ err "Usage: roll slides delete <slug> [--force]"
3779
+ echo "$(msg slides_delete.usage_roll_slides_delete_slug_force)" >&2
3780
+ return 1
3781
+ fi
3782
+
3783
+ local deck_dir=".roll/slides/${slug}"
3784
+ local html=".roll/slides/${slug}.html"
3785
+
3786
+ if [[ ! -d "$deck_dir" ]] || [[ ! -f "${deck_dir}/deck.md" ]]; then
3787
+ err "$(msg slides_delete.deck_not_found ${slug})"
3788
+ return 1
3789
+ fi
3790
+
3791
+ # Non-TTY must use --force (skip interactive confirmation)
3792
+ if [[ $force -eq 0 ]]; then
3793
+ if [[ ! -t 0 ]]; then
3794
+ err "$(msg slides_delete.non_interactive_terminal_must_use_force)"
3795
+ return 1
3796
+ fi
3797
+ printf '%s ' "$(msg slides_delete.prompt "$slug")" >&2
3798
+ read -r answer
3799
+ case "$answer" in
3800
+ [yY]|[yY][eE][sS]) : ;;
3801
+ *) info "$(msg slides_delete.cancelled)"; return 0 ;;
3802
+ esac
3803
+ fi
3804
+
3805
+ # Remove deck directory and HTML file
3806
+ rm -rf "$deck_dir" 2>/dev/null || true
3807
+ rm -f "$html" 2>/dev/null || true
3808
+ ok "$(msg slides_delete.deleted ${slug})"
3809
+ return 0
3810
+ }
3811
+
3812
+ # ─── US-DECK-014 ─────────────────────────────────────────────────────────────
3813
+ cmd_slides_templates() {
3814
+ while [[ $# -gt 0 ]]; do
3815
+ case "$1" in
3816
+ --help|-h) _slides_help; return 0 ;;
3817
+ --*) err "$(msg slides_templates.unknown_option_1)"; return 1 ;;
3818
+ *) err "$(msg slides_templates.unexpected_argument_1)"; return 1 ;;
3819
+ esac
3820
+ done
3821
+
3822
+ local seen=""
3823
+ local found=0
3824
+ local name base path source
3825
+
3826
+ printf '%-24s %-12s %s\n' "name" "source" "path"
3827
+ printf '%-24s %-12s %s\n' "----" "------" "----"
3828
+
3829
+ # Built-in templates (shipped with roll package)
3830
+ local builtin_dir="${ROLL_PKG_DIR}/lib/slides/templates"
3831
+ if [[ -d "$builtin_dir" ]]; then
3832
+ shopt -s nullglob
3833
+ for tpl in "$builtin_dir"/*.html; do
3834
+ name="${tpl##*/}"
3835
+ name="${name%.html}"
3836
+ printf '%-24s %-12s %s\n' "$name" "builtin" "$tpl"
3837
+ found=1
3838
+ done
3839
+ shopt -u nullglob
3840
+ fi
3841
+
3842
+ # Project-level overrides (.roll/slides/templates/)
3843
+ local proj_dir=".roll/slides/templates"
3844
+ if [[ -d "$proj_dir" ]]; then
3845
+ shopt -s nullglob
3846
+ for tpl in "$proj_dir"/*.html; do
3847
+ name="${tpl##*/}"
3848
+ name="${name%.html}"
3849
+ # Mark as project override if same name exists in builtin
3850
+ if [[ -f "${builtin_dir}/${name}.html" ]]; then
3851
+ source="project (override)"
3852
+ else
3853
+ source="project"
3854
+ fi
3855
+ printf '%-24s %-12s %s\n' "$name" "$source" "$tpl"
3856
+ found=1
3857
+ done
3858
+ shopt -u nullglob
3859
+ fi
3860
+
3861
+ if [[ $found -eq 0 ]]; then
3862
+ info "$(msg slides_templates.no_templates_found)"
3863
+ fi
3864
+ return 0
3865
+ }
3866
+
3727
3867
  # ─── US-DECK-004 ─────────────────────────────────────────────────────────────
3728
3868
  # Turn a topic string into a kebab-case slug.
3729
3869
  # Lower-cases, replaces any run of non-alphanumerics with a single dash,
@@ -3750,18 +3890,18 @@ cmd_slides_new() {
3750
3890
  while [[ $# -gt 0 ]]; do
3751
3891
  case "$1" in
3752
3892
  --template)
3753
- [[ -n "${2:-}" ]] || { err "--template requires a value --template 需要一个值"; return 1; }
3893
+ [[ -n "${2:-}" ]] || { err "$(msg slides_new.template_requires_value)"; return 1; }
3754
3894
  template="$2"; shift 2 ;;
3755
3895
  --template=*) template="${1#--template=}"; shift ;;
3756
3896
  --quiet) quiet=1; shift ;;
3757
3897
  --no-build) no_build=1; shift ;;
3758
3898
  --help|-h) _slides_help; return 0 ;;
3759
- --*) err "Unknown option: $1 未知选项: $1"; return 1 ;;
3899
+ --*) err "$(msg slides_new.unknown_option_1)"; return 1 ;;
3760
3900
  *)
3761
3901
  if [[ -z "$topic" ]]; then
3762
3902
  topic="$1"; shift
3763
3903
  else
3764
- err "Unexpected argument: $1 多余参数: $1"; return 1
3904
+ err "$(msg slides_new.unexpected_argument_1)"; return 1
3765
3905
  fi
3766
3906
  ;;
3767
3907
  esac
@@ -3769,14 +3909,14 @@ cmd_slides_new() {
3769
3909
 
3770
3910
  if [[ -z "$topic" ]]; then
3771
3911
  err "Usage: roll slides new \"<topic>\" [--template <name>] [--quiet] [--no-build]"
3772
- echo " 用法:roll slides new \"<主题>\" [--template <模板名>] [--quiet] [--no-build]" >&2
3912
+ echo "$(msg slides_new.en_roll_slides_new_template)" >&2
3773
3913
  return 1
3774
3914
  fi
3775
3915
 
3776
3916
  local slug; slug=$(_slides_topic_slug "$topic")
3777
3917
  if [[ -z "$slug" ]]; then
3778
3918
  err "Could not derive a slug from topic: $topic"
3779
- echo " 无法从主题派生 slug:$topic" >&2
3919
+ echo "$(msg slides_new.en_slug $topic)" >&2
3780
3920
  return 1
3781
3921
  fi
3782
3922
 
@@ -3898,7 +4038,7 @@ EOF
3898
4038
  # Print Next hint (unless --quiet flag was explicitly passed)
3899
4039
  echo
3900
4040
  echo "Next: roll slides build ${slug}"
3901
- echo "下一步:roll slides build ${slug}"
4041
+ echo "$(msg slides_new.en_roll_slides_build ${slug})"
3902
4042
 
3903
4043
  return "$rc"
3904
4044
  }
@@ -3961,7 +4101,7 @@ cmd_prices_refresh() {
3961
4101
  case "$1" in
3962
4102
  --url) url="$2"; shift 2 ;;
3963
4103
  --vendor) vendor="$2"; shift 2 ;;
3964
- *) err "Unknown flag: $1 未知参数: $1"; return 1 ;;
4104
+ *) err "$(msg prices_refresh.unknown_flag_1)"; return 1 ;;
3965
4105
  esac
3966
4106
  done
3967
4107
  local lib_dir="${ROLL_PKG_DIR}/lib"
@@ -3985,8 +4125,8 @@ VENDOR_URLS = {
3985
4125
  if vendor:
3986
4126
  default_url = VENDOR_URLS.get(vendor)
3987
4127
  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)
4128
+ print(f"$(msg prices_refresh.roll_unknown_vendor_vendor)", file=sys.stderr)
4129
+ print(f"$(msg prices_refresh.roll_known_vendors_join_sorted_vendor)", file=sys.stderr)
3990
4130
  sys.exit(1)
3991
4131
  url = override_url or default_url
3992
4132
  pf.DEFAULT_SOURCE_URL = url
@@ -4026,7 +4166,7 @@ cmd_prices() {
4026
4166
  refresh) cmd_prices_refresh "$@" ;;
4027
4167
  --help|-h|help|"") _prices_help ;;
4028
4168
  *)
4029
- err "Unknown subcommand: ${subcmd} 未知子命令:${subcmd}"
4169
+ err "$(msg prices.unknown_subcommand ${subcmd})"
4030
4170
  _prices_help >&2
4031
4171
  return 1
4032
4172
  ;;
@@ -4057,7 +4197,7 @@ Read-only — never edits CHANGELOG. Use to catch drift before release.
4057
4197
  EOF
4058
4198
  ;;
4059
4199
  *)
4060
- err "Unknown subcommand: ${subcmd} 未知子命令:${subcmd}"
4200
+ err "$(msg changelog.unknown_subcommand ${subcmd})"
4061
4201
  err "Try: roll changelog audit"
4062
4202
  return 1
4063
4203
  ;;
@@ -4083,6 +4223,12 @@ cmd_slides() {
4083
4223
  logs)
4084
4224
  cmd_slides_logs "$@"
4085
4225
  ;;
4226
+ templates)
4227
+ cmd_slides_templates "$@"
4228
+ ;;
4229
+ delete)
4230
+ cmd_slides_delete "$@"
4231
+ ;;
4086
4232
  --help|-h|help)
4087
4233
  _slides_help
4088
4234
  return 0
@@ -4092,7 +4238,7 @@ cmd_slides() {
4092
4238
  return 1
4093
4239
  ;;
4094
4240
  *)
4095
- err "Unknown subcommand: ${subcmd} 未知子命令:${subcmd}"
4241
+ err "$(msg slides.unknown_subcommand ${subcmd})"
4096
4242
  _slides_help >&2
4097
4243
  return 1
4098
4244
  ;;
@@ -4201,7 +4347,7 @@ cmd_agent() {
4201
4347
  use)
4202
4348
  local name="${1:-}"
4203
4349
  [[ -z "$name" ]] && { err "Usage: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"; exit 1; }
4204
- command -v "$name" &>/dev/null || warn "${name} not found in PATH — setting anyway 未找到,仍写入配置"
4350
+ command -v "$name" &>/dev/null || warn "$(msg agent.not_found_in_path_setting_anyway "$name")"
4205
4351
  # REFACTOR-040: write to .roll/local.yaml (per-machine state). Migrate
4206
4352
  # from legacy .roll.yaml in the project root on the spot — copy the
4207
4353
  # value over once, then delete the old file so the root stays clean.
@@ -4222,17 +4368,17 @@ cmd_agent() {
4222
4368
  mv "$tmp" .roll.yaml
4223
4369
  [[ -s ".roll.yaml" ]] || rm -f .roll.yaml
4224
4370
  fi
4225
- ok "Agent set to ${name} for this project 当前项目 agent 已设为 ${name}"
4371
+ ok "$(msg agent.agent_set_to_for_this_project ${name})"
4226
4372
  local project_path; project_path=$(pwd -P)
4227
4373
  local slug; slug=$(_project_slug "$project_path")
4228
4374
  local runner="${_SHARED_ROOT}/loop/run-${slug}.sh"
4229
4375
  if [[ -f "$runner" ]]; then
4230
4376
  _install_launchd_plists "$project_path" >/dev/null
4231
- ok "Loop runner scripts regenerated for new agent 已为新 agent 重新生成 loop 脚本"
4377
+ ok "$(msg agent.loop_runner_scripts_regenerated_for_new)"
4232
4378
  fi
4233
4379
  ;;
4234
4380
  list)
4235
- echo ""; echo " Available agents 可用 agent:"; echo ""
4381
+ echo ""; echo " $(msg agent.available_agents)"; echo ""
4236
4382
  local current; current=$(_project_agent)
4237
4383
  for a in claude kimi deepseek opencode codex pi; do
4238
4384
  if command -v "$a" &>/dev/null; then
@@ -4290,6 +4436,47 @@ _project_slug() {
4290
4436
  if [[ -n "$_common" && "$_common" == *"/.git" ]]; then
4291
4437
  path="${_common%/.git}"
4292
4438
  fi
4439
+
4440
+ # US-OBS-010: derive slug from git remote URL for stable cross-machine
4441
+ # identity. Normalize: strip .git, git@HOST:PATH → https://HOST/PATH,
4442
+ # lowercase. Fallback chain: origin → first available remote → path-based.
4443
+ local remote_url
4444
+ remote_url=$(git -C "$path" remote get-url origin 2>/dev/null)
4445
+ if [[ -z "$remote_url" ]]; then
4446
+ local first_remote
4447
+ first_remote=$(git -C "$path" remote 2>/dev/null | head -1)
4448
+ if [[ -n "$first_remote" ]]; then
4449
+ remote_url=$(git -C "$path" remote get-url "$first_remote" 2>/dev/null)
4450
+ fi
4451
+ fi
4452
+
4453
+ if [[ -n "$remote_url" ]]; then
4454
+ remote_url="${remote_url%.git}"
4455
+ if [[ "$remote_url" =~ ^git@([^:]+):(.+)$ ]]; then
4456
+ remote_url="https://${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
4457
+ fi
4458
+ remote_url=$(printf '%s' "$remote_url" | tr '[:upper:]' '[:lower:]')
4459
+ local base; base=$(basename "$remote_url")
4460
+ local hash
4461
+ if command -v md5 &>/dev/null; then
4462
+ hash=$(printf '%s' "$remote_url" | md5 | cut -c1-6)
4463
+ else
4464
+ hash=$(printf '%s' "$remote_url" | md5sum | cut -c1-6)
4465
+ fi
4466
+ base=$(printf '%s' "$base" | tr -cs '[:alnum:]' '-' | sed 's/-*$//')
4467
+ printf '%s' "${base}-${hash}"
4468
+ return 0
4469
+ fi
4470
+
4471
+ # No remote available — fall back to path-based slug.
4472
+ # If roll_records_remote is configured, warn the user: the slug won't be
4473
+ # stable across machines, so cross-machine sync cannot work.
4474
+ local records_remote
4475
+ records_remote=$(config_get "roll_records_remote" "")
4476
+ if [[ -n "$records_remote" ]]; then
4477
+ printf 'roll: WARNING — roll_records_remote is configured but no git remote URL found; slug will fall back to path-based (cross-machine merge will not work)\n' >&2
4478
+ fi
4479
+
4293
4480
  local base; base=$(basename "$path")
4294
4481
  local hash
4295
4482
  if command -v md5 &>/dev/null; then
@@ -4389,6 +4576,144 @@ PYEOF
4389
4576
  rm -f "${loop_dir}/run-${old_slug}.sh" "${loop_dir}/run-${old_slug}-inner.sh"
4390
4577
  }
4391
4578
 
4579
+ # US-OBS-010: path-based slug (without remote URL) — used to detect the
4580
+ # pre-remote slug so migration can merge old records into the new identity.
4581
+ _project_slug_path_based() {
4582
+ local path="${1:-$(pwd -P 2>/dev/null || pwd)}"
4583
+ if [[ "$(uname -s 2>/dev/null)" == "Darwin" ]]; then
4584
+ local _canon
4585
+ _canon=$(realpath "$path" 2>/dev/null) && path="$_canon"
4586
+ fi
4587
+ local _common
4588
+ _common=$(git -C "$path" rev-parse --git-common-dir 2>/dev/null)
4589
+ if [[ -n "$_common" && "$_common" == *"/.git" ]]; then
4590
+ path="${_common%/.git}"
4591
+ fi
4592
+ local base; base=$(basename "$path")
4593
+ local hash
4594
+ if command -v md5 &>/dev/null; then
4595
+ hash=$(printf '%s' "$path" | md5 | cut -c1-6)
4596
+ else
4597
+ hash=$(printf '%s' "$path" | md5sum | cut -c1-6)
4598
+ fi
4599
+ base=$(printf '%s' "$base" | tr -cs '[:alnum:]' '-' | sed 's/-*$//')
4600
+ printf '%s' "${base}-${hash}"
4601
+ }
4602
+
4603
+ # US-OBS-010: migrate loop records from old path-based slug to new
4604
+ # remote-based slug. Dedup by run_id; atomic cp→tmp→mv; keep old as .bak.
4605
+ #
4606
+ # Usage: _slug_migrate_to_remote <project_path> [<loop_dir>]
4607
+ _slug_migrate_to_remote() {
4608
+ local project_path="$1"
4609
+ local loop_dir="${2:-${_SHARED_ROOT}/loop}"
4610
+
4611
+ local new_slug; new_slug=$(_project_slug "$project_path")
4612
+ local old_slug; old_slug=$(_project_slug_path_based "$project_path")
4613
+
4614
+ # Dedup guard: no migration needed
4615
+ [[ "$old_slug" == "$new_slug" ]] && return 0
4616
+ [[ -f "${loop_dir}/events-${old_slug}.ndjson" ]] || return 0
4617
+
4618
+ printf 'roll: migrating loop records %s → %s (cross-machine slug)\n' "$old_slug" "$new_slug" >&2
4619
+
4620
+ # Migrate events with run_id dedup + atomic write
4621
+ local events_file="${loop_dir}/events-${new_slug}.ndjson"
4622
+ local tmp_events; tmp_events=$(mktemp)
4623
+
4624
+ # Collect existing run_ids from new slug file
4625
+ local existing_ids=""
4626
+ if [[ -f "$events_file" ]]; then
4627
+ existing_ids=$(python3 -c "
4628
+ import json, sys
4629
+ try:
4630
+ with open('$events_file') as f:
4631
+ for line in f:
4632
+ d = json.loads(line.strip())
4633
+ if 'label' in d:
4634
+ print(d['label'])
4635
+ except: pass
4636
+ " 2>/dev/null)
4637
+ fi
4638
+
4639
+ # Append old events to new, dedup by label (run_id / cycle_id)
4640
+ python3 - "$old_slug" "$events_file" "$tmp_events" << 'PYEOF'
4641
+ import json, sys
4642
+ old_slug, new_file, tmp_file = sys.argv[1], sys.argv[2], sys.argv[3]
4643
+
4644
+ # Read existing new-file ids
4645
+ seen = set()
4646
+ try:
4647
+ with open(new_file) as f:
4648
+ for line in f:
4649
+ d = json.loads(line.strip())
4650
+ if 'label' in d:
4651
+ seen.add(d['label'])
4652
+ except FileNotFoundError:
4653
+ pass
4654
+
4655
+ # Append old events, deduped
4656
+ old_file = new_file.replace(new_file.split('-')[-1].split('.')[0], old_slug)
4657
+ with open(tmp_file, 'w') as out:
4658
+ # Copy existing new file
4659
+ try:
4660
+ with open(new_file) as f:
4661
+ out.write(f.read())
4662
+ except FileNotFoundError:
4663
+ pass
4664
+ # Append old events not yet seen
4665
+ old_path = '/'.join(new_file.rsplit('/', 1)[:-1] + [f'events-{old_slug}.ndjson'])
4666
+ try:
4667
+ with open(old_path) as f:
4668
+ for line in f:
4669
+ line = line.strip()
4670
+ if not line:
4671
+ continue
4672
+ d = json.loads(line)
4673
+ lid = d.get('label', '')
4674
+ if lid not in seen:
4675
+ seen.add(lid)
4676
+ out.write(json.dumps(d) + '\n')
4677
+ except FileNotFoundError:
4678
+ pass
4679
+ PYEOF
4680
+
4681
+ # Atomic replace
4682
+ mv "$tmp_events" "$events_file"
4683
+
4684
+ # Keep old events as .bak (do not delete)
4685
+ cp "${loop_dir}/events-${old_slug}.ndjson" "${loop_dir}/events-${old_slug}.ndjson.bak"
4686
+ rm "${loop_dir}/events-${old_slug}.ndjson"
4687
+
4688
+ # Migrate runs.jsonl: rewrite project field + dedup by run_id
4689
+ local runs_file="${loop_dir}/runs.jsonl"
4690
+ if [[ -f "$runs_file" ]]; then
4691
+ local tmp; tmp=$(mktemp)
4692
+ python3 - "$old_slug" "$new_slug" "$runs_file" > "$tmp" << 'PYEOF'
4693
+ import json, sys
4694
+ old, new, path = sys.argv[1], sys.argv[2], sys.argv[3]
4695
+ seen = set()
4696
+ with open(path) as f:
4697
+ for line in f:
4698
+ line = line.rstrip('\n')
4699
+ if not line:
4700
+ continue
4701
+ try:
4702
+ d = json.loads(line)
4703
+ if 'project' in d and old in str(d['project']):
4704
+ d['project'] = str(d['project']).replace(old, new)
4705
+ rid = d.get('run_id', '')
4706
+ if rid in seen:
4707
+ continue
4708
+ seen.add(rid)
4709
+ print(json.dumps(d))
4710
+ except Exception:
4711
+ print(line)
4712
+ PYEOF
4713
+ mv "$tmp" "$runs_file"
4714
+ fi
4715
+ }
4716
+
4392
4717
  _LOOP_TAG="# roll-loop"
4393
4718
  # FIX-065: when sourced in a test context with no explicit override, route
4394
4719
  # shared state into a per-process /tmp path instead of falling back to
@@ -4502,6 +4827,98 @@ _loop_derive_minute() {
4502
4827
  echo $(( (hash_dec + offset) % 55 + 1 ))
4503
4828
  }
4504
4829
 
4830
+ # US-LOOP-011: validate a (period, offset) pair against the allowed schedule spec.
4831
+ # Allowed periods are the divisors of 60: 60/30/20/15/12/10/6/5.
4832
+ # Offset must be within [0, period).
4833
+ _loop_schedule_valid() {
4834
+ local period="$1" offset="$2"
4835
+ case "$period" in
4836
+ 60|30|20|15|12|10|6|5) ;;
4837
+ *) return 1 ;;
4838
+ esac
4839
+ [[ "$offset" =~ ^[0-9]+$ ]] || return 1
4840
+ if (( offset >= period )); then return 1; fi
4841
+ return 0
4842
+ }
4843
+
4844
+ # US-LOOP-011: compute the loop schedule spec for a project.
4845
+ # Resolution order:
4846
+ # 1. .roll/local.yaml loop_schedule.{period_minutes,offset_minute}
4847
+ # 2. ~/.roll/config.yaml loop_minute → period=60, offset=loop_minute
4848
+ # 3. default period=60, offset=hash(project_path)%60
4849
+ # Output: "<period> <offset>" on stdout. Exit 0 on success.
4850
+ # Invalid project config → fallback to global/default + write ALERT.
4851
+ _loop_schedule_spec() {
4852
+ local project_path="$1"
4853
+
4854
+ # 1. Try project-level .roll/local.yaml
4855
+ local local_file="${project_path}/.roll/local.yaml"
4856
+ if [[ -f "$local_file" ]]; then
4857
+ local local_period local_offset
4858
+ # Extract values from under loop_schedule: key (using awk for reliable block parsing)
4859
+ local_period=$(awk '/^loop_schedule:/{found=1;next} found && /^[[:space:]]+period_minutes:/{print $2; exit}' "$local_file")
4860
+ local_offset=$(awk '/^loop_schedule:/{found=1;next} found && /^[[:space:]]+offset_minute:/{print $2; exit}' "$local_file")
4861
+ if [[ -n "$local_period" && -n "$local_offset" ]]; then
4862
+ if _loop_schedule_valid "$local_period" "$local_offset"; then
4863
+ echo "$local_period $local_offset"
4864
+ return 0
4865
+ fi
4866
+ # Invalid: alert, then fall through to global/default
4867
+ local slug; slug=$(_project_slug "$project_path")
4868
+ local alert_file="${_SHARED_ROOT:-$HOME/.shared/roll}/loop/ALERT-${slug}.md"
4869
+ mkdir -p "$(dirname "$alert_file")" 2>/dev/null || true
4870
+ {
4871
+ printf '## ⚠️ US-LOOP-011: Invalid loop_schedule\n\n'
4872
+ printf '**Time**: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')"
4873
+ printf '**Source**: %s\n\n' "${project_path}/.roll/local.yaml"
4874
+ printf '**Values**: period_minutes=%s, offset_minute=%s\n\n' "$local_period" "$local_offset"
4875
+ printf '**Action**: period must be one of 60/30/20/15/12/10/6/5; offset must be 0–(period-1). Falling back to default (period=60).\n\n'
4876
+ printf '%s\n' '---'
4877
+ } >> "$alert_file"
4878
+ fi
4879
+ fi
4880
+
4881
+ # 2. Try global ~/.roll/config.yaml loop_minute (backward compat)
4882
+ local global_minute
4883
+ global_minute=$(_config_read_int "loop_minute" "")
4884
+ if [[ -n "$global_minute" && "$global_minute" =~ ^[0-9]+$ ]]; then
4885
+ echo "60 $global_minute"
4886
+ return 0
4887
+ fi
4888
+
4889
+ # 3. Default: derive from project path hash (never collides across projects)
4890
+ local offset
4891
+ offset=$(_loop_derive_minute "$project_path" 0)
4892
+ echo "60 $offset"
4893
+ }
4894
+
4895
+ # US-LOOP-013: human-readable schedule description for display.
4896
+ # Args: period offset [lang]
4897
+ # lang: en (default) or zh
4898
+ _loop_schedule_desc() {
4899
+ local period="$1" offset="$2" lang="${3:-en}"
4900
+ if [[ "$period" -eq 60 ]]; then
4901
+ if [[ "$lang" == "zh" ]]; then
4902
+ # msg_lang uses the explicit lang param, not ROLL_LANG env; strips trailing
4903
+ # newline via command substitution so callers get a clean string.
4904
+ printf '%s' "$(msg_lang "$lang" agent.hourly_at_02d "$offset")"
4905
+ else
4906
+ printf "every hour :%02d" "$offset"
4907
+ fi
4908
+ return 0
4909
+ fi
4910
+ local times="" slots=$((60 / period)) i m
4911
+ for i in $(seq 0 $((slots - 1))); do
4912
+ m=$((offset + i * period))
4913
+ times="${times} :$(printf '%02d' "$m")"
4914
+ done
4915
+ if [[ "$lang" == "zh" ]]; then
4916
+ printf '%s' "$(msg_lang "$lang" agent.every_d_min_s "$period" "${times# }")"
4917
+ else
4918
+ printf "every %dmin (%s)" "$period" "${times# }"
4919
+ fi
4920
+ }
4921
+
4505
4922
  # US-LOOP-001: structured event emission for cycle observability.
4506
4923
  # Writes a tab-separated line to stdout (for tmux/attach display) and appends
4507
4924
  # a JSON line to the per-project NDJSON event file under _SHARED_ROOT/loop/.
@@ -4542,7 +4959,7 @@ _loop_event() {
4542
4959
  startup) _emoji="🚀" ;;
4543
4960
  preflight) _emoji="🔍" ;;
4544
4961
  worktree_setup) _emoji="🌳" ;;
4545
- claude_invoke) _emoji="🤖" ;;
4962
+ agent_invoke) _emoji="🤖" ;;
4546
4963
  publish_push) _emoji="📤" ;;
4547
4964
  publish_wait_merge) _emoji="⏳" ;;
4548
4965
  cleanup) _emoji="🧹" ;;
@@ -4649,7 +5066,7 @@ _launchd_plist_path() {
4649
5066
 
4650
5067
  _write_launchd_plist() {
4651
5068
  local plist_path="$1" label="$2" project_path="$3"
4652
- local minute="$4" hour="$5" runner_script="$6"
5069
+ local period="$4" offset="$5" hour="$6" runner_script="$7"
4653
5070
 
4654
5071
  # FIX-087 tripwire: last line of defense if some caller explicitly set
4655
5072
  # _LAUNCHD_DIR back to the real path (or built plist_path manually) while
@@ -4676,26 +5093,37 @@ _write_launchd_plist() {
4676
5093
  local path_value; path_value=$(_detect_path_prepend)
4677
5094
 
4678
5095
  # FIX-105: macOS 26.4 launchd silently refuses to fire StartCalendarInterval
4679
- # entries that contain BOTH Hour and Minute keys (verified: runs stays 0,
4680
- # last exit "never exited", no log output, the calendarinterval trigger is
4681
- # registered but never invoked by UserEventAgent-Aqua). Single-Minute (hourly)
4682
- # entries still fire fine. Workaround: when an Hour is provided (daily
4683
- # schedule), emit StartInterval=86400 (24h period) instead. First fire is
4684
- # bootstrap+24h rather than the exact requested wall-clock time — acceptable
4685
- # trade since the alternative was "never fires at all" (dream/brief broken
4686
- # for 4+ days). The Minute/Hour args are still kept in the function signature
4687
- # for callers that may want to filter at runtime, but they no longer steer
4688
- # the plist trigger format for daily schedules.
5096
+ # entries that contain BOTH Hour and Minute keys. Daily services use
5097
+ # StartInterval=86400 instead.
5098
+ # US-LOOP-012: when period < 60 and no hour, generate StartCalendarInterval
5099
+ # <array> with one <dict> per trigger minute.
4689
5100
  local schedule_xml
4690
5101
  if [[ -n "$hour" ]]; then
4691
5102
  schedule_xml=" <key>StartInterval</key>
4692
5103
  <integer>86400</integer>"
4693
- else
5104
+ elif [[ "$period" == "60" ]]; then
4694
5105
  schedule_xml=" <key>StartCalendarInterval</key>
4695
5106
  <dict>
4696
5107
  <key>Minute</key>
4697
- <integer>${minute}</integer>
5108
+ <integer>${offset}</integer>
4698
5109
  </dict>"
5110
+ else
5111
+ # US-LOOP-012: period < 60 → generate array of dicts
5112
+ local entries=$(( 60 / period ))
5113
+ local xml_lines=" <key>StartCalendarInterval</key>
5114
+ <array>"
5115
+ local i m
5116
+ for ((i = 0; i < entries; i++)); do
5117
+ m=$(( offset + i * period ))
5118
+ xml_lines+="
5119
+ <dict>
5120
+ <key>Minute</key>
5121
+ <integer>${m}</integer>
5122
+ </dict>"
5123
+ done
5124
+ xml_lines+="
5125
+ </array>"
5126
+ schedule_xml="$xml_lines"
4699
5127
  fi
4700
5128
 
4701
5129
  local content
@@ -4761,30 +5189,15 @@ _write_loop_runner_script() {
4761
5189
  # US-AUTO-037: strip leading `cd "<path>" && ` (callers like
4762
5190
  # _install_launchd_plists prepend it). The runner now manages cwd itself
4763
5191
  # — pointing at the worktree when isolation succeeds, project_path otherwise.
4764
- local claude_cmd; claude_cmd="${cmd_verbose#cd \"*\" && }"
5192
+ local agent_cmd; agent_cmd="${cmd_verbose#cd \"*\" && }"
4765
5193
  # FIX-048: Claude Code resolves project root from the worktree's .git file to
4766
5194
  # the main repo, placing worktree absolute paths outside its sandbox. Inject
4767
5195
  # --add-dir "$WT" so the worktree directory is explicitly allowed. Only applies
4768
5196
  # to claude (the --output-format stream-json flag is exclusive to claude runs).
4769
- if [[ "$claude_cmd" == *"--output-format stream-json"* ]]; then
4770
- claude_cmd="${claude_cmd/--output-format stream-json/--output-format stream-json --add-dir \"\$WT\"}"
5197
+ if [[ "$agent_cmd" == *"--output-format stream-json"* ]]; then
5198
+ agent_cmd="${agent_cmd/--output-format stream-json/--output-format stream-json --add-dir \"\$WT\"}"
4771
5199
  fi
4772
5200
  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
4788
5201
  cat > "$inner_path" << INNER
4789
5202
  #!/bin/bash -l
4790
5203
  set -o pipefail
@@ -4816,7 +5229,7 @@ printf '%s:%s\n' "\$\$" "\$(date -u +%s)" > "\$INNER_LOCK"
4816
5229
  # to detect stale execution without relying on PID reuse heuristics.
4817
5230
  # US-LOOP-007: heartbeat also emits phase_tick for the current phase so tmux
4818
5231
  # readers see "still alive in <phase>" during long-running silences (e.g.
4819
- # claude_invoke 5-45 min). CURRENT_PHASE is maintained by _phase_begin/_phase_end.
5232
+ # agent_invoke 5-45 min). CURRENT_PHASE is maintained by _phase_begin/_phase_end.
4820
5233
  HEARTBEAT_FILE="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/.heartbeat-${slug}"
4821
5234
  CURRENT_PHASE=""
4822
5235
  # bash 3.2 (macOS /bin/bash) lacks associative arrays — use namespaced
@@ -5056,6 +5469,17 @@ _runs_append "failed" 0 "[]" "\$_phases_t" 2>/dev/null || true
5056
5469
  _phases_t=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_t" ] && _phases_t='{}'
5057
5470
  _runs_append "aborted" 0 "[]" "\$_phases_t" 2>/dev/null || true
5058
5471
  fi
5472
+ # US-LOOP-015: process cycle raw log — strip ANSI, remove CR, rotate
5473
+ if [ -n "\${ROLL_CYCLE_LOG_RAW:-}" ] && [ -f "\$ROLL_CYCLE_LOG_RAW" ]; then
5474
+ _log_dir="${project_path}/.roll/cycle-logs"
5475
+ mkdir -p "\$_log_dir"
5476
+ sed -E 's/\x1b\[[0-9;]*[A-Za-z]//g; s/\r$//' "\$ROLL_CYCLE_LOG_RAW" \
5477
+ > "\${_log_dir}/\${CYCLE_ID}.log" 2>/dev/null || true
5478
+ rm -f "\$ROLL_CYCLE_LOG_RAW"
5479
+ # Rotate: keep newest ROLL_CYCLE_LOG_KEEP (default 50) .log files
5480
+ _keep="\${ROLL_CYCLE_LOG_KEEP:-50}"
5481
+ ( cd "\$_log_dir" && ls -t *.log 2>/dev/null | tail -n +\$((_keep + 1)) | xargs -r rm -f ) 2>/dev/null || true
5482
+ fi
5059
5483
  rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE"
5060
5484
  exit "\$_rc"
5061
5485
  }
@@ -5193,11 +5617,7 @@ export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
5193
5617
  # US-LOOP-010: tell loop-fmt.py which agent is running so it can branch
5194
5618
  # rendering: claude → stream-json parser, others → transparent passthrough.
5195
5619
  export ROLL_LOOP_AGENT="\$(_project_agent)"
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
5620
+ _phase_begin agent_invoke
5201
5621
  for _attempt in 1 2 3; do
5202
5622
  # FIX-068: defensive reset before each attempt — _CYCLE_TIMED_OUT carries
5203
5623
  # the SIGTERM result of the previous attempt and would otherwise force an
@@ -5220,9 +5640,9 @@ for _attempt in 1 2 3; do
5220
5640
  } ) &
5221
5641
  _WATCHDOG_PID=\$!
5222
5642
  if [ -f "\$FMT" ]; then
5223
- ( cd "\$WT" && ${claude_cmd} ) | python3 "\$FMT"
5643
+ ( cd "\$WT" && ${agent_cmd} ) | python3 "\$FMT"
5224
5644
  else
5225
- ( cd "\$WT" && ${claude_cmd} )
5645
+ ( cd "\$WT" && ${agent_cmd} )
5226
5646
  fi
5227
5647
  _exit=\$?
5228
5648
  kill "\$_WATCHDOG_PID" 2>/dev/null
@@ -5235,48 +5655,10 @@ for _attempt in 1 2 3; do
5235
5655
  fi
5236
5656
  done
5237
5657
 
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
-
5276
5658
  if [ "\$_CYCLE_TIMED_OUT" -eq 1 ] || [ "\$_exit" -ne 0 ]; then
5277
- _phase_end claude_invoke fail
5659
+ _phase_end agent_invoke fail
5278
5660
  else
5279
- _phase_end claude_invoke ok
5661
+ _phase_end agent_invoke ok
5280
5662
  fi
5281
5663
 
5282
5664
  # FIX-057: timed out — skip publish; EXIT trap writes cycle_end blocked + ALERT.
@@ -5323,6 +5705,14 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
5323
5705
  if [ "\$_cycle_commits" -eq 0 ]; then
5324
5706
  _worktree_cleanup "\$WT" "\$BRANCH"
5325
5707
  _loop_event idle "\${CYCLE_ID}" "" "" || true
5708
+ # FIX-F (2026-05-25): explicitly write the terminal "idle" cycle_end +
5709
+ # runs row here, otherwise the EXIT trap's catch-all fallback (which
5710
+ # writes "aborted" when _CYCLE_END_WRITTEN is still 0) will reclassify
5711
+ # this successful no-op as a failure on the dashboard.
5712
+ _loop_event cycle_end "\${CYCLE_ID}" "" "idle" || true
5713
+ _CYCLE_END_WRITTEN=1
5714
+ _phases_idle=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_idle" ] && _phases_idle='{}'
5715
+ _runs_append "idle" 0 "[]" "\$_phases_idle" 2>/dev/null || true
5326
5716
  echo "[loop] cycle \${CYCLE_ID}: idle (no new commits); worktree cleaned"
5327
5717
  else
5328
5718
  _is_doc_only=0
@@ -5513,7 +5903,10 @@ if command -v tmux >/dev/null 2>&1; then
5513
5903
  tmux kill-session -t "\$_s" 2>/dev/null || true
5514
5904
  done
5515
5905
  tmux new-session -d -s "\$SESSION" -x 200 -y 50 "bash \"\$INNER_SCRIPT\""
5516
- tmux pipe-pane -t "\$SESSION" "cat >> \"\$LOG\""
5906
+ CYCLE_LOG_RAW="${project_path}/.roll/cycle-logs/.pipe-\$\$.raw"
5907
+ mkdir -p "${project_path}/.roll/cycle-logs"
5908
+ export ROLL_CYCLE_LOG_RAW="\$CYCLE_LOG_RAW"
5909
+ tmux pipe-pane -t "\$SESSION" "tee -a \"\$LOG\" >> \"\$ROLL_CYCLE_LOG_RAW\""
5517
5910
  # Auto-attach popup: when not muted, spawn a Terminal.app window attached
5518
5911
  # to the tmux session so the user can watch the loop work in real time.
5519
5912
  # FIX-054: terminal selection removed — fixed to macOS Terminal.app for
@@ -5525,7 +5918,14 @@ if command -v tmux >/dev/null 2>&1; then
5525
5918
  # Microsoft Teams.app).
5526
5919
  if [ -z "\${ROLL_LOOP_NO_POPUP:-}" ] && [ -z "\${BATS_TEST_NUMBER:-}" ] && [ ! -f "\$HOME/.shared/roll/loop/mute-${slug}" ] && [ "\$(uname)" = "Darwin" ]; then
5527
5920
  _attach_cmd="\$HOME/.shared/roll/loop/attach-\$SESSION.command"
5528
- printf '#!/bin/bash\\nexec tmux attach -t %s\\n' "\$SESSION" > "\$_attach_cmd" 2>/dev/null || true
5921
+ # Drop \`exec\` so the wrapping shell survives \`tmux attach\` exiting,
5922
+ # then \`read\` to hold the Terminal open until the user has had a
5923
+ # chance to scroll back through the cycle's output. Without this the
5924
+ # window closes the instant the tmux session ends (cycle_end kills
5925
+ # the session) and the entire scrollback disappears with it; the
5926
+ # cron-<slug>.log file still has the full transcript as a fallback.
5927
+ printf '#!/bin/bash\\ntmux attach -t %s\\necho\\necho "================================================================"\\necho " cycle ended. log: ~/.shared/roll/loop/cron-%s.log"\\necho " press enter to close this window."\\necho "================================================================"\\nread _\\n' \\
5928
+ "\$SESSION" "${slug}" > "\$_attach_cmd" 2>/dev/null || true
5529
5929
  chmod +x "\$_attach_cmd" 2>/dev/null || true
5530
5930
  open -g -a Terminal "\$_attach_cmd" >/dev/null 2>&1 || true
5531
5931
  fi
@@ -5625,10 +6025,13 @@ _install_launchd_plists() {
5625
6025
  mkdir -p "$_LAUNCHD_DIR"
5626
6026
  mkdir -p "${shared}/loop" "${shared}/dream" "${shared}/brief"
5627
6027
 
5628
- local active_start active_end loop_minute dream_hour dream_minute brief_hour brief_minute
6028
+ local active_start active_end dream_hour dream_minute brief_hour brief_minute loop_period loop_offset
5629
6029
  active_start=$(_config_read_int "loop_active_start" "10")
5630
6030
  active_end=$(_config_read_int "loop_active_end" "18")
5631
- loop_minute=$(_config_read_int "loop_minute" "$(_loop_derive_minute "$project_path" 0)")
6031
+ # US-LOOP-012: use _loop_schedule_spec instead of raw loop_minute
6032
+ local loop_spec; loop_spec=$(_loop_schedule_spec "$project_path")
6033
+ loop_period="${loop_spec%% *}"
6034
+ loop_offset="${loop_spec##* }"
5632
6035
  dream_hour=$(_config_read_int "loop_dream_hour" "3")
5633
6036
  dream_minute=$(_config_read_int "loop_dream_minute" "$(_loop_derive_minute "$project_path" 2)")
5634
6037
  brief_hour=$(_config_read_int "loop_brief_hour" "9")
@@ -5638,7 +6041,8 @@ _install_launchd_plists() {
5638
6041
 
5639
6042
  local services=("loop" "dream" "brief")
5640
6043
  local skill_names=("roll-loop" "roll-.dream" "roll-brief")
5641
- local minutes=("$loop_minute" "$dream_minute" "$brief_minute")
6044
+ local periods=("$loop_period" "60" "60")
6045
+ local offsets=("$loop_offset" "$dream_minute" "$brief_minute")
5642
6046
  local hours=("" "$dream_hour" "$brief_hour")
5643
6047
 
5644
6048
  local updated=0
@@ -5646,10 +6050,14 @@ _install_launchd_plists() {
5646
6050
  # FIX-058: after FIX-056 introduced realpath normalization, the slug for an
5647
6051
  # existing project may have changed. Migrate state before creating new files.
5648
6052
  _slug_migrate_from_legacy "$slug" "${shared}/loop"
6053
+ # US-OBS-010: when slug changed from path-based to remote-based, merge
6054
+ # old records into the new identity (dedup, atomic, .bak backup)
6055
+ _slug_migrate_to_remote "$project_path" "${shared}/loop"
5649
6056
  for i in "${!services[@]}"; do
5650
6057
  local svc="${services[$i]}"
5651
6058
  local skill="${skill_names[$i]}"
5652
- local minute="${minutes[$i]}"
6059
+ local period="${periods[$i]}"
6060
+ local offset="${offsets[$i]}"
5653
6061
  local hour="${hours[$i]}"
5654
6062
  local label; label=$(_launchd_label "$svc" "$project_path")
5655
6063
  local plist; plist=$(_launchd_plist_path "$svc" "$project_path")
@@ -5666,7 +6074,7 @@ _install_launchd_plists() {
5666
6074
 
5667
6075
  local before=""
5668
6076
  [[ -f "$plist" ]] && before=$(cat "$plist")
5669
- _write_launchd_plist "$plist" "$label" "$project_path" "$minute" "$hour" "$runner"
6077
+ _write_launchd_plist "$plist" "$label" "$project_path" "$period" "$offset" "$hour" "$runner"
5670
6078
  local after; after=$(cat "$plist")
5671
6079
  if [[ "$before" != "$after" ]]; then
5672
6080
  updated=$((updated + 1))
@@ -5695,10 +6103,10 @@ _install_launchd_plists() {
5695
6103
  done
5696
6104
 
5697
6105
  if [[ $updated -gt 0 ]]; then
5698
- ok "Launchd plists installed (${updated} updated) LaunchAgents 已安装"
5699
- echo " Run: roll loop on to activate 运行 roll loop on 激活"
6106
+ ok "$(msg agent.launchd_plists_installed_updated_launchagents ${updated})"
6107
+ echo "$(msg agent.run_roll_loop_on_to_activate)"
5700
6108
  else
5701
- ok "Launchd plists up to date LaunchAgents 无需更新"
6109
+ ok "$(msg agent.launchd_plists_up_to_date_launchagents)"
5702
6110
  fi
5703
6111
  }
5704
6112
 
@@ -5735,6 +6143,8 @@ cmd_loop() {
5735
6143
  status) _loop_status "$@" ;;
5736
6144
  monitor) _loop_monitor "${1:-3}" ;;
5737
6145
  runs) _loop_runs "$@" ;;
6146
+ log) _loop_log "$@" ;;
6147
+ story) _loop_story "$@" ;;
5738
6148
  events) _loop_event_log "${1:-20}" ;;
5739
6149
  attach) _loop_attach ;;
5740
6150
  mute) _loop_mute ;;
@@ -5746,7 +6156,39 @@ cmd_loop() {
5746
6156
  enforce-tcr) _loop_enforce_tcr "${1:-}" "${2:-}" ;;
5747
6157
  precheck-ci) _loop_precheck_ci ;;
5748
6158
  branches) _loop_branches "$(pwd -P)" ;;
5749
- *) err "Usage: roll loop <on|off|now|test|status|monitor|runs|events|attach|mute|unmute|pause|resume|reset|notify|enforce-tcr|precheck-ci|branches>"; exit 1 ;;
6159
+ *) cat <<'HELP'
6160
+ Usage: roll loop <on|off|now|test|status|monitor|runs|log|story|events|attach|mute|unmute|pause|resume|reset|notify|enforce-tcr|precheck-ci|branches>
6161
+
6162
+ on Install launchd scheduler (loop + dream + brief)
6163
+ off Remove launchd scheduler
6164
+ now Run one cycle immediately
6165
+ test Quick smoke test (tmux/popup/stream chain)
6166
+ status Show scheduler state and current loop state
6167
+ monitor Live dashboard: launchd status, queue, recent runs
6168
+ runs [N] Show last N run summaries (default 10)
6169
+ log [id] Show per-cycle log (default: latest; optional cycle-id or prefix)
6170
+ story <ID> Show per-story rollup (cycles, duration, tokens, cost, PRs)
6171
+ events [N] Show last N cycle events (default 20)
6172
+ attach Attach to running loop tmux session (Ctrl-B D to detach)
6173
+ mute Suppress auto-attach popup
6174
+ unmute Re-enable auto-attach popup
6175
+ pause Pause scheduling (keep plist, skip execution)
6176
+ resume Resume scheduling after pause
6177
+ reset Clear loop state (start fresh on next fire)
6178
+ notify Send macOS notification
6179
+ enforce-tcr Verify TCR commit count for a completed story
6180
+ precheck-ci Check HEAD CI status before scanning BACKLOG
6181
+ branches List loop-related branches
6182
+
6183
+ Schedule is configured per-project in .roll/local.yaml:
6184
+
6185
+ loop_schedule:
6186
+ period_minutes: 30 # 60, 30, 20, 15, 12, 10, 6, or 5
6187
+ offset_minute: 7 # 0 – (period_minutes - 1)
6188
+
6189
+ See guide/en/loop.md for full documentation.
6190
+ HELP
6191
+ exit 1 ;;
5750
6192
  esac
5751
6193
  }
5752
6194
 
@@ -5757,7 +6199,16 @@ _loop_on() {
5757
6199
  local active_start active_end loop_minute dream_hour dream_minute brief_hour brief_minute
5758
6200
  active_start=$(_config_read_int "loop_active_start" "10")
5759
6201
  active_end=$(_config_read_int "loop_active_end" "18")
5760
- loop_minute=$(_config_read_int "loop_minute" "$(_loop_derive_minute "$project_path" 0)")
6202
+ # US-LOOP-011: read schedule spec from project or global config
6203
+ local loop_spec loop_period loop_offset
6204
+ loop_spec=$(_loop_schedule_spec "$project_path")
6205
+ loop_period="${loop_spec%% *}"
6206
+ loop_offset="${loop_spec##* }"
6207
+ # Keep loop_minute for Linux crontab backward compat (only supports hourly)
6208
+ loop_minute="$loop_offset"
6209
+ local loop_sched_en loop_sched_zh
6210
+ loop_sched_en=$(_loop_schedule_desc "$loop_period" "$loop_offset" en)
6211
+ loop_sched_zh=$(_loop_schedule_desc "$loop_period" "$loop_offset" zh)
5761
6212
  dream_hour=$(_config_read_int "loop_dream_hour" "3")
5762
6213
  dream_minute=$(_config_read_int "loop_dream_minute" "$(_loop_derive_minute "$project_path" 2)")
5763
6214
  brief_hour=$(_config_read_int "loop_brief_hour" "9")
@@ -5789,14 +6240,14 @@ _loop_on() {
5789
6240
  done
5790
6241
 
5791
6242
  if $all_loaded; then
5792
- warn "Loop already enabled for this project 当前项目 loop 已启用"; return 0
6243
+ warn "$(msg loop.loop_already_enabled_for_this_project)"; return 0
5793
6244
  fi
5794
6245
 
5795
- ok "Loop enabled 已启用"
5796
- printf " roll-loop every hour :%02d active %02d:00–%02d:00 每小时 :%02d(窗口 %02d:00–%02d:00)\n" \
5797
- "$loop_minute" "$active_start" "$active_end" "$loop_minute" "$active_start" "$active_end"
5798
- printf " roll-.dream daily at %02d:%02d 每天 %02d:%02d\n" "$dream_hour" "$dream_minute" "$dream_hour" "$dream_minute"
5799
- printf " roll-brief daily at %02d:%02d 每天 %02d:%02d\n" "$brief_hour" "$brief_minute" "$brief_hour" "$brief_minute"
6246
+ ok "$(msg loop.loop_enabled)"
6247
+ printf "$(msg loop.roll_loop_s_active_02d_00)" \
6248
+ "$loop_sched_en" "$active_start" "$active_end" "$loop_sched_zh" "$active_start" "$active_end"
6249
+ printf "$(msg loop.roll_dream_daily_at_02d_02d)" "$dream_hour" "$dream_minute" "$dream_hour" "$dream_minute"
6250
+ printf "$(msg loop.roll_brief_daily_at_02d_02d)" "$brief_hour" "$brief_minute" "$brief_hour" "$brief_minute"
5800
6251
  echo " • Agent: ${agent} (change: roll agent use <name>)"
5801
6252
  return 0
5802
6253
  fi
@@ -5804,7 +6255,7 @@ _loop_on() {
5804
6255
  # Linux: crontab
5805
6256
  local sd="${ROLL_HOME}/skills"
5806
6257
  if crontab -l 2>/dev/null | grep -q "${_LOOP_TAG}:${project_path}"; then
5807
- warn "Loop already enabled for this project 当前项目 loop 已启用"; return 0
6258
+ warn "$(msg loop.loop_already_enabled_for_this_project_2)"; return 0
5808
6259
  fi
5809
6260
 
5810
6261
  mkdir -p "${_SHARED_ROOT}/loop" "${_SHARED_ROOT}/dream" "${_SHARED_ROOT}/brief"
@@ -5823,11 +6274,11 @@ _loop_on() {
5823
6274
  printf "%d %d * * * %s %s:%s\n" "$brief_minute" "$brief_hour" "$brief_cmd" "$_LOOP_TAG" "$project_path"
5824
6275
  ) | crontab -
5825
6276
 
5826
- ok "Loop enabled 已启用"
5827
- printf " roll-loop every hour :%02d active %02d:00–%02d:00 每小时 :%02d(窗口 %02d:00–%02d:00)\n" \
5828
- "$loop_minute" "$active_start" "$active_end" "$loop_minute" "$active_start" "$active_end"
5829
- printf " roll-.dream daily at %02d:%02d 每天 %02d:%02d\n" "$dream_hour" "$dream_minute" "$dream_hour" "$dream_minute"
5830
- printf " roll-brief daily at %02d:%02d 每天 %02d:%02d\n" "$brief_hour" "$brief_minute" "$brief_hour" "$brief_minute"
6277
+ ok "$(msg loop.loop_enabled_2)"
6278
+ printf "$(msg loop.roll_loop_s_active_02d_00_2)" \
6279
+ "$loop_sched_en" "$active_start" "$active_end" "$loop_sched_zh" "$active_start" "$active_end"
6280
+ printf "$(msg loop.roll_dream_daily_at_02d_02d_2)" "$dream_hour" "$dream_minute" "$dream_hour" "$dream_minute"
6281
+ printf "$(msg loop.roll_brief_daily_at_02d_02d_2)" "$brief_hour" "$brief_minute" "$brief_hour" "$brief_minute"
5831
6282
  echo " • Agent: ${agent} (change: roll agent use <name>)"
5832
6283
  }
5833
6284
 
@@ -5848,7 +6299,7 @@ _loop_off() {
5848
6299
  fi
5849
6300
  done
5850
6301
  if ! $any_loaded; then
5851
- warn "Loop not enabled for this project 当前项目 loop 未启用"; return 0
6302
+ warn "$(msg loop.loop_not_enabled_for_this_project)"; return 0
5852
6303
  fi
5853
6304
  local slug; slug=$(_project_slug "$project_path")
5854
6305
  local uid; uid=$(id -u)
@@ -5867,16 +6318,16 @@ _loop_off() {
5867
6318
  [[ "$_skip_off" == "1" ]] && continue
5868
6319
  _launchctl_safe enable "gui/${uid}/${label}" 2>/dev/null || true
5869
6320
  done
5870
- ok "Loop disabled 已停用"
6321
+ ok "$(msg loop.loop_disabled)"
5871
6322
  return 0
5872
6323
  fi
5873
6324
 
5874
6325
  # Linux: crontab
5875
6326
  if ! crontab -l 2>/dev/null | grep -q "${_LOOP_TAG}:${project_path}"; then
5876
- warn "Loop not enabled for this project 当前项目 loop 未启用"; return 0
6327
+ warn "$(msg loop.loop_not_enabled_for_this_project_2)"; return 0
5877
6328
  fi
5878
6329
  crontab -l 2>/dev/null | grep -v "${_LOOP_TAG}:${project_path}" | crontab -
5879
- ok "Loop disabled 已停用"
6330
+ ok "$(msg loop.loop_disabled_2)"
5880
6331
  }
5881
6332
 
5882
6333
  _loop_is_active() {
@@ -5910,9 +6361,9 @@ _loop_now() {
5910
6361
  # but no live signal exists, this trigger is the canonical recovery point.
5911
6362
  if [[ -f "$_LOOP_STATE" ]] && grep -q "status: running" "$_LOOP_STATE" 2>/dev/null; then
5912
6363
  if _loop_is_active "$slug"; then
5913
- warn "Loop already running loop 正在运行中"; return 0
6364
+ warn "$(msg loop.loop_already_running_loop)"; return 0
5914
6365
  fi
5915
- info "Stale running state detected — healing before new cycle 检测到孤儿状态,正在修复..."
6366
+ info "$(msg loop.stale_running_state_detected_healing_before)"
5916
6367
  printf "status: idle\n" > "$_LOOP_STATE"
5917
6368
  rm -f "${_SHARED_ROOT}/loop/.LOCK-${slug}" 2>/dev/null || true
5918
6369
  fi
@@ -5925,7 +6376,7 @@ _loop_now() {
5925
6376
  err "Run 'roll setup' or 'roll loop on' first to generate it."
5926
6377
  return 1
5927
6378
  fi
5928
- info "Starting new loop cycle... 正在启动新的循环..."
6379
+ info "$(msg loop.starting_new_loop_cycle)"
5929
6380
  ROLL_LOOP_FORCE=1 bash "$runner"
5930
6381
  # Reset stale running state if the cycle exited without cleanup (e.g. API error, SIGKILL)
5931
6382
  if [[ -f "$_LOOP_STATE" ]] && grep -q "^status: running" "$_LOOP_STATE" 2>/dev/null; then
@@ -5970,14 +6421,14 @@ _loop_test() {
5970
6421
  active_start=$(_config_read_int "loop_active_start" "10")
5971
6422
  active_end=$(_config_read_int "loop_active_end" "18")
5972
6423
 
5973
- info "Generating test runner (agent: ${agent})... 正在生成测试启动脚本 (agent: ${agent})..."
6424
+ info "$(msg loop.generating_test_runner_agent ${agent})"
5974
6425
  _write_loop_runner_script "$test_runner" "$project_path" \
5975
6426
  "${agent_cmd}" \
5976
6427
  "$log" "$active_start" "$active_end"
5977
6428
 
5978
- info "Starting smoke test (agent: ${agent})... 正在运行 smoke 测试 (agent: ${agent})..."
6429
+ info "$(msg loop.starting_smoke_test_agent ${agent})"
5979
6430
  info "Watch for: tmux session + terminal popup + stream-json events flowing"
5980
- info "观察:tmux 会话 + 终端弹窗 + stream-json 事件流"
6431
+ info "$(msg loop.observing_tmux_session_terminal_popup_stream)"
5981
6432
 
5982
6433
  local start_time; start_time=$(date +%s)
5983
6434
  ROLL_LOOP_FORCE=1 bash "$test_runner"
@@ -5985,9 +6436,9 @@ _loop_test() {
5985
6436
  local elapsed=$(( $(date +%s) - start_time ))
5986
6437
 
5987
6438
  if [[ $exit_code -eq 0 ]]; then
5988
- ok "Smoke test passed (${elapsed}s, agent: ${agent}) smoke 测试通过 (${elapsed}秒, agent: ${agent})"
6439
+ ok "$(msg loop.smoke_test_passed_s_agent_smoke ${elapsed} ${agent})"
5989
6440
  else
5990
- err "Smoke test failed (exit ${exit_code}, ${elapsed}s, agent: ${agent}) smoke 测试失败 (退出码 ${exit_code}, ${elapsed}s, agent: ${agent})"
6441
+ err "$(msg loop.smoke_test_failed_exit_s_agent ${exit_code} ${elapsed} ${agent})"
5991
6442
  return 1
5992
6443
  fi
5993
6444
  }
@@ -6005,6 +6456,25 @@ _loop_status() {
6005
6456
  _legacy_loop_status "$@"
6006
6457
  }
6007
6458
 
6459
+ _loop_story() {
6460
+ if [[ -z "${1:-}" || "$1" == "-h" || "$1" == "--help" ]]; then
6461
+ cat <<'HELP'
6462
+ Usage: roll loop story <STORY-ID> [--days N] [--json]
6463
+
6464
+ Show a per-story rollup across cycles: count, span, duration, tokens,
6465
+ cost, model, PR landings, and the last 3 cycles. Story ID is case-
6466
+ insensitive (us-loop-004 == US-LOOP-004).
6467
+
6468
+ Examples:
6469
+ roll loop story US-LOOP-004
6470
+ roll loop story us-loop-004 --days 90
6471
+ roll loop story US-LOOP-004 --json | jq .cost
6472
+ HELP
6473
+ return 1
6474
+ fi
6475
+ python3 "${ROLL_PKG_DIR}/lib/roll-loop-story.py" "$@"
6476
+ }
6477
+
6008
6478
  _legacy_loop_status() {
6009
6479
  local project_path; project_path=$(pwd -P)
6010
6480
  local agent; agent=$(_project_agent)
@@ -6062,7 +6532,7 @@ _loop_pause() {
6062
6532
  if [[ "$(uname)" == "Darwin" ]]; then
6063
6533
  local label; label=$(_launchd_label "loop" "$project_path")
6064
6534
  if ! _launchd_is_loaded "$label"; then
6065
- warn "Loop not enabled — nothing to pause loop 未启用,无需暂停"; return 0
6535
+ warn "$(msg loop.loop_not_enabled_nothing_to_pause)"; return 0
6066
6536
  fi
6067
6537
  # FIX-097: never touch host launchctl from a sandboxed plist path.
6068
6538
  if ! _launchd_should_skip_registry; then
@@ -6076,7 +6546,7 @@ _loop_pause() {
6076
6546
 
6077
6547
  mkdir -p "$(dirname "$_LOOP_STATE")"
6078
6548
  printf 'status: paused\npaused_at: "%s"\npaused_reason: manual\n' "$paused_at" > "$_LOOP_STATE"
6079
- ok "Loop paused 已暂停 run: roll loop resume"
6549
+ ok "$(msg loop.loop_paused)"
6080
6550
  }
6081
6551
 
6082
6552
  _loop_resume() {
@@ -6096,27 +6566,27 @@ _loop_resume() {
6096
6566
  rm -f "${_SHARED_ROOT}/loop/PAUSE-${slug}"
6097
6567
  fi
6098
6568
  printf "status: idle\n" > "$_LOOP_STATE"
6099
- ok "Loop resumed 已恢复"
6569
+ ok "$(msg loop.loop_resumed)"
6100
6570
  return 0
6101
6571
  fi
6102
6572
 
6103
6573
  # Interrupt resume: loop was running a Story and crashed
6104
6574
  if [[ ! -f "$_LOOP_STATE" ]]; then
6105
- warn "No loop state found — nothing to resume 未找到 loop 状态,无需恢复"; return 0
6575
+ warn "$(msg loop.no_loop_state_found_nothing_to)"; return 0
6106
6576
  fi
6107
6577
  if grep -q "status: running" "$_LOOP_STATE" 2>/dev/null; then
6108
- warn "Loop already running loop 正在运行中"; return 0
6578
+ warn "$(msg loop.loop_already_running_loop_2)"; return 0
6109
6579
  fi
6110
- info "Resuming loop from last state... 正在从上次状态恢复..."
6580
+ info "$(msg loop.resuming_loop_from_last_state)"
6111
6581
  _agent_run_skill "roll-loop"
6112
6582
  }
6113
6583
 
6114
6584
  _loop_reset() {
6115
6585
  if [[ -f "$_LOOP_STATE" ]]; then
6116
6586
  rm -f "$_LOOP_STATE"
6117
- ok "Loop state cleared — will start fresh on next run loop 状态已清除,下次运行将重新开始"
6587
+ ok "$(msg loop.loop_state_cleared_will_start_fresh)"
6118
6588
  else
6119
- info "No loop state to clear 无 loop 状态可清除"
6589
+ info "$(msg loop.no_loop_state_to_clear)"
6120
6590
  fi
6121
6591
  rm -rf "$(_loop_heal_dir)"
6122
6592
  }
@@ -6127,13 +6597,13 @@ _loop_reset() {
6127
6597
  _loop_mute() {
6128
6598
  mkdir -p "$(dirname "$_LOOP_MUTE_FILE")"
6129
6599
  : > "$_LOOP_MUTE_FILE"
6130
- ok "🔇 muted — auto-attach disabled 已静音,自动弹窗已关闭"
6600
+ ok "$(msg loop.muted_auto_attach_disabled)"
6131
6601
  }
6132
6602
 
6133
6603
  # Re-enable the auto-attach popup.
6134
6604
  _loop_unmute() {
6135
6605
  rm -f "$_LOOP_MUTE_FILE"
6136
- ok "🔔 unmuted — auto-attach live 已恢复,自动弹窗已开启"
6606
+ ok "$(msg loop.unmuted_auto_attach_live)"
6137
6607
  }
6138
6608
 
6139
6609
  # Attach to the tmux session a running loop iteration writes to. Returns 1 when
@@ -6144,12 +6614,12 @@ _loop_attach() {
6144
6614
  local session="roll-loop-${slug}"
6145
6615
 
6146
6616
  if ! command -v tmux >/dev/null 2>&1; then
6147
- warn "tmux not installed — install with 'brew install tmux' 请先安装 tmux"
6617
+ warn "$(msg loop.tmux_not_installed_install_with_brew)"
6148
6618
  return 1
6149
6619
  fi
6150
6620
 
6151
6621
  if ! tmux has-session -t "$session" 2>/dev/null; then
6152
- info "No running loop session for this project 当前项目无运行中的 loop"
6622
+ info "$(msg loop.no_running_loop_session_for_this)"
6153
6623
  info "Wait for next scheduled fire, or run: roll loop now"
6154
6624
  return 1
6155
6625
  fi
@@ -6160,7 +6630,7 @@ _loop_attach() {
6160
6630
  # Pretty-print a duration in seconds as "Xs" / "Ym" / "Yh Zm".
6161
6631
  # US-VIEW-019: compute slowest phase + % from a JSON line's phases object.
6162
6632
  # Returns "<abbr> <pct>%" (e.g. "claude 97%") or empty when no phases data.
6163
- # Abbreviations match the AC: claude_invokeclaude, publish_wait_merge→pr-wait,
6633
+ # Abbreviations match the AC: agent_invokeagent, publish_wait_merge→pr-wait,
6164
6634
  # publish_push→publish, worktree_setup→worktree; others unchanged.
6165
6635
  _loop_runs_slowest_phase() {
6166
6636
  local line="$1"
@@ -6173,7 +6643,7 @@ _loop_runs_slowest_phase() {
6173
6643
  [ -z "$max_name" ] && return 0
6174
6644
  local abbr="$max_name"
6175
6645
  case "$max_name" in
6176
- claude_invoke) abbr="claude" ;;
6646
+ agent_invoke) abbr="agent" ;;
6177
6647
  publish_wait_merge) abbr="pr-wait" ;;
6178
6648
  publish_push) abbr="publish" ;;
6179
6649
  worktree_setup) abbr="worktree" ;;
@@ -6189,19 +6659,19 @@ _loop_runs_detail() {
6189
6659
  local cycle_id="$1"
6190
6660
  command -v jq >/dev/null 2>&1 || { err "jq required for --detail"; return 1; }
6191
6661
  if [[ ! -f "$_LOOP_RUNS" ]]; then
6192
- echo "No runs.jsonl yet 尚无运行记录"
6662
+ echo "$(msg loop.no_runs_jsonl_yet)"
6193
6663
  return 0
6194
6664
  fi
6195
6665
  local row
6196
6666
  row=$(jq -c --arg cid "$cycle_id" 'select(.cycle_id == $cid)' "$_LOOP_RUNS" | head -1)
6197
6667
  if [[ -z "$row" ]]; then
6198
- echo "Cycle not found: $cycle_id 未找到对应 cycle"
6668
+ echo "$(msg loop.cycle_not_found $cycle_id)"
6199
6669
  return 1
6200
6670
  fi
6201
6671
  local has_phases
6202
6672
  has_phases=$(jq -r '(.phases // {}) | length' <<<"$row")
6203
6673
  if [[ "$has_phases" == "0" ]]; then
6204
- echo "Cycle $cycle_id has no phases data (pre-US-LOOP-008) 无 phase 数据"
6674
+ echo "$(msg loop.cycle_has_no_phases_data_pre $cycle_id)"
6205
6675
  return 0
6206
6676
  fi
6207
6677
  echo ""
@@ -6330,12 +6800,12 @@ _loop_runs() {
6330
6800
  _loop_backfill_merged >/dev/null 2>&1 || true
6331
6801
 
6332
6802
  if [[ ! -f "$_LOOP_RUNS" ]] || [[ ! -s "$_LOOP_RUNS" ]]; then
6333
- echo "No loop runs yet 尚无 loop 运行记录"
6803
+ echo "$(msg loop.no_loop_runs_yet)"
6334
6804
  return 0
6335
6805
  fi
6336
6806
 
6337
6807
  if ! command -v jq >/dev/null 2>&1; then
6338
- err "jq required for 'roll loop runs' 需要 jq"
6808
+ err "$(msg loop.jq_required_for_roll_loop_runs)"
6339
6809
  return 1
6340
6810
  fi
6341
6811
 
@@ -6349,7 +6819,7 @@ _loop_runs() {
6349
6819
  fi
6350
6820
 
6351
6821
  if [[ -z "$filtered" ]]; then
6352
- echo "No loop runs for current project 当前项目尚无 loop 运行记录"
6822
+ echo "$(msg loop.no_loop_runs_for_current_project)"
6353
6823
  return 0
6354
6824
  fi
6355
6825
 
@@ -6380,12 +6850,83 @@ _notify() {
6380
6850
  }
6381
6851
 
6382
6852
  # Count `tcr:` prefixed commits in the current git repo since started_at timestamp.
6853
+ # FIX-D 2026-05-25: use --all so commits made on a cycle worktree branch (not
6854
+ # yet merged into the current HEAD) are still counted. Without --all the
6855
+ # enforce-tcr check inside a cycle-worktree cwd would miss the worktree's own
6856
+ # fresh commits when the runner happens to chdir to the main repo before
6857
+ # calling enforce-tcr.
6383
6858
  _loop_tcr_count() {
6384
6859
  local started_at="$1"
6385
- git log --oneline --since="${started_at}" 2>/dev/null \
6860
+ git log --all --oneline --since="${started_at}" 2>/dev/null \
6386
6861
  | awk '/^[a-f0-9]+ tcr:/{n++} END{print n+0}'
6387
6862
  }
6388
6863
 
6864
+ # US-LOOP-016: display a single cycle log with header.
6865
+ _loop_log_show() {
6866
+ local file="$1"
6867
+ local id; id=$(basename "$file" .log)
6868
+ local ts; ts=$(echo "$id" | sed 's/^\([0-9]\{4\}\)\([0-9]\{2\}\)\([0-9]\{2\}\)-\([0-9]\{2\}\)\([0-9]\{2\}\).*/\1-\2-\3 \4:\5/')
6869
+ printf '# cycle %s · %s\n' "$id" "$ts"
6870
+ cat "$file"
6871
+ }
6872
+
6873
+ # US-LOOP-016: roll loop log [cycle-id] — view per-cycle logs.
6874
+ _loop_log() {
6875
+ local project_path; project_path=$(pwd -P)
6876
+ local logs_dir="${project_path}/.roll/cycle-logs"
6877
+ local query="${1:-}"
6878
+
6879
+ # Directory missing or empty — friendly message.
6880
+ if [[ ! -d "$logs_dir" ]] || [[ -z "$(ls -A "$logs_dir" 2>/dev/null)" ]]; then
6881
+ echo "$(msg loop.no_cycle_logs_found_run_roll)"
6882
+ return 0
6883
+ fi
6884
+
6885
+ if [[ -z "$query" ]]; then
6886
+ # No argument: find latest .log by cycle-id (filename reverse-sort).
6887
+ # Cycle filenames encode the start timestamp (YYYYMMDD-HHMMSS-PID.log) so
6888
+ # the lexicographically greatest name is always the most recent cycle.
6889
+ # mtime-based sorting (ls -t) is unreliable when files are created in the
6890
+ # same sub-second window (all share the same inode timestamp).
6891
+ local latest; latest=$(ls "$logs_dir"/*.log 2>/dev/null | sort -r | head -1)
6892
+ if [[ -z "$latest" ]]; then
6893
+ echo "$(msg loop.no_cycle_logs_found_run_roll_2)"
6894
+ return 0
6895
+ fi
6896
+ _loop_log_show "$latest"
6897
+ return 0
6898
+ fi
6899
+
6900
+ # Exact match first.
6901
+ local exact="${logs_dir}/${query}.log"
6902
+ if [[ -f "$exact" ]]; then
6903
+ _loop_log_show "$exact"
6904
+ return 0
6905
+ fi
6906
+
6907
+ # Prefix match: glob for query*.log files.
6908
+ local matches; matches=$(ls "$logs_dir/${query}"*.log 2>/dev/null || true)
6909
+ local count=0
6910
+ if [[ -n "$matches" ]]; then
6911
+ count=$(echo "$matches" | wc -l | tr -d ' ')
6912
+ count=$((count))
6913
+ fi
6914
+
6915
+ if [[ "$count" -eq 0 ]]; then
6916
+ echo "$(msg loop.no_cycle_log_matching ${query})"
6917
+ return 1
6918
+ elif [[ "$count" -eq 1 ]]; then
6919
+ _loop_log_show "$matches"
6920
+ return 0
6921
+ else
6922
+ echo "$(msg loop.ambiguous_prefix_matches_logs ${query} ${count})"
6923
+ echo "$matches" | while IFS= read -r f; do
6924
+ echo " $(basename "$f" .log)"
6925
+ done
6926
+ return 1
6927
+ fi
6928
+ }
6929
+
6389
6930
  # Parse origin remote URL → "owner/repo" for GitHub repos.
6390
6931
  # Returns non-zero if no origin, or origin is not github.com.
6391
6932
  # Decoupled from `gh` auto-detection so SSH config host rewrites don't break it.
@@ -6426,24 +6967,24 @@ _ci_wait() {
6426
6967
  local interval=15
6427
6968
  local elapsed=0
6428
6969
 
6429
- _gh_available || { warn "gh not installed — skipping CI gate gh 未安装,跳过 CI 检查"; return 0; }
6970
+ _gh_available || { warn "$(msg loop.gh_not_installed_skipping_ci_gate)"; return 0; }
6430
6971
 
6431
- local commit; commit=$(git rev-parse HEAD 2>/dev/null) || { err "Not a git repo 非 git 仓库"; return 1; }
6972
+ local commit; commit=$(git rev-parse HEAD 2>/dev/null) || { err "$(msg loop.not_a_git_repo)"; return 1; }
6432
6973
  local short; short=$(git rev-parse --short HEAD 2>/dev/null)
6433
6974
 
6434
6975
  # Resolve owner/repo from git remote so we don't depend on gh's auto-detection,
6435
6976
  # which breaks when ~/.ssh/config rewrites `Host github.com` → IP address.
6436
6977
  local repo_slug; repo_slug=$(_gh_repo_slug) || {
6437
- err "Cannot determine GitHub repo from origin remote 无法从 origin 推导 GitHub 仓库"
6978
+ err "$(msg loop.cannot_determine_github_repo_from_origin)"
6438
6979
  return 1
6439
6980
  }
6440
6981
 
6441
- ok "Waiting for CI on ${short} (${repo_slug}) 等待 CI: ${short}"
6982
+ ok "$(msg loop.waiting_for_ci_on ${short} ${repo_slug})"
6442
6983
 
6443
6984
  while (( elapsed < timeout )); do
6444
6985
  local runs
6445
6986
  runs=$(gh -R "$repo_slug" run list --commit "$commit" --json status,conclusion 2>&1) || {
6446
- err "gh run list failed for ${repo_slug}@${short}: ${runs} gh 调用失败"
6987
+ err "$(msg loop.gh_run_list_failed_for_gh ${repo_slug} ${short} ${runs})"
6447
6988
  return 1
6448
6989
  }
6449
6990
 
@@ -6455,11 +6996,11 @@ _ci_wait() {
6455
6996
  local _pr_json; _pr_json=$(gh -R "$repo_slug" pr list --head "$_branch" --state open --json number 2>/dev/null || echo "1")
6456
6997
  local _pr_count; _pr_count=$(echo "$_pr_json" | jq 'length' 2>/dev/null || echo "1")
6457
6998
  if [[ "$_pr_count" == "0" ]]; then
6458
- warn "No open PR for ${_branch} — CI not triggered; skipping CI gate 当前分支无 PR,CI 未触发,跳过"
6999
+ warn "$(msg loop.no_open_pr_for_ci_not ${_branch})"
6459
7000
  return 0
6460
7001
  fi
6461
7002
  fi
6462
- (( elapsed == 0 )) && echo " No CI runs found yet, waiting... 尚无 CI 记录,等待触发..."
7003
+ (( elapsed == 0 )) && echo "$(msg loop.no_ci_runs_found_yet_waiting)"
6463
7004
  sleep "$interval"
6464
7005
  elapsed=$(( elapsed + interval ))
6465
7006
  continue
@@ -6469,7 +7010,7 @@ _ci_wait() {
6469
7010
  pending=$(echo "$runs" | jq -r '[.[] | select(.status != "completed")] | length' 2>/dev/null || echo "0")
6470
7011
 
6471
7012
  if [[ "$pending" -gt 0 ]]; then
6472
- printf " CI running (%ds)... CI 运行中(%ds)...\n" "$elapsed" "$elapsed"
7013
+ printf "$(msg loop.ci_running_ds_ci)" "$elapsed" "$elapsed"
6473
7014
  sleep "$interval"
6474
7015
  elapsed=$(( elapsed + interval ))
6475
7016
  continue
@@ -6479,15 +7020,15 @@ _ci_wait() {
6479
7020
  failed=$(echo "$runs" | jq -r '[.[] | select(.conclusion != "success" and .conclusion != "skipped" and .conclusion != null)] | length' 2>/dev/null || echo "0")
6480
7021
 
6481
7022
  if [[ "$failed" -gt 0 ]]; then
6482
- err "CI failed for ${short} CI 失败: ${short}"
7023
+ err "$(msg loop.ci_failed_for_ci ${short})"
6483
7024
  return 1
6484
7025
  fi
6485
7026
 
6486
- ok "CI passed CI 通过"
7027
+ ok "$(msg loop.ci_passed_ci)"
6487
7028
  return 0
6488
7029
  done
6489
7030
 
6490
- warn "CI timed out after ${timeout}s CI 等待超时(${timeout}s)"
7031
+ warn "$(msg loop.ci_timed_out_after_s_ci ${timeout})"
6491
7032
  return 1
6492
7033
  }
6493
7034
 
@@ -6524,14 +7065,14 @@ _loop_precheck_ci() {
6524
7065
  run_states=$(echo "$runs" \
6525
7066
  | jq -r '[.[] | "\(.status // "?")/\(.conclusion // "null")"] | unique | join(", ")' \
6526
7067
  2>/dev/null || echo "?")
6527
- err "Pre-run CI check: HEAD CI is red — refuse to build on broken base (${short}) HEAD CI 红,拒绝在破损的基础上构建"
7068
+ err "$(msg loop.pre_run_ci_check_head_ci ${short})"
6528
7069
  mkdir -p "$(dirname "$_LOOP_ALERT")"
6529
7070
  cat > "$_LOOP_ALERT" << EOF
6530
7071
  # ALERT — Pre-run CI check failed (red base)
6531
7072
 
6532
7073
  **Time**: $(date '+%Y-%m-%d %H:%M')
6533
7074
  **Commit**: ${short}
6534
- **Reason**: HEAD CI is red — loop refused to build on a broken base HEAD CI 红,loop 拒绝在破损的基础上构建
7075
+ **Reason**: $(msg loop.pre_run_ci_red_base)
6535
7076
  **Failing conclusions**: ${failed_conclusions}
6536
7077
  **Run states**: ${run_states}
6537
7078
 
@@ -6622,7 +7163,7 @@ _loop_enforce_ci() {
6622
7163
  **Time**: $(date '+%Y-%m-%d %H:%M')
6623
7164
  **Story**: ${story_id}
6624
7165
  **Commit**: $(git rev-parse --short HEAD 2>/dev/null || echo unknown)
6625
- **Reason**: CI did not pass — story kept as 🔨 In Progress CI 未通过,故事保持进行中
7166
+ **Reason**: $(msg loop.ci_did_not_pass_story_kept_in_progress)
6626
7167
 
6627
7168
  **Action required** (choose one):
6628
7169
  - Fix CI and re-run: \`roll loop now\`
@@ -6845,7 +7386,7 @@ _loop_pr_rebase_circuit() {
6845
7386
 
6846
7387
  **Time**: $(date '+%Y-%m-%d %H:%M')
6847
7388
  **PR**: #${pr}
6848
- **Reason**: PR #${pr} rebased ${count}× within 24h without CI resolution — possible workflow file error PR rebase 多次未解决,可能是 workflow 文件错误
7389
+ **Reason**: $(msg loop.pr_rebased_within_24h_no_ci "$pr" "$count")
6849
7390
 
6850
7391
  **Action required**:
6851
7392
  - Check PR CI logs and workflow files for breakage
@@ -7386,6 +7927,7 @@ _worktree_sync_meta() {
7386
7927
  local path="$1"
7387
7928
  [ -d ".roll" ] || return 0
7388
7929
  rsync -a \
7930
+ --exclude='.git/' \
7389
7931
  --exclude='state/' \
7390
7932
  --exclude='scratch/' \
7391
7933
  --exclude='*.lock' \
@@ -7416,6 +7958,33 @@ _worktree_merge_back() {
7416
7958
  _worktree_alert "pull --ff-only origin main failed (remote diverged?)"
7417
7959
  return 1
7418
7960
  fi
7961
+ # FIX-E (2026-05-25): only doc-only branches may take the ff-merge fast path.
7962
+ # Code changes must go through PR + CI; bypassing that gate has caused
7963
+ # cycle commits to land on main with red CI, undetected. The check is the
7964
+ # same one publish_push uses for the doc-vs-code split; here we apply it
7965
+ # to the loop branch's diff against the (already-pulled) main HEAD so the
7966
+ # bypass is also closed in the gh-unavailable fallback path. The ALERT
7967
+ # body is targeted at the next cycle's agent (human-on-the-loop), telling
7968
+ # it how to retry through the normal flow rather than waiting for a human.
7969
+ local _changed
7970
+ _changed=$(git diff --name-only HEAD.."$branch" 2>/dev/null)
7971
+ if [ -n "$_changed" ] \
7972
+ && echo "$_changed" | grep -qvE '^(\.roll/|CHANGELOG\.md|guide/|site/|\.claude/|BACKLOG\.md|PROPOSALS\.md|docs/)'; then
7973
+ _worktree_alert "$(printf '%s\n' \
7974
+ "## PRIOR CYCLE FAILED PUBLISH — REQUIRES NORMAL PR FLOW (FIX-E)" \
7975
+ "branch: ${branch}" \
7976
+ "reason: _loop_publish_pr failed (gh unavailable / API error); ff-merge" \
7977
+ " fallback was refused because the branch contains code changes." \
7978
+ " Code must not bypass PR+CI." \
7979
+ "" \
7980
+ "next-cycle action (agent reads this and retries automatically):" \
7981
+ " 1. SKIP normal Todo scan this cycle." \
7982
+ " 2. git push origin ${branch}" \
7983
+ " 3. gh pr create --base main --head ${branch}" \
7984
+ " 4. gh pr merge ${branch} --auto --squash --delete-branch" \
7985
+ " 5. exit cleanly so CI runs and auto-merge takes over.")"
7986
+ return 1
7987
+ fi
7419
7988
  if ! git merge --ff-only "$branch" --quiet 2>/dev/null; then
7420
7989
  _worktree_alert "merge --ff-only ${branch} failed (not fast-forwardable from main)"
7421
7990
  return 1
@@ -7793,19 +8362,24 @@ _loop_monitor() {
7793
8362
  echo -e "\n ${BOLD}${CYAN}roll loop monitor${NC} ${YELLOW}${project_name}${NC} ${now} (Ctrl-C to exit)\n"
7794
8363
 
7795
8364
  # Services status (three services on macOS, single on Linux)
7796
- echo -e " ${BOLD}Services 服务状态${NC} Agent: ${CYAN}${agent}${NC}"
8365
+ echo -e "$(msg loop.services ${BOLD} ${NC} ${CYAN} ${agent})"
7797
8366
  if [[ "$(uname)" == "Darwin" ]]; then
7798
- local active_start active_end loop_minute dream_hour dream_minute brief_hour brief_minute
8367
+ local active_start active_end dream_hour dream_minute brief_hour brief_minute
7799
8368
  active_start=$(_config_read_int "loop_active_start" "10")
7800
8369
  active_end=$(_config_read_int "loop_active_end" "18")
7801
- loop_minute=$(_config_read_int "loop_minute" "$(_loop_derive_minute "$project_path" 0)")
8370
+ # US-LOOP-013: use schedule spec for display
8371
+ local loop_spec loop_period loop_offset
8372
+ loop_spec=$(_loop_schedule_spec "$project_path")
8373
+ loop_period="${loop_spec%% *}"
8374
+ loop_offset="${loop_spec##* }"
7802
8375
  dream_hour=$(_config_read_int "loop_dream_hour" "3")
7803
8376
  dream_minute=$(_config_read_int "loop_dream_minute" "$(_loop_derive_minute "$project_path" 2)")
7804
8377
  brief_hour=$(_config_read_int "loop_brief_hour" "9")
7805
8378
  brief_minute=$(_config_read_int "loop_brief_minute" "$(_loop_derive_minute "$project_path" 4)")
7806
8379
 
7807
8380
  local loop_sched dream_sched brief_sched
7808
- loop_sched=$(printf "every hour :%02d active %02d:00–%02d:00" "$loop_minute" "$active_start" "$active_end")
8381
+ loop_sched=$(_loop_schedule_desc "$loop_period" "$loop_offset" en)
8382
+ loop_sched="${loop_sched} active ${active_start}:00–${active_end}:00"
7809
8383
  dream_sched=$(printf "%02d:%02d" "$dream_hour" "$dream_minute")
7810
8384
  brief_sched=$(printf "%02d:%02d" "$brief_hour" "$brief_minute")
7811
8385
 
@@ -7815,9 +8389,9 @@ _loop_monitor() {
7815
8389
  local svc="${svcs[$i]}" schedule="${scheds[$i]}"
7816
8390
  local state; state=$(_launchd_svc_state "$svc" "$project_path")
7817
8391
  case "$state" in
7818
- enabled) printf " ${GREEN}%-8s ● enabled${NC} (%s)\n" "$svc" "$schedule" ;;
7819
- installed-off) printf " ${YELLOW}%-8s ⚠ installed/off${NC} (%s) run: roll loop on\n" "$svc" "$schedule" ;;
7820
- not-installed) printf " ${RED}%-8s ○ not installed${NC} (%s) run: roll setup\n" "$svc" "$schedule" ;;
8392
+ enabled) printf " ${GREEN}%-8s %s${NC} (%s)\n" "$svc" "$(msg loop.svc_enabled)" "$schedule" ;;
8393
+ installed-off) printf " ${YELLOW}%-8s %s${NC} (%s) %s\n" "$svc" "$(msg loop.svc_installed_off)" "$schedule" "$(msg loop.svc_enabled_run)" ;;
8394
+ not-installed) printf " ${RED}%-8s %s${NC} (%s) %s\n" "$svc" "$(msg loop.svc_not_installed)" "$schedule" "$(msg loop.svc_not_installed_run)" ;;
7821
8395
  esac
7822
8396
  done
7823
8397
  else
@@ -7856,7 +8430,7 @@ _loop_monitor() {
7856
8430
 
7857
8431
  # Queue: pending items
7858
8432
  echo ""
7859
- echo -e " ${BOLD}Queue 待处理队列${NC}"
8433
+ echo -e "$(msg loop.queue ${BOLD} ${NC})"
7860
8434
  local backlog=".roll/backlog.md"
7861
8435
  if [[ -f "$backlog" ]]; then
7862
8436
  local queue_count=0
@@ -7904,7 +8478,7 @@ _loop_monitor() {
7904
8478
  local log_file="${_SHARED_ROOT}/loop/launchd.log"
7905
8479
  echo ""
7906
8480
  echo -e " ─────────────────────────────────────────────────────"
7907
- echo -e " ${BOLD}Log Tail 实时日志${NC} (~/.shared/roll/loop/launchd.log, last 10 lines)"
8481
+ echo -e "$(msg loop.log_tail ${BOLD} ${NC})"
7908
8482
  if [[ -f "$log_file" && -s "$log_file" ]]; then
7909
8483
  tail -10 "$log_file" | sed 's/^/ /'
7910
8484
  else
@@ -7916,7 +8490,7 @@ _loop_monitor() {
7916
8490
  local evfile="${_SHARED_ROOT}/loop/events-${slug}.ndjson"
7917
8491
  echo ""
7918
8492
  echo -e " ─────────────────────────────────────────────────────"
7919
- echo -e " ${BOLD}Cycle Events 事件流${NC} (last 10)"
8493
+ echo -e "$(msg loop.cycle_events ${BOLD} ${NC})"
7920
8494
  if [[ -f "$evfile" && -s "$evfile" ]]; then
7921
8495
  tail -n 10 "$evfile" | python3 -c "
7922
8496
  import sys, json
@@ -7971,7 +8545,7 @@ cmd_brief() {
7971
8545
  local latest; latest=$(ls "${briefs_dir}"/*.md 2>/dev/null | sort | tail -1 || true)
7972
8546
 
7973
8547
  if [[ -z "$latest" ]]; then
7974
- info "No brief yet — generating... 暂无简报,正在生成..."
8548
+ info "$(msg brief.no_brief_yet_generating)"
7975
8549
  _agent_run_skill "roll-brief"
7976
8550
  latest=$(ls "${briefs_dir}"/*.md 2>/dev/null | sort | tail -1 || true)
7977
8551
  else
@@ -7979,7 +8553,7 @@ cmd_brief() {
7979
8553
  mod_time=$(_file_mtime "$latest")
7980
8554
  now=$(date +%s); age=$(( now - mod_time ))
7981
8555
  if (( age > 86400 )); then
7982
- info "Brief is $(( age / 3600 ))h old — regenerating... 简报已 $(( age / 3600 )) 小时未更新,重新生成..."
8556
+ info "$(msg brief.brief_is_age_3600_h_old "$(( age / 3600 ))")"
7983
8557
  _agent_run_skill "roll-brief"
7984
8558
  latest=$(ls "${briefs_dir}"/*.md 2>/dev/null | sort | tail -1 || true)
7985
8559
  fi
@@ -8074,19 +8648,19 @@ cmd_alert() {
8074
8648
  case "$subcmd" in
8075
8649
  list|"")
8076
8650
  if [[ ! -f "$_LOOP_ALERT" ]]; then
8077
- ok "No active alerts 暂无告警"
8651
+ ok "$(msg alert.no_active_alerts)"
8078
8652
  return 0
8079
8653
  fi
8080
- echo -e "${BOLD}Active Alert 当前告警${NC}"
8654
+ echo -e "$(msg alert.active_alert ${BOLD} ${NC})"
8081
8655
  echo ""
8082
8656
  cat "$_LOOP_ALERT"
8083
8657
  echo ""
8084
8658
  echo -e " Run '${CYAN}roll alert ack${NC}' to acknowledge, '${CYAN}roll alert resolve${NC}' to clear."
8085
- echo -e " 运行 'roll alert ack' 确认告警,'roll alert resolve' 清除告警。"
8659
+ echo -e "$(msg alert.run_roll_alert_ack_to_acknowledge)"
8086
8660
  ;;
8087
8661
  ack)
8088
8662
  if [[ ! -f "$_LOOP_ALERT" ]]; then
8089
- warn "No active alerts to acknowledge 暂无待确认告警"
8663
+ warn "$(msg alert.no_active_alerts_to_acknowledge)"
8090
8664
  return 0
8091
8665
  fi
8092
8666
  local ts; ts=$(date '+%Y-%m-%d %H:%M:%S')
@@ -8094,20 +8668,20 @@ cmd_alert() {
8094
8668
  echo ""
8095
8669
  echo "**Acknowledged**: ${ts}"
8096
8670
  } >> "$_LOOP_ALERT"
8097
- ok "Alert acknowledged at ${ts} 告警已确认"
8671
+ ok "$(msg alert.alert_acknowledged_at ${ts})"
8098
8672
  ;;
8099
8673
  resolve|clear)
8100
8674
  if [[ ! -f "$_LOOP_ALERT" ]]; then
8101
- ok "No active alerts 暂无告警"
8675
+ ok "$(msg alert.no_active_alerts_2)"
8102
8676
  return 0
8103
8677
  fi
8104
8678
  rm -f "$_LOOP_ALERT"
8105
- ok "Alert resolved and cleared 告警已解决并清除"
8679
+ ok "$(msg alert.alert_resolved_and_cleared)"
8106
8680
  ;;
8107
8681
  *)
8108
- err "Unknown subcommand: $subcmd 未知子命令: $subcmd"
8682
+ err "$(msg alert.unknown_subcommand $subcmd)"
8109
8683
  echo " Usage: roll alert [list|ack|resolve]"
8110
- echo " 用法: roll alert [list|ack|resolve]"
8684
+ echo "$(msg alert.usage_roll_alert_list_ack_resolve)"
8111
8685
  return 1
8112
8686
  ;;
8113
8687
  esac
@@ -8144,7 +8718,7 @@ cmd_lang() {
8144
8718
  printf 'lang: %s\n' "$arg" >> "$tmp"
8145
8719
  mv "$tmp" "$ROLL_CONFIG"
8146
8720
  unset ROLL_LANG_RESOLVED
8147
- ok "Language set to ${arg} 语言已设置为 ${arg}"
8721
+ ok "$(msg lang.language_set_to ${arg})"
8148
8722
  ;;
8149
8723
  --reset)
8150
8724
  if [[ -f "$ROLL_CONFIG" ]]; then
@@ -8153,12 +8727,12 @@ cmd_lang() {
8153
8727
  mv "$tmp" "$ROLL_CONFIG"
8154
8728
  fi
8155
8729
  unset ROLL_LANG_RESOLVED
8156
- ok "Language preference cleared (will follow locale) 语言偏好已清除(跟随系统 locale)"
8730
+ ok "$(msg lang.language_preference_cleared_will_follow_locale)"
8157
8731
  ;;
8158
8732
  *)
8159
- err "Unknown language: ${arg} 未知语言: ${arg}"
8733
+ err "$(msg lang.unknown_language ${arg})"
8160
8734
  echo " Valid values: zh, en, --reset"
8161
- echo " 可选值: zh, en, --reset"
8735
+ echo "$(msg lang.options_zh_en_reset)"
8162
8736
  return 1
8163
8737
  ;;
8164
8738
  esac
@@ -8174,7 +8748,7 @@ cmd_ci() {
8174
8748
  case "$1" in
8175
8749
  --wait) wait_mode=true; shift ;;
8176
8750
  --timeout=*) timeout="${1#*=}"; shift ;;
8177
- *) err "Usage: roll ci [--wait] [--timeout=N] 用法: roll ci [--wait] [--timeout=N]"; exit 1 ;;
8751
+ *) err "$(msg ci.usage_roll_ci_wait_timeout_n)"; exit 1 ;;
8178
8752
  esac
8179
8753
  done
8180
8754
 
@@ -8183,12 +8757,12 @@ cmd_ci() {
8183
8757
  return
8184
8758
  fi
8185
8759
 
8186
- _gh_available || { warn "gh not installed gh 未安装"; return 0; }
8187
- local commit; commit=$(git rev-parse HEAD 2>/dev/null) || { err "Not a git repo 非 git 仓库"; return 1; }
8760
+ _gh_available || { warn "$(msg ci.gh_not_installed_gh)"; return 0; }
8761
+ local commit; commit=$(git rev-parse HEAD 2>/dev/null) || { err "$(msg ci.not_a_git_repo)"; return 1; }
8188
8762
  local runs
8189
8763
  runs=$(gh run list --commit "$commit" --json status,conclusion,name 2>/dev/null) || { warn "gh run list failed"; return 0; }
8190
8764
  if [[ -z "$runs" || "$runs" == "[]" ]]; then
8191
- echo "No CI runs for $(git rev-parse --short HEAD) 当前提交无 CI 记录"
8765
+ echo "$(msg ci.no_ci_runs_for_git_rev "${commit:0:7}")"
8192
8766
  return 0
8193
8767
  fi
8194
8768
  echo "$runs" | jq -r '.[] | "\(.name): \(.status)/\(.conclusion)"'
@@ -8278,12 +8852,12 @@ _backlog_lint() {
8278
8852
  if [ "$violations" -gt 0 ]; then
8279
8853
  echo " ${violations} violation(s) — see conventions/global/AGENTS.md §4"
8280
8854
  if [ "$gate" = 1 ]; then
8281
- echo " ${violations} 条违规 — --gate enabled, exiting 1"
8855
+ echo "$(msg ci.gate_enabled_exiting_1 ${violations})"
8282
8856
  return 1
8283
8857
  fi
8284
- echo " ${violations} 条违规 — Phase 1: warn-only, not blocking"
8858
+ echo "$(msg ci.phase_1_warn_only_not_blocking ${violations})"
8285
8859
  else
8286
- echo " No violations 无违规"
8860
+ echo "$(msg ci.no_violations)"
8287
8861
  fi
8288
8862
  return 0
8289
8863
  }
@@ -8291,7 +8865,7 @@ _backlog_lint() {
8291
8865
  cmd_backlog() {
8292
8866
  local backlog=".roll/backlog.md"
8293
8867
  if [[ ! -f "$backlog" ]]; then
8294
- err ".roll/backlog.md not found — run 'roll init' first 未找到 .roll/backlog.md,请先运行 roll init"
8868
+ err "$(msg backlog.roll_backlog_md_not_found_run)"
8295
8869
  return 1
8296
8870
  fi
8297
8871
 
@@ -8316,7 +8890,7 @@ cmd_backlog() {
8316
8890
  local pattern="${2:-}"
8317
8891
  local reason="${3:-}"
8318
8892
  if [[ -z "$pattern" ]]; then
8319
- err "Usage: roll backlog $subcmd <pattern> [reason] 用法: roll backlog $subcmd <匹配模式> [原因]"
8893
+ err "$(msg backlog.usage_roll_backlog_pattern_reason $subcmd)"
8320
8894
  return 1
8321
8895
  fi
8322
8896
  local new_status
@@ -8328,9 +8902,9 @@ cmd_backlog() {
8328
8902
  local count
8329
8903
  count=$(_backlog_set_status "$pattern" "$new_status")
8330
8904
  if [[ "$count" -eq 0 ]]; then
8331
- echo " No items matched: $pattern 未找到匹配项: $pattern"
8905
+ echo "$(msg backlog.no_items_matched $pattern)"
8332
8906
  else
8333
- echo " Updated ${count} item(s) ${new_status} 已更新 ${count} 条目"
8907
+ echo "$(msg backlog.updated_item_s ${count} ${new_status})"
8334
8908
  fi
8335
8909
  return
8336
8910
  ;;
@@ -8375,22 +8949,22 @@ cmd_backlog() {
8375
8949
  [[ -z "$unknown_items" ]] && unknown_count=0
8376
8950
 
8377
8951
  echo ""
8378
- echo -e " ${BOLD}Pending Backlog 待处理任务${NC} (${total} items)"
8952
+ echo -e "$(msg backlog.pending_backlog ${BOLD} ${NC} ${total})"
8379
8953
  echo ""
8380
8954
 
8381
- [[ $fix_count -gt 0 ]] && _backlog_render_group "Bug Fixes 缺陷修复" "$RED" "$fix_count" 12 "$fix_items"
8382
- [[ $us_count -gt 0 ]] && _backlog_render_group "User Stories 用户故事" "$CYAN" "$us_count" 14 "$us_items"
8383
- [[ $refactor_count -gt 0 ]] && _backlog_render_group "Refactors 重构" "$YELLOW" "$refactor_count" 16 "$refactor_items"
8384
- [[ $idea_count -gt 0 ]] && _backlog_render_group "Ideas 创意" "$NC" "$idea_count" 14 "$idea_items"
8955
+ [[ $fix_count -gt 0 ]] && _backlog_render_group "$(msg backlog.bug_fixes)" "$RED" "$fix_count" 12 "$fix_items"
8956
+ [[ $us_count -gt 0 ]] && _backlog_render_group "$(msg backlog.user_stories)" "$CYAN" "$us_count" 14 "$us_items"
8957
+ [[ $refactor_count -gt 0 ]] && _backlog_render_group "$(msg backlog.refactors)" "$YELLOW" "$refactor_count" 16 "$refactor_items"
8958
+ [[ $idea_count -gt 0 ]] && _backlog_render_group "$(msg backlog.ideas)" "$NC" "$idea_count" 14 "$idea_items"
8385
8959
 
8386
8960
  if [[ $total -eq 0 ]]; then
8387
- echo -e " ${GREEN} Nothing pending — backlog is clear 暂无待处理任务${NC}"
8961
+ echo -e "$(msg backlog.nothing_pending_backlog_is_clear ${GREEN} ${NC})"
8388
8962
  echo ""
8389
8963
  fi
8390
8964
 
8391
8965
  # ── Blocked ───────────────────────────────────────────────────────────────
8392
8966
  if [[ $blocked_count -gt 0 ]]; then
8393
- echo -e " ${DIM}Blocked 已阻塞 (${blocked_count})${NC}"
8967
+ echo -e "$(msg backlog.blocked ${DIM} ${blocked_count} ${NC})"
8394
8968
  while IFS= read -r line; do
8395
8969
  [[ -z "$line" ]] && continue
8396
8970
  local id desc reason
@@ -8406,7 +8980,7 @@ cmd_backlog() {
8406
8980
 
8407
8981
  # ── Deferred ──────────────────────────────────────────────────────────────
8408
8982
  if [[ $deferred_count -gt 0 ]]; then
8409
- echo -e " ${DIM}Deferred 已推迟 (${deferred_count})${NC}"
8983
+ echo -e "$(msg backlog.deferred ${DIM} ${deferred_count} ${NC})"
8410
8984
  while IFS= read -r line; do
8411
8985
  [[ -z "$line" ]] && continue
8412
8986
  local id desc reason
@@ -8422,8 +8996,8 @@ cmd_backlog() {
8422
8996
 
8423
8997
  # ── Unknown status (show for human/AI triage) ─────────────────────────────
8424
8998
  if [[ $unknown_count -gt 0 ]]; then
8425
- echo -e " ${YELLOW}? Unknown Status 未知状态 (${unknown_count})${NC}"
8426
- echo -e " ${YELLOW} Fix: roll backlog block/defer/unblock <pattern> 运行命令修正状态${NC}"
8999
+ echo -e "$(msg backlog.unknown_status ${YELLOW} ${unknown_count} ${NC})"
9000
+ echo -e "$(msg backlog.fix_roll_backlog_block_defer_unblock ${YELLOW} ${NC})"
8427
9001
  while IFS= read -r line; do
8428
9002
  [[ -z "$line" ]] && continue
8429
9003
  local id desc status_raw
@@ -8650,7 +9224,7 @@ _legacy_home() {
8650
9224
  echo ""
8651
9225
 
8652
9226
  # ── ② AI 自治 — 三层 × 四道防线 (主视觉) ────────────────────────────────
8653
- echo -e " ${BOLD}╔══ 🤖 AI 自治 — 三层 × 四道防线 ══════════════════════════╗${NC}"
9227
+ echo -e "$(msg backlog.ai ${BOLD} ${NC})"
8654
9228
 
8655
9229
  # Loop layer
8656
9230
  local loop_state="not-installed"
@@ -8661,17 +9235,22 @@ _legacy_home() {
8661
9235
  else
8662
9236
  crontab -l 2>/dev/null | grep -q "${_LOOP_TAG}:${project_path}" && loop_state="enabled"
8663
9237
  fi
8664
- local active_start active_end loop_minute dream_hour dream_minute brief_hour brief_minute
9238
+ local active_start active_end dream_hour dream_minute brief_hour brief_minute
8665
9239
  active_start=$(_config_read_int "loop_active_start" "10")
8666
9240
  active_end=$(_config_read_int "loop_active_end" "18")
8667
- loop_minute=$(_config_read_int "loop_minute" "$(_loop_derive_minute "$project_path" 0)")
9241
+ # US-LOOP-013: use schedule spec for display
9242
+ local loop_spec loop_period loop_offset
9243
+ loop_spec=$(_loop_schedule_spec "$project_path")
9244
+ loop_period="${loop_spec%% *}"
9245
+ loop_offset="${loop_spec##* }"
8668
9246
  dream_hour=$(_config_read_int "loop_dream_hour" "3")
8669
9247
  dream_minute=$(_config_read_int "loop_dream_minute" "$(_loop_derive_minute "$project_path" 2)")
8670
9248
  brief_hour=$(_config_read_int "loop_brief_hour" "9")
8671
9249
  brief_minute=$(_config_read_int "loop_brief_minute" "$(_loop_derive_minute "$project_path" 4)")
8672
9250
 
8673
9251
  local loop_badge loop_sched
8674
- loop_sched=$(printf "every :%02d active %02d:00–%02d:00" "$loop_minute" "$active_start" "$active_end")
9252
+ loop_sched=$(_loop_schedule_desc "$loop_period" "$loop_offset" en)
9253
+ loop_sched="${loop_sched} active ${active_start}:00–${active_end}:00"
8675
9254
  case "$loop_state" in
8676
9255
  enabled) loop_badge="${GREEN}● enabled${NC}" ;;
8677
9256
  installed-off) loop_badge="${YELLOW}⚠ off${NC}" ;;
@@ -8733,7 +9312,7 @@ _legacy_home() {
8733
9312
  fi
8734
9313
 
8735
9314
  # 四道防线
8736
- echo -e " ${BOLD} 四道防线 ─${NC}"
9315
+ echo -e "$(msg backlog. ${BOLD} ${NC})"
8737
9316
  local def_tcr="${RED}○${NC}" def_review="${GREEN}●${NC}" def_spar="${YELLOW}○${NC}" def_sentinel="${YELLOW}○ off${NC}"
8738
9317
  if [[ -n "$last_tcr_min" ]]; then
8739
9318
  def_tcr="${GREEN}● ${last_tcr_min}min${NC}"
@@ -8775,7 +9354,7 @@ _legacy_home() {
8775
9354
  printf " ${BOLD}📊 Current Focus · DoD${NC}\n"
8776
9355
  printf " 🔨 ${BOLD}%s${NC} %s\n" "$p_id" "$p_desc"
8777
9356
  printf " [%b] [%b]\n" "$ac_badge" "$ci_badge"
8778
- printf " ${YELLOW}其余 4 项 DoD 信号源待接入:see US-AUTO-030/031, IDEA-013/014${NC}\n"
9357
+ printf "$(msg backlog.4_dod_see_us_auto_030 ${YELLOW} ${NC})"
8779
9358
  echo ""
8780
9359
  fi
8781
9360
 
@@ -8784,9 +9363,9 @@ _legacy_home() {
8784
9363
  alerts=$(_dash_alert_count); alerts=${alerts//[^0-9]/}; alerts=${alerts:-0}
8785
9364
  proposals=$(_dash_proposal_count); proposals=${proposals//[^0-9]/}; proposals=${proposals:-0}
8786
9365
  release_ready=false; _dash_release_ready && release_ready=true
8787
- printf " ${BOLD}👤 需要你介入${NC}\n"
9366
+ printf "$(msg backlog.n ${BOLD} ${NC})"
8788
9367
  if (( alerts == 0 )) && (( proposals == 0 )) && ! $release_ready; then
8789
- printf " ${GREEN} AI 自驱中 — 无需介入${NC}\n"
9368
+ printf "$(msg backlog.ai_2 ${GREEN} ${NC})"
8790
9369
  else
8791
9370
  (( alerts > 0 )) && printf " ${RED}⚠ %s ALERT${NC} run: roll alert\n" "$alerts"
8792
9371
  (( proposals > 0 )) && printf " ${YELLOW}📋 %s PROPOSAL${NC} see: .roll/proposals.md\n" "$proposals"
@@ -8796,8 +9375,9 @@ _legacy_home() {
8796
9375
 
8797
9376
  # ── ⑥ Schedules & Last Brief ──────────────────────────────────────────────
8798
9377
  printf " ${BOLD}⏰ Schedules & Last Brief${NC}\n"
8799
- printf " loop :%02d · dream %02d:%02d · brief %02d:%02d\n" \
8800
- "$loop_minute" "$dream_hour" "$dream_minute" "$brief_hour" "$brief_minute"
9378
+ local loop_sched_short; loop_sched_short=$(_loop_schedule_desc "$loop_period" "$loop_offset" en)
9379
+ printf " %s · dream %02d:%02d · brief %02d:%02d\n" \
9380
+ "$loop_sched_short" "$dream_hour" "$dream_minute" "$brief_hour" "$brief_minute"
8801
9381
  local latest_brief; latest_brief=$(ls .roll/briefs/*.md 2>/dev/null | sort | tail -1 || true)
8802
9382
  if [[ -n "$latest_brief" ]]; then
8803
9383
  local mod_time now age summary
@@ -8833,37 +9413,37 @@ _legacy_help() {
8833
9413
  echo -e " ${BOLD}v${VERSION}${NC} — Roll out features with AI agents"
8834
9414
  echo ""
8835
9415
  echo "Usage: roll <command> [options]"
8836
- echo "用法: roll <command> [options]"
9416
+ echo "$(msg backlog.usage_roll_command_options)"
8837
9417
  echo ""
8838
9418
  echo "Commands:"
8839
- echo " setup [-f] [Machine] First-time install or re-sync 首次安装或重新同步"
8840
- echo " update [Upgrade] npm install latest + re-sync 一键升级到最新版"
8841
- echo " init [Project] Create AGENTS.md + .roll/backlog.md + .roll/features/ 初始化项目工作流文件"
8842
- echo " offboard [--confirm] [Project] Reverse a previous \`roll init --apply\` (dry-run by default) 卸载本项目的 Roll 痕迹"
8843
- echo " status [Diagnostic] Show current state 显示当前状态"
8844
- echo " peer [Peer Review] Cross-agent negotiation 跨 Agent 协商对审"
8845
- echo " loop <on|off|now|status|monitor|resume|reset> [Autonomous] Manage scheduled BACKLOG executor 管理自主执行循环"
8846
- echo " brief [Digest] Show latest owner brief (regenerate if stale) 展示最新简报"
8847
- echo " backlog [View] Show pending tasks (Todo/Blocked/Deferred/Unknown) 显示任务清单"
8848
- echo " backlog block <pat> [reason] Mark matching items as 🔒 Blocked 标记为已阻塞"
8849
- echo " backlog defer <pat> [reason] Mark matching items as ⏸ Deferred 标记为已推迟"
8850
- echo " backlog unblock <pat> Restore matching items to 📋 Todo 恢复为待处理"
8851
- echo " backlog unstick [--dry-run] Revert In Progress whose cycle failed >4h ago 自愈卡住的进行中任务"
8852
- echo " backlog lint Check descriptions for path/function/filename violations 检查描述合规"
8853
- echo " agent [use <name>|list] [Config] Per-project agent selection 切换项目 agent"
8854
- echo " ci [--wait] [CI] Show or wait for current commit's CI status 查看/等待 CI 状态"
8855
- echo " review-pr <number> [PR Review] AI-powered code review for a PR AI 代码评审"
9419
+ echo "$(msg backlog.setup_f_machine_first_time_install)"
9420
+ echo "$(msg backlog.update_upgrade_npm_install_latest_re)"
9421
+ echo "$(msg backlog.init_project_create_agents_md_roll)"
9422
+ echo "$(msg backlog.offboard_confirm_project_reverse_a_previous)"
9423
+ echo "$(msg backlog.status_diagnostic_show_current_state)"
9424
+ echo "$(msg backlog.peer_peer_review_cross_agent_negotiation)"
9425
+ echo "$(msg backlog.loop_on_off_now_status_monitor)"
9426
+ echo "$(msg backlog.brief_digest_show_latest_owner_brief)"
9427
+ echo "$(msg backlog.backlog_view_show_pending_tasks_todo)"
9428
+ echo "$(msg backlog.backlog_block_pat_reason_mark_matching)"
9429
+ echo "$(msg backlog.backlog_defer_pat_reason_mark_matching)"
9430
+ echo "$(msg backlog.backlog_unblock_pat_restore_matching_items)"
9431
+ echo "$(msg backlog.backlog_unstick_dry_run_revert_in)"
9432
+ echo "$(msg backlog.backlog_lint_check_descriptions_for_path)"
9433
+ echo "$(msg backlog.agent_use_name_list_config_per)"
9434
+ echo "$(msg backlog.ci_wait_ci_show_or_wait)"
9435
+ echo "$(msg backlog.review_pr_number_pr_review_ai)"
8856
9436
  echo ""
8857
- echo "Examples / 示例:"
8858
- echo " roll setup # New machine: first-time install 新机器首次安装"
8859
- echo " roll update # Upgrade to latest version + re-sync 升级到最新版并重新同步"
8860
- echo " roll init # New or re-merge project (run in project) 新建或重新合并(项目目录)"
8861
- echo " roll loop on # Enable autonomous loop (cron) 启用自主执行循环"
8862
- echo " roll brief # Show latest brief 查看最新简报"
8863
- echo " roll backlog # Show pending/blocked/deferred items 查看待处理任务"
8864
- echo " roll backlog defer US-DOC '过早引入' # Defer all US-DOC-* items 推迟一类任务"
8865
- echo " roll backlog block US-HW-001 '硬件未到货' # Block a specific item 标记阻塞"
8866
- echo " roll agent use kimi # Switch this project to kimi 切换当前项目到 kimi"
9437
+ echo "$(msg backlog.examples)"
9438
+ echo "$(msg backlog.roll_setup_new_machine_first_time)"
9439
+ echo "$(msg backlog.roll_update_upgrade_to_latest_version)"
9440
+ echo "$(msg backlog.roll_init_new_or_re_merge)"
9441
+ echo "$(msg backlog.roll_loop_on_enable_autonomous_loop)"
9442
+ echo "$(msg backlog.roll_brief_show_latest_brief)"
9443
+ echo "$(msg backlog.roll_backlog_show_pending_blocked_deferred)"
9444
+ echo "$(msg backlog.roll_backlog_defer_us_doc)"
9445
+ echo "$(msg backlog.roll_backlog_block_us_hw_001)"
9446
+ echo "$(msg backlog.roll_agent_use_kimi_switch_this)"
8867
9447
 
8868
9448
  }
8869
9449