@seanyao/roll 2026.523.1 → 2026.523.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/roll CHANGED
@@ -4,7 +4,7 @@ set -euo pipefail
4
4
  # Roll — AI Agent Convention Manager
5
5
  # Single source of truth for how all AI coding agents behave.
6
6
 
7
- VERSION="2026.523.1"
7
+ VERSION="2026.523.2"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -21,6 +21,10 @@ SCRIPT_DIR="$(cd "$(dirname "$_source")" && pwd)"
21
21
  ROLL_PKG_DIR="$(dirname "$SCRIPT_DIR")"
22
22
  ROLL_PKG_CONVENTIONS="${ROLL_PKG_DIR}/conventions"
23
23
 
24
+ # US-I18N-001: i18n engine (locale resolution + message catalog).
25
+ # shellcheck source=../lib/i18n.sh
26
+ [[ -f "${ROLL_PKG_DIR}/lib/i18n.sh" ]] && source "${ROLL_PKG_DIR}/lib/i18n.sh"
27
+
24
28
  # Colors
25
29
  RED=$'\033[0;31m'
26
30
  GREEN=$'\033[0;32m'
@@ -3620,6 +3624,107 @@ EOF
3620
3624
  return "$rc"
3621
3625
  }
3622
3626
 
3627
+ # ─── cmd_prices (US-VIEW-013) ─────────────────────────────────────────────────
3628
+ # `roll prices show` — print the current price snapshot table.
3629
+ # `roll prices refresh` — fetch live pricing docs, diff vs latest snapshot,
3630
+ # write a new snapshot when rates have changed.
3631
+ _prices_help() {
3632
+ cat <<'EOF'
3633
+ Usage: roll prices <subcommand> [--url URL]
3634
+ roll prices <子命令> [--url 网址]
3635
+
3636
+ Subcommands:
3637
+ show Print the current price snapshot table.
3638
+ 显示当前价格快照表。
3639
+ refresh Fetch the official pricing docs, diff against the latest snapshot,
3640
+ and write a new snapshot only when rates have changed.
3641
+ 拉取官方价格文档与最新快照对比,有变化才落新快照。
3642
+ EOF
3643
+ }
3644
+
3645
+ cmd_prices_show() {
3646
+ local lib_dir="${ROLL_PKG_DIR}/lib"
3647
+ python3 - "$lib_dir" <<'PY'
3648
+ import json, os, sys
3649
+ lib_dir = sys.argv[1]
3650
+ sys.path.insert(0, lib_dir)
3651
+ import model_prices as mp
3652
+
3653
+ version, effective_at, source_url = mp.snapshot_meta()
3654
+ print(f"price snapshot 价格快照")
3655
+ print(f" version {version}")
3656
+ print(f" effective_at {effective_at}")
3657
+ print(f" source {source_url}")
3658
+ print(f" default_model {mp.DEFAULT}")
3659
+ print()
3660
+ print(f" {'model':<22}{'in':>8}{'out':>8}{'cw':>8}{'cr':>8}")
3661
+ for model in sorted(mp.PRICES):
3662
+ p = mp.PRICES[model]
3663
+ print(f" {model:<22}{p['in']:>8.2f}{p['out']:>8.2f}{p['cache_create']:>8.2f}{p['cache_read']:>8.2f}")
3664
+ print()
3665
+ print("rates per million tokens, USD 单位为每百万 token 美元")
3666
+ PY
3667
+ }
3668
+
3669
+ cmd_prices_refresh() {
3670
+ local url=""
3671
+ while (( $# > 0 )); do
3672
+ case "$1" in
3673
+ --url) url="$2"; shift 2 ;;
3674
+ *) err "Unknown flag: $1 未知参数: $1"; return 1 ;;
3675
+ esac
3676
+ done
3677
+ local lib_dir="${ROLL_PKG_DIR}/lib"
3678
+ python3 - "$lib_dir" "$url" <<'PY'
3679
+ import os, sys
3680
+ lib_dir = sys.argv[1]
3681
+ override_url = sys.argv[2] if len(sys.argv) > 2 else ""
3682
+ sys.path.insert(0, lib_dir)
3683
+ import prices_fetcher as pf
3684
+
3685
+ snapshot_dir = os.path.join(lib_dir, "prices")
3686
+ url = override_url or pf.DEFAULT_SOURCE_URL
3687
+
3688
+ try:
3689
+ action, changes = pf.refresh(snapshot_dir=snapshot_dir, url=url)
3690
+ except pf.FetchError as exc:
3691
+ print(f"[roll] fetch failed: {exc}", file=sys.stderr)
3692
+ print("[roll] keeping existing snapshot, no changes written 保留旧快照,未写入新文件",
3693
+ file=sys.stderr)
3694
+ sys.exit(2)
3695
+ except pf.ParseError as exc:
3696
+ print(f"[roll] parse failed: {exc}", file=sys.stderr)
3697
+ print("[roll] keeping existing snapshot, no changes written 保留旧快照,未写入新文件",
3698
+ file=sys.stderr)
3699
+ sys.exit(3)
3700
+
3701
+ kind, _, _ = (action + "::").split(":", 2)
3702
+ if action == "unchanged":
3703
+ print("[roll] up to date 价格快照已是最新")
3704
+ sys.exit(0)
3705
+ if kind == "first":
3706
+ print(f"[roll] baseline snapshot written 写入首份基线快照")
3707
+ elif kind == "written":
3708
+ print(f"[roll] new snapshot written 写入新版价格快照")
3709
+ print(pf.format_diff(changes, colored=sys.stdout.isatty()))
3710
+ PY
3711
+ }
3712
+
3713
+ cmd_prices() {
3714
+ local subcmd="${1:-}"
3715
+ shift || true
3716
+ case "$subcmd" in
3717
+ show) cmd_prices_show "$@" ;;
3718
+ refresh) cmd_prices_refresh "$@" ;;
3719
+ --help|-h|help|"") _prices_help ;;
3720
+ *)
3721
+ err "Unknown subcommand: ${subcmd} 未知子命令:${subcmd}"
3722
+ _prices_help >&2
3723
+ return 1
3724
+ ;;
3725
+ esac
3726
+ }
3727
+
3623
3728
  cmd_slides() {
3624
3729
  local subcmd="${1:-}"
3625
3730
  shift || true
@@ -4085,8 +4190,39 @@ _loop_event() {
4085
4190
  esac
4086
4191
  mkdir -p "$(dirname "$evfile")"
4087
4192
 
4088
- # stdout: tab-separated for tmux display
4089
- printf '%s\t%s\t%s\t%s\t%s\n' "$ts" "$stage" "$label" "$detail" "$outcome"
4193
+ # US-LOOP-007: human-friendly stdout for phase_* stages so tmux readers
4194
+ # spot phase boundaries amid claude output. Other stages keep the legacy
4195
+ # tab-separated format (consumers like tests grep on it).
4196
+ case "$stage" in
4197
+ phase_start)
4198
+ local _emoji
4199
+ case "$label" in
4200
+ startup) _emoji="🚀" ;;
4201
+ preflight) _emoji="🔍" ;;
4202
+ worktree_setup) _emoji="🌳" ;;
4203
+ claude_invoke) _emoji="🤖" ;;
4204
+ publish_push) _emoji="📤" ;;
4205
+ publish_wait_merge) _emoji="⏳" ;;
4206
+ cleanup) _emoji="🧹" ;;
4207
+ *) _emoji="▶" ;;
4208
+ esac
4209
+ printf '%s %-22s ─────────\n' "$_emoji" "$label"
4210
+ ;;
4211
+ phase_tick)
4212
+ printf ' ⏱ %-20s ───── %s\n' "$label" "$detail"
4213
+ ;;
4214
+ phase_end)
4215
+ local _mark
4216
+ case "$outcome" in
4217
+ fail) _mark="✗" ;;
4218
+ *) _mark="✓" ;;
4219
+ esac
4220
+ printf ' %s %-20s ───── %s\n' "$_mark" "$label" "$detail"
4221
+ ;;
4222
+ *)
4223
+ printf '%s\t%s\t%s\t%s\t%s\n' "$ts" "$stage" "$label" "$detail" "$outcome"
4224
+ ;;
4225
+ esac
4090
4226
 
4091
4227
  # JSON line appended to NDJSON file. FIX-067: drop the flock/lockf guard.
4092
4228
  # POSIX requires write() ≤ PIPE_BUF (≥512 bytes, 4 KiB on Linux/macOS) to
@@ -4321,9 +4457,47 @@ fi
4321
4457
  printf '%s:%s\n' "\$\$" "\$(date -u +%s)" > "\$INNER_LOCK"
4322
4458
  # FIX-038: background heartbeat writer — outer script uses this as primary liveness signal
4323
4459
  # to detect stale execution without relying on PID reuse heuristics.
4460
+ # US-LOOP-007: heartbeat also emits phase_tick for the current phase so tmux
4461
+ # readers see "still alive in <phase>" during long-running silences (e.g.
4462
+ # claude_invoke 5-45 min). CURRENT_PHASE is maintained by _phase_begin/_phase_end.
4324
4463
  HEARTBEAT_FILE="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/.heartbeat-${slug}"
4464
+ CURRENT_PHASE=""
4465
+ # bash 3.2 (macOS /bin/bash) lacks associative arrays — use namespaced
4466
+ # variables via 'printf -v' + indirect '\${!VAR}' expansion instead.
4467
+ # _PHASE_START_<name> stores the unix-second start ts per phase
4468
+ # _PHASE_DUR_<name> stores the computed duration in seconds per phase
4469
+ # _PHASE_NAMES_DONE space-separated list of completed phase names, in order
4470
+ _PHASE_NAMES_DONE=""
4471
+ _phase_begin() {
4472
+ local _name="\$1"
4473
+ printf -v "_PHASE_START_\${_name}" '%s' "\$(date +%s)"
4474
+ CURRENT_PHASE="\$_name"
4475
+ _loop_event phase_start "\$_name" "" "" || true
4476
+ }
4477
+ _phase_end() {
4478
+ local _name="\$1" _outcome="\${2:-ok}"
4479
+ local _start_var="_PHASE_START_\${_name}"
4480
+ local _start="\${!_start_var:-\$(date +%s)}"
4481
+ local _dur=\$(( \$(date +%s) - _start ))
4482
+ [ "\$_dur" -lt 0 ] && _dur=0
4483
+ printf -v "_PHASE_DUR_\${_name}" '%s' "\$_dur"
4484
+ case " \$_PHASE_NAMES_DONE " in *" \$_name "*) ;; *) _PHASE_NAMES_DONE="\${_PHASE_NAMES_DONE} \$_name" ;; esac
4485
+ CURRENT_PHASE=""
4486
+ _loop_event phase_end "\$_name" "\${_dur}s" "\$_outcome" || true
4487
+ }
4325
4488
  _heartbeat_writer() {
4326
- while true; do echo "\$(date -u +%s)" > "\$HEARTBEAT_FILE"; sleep 60; done
4489
+ while true; do
4490
+ echo "\$(date -u +%s)" > "\$HEARTBEAT_FILE"
4491
+ if [ -n "\$CURRENT_PHASE" ]; then
4492
+ local _start_var="_PHASE_START_\${CURRENT_PHASE}"
4493
+ local _phase_start="\${!_start_var:-}"
4494
+ if [ -n "\$_phase_start" ]; then
4495
+ local _el=\$(( \$(date +%s) - _phase_start ))
4496
+ _loop_event phase_tick "\$CURRENT_PHASE" "\${_el}s elapsed" "" 2>/dev/null || true
4497
+ fi
4498
+ fi
4499
+ sleep 60
4500
+ done
4327
4501
  }
4328
4502
  _heartbeat_writer &
4329
4503
  _HEARTBEAT_PID=\$!
@@ -4343,8 +4517,65 @@ trap '_on_sigterm' TERM
4343
4517
  # US-LOOP-005: idempotent runs.jsonl writer shared by normal exit, timeout
4344
4518
  # trap, and worktree-setup-failure early exit. Guards on jq + run_id dedupe so
4345
4519
  # multiple callers in the same cycle are safe.
4520
+ # US-LOOP-008: build a JSON object {"<phase>": <duration_sec>, ...} from
4521
+ # the ordered list of completed phases. Returns "{}" if no phases ran.
4522
+ _phases_to_json() {
4523
+ command -v jq >/dev/null 2>&1 || { printf '{}'; return 0; }
4524
+ local _name _var _dur _args="" _filter="{}"
4525
+ local _first=1
4526
+ for _name in \$_PHASE_NAMES_DONE; do
4527
+ [ -z "\$_name" ] && continue
4528
+ _var="_PHASE_DUR_\${_name}"
4529
+ _dur="\${!_var:-0}"
4530
+ if [ "\$_first" -eq 1 ]; then
4531
+ _filter="{\\"\${_name}\\": \\\$d_\${_name}}"
4532
+ _first=0
4533
+ else
4534
+ _filter="\${_filter} + {\\"\${_name}\\": \\\$d_\${_name}}"
4535
+ fi
4536
+ _args="\${_args} --argjson d_\${_name} \${_dur}"
4537
+ done
4538
+ if [ "\$_first" -eq 1 ]; then
4539
+ printf '{}'
4540
+ else
4541
+ eval "jq -nc \${_args} '\${_filter}'" 2>/dev/null || printf '{}'
4542
+ fi
4543
+ }
4544
+
4545
+ # US-LOOP-008: print phase breakdown panel sorted by duration desc.
4546
+ # Idle/failed/aborted cycles only list phases that actually entered — no
4547
+ # placeholder rows. Panel is best-effort (skips silently if no phases).
4548
+ _print_phase_breakdown() {
4549
+ [ -n "\$_PHASE_NAMES_DONE" ] || return 0
4550
+ local _name _var _dur _total=0 _rows=""
4551
+ for _name in \$_PHASE_NAMES_DONE; do
4552
+ [ -z "\$_name" ] && continue
4553
+ _var="_PHASE_DUR_\${_name}"
4554
+ _dur="\${!_var:-0}"
4555
+ _total=\$(( _total + _dur ))
4556
+ _rows="\${_rows}\${_dur} \${_name}\n"
4557
+ done
4558
+ [ "\$_total" -le 0 ] && _total=1
4559
+ printf '\\n─── Cycle %s Phase Breakdown ───\\n' "\${CYCLE_ID:-unknown}"
4560
+ printf '%b' "\$_rows" | sort -rn | while read -r _d _n; do
4561
+ [ -z "\$_n" ] && continue
4562
+ local _pct=\$(( (_d * 1000) / _total ))
4563
+ local _pct_str
4564
+ _pct_str=\$(printf '%d.%d%%' \$(( _pct / 10 )) \$(( _pct % 10 )))
4565
+ local _bar="" _bar_len=\$(( (_d * 20) / _total ))
4566
+ [ "\$_bar_len" -gt 0 ] && _bar=\$(printf '█%.0s' \$(seq 1 \$_bar_len))
4567
+ printf ' %-22s %6ds (%6s) %s\\n' "\$_n" "\$_d" "\$_pct_str" "\$_bar"
4568
+ done
4569
+ printf ' %s\\n' "──────────────────────────────────────"
4570
+ printf ' %-22s %6ds\\n\\n' "Total" "\$_total"
4571
+ }
4572
+
4346
4573
  _runs_append() {
4347
4574
  local _status="\$1"; local _tcr="\${2:-0}"; local _built="\${3:-[]}"
4575
+ # bash parameter expansion \${4:-{}} stops at the first \} so the default
4576
+ # leaks a trailing \} into a real 4th arg ("{...}}"). Test explicit empty.
4577
+ local _phases_json="\${4:-}"
4578
+ [ -z "\$_phases_json" ] && _phases_json="{}"
4348
4579
  local _runs_dst="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/runs.jsonl"
4349
4580
  command -v jq >/dev/null 2>&1 || return 0
4350
4581
  local _cid="\${CYCLE_ID:-pre-cycle-\$\$}"
@@ -4366,14 +4597,21 @@ _runs_append() {
4366
4597
  --argjson alerts '[]' \\
4367
4598
  --argjson tcr_count "\$_tcr" \\
4368
4599
  --argjson duration_sec "\$_dur" \\
4600
+ --argjson phases "\$_phases_json" \\
4369
4601
  '{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
4370
4602
  cycle_id:\$cycle_id,
4371
4603
  built:\$built, skipped:\$skipped, alerts:\$alerts,
4372
- tcr_count:\$tcr_count, duration_sec:\$duration_sec}' \\
4604
+ tcr_count:\$tcr_count, duration_sec:\$duration_sec, phases:\$phases}' \\
4373
4605
  >> "\$_runs_dst" 2>/dev/null || true
4374
4606
  }
4375
4607
  _inner_cleanup() {
4376
4608
  local _rc=\$?
4609
+ # US-LOOP-007: close any CURRENT_PHASE that wasn't ended explicitly
4610
+ # (sigterm / set -e fire / aborted exit paths). Marks as fail so the
4611
+ # breakdown panel shows where we died.
4612
+ if [ -n "\${CURRENT_PHASE:-}" ]; then
4613
+ _phase_end "\$CURRENT_PHASE" fail 2>/dev/null || true
4614
+ fi
4377
4615
  # Kill heartbeat + every remaining background job (watchdog, orphan
4378
4616
  # loop-fmt.py, publish subshells) — bash's foreground 'wait' for the
4379
4617
  # pipe has already returned by the time the EXIT trap runs.
@@ -4385,7 +4623,8 @@ _inner_cleanup() {
4385
4623
  # US-LOOP-005 T9: timeout path must also write runs.jsonl row so dashboard
4386
4624
  # has a terminal record (cycle_end alone is insufficient — runs.jsonl is
4387
4625
  # the canonical history feed for 'roll loop runs').
4388
- _runs_append "failed" 0 "[]" 2>/dev/null || true
4626
+ _phases_t=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_t" ] && _phases_t='{}'
4627
+ _runs_append "failed" 0 "[]" "\$_phases_t" 2>/dev/null || true
4389
4628
  _worktree_alert "cycle \${CYCLE_ID:-unknown}: \${LOOP_CYCLE_TIMEOUT_SEC}s timeout — claude/python killed; in-progress story marked Blocked" 2>/dev/null || true
4390
4629
  fi
4391
4630
  # FIX-086: aborted-path orphan safety net. When the inner script is killed
@@ -4430,7 +4669,8 @@ _inner_cleanup() {
4430
4669
  _loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "done" 2>/dev/null || true
4431
4670
  _CYCLE_END_WRITTEN=1
4432
4671
  # FIX-099: pass real tcr_count + built[] instead of 0/"[]"
4433
- _runs_append "done" "\${_orphan_tcr}" "\${_orphan_built}" 2>/dev/null || true
4672
+ _phases_t=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_t" ] && _phases_t='{}'
4673
+ _runs_append "done" "\${_orphan_tcr}" "\${_orphan_built}" "\$_phases_t" 2>/dev/null || true
4434
4674
  # FIX-099: three-field ALERT so callers can distinguish recovered orphan
4435
4675
  # from a cycle's normally-picked story (was: "FIX-091 published as PR"
4436
4676
  # which leaked a hardcoded string regardless of what was actually built).
@@ -4443,7 +4683,8 @@ _inner_cleanup() {
4443
4683
  _loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "orphan" 2>/dev/null || true
4444
4684
  _CYCLE_END_WRITTEN=1
4445
4685
  # FIX-099: pass real tcr_count + built[] for the orphan-tag path too
4446
- _runs_append "orphan" "\${_orphan_tcr}" "\${_orphan_built}" 2>/dev/null || true
4686
+ _phases_t=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_t" ] && _phases_t='{}'
4687
+ _runs_append "orphan" "\${_orphan_tcr}" "\${_orphan_built}" "\$_phases_t" 2>/dev/null || true
4447
4688
  _worktree_alert "cycle \${CYCLE_ID}: recovered_from_orphan=yes; tcr_commits=\${_orphan_tcr}; stories=\${_orphan_built}; FIX-086 pushed orphan tag \${_orphan_tag}" 2>/dev/null || true
4448
4689
  fi
4449
4690
  fi
@@ -4455,7 +4696,8 @@ _inner_cleanup() {
4455
4696
  # "still running" until the next successful cycle rolls past it.
4456
4697
  if [ "\${_CYCLE_END_WRITTEN:-0}" -eq 0 ] && [ -n "\${CYCLE_ID:-}" ]; then
4457
4698
  _loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "aborted" 2>/dev/null || true
4458
- _runs_append "aborted" 0 "[]" 2>/dev/null || true
4699
+ _phases_t=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_t" ] && _phases_t='{}'
4700
+ _runs_append "aborted" 0 "[]" "\$_phases_t" 2>/dev/null || true
4459
4701
  fi
4460
4702
  rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE"
4461
4703
  exit "\$_rc"
@@ -4494,6 +4736,11 @@ CYCLE_START=\$(date +%s)
4494
4736
  WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
4495
4737
  BRANCH="loop/cycle-\${CYCLE_ID}"
4496
4738
  _USE_WORKTREE=0
4739
+ # US-LOOP-007: startup phase covers env / lock / heartbeat setup. End it now
4740
+ # that cycle vars are bound and we're about to do real work.
4741
+ _phase_begin startup
4742
+ _phase_end startup ok
4743
+ _phase_begin preflight
4497
4744
  cd "${project_path}" 2>/dev/null || true
4498
4745
  # FIX-104: GC stale merged temp branches at cycle entry — before worktree setup
4499
4746
  # and before any early-exit gate (pre-run abort, CI red precheck). The post-claude
@@ -4536,6 +4783,8 @@ done
4536
4783
  # US-AUTO-038: snapshot orphan claude/* branches before claude runs so the
4537
4784
  # post-claude cleanup can diff and delete only this session's additions.
4538
4785
  CLAUDE_BRANCH_SNAPSHOT="\$(_claude_remote_snapshot "${project_path}")"
4786
+ _phase_end preflight ok
4787
+ _phase_begin worktree_setup
4539
4788
  if _worktree_fetch_origin main \\
4540
4789
  && _worktree_create "\$WT" "\$BRANCH" "origin/main"; then
4541
4790
  _USE_WORKTREE=1
@@ -4547,16 +4796,19 @@ if _worktree_fetch_origin main \\
4547
4796
  _worktree_sync_meta "\$WT" 2>/dev/null || true
4548
4797
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
4549
4798
  _loop_event cycle_start "\${CYCLE_ID}" "" "" || true
4799
+ _phase_end worktree_setup ok
4550
4800
  else
4551
4801
  # P3 fix: skip the cycle entirely when worktree isolation fails.
4552
4802
  # --dangerously-skip-permissions is only safe paired with worktree isolation;
4553
4803
  # falling back to the main tree without isolation is unacceptable.
4554
4804
  _worktree_alert "cycle \${CYCLE_ID}: worktree setup failed — skipping cycle to avoid running without isolation"
4555
4805
  echo "[loop] cycle \${CYCLE_ID}: worktree setup failed; skipping cycle (no isolation)"
4806
+ _phase_end worktree_setup fail
4556
4807
  # US-LOOP-005 T10: worktree-setup-failed path leaves no commits and never
4557
4808
  # emits cycle_start, but dashboard still needs a runs.jsonl row marking the
4558
4809
  # cycle as failed (otherwise the scheduled tick appears to have vanished).
4559
- _runs_append "failed" 0 "[]" 2>/dev/null || true
4810
+ _phases_t=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_t" ] && _phases_t='{}'
4811
+ _runs_append "failed" 0 "[]" "\$_phases_t" 2>/dev/null || true
4560
4812
  exit 0
4561
4813
  fi
4562
4814
 
@@ -4568,6 +4820,7 @@ FMT="${fmt_script}"
4568
4820
  export LOOP_PROJECT_SLUG="${slug}"
4569
4821
  export LOOP_CYCLE_ID="\${CYCLE_ID}"
4570
4822
  export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
4823
+ _phase_begin claude_invoke
4571
4824
  for _attempt in 1 2 3; do
4572
4825
  # FIX-068: defensive reset before each attempt — _CYCLE_TIMED_OUT carries
4573
4826
  # the SIGTERM result of the previous attempt and would otherwise force an
@@ -4605,6 +4858,12 @@ for _attempt in 1 2 3; do
4605
4858
  fi
4606
4859
  done
4607
4860
 
4861
+ if [ "\$_CYCLE_TIMED_OUT" -eq 1 ] || [ "\$_exit" -ne 0 ]; then
4862
+ _phase_end claude_invoke fail
4863
+ else
4864
+ _phase_end claude_invoke ok
4865
+ fi
4866
+
4608
4867
  # FIX-057: timed out — skip publish; EXIT trap writes cycle_end blocked + ALERT.
4609
4868
  if [ "\$_CYCLE_TIMED_OUT" -eq 1 ]; then
4610
4869
  echo "[loop] cycle \${CYCLE_ID}: \${LOOP_CYCLE_TIMEOUT_SEC}s timeout — aborting cycle (worktree preserved at \$WT)"
@@ -4653,6 +4912,7 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
4653
4912
  else
4654
4913
  _is_doc_only=0
4655
4914
  ( cd "\$WT" && _loop_is_doc_only_change ) && _is_doc_only=1
4915
+ _phase_begin publish_push
4656
4916
  if [ "\$_is_doc_only" -eq 1 ]; then
4657
4917
  ( cd "\$WT" && _loop_publish_doc_pr "\$BRANCH" "doc: loop cycle \${CYCLE_ID}" )
4658
4918
  else
@@ -4660,18 +4920,25 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
4660
4920
  fi
4661
4921
  _publish_status=\$?
4662
4922
  if [ "\$_publish_status" -eq 0 ]; then
4923
+ _phase_end publish_push ok
4663
4924
  # FIX-047: CI green ≠ delivered — wait for actual PR merge before cycle complete
4664
4925
  if [ "\$_is_doc_only" -eq 0 ]; then
4926
+ _phase_begin publish_wait_merge
4665
4927
  if ! ( cd "\$WT" && _loop_wait_pr_merge "\$BRANCH" ); then
4666
4928
  _worktree_alert "cycle \${CYCLE_ID}: FIX-047: PR not merged within timeout — code may not be in main (BRANCH=\${BRANCH})"
4929
+ _phase_end publish_wait_merge fail
4930
+ else
4931
+ _phase_end publish_wait_merge ok
4667
4932
  fi
4668
4933
  fi
4934
+ _phase_begin cleanup
4669
4935
  # US-VIEW-011: emit terminal PR state (merged/closed/open) before cycle_end
4670
4936
  # so dashboard renders #NN ✓/↩/… correctly. Must run while branch ref
4671
4937
  # is still resolvable on remote — gh pr view <branch> needs the head ref.
4672
4938
  _loop_emit_pr_final "\$BRANCH" 2>/dev/null || true
4673
4939
  _worktree_cleanup "\$WT" "\$BRANCH"
4674
4940
  _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true; _CYCLE_END_WRITTEN=1
4941
+ _phase_end cleanup ok
4675
4942
  echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
4676
4943
  elif [ "\$_publish_status" -eq 2 ]; then
4677
4944
  if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
@@ -4726,11 +4993,15 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
4726
4993
  fi
4727
4994
  fi
4728
4995
 
4996
+ # US-LOOP-008: cycle Phase Breakdown panel — printed to stdout (tmux readers)
4997
+ # before runs.jsonl is appended, so the user sees timings even if jq is missing.
4998
+ _print_phase_breakdown 2>/dev/null || true
4999
+ _phases_for_runs=\$(_phases_to_json 2>/dev/null || echo '{}')
4729
5000
  # FIX-044 / Step 5: Write loop cycle run summary to runs.jsonl
4730
5001
  # Deterministic — runs in shell regardless of whether agent executes SKILL.md Step 5.
4731
5002
  # US-LOOP-005: now routed through _runs_append so timeout/worktree-setup-fail
4732
5003
  # share the same write logic. _runs_append is idempotent on run_id.
4733
- _runs_append "\$_cycle_status" "\$_cycle_tcr" "\$_cycle_built" 2>/dev/null || true
5004
+ _runs_append "\$_cycle_status" "\$_cycle_tcr" "\$_cycle_built" "\$_phases_for_runs" 2>/dev/null || true
4734
5005
  INNER
4735
5006
  chmod +x "$inner_path"
4736
5007
 
@@ -5453,6 +5724,71 @@ _loop_attach() {
5453
5724
  }
5454
5725
 
5455
5726
  # Pretty-print a duration in seconds as "Xs" / "Ym" / "Yh Zm".
5727
+ # US-VIEW-019: compute slowest phase + % from a JSON line's phases object.
5728
+ # Returns "<abbr> <pct>%" (e.g. "claude 97%") or empty when no phases data.
5729
+ # Abbreviations match the AC: claude_invoke→claude, publish_wait_merge→pr-wait,
5730
+ # publish_push→publish, worktree_setup→worktree; others unchanged.
5731
+ _loop_runs_slowest_phase() {
5732
+ local line="$1"
5733
+ command -v jq >/dev/null 2>&1 || return 0
5734
+ local total max_name max_dur
5735
+ total=$(jq -r '(.phases // {}) | to_entries | map(.value) | add // 0' <<<"$line")
5736
+ [ -z "$total" ] || [ "$total" = "0" ] || [ "$total" = "null" ] && return 0
5737
+ max_name=$(jq -r '(.phases // {}) | to_entries | sort_by(-.value) | .[0].key // ""' <<<"$line")
5738
+ max_dur=$(jq -r '(.phases // {}) | to_entries | sort_by(-.value) | .[0].value // 0' <<<"$line")
5739
+ [ -z "$max_name" ] && return 0
5740
+ local abbr="$max_name"
5741
+ case "$max_name" in
5742
+ claude_invoke) abbr="claude" ;;
5743
+ publish_wait_merge) abbr="pr-wait" ;;
5744
+ publish_push) abbr="publish" ;;
5745
+ worktree_setup) abbr="worktree" ;;
5746
+ esac
5747
+ local pct=$(( (max_dur * 100 + total / 2) / total ))
5748
+ printf '%s %d%%' "$abbr" "$pct"
5749
+ }
5750
+
5751
+ # US-VIEW-019: render the full Phase Breakdown panel for a cycle by re-using
5752
+ # the same shape that the inner runner script prints. Reads runs.jsonl,
5753
+ # locates the row by cycle_id, prints sorted-desc table.
5754
+ _loop_runs_detail() {
5755
+ local cycle_id="$1"
5756
+ command -v jq >/dev/null 2>&1 || { err "jq required for --detail"; return 1; }
5757
+ if [[ ! -f "$_LOOP_RUNS" ]]; then
5758
+ echo "No runs.jsonl yet 尚无运行记录"
5759
+ return 0
5760
+ fi
5761
+ local row
5762
+ row=$(jq -c --arg cid "$cycle_id" 'select(.cycle_id == $cid)' "$_LOOP_RUNS" | head -1)
5763
+ if [[ -z "$row" ]]; then
5764
+ echo "Cycle not found: $cycle_id 未找到对应 cycle"
5765
+ return 1
5766
+ fi
5767
+ local has_phases
5768
+ has_phases=$(jq -r '(.phases // {}) | length' <<<"$row")
5769
+ if [[ "$has_phases" == "0" ]]; then
5770
+ echo "Cycle $cycle_id has no phases data (pre-US-LOOP-008) 无 phase 数据"
5771
+ return 0
5772
+ fi
5773
+ echo ""
5774
+ echo "─── Cycle $cycle_id Phase Breakdown ───"
5775
+ local total
5776
+ total=$(jq -r '(.phases // {}) | to_entries | map(.value) | add // 0' <<<"$row")
5777
+ [ "$total" -le 0 ] && total=1
5778
+ jq -r '(.phases // {}) | to_entries | sort_by(-.value) | .[] | "\(.value) \(.key)"' <<<"$row" \
5779
+ | while read -r dur name; do
5780
+ [[ -z "$name" ]] && continue
5781
+ local pct=$(( (dur * 1000) / total ))
5782
+ local pct_str
5783
+ pct_str=$(printf '%d.%d%%' $((pct / 10)) $((pct % 10)))
5784
+ local bar="" bar_len=$(( (dur * 20) / total ))
5785
+ [ "$bar_len" -gt 0 ] && bar=$(printf '█%.0s' $(seq 1 "$bar_len"))
5786
+ printf ' %-22s %6ds (%6s) %s\n' "$name" "$dur" "$pct_str" "$bar"
5787
+ done
5788
+ echo " ──────────────────────────────────────"
5789
+ printf ' %-22s %6ds\n\n' "Total" "$total"
5790
+ }
5791
+
5456
5792
  _loop_runs_dur() {
5457
5793
  local s="${1:-0}"
5458
5794
  if [[ "$s" -lt 60 ]]; then printf "%ds" "$s"
@@ -5497,8 +5833,12 @@ _loop_runs_format_line() {
5497
5833
  local skipped_note=""
5498
5834
  [[ "$skipped_count" -gt 0 ]] && skipped_note=", ${skipped_count} skipped"
5499
5835
  local items_word; [[ "$built_count" -eq 1 ]] && items_word="item" || items_word="items"
5500
- printf " %s %s✅ built %d %s (%d tcr%s, %s)\n" \
5501
- "$hhmm" "$prefix" "$built_count" "$items_word" "$tcr" "$skipped_note" "$(_loop_runs_dur "$duration")"
5836
+ # US-VIEW-019: append slowest phase summary when phases data is present.
5837
+ local slowest_note=""
5838
+ local slowest_str; slowest_str=$(_loop_runs_slowest_phase "$line")
5839
+ [[ -n "$slowest_str" ]] && slowest_note=", slowest=${slowest_str}"
5840
+ printf " %s %s✅ built %d %s (%d tcr%s, %s%s)\n" \
5841
+ "$hhmm" "$prefix" "$built_count" "$items_word" "$tcr" "$skipped_note" "$(_loop_runs_dur "$duration")" "$slowest_note"
5502
5842
  local id desc
5503
5843
  while IFS= read -r id; do
5504
5844
  [[ -z "$id" ]] && continue
@@ -5535,15 +5875,23 @@ _loop_runs_format_line() {
5535
5875
 
5536
5876
  # `roll loop runs [N] [--all]` — show recent loop iteration summaries.
5537
5877
  _loop_runs() {
5538
- local n=10 all_flag=false
5878
+ local n=10 all_flag=false detail_cycle=""
5539
5879
  while [[ $# -gt 0 ]]; do
5540
5880
  case "$1" in
5541
5881
  --all) all_flag=true; shift ;;
5882
+ --detail) detail_cycle="${2:-}"; shift 2 ;;
5883
+ --detail=*) detail_cycle="${1#--detail=}"; shift ;;
5542
5884
  [0-9]*) n="$1"; shift ;;
5543
5885
  *) shift ;;
5544
5886
  esac
5545
5887
  done
5546
5888
 
5889
+ # US-VIEW-019: --detail <cycle_id> prints the Phase Breakdown panel.
5890
+ if [[ -n "$detail_cycle" ]]; then
5891
+ _loop_runs_detail "$detail_cycle"
5892
+ return $?
5893
+ fi
5894
+
5547
5895
  # FIX-060: refresh merged status before reading, so paused-window merges show up.
5548
5896
  _loop_backfill_merged >/dev/null 2>&1 || true
5549
5897
 
@@ -6872,6 +7220,9 @@ _loop_wait_pr_merge() {
6872
7220
  local elapsed=0
6873
7221
  local slug; _gh_resolve slug || return 0
6874
7222
  while (( elapsed < timeout )); do
7223
+ # US-LOOP-007: emit phase_tick at the top of each poll iteration so the
7224
+ # tmux reader sees "still waiting" every 30s during merge wait.
7225
+ (( elapsed > 0 )) && _loop_event phase_tick publish_wait_merge "${elapsed}s elapsed" "" 2>/dev/null || true
6875
7226
  local state; state=$(gh -R "$slug" pr view "$branch" --json state -q .state 2>/dev/null || echo "UNKNOWN")
6876
7227
  case "$state" in
6877
7228
  MERGED) return 0 ;;
@@ -7323,6 +7674,57 @@ cmd_alert() {
7323
7674
  esac
7324
7675
  }
7325
7676
 
7677
+ # ═══════════════════════════════════════════════════════════════════════════════
7678
+ # LANG — switch / inspect Roll's UI language (US-I18N-001)
7679
+ # ═══════════════════════════════════════════════════════════════════════════════
7680
+
7681
+ cmd_lang() {
7682
+ local arg="${1:-}"
7683
+
7684
+ case "$arg" in
7685
+ "")
7686
+ unset ROLL_LANG_RESOLVED
7687
+ local current src
7688
+ current=$(_i18n_resolve_lang)
7689
+ if [[ -n "${ROLL_LANG:-}" ]]; then
7690
+ src="ROLL_LANG env"
7691
+ elif [[ -f "$ROLL_CONFIG" ]] && grep -qE '^lang:' "$ROLL_CONFIG"; then
7692
+ src="config (${ROLL_CONFIG})"
7693
+ elif [[ -n "${LC_ALL:-}" || -n "${LANG:-}" ]]; then
7694
+ src="LC_ALL/LANG"
7695
+ else
7696
+ src="default"
7697
+ fi
7698
+ echo "current: ${current}, source: ${src}"
7699
+ ;;
7700
+ zh|en)
7701
+ mkdir -p "$(dirname "$ROLL_CONFIG")"
7702
+ [[ -f "$ROLL_CONFIG" ]] || : > "$ROLL_CONFIG"
7703
+ local tmp; tmp=$(mktemp)
7704
+ grep -vE '^lang:' "$ROLL_CONFIG" > "$tmp" || true
7705
+ printf 'lang: %s\n' "$arg" >> "$tmp"
7706
+ mv "$tmp" "$ROLL_CONFIG"
7707
+ unset ROLL_LANG_RESOLVED
7708
+ ok "Language set to ${arg} 语言已设置为 ${arg}"
7709
+ ;;
7710
+ --reset)
7711
+ if [[ -f "$ROLL_CONFIG" ]]; then
7712
+ local tmp; tmp=$(mktemp)
7713
+ grep -vE '^lang:' "$ROLL_CONFIG" > "$tmp" || true
7714
+ mv "$tmp" "$ROLL_CONFIG"
7715
+ fi
7716
+ unset ROLL_LANG_RESOLVED
7717
+ ok "Language preference cleared (will follow locale) 语言偏好已清除(跟随系统 locale)"
7718
+ ;;
7719
+ *)
7720
+ err "Unknown language: ${arg} 未知语言: ${arg}"
7721
+ echo " Valid values: zh, en, --reset"
7722
+ echo " 可选值: zh, en, --reset"
7723
+ return 1
7724
+ ;;
7725
+ esac
7726
+ }
7727
+
7326
7728
  # ═══════════════════════════════════════════════════════════════════════════════
7327
7729
 
7328
7730
  cmd_ci() {
@@ -8117,11 +8519,13 @@ main() {
8117
8519
  brief) cmd_brief "$@" ;;
8118
8520
  backlog) cmd_backlog "$@" ;;
8119
8521
  alert) cmd_alert "$@" ;;
8522
+ lang) cmd_lang "$@" ;;
8120
8523
  agent) cmd_agent "$@" ;;
8121
8524
  ci) cmd_ci "$@" ;;
8122
8525
  doctor) cmd_doctor "$@" ;;
8123
8526
  review-pr) cmd_review_pr "$@" ;;
8124
8527
  slides) cmd_slides "$@" ;;
8528
+ prices) cmd_prices "$@" ;;
8125
8529
  version|--version|-v) echo "roll v${VERSION}" ;;
8126
8530
  help|--help|-h) _help "$@" ;;
8127
8531
  "") [[ -f ".roll/backlog.md" ]] && _home || { _help; _show_changelog; } ;;