@seanyao/roll 2026.522.2 → 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.522.2"
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'
@@ -322,16 +326,25 @@ safe_copy() {
322
326
  if diff -q "$src" "$dst" &>/dev/null; then
323
327
  return # identical, skip silently
324
328
  fi
329
+ # Non-interactive (stdin is not a terminal): silently overwrite.
330
+ # _run_setup_step / cmd_update redirect stdin to /dev/null and all
331
+ # stdout/stderr is suppressed — prompting here would either hang on a
332
+ # hidden read or silently default to overwrite. Be explicit.
333
+ if [[ ! -t 0 ]]; then
334
+ cp "$src" "$dst"
335
+ ok "Wrote: ${dst/#$HOME/~} 已写入: ${dst/#$HOME/~}"
336
+ return
337
+ fi
325
338
  echo ""
326
339
  warn "File exists and differs: ${dst/#$HOME/~} 文件已存在且内容不同: ${dst/#$HOME/~}"
327
340
  echo -e " ${BOLD}Overwrite?${NC} [Y/n/d(iff)] "
328
- read -r answer
341
+ read -r answer || answer="Y"
329
342
  case "$answer" in
330
343
  d|D|diff)
331
344
  diff --color=auto "$dst" "$src" || true
332
345
  echo ""
333
346
  echo -e " ${BOLD}Overwrite?${NC} [Y/n] "
334
- read -r answer2
347
+ read -r answer2 || answer2="Y"
335
348
  [[ "$answer2" =~ ^[Nn]$ ]] && { info "Skipped: ${dst/#$HOME/\~} 已跳过: ${dst/#$HOME/\~}"; return; }
336
349
  ;;
337
350
  n|N) info "Skipped: ${dst/#$HOME/~} 已跳过: ${dst/#$HOME/~}"; return ;;
@@ -726,7 +739,7 @@ _run_setup_step() {
726
739
  local watch="$1"; shift
727
740
  local before after
728
741
  before=$(_setup_snapshot "$watch")
729
- if "$@" >/dev/null 2>&1; then
742
+ if "$@" </dev/null >/dev/null 2>&1; then
730
743
  after=$(_setup_snapshot "$watch")
731
744
  if [[ "$before" == "$after" ]]; then
732
745
  _ROLL_SETUP_STATE="unchanged"
@@ -883,6 +896,40 @@ HINT
883
896
  # in (or opted out) don't get spammed each upgrade.
884
897
  cmd_doctor() {
885
898
  _doctor_pr_section
899
+ _doctor_launchd_stale_section
900
+ }
901
+
902
+ # FIX-097: scan ${_LAUNCHD_DIR}/com.roll.*.plist for entries whose
903
+ # WorkingDirectory no longer exists on disk. These are the ghost agents left
904
+ # behind when a user manually reproduces a bug under /private/tmp/ or
905
+ # /var/folders/ — the auto-sandbox redirects plist writes but launchctl
906
+ # bootstrap (before this fix) registered them anyway. Print labels +
907
+ # cleanup hint; never auto-delete (host launchctl state is user-owned).
908
+ _doctor_launchd_stale_section() {
909
+ [[ "$(uname)" == "Darwin" ]] || return 0
910
+ local dir="${_LAUNCHD_DIR:-${HOME}/Library/LaunchAgents}"
911
+ [[ -d "$dir" ]] || return 0
912
+
913
+ local found=0 plist label wd
914
+ for plist in "$dir"/com.roll.*.plist; do
915
+ [[ -e "$plist" ]] || continue
916
+ wd=$(awk '
917
+ /<key>WorkingDirectory<\/key>/ { getline; gsub(/.*<string>|<\/string>.*/, ""); print; exit }
918
+ ' "$plist" 2>/dev/null)
919
+ [[ -n "$wd" ]] || continue
920
+ [[ -d "$wd" ]] && continue
921
+ if [[ "$found" -eq 0 ]]; then
922
+ echo ""
923
+ echo "Stale launchd plists 无效的 launchd 服务"
924
+ echo ""
925
+ found=1
926
+ fi
927
+ label=$(basename "$plist" .plist)
928
+ echo " ⚠ ${label}"
929
+ echo " WorkingDirectory missing: ${wd}"
930
+ echo " 路径已失效,可清理: launchctl bootout gui/$(id -u)/${label}; rm '${plist}'"
931
+ done
932
+ return 0
886
933
  }
887
934
 
888
935
  _doctor_pr_section() {
@@ -1904,7 +1951,7 @@ PY
1904
1951
  fi
1905
1952
  if [ "${#plists[@]}" -gt 0 ]; then
1906
1953
  for item in "${plists[@]}"; do
1907
- launchctl unload -w "$HOME/Library/LaunchAgents/$item" 2>/dev/null && echo " unloaded $item"
1954
+ _launchctl_safe unload -w "$HOME/Library/LaunchAgents/$item" 2>/dev/null && echo " unloaded $item"
1908
1955
  rm -f "$HOME/Library/LaunchAgents/$item" 2>/dev/null
1909
1956
  done
1910
1957
  fi
@@ -3577,6 +3624,107 @@ EOF
3577
3624
  return "$rc"
3578
3625
  }
3579
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
+
3580
3728
  cmd_slides() {
3581
3729
  local subcmd="${1:-}"
3582
3730
  shift || true
@@ -3892,7 +4040,7 @@ PYEOF
3892
4040
 
3893
4041
  local old_plist=~/Library/LaunchAgents/com.roll.loop.${old_slug}.plist
3894
4042
  if [[ -f "$old_plist" ]]; then
3895
- launchctl unload "$old_plist" 2>/dev/null || true
4043
+ _launchctl_safe unload "$old_plist" 2>/dev/null || true
3896
4044
  rm -f "$old_plist"
3897
4045
  fi
3898
4046
 
@@ -3969,6 +4117,13 @@ if [ -z "${_LAUNCHD_DIR:-}" ]; then
3969
4117
  _LAUNCHD_DIR="${_SHARED_ROOT}/LaunchAgents"
3970
4118
  mkdir -p "$_LAUNCHD_DIR"
3971
4119
  export _LAUNCHD_DIR
4120
+ # FIX-097: same trigger that sandboxed the plist FILE path must also
4121
+ # short-circuit every `launchctl bootstrap/load/unload/enable` against
4122
+ # that path. Otherwise a user who reproduces a bug under /private/tmp/
4123
+ # or /var/folders/ ends up with sandboxed plists registered in their
4124
+ # real gui/<uid> domain — when the tmp dir is cleaned, the agents become
4125
+ # ghosts that fire forever (the historical 23:13 CST Terminal popup).
4126
+ export _LAUNCHD_SKIP_REGISTRY=1
3972
4127
  fi
3973
4128
  unset _roll_in_test_ctx _roll_caller
3974
4129
  ;;
@@ -4035,8 +4190,39 @@ _loop_event() {
4035
4190
  esac
4036
4191
  mkdir -p "$(dirname "$evfile")"
4037
4192
 
4038
- # stdout: tab-separated for tmux display
4039
- 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
4040
4226
 
4041
4227
  # JSON line appended to NDJSON file. FIX-067: drop the flock/lockf guard.
4042
4228
  # POSIX requires write() ≤ PIPE_BUF (≥512 bytes, 4 KiB on Linux/macOS) to
@@ -4095,6 +4281,25 @@ _launchd_label() {
4095
4281
  printf 'com.roll.%s.%s' "$service" "$(_project_slug "$project_path")"
4096
4282
  }
4097
4283
 
4284
+ # FIX-097: central skip predicate consulted by every launchctl invocation that
4285
+ # operates on a plist path Roll wrote. Returns 0 (skip) when either:
4286
+ # - explicit: _LAUNCHD_SKIP_REGISTRY=1 was exported (tests, future opt-out)
4287
+ # - implicit: _LAUNCHD_DIR is a child of _SHARED_ROOT (auto-sandbox active)
4288
+ # Returns 1 (do not skip) in production.
4289
+ #
4290
+ # History: FIX-090 introduced the same logic INSIDE _install_launchd_plists.
4291
+ # FIX-097 hoists it to a helper because the bootstrap call inside
4292
+ # _install_launchd_plists was not the only leak: _loop_on / _loop_off /
4293
+ # _loop_pause / _loop_resume each had bare `launchctl load/unload/enable`
4294
+ # calls that bypassed the gate.
4295
+ _launchd_should_skip_registry() {
4296
+ [[ "${_LAUNCHD_SKIP_REGISTRY:-}" == "1" ]] && return 0
4297
+ case "${_LAUNCHD_DIR:-}/" in
4298
+ "${_SHARED_ROOT:-/nonexistent}"/*) return 0 ;;
4299
+ esac
4300
+ return 1
4301
+ }
4302
+
4098
4303
  _launchd_plist_path() {
4099
4304
  local service="$1" project_path="$2"
4100
4305
  printf '%s/%s.plist' "$_LAUNCHD_DIR" "$(_launchd_label "$service" "$project_path")"
@@ -4123,16 +4328,34 @@ _write_launchd_plist() {
4123
4328
  ;;
4124
4329
  esac
4125
4330
 
4126
- local hour_xml=""
4127
- [[ -n "$hour" ]] && hour_xml=" <key>Hour</key>
4128
- <integer>${hour}</integer>
4129
- "
4130
-
4131
4331
  # FIX-050: bake PATH into the plist so launchd-spawned bash can find tmux,
4132
4332
  # claude, node, etc. The runner script also re-asserts PATH at runtime as
4133
4333
  # a second layer (covers stale plists where brew was installed after setup).
4134
4334
  local path_value; path_value=$(_detect_path_prepend)
4135
4335
 
4336
+ # FIX-105: macOS 26.4 launchd silently refuses to fire StartCalendarInterval
4337
+ # entries that contain BOTH Hour and Minute keys (verified: runs stays 0,
4338
+ # last exit "never exited", no log output, the calendarinterval trigger is
4339
+ # registered but never invoked by UserEventAgent-Aqua). Single-Minute (hourly)
4340
+ # entries still fire fine. Workaround: when an Hour is provided (daily
4341
+ # schedule), emit StartInterval=86400 (24h period) instead. First fire is
4342
+ # bootstrap+24h rather than the exact requested wall-clock time — acceptable
4343
+ # trade since the alternative was "never fires at all" (dream/brief broken
4344
+ # for 4+ days). The Minute/Hour args are still kept in the function signature
4345
+ # for callers that may want to filter at runtime, but they no longer steer
4346
+ # the plist trigger format for daily schedules.
4347
+ local schedule_xml
4348
+ if [[ -n "$hour" ]]; then
4349
+ schedule_xml=" <key>StartInterval</key>
4350
+ <integer>86400</integer>"
4351
+ else
4352
+ schedule_xml=" <key>StartCalendarInterval</key>
4353
+ <dict>
4354
+ <key>Minute</key>
4355
+ <integer>${minute}</integer>
4356
+ </dict>"
4357
+ fi
4358
+
4136
4359
  local content
4137
4360
  content="<?xml version=\"1.0\" encoding=\"UTF-8\"?>
4138
4361
  <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
@@ -4151,11 +4374,7 @@ _write_launchd_plist() {
4151
4374
  <key>PATH</key>
4152
4375
  <string>${path_value}</string>
4153
4376
  </dict>
4154
- <key>StartCalendarInterval</key>
4155
- <dict>
4156
- <key>Minute</key>
4157
- <integer>${minute}</integer>
4158
- ${hour_xml} </dict>
4377
+ ${schedule_xml}
4159
4378
  <key>WorkingDirectory</key>
4160
4379
  <string>${project_path}</string>
4161
4380
  </dict>
@@ -4238,9 +4457,47 @@ fi
4238
4457
  printf '%s:%s\n' "\$\$" "\$(date -u +%s)" > "\$INNER_LOCK"
4239
4458
  # FIX-038: background heartbeat writer — outer script uses this as primary liveness signal
4240
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.
4241
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
+ }
4242
4488
  _heartbeat_writer() {
4243
- 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
4244
4501
  }
4245
4502
  _heartbeat_writer &
4246
4503
  _HEARTBEAT_PID=\$!
@@ -4260,8 +4517,65 @@ trap '_on_sigterm' TERM
4260
4517
  # US-LOOP-005: idempotent runs.jsonl writer shared by normal exit, timeout
4261
4518
  # trap, and worktree-setup-failure early exit. Guards on jq + run_id dedupe so
4262
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
+
4263
4573
  _runs_append() {
4264
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="{}"
4265
4579
  local _runs_dst="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/runs.jsonl"
4266
4580
  command -v jq >/dev/null 2>&1 || return 0
4267
4581
  local _cid="\${CYCLE_ID:-pre-cycle-\$\$}"
@@ -4283,14 +4597,21 @@ _runs_append() {
4283
4597
  --argjson alerts '[]' \\
4284
4598
  --argjson tcr_count "\$_tcr" \\
4285
4599
  --argjson duration_sec "\$_dur" \\
4600
+ --argjson phases "\$_phases_json" \\
4286
4601
  '{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
4287
4602
  cycle_id:\$cycle_id,
4288
4603
  built:\$built, skipped:\$skipped, alerts:\$alerts,
4289
- tcr_count:\$tcr_count, duration_sec:\$duration_sec}' \\
4604
+ tcr_count:\$tcr_count, duration_sec:\$duration_sec, phases:\$phases}' \\
4290
4605
  >> "\$_runs_dst" 2>/dev/null || true
4291
4606
  }
4292
4607
  _inner_cleanup() {
4293
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
4294
4615
  # Kill heartbeat + every remaining background job (watchdog, orphan
4295
4616
  # loop-fmt.py, publish subshells) — bash's foreground 'wait' for the
4296
4617
  # pipe has already returned by the time the EXIT trap runs.
@@ -4302,7 +4623,8 @@ _inner_cleanup() {
4302
4623
  # US-LOOP-005 T9: timeout path must also write runs.jsonl row so dashboard
4303
4624
  # has a terminal record (cycle_end alone is insufficient — runs.jsonl is
4304
4625
  # the canonical history feed for 'roll loop runs').
4305
- _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
4306
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
4307
4629
  fi
4308
4630
  # FIX-086: aborted-path orphan safety net. When the inner script is killed
@@ -4329,13 +4651,30 @@ _inner_cleanup() {
4329
4651
  # FIX-091: prefer a real PR so auto-merge lands the work; tag-only is the
4330
4652
  # last-resort because it requires manual cherry-pick. Emit cycle_end "done"
4331
4653
  # (canonical success status the dashboard recognizes) when PR publishes.
4654
+ # FIX-099: compute tcr_count + built[] from the worktree (it's still alive
4655
+ # at EXIT trap time) so runs.jsonl and ALERT carry truthful data.
4656
+ _orphan_tcr=0
4657
+ _orphan_built="[]"
4658
+ if command -v jq >/dev/null 2>&1; then
4659
+ _orphan_tcr=\$(cd "\$WT" && git log --oneline "origin/main..HEAD" 2>/dev/null | grep -c ' tcr:' || echo 0)
4660
+ _orphan_built=\$(cd "\$WT" && git log --oneline "origin/main..HEAD" 2>/dev/null \
4661
+ | grep ' tcr:' \
4662
+ | grep -oE '\b(FIX|US|REFACTOR|CHORE)-[0-9]+\b' \
4663
+ | sort -u \
4664
+ | jq -R -s 'split("\n") | map(select(length>0))' 2>/dev/null || echo "[]")
4665
+ fi
4332
4666
  _slug=""
4333
4667
  if _gh_resolve _slug \\
4334
4668
  && ( cd "\$WT" && _loop_publish_pr "\$BRANCH" "loop cycle \${CYCLE_ID}" ) >/dev/null 2>&1; then
4335
4669
  _loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "done" 2>/dev/null || true
4336
4670
  _CYCLE_END_WRITTEN=1
4337
- _runs_append "done" 0 "[]" 2>/dev/null || true
4338
- _worktree_alert "cycle \${CYCLE_ID}: aborted with \${_unpushed} commits; FIX-091 published as PR" 2>/dev/null || true
4671
+ # FIX-099: pass real tcr_count + built[] instead of 0/"[]"
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
4674
+ # FIX-099: three-field ALERT so callers can distinguish recovered orphan
4675
+ # from a cycle's normally-picked story (was: "FIX-091 published as PR"
4676
+ # which leaked a hardcoded string regardless of what was actually built).
4677
+ _worktree_alert "cycle \${CYCLE_ID}: recovered_from_orphan=yes; tcr_commits=\${_orphan_tcr}; stories=\${_orphan_built}; pr_branch=\${BRANCH:-unknown}" 2>/dev/null || true
4339
4678
  else
4340
4679
  _orphan_tag="loop-orphan-\${CYCLE_ID}"
4341
4680
  if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \\
@@ -4343,8 +4682,10 @@ _inner_cleanup() {
4343
4682
  && git push origin "\$_orphan_tag" 2>/dev/null ); then
4344
4683
  _loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "orphan" 2>/dev/null || true
4345
4684
  _CYCLE_END_WRITTEN=1
4346
- _runs_append "orphan" 0 "[]" 2>/dev/null || true
4347
- _worktree_alert "cycle \${CYCLE_ID}: aborted with \${_unpushed} commits; FIX-086 pushed orphan tag \${_orphan_tag}" 2>/dev/null || true
4685
+ # FIX-099: pass real tcr_count + built[] for the orphan-tag path too
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
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
4348
4689
  fi
4349
4690
  fi
4350
4691
  fi
@@ -4355,7 +4696,8 @@ _inner_cleanup() {
4355
4696
  # "still running" until the next successful cycle rolls past it.
4356
4697
  if [ "\${_CYCLE_END_WRITTEN:-0}" -eq 0 ] && [ -n "\${CYCLE_ID:-}" ]; then
4357
4698
  _loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "aborted" 2>/dev/null || true
4358
- _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
4359
4701
  fi
4360
4702
  rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE"
4361
4703
  exit "\$_rc"
@@ -4394,7 +4736,16 @@ CYCLE_START=\$(date +%s)
4394
4736
  WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
4395
4737
  BRANCH="loop/cycle-\${CYCLE_ID}"
4396
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
4397
4744
  cd "${project_path}" 2>/dev/null || true
4745
+ # FIX-104: GC stale merged temp branches at cycle entry — before worktree setup
4746
+ # and before any early-exit gate (pre-run abort, CI red precheck). The post-claude
4747
+ # call site doesn't cover those paths, so merged branches accumulated on origin.
4748
+ _loop_cleanup_stale_cycle_branches "${project_path}" || true
4398
4749
  # FIX-040: orphan worktree recovery — scan for worktrees left by previous failed
4399
4750
  # cycles (publish failed or inner script was SIGKILL'd). Attempt to publish each
4400
4751
  # before starting the new cycle. Glob is chronological via timestamp in name.
@@ -4432,6 +4783,8 @@ done
4432
4783
  # US-AUTO-038: snapshot orphan claude/* branches before claude runs so the
4433
4784
  # post-claude cleanup can diff and delete only this session's additions.
4434
4785
  CLAUDE_BRANCH_SNAPSHOT="\$(_claude_remote_snapshot "${project_path}")"
4786
+ _phase_end preflight ok
4787
+ _phase_begin worktree_setup
4435
4788
  if _worktree_fetch_origin main \\
4436
4789
  && _worktree_create "\$WT" "\$BRANCH" "origin/main"; then
4437
4790
  _USE_WORKTREE=1
@@ -4443,16 +4796,19 @@ if _worktree_fetch_origin main \\
4443
4796
  _worktree_sync_meta "\$WT" 2>/dev/null || true
4444
4797
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
4445
4798
  _loop_event cycle_start "\${CYCLE_ID}" "" "" || true
4799
+ _phase_end worktree_setup ok
4446
4800
  else
4447
4801
  # P3 fix: skip the cycle entirely when worktree isolation fails.
4448
4802
  # --dangerously-skip-permissions is only safe paired with worktree isolation;
4449
4803
  # falling back to the main tree without isolation is unacceptable.
4450
4804
  _worktree_alert "cycle \${CYCLE_ID}: worktree setup failed — skipping cycle to avoid running without isolation"
4451
4805
  echo "[loop] cycle \${CYCLE_ID}: worktree setup failed; skipping cycle (no isolation)"
4806
+ _phase_end worktree_setup fail
4452
4807
  # US-LOOP-005 T10: worktree-setup-failed path leaves no commits and never
4453
4808
  # emits cycle_start, but dashboard still needs a runs.jsonl row marking the
4454
4809
  # cycle as failed (otherwise the scheduled tick appears to have vanished).
4455
- _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
4456
4812
  exit 0
4457
4813
  fi
4458
4814
 
@@ -4464,6 +4820,7 @@ FMT="${fmt_script}"
4464
4820
  export LOOP_PROJECT_SLUG="${slug}"
4465
4821
  export LOOP_CYCLE_ID="\${CYCLE_ID}"
4466
4822
  export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
4823
+ _phase_begin claude_invoke
4467
4824
  for _attempt in 1 2 3; do
4468
4825
  # FIX-068: defensive reset before each attempt — _CYCLE_TIMED_OUT carries
4469
4826
  # the SIGTERM result of the previous attempt and would otherwise force an
@@ -4501,6 +4858,12 @@ for _attempt in 1 2 3; do
4501
4858
  fi
4502
4859
  done
4503
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
+
4504
4867
  # FIX-057: timed out — skip publish; EXIT trap writes cycle_end blocked + ALERT.
4505
4868
  if [ "\$_CYCLE_TIMED_OUT" -eq 1 ]; then
4506
4869
  echo "[loop] cycle \${CYCLE_ID}: \${LOOP_CYCLE_TIMEOUT_SEC}s timeout — aborting cycle (worktree preserved at \$WT)"
@@ -4549,6 +4912,7 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
4549
4912
  else
4550
4913
  _is_doc_only=0
4551
4914
  ( cd "\$WT" && _loop_is_doc_only_change ) && _is_doc_only=1
4915
+ _phase_begin publish_push
4552
4916
  if [ "\$_is_doc_only" -eq 1 ]; then
4553
4917
  ( cd "\$WT" && _loop_publish_doc_pr "\$BRANCH" "doc: loop cycle \${CYCLE_ID}" )
4554
4918
  else
@@ -4556,18 +4920,25 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
4556
4920
  fi
4557
4921
  _publish_status=\$?
4558
4922
  if [ "\$_publish_status" -eq 0 ]; then
4923
+ _phase_end publish_push ok
4559
4924
  # FIX-047: CI green ≠ delivered — wait for actual PR merge before cycle complete
4560
4925
  if [ "\$_is_doc_only" -eq 0 ]; then
4926
+ _phase_begin publish_wait_merge
4561
4927
  if ! ( cd "\$WT" && _loop_wait_pr_merge "\$BRANCH" ); then
4562
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
4563
4932
  fi
4564
4933
  fi
4934
+ _phase_begin cleanup
4565
4935
  # US-VIEW-011: emit terminal PR state (merged/closed/open) before cycle_end
4566
4936
  # so dashboard renders #NN ✓/↩/… correctly. Must run while branch ref
4567
4937
  # is still resolvable on remote — gh pr view <branch> needs the head ref.
4568
4938
  _loop_emit_pr_final "\$BRANCH" 2>/dev/null || true
4569
4939
  _worktree_cleanup "\$WT" "\$BRANCH"
4570
4940
  _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true; _CYCLE_END_WRITTEN=1
4941
+ _phase_end cleanup ok
4571
4942
  echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
4572
4943
  elif [ "\$_publish_status" -eq 2 ]; then
4573
4944
  if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
@@ -4622,14 +4993,15 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
4622
4993
  fi
4623
4994
  fi
4624
4995
 
4625
- # US-AUTO-040: fallback GC delete remote loop/cycle-* branches already merged to main.
4626
- _loop_cleanup_stale_cycle_branches "${project_path}" || true
4627
-
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 '{}')
4628
5000
  # FIX-044 / Step 5: Write loop cycle run summary to runs.jsonl
4629
5001
  # Deterministic — runs in shell regardless of whether agent executes SKILL.md Step 5.
4630
5002
  # US-LOOP-005: now routed through _runs_append so timeout/worktree-setup-fail
4631
5003
  # share the same write logic. _runs_append is idempotent on run_id.
4632
- _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
4633
5005
  INNER
4634
5006
  chmod +x "$inner_path"
4635
5007
 
@@ -4760,17 +5132,67 @@ SCRIPT
4760
5132
  }
4761
5133
 
4762
5134
  _launchd_is_loaded() {
4763
- launchctl print-disabled "gui/$(id -u)" 2>/dev/null | grep -qF "\"$1\" => enabled"
5135
+ # FIX-098: probe actual launchd registry via `launchctl print`, NOT
5136
+ # `launchctl print-disabled`. The disabled-overrides DB only tracks
5137
+ # labels explicitly enabled/disabled by the user — after `roll loop off`
5138
+ # (bootout) + `roll update` the label stays absent from the overrides DB,
5139
+ # so the old grep returned false-positive "loaded". `launchctl print`
5140
+ # returns exit 0 only when the agent is actually registered in the current
5141
+ # launchd session; non-zero means the label is unknown to launchd.
5142
+ launchctl print "gui/$(id -u)/$1" >/dev/null 2>&1
5143
+ }
5144
+
5145
+ # FIX-101 tripwire: refuse to mutate the host's launchd session when
5146
+ # _LAUNCHD_DIR has been sandboxed (i.e. is not the canonical
5147
+ # ${HOME}/Library/LaunchAgents). Tests that auto-sandbox _LAUNCHD_DIR for
5148
+ # isolation (FIX-087) may still forget to set _LAUNCHD_SKIP_REGISTRY=1 or
5149
+ # stub the launchctl binary; without this defensive layer the production
5150
+ # label's plist path can get overwritten with a transient sandbox path,
5151
+ # leading to launchd EX_CONFIG (exit 78) when the tmp dir is later cleaned
5152
+ # and the next scheduled fire can't find the plist. Read-only ops (print*,
5153
+ # list, version) are always allowed since they have no side effects.
5154
+ _launchctl_safe() {
5155
+ # Read-only ops are always safe (no host launchd state mutation).
5156
+ case "${1:-}" in
5157
+ print|print-disabled|list|version|dumpstate|examine)
5158
+ launchctl "$@"
5159
+ return $?
5160
+ ;;
5161
+ esac
5162
+ # If `launchctl` has been replaced by a function stub (typical in bats tests
5163
+ # that want to assert against captured calls), pass through to the stub.
5164
+ # Stubs by definition don't touch host launchd, so this is safe; and tests
5165
+ # like `_install_launchd_plists: bootout targets gui/<uid>/<label>` rely on
5166
+ # the literal call landing in their captured log.
5167
+ if [[ "$(type -t launchctl 2>/dev/null)" == "function" ]]; then
5168
+ launchctl "$@"
5169
+ return $?
5170
+ fi
5171
+ # Real launchctl binary path: refuse to mutate when _LAUNCHD_DIR has been
5172
+ # sandboxed (i.e. is not the canonical ${HOME}/Library/LaunchAgents). This
5173
+ # is the FIX-101 defensive layer — when a test forgets to stub launchctl
5174
+ # AND has _LAUNCHD_DIR sandboxed, prevent the call from reaching the host's
5175
+ # production launchd and overwriting a live label's plist path.
5176
+ local canonical="${HOME}/Library/LaunchAgents"
5177
+ if [[ "${_LAUNCHD_DIR:-$canonical}" != "$canonical" ]]; then
5178
+ return 0
5179
+ fi
5180
+ launchctl "$@"
4764
5181
  }
4765
5182
 
4766
5183
  _launchd_svc_state() {
5184
+ # FIX-098: three-state classification:
5185
+ # enabled — plist on disk AND registered in launchd
5186
+ # stale — plist on disk BUT NOT registered in launchd
5187
+ # installed-off — kept for back-compat (maps to stale semantics)
5188
+ # not-installed — no plist
4767
5189
  local svc="$1" project_path="$2"
4768
5190
  local label; label=$(_launchd_label "$svc" "$project_path")
4769
5191
  local plist; plist=$(_launchd_plist_path "$svc" "$project_path")
4770
5192
  if _launchd_is_loaded "$label"; then
4771
5193
  echo "enabled"
4772
5194
  elif [[ -f "$plist" ]]; then
4773
- echo "installed-off"
5195
+ echo "stale"
4774
5196
  else
4775
5197
  echo "not-installed"
4776
5198
  fi
@@ -4833,42 +5255,25 @@ _install_launchd_plists() {
4833
5255
  local after; after=$(cat "$plist")
4834
5256
  if [[ "$before" != "$after" ]]; then
4835
5257
  updated=$((updated + 1))
4836
- # FIX-090: gate launchctl writes so a sandboxed plist never gets
4837
- # registered into the user's REAL gui/<uid> domain. Without this,
4838
- # `launchctl bootstrap gui/<uid> <sandbox-plist>` outlives TEST_TMP
4839
- # cleanup as a zombie that either fails silently (EX_CONFIG) or, when
4840
- # the label collides with the dev's project slug, displaces the real
4841
- # registration and kills the autonomous loop. Two gate paths:
4842
- # - explicit: integration_setup exports _LAUNCHD_SKIP_REGISTRY=1
4843
- # - implicit: if _LAUNCHD_DIR was auto-sandboxed under _SHARED_ROOT
4844
- # (FIX-087 inner-runner.sh re-source path) we infer skip — callers
4845
- # that genuinely want the launchctl flow override _LAUNCHD_DIR to
4846
- # a path outside _SHARED_ROOT (unit tests; production has no
4847
- # _SHARED_ROOT match against ~/Library/LaunchAgents).
4848
- # See helpers.bash and tests/unit/launchd_sandbox.bats.
4849
- local _skip_reg="${_LAUNCHD_SKIP_REGISTRY:-}"
4850
- if [[ -z "$_skip_reg" ]]; then
4851
- case "${_LAUNCHD_DIR:-}/" in
4852
- "${_SHARED_ROOT:-/nonexistent}"/*) _skip_reg=1 ;;
4853
- *) _skip_reg=0 ;;
4854
- esac
4855
- fi
4856
- if [[ "$_skip_reg" != "1" ]]; then
5258
+ # FIX-090/FIX-097: gate launchctl writes via central helper so a
5259
+ # sandboxed plist never gets registered into the user's REAL gui/<uid>
5260
+ # domain. See _launchd_should_skip_registry for the predicate rules.
5261
+ if ! _launchd_should_skip_registry; then
4857
5262
  if _launchd_is_loaded "$label"; then
4858
5263
  # FIX-027: use bootout/bootstrap so we don't disturb the label's
4859
5264
  # enabled flag in the launchd overrides db (which legacy
4860
5265
  # unload/load no-`-w` wipes on macOS Sonoma+, causing
4861
5266
  # `roll loop status` to falsely report off after `roll update`).
4862
5267
  local uid; uid=$(id -u)
4863
- launchctl bootout "gui/${uid}/${label}" 2>/dev/null || true
4864
- launchctl bootstrap "gui/${uid}" "$plist" 2>/dev/null || true
5268
+ _launchctl_safe bootout "gui/${uid}/${label}" 2>/dev/null || true
5269
+ _launchctl_safe bootstrap "gui/${uid}" "$plist" 2>/dev/null || true
4865
5270
  elif [[ -z "$before" ]]; then
4866
5271
  # FIX-059: brand-new plist — macOS FSEvents auto-bootstraps any new
4867
5272
  # file dropped in ~/Library/LaunchAgents/, so projects never enabled
4868
5273
  # via 'roll loop on' would fire every hour. Immediately mark disabled
4869
5274
  # in the overrides db to block that auto-load.
4870
5275
  local uid; uid=$(id -u)
4871
- launchctl disable "gui/${uid}/${label}" 2>/dev/null || true
5276
+ _launchctl_safe disable "gui/${uid}/${label}" 2>/dev/null || true
4872
5277
  fi
4873
5278
  fi
4874
5279
  fi
@@ -4925,7 +5330,8 @@ cmd_loop() {
4925
5330
  notify) _notify "${1:-roll}" "${2:-}" ;;
4926
5331
  enforce-tcr) _loop_enforce_tcr "${1:-}" "${2:-}" ;;
4927
5332
  precheck-ci) _loop_precheck_ci ;;
4928
- *) err "Usage: roll loop <on|off|now|test|status|monitor|runs|events|attach|mute|unmute|pause|resume|reset|notify|enforce-tcr|precheck-ci>"; exit 1 ;;
5333
+ branches) _loop_branches "$(pwd -P)" ;;
5334
+ *) 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 ;;
4929
5335
  esac
4930
5336
  }
4931
5337
 
@@ -4945,12 +5351,25 @@ _loop_on() {
4945
5351
  if [[ "$(uname)" == "Darwin" ]]; then
4946
5352
  _install_launchd_plists "$project_path" >/dev/null
4947
5353
 
5354
+ # FIX-098: use launchctl bootstrap/enable instead of load -w.
5355
+ # `load -w` writes to the disabled-overrides DB which causes FIX-027's
5356
+ # re-source to break after `roll update`. bootstrap is idem-potent and
5357
+ # does not disturb the overrides DB.
5358
+ local uid; uid=$(id -u)
4948
5359
  local all_loaded=true
4949
5360
  for svc in loop dream brief; do
4950
5361
  local label; label=$(_launchd_label "$svc" "$project_path")
5362
+ local plist; plist=$(_launchd_plist_path "$svc" "$project_path")
4951
5363
  if ! _launchd_is_loaded "$label"; then
4952
5364
  all_loaded=false
4953
- launchctl load -w "$(_launchd_plist_path "$svc" "$project_path")" 2>/dev/null || true
5365
+ # FIX-097 guard: skip real launchctl when _LAUNCHD_DIR was auto-sandboxed.
5366
+ _launchd_should_skip_registry && continue
5367
+ # FIX-098 semantic: enable+bootstrap pair (better than load -w).
5368
+ # enable clears any disable-override; bootstrap registers with launchd.
5369
+ # FIX-101 wrapper additionally tripwire-gates each call so a sandboxed
5370
+ # _LAUNCHD_DIR can't accidentally touch host launchd state.
5371
+ _launchctl_safe enable "gui/${uid}/${label}" 2>/dev/null || true
5372
+ _launchctl_safe bootstrap "gui/${uid}" "$plist" 2>/dev/null || true
4954
5373
  fi
4955
5374
  done
4956
5375
 
@@ -5002,11 +5421,15 @@ _loop_off() {
5002
5421
 
5003
5422
  if [[ "$(uname)" == "Darwin" ]]; then
5004
5423
  local any_loaded=false
5424
+ local _skip_off; _launchd_should_skip_registry && _skip_off=1 || _skip_off=0
5005
5425
  for svc in loop dream brief; do
5006
5426
  local label; label=$(_launchd_label "$svc" "$project_path")
5007
5427
  if _launchd_is_loaded "$label"; then
5008
5428
  any_loaded=true
5009
- launchctl unload -w "$(_launchd_plist_path "$svc" "$project_path")" 2>/dev/null || true
5429
+ # FIX-097: skip real launchctl in sandbox to avoid touching the user's
5430
+ # real launchd registry.
5431
+ [[ "$_skip_off" == "1" ]] && continue
5432
+ _launchctl_safe unload -w "$(_launchd_plist_path "$svc" "$project_path")" 2>/dev/null || true
5010
5433
  fi
5011
5434
  done
5012
5435
  if ! $any_loaded; then
@@ -5025,7 +5448,9 @@ _loop_off() {
5025
5448
  # disable list, polluting `launchctl print-disabled` forever even after
5026
5449
  # the project dir, plists, and ~/.roll are gone.
5027
5450
  local label; label=$(_launchd_label "$svc" "$project_path")
5028
- launchctl enable "gui/${uid}/${label}" 2>/dev/null || true
5451
+ # FIX-097: same gate never touch host launchctl from a sandbox.
5452
+ [[ "$_skip_off" == "1" ]] && continue
5453
+ _launchctl_safe enable "gui/${uid}/${label}" 2>/dev/null || true
5029
5454
  done
5030
5455
  ok "Loop disabled 已停用"
5031
5456
  return 0
@@ -5169,7 +5594,7 @@ _legacy_loop_status() {
5169
5594
  else
5170
5595
  case "$state" in
5171
5596
  enabled) echo -e " ${GREEN}${svc} ● enabled${NC}" ;;
5172
- installed-off) echo -e " ${YELLOW}${svc} ⚠ installed/off${NC} run: roll loop on" ;;
5597
+ stale|installed-off) echo -e " ${YELLOW}${svc} ⚠ STALE — plist present but not loaded${NC} run: roll loop on" ;;
5173
5598
  not-installed) echo -e " ${RED}${svc} ○ not installed${NC} run: roll setup" ;;
5174
5599
  esac
5175
5600
  fi
@@ -5205,7 +5630,10 @@ _loop_pause() {
5205
5630
  if ! _launchd_is_loaded "$label"; then
5206
5631
  warn "Loop not enabled — nothing to pause loop 未启用,无需暂停"; return 0
5207
5632
  fi
5208
- launchctl unload -w "$(_launchd_plist_path "loop" "$project_path")" 2>/dev/null || true
5633
+ # FIX-097: never touch host launchctl from a sandboxed plist path.
5634
+ if ! _launchd_should_skip_registry; then
5635
+ _launchctl_safe unload -w "$(_launchd_plist_path "loop" "$project_path")" 2>/dev/null || true
5636
+ fi
5209
5637
  else
5210
5638
  local slug; slug=$(_project_slug "$project_path")
5211
5639
  mkdir -p "${_SHARED_ROOT}/loop"
@@ -5225,8 +5653,9 @@ _loop_resume() {
5225
5653
  if [[ "$(uname)" == "Darwin" ]]; then
5226
5654
  local label; label=$(_launchd_label "loop" "$project_path")
5227
5655
  local plist; plist=$(_launchd_plist_path "loop" "$project_path")
5228
- if [[ -f "$plist" ]]; then
5229
- launchctl load -w "$plist" 2>/dev/null || true
5656
+ if [[ -f "$plist" ]] && ! _launchd_should_skip_registry; then
5657
+ # FIX-097: never touch host launchctl from a sandboxed plist path.
5658
+ _launchctl_safe load -w "$plist" 2>/dev/null || true
5230
5659
  fi
5231
5660
  else
5232
5661
  local slug; slug=$(_project_slug "$project_path")
@@ -5295,6 +5724,71 @@ _loop_attach() {
5295
5724
  }
5296
5725
 
5297
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
+
5298
5792
  _loop_runs_dur() {
5299
5793
  local s="${1:-0}"
5300
5794
  if [[ "$s" -lt 60 ]]; then printf "%ds" "$s"
@@ -5339,8 +5833,12 @@ _loop_runs_format_line() {
5339
5833
  local skipped_note=""
5340
5834
  [[ "$skipped_count" -gt 0 ]] && skipped_note=", ${skipped_count} skipped"
5341
5835
  local items_word; [[ "$built_count" -eq 1 ]] && items_word="item" || items_word="items"
5342
- printf " %s %s✅ built %d %s (%d tcr%s, %s)\n" \
5343
- "$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"
5344
5842
  local id desc
5345
5843
  while IFS= read -r id; do
5346
5844
  [[ -z "$id" ]] && continue
@@ -5377,15 +5875,23 @@ _loop_runs_format_line() {
5377
5875
 
5378
5876
  # `roll loop runs [N] [--all]` — show recent loop iteration summaries.
5379
5877
  _loop_runs() {
5380
- local n=10 all_flag=false
5878
+ local n=10 all_flag=false detail_cycle=""
5381
5879
  while [[ $# -gt 0 ]]; do
5382
5880
  case "$1" in
5383
5881
  --all) all_flag=true; shift ;;
5882
+ --detail) detail_cycle="${2:-}"; shift 2 ;;
5883
+ --detail=*) detail_cycle="${1#--detail=}"; shift ;;
5384
5884
  [0-9]*) n="$1"; shift ;;
5385
5885
  *) shift ;;
5386
5886
  esac
5387
5887
  done
5388
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
+
5389
5895
  # FIX-060: refresh merged status before reading, so paused-window merges show up.
5390
5896
  _loop_backfill_merged >/dev/null 2>&1 || true
5391
5897
 
@@ -5562,15 +6068,28 @@ _loop_precheck_ci() {
5562
6068
 
5563
6069
  local commit; commit=$(git rev-parse HEAD 2>/dev/null) || return 0
5564
6070
 
6071
+ # FIX-103: fetch both `status` and `conclusion`. Pre-run gate must distinguish
6072
+ # a still-running CI (status=in_progress/queued/waiting, conclusion=null) from
6073
+ # a genuinely red CI (conclusion=failure/cancelled/timed_out/...). Treating
6074
+ # in_progress as red kills every cycle started within the first ~30s of a
6075
+ # merge-triggered CI run.
5565
6076
  local runs
5566
- runs=$(gh -R "$slug" run list --commit "$commit" --json conclusion 2>/dev/null) || return 0
6077
+ runs=$(gh -R "$slug" run list --commit "$commit" --json conclusion,status 2>/dev/null) || return 0
5567
6078
  [[ -z "$runs" || "$runs" == "[]" ]] && return 0
5568
6079
 
5569
- local failed
5570
- failed=$(echo "$runs" | jq -r '[.[] | select(.conclusion != null and .conclusion != "success" and .conclusion != "skipped")] | length' 2>/dev/null || echo "0")
6080
+ # Conclusions that block the loop. Anything else (success, skipped, neutral,
6081
+ # or null while still running) is treated as pass/pending.
6082
+ local failed_conclusions
6083
+ failed_conclusions=$(echo "$runs" \
6084
+ | jq -r '[.[] | select(.conclusion=="failure" or .conclusion=="cancelled" or .conclusion=="timed_out" or .conclusion=="action_required" or .conclusion=="startup_failure") | .conclusion] | unique | join(",")' \
6085
+ 2>/dev/null || echo "")
5571
6086
 
5572
- if [[ "$failed" -gt 0 ]]; then
6087
+ if [[ -n "$failed_conclusions" ]]; then
5573
6088
  local short; short=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
6089
+ local run_states
6090
+ run_states=$(echo "$runs" \
6091
+ | jq -r '[.[] | "\(.status // "?")/\(.conclusion // "null")"] | unique | join(", ")' \
6092
+ 2>/dev/null || echo "?")
5574
6093
  err "Pre-run CI check: HEAD CI is red — refuse to build on broken base (${short}) HEAD CI 红,拒绝在破损的基础上构建"
5575
6094
  mkdir -p "$(dirname "$_LOOP_ALERT")"
5576
6095
  cat > "$_LOOP_ALERT" << EOF
@@ -5579,6 +6098,8 @@ _loop_precheck_ci() {
5579
6098
  **Time**: $(date '+%Y-%m-%d %H:%M')
5580
6099
  **Commit**: ${short}
5581
6100
  **Reason**: HEAD CI is red — loop refused to build on a broken base HEAD CI 红,loop 拒绝在破损的基础上构建
6101
+ **Failing conclusions**: ${failed_conclusions}
6102
+ **Run states**: ${run_states}
5582
6103
 
5583
6104
  **Action required**:
5584
6105
  - Investigate and fix CI: \`gh -R ${slug} run list --commit ${commit}\`
@@ -6082,10 +6603,24 @@ _loop_mark_in_progress() {
6082
6603
  [ -n "$story_id" ] || return 1
6083
6604
  [ -f "$backlog" ] || return 0
6084
6605
  local tmp; tmp=$(mktemp "${backlog}.XXXXXX") || return 1
6606
+ # FIX-106: match the story-id column (col 2) for equality instead of doing
6607
+ # substring match on the whole row. Pre-fix, picking US-X-001 also flipped
6608
+ # any row whose description contained "depends-on:US-X-001" — leaving the
6609
+ # dashboard claiming work on stories no one had picked.
6085
6610
  awk -v sid="$story_id" '
6086
6611
  {
6087
- if (index($0, sid) > 0 && index($0, "📋 Todo") > 0) {
6088
- sub(/📋 Todo/, "🔨 In Progress")
6612
+ if (index($0, "📋 Todo") > 0) {
6613
+ n = split($0, cols, "|")
6614
+ if (n >= 2) {
6615
+ id_cell = cols[2]
6616
+ gsub(/[[:space:]]/, "", id_cell)
6617
+ # Markdown link form "[ID](path)" → keep just "ID"
6618
+ sub(/^\[/, "", id_cell)
6619
+ sub(/\].*$/, "", id_cell)
6620
+ if (id_cell == sid) {
6621
+ sub(/📋 Todo/, "🔨 In Progress")
6622
+ }
6623
+ }
6089
6624
  }
6090
6625
  print
6091
6626
  }
@@ -6101,10 +6636,20 @@ _loop_mark_todo() {
6101
6636
  [ -n "$story_id" ] || return 1
6102
6637
  [ -f "$backlog" ] || return 0
6103
6638
  local tmp; tmp=$(mktemp "${backlog}.XXXXXX") || return 1
6639
+ # FIX-106: same column-2 equality match as _loop_mark_in_progress.
6104
6640
  awk -v sid="$story_id" '
6105
6641
  {
6106
- if (index($0, sid) > 0 && index($0, "🔨 In Progress") > 0) {
6107
- sub(/🔨 In Progress/, "📋 Todo")
6642
+ if (index($0, "🔨 In Progress") > 0) {
6643
+ n = split($0, cols, "|")
6644
+ if (n >= 2) {
6645
+ id_cell = cols[2]
6646
+ gsub(/[[:space:]]/, "", id_cell)
6647
+ sub(/^\[/, "", id_cell)
6648
+ sub(/\].*$/, "", id_cell)
6649
+ if (id_cell == sid) {
6650
+ sub(/🔨 In Progress/, "📋 Todo")
6651
+ }
6652
+ }
6108
6653
  }
6109
6654
  print
6110
6655
  }
@@ -6512,14 +7057,29 @@ _claude_cleanup_stale_worktrees() {
6512
7057
  return 0
6513
7058
  }
6514
7059
 
7060
+ # FIX-104: scan multiple ephemeral prefixes (loop/cycle-, worktree-agent-,
7061
+ # claude/) and delete any already merged to origin/main. Unmerged branches
7062
+ # are preserved — they may be active WIP. Caller can pass a custom prefix
7063
+ # list via $2 (newline-separated `refs/heads/<prefix>*` patterns) but the
7064
+ # default whitelist covers every temp prefix the loop / Claude session /
7065
+ # worktree-agent paths create.
6515
7066
  _loop_cleanup_stale_cycle_branches() {
6516
7067
  local project_path="${1:-.}"
6517
7068
  local url; url=$(git -C "$project_path" remote get-url origin 2>/dev/null) || return 0
6518
7069
  [[ "$url" == *github.com* ]] || return 0
6519
7070
 
6520
- local branches
6521
- branches=$(git -C "$project_path" ls-remote --heads origin 'refs/heads/loop/cycle-*' 2>/dev/null \
6522
- | awk '{print $2}' | sed 's|^refs/heads/||')
7071
+ local prefixes="${2:-refs/heads/loop/cycle-*
7072
+ refs/heads/worktree-agent-*
7073
+ refs/heads/claude/*}"
7074
+
7075
+ local branches=""
7076
+ while IFS= read -r pat; do
7077
+ [ -z "$pat" ] && continue
7078
+ local found
7079
+ found=$(git -C "$project_path" ls-remote --heads origin "$pat" 2>/dev/null \
7080
+ | awk '{print $2}' | sed 's|^refs/heads/||')
7081
+ [ -n "$found" ] && branches+="${found}"$'\n'
7082
+ done <<< "$prefixes"
6523
7083
  [ -z "$branches" ] && return 0
6524
7084
 
6525
7085
  while IFS= read -r branch; do
@@ -6534,6 +7094,41 @@ _loop_cleanup_stale_cycle_branches() {
6534
7094
  return 0
6535
7095
  }
6536
7096
 
7097
+ # FIX-104: residual-visibility command. List origin's ephemeral temp branches
7098
+ # (loop/cycle-*, worktree-agent-*, claude/*) with their merge status so the
7099
+ # user can see what GC will clean up next cycle and what's still active WIP.
7100
+ # Output: TAB-separated `<branch>\t<merged|open>` lines, one per branch.
7101
+ # Silent on non-GitHub remote / empty / unreachable.
7102
+ _loop_branches() {
7103
+ local project_path="${1:-.}"
7104
+ local url; url=$(git -C "$project_path" remote get-url origin 2>/dev/null) || return 0
7105
+ [[ "$url" == *github.com* ]] || return 0
7106
+
7107
+ local prefixes="refs/heads/loop/cycle-*
7108
+ refs/heads/worktree-agent-*
7109
+ refs/heads/claude/*"
7110
+
7111
+ local branches=""
7112
+ while IFS= read -r pat; do
7113
+ [ -z "$pat" ] && continue
7114
+ local found
7115
+ found=$(git -C "$project_path" ls-remote --heads origin "$pat" 2>/dev/null \
7116
+ | awk '{print $2}' | sed 's|^refs/heads/||')
7117
+ [ -n "$found" ] && branches+="${found}"$'\n'
7118
+ done <<< "$prefixes"
7119
+ [ -z "$branches" ] && return 0
7120
+
7121
+ while IFS= read -r branch; do
7122
+ [ -z "$branch" ] && continue
7123
+ local status="open"
7124
+ if git -C "$project_path" merge-base --is-ancestor "$branch" origin/main 2>/dev/null; then
7125
+ status="merged"
7126
+ fi
7127
+ printf "%s\t%s\n" "$branch" "$status"
7128
+ done <<< "$branches"
7129
+ return 0
7130
+ }
7131
+
6537
7132
  # US-AUTO-033: publish a loop cycle branch as a GitHub PR with auto-merge.
6538
7133
  #
6539
7134
  # _loop_publish_pr <branch> [title]
@@ -6625,6 +7220,9 @@ _loop_wait_pr_merge() {
6625
7220
  local elapsed=0
6626
7221
  local slug; _gh_resolve slug || return 0
6627
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
6628
7226
  local state; state=$(gh -R "$slug" pr view "$branch" --json state -q .state 2>/dev/null || echo "UNKNOWN")
6629
7227
  case "$state" in
6630
7228
  MERGED) return 0 ;;
@@ -7076,6 +7674,57 @@ cmd_alert() {
7076
7674
  esac
7077
7675
  }
7078
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
+
7079
7728
  # ═══════════════════════════════════════════════════════════════════════════════
7080
7729
 
7081
7730
  cmd_ci() {
@@ -7114,7 +7763,19 @@ cmd_ci() {
7114
7763
  # will switch to hard-fail. Output format mirrors a linter ("file:line:
7115
7764
  # message") so editors can navigate from it.
7116
7765
  _backlog_lint() {
7117
- local backlog="${1:-.roll/backlog.md}"
7766
+ # FIX-102: --gate flag flips Phase 1 warn-only behavior to hard-fail.
7767
+ # When passed, any violation makes the command exit 1 — used by the
7768
+ # PreToolUse / Stop hook in ~/.claude/settings.json to actually block
7769
+ # the assistant from leaving the backlog dirty.
7770
+ local gate=0
7771
+ local backlog=".roll/backlog.md"
7772
+ while [ $# -gt 0 ]; do
7773
+ case "$1" in
7774
+ --gate) gate=1 ;;
7775
+ *) backlog="$1" ;;
7776
+ esac
7777
+ shift
7778
+ done
7118
7779
  [ -f "$backlog" ] || { err "backlog not found: $backlog"; return 1; }
7119
7780
 
7120
7781
  local violations=0
@@ -7139,6 +7800,18 @@ _backlog_lint() {
7139
7800
  | sed -E 's|^\[[A-Z]+-[0-9]+\]\([^)]*\)[[:space:]]*||' \
7140
7801
  | sed -E 's|^[A-Z]+-[0-9]+[[:space:]]*||')
7141
7802
  local issues=""
7803
+ # FIX-102: length check — backlog rows are an index page; descriptions
7804
+ # must be one human sentence (≤120 chars). Longer = technical detail
7805
+ # that belongs in the linked .roll/features/<epic>/<slug>.md.
7806
+ if [ "${#body}" -gt 120 ]; then
7807
+ issues="${issues:+${issues}, }length>${#body}"
7808
+ fi
7809
+ # FIX-102: code-fence check — backticks (`code`) signal technical jargon
7810
+ # (commands, identifiers, paths). Keep description prose plain text;
7811
+ # any code goes in the feature file.
7812
+ if echo "$body" | grep -qF '`'; then
7813
+ issues="${issues:+${issues}, }code-fence"
7814
+ fi
7142
7815
  # Filenames: bare `something.ext` for common code/config extensions
7143
7816
  if echo "$body" | grep -qE '\b[A-Za-z_][A-Za-z0-9_.-]*\.(sh|bash|yaml|yml|json|js|ts|tsx|py|rb|go|rs|c|cpp|h)\b'; then
7144
7817
  issues="${issues:+${issues}, }filename"
@@ -7165,11 +7838,14 @@ _backlog_lint() {
7165
7838
  echo ""
7166
7839
  if [ "$violations" -gt 0 ]; then
7167
7840
  echo " ${violations} violation(s) — see conventions/global/AGENTS.md §4"
7841
+ if [ "$gate" = 1 ]; then
7842
+ echo " ${violations} 条违规 — --gate enabled, exiting 1"
7843
+ return 1
7844
+ fi
7168
7845
  echo " ${violations} 条违规 — Phase 1: warn-only, not blocking"
7169
7846
  else
7170
7847
  echo " No violations 无违规"
7171
7848
  fi
7172
- # Phase 1: warn-only. Exit 0 regardless.
7173
7849
  return 0
7174
7850
  }
7175
7851
 
@@ -7185,7 +7861,8 @@ cmd_backlog() {
7185
7861
  # ── Status management subcommands ─────────────────────────────────────────
7186
7862
  case "$subcmd" in
7187
7863
  lint)
7188
- _backlog_lint "$backlog"
7864
+ shift
7865
+ _backlog_lint "$@" "$backlog"
7189
7866
  return
7190
7867
  ;;
7191
7868
  block|defer|unblock|promote)
@@ -7842,11 +8519,13 @@ main() {
7842
8519
  brief) cmd_brief "$@" ;;
7843
8520
  backlog) cmd_backlog "$@" ;;
7844
8521
  alert) cmd_alert "$@" ;;
8522
+ lang) cmd_lang "$@" ;;
7845
8523
  agent) cmd_agent "$@" ;;
7846
8524
  ci) cmd_ci "$@" ;;
7847
8525
  doctor) cmd_doctor "$@" ;;
7848
8526
  review-pr) cmd_review_pr "$@" ;;
7849
8527
  slides) cmd_slides "$@" ;;
8528
+ prices) cmd_prices "$@" ;;
7850
8529
  version|--version|-v) echo "roll v${VERSION}" ;;
7851
8530
  help|--help|-h) _help "$@" ;;
7852
8531
  "") [[ -f ".roll/backlog.md" ]] && _home || { _help; _show_changelog; } ;;