@seanyao/roll 2026.518.2 → 2026.518.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.518.4
4
+
5
+ ### Improved
6
+
7
+ - **`roll` 主页焕新** — 一屏看清 loop / dream / peer 状态、四道防线、Pipeline 进度和待你处理的事 `[loop]`
8
+ - **`roll --help` 焕新** — 命令按日常 / 项目 / 全局三组分类,常用命令 ★ 高亮,不再被大字 ASCII banner 占屏 `[loop]`
9
+ - **`roll status` 焕新** — 一行看健康总览,AI 客户端同步状态、约定文件清单、项目模板逐段展示,drift 行直接给修复命令 `[loop]`
10
+ - **`roll backlog`** — 待办任务按缺陷 / 故事 / 重构 / 创意四类分组显示,进行中的条目紫色高亮,Blocked / Deferred 分区附带原因 `[loop]`
11
+ - **`roll brief`** — 简报现在用终端三段式渲染:摘要数字、已完成亮点、待决策清单按 D1/D2 编号 `[loop]`
12
+ - **`roll setup`** — 首次安装流程现在显示 6 步编号进度,每步完成打勾,结尾显示"Setup complete" `[loop]`
13
+
14
+ ### Fixed
15
+
16
+ - **loop 弹窗现在固定走 Terminal.app** — 不再按终端偏好挑来挑去,省得在新版 Ghostty 上弹窗假成功、备选方案也失效 `[loop]`
17
+ - **与 roll agent 对话** — 不再因工具不同而忽冷忽热 `[loop]`
18
+ - **大小写不同路径进同一项目** — 不再被 loop 当成两个独立项目、LOCK 和状态不再分裂 `[loop]`
19
+ - **loop 跑完一轮** — 不再挡死后续调度、卡到手动 kill `[loop]`
20
+ - **binary 升级后 loop dashboard 回落 IDLE 问题** — 旧 binary 用不同路径大小写算出不同 slug,新 binary 启动时自动把旧 slug 的状态文件、日志、历史记录全部迁到新 slug,upgrade 无感无损 `[loop]`
21
+ - **loop status 费用显示严重偏低** — `$9.25` 的 cycle 显示为 `$0.04`:读取了最后一次 API 调用的 token 数重新算价,改为直接用 `cost_reported_usd`(loop-fmt 写入的权威累计值) `[loop]`
22
+
23
+ ## v2026.518.3
24
+
25
+ ### Fixed
26
+
27
+ - **autonomous loop 跑测试时狂开 Ghostty 窗口** — `_write_loop_runner_script` 生成的 outer runner 在 popup 分支只检查 `ROLL_LOOP_NO_POPUP` / mute 文件 / Darwin;当 loop 的执行 agent 跑 bats 跑到 `_loop_test` 这类需要执行生成脚本的测试时,每个 test case 都会真的弹一个 Ghostty 窗口,一轮 cycle 累出 80+ 个孤儿窗口。popup 守卫追加 `BATS_TEST_NUMBER` 判定,任何 bats 上下文里自动跳过 popup `[loop]`
28
+
3
29
  ## v2026.518.2
4
30
 
5
31
  ### Fixed
package/bin/roll CHANGED
@@ -4,14 +4,14 @@ 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.518.2"
7
+ VERSION="2026.518.4"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
11
11
  ROLL_TEMPLATES="${ROLL_HOME}/conventions/templates"
12
12
 
13
13
  # Find package root (resolve symlinks so it works from ~/.local/bin/roll or npm global bin)
14
- _source="${BASH_SOURCE[0]}"
14
+ _source="${BASH_SOURCE[0]:-$0}"
15
15
  while [[ -L "$_source" ]]; do
16
16
  _dir="$(cd "$(dirname "$_source")" && pwd)"
17
17
  _source="$(readlink "$_source")"
@@ -574,13 +574,20 @@ _ensure_tmux() {
574
574
 
575
575
  cmd_setup() {
576
576
  local force=false
577
+ local demo=false
577
578
  while [[ $# -gt 0 ]]; do
578
579
  case "$1" in
579
580
  --force|-f) force=true; shift ;;
581
+ --demo) demo=true; shift ;;
580
582
  *) err "Unknown argument: $1 未知参数: $1"; exit 1 ;;
581
583
  esac
582
584
  done
583
585
 
586
+ if [[ "${ROLL_UI:-v2}" == "v2" ]] || [[ "$demo" == "true" ]]; then
587
+ python3 "${ROLL_PKG_DIR}/lib/roll-setup.py" --demo
588
+ if [[ "$demo" == "true" ]]; then return; fi
589
+ fi
590
+
584
591
  info "Setting up Roll on this machine... 正在初始化 Roll..."
585
592
  echo ""
586
593
 
@@ -1070,7 +1077,7 @@ EOF
1070
1077
  # COMMAND: status
1071
1078
  # Show current state of conventions
1072
1079
  # ═══════════════════════════════════════════════════════════════════════════════
1073
- cmd_status() {
1080
+ _legacy_status() {
1074
1081
  echo -e "${BOLD}Roll Convention Status Roll 约定状态${NC}"
1075
1082
  echo ""
1076
1083
 
@@ -1187,6 +1194,14 @@ cmd_status() {
1187
1194
  _status_loop_overview
1188
1195
  }
1189
1196
 
1197
+ cmd_status() {
1198
+ if [[ "${ROLL_UI:-v2}" == "v2" ]]; then
1199
+ python3 "${ROLL_PKG_DIR}/lib/roll-status.py" "$@"
1200
+ else
1201
+ _legacy_status "$@"
1202
+ fi
1203
+ }
1204
+
1190
1205
  _status_loop_overview() {
1191
1206
  [[ "$(uname)" != "Darwin" ]] && return 0
1192
1207
 
@@ -1352,41 +1367,20 @@ _peer_route() {
1352
1367
  return 1
1353
1368
  }
1354
1369
 
1355
- # Open a terminal window attached to the given tmux session (peer auto-attach).
1356
- # No-ops when muted, non-macOS, or tmux unavailable. Mirrors loop popup logic.
1370
+ # Open a Terminal.app window attached to the given tmux session (peer
1371
+ # auto-attach). No-ops when muted, non-macOS, or osascript unavailable.
1372
+ # FIX-054: terminal selection removed — always dispatches to macOS
1373
+ # Terminal.app for predictability (per-user detection silently failed on
1374
+ # Ghostty upgrades).
1357
1375
  _peer_auto_attach() {
1358
1376
  local session="$1"
1359
1377
  [ "$(uname)" = "Darwin" ] || return 0
1360
1378
  [ -f "$_LOOP_MUTE_FILE" ] && return 0
1361
- local terminal_pref
1362
- terminal_pref=$(config_get "loop_attach_terminal" "")
1363
- if [[ -z "$terminal_pref" ]]; then
1364
- case "${TERM_PROGRAM:-}" in
1365
- ghostty) terminal_pref="ghostty" ;;
1366
- iTerm.app) terminal_pref="iTerm2" ;;
1367
- *) terminal_pref="Terminal" ;;
1368
- esac
1369
- fi
1370
- local launched=0
1371
- if [[ "$terminal_pref" = "ghostty" || "$terminal_pref" = "Ghostty" ]]; then
1372
- open -na Ghostty.app --args -e tmux attach -t "$session" >/dev/null 2>&1 && launched=1 || true
1373
- fi
1374
- if [[ $launched -eq 0 ]] && { [[ "$terminal_pref" = "iTerm2" || "$terminal_pref" = "iTerm" ]] || [[ -d "/Applications/iTerm.app" ]]; }; then
1375
- osascript \
1376
- -e 'tell application "System Events" to set _prev to name of first application process whose frontmost is true' \
1377
- -e "tell application \"iTerm2\" to create window with default profile command \"tmux attach -t $session\"" \
1378
- -e 'delay 0.3' -e 'try' -e 'tell application _prev to activate' -e 'end try' >/dev/null 2>&1 \
1379
- && launched=1 || true
1380
- fi
1381
- if [[ $launched -eq 0 ]] && [[ -d "/Applications/Ghostty.app" ]]; then
1382
- open -na Ghostty.app --args -e tmux attach -t "$session" >/dev/null 2>&1 && launched=1 || true
1383
- fi
1384
- if [[ $launched -eq 0 ]] && command -v osascript >/dev/null 2>&1; then
1385
- osascript \
1386
- -e 'tell application "System Events" to set _prev to name of first application process whose frontmost is true' \
1387
- -e "tell application \"Terminal\" to do script \"tmux attach -t $session\"" \
1388
- -e 'delay 0.3' -e 'try' -e 'tell application _prev to activate' -e 'end try' >/dev/null 2>&1 || true
1389
- fi
1379
+ command -v osascript >/dev/null 2>&1 || return 0
1380
+ osascript \
1381
+ -e 'tell application "System Events" to set _prev to name of first application process whose frontmost is true' \
1382
+ -e "tell application \"Terminal\" to do script \"tmux attach -t $session\"" \
1383
+ -e 'delay 0.3' -e 'try' -e 'tell application _prev to activate' -e 'end try' >/dev/null 2>&1 || true
1390
1384
  }
1391
1385
 
1392
1386
  # Dispatch a peer CLI command inside an existing tmux session (window 0).
@@ -2005,6 +1999,13 @@ cmd_agent() {
2005
1999
  # hash of the full path, ensuring uniqueness across sibling dirs with same name.
2006
2000
  _project_slug() {
2007
2001
  local path="${1:-$(pwd -P 2>/dev/null || pwd)}"
2002
+ # FIX-056: normalize path to canonical case on macOS case-insensitive filesystem.
2003
+ # Two paths differing only in case point to the same directory; realpath
2004
+ # resolves both symlinks and case variations to the canonical filesystem path.
2005
+ if [[ "$(uname -s 2>/dev/null)" == "Darwin" ]]; then
2006
+ local _canon
2007
+ _canon=$(realpath "$path" 2>/dev/null) && path="$_canon"
2008
+ fi
2008
2009
  # FIX-034: when inside a git worktree, git-common-dir returns the main tree's
2009
2010
  # absolute .git path; resolve to the main tree so worktree and main-tree runs
2010
2011
  # produce the same slug.
@@ -2024,6 +2025,90 @@ _project_slug() {
2024
2025
  printf '%s' "${base}-${hash}"
2025
2026
  }
2026
2027
 
2028
+ # FIX-058: migrate loop state files when the per-project slug changed due to
2029
+ # FIX-056 (realpath case-normalization on macOS). Called by
2030
+ # _install_launchd_plists before generating new runner/plist so existing state
2031
+ # (paused/running/etc.) is not silently lost.
2032
+ #
2033
+ # Usage: _slug_migrate_from_legacy <new_slug> [<loop_dir>] [<old_slug>]
2034
+ # new_slug — the correct slug computed by the current _project_slug
2035
+ # loop_dir — optional override of ${_SHARED_ROOT}/loop (for unit tests)
2036
+ # old_slug — optional explicit old slug (for unit tests; auto-computed otherwise)
2037
+ _slug_migrate_from_legacy() {
2038
+ local new_slug="$1"
2039
+ local loop_dir="${2:-${_SHARED_ROOT}/loop}"
2040
+ local old_slug="${3:-}"
2041
+
2042
+ if [[ -z "$old_slug" ]]; then
2043
+ [[ "$(uname -s 2>/dev/null)" == "Darwin" ]] || return 0
2044
+ # Compute the pre-FIX-056 slug: same algorithm but without realpath.
2045
+ local raw_path; raw_path=$(pwd 2>/dev/null)
2046
+ local _common
2047
+ _common=$(git -C "$raw_path" rev-parse --git-common-dir 2>/dev/null)
2048
+ if [[ -n "$_common" && "$_common" == *"/.git" ]]; then
2049
+ raw_path="${_common%/.git}"
2050
+ fi
2051
+ local old_base; old_base=$(basename "$raw_path")
2052
+ local old_hash
2053
+ if command -v md5 &>/dev/null; then
2054
+ old_hash=$(printf '%s' "$raw_path" | md5 | cut -c1-6)
2055
+ else
2056
+ old_hash=$(printf '%s' "$raw_path" | md5sum | cut -c1-6)
2057
+ fi
2058
+ old_base=$(printf '%s' "$old_base" | tr -cs '[:alnum:]' '-' | sed 's/-*$//')
2059
+ old_slug="${old_base}-${old_hash}"
2060
+ fi
2061
+
2062
+ [[ "$old_slug" == "$new_slug" ]] && return 0
2063
+ [[ -f "${loop_dir}/state-${old_slug}.yaml" ]] || return 0
2064
+
2065
+ printf 'roll: migrating loop state %s → %s\n' "$old_slug" "$new_slug" >&2
2066
+
2067
+ mv "${loop_dir}/state-${old_slug}.yaml" "${loop_dir}/state-${new_slug}.yaml"
2068
+
2069
+ [[ -f "${loop_dir}/cron-${old_slug}.log" ]] && \
2070
+ mv "${loop_dir}/cron-${old_slug}.log" "${loop_dir}/cron-${new_slug}.log"
2071
+
2072
+ if [[ -f "${loop_dir}/events-${old_slug}.ndjson" ]]; then
2073
+ if [[ -f "${loop_dir}/events-${new_slug}.ndjson" ]]; then
2074
+ cat "${loop_dir}/events-${old_slug}.ndjson" >> "${loop_dir}/events-${new_slug}.ndjson"
2075
+ rm "${loop_dir}/events-${old_slug}.ndjson"
2076
+ else
2077
+ mv "${loop_dir}/events-${old_slug}.ndjson" "${loop_dir}/events-${new_slug}.ndjson"
2078
+ fi
2079
+ fi
2080
+
2081
+ local runs_file="${loop_dir}/runs.jsonl"
2082
+ if [[ -f "$runs_file" ]]; then
2083
+ local tmp; tmp=$(mktemp)
2084
+ python3 - "$old_slug" "$new_slug" "$runs_file" > "$tmp" << 'PYEOF'
2085
+ import json, sys
2086
+ old, new, path = sys.argv[1], sys.argv[2], sys.argv[3]
2087
+ with open(path) as f:
2088
+ for line in f:
2089
+ line = line.rstrip('\n')
2090
+ if not line:
2091
+ continue
2092
+ try:
2093
+ d = json.loads(line)
2094
+ if 'project' in d and old in str(d['project']):
2095
+ d['project'] = str(d['project']).replace(old, new)
2096
+ print(json.dumps(d))
2097
+ except Exception:
2098
+ print(line)
2099
+ PYEOF
2100
+ mv "$tmp" "$runs_file"
2101
+ fi
2102
+
2103
+ local old_plist=~/Library/LaunchAgents/com.roll.loop.${old_slug}.plist
2104
+ if [[ -f "$old_plist" ]]; then
2105
+ launchctl unload "$old_plist" 2>/dev/null || true
2106
+ rm -f "$old_plist"
2107
+ fi
2108
+
2109
+ rm -f "${loop_dir}/run-${old_slug}.sh" "${loop_dir}/run-${old_slug}-inner.sh"
2110
+ }
2111
+
2027
2112
  _LOOP_TAG="# roll-loop"
2028
2113
  : "${_SHARED_ROOT:=${HOME}/.shared/roll}"
2029
2114
  # FIX-052: per-project loop state — ALERT/state/mute were globally shared,
@@ -2202,7 +2287,10 @@ _write_runner_script() {
2202
2287
  # Falls back to headless execution when tmux is not installed.
2203
2288
  _write_loop_runner_script() {
2204
2289
  local script_path="$1" project_path="$2" cmd="$3" log_path="$4"
2205
- local active_start="${5:-10}" active_end="${6:-18}" terminal_pref="${7:-Terminal}"
2290
+ local active_start="${5:-10}" active_end="${6:-18}"
2291
+ # FIX-054: terminal preference detection removed. Popup is hard-coded to
2292
+ # macOS Terminal.app; the 7th positional arg, if any, is ignored for
2293
+ # backwards compatibility with existing callers.
2206
2294
  mkdir -p "$(dirname "$script_path")"
2207
2295
 
2208
2296
  local inner_path="${script_path%.sh}-inner.sh"
@@ -2262,7 +2350,29 @@ _heartbeat_writer() {
2262
2350
  }
2263
2351
  _heartbeat_writer &
2264
2352
  _HEARTBEAT_PID=\$!
2265
- trap 'kill "\${_HEARTBEAT_PID}" 2>/dev/null; rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE"' EXIT
2353
+ # FIX-057: cycle hard timeout 45 minute SLA per loop cycle. If a cycle runs
2354
+ # longer, kill claude / loop-fmt.py / all backgrounded children, mark the
2355
+ # in-progress backlog item Blocked (caller decides), and exit cleanly so the
2356
+ # next cron tick can proceed. Overridable via env for tests.
2357
+ LOOP_CYCLE_TIMEOUT_SEC="\${ROLL_LOOP_CYCLE_TIMEOUT_SEC:-2700}"
2358
+ _CYCLE_TIMED_OUT=0
2359
+ _on_sigterm() { _CYCLE_TIMED_OUT=1; }
2360
+ trap '_on_sigterm' TERM
2361
+ _inner_cleanup() {
2362
+ local _rc=\$?
2363
+ # Kill heartbeat + every remaining background job (watchdog, orphan
2364
+ # loop-fmt.py, publish subshells) — bash's foreground 'wait' for the
2365
+ # pipe has already returned by the time the EXIT trap runs.
2366
+ kill "\${_HEARTBEAT_PID}" 2>/dev/null
2367
+ for _pid in \$(jobs -p); do kill "\$_pid" 2>/dev/null; done
2368
+ if [ "\${_CYCLE_TIMED_OUT:-0}" -eq 1 ]; then
2369
+ _loop_event cycle_end "\${CYCLE_ID:-unknown}" "\${BRANCH:-}" "blocked" 2>/dev/null || true
2370
+ _worktree_alert "cycle \${CYCLE_ID:-unknown}: \${LOOP_CYCLE_TIMEOUT_SEC}s timeout — claude/python killed; in-progress story marked Blocked" 2>/dev/null || true
2371
+ fi
2372
+ rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE"
2373
+ exit "\$_rc"
2374
+ }
2375
+ trap '_inner_cleanup' EXIT
2266
2376
 
2267
2377
  # US-AUTO-037: pull in worktree helpers (US-AUTO-036). Sourcing bin/roll is
2268
2378
  # safe — its main() only runs when invoked directly (BASH_SOURCE == \$0).
@@ -2350,12 +2460,21 @@ export LOOP_PROJECT_SLUG="${slug}"
2350
2460
  export LOOP_CYCLE_ID="\${CYCLE_ID}"
2351
2461
  export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
2352
2462
  for _attempt in 1 2 3; do
2463
+ # FIX-057: watchdog — fires SIGTERM at the inner script (and its direct
2464
+ # children) when the cycle exceeds LOOP_CYCLE_TIMEOUT_SEC. Signal the inner
2465
+ # script first so _on_sigterm sets _CYCLE_TIMED_OUT before pkill takes out
2466
+ # the watchdog subshell itself (pkill -P \$\$ matches this subshell too).
2467
+ ( sleep "\$LOOP_CYCLE_TIMEOUT_SEC" && { kill -TERM \$\$ 2>/dev/null; pkill -TERM -P \$\$ 2>/dev/null; } ) &
2468
+ _WATCHDOG_PID=\$!
2353
2469
  if [ -f "\$FMT" ]; then
2354
2470
  ( cd "\$WT" && ${claude_cmd} ) | python3 "\$FMT"
2355
2471
  else
2356
2472
  ( cd "\$WT" && ${claude_cmd} )
2357
2473
  fi
2358
2474
  _exit=\$?
2475
+ kill "\$_WATCHDOG_PID" 2>/dev/null
2476
+ wait "\$_WATCHDOG_PID" 2>/dev/null
2477
+ [ "\$_CYCLE_TIMED_OUT" -eq 1 ] && break
2359
2478
  [ "\$_exit" -eq 0 ] && break
2360
2479
  if [ "\$_attempt" -lt 3 ]; then
2361
2480
  echo "[loop] claude exited \$_exit (attempt \$_attempt/3) — retrying in 30s..."
@@ -2363,6 +2482,12 @@ for _attempt in 1 2 3; do
2363
2482
  fi
2364
2483
  done
2365
2484
 
2485
+ # FIX-057: timed out — skip publish; EXIT trap writes cycle_end blocked + ALERT.
2486
+ if [ "\$_CYCLE_TIMED_OUT" -eq 1 ]; then
2487
+ echo "[loop] cycle \${CYCLE_ID}: \${LOOP_CYCLE_TIMEOUT_SEC}s timeout — aborting cycle (worktree preserved at \$WT)"
2488
+ exit 0
2489
+ fi
2490
+
2366
2491
  # FIX-044: capture cycle data from worktree before cleanup removes it
2367
2492
  _cycle_tcr=0
2368
2493
  _cycle_status="idle"
@@ -2587,33 +2712,28 @@ if command -v tmux >/dev/null 2>&1; then
2587
2712
  done
2588
2713
  tmux new-session -d -s "\$SESSION" -x 200 -y 50 "bash \"\$INNER_SCRIPT\""
2589
2714
  tmux pipe-pane -t "\$SESSION" "cat >> \"\$LOG\""
2590
- # Auto-attach popup: when not muted, spawn a Terminal window attached to the
2591
- # tmux session so the user can watch the loop work in real time. Best-effort
2592
- # focus retention: capture the current frontmost app and re-activate after.
2593
- if [ -z "\${ROLL_LOOP_NO_POPUP:-}" ] && [ ! -f "\$HOME/.shared/roll/loop/mute-${slug}" ] && [ "\$(uname)" = "Darwin" ]; then
2594
- # Runtime terminal detection: try preferred first, fallback through installed apps.
2595
- # open -na returns non-zero when app not found, so || chain works as fallback.
2596
- _launched=0
2597
- case "${terminal_pref}" in
2598
- ghostty|Ghostty)
2599
- open -na Ghostty.app --args -e tmux attach -t \$SESSION >/dev/null 2>&1 && _launched=1 || true
2600
- ;;
2601
- iTerm2|iTerm)
2602
- osascript \\
2603
- -e 'tell application "System Events" to set _prev to name of first application process whose frontmost is true' \\
2604
- -e "tell application \"iTerm2\" to create window with default profile command \"tmux attach -t \$SESSION\"" \\
2605
- -e 'delay 0.3' -e 'try' -e 'tell application _prev to activate' -e 'end try' >/dev/null 2>&1 \\
2606
- && _launched=1 || true
2607
- ;;
2608
- esac
2609
- if [ "\$_launched" -eq 0 ] && command -v osascript >/dev/null 2>&1; then
2610
- osascript \\
2611
- -e 'tell application "System Events" to set _prev to name of first application process whose frontmost is true' \\
2612
- -e "tell application \"Terminal\" to do script \"tmux attach -t \$SESSION\"" \\
2613
- -e 'delay 0.3' -e 'try' -e 'tell application _prev to activate' -e 'end try' >/dev/null 2>&1 || true
2614
- fi
2715
+ # Auto-attach popup: when not muted, spawn a Terminal.app window attached
2716
+ # to the tmux session so the user can watch the loop work in real time.
2717
+ # FIX-054: terminal selection removed fixed to macOS Terminal.app for
2718
+ # predictability (per-user detection silently failed on Ghostty upgrades).
2719
+ # Best-effort focus retention: capture the current frontmost app and
2720
+ # re-activate after.
2721
+ if [ -z "\${ROLL_LOOP_NO_POPUP:-}" ] && [ -z "\${BATS_TEST_NUMBER:-}" ] && [ ! -f "\$HOME/.shared/roll/loop/mute-${slug}" ] && [ "\$(uname)" = "Darwin" ] && command -v osascript >/dev/null 2>&1; then
2722
+ osascript \\
2723
+ -e 'tell application "System Events" to set _prev to name of first application process whose frontmost is true' \\
2724
+ -e "tell application \"Terminal\" to do script \"tmux attach -t \$SESSION\"" \\
2725
+ -e 'delay 0.3' -e 'try' -e 'tell application _prev to activate' -e 'end try' >/dev/null 2>&1 || true
2615
2726
  fi
2616
- while tmux has-session -t "\$SESSION" 2>/dev/null; do sleep 5; done
2727
+ _OUTER_TIMEOUT=\$(( \${ROLL_LOOP_CYCLE_TIMEOUT_SEC:-2700} + 300 ))
2728
+ _outer_wait_start=\$(date +%s)
2729
+ while tmux has-session -t "\$SESSION" 2>/dev/null; do
2730
+ sleep 5
2731
+ if (( \$(date +%s) - _outer_wait_start > _OUTER_TIMEOUT )); then
2732
+ echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] FIX-057: outer timeout (\${_OUTER_TIMEOUT}s) — killing tmux session \$SESSION" >> "\$LOG"
2733
+ tmux kill-session -t "\$SESSION" 2>/dev/null || true
2734
+ break
2735
+ fi
2736
+ done
2617
2737
  else
2618
2738
  bash "\$INNER_SCRIPT" >> "\$LOG" 2>&1
2619
2739
  fi
@@ -2659,16 +2779,7 @@ _install_launchd_plists() {
2659
2779
  brief_hour=$(_config_read_int "loop_brief_hour" "9")
2660
2780
  brief_minute=$(_config_read_int "loop_brief_minute" "$(_loop_derive_minute "$project_path" 4)")
2661
2781
 
2662
- # Terminal preference: config wins, then TERM_PROGRAM env, then "Terminal"
2663
- local terminal_pref
2664
- terminal_pref=$(config_get "loop_attach_terminal" "")
2665
- if [[ -z "$terminal_pref" ]]; then
2666
- case "${TERM_PROGRAM:-}" in
2667
- ghostty) terminal_pref="ghostty" ;;
2668
- iTerm.app) terminal_pref="iTerm2" ;;
2669
- *) terminal_pref="Terminal" ;;
2670
- esac
2671
- fi
2782
+ # FIX-054: terminal preference removed runner always uses Terminal.app.
2672
2783
 
2673
2784
  local services=("loop" "dream" "brief")
2674
2785
  local skill_names=("roll-loop" "roll-.dream" "roll-brief")
@@ -2677,6 +2788,9 @@ _install_launchd_plists() {
2677
2788
 
2678
2789
  local updated=0
2679
2790
  local slug; slug=$(_project_slug "$project_path")
2791
+ # FIX-058: after FIX-056 introduced realpath normalization, the slug for an
2792
+ # existing project may have changed. Migrate state before creating new files.
2793
+ _slug_migrate_from_legacy "$slug" "${shared}/loop"
2680
2794
  for i in "${!services[@]}"; do
2681
2795
  local svc="${services[$i]}"
2682
2796
  local skill="${skill_names[$i]}"
@@ -2690,7 +2804,7 @@ _install_launchd_plists() {
2690
2804
  local cmd; cmd=$(_agent_skill_cmd "${sd}/${skill}/SKILL.md" 2>/dev/null || echo "roll loop now")
2691
2805
 
2692
2806
  if [[ "$svc" == "loop" ]]; then
2693
- _write_loop_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log" "$active_start" "$active_end" "$terminal_pref"
2807
+ _write_loop_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log" "$active_start" "$active_end"
2694
2808
  else
2695
2809
  _write_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log"
2696
2810
  fi
@@ -2709,6 +2823,13 @@ _install_launchd_plists() {
2709
2823
  local uid; uid=$(id -u)
2710
2824
  launchctl bootout "gui/${uid}/${label}" 2>/dev/null || true
2711
2825
  launchctl bootstrap "gui/${uid}" "$plist" 2>/dev/null || true
2826
+ elif [[ -z "$before" ]]; then
2827
+ # FIX-059: brand-new plist — macOS FSEvents auto-bootstraps any new
2828
+ # file dropped in ~/Library/LaunchAgents/, so projects never enabled
2829
+ # via 'roll loop on' would fire every hour. Immediately mark disabled
2830
+ # in the overrides db to block that auto-load.
2831
+ local uid; uid=$(id -u)
2832
+ launchctl disable "gui/${uid}/${label}" 2>/dev/null || true
2712
2833
  fi
2713
2834
  fi
2714
2835
  done
@@ -2934,17 +3055,7 @@ _loop_test() {
2934
3055
  local log="${_SHARED_ROOT}/loop/cron-${slug}.log"
2935
3056
  local test_runner="${_SHARED_ROOT}/loop/run-${slug}-test.sh"
2936
3057
 
2937
- # Detect terminal pref same way _install_launchd_plists does
2938
- local terminal_pref
2939
- terminal_pref=$(config_get "loop_attach_terminal" "")
2940
- if [[ -z "$terminal_pref" ]]; then
2941
- case "${TERM_PROGRAM:-}" in
2942
- ghostty) terminal_pref="ghostty" ;;
2943
- iTerm.app) terminal_pref="iTerm2" ;;
2944
- *) terminal_pref="Terminal" ;;
2945
- esac
2946
- fi
2947
-
3058
+ # FIX-054: terminal preference removed runner always uses Terminal.app.
2948
3059
  local active_start active_end
2949
3060
  active_start=$(_config_read_int "loop_active_start" "10")
2950
3061
  active_end=$(_config_read_int "loop_active_end" "18")
@@ -2952,7 +3063,7 @@ _loop_test() {
2952
3063
  info "Generating test runner... 正在生成测试启动脚本..."
2953
3064
  _write_loop_runner_script "$test_runner" "$project_path" \
2954
3065
  'claude -p "Reply with a single word: hello"; sleep 10' \
2955
- "$log" "$active_start" "$active_end" "$terminal_pref"
3066
+ "$log" "$active_start" "$active_end"
2956
3067
 
2957
3068
  info "Starting smoke test (real claude, trivial prompt)... 正在运行 smoke 测试(真实 claude)..."
2958
3069
  info "Watch for: tmux session + terminal popup + stream-json events flowing"
@@ -4057,7 +4168,7 @@ _changelog_audit_bullet() {
4057
4168
  # Rule 1: length cap 30 (user-command in backticks bypasses this rule).
4058
4169
  if ! printf '%s' "$bullet" | grep -q '`'; then
4059
4170
  local len
4060
- len=$(LC_ALL=en_US.UTF-8 printf '%s' "$bullet" | LC_ALL=en_US.UTF-8 wc -m | tr -d ' ')
4171
+ len=$(printf '%s' "$bullet" | LC_ALL=C.UTF-8 wc -m | tr -d ' ')
4061
4172
  if [ "${len:-0}" -gt 30 ]; then
4062
4173
  echo "over-length-30"
4063
4174
  fi
@@ -4080,7 +4191,7 @@ _changelog_audit_bullet() {
4080
4191
  fi
4081
4192
 
4082
4193
  # Rule 5: required shape — em dash, 不再, or 现在.
4083
- if ! printf '%s' "$bullet" | grep -qE '—|不再|现在'; then
4194
+ if ! printf '%s' "$bullet" | LC_ALL=C.UTF-8 grep -qE '—|不再|现在'; then
4084
4195
  echo "bad-shape"
4085
4196
  fi
4086
4197
 
@@ -4654,7 +4765,17 @@ cmd_brief() {
4654
4765
  fi
4655
4766
  fi
4656
4767
 
4657
- [[ -f "$latest" ]] && cat "$latest"
4768
+ if [[ ! -f "$latest" ]]; then
4769
+ return 1
4770
+ fi
4771
+
4772
+ # ── Display mode ──────────────────────────────────────────────────────────
4773
+ if [[ "${ROLL_UI:-v2}" == "v2" ]]; then
4774
+ python3 "${ROLL_PKG_DIR}/lib/roll-brief.py" "$@"
4775
+ return
4776
+ fi
4777
+
4778
+ cat "$latest"
4658
4779
  }
4659
4780
 
4660
4781
  _promote_unreleased() {
@@ -4861,6 +4982,10 @@ cmd_backlog() {
4861
4982
  esac
4862
4983
 
4863
4984
  # ── Display mode ──────────────────────────────────────────────────────────
4985
+ if [[ "${ROLL_UI:-v2}" == "v2" ]]; then
4986
+ python3 "${ROLL_PKG_DIR}/lib/roll-backlog.py" "$@"
4987
+ return
4988
+ fi
4864
4989
  local DIM='\033[2m'
4865
4990
 
4866
4991
  local us_items fix_items refactor_items idea_items total=0
@@ -5150,7 +5275,7 @@ _dash_brief_summary() {
5150
5275
  ' "$latest" 2>/dev/null | head -1 | cut -c1-60
5151
5276
  }
5152
5277
 
5153
- _dashboard() {
5278
+ _legacy_home() {
5154
5279
  local project_path; project_path=$(pwd -P)
5155
5280
  local project_name; project_name=$(basename "$project_path")
5156
5281
  local agent; agent=$(_project_agent)
@@ -5331,10 +5456,18 @@ _dashboard() {
5331
5456
  echo ""
5332
5457
  }
5333
5458
 
5459
+ _home() {
5460
+ if [[ "${ROLL_UI:-v2}" == "v2" ]]; then
5461
+ python3 "${ROLL_PKG_DIR}/lib/roll-home.py" "$@"
5462
+ else
5463
+ _legacy_home "$@"
5464
+ fi
5465
+ }
5466
+
5334
5467
  # ═══════════════════════════════════════════════════════════════════════════════
5335
5468
  # MAIN
5336
5469
  # ═══════════════════════════════════════════════════════════════════════════════
5337
- usage() {
5470
+ _legacy_help() {
5338
5471
  echo -e "${CYAN} ██████╗ ██████╗ ██╗ ██╗ ${NC}"
5339
5472
  echo -e "${CYAN} ██╔══██╗██╔═══██╗██║ ██║ ${NC}"
5340
5473
  echo -e "${CYAN} ██████╔╝██║ ██║██║ ██║ ${NC}"
@@ -5376,6 +5509,14 @@ usage() {
5376
5509
 
5377
5510
  }
5378
5511
 
5512
+ _help() {
5513
+ if [[ "${ROLL_UI:-v2}" == "v2" ]]; then
5514
+ python3 "${ROLL_PKG_DIR}/lib/roll-help.py" "$@"
5515
+ else
5516
+ _legacy_help "$@"
5517
+ fi
5518
+ }
5519
+
5379
5520
  main() {
5380
5521
  local cmd="${1:-}"
5381
5522
  shift || true
@@ -5395,8 +5536,8 @@ main() {
5395
5536
  doctor) cmd_doctor "$@" ;;
5396
5537
  review-pr) cmd_review_pr "$@" ;;
5397
5538
  version|--version|-v) echo "roll v${VERSION}" ;;
5398
- help|--help|-h) usage ;;
5399
- "") [[ -f "BACKLOG.md" ]] && _dashboard || { usage; _show_changelog; } ;;
5539
+ help|--help|-h) _help "$@" ;;
5540
+ "") [[ -f "BACKLOG.md" ]] && _home || { _help; _show_changelog; } ;;
5400
5541
  *)
5401
5542
  err "Unknown command: $cmd 未知命令: $cmd"
5402
5543
  echo ""
@@ -9,6 +9,7 @@
9
9
  - **Ambiguity resolution**: When user says "explicit" in automation contexts,
10
10
  interpret as "logged/observable with clear output", NOT "requiring manual
11
11
  intervention". Confirm with one question if uncertain.
12
+ - **Voice**: Natural, colleague-like tone — neither robotic ("Executing…") nor over-enthusiastic ("Great!"). "Done — here's what changed." instead of "Task completed successfully." Consistent warmth for success and failure alike.
12
13
  - **Bilingual output**: EN + ZH on separate lines, never inline.
13
14
  ```
14
15
  Processing...