@seanyao/roll 2026.511.5 → 2026.511.7

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,30 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.511.11
4
+ - **Added**: loop 默认 auto-attach 弹窗 — 每次 loop 触发时,runner script 自动通过 osascript 开一个背景 Terminal 窗口 `tmux attach -t roll-loop-<slug>`,看 claude 实时打字干活;弹窗用 `delay 0.3` + 还原前一个 frontmost app 做到不抢焦点,tmux session 结束窗口保留供回看,关掉窗口 loop 仍在 tmux 里继续跑。
5
+ - **Added**: `roll loop mute` / `roll loop unmute` 一键开关 — 不想看弹窗时 `roll loop mute` 创建 `~/.shared/roll/mute` 标记文件即刻静音,`unmute` 删掉它恢复;mute 状态对所有项目生效(一个开关治整机),`roll loop status` 新增 `Auto-attach live | muted` 一行实时显示。
6
+ - **Added**: tmux 升级为 `roll setup` 必装依赖 — 新增 `_ensure_tmux` helper:macOS 自动 `brew install tmux`(无 brew 给手动命令),Linux/其他系统打印对应包管理器安装指引;任何失败路径都返回 0,不阻塞 setup 主流程。
7
+ - **Fixed**: launchd runner 缺 brew PATH 导致 hook 子进程报 `node: command not found` — launchd 默认 PATH 不含 `/opt/homebrew/bin`,claude 通过 `sh -c` 调 SessionEnd hook 时找不到 node;inner runner 模板显式 `export PATH="/opt/homebrew/bin:$PATH"` 让整条 fork 链都能拿到 brew 工具。
8
+ - **Fixed**: runs.jsonl schema 漂移 — 早期 claude 在 status/ts/alerts/project 字段自由发挥(`built` vs `success` vs `noop`、UTC vs `+08:00`、number vs array、全路径 vs slug)。SKILL Step 5 改为"严格契约":ts 强制 UTC Z 后缀、project 用 slug、alerts/built/skipped 永远是数组、status 限定 `built/idle/failed` 三个 enum 无同义词;contract test 锁死关键不变量留在 prompt 里。
9
+
10
+ ## v2026.511.9
11
+ - **Added**: `roll loop runs` 每次 loop 运行的快速可见性 — 单次 loop 结束追加一行 JSON 到 `~/.shared/roll/loop/runs.jsonl`(含 ts/project/run_id/status/built/skipped/alerts/tcr_count/duration_sec),新命令 `roll loop runs [N] [--all]` 倒序显示最近 N 次(默认 10),不必等次日早报就能查到中间 13 次 loop 各干了啥。
12
+ - **Added**: loop 跑在 tmux session + `roll loop attach` 实时观看 — runner script 自动把 claude 包进 detached tmux session `roll-loop-<slug>`,输出同时 pipe 到 `cron.log`;执行 `roll loop attach` 可随时 attach 上去看它打字、写文件、commit,Ctrl-B D 分离后 loop 继续跑;未装 tmux 时自动 fallback 到原 headless 模式,零依赖回退。
13
+ - **Improved**: roll-.dream 日志改为中文输出 — Dream Log 输出模板(概要 / 死代码 / 架构漂移 / 裁剪候选 / 新兴模式 / 创建的 REFACTOR 条目)和"未发现 / 部分完成"等固定文案全部中文化,与 roll-brief 风格对齐,晨间扫一眼不再需要在中英文之间切换语境。
14
+
15
+ ## v2026.511.8
16
+ - **Fixed**: 集成测试 launchd ghost 泄漏 — `integration_teardown` 在删除 TEST_TMP 之前,先 `launchctl bootout` 该沙箱里被 `roll loop on` 注册到 user gui domain 的所有 `com.roll.*` 服务,避免删 plist 后 launchd 仍保留指向不存在路径的 ghost 注册。
17
+
18
+ ## v2026.511.7
19
+ - **Added**: loop 执行 story 前显式标记 🔨 In Progress — roll-loop SKILL 在调用 executor 之前先把 BACKLOG 中的故事状态从 📋 Todo 改为 🔨 In Progress 并提交 `chore: mark US-XXX in progress`,brief 简报和 peer agent 都能即时感知正在进行的工作,tcr 微提交不再"对 brief 不可见"。
20
+ - **Added**: loop 启动时孤儿 🔨 自愈 — 扫描 BACKLOG 中无对应 state.yaml running item 的 🔨 条目,视为上次崩溃残留,自动 revert 回 📋 Todo 并写 ALERT,避免被"卡"在错误的中间状态里。
21
+ - **Improved**: roll-build / roll-fix SKILL 状态转换段更新 — 显式接受 📋 Todo 或 🔨 In Progress 作为 ✅ Done 前置状态,loop 触发链路状态过渡更稳健。
22
+
23
+ ## v2026.511.6
24
+ - **Added**: Loop 并发安全 — runner script 启动时写入 per-project LOCK 文件并检测重入;活跃 PID 已存在则跳过本次,残留死 LOCK 自动清理;正常/异常退出均通过 trap 清掉 LOCK。彻底防止两个 loop 实例同时启动造成的 BACKLOG/git 冲突。
25
+ - **Added**: roll-loop SKILL 显式声明 skip-🔨 In Progress 语义 — claude 扫 BACKLOG 时跳过已被人工或 peer agent 标记的执行中条目,为人机协同和多 agent 协作奠定基础。
26
+ - **Fixed**: 5 个 pre-existing 测试失败 — `run_roll` helper 切换到 TEST_TMP 作为 cwd 避免 slug 冲突;loop status 测试匹配三态显示新文案;dashboard 测试匹配 `_launchd_svc_state` + array 派生 schedule 的新结构。
27
+
3
28
  ## v2026.511.5
4
29
  - **Fixed**: launchd plist 自动 reload — plist 内容变更且服务已加载时自动 unload + reload,升级 roll 后 loop 服务立即生效,无需手动重启
5
30
  - **Improved**: roll loop status/monitor 三态展示 — 区分 ● 运行中 / ⚠ 已安装未加载 / ○ 未安装,并给出对应的自愈操作提示
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.511.5"
7
+ VERSION="2026.511.7"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -531,6 +531,35 @@ _sync_skills() {
531
531
  # COMMAND: setup [--force]
532
532
  # Initialize ~/.roll/ and sync everything to AI tools in one step
533
533
  # ═══════════════════════════════════════════════════════════════════════════════
534
+ # Ensures tmux is available (US-AUTO-026 promoted it from soft to required
535
+ # dependency for visible loop runs). On macOS attempts `brew install tmux`
536
+ # when brew exists; elsewhere prints the install command. Never fails the
537
+ # setup main flow — returns 0 even if install was not possible so the rest
538
+ # of `roll setup` proceeds.
539
+ _ensure_tmux() {
540
+ if command -v tmux >/dev/null 2>&1; then
541
+ return 0
542
+ fi
543
+
544
+ local os; os="$(uname)"
545
+ if [[ "$os" == "Darwin" ]]; then
546
+ if command -v brew >/dev/null 2>&1; then
547
+ info "tmux not found — installing via brew... 未安装 tmux,正在通过 brew 安装..."
548
+ if brew install tmux >/dev/null 2>&1; then
549
+ ok "tmux installed. tmux 已安装。"
550
+ return 0
551
+ fi
552
+ warn "brew install tmux failed — install manually: brew install tmux brew 安装失败,请手动 'brew install tmux'"
553
+ return 0
554
+ fi
555
+ warn "tmux required but brew not available — install manually: brew install tmux 缺少 brew,请手动 'brew install tmux'"
556
+ return 0
557
+ fi
558
+
559
+ warn "tmux required — install via your package manager (e.g. apt install tmux / pacman -S tmux) 请用系统包管理器安装 tmux"
560
+ return 0
561
+ }
562
+
534
563
  cmd_setup() {
535
564
  local force=false
536
565
  while [[ $# -gt 0 ]]; do
@@ -555,6 +584,10 @@ cmd_setup() {
555
584
  _peer_ensure_state_dir
556
585
  ok "Peer state directory ready. Peer 状态目录已就绪。"
557
586
 
587
+ echo ""
588
+ info "Ensuring tmux is installed (required for visible loop runs)... 正在确认 tmux 已安装..."
589
+ _ensure_tmux
590
+
558
591
  if [[ "$(uname)" == "Darwin" ]]; then
559
592
  echo ""
560
593
  info "Installing launchd plists... 正在安装 LaunchAgents..."
@@ -1751,6 +1784,8 @@ _LOOP_TAG="# roll-loop"
1751
1784
  _SHARED_ROOT="${HOME}/.shared/roll"
1752
1785
  _LOOP_STATE="${HOME}/.shared/roll/loop/state.yaml"
1753
1786
  _LOOP_ALERT="${HOME}/.shared/roll/loop/ALERT.md"
1787
+ _LOOP_RUNS="${HOME}/.shared/roll/loop/runs.jsonl"
1788
+ _LOOP_MUTE_FILE="${HOME}/.shared/roll/mute"
1754
1789
  _LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
1755
1790
 
1756
1791
  # Returns a filesystem-safe slug combining the project basename and a 6-char
@@ -1847,15 +1882,60 @@ _write_runner_script() {
1847
1882
 
1848
1883
  # Like _write_runner_script but prepends an active window guard.
1849
1884
  # Silently exits when current hour is outside [active_start, active_end).
1885
+ # When tmux is available, wraps the inner command in a detached tmux session
1886
+ # named `roll-loop-<slug>` so `roll loop attach` can watch in real time.
1887
+ # Falls back to headless execution when tmux is not installed.
1850
1888
  _write_loop_runner_script() {
1851
1889
  local script_path="$1" project_path="$2" cmd="$3" log_path="$4"
1852
1890
  local active_start="${5:-10}" active_end="${6:-18}"
1853
1891
  mkdir -p "$(dirname "$script_path")"
1892
+
1893
+ local inner_path="${script_path%.sh}-inner.sh"
1894
+ cat > "$inner_path" << INNER
1895
+ #!/bin/bash -l
1896
+ export PATH="/opt/homebrew/bin:\$PATH"
1897
+ cd "${project_path}" && ${cmd}
1898
+ INNER
1899
+ chmod +x "$inner_path"
1900
+
1854
1901
  cat > "$script_path" << SCRIPT
1855
1902
  #!/bin/bash -l
1856
1903
  h=\$(printf '%d' "\$(date +%H)")
1857
1904
  if [ "\$h" -lt ${active_start} ] || [ "\$h" -ge ${active_end} ]; then exit 0; fi
1858
- cd "${project_path}" && ${cmd} >> "${log_path}" 2>&1
1905
+ LOCK="\$(dirname "\$0")/.LOCK-\$(basename "\$0" .sh | sed 's/^run-//')"
1906
+ SESSION="roll-loop-\$(basename "\$0" .sh | sed 's/^run-//')"
1907
+ INNER_SCRIPT="${inner_path}"
1908
+ LOG="${log_path}"
1909
+ if [ -f "\$LOCK" ]; then
1910
+ prev_pid=\$(head -1 "\$LOCK" 2>/dev/null || echo "")
1911
+ if [ -n "\$prev_pid" ] && kill -0 "\$prev_pid" 2>/dev/null; then
1912
+ echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] loop already running (PID \$prev_pid), skipping" >> "\$LOG"
1913
+ exit 0
1914
+ fi
1915
+ rm -f "\$LOCK"
1916
+ fi
1917
+ echo "\$\$" > "\$LOCK"
1918
+ trap 'rm -f "\$LOCK"' EXIT
1919
+ if command -v tmux >/dev/null 2>&1; then
1920
+ tmux kill-session -t "\$SESSION" 2>/dev/null || true
1921
+ tmux new-session -d -s "\$SESSION" -x 200 -y 50 "bash \"\$INNER_SCRIPT\""
1922
+ tmux pipe-pane -t "\$SESSION" "cat >> \"\$LOG\""
1923
+ # Auto-attach popup: when not muted, spawn a Terminal window attached to the
1924
+ # tmux session so the user can watch the loop work in real time. Best-effort
1925
+ # focus retention: capture the current frontmost app and re-activate after.
1926
+ if [ ! -f "\$HOME/.shared/roll/mute" ] && [ "\$(uname)" = "Darwin" ] && command -v osascript >/dev/null 2>&1; then
1927
+ (osascript \\
1928
+ -e 'tell application "System Events" to set _prev to name of first application process whose frontmost is true' \\
1929
+ -e "tell application \"Terminal\" to do script \"tmux attach -t \$SESSION\"" \\
1930
+ -e 'delay 0.3' \\
1931
+ -e 'try' \\
1932
+ -e 'tell application _prev to activate' \\
1933
+ -e 'end try' >/dev/null 2>&1) &
1934
+ fi
1935
+ while tmux has-session -t "\$SESSION" 2>/dev/null; do sleep 5; done
1936
+ else
1937
+ bash "\$INNER_SCRIPT" >> "\$LOG" 2>&1
1938
+ fi
1859
1939
  SCRIPT
1860
1940
  chmod +x "$script_path"
1861
1941
  }
@@ -1967,9 +2047,13 @@ cmd_loop() {
1967
2047
  now) _loop_now ;;
1968
2048
  status) _loop_status ;;
1969
2049
  monitor) _loop_monitor "${1:-3}" ;;
2050
+ runs) _loop_runs "$@" ;;
2051
+ attach) _loop_attach ;;
2052
+ mute) _loop_mute ;;
2053
+ unmute) _loop_unmute ;;
1970
2054
  resume) _loop_resume ;;
1971
2055
  reset) _loop_reset ;;
1972
- *) err "Usage: roll loop <on|off|now|status|monitor|resume|reset>"; exit 1 ;;
2056
+ *) err "Usage: roll loop <on|off|now|status|monitor|runs|attach|mute|unmute|resume|reset>"; exit 1 ;;
1973
2057
  esac
1974
2058
  }
1975
2059
 
@@ -2107,6 +2191,12 @@ _loop_status() {
2107
2191
  echo -e " Scheduler ${YELLOW}○ disabled${NC} run: roll loop on"
2108
2192
  fi
2109
2193
  fi
2194
+ echo ""
2195
+ if [[ -f "$_LOOP_MUTE_FILE" ]]; then
2196
+ echo -e " Auto-attach ${YELLOW}muted${NC} run: roll loop unmute"
2197
+ else
2198
+ echo -e " Auto-attach ${GREEN}live${NC} run: roll loop mute"
2199
+ fi
2110
2200
  [[ -f "$_LOOP_ALERT" ]] && { echo ""; echo -e " ${RED}⚠ ALERT:${NC}"; sed 's/^/ /' "$_LOOP_ALERT"; }
2111
2201
  [[ -f "$_LOOP_STATE" ]] && { echo ""; echo " State:"; sed 's/^/ /' "$_LOOP_STATE"; }
2112
2202
  echo ""
@@ -2132,6 +2222,138 @@ _loop_reset() {
2132
2222
  fi
2133
2223
  }
2134
2224
 
2225
+ # Suppress the auto-attach popup. When the marker file exists, runner scripts
2226
+ # skip the osascript Terminal-popup step on next fire. Loop output still goes
2227
+ # to tmux + log; users can run `roll loop attach` manually.
2228
+ _loop_mute() {
2229
+ mkdir -p "$(dirname "$_LOOP_MUTE_FILE")"
2230
+ : > "$_LOOP_MUTE_FILE"
2231
+ ok "🔇 muted — auto-attach disabled 已静音,自动弹窗已关闭"
2232
+ }
2233
+
2234
+ # Re-enable the auto-attach popup.
2235
+ _loop_unmute() {
2236
+ rm -f "$_LOOP_MUTE_FILE"
2237
+ ok "🔔 unmuted — auto-attach live 已恢复,自动弹窗已开启"
2238
+ }
2239
+
2240
+ # Attach to the tmux session a running loop iteration writes to. Returns 1 when
2241
+ # tmux is missing or no session exists for the current project.
2242
+ _loop_attach() {
2243
+ local project_path; project_path=$(pwd -P)
2244
+ local slug; slug=$(_project_slug "$project_path")
2245
+ local session="roll-loop-${slug}"
2246
+
2247
+ if ! command -v tmux >/dev/null 2>&1; then
2248
+ warn "tmux not installed — install with 'brew install tmux' 请先安装 tmux"
2249
+ return 1
2250
+ fi
2251
+
2252
+ if ! tmux has-session -t "$session" 2>/dev/null; then
2253
+ info "No running loop session for this project 当前项目无运行中的 loop"
2254
+ info "Wait for next scheduled fire, or run: roll loop now"
2255
+ return 1
2256
+ fi
2257
+
2258
+ exec tmux attach -t "$session"
2259
+ }
2260
+
2261
+ # Pretty-print a duration in seconds as "Xs" / "Ym" / "Yh Zm".
2262
+ _loop_runs_dur() {
2263
+ local s="${1:-0}"
2264
+ if [[ "$s" -lt 60 ]]; then printf "%ds" "$s"
2265
+ elif [[ "$s" -lt 3600 ]]; then printf "%dm" "$((s / 60))"
2266
+ else printf "%dh %dm" "$((s / 3600))" "$(((s % 3600) / 60))"
2267
+ fi
2268
+ }
2269
+
2270
+ # Format a single JSONL run record for display.
2271
+ _loop_runs_format_line() {
2272
+ local line="$1" show_project="$2"
2273
+ command -v jq >/dev/null 2>&1 || { echo " (jq required)"; return 0; }
2274
+
2275
+ local ts status project tcr duration alerts run_id reason built_count built_csv skipped_count
2276
+ ts=$(jq -r '.ts // ""' <<<"$line")
2277
+ status=$(jq -r '.status // ""' <<<"$line")
2278
+ project=$(jq -r '.project // ""' <<<"$line")
2279
+ tcr=$(jq -r '.tcr_count // 0' <<<"$line")
2280
+ duration=$(jq -r '.duration_sec // 0' <<<"$line")
2281
+ alerts=$(jq -r '.alerts // 0' <<<"$line")
2282
+ run_id=$(jq -r '.run_id // ""' <<<"$line")
2283
+ reason=$(jq -r '.reason // ""' <<<"$line")
2284
+ built_count=$(jq -r '(.built // []) | length' <<<"$line")
2285
+ built_csv=$(jq -r '(.built // []) | join(", ")' <<<"$line")
2286
+ skipped_count=$(jq -r '(.skipped // []) | length' <<<"$line")
2287
+
2288
+ local hhmm; hhmm=$(printf "%s" "$ts" | sed -E 's/.*T([0-9]{2}):([0-9]{2}).*/\1:\2/')
2289
+ local prefix=""
2290
+ if [[ "$show_project" == "true" ]]; then
2291
+ prefix="[$(basename "$project")] "
2292
+ fi
2293
+
2294
+ case "$status" in
2295
+ built)
2296
+ local skipped_note=""
2297
+ [[ "$skipped_count" -gt 0 ]] && skipped_note=", ${skipped_count} skipped"
2298
+ printf " %s %s✅ built %s (%d items, %d tcr%s, %s)\n" \
2299
+ "$hhmm" "$prefix" "$built_csv" "$built_count" "$tcr" "$skipped_note" "$(_loop_runs_dur "$duration")"
2300
+ ;;
2301
+ idle)
2302
+ printf " %s %s○ idle — no Todo items\n" "$hhmm" "$prefix"
2303
+ ;;
2304
+ failed)
2305
+ local msg="${reason:-unknown}"
2306
+ printf " %s %s✗ FAILED — %s\n" "$hhmm" "$prefix" "$msg"
2307
+ ;;
2308
+ *)
2309
+ printf " %s %s? %s\n" "$hhmm" "$prefix" "$status"
2310
+ ;;
2311
+ esac
2312
+ }
2313
+
2314
+ # `roll loop runs [N] [--all]` — show recent loop iteration summaries.
2315
+ _loop_runs() {
2316
+ local n=10 all_flag=false
2317
+ while [[ $# -gt 0 ]]; do
2318
+ case "$1" in
2319
+ --all) all_flag=true; shift ;;
2320
+ [0-9]*) n="$1"; shift ;;
2321
+ *) shift ;;
2322
+ esac
2323
+ done
2324
+
2325
+ if [[ ! -f "$_LOOP_RUNS" ]] || [[ ! -s "$_LOOP_RUNS" ]]; then
2326
+ echo "No loop runs yet 尚无 loop 运行记录"
2327
+ return 0
2328
+ fi
2329
+
2330
+ if ! command -v jq >/dev/null 2>&1; then
2331
+ err "jq required for 'roll loop runs' 需要 jq"
2332
+ return 1
2333
+ fi
2334
+
2335
+ local project_path; project_path=$(pwd -P)
2336
+ local filtered
2337
+ if $all_flag; then
2338
+ filtered=$(cat "$_LOOP_RUNS")
2339
+ else
2340
+ filtered=$(jq -c --arg p "$project_path" 'select(.project == $p)' "$_LOOP_RUNS")
2341
+ fi
2342
+
2343
+ if [[ -z "$filtered" ]]; then
2344
+ echo "No loop runs for current project 当前项目尚无 loop 运行记录"
2345
+ return 0
2346
+ fi
2347
+
2348
+ local reversed; reversed=$(printf "%s\n" "$filtered" | awk '{a[NR]=$0} END{for(i=NR; i>=1; i--) print a[i]}')
2349
+ local recent; recent=$(printf "%s\n" "$reversed" | head -n "$n")
2350
+
2351
+ while IFS= read -r line; do
2352
+ [[ -z "$line" ]] && continue
2353
+ _loop_runs_format_line "$line" "$all_flag"
2354
+ done <<<"$recent"
2355
+ }
2356
+
2135
2357
  # Count `tcr:` prefixed commits in the current git repo since started_at timestamp.
2136
2358
  _loop_tcr_count() {
2137
2359
  local started_at="$1"
@@ -2454,13 +2676,28 @@ _dashboard() {
2454
2676
  if $_dash_loop_enabled; then
2455
2677
  echo -e " Loop ${GREEN}● on${NC} Agent: ${CYAN}${agent}${NC}"
2456
2678
  if [[ "$(uname)" == "Darwin" ]]; then
2457
- for svc_info in "loop:hourly" "dream:03:00" "brief:09:00"; do
2458
- local svc="${svc_info%%:*}" schedule="${svc_info#*:}"
2459
- if _launchd_is_loaded "$(_launchd_label "$svc" "$project_path")"; then
2460
- printf " ${GREEN}%-8s ● enabled${NC} (%s)\n" "$svc" "$schedule"
2461
- else
2462
- printf " ${YELLOW}%-8s ○ disabled${NC} (%s)\n" "$svc" "$schedule"
2463
- fi
2679
+ local active_start active_end loop_minute dream_hour dream_minute brief_hour brief_minute
2680
+ active_start=$(_config_read_int "loop_active_start" "10")
2681
+ active_end=$(_config_read_int "loop_active_end" "18")
2682
+ loop_minute=$(_config_read_int "loop_minute" "$(_loop_derive_minute "$project_path" 0)")
2683
+ dream_hour=$(_config_read_int "loop_dream_hour" "3")
2684
+ dream_minute=$(_config_read_int "loop_dream_minute" "$(_loop_derive_minute "$project_path" 2)")
2685
+ brief_hour=$(_config_read_int "loop_brief_hour" "9")
2686
+ brief_minute=$(_config_read_int "loop_brief_minute" "$(_loop_derive_minute "$project_path" 4)")
2687
+ local loop_sched dream_sched brief_sched
2688
+ loop_sched=$(printf "every hour :%02d active %02d:00–%02d:00" "$loop_minute" "$active_start" "$active_end")
2689
+ dream_sched=$(printf "%02d:%02d" "$dream_hour" "$dream_minute")
2690
+ brief_sched=$(printf "%02d:%02d" "$brief_hour" "$brief_minute")
2691
+ local svcs=("loop" "dream" "brief")
2692
+ local scheds=("$loop_sched" "$dream_sched" "$brief_sched")
2693
+ for i in "${!svcs[@]}"; do
2694
+ local svc="${svcs[$i]}" schedule="${scheds[$i]}"
2695
+ local state; state=$(_launchd_svc_state "$svc" "$project_path")
2696
+ case "$state" in
2697
+ enabled) printf " ${GREEN}%-8s ● enabled${NC} (%s)\n" "$svc" "$schedule" ;;
2698
+ installed-off) printf " ${YELLOW}%-8s ⚠ off${NC} (%s) run: roll loop on\n" "$svc" "$schedule" ;;
2699
+ not-installed) printf " ${RED}%-8s ○ missing${NC} (%s) run: roll setup\n" "$svc" "$schedule" ;;
2700
+ esac
2464
2701
  done
2465
2702
  fi
2466
2703
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.511.5",
3
+ "version": "2026.511.7",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "find tests/unit tests/integration -name '*.bats' | sort | xargs ./tests/helpers/bats-core/bin/bats"
@@ -113,29 +113,31 @@ complexity or prevent future bugs. Ignore cosmetic issues.
113
113
 
114
114
  ### Dream Log (docs/dream/YYYY-MM-DD.md)
115
115
 
116
- Always write a log, even when no REFACTOR entries are created:
116
+ Always write a log, even when no REFACTOR entries are created. Output uses
117
+ Chinese to align with roll-brief style — easier for the morning reader to scan
118
+ without context switching:
117
119
 
118
120
  ```markdown
119
121
  # Dream Log {YYYY-MM-DD}
120
122
 
121
- ## Summary
122
- - Scans run: Dead Code / Architectural Drift / Pruning / Patterns
123
- - Findings: {N} flagged, {M} REFACTOR entries created
123
+ ## 概要
124
+ - 扫描项:死代码 / 架构漂移 / 裁剪候选 / 新兴模式
125
+ - 发现:{N} 项标记,{M} REFACTOR 条目已创建
124
126
 
125
- ## Dead Code
126
- {finding or "Nothing flagged."}
127
+ ## 死代码
128
+ {发现内容 "未发现死代码。"}
127
129
 
128
- ## Architectural Drift
129
- {finding or "No drift detected."}
130
+ ## 架构漂移
131
+ {发现内容 "未发现架构漂移。"}
130
132
 
131
- ## Pruning Candidates
132
- {finding or "Nothing flagged."}
133
+ ## 裁剪候选
134
+ {发现内容 "未发现可裁剪项。"}
133
135
 
134
- ## Emerging Patterns
135
- {finding or "No patterns detected."}
136
+ ## 新兴模式
137
+ {发现内容 "未发现可提取的重复模式。"}
136
138
 
137
- ## REFACTOR Entries Created
138
- {list or "None."}
139
+ ## 创建的 REFACTOR 条目
140
+ {列表 "无。"}
139
141
  ```
140
142
 
141
143
  ## Scheduler Configuration
@@ -151,7 +153,7 @@ The cron entry is generated using the configured agent — no manual cron editin
151
153
 
152
154
  If the scan fails partway through:
153
155
 
154
- 1. Write partial results to `docs/dream/YYYY-MM-DD.md` with a `## Status: PARTIAL` header
156
+ 1. Write partial results to `docs/dream/YYYY-MM-DD.md` with a `## 状态:部分完成` header
155
157
  2. Do not write incomplete REFACTOR entries to BACKLOG
156
158
  3. Log the error to `~/.shared/roll/dream/error.log`
157
159
 
@@ -483,7 +483,7 @@ Both locations must be updated — neither can be skipped:
483
483
  | [US-{ID}](docs/features/<feature>.md#us-{id}) | {Title} | ✅ Done |
484
484
  ```
485
485
 
486
- Change the Status from `📋 Todo` to `✅ Done`.
486
+ Change the Status from `📋 Todo` or `🔨 In Progress` (whichever the row currently shows) to `✅ Done`. When invoked by `roll-loop`, the row will already be `🔨 In Progress` — that is the expected starting state, and the transition is the same Edit operation.
487
487
  For Fly mode: first append an index row under the appropriate Epic > Feature group, then mark it done.
488
488
 
489
489
  **② Update `docs/features/<feature>.md` US section:**
@@ -296,7 +296,7 @@ Both locations must be updated — neither can be skipped:
296
296
  | [FIX-{ID}](docs/features/<feature>.md#fix-{id}) | {Title} | ✅ Done |
297
297
  ```
298
298
 
299
- Change the Status of the corresponding row from `📋 Todo` to `✅ Done`.
299
+ Change the Status of the corresponding row from `📋 Todo` or `🔨 In Progress` (whichever the row currently shows) to `✅ Done`. When invoked by `roll-loop`, the row will already be `🔨 In Progress` — that is the expected starting state.
300
300
 
301
301
  **② Update `docs/features/<feature>.md` FIX section:**
302
302
 
@@ -57,17 +57,56 @@ if [ -f "$STATE_FILE" ] && grep -q "status: interrupted" "$STATE_FILE"; then
57
57
  fi
58
58
  ```
59
59
 
60
+ **Orphan 🔨 recovery** — clean up stories left in `🔨 In Progress` by a crashed previous run:
61
+
62
+ 1. Scan BACKLOG.md for all rows whose Status column contains `🔨 In Progress`.
63
+ 2. For each such story, check `state.yaml`:
64
+ - If `current_item` matches the story id AND `status: running` → this is the resume case (handled above), leave it.
65
+ - Otherwise → this is an **orphan 🔨** (the loop that marked it crashed before finishing). Revert the status back to `📋 Todo`, commit `chore: revert orphan 🔨 US-XXX to 📋`, and append a line to `~/.shared/roll/loop/ALERT.md` recording the orphan id and time so the next brief surfaces it.
66
+ 3. After orphan sweep, proceed to Step 2 (Scan BACKLOG) — the reverted stories will be picked up normally if they're top of the queue.
67
+
60
68
  ### Step 2 — Scan BACKLOG
61
69
 
62
70
  Read `BACKLOG.md`. Collect all rows where Status = `📋 Todo`, in order:
63
71
 
64
72
  Priority: FIX-XXX first (bugs block progress), then US-XXX, then REFACTOR-XXX.
65
73
 
74
+ **Skip rows with Status = `🔨 In Progress`**. These are currently being executed by:
75
+ - Another concurrent executor (human via `$roll-build`, peer agent)
76
+ - An earlier loop iteration that hasn't finished yet (rare; should be guarded by LOCK)
77
+ - A previous interrupted run (the resume logic in Step 1 will pick these up)
78
+
66
79
  Cap at `max_items_per_run` to limit blast radius per cycle.
67
80
 
81
+ ### Concurrency Safety
82
+
83
+ Loop has two layers of concurrency protection:
84
+
85
+ 1. **Per-project LOCK** (enforced by runner script, see `bin/roll:_write_loop_runner_script`):
86
+ - LOCK file path: `~/.shared/roll/loop/.LOCK-<project-slug>`
87
+ - On launch: if LOCK exists and the PID inside is alive → exit 0 (previous loop still running)
88
+ - On launch: if LOCK exists but PID is dead → clean up stale LOCK and continue
89
+ - On exit (normal or via trap): LOCK is removed
90
+ - One LOCK per project — different projects' loops run independently
91
+
92
+ 2. **🔨 In Progress story status** (enforced here):
93
+ - Before picking a story, check its status is `📋 Todo`
94
+ - Skip any `🔨 In Progress` row (someone else is on it)
95
+ - Mark each story `🔨 In Progress` BEFORE invoking the executor skill (see Step 3)
96
+ - On completion: update to `✅ Done`; on TCR failure: revert to `📋 Todo`
97
+
98
+ Together these mean: only one loop runs at a time per project (LOCK), and within a loop, stories already claimed by humans or peer agents are skipped (status check).
99
+
68
100
  ### Step 3 — Route and Execute
69
101
 
70
- For each item:
102
+ For each item, **before invoking the executor skill**, mark the story 🔨 In Progress in BACKLOG.md so brief and peer agents can see it's being worked on:
103
+
104
+ 1. Edit BACKLOG.md: change the row's Status column from `📋 Todo` to `🔨 In Progress`.
105
+ 2. Commit: `git commit -am "chore: mark US-XXX in progress"` (use the actual story id).
106
+
107
+ This commit is what makes the work visible — without it, tcr micro-commits during execution are invisible to `roll-brief`.
108
+
109
+ Then invoke the executor:
71
110
 
72
111
  ```
73
112
  Item type → Skill invoked
@@ -77,7 +116,9 @@ FIX-XXX → Skill("roll-fix", "FIX-XXX")
77
116
  REFACTOR-XXX → Skill("roll-build", "REFACTOR-XXX")
78
117
  ```
79
118
 
80
- Before invoking, write current item to state file:
119
+ The executor will update the row to `✅ Done` on success (it transitions from `🔨 In Progress` → `✅ Done`, same Edit logic as from `📋 Todo`).
120
+
121
+ Before invoking, also write current item to state file:
81
122
 
82
123
  ```yaml
83
124
  # ~/.shared/roll/loop/state.yaml
@@ -112,6 +153,67 @@ last_run_items: [US-AUTH-003, FIX-007]
112
153
  last_run_outcome: success
113
154
  ```
114
155
 
156
+ Then append a JSONL record to `~/.shared/roll/loop/runs.jsonl` for per-iteration
157
+ visibility (one line per cycle, append-only — never delete or rewrite earlier lines).
158
+
159
+ **⚠️ Strict schema contract — do NOT deviate.** Every field has exactly one
160
+ canonical form. Synonyms like `"success"`, `"noop"`, `"completed"` are forbidden
161
+ for `status`. Numbers and arrays cannot be interchanged. UTC `Z` suffix only,
162
+ no timezone offsets. **No extra fields** — emit only the keys listed below (plus
163
+ optional `reason` when `status="failed"`); do not add `note`, `comment`,
164
+ `details`, `info`, etc. If you feel the urge to annotate, put it in the cycle's
165
+ final report in `cron.log` instead.
166
+
167
+ **Canonical record (copy this exact shape, fill in real values):**
168
+
169
+ ```json
170
+ {"ts":"2026-05-11T11:46:43Z","project":"roll-d9dfa0","run_id":"loop-20260511-1911","status":"built","built":["US-AUTO-024","US-AUTO-025"],"skipped":[],"alerts":[],"tcr_count":5,"duration_sec":2080}
171
+ ```
172
+
173
+ **Field contract — types are enforced**:
174
+
175
+ | Field | Type | Format / Enum |
176
+ |---|---|---|
177
+ | `ts` | string | ISO 8601 **UTC** with `Z` suffix. Get via `date -u +%Y-%m-%dT%H:%M:%SZ`. Never use `+08:00` or other offsets. |
178
+ | `project` | string | Project **slug** only (e.g. `roll-d9dfa0`), NOT the absolute path. Derive from `basename` of plist label or `_project_slug` output. |
179
+ | `run_id` | string | Matches `state.yaml` `run_id` exactly. Format: `loop-YYYYMMDD-HHMM`. |
180
+ | `status` | enum | Exactly one of: `built` (≥1 story shipped), `idle` (no Todo items found), `failed` (paused/error). **No synonyms.** |
181
+ | `built` | array&lt;string&gt; | Story ids completed this cycle. `[]` when none. **Always array, never null/number.** |
182
+ | `skipped` | array&lt;string&gt; | Story ids skipped because they were `🔨 In Progress`. `[]` when none. **Always array.** |
183
+ | `alerts` | array&lt;string&gt; | Newly raised ALERT identifiers/tags this cycle. `[]` when none. **Always array, never number.** |
184
+ | `tcr_count` | integer | Total `tcr:` prefix commits made this cycle. `0` when none. |
185
+ | `duration_sec` | integer | Seconds from cycle start to completion. Integer only, no decimals. |
186
+
187
+ Optional field, only when `status == "failed"`:
188
+ - `reason` (string): short human-readable explanation.
189
+
190
+ **Write recipe:**
191
+
192
+ ```bash
193
+ ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
194
+ project=$(basename "$(pwd)" | sed 's/.*-//') # or use _project_slug if available
195
+ # duration_sec = cycle_end_epoch - cycle_start_epoch (track at Step 1)
196
+ # tcr_count = git log --oneline --since="<cycle_start>" | grep -c '^[a-f0-9]* tcr:'
197
+
198
+ jq -nc \
199
+ --arg ts "$ts" \
200
+ --arg project "$project" \
201
+ --arg run_id "$run_id" \
202
+ --arg status "built" \
203
+ --argjson built '["US-AUTO-024"]' \
204
+ --argjson skipped '[]' \
205
+ --argjson alerts '[]' \
206
+ --argjson tcr_count 14 \
207
+ --argjson duration_sec 1680 \
208
+ '{ts:$ts, project:$project, run_id:$run_id, status:$status,
209
+ built:$built, skipped:$skipped, alerts:$alerts,
210
+ tcr_count:$tcr_count, duration_sec:$duration_sec}' \
211
+ >> ~/.shared/roll/loop/runs.jsonl
212
+ ```
213
+
214
+ The companion read-side is `roll loop runs [N] [--all]` — shows the most recent
215
+ N records (default 10) for the current project, or across all projects with `--all`.
216
+
115
217
  ## Failure Handling
116
218
 
117
219
  ### Network Error (transient)
@@ -194,6 +296,38 @@ roll loop status # show current state
194
296
  roll loop now # execute one cycle immediately
195
297
  ```
196
298
 
299
+ ### Live attach (transparency)
300
+
301
+ Each loop iteration runs inside a detached tmux session named
302
+ `roll-loop-<slug>` (tmux is a required dependency — `roll setup` auto-installs
303
+ it via Homebrew on macOS, or prints the install command elsewhere).
304
+
305
+ **Default — auto-attach popup**: when the loop fires, a background Terminal
306
+ window pops up running `tmux attach -t roll-loop-<slug>`. You can watch the
307
+ agent work in real time without typing anything. The popup is best-effort
308
+ focus-retaining (it captures the previously-active app and restores focus
309
+ after the window appears) and the tmux session keeps running even if you
310
+ close the window.
311
+
312
+ **Manual attach** (any time):
313
+
314
+ ```bash
315
+ roll loop attach # exec tmux attach -t roll-loop-<slug>
316
+ ```
317
+
318
+ Press `Ctrl-B D` to detach — the loop continues running uninterrupted.
319
+
320
+ **Mute / unmute the popup**:
321
+
322
+ ```bash
323
+ roll loop mute # 🔇 — suppress auto-attach popup (loop still runs in tmux)
324
+ roll loop unmute # 🔔 — re-enable the popup
325
+ ```
326
+
327
+ Mute state is a single marker file at `~/.shared/roll/mute` and is shared
328
+ across all projects on this machine. Check the current state with
329
+ `roll loop status` — it shows an `Auto-attach: live | muted` line.
330
+
197
331
  ## Integration Map
198
332
 
199
333
  ```