@seanyao/roll 2026.511.8 → 2026.512.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/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # Changelog
2
2
 
3
3
  ## Unreleased
4
+ - **Added**: BACKLOG 支持 block / defer / unblock 状态管理 — 标记卡住的任务不再占队列
5
+ - **Fixed**: 自动弹窗现在能识别 Ghostty 和 iTerm2,不再强制弹出 Terminal.app
6
+ - **Fixed**: loop 检测到上一轮还在跑时自动跳过,不重复启动
7
+
8
+ ## v2026.512.1
9
+ - **Added**: `roll loop pause` / `roll loop resume` — 想自己上手时一键暂停 loop,做完再恢复
10
+ - **Added**: `roll status` 新增所有项目的 loop 状态一览 — 调度时间、待办数、是否在跑
4
11
  - **Fixed**: `roll loop attach` 不再黑屏,AI 干活过程实时可见
5
12
 
6
13
  ## v2026.511.7
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.8"
7
+ VERSION="2026.512.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"
@@ -1254,6 +1254,63 @@ cmd_status() {
1254
1254
  fi
1255
1255
  done
1256
1256
 
1257
+ _status_loop_overview
1258
+ }
1259
+
1260
+ _status_loop_overview() {
1261
+ [[ "$(uname)" != "Darwin" ]] && return 0
1262
+
1263
+ local plists=()
1264
+ while IFS= read -r p; do
1265
+ [[ -f "$p" ]] && plists+=("$p")
1266
+ done < <(ls "${_LAUNCHD_DIR}"/com.roll.loop.*.plist 2>/dev/null)
1267
+
1268
+ [[ "${#plists[@]}" -eq 0 ]] && return 0
1269
+
1270
+ echo ""
1271
+ echo -e "${BOLD}Loop Overview: 所有项目 loop 状态${NC}"
1272
+
1273
+ for plist in "${plists[@]}"; do
1274
+ local label; label=$(basename "$plist" .plist)
1275
+
1276
+ local proj_path
1277
+ proj_path=$(awk '/<key>WorkingDirectory<\/key>/{f=1;next} f{gsub(/^[[:space:]]*<string>|<\/string>[[:space:]]*$/,"");print;exit}' "$plist" 2>/dev/null)
1278
+
1279
+ local proj_name path_note=""
1280
+ if [[ -n "$proj_path" && -d "$proj_path" ]]; then
1281
+ proj_name=$(basename "$proj_path")
1282
+ elif [[ -n "$proj_path" ]]; then
1283
+ proj_name=$(basename "$proj_path")
1284
+ path_note=" (path missing)"
1285
+ else
1286
+ proj_name="(unknown)"
1287
+ fi
1288
+
1289
+ local state_icon
1290
+ if _launchd_is_loaded "$label"; then
1291
+ state_icon="${GREEN}●${NC}"
1292
+ else
1293
+ state_icon="${RED}○${NC}"
1294
+ fi
1295
+
1296
+ local minute hour schedule
1297
+ minute=$(awk '/<key>Minute<\/key>/{f=1;next} f{gsub(/^[[:space:]]*<integer>|<\/integer>[[:space:]]*$/,"");print;exit}' "$plist" 2>/dev/null)
1298
+ hour=$(awk '/<key>Hour<\/key>/{f=1;next} f{gsub(/^[[:space:]]*<integer>|<\/integer>[[:space:]]*$/,"");print;exit}' "$plist" 2>/dev/null)
1299
+ if [[ -n "$hour" && -n "$minute" ]]; then
1300
+ schedule=$(printf "%02d:%02d" "$hour" "$minute")
1301
+ elif [[ -n "$minute" ]]; then
1302
+ schedule=":$(printf '%02d' "$minute")"
1303
+ else
1304
+ schedule="?"
1305
+ fi
1306
+
1307
+ local todo_count=0
1308
+ if [[ -z "$path_note" && -f "${proj_path}/BACKLOG.md" ]]; then
1309
+ todo_count=$(grep -c '📋 Todo' "${proj_path}/BACKLOG.md" 2>/dev/null; true)
1310
+ fi
1311
+
1312
+ echo -e " ${state_icon} ${proj_name}${path_note} ${schedule} ${todo_count} pending"
1313
+ done
1257
1314
  }
1258
1315
 
1259
1316
  check_sync_status() {
@@ -1726,7 +1783,7 @@ _agent_run_skill() {
1726
1783
  [[ -f "$skill_file" ]] || { err "Skill not found: ${skill}"; return 1; }
1727
1784
  local content; content=$(_skill_content "$skill_file")
1728
1785
  case "$agent" in
1729
- claude) claude -p "$content" ;;
1786
+ claude) claude -p --output-format stream-json "$content" ;;
1730
1787
  kimi) kimi --quiet -p "$content" ;;
1731
1788
  deepseek) deepseek "$content" ;;
1732
1789
  pi) pi -p "$content" ;;
@@ -1810,6 +1867,13 @@ _config_read_int() {
1810
1867
  if [[ "$val" =~ ^[0-9]+$ ]]; then echo "$val"; else echo "$default"; fi
1811
1868
  }
1812
1869
 
1870
+ _config_read_string() {
1871
+ local key="$1" default="$2"
1872
+ local val
1873
+ val=$(grep "^${key}:" "$ROLL_CONFIG" 2>/dev/null | awk '{print $2}' | tr -d '"' | head -1)
1874
+ if [[ -n "$val" ]]; then echo "$val"; else echo "$default"; fi
1875
+ }
1876
+
1813
1877
  # Derive a minute in [1,55] from project path hash + offset so different projects
1814
1878
  # and different services within a project don't fire at the same time.
1815
1879
  # Offsets used: loop=0, dream=2, brief=4 → always three distinct values (2<55).
@@ -1887,25 +1951,37 @@ _write_runner_script() {
1887
1951
  # Falls back to headless execution when tmux is not installed.
1888
1952
  _write_loop_runner_script() {
1889
1953
  local script_path="$1" project_path="$2" cmd="$3" log_path="$4"
1890
- local active_start="${5:-10}" active_end="${6:-18}"
1954
+ local active_start="${5:-10}" active_end="${6:-18}" terminal_pref="${7:-Terminal}"
1891
1955
  mkdir -p "$(dirname "$script_path")"
1892
1956
 
1893
1957
  local inner_path="${script_path%.sh}-inner.sh"
1894
- # Inject --verbose into `claude -p` so the tmux-attach view shows live
1895
- # tool-use and reasoning events (loop is launchd-triggered, the user is
1896
- # not driving it — visibility is required, not optional).
1897
- local cmd_verbose="${cmd/claude -p/claude --verbose -p}"
1958
+ # Use stream-json + formatter: --verbose alone does nothing in -p mode;
1959
+ # stream-json enables realtime streaming; loop-fmt.py humanizes the events.
1960
+ local fmt_script="${ROLL_PKG_DIR}/lib/loop-fmt.py"
1961
+ local cmd_verbose="${cmd/claude -p/claude -p --verbose --output-format stream-json}"
1898
1962
  cat > "$inner_path" << INNER
1899
1963
  #!/bin/bash -l
1900
1964
  export PATH="/opt/homebrew/bin:\$PATH"
1901
- cd "${project_path}" && ${cmd_verbose}
1965
+ FMT="${fmt_script}"
1966
+ if [ -f "\$FMT" ]; then
1967
+ ( cd "${project_path}" && ${cmd_verbose} ) | python3 "\$FMT"
1968
+ else
1969
+ cd "${project_path}" && ${cmd_verbose}
1970
+ fi
1902
1971
  INNER
1903
1972
  chmod +x "$inner_path"
1904
1973
 
1974
+ local slug; slug=$(_project_slug "$project_path")
1905
1975
  cat > "$script_path" << SCRIPT
1906
1976
  #!/bin/bash -l
1907
- h=\$(printf '%d' "\$(date +%H)")
1908
- if [ "\$h" -lt ${active_start} ] || [ "\$h" -ge ${active_end} ]; then exit 0; fi
1977
+ # Active-window check — skipped when ROLL_LOOP_FORCE is set (manual 'roll loop now')
1978
+ if [ -z "\$ROLL_LOOP_FORCE" ]; then
1979
+ h=\$(printf '%d' "\$(date +%H)")
1980
+ if [ "\$h" -lt ${active_start} ] || [ "\$h" -ge ${active_end} ]; then exit 0; fi
1981
+ fi
1982
+ # Pause check — 'roll loop pause' creates this marker to suspend scheduling
1983
+ PAUSE="\$HOME/.shared/roll/loop/PAUSE-${slug}"
1984
+ if [ -z "\$ROLL_LOOP_FORCE" ] && [ -f "\$PAUSE" ]; then exit 0; fi
1909
1985
  LOCK="\$(dirname "\$0")/.LOCK-\$(basename "\$0" .sh | sed 's/^run-//')"
1910
1986
  SESSION="roll-loop-\$(basename "\$0" .sh | sed 's/^run-//')"
1911
1987
  INNER_SCRIPT="${inner_path}"
@@ -1918,6 +1994,13 @@ if [ -f "\$LOCK" ]; then
1918
1994
  fi
1919
1995
  rm -f "\$LOCK"
1920
1996
  fi
1997
+ # Guard against stale-LOCK case: if the tmux session is already alive,
1998
+ # a previous runner's LOCK was removed (e.g. parent terminal closed) but
1999
+ # the work is still in progress — don't kill it.
2000
+ if command -v tmux >/dev/null 2>&1 && tmux has-session -t "\$SESSION" 2>/dev/null; then
2001
+ echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] tmux session \$SESSION still active, skipping" >> "\$LOG"
2002
+ exit 0
2003
+ fi
1921
2004
  echo "\$\$" > "\$LOCK"
1922
2005
  trap 'rm -f "\$LOCK"' EXIT
1923
2006
  if command -v tmux >/dev/null 2>&1; then
@@ -1927,14 +2010,30 @@ if command -v tmux >/dev/null 2>&1; then
1927
2010
  # Auto-attach popup: when not muted, spawn a Terminal window attached to the
1928
2011
  # tmux session so the user can watch the loop work in real time. Best-effort
1929
2012
  # focus retention: capture the current frontmost app and re-activate after.
1930
- if [ ! -f "\$HOME/.shared/roll/mute" ] && [ "\$(uname)" = "Darwin" ] && command -v osascript >/dev/null 2>&1; then
1931
- (osascript \\
1932
- -e 'tell application "System Events" to set _prev to name of first application process whose frontmost is true' \\
1933
- -e "tell application \"Terminal\" to do script \"tmux attach -t \$SESSION\"" \\
1934
- -e 'delay 0.3' \\
1935
- -e 'try' \\
1936
- -e 'tell application _prev to activate' \\
1937
- -e 'end try' >/dev/null 2>&1) &
2013
+ if [ ! -f "\$HOME/.shared/roll/mute" ] && [ "\$(uname)" = "Darwin" ]; then
2014
+ # Runtime terminal detection: try preferred first, fallback through installed apps.
2015
+ # open -na returns non-zero when app not found, so || chain works as fallback.
2016
+ _PREF="${terminal_pref}"
2017
+ _launched=0
2018
+ if [ "\$_PREF" = "ghostty" ] || [ "\$_PREF" = "Ghostty" ]; then
2019
+ open -na Ghostty.app --args -e tmux attach -t \$SESSION >/dev/null 2>&1 && _launched=1 || true
2020
+ fi
2021
+ if [ "\$_launched" -eq 0 ] && { [ "\$_PREF" = "iTerm2" ] || [ "\$_PREF" = "iTerm" ] || [ -d "/Applications/iTerm.app" ]; }; then
2022
+ osascript \\
2023
+ -e 'tell application "System Events" to set _prev to name of first application process whose frontmost is true' \\
2024
+ -e "tell application \"iTerm2\" to create window with default profile command \"tmux attach -t \$SESSION\"" \\
2025
+ -e 'delay 0.3' -e 'try' -e 'tell application _prev to activate' -e 'end try' >/dev/null 2>&1 \\
2026
+ && _launched=1 || true
2027
+ fi
2028
+ if [ "\$_launched" -eq 0 ] && [ -d "/Applications/Ghostty.app" ]; then
2029
+ open -na Ghostty.app --args -e tmux attach -t \$SESSION >/dev/null 2>&1 && _launched=1 || true
2030
+ fi
2031
+ if [ "\$_launched" -eq 0 ] && command -v osascript >/dev/null 2>&1; then
2032
+ osascript \\
2033
+ -e 'tell application "System Events" to set _prev to name of first application process whose frontmost is true' \\
2034
+ -e "tell application \"Terminal\" to do script \"tmux attach -t \$SESSION\"" \\
2035
+ -e 'delay 0.3' -e 'try' -e 'tell application _prev to activate' -e 'end try' >/dev/null 2>&1 || true
2036
+ fi
1938
2037
  fi
1939
2038
  while tmux has-session -t "\$SESSION" 2>/dev/null; do sleep 5; done
1940
2039
  else
@@ -1982,6 +2081,17 @@ _install_launchd_plists() {
1982
2081
  brief_hour=$(_config_read_int "loop_brief_hour" "9")
1983
2082
  brief_minute=$(_config_read_int "loop_brief_minute" "$(_loop_derive_minute "$project_path" 4)")
1984
2083
 
2084
+ # Terminal preference: config wins, then TERM_PROGRAM env, then "Terminal"
2085
+ local terminal_pref
2086
+ terminal_pref=$(_config_read_string "loop_attach_terminal" "")
2087
+ if [[ -z "$terminal_pref" ]]; then
2088
+ case "${TERM_PROGRAM:-}" in
2089
+ ghostty) terminal_pref="ghostty" ;;
2090
+ iTerm.app) terminal_pref="iTerm2" ;;
2091
+ *) terminal_pref="Terminal" ;;
2092
+ esac
2093
+ fi
2094
+
1985
2095
  local services=("loop" "dream" "brief")
1986
2096
  local skill_names=("roll-loop" "roll-.dream" "roll-brief")
1987
2097
  local minutes=("$loop_minute" "$dream_minute" "$brief_minute")
@@ -2001,7 +2111,7 @@ _install_launchd_plists() {
2001
2111
  local cmd; cmd=$(_agent_skill_cmd "${sd}/${skill}/SKILL.md" 2>/dev/null || echo "roll loop now")
2002
2112
 
2003
2113
  if [[ "$svc" == "loop" ]]; then
2004
- _write_loop_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log" "$active_start" "$active_end"
2114
+ _write_loop_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log" "$active_start" "$active_end" "$terminal_pref"
2005
2115
  else
2006
2116
  _write_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log"
2007
2117
  fi
@@ -2049,15 +2159,17 @@ cmd_loop() {
2049
2159
  on) _loop_on ;;
2050
2160
  off) _loop_off ;;
2051
2161
  now) _loop_now ;;
2162
+ test) _loop_test ;;
2052
2163
  status) _loop_status ;;
2053
2164
  monitor) _loop_monitor "${1:-3}" ;;
2054
2165
  runs) _loop_runs "$@" ;;
2055
2166
  attach) _loop_attach ;;
2056
2167
  mute) _loop_mute ;;
2057
2168
  unmute) _loop_unmute ;;
2169
+ pause) _loop_pause ;;
2058
2170
  resume) _loop_resume ;;
2059
2171
  reset) _loop_reset ;;
2060
- *) err "Usage: roll loop <on|off|now|status|monitor|runs|attach|mute|unmute|resume|reset>"; exit 1 ;;
2172
+ *) err "Usage: roll loop <on|off|now|test|status|monitor|runs|attach|mute|unmute|pause|resume|reset>"; exit 1 ;;
2061
2173
  esac
2062
2174
  }
2063
2175
 
@@ -2162,34 +2274,108 @@ _loop_now() {
2162
2274
  if [[ -f "$_LOOP_STATE" ]] && grep -q "status: running" "$_LOOP_STATE" 2>/dev/null; then
2163
2275
  warn "Loop already running loop 正在运行中"; return 0
2164
2276
  fi
2165
- info "Triggering loop cycle... 正在触发一个周期..."
2166
- local log_file="${_SHARED_ROOT}/loop/launchd.log"
2167
- mkdir -p "$(dirname "$log_file")"
2168
- _agent_run_skill "roll-loop" 2>&1 | tee -a "$log_file" || true
2169
- # Reset stale running state if skill exited without cleanup (e.g. API error, SIGKILL)
2277
+ # Invoke the SAME runner script that launchd would invoke — same tmux,
2278
+ # same --verbose, same LOCK, same auto-attach popup. ROLL_LOOP_FORCE
2279
+ # bypasses only the active-window check (manual triggers aren't time-gated).
2280
+ local project_path; project_path=$(pwd -P)
2281
+ local slug; slug=$(_project_slug "$project_path")
2282
+ local runner="${_SHARED_ROOT}/loop/run-${slug}.sh"
2283
+ if [[ ! -f "$runner" ]]; then
2284
+ err "Runner script not found: ${runner}"
2285
+ err "Run 'roll setup' or 'roll loop on' first to generate it."
2286
+ return 1
2287
+ fi
2288
+ info "Starting new loop cycle... 正在启动新的循环..."
2289
+ ROLL_LOOP_FORCE=1 bash "$runner"
2290
+ # Reset stale running state if the cycle exited without cleanup (e.g. API error, SIGKILL)
2170
2291
  if [[ -f "$_LOOP_STATE" ]] && grep -q "^status: running" "$_LOOP_STATE" 2>/dev/null; then
2171
2292
  printf "status: idle\n" > "$_LOOP_STATE"
2172
2293
  fi
2173
2294
  }
2174
2295
 
2296
+ _loop_test() {
2297
+ local project_path; project_path=$(pwd -P)
2298
+ local slug; slug=$(_project_slug "$project_path")
2299
+ local runner="${_SHARED_ROOT}/loop/run-${slug}.sh"
2300
+ if [[ ! -f "$runner" ]]; then
2301
+ err "Runner not found: ${runner}"
2302
+ err "Run 'roll loop on' first to generate it."
2303
+ return 1
2304
+ fi
2305
+ local log="${_SHARED_ROOT}/loop/cron.log"
2306
+ local test_runner="${_SHARED_ROOT}/loop/run-${slug}-test.sh"
2307
+
2308
+ # Detect terminal pref same way _install_launchd_plists does
2309
+ local terminal_pref
2310
+ terminal_pref=$(_config_read_string "loop_attach_terminal" "")
2311
+ if [[ -z "$terminal_pref" ]]; then
2312
+ case "${TERM_PROGRAM:-}" in
2313
+ ghostty) terminal_pref="ghostty" ;;
2314
+ iTerm.app) terminal_pref="iTerm2" ;;
2315
+ *) terminal_pref="Terminal" ;;
2316
+ esac
2317
+ fi
2318
+
2319
+ local active_start active_end
2320
+ active_start=$(_config_read_int "loop_active_start" "10")
2321
+ active_end=$(_config_read_int "loop_active_end" "18")
2322
+
2323
+ info "Generating test runner... 正在生成测试启动脚本..."
2324
+ _write_loop_runner_script "$test_runner" "$project_path" \
2325
+ 'claude -p "Reply with a single word: hello"; sleep 10' \
2326
+ "$log" "$active_start" "$active_end" "$terminal_pref"
2327
+
2328
+ info "Starting smoke test (real claude, trivial prompt)... 正在运行 smoke 测试(真实 claude)..."
2329
+ info "Watch for: tmux session + terminal popup + stream-json events flowing"
2330
+ info "观察:tmux 会话 + 终端弹窗 + stream-json 事件流"
2331
+
2332
+ local start_time; start_time=$(date +%s)
2333
+ ROLL_LOOP_FORCE=1 bash "$test_runner"
2334
+ local exit_code=$?
2335
+ local elapsed=$(( $(date +%s) - start_time ))
2336
+
2337
+ if [[ $exit_code -eq 0 ]]; then
2338
+ ok "Smoke test passed (${elapsed}s) smoke 测试通过 (${elapsed}秒)"
2339
+ else
2340
+ err "Smoke test failed (exit ${exit_code}, ${elapsed}s) smoke 测试失败 (退出码 ${exit_code}, ${elapsed}秒)"
2341
+ return 1
2342
+ fi
2343
+ }
2344
+
2175
2345
  _loop_status() {
2176
2346
  local project_path; project_path=$(pwd -P)
2177
2347
  local agent; agent=$(_project_agent)
2348
+ local _is_paused=false
2349
+ [[ -f "$_LOOP_STATE" ]] && grep -q "^status: paused" "$_LOOP_STATE" 2>/dev/null && _is_paused=true
2178
2350
  echo ""
2179
2351
  if [[ "$(uname)" == "Darwin" ]]; then
2180
2352
  echo -e " Services Agent: ${CYAN}${agent}${NC}"
2181
2353
  for svc in loop dream brief; do
2182
2354
  local state; state=$(_launchd_svc_state "$svc" "$project_path")
2183
- case "$state" in
2184
- enabled) echo -e " ${GREEN}${svc} enabled${NC}" ;;
2185
- installed-off) echo -e " ${YELLOW}${svc} ⚠ installed/off${NC} run: roll loop on" ;;
2186
- not-installed) echo -e " ${RED}${svc} ○ not installed${NC} run: roll setup" ;;
2187
- esac
2355
+ if [[ "$svc" == "loop" ]] && $_is_paused; then
2356
+ local _paused_at; _paused_at=$(grep '^paused_at:' "$_LOOP_STATE" 2>/dev/null | awk '{print $2}' | tr -d '"')
2357
+ local _dur=""
2358
+ if [[ -n "$_paused_at" ]]; then
2359
+ local _pe _ne _sec
2360
+ _pe=$(date -d "$_paused_at" +%s 2>/dev/null || date -jf "%Y-%m-%dT%H:%M:%SZ" "$_paused_at" +%s 2>/dev/null || echo 0)
2361
+ _ne=$(date +%s); _sec=$(( _ne - _pe ))
2362
+ _dur=" ($(( _sec / 3600 ))h $(( (_sec % 3600) / 60 ))m ago)"
2363
+ fi
2364
+ echo -e " ${YELLOW}loop ⏸ paused${NC}${_dur} run: roll loop resume"
2365
+ else
2366
+ case "$state" in
2367
+ enabled) echo -e " ${GREEN}${svc} ● enabled${NC}" ;;
2368
+ installed-off) echo -e " ${YELLOW}${svc} ⚠ installed/off${NC} run: roll loop on" ;;
2369
+ not-installed) echo -e " ${RED}${svc} ○ not installed${NC} run: roll setup" ;;
2370
+ esac
2371
+ fi
2188
2372
  done
2189
2373
  else
2190
2374
  local loop_enabled=false
2191
2375
  crontab -l 2>/dev/null | grep -q "${_LOOP_TAG}:${project_path}" && loop_enabled=true
2192
- if $loop_enabled; then
2376
+ if $_is_paused; then
2377
+ echo -e " Scheduler ${YELLOW}⏸ paused${NC} run: roll loop resume"
2378
+ elif $loop_enabled; then
2193
2379
  echo -e " Scheduler ${GREEN}● enabled${NC} Agent: ${CYAN}${agent}${NC}"
2194
2380
  else
2195
2381
  echo -e " Scheduler ${YELLOW}○ disabled${NC} run: roll loop on"
@@ -2206,7 +2392,47 @@ _loop_status() {
2206
2392
  echo ""
2207
2393
  }
2208
2394
 
2395
+ _loop_pause() {
2396
+ local project_path; project_path=$(pwd -P)
2397
+ local paused_at; paused_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
2398
+
2399
+ if [[ "$(uname)" == "Darwin" ]]; then
2400
+ local label; label=$(_launchd_label "loop" "$project_path")
2401
+ if ! _launchd_is_loaded "$label"; then
2402
+ warn "Loop not enabled — nothing to pause loop 未启用,无需暂停"; return 0
2403
+ fi
2404
+ launchctl unload -w "$(_launchd_plist_path "loop" "$project_path")" 2>/dev/null || true
2405
+ else
2406
+ local slug; slug=$(_project_slug "$project_path")
2407
+ touch "${_SHARED_ROOT}/loop/PAUSE-${slug}"
2408
+ fi
2409
+
2410
+ mkdir -p "$(dirname "$_LOOP_STATE")"
2411
+ printf 'status: paused\npaused_at: "%s"\npaused_reason: manual\n' "$paused_at" > "$_LOOP_STATE"
2412
+ ok "Loop paused 已暂停 run: roll loop resume"
2413
+ }
2414
+
2209
2415
  _loop_resume() {
2416
+ local project_path; project_path=$(pwd -P)
2417
+
2418
+ # Scheduler resume: loop was manually paused via `roll loop pause`
2419
+ if [[ -f "$_LOOP_STATE" ]] && grep -q "^status: paused" "$_LOOP_STATE" 2>/dev/null; then
2420
+ if [[ "$(uname)" == "Darwin" ]]; then
2421
+ local label; label=$(_launchd_label "loop" "$project_path")
2422
+ local plist; plist=$(_launchd_plist_path "loop" "$project_path")
2423
+ if [[ -f "$plist" ]]; then
2424
+ launchctl load -w "$plist" 2>/dev/null || true
2425
+ fi
2426
+ else
2427
+ local slug; slug=$(_project_slug "$project_path")
2428
+ rm -f "${_SHARED_ROOT}/loop/PAUSE-${slug}"
2429
+ fi
2430
+ printf "status: idle\n" > "$_LOOP_STATE"
2431
+ ok "Loop resumed 已恢复"
2432
+ return 0
2433
+ fi
2434
+
2435
+ # Interrupt resume: loop was running a Story and crashed
2210
2436
  if [[ ! -f "$_LOOP_STATE" ]]; then
2211
2437
  warn "No loop state found — nothing to resume 未找到 loop 状态,无需恢复"; return 0
2212
2438
  fi
@@ -2580,9 +2806,45 @@ cmd_release() {
2580
2806
  }
2581
2807
 
2582
2808
  # ═══════════════════════════════════════════════════════════════════════════════
2583
- # BACKLOG — show pending tasks
2809
+ # BACKLOG — show pending tasks / manage status
2584
2810
  # ═══════════════════════════════════════════════════════════════════════════════
2585
2811
 
2812
+ # Update status of all BACKLOG rows whose ID field contains <pattern> (case-insensitive).
2813
+ # Uses Python for reliable emoji/Unicode handling.
2814
+ _backlog_set_status() {
2815
+ local pattern="$1"
2816
+ local new_status="$2"
2817
+ local backlog="BACKLOG.md"
2818
+ python3 -c "
2819
+ import sys, re
2820
+ pattern, new_status, filename = sys.argv[1], sys.argv[2], sys.argv[3]
2821
+ lines = open(filename, encoding='utf-8').readlines()
2822
+ count = 0
2823
+ out = []
2824
+ for line in lines:
2825
+ if line.startswith('|') and line.count('|') >= 4:
2826
+ parts = line.split('|')
2827
+ if len(parts) >= 5:
2828
+ id_field = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', parts[1]).strip()
2829
+ if pattern.upper() in id_field.upper():
2830
+ parts[-2] = ' ' + new_status + ' '
2831
+ line = '|'.join(parts)
2832
+ count += 1
2833
+ out.append(line)
2834
+ open(filename, 'w', encoding='utf-8').writelines(out)
2835
+ print(count)
2836
+ " "$pattern" "$new_status" "$backlog"
2837
+ }
2838
+
2839
+ _backlog_extract_id() {
2840
+ local line="$1"
2841
+ if echo "$line" | grep -q '\[US-'; then
2842
+ echo "$line" | sed 's/.*\[\(US-[^]]*\)\].*/\1/'
2843
+ else
2844
+ echo "$line" | awk -F'|' '{print $2}' | tr -d ' '
2845
+ fi
2846
+ }
2847
+
2586
2848
  cmd_backlog() {
2587
2849
  local backlog="BACKLOG.md"
2588
2850
  if [[ ! -f "$backlog" ]]; then
@@ -2590,8 +2852,38 @@ cmd_backlog() {
2590
2852
  return 1
2591
2853
  fi
2592
2854
 
2593
- local us_items fix_items refactor_items idea_items total=0
2855
+ local subcmd="${1:-}"
2594
2856
 
2857
+ # ── Status management subcommands ─────────────────────────────────────────
2858
+ case "$subcmd" in
2859
+ block|defer|unblock|promote)
2860
+ local pattern="${2:-}"
2861
+ local reason="${3:-}"
2862
+ if [[ -z "$pattern" ]]; then
2863
+ err "Usage: roll backlog $subcmd <pattern> [reason] 用法: roll backlog $subcmd <匹配模式> [原因]"
2864
+ return 1
2865
+ fi
2866
+ local new_status
2867
+ case "$subcmd" in
2868
+ block) new_status="🔒 Blocked${reason:+ [${reason}]}" ;;
2869
+ defer) new_status="⏸ Deferred${reason:+ [${reason}]}" ;;
2870
+ unblock|promote) new_status="📋 Todo" ;;
2871
+ esac
2872
+ local count
2873
+ count=$(_backlog_set_status "$pattern" "$new_status")
2874
+ if [[ "$count" -eq 0 ]]; then
2875
+ echo " No items matched: $pattern 未找到匹配项: $pattern"
2876
+ else
2877
+ echo " Updated ${count} item(s) → ${new_status} 已更新 ${count} 条目"
2878
+ fi
2879
+ return
2880
+ ;;
2881
+ esac
2882
+
2883
+ # ── Display mode ──────────────────────────────────────────────────────────
2884
+ local DIM='\033[2m'
2885
+
2886
+ local us_items fix_items refactor_items idea_items total=0
2595
2887
  us_items=$(grep -E '^\| \[US-' "$backlog" | grep -F '| 📋 Todo |' || true)
2596
2888
  fix_items=$(grep -E '^\| FIX-' "$backlog" | grep -F '| 📋 Todo |' || true)
2597
2889
  refactor_items=$(grep -E '^\| REFACTOR-' "$backlog" | grep -F '| 📋 Todo |' || true)
@@ -2608,6 +2900,20 @@ cmd_backlog() {
2608
2900
  [[ -z "$idea_items" ]] && idea_count=0
2609
2901
  total=$(( us_count + fix_count + refactor_count + idea_count ))
2610
2902
 
2903
+ local blocked_items deferred_items unknown_items
2904
+ blocked_items=$(grep -E '^\|' "$backlog" | grep '🔒 Blocked' || true)
2905
+ deferred_items=$(grep -E '^\|' "$backlog" | grep '⏸ Deferred' || true)
2906
+ unknown_items=$( { grep -E '^\| \[US-' "$backlog"; grep -E '^\| FIX-' "$backlog"; grep -E '^\| REFACTOR-' "$backlog"; grep -E '^\| IDEA-' "$backlog"; } \
2907
+ | grep -v '📋 Todo\|🔨 In Progress\|✅ Done\|🔒 Blocked\|⏸ Deferred' || true)
2908
+
2909
+ local blocked_count deferred_count unknown_count
2910
+ blocked_count=$(echo "$blocked_items" | grep -c . || true)
2911
+ deferred_count=$(echo "$deferred_items" | grep -c . || true)
2912
+ unknown_count=$(echo "$unknown_items" | grep -c . || true)
2913
+ [[ -z "$blocked_items" ]] && blocked_count=0
2914
+ [[ -z "$deferred_items" ]] && deferred_count=0
2915
+ [[ -z "$unknown_items" ]] && unknown_count=0
2916
+
2611
2917
  echo ""
2612
2918
  echo -e " ${BOLD}Pending Backlog 待处理任务${NC} (${total} items)"
2613
2919
  echo ""
@@ -2660,6 +2966,53 @@ cmd_backlog() {
2660
2966
  echo -e " ${GREEN}✓ Nothing pending — backlog is clear 暂无待处理任务${NC}"
2661
2967
  echo ""
2662
2968
  fi
2969
+
2970
+ # ── Blocked ───────────────────────────────────────────────────────────────
2971
+ if [[ $blocked_count -gt 0 ]]; then
2972
+ echo -e " ${DIM}Blocked 已阻塞 (${blocked_count})${NC}"
2973
+ while IFS= read -r line; do
2974
+ [[ -z "$line" ]] && continue
2975
+ local id desc reason
2976
+ id=$(_backlog_extract_id "$line")
2977
+ desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//' | cut -c1-52)
2978
+ reason=$(echo "$line" | awk -F'|' '{print $4}' | grep -oE '\[.*\]' | tr -d '[]' || true)
2979
+ printf " ${DIM}🔒 %-14s %s${NC}" "$id" "$desc"
2980
+ [[ -n "$reason" ]] && printf "${DIM} (%s)${NC}" "$reason"
2981
+ printf "\n"
2982
+ done <<< "$blocked_items"
2983
+ echo ""
2984
+ fi
2985
+
2986
+ # ── Deferred ──────────────────────────────────────────────────────────────
2987
+ if [[ $deferred_count -gt 0 ]]; then
2988
+ echo -e " ${DIM}Deferred 已推迟 (${deferred_count})${NC}"
2989
+ while IFS= read -r line; do
2990
+ [[ -z "$line" ]] && continue
2991
+ local id desc reason
2992
+ id=$(_backlog_extract_id "$line")
2993
+ desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//' | cut -c1-52)
2994
+ reason=$(echo "$line" | awk -F'|' '{print $4}' | grep -oE '\[.*\]' | tr -d '[]' || true)
2995
+ printf " ${DIM}⏸ %-14s %s${NC}" "$id" "$desc"
2996
+ [[ -n "$reason" ]] && printf "${DIM} (%s)${NC}" "$reason"
2997
+ printf "\n"
2998
+ done <<< "$deferred_items"
2999
+ echo ""
3000
+ fi
3001
+
3002
+ # ── Unknown status (show for human/AI triage) ─────────────────────────────
3003
+ if [[ $unknown_count -gt 0 ]]; then
3004
+ echo -e " ${YELLOW}? Unknown Status 未知状态 (${unknown_count})${NC}"
3005
+ echo -e " ${YELLOW} Fix: roll backlog block/defer/unblock <pattern> 运行命令修正状态${NC}"
3006
+ while IFS= read -r line; do
3007
+ [[ -z "$line" ]] && continue
3008
+ local id desc status_raw
3009
+ id=$(_backlog_extract_id "$line")
3010
+ desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//' | cut -c1-52)
3011
+ status_raw=$(echo "$line" | awk -F'|' '{print $4}' | sed 's/^ *//;s/ *$//')
3012
+ printf " ${YELLOW}? %-14s %s [%s]${NC}\n" "$id" "$desc" "$status_raw"
3013
+ done <<< "$unknown_items"
3014
+ echo ""
3015
+ fi
2663
3016
  }
2664
3017
 
2665
3018
  # ─────────────────────────────────────────────────────────────────────────────
@@ -2671,13 +3024,18 @@ _dashboard() {
2671
3024
 
2672
3025
  echo -e "\n ${BOLD}${CYAN}${project_name}${NC} ${YELLOW}v${VERSION}${NC}\n"
2673
3026
 
3027
+ local _dash_loop_paused=false
3028
+ [[ -f "$_LOOP_STATE" ]] && grep -q "^status: paused" "$_LOOP_STATE" 2>/dev/null && _dash_loop_paused=true
3029
+
2674
3030
  local _dash_loop_enabled=false
2675
3031
  if [[ "$(uname)" == "Darwin" ]]; then
2676
3032
  _launchd_is_loaded "$(_launchd_label "loop" "$project_path")" && _dash_loop_enabled=true
2677
3033
  else
2678
3034
  crontab -l 2>/dev/null | grep -q "${_LOOP_TAG}:${project_path}" && _dash_loop_enabled=true
2679
3035
  fi
2680
- if $_dash_loop_enabled; then
3036
+ if $_dash_loop_paused; then
3037
+ echo -e " Loop ${YELLOW}⏸ paused${NC} run: roll loop resume"
3038
+ elif $_dash_loop_enabled; then
2681
3039
  echo -e " Loop ${GREEN}● on${NC} Agent: ${CYAN}${agent}${NC}"
2682
3040
  if [[ "$(uname)" == "Darwin" ]]; then
2683
3041
  local active_start active_end loop_minute dream_hour dream_minute brief_hour brief_minute
@@ -2761,7 +3119,10 @@ usage() {
2761
3119
  echo " peer [Peer Review] Cross-agent negotiation 跨 Agent 协商对审"
2762
3120
  echo " loop <on|off|now|status|monitor|resume|reset> [Autonomous] Manage scheduled BACKLOG executor 管理自主执行循环"
2763
3121
  echo " brief [Digest] Show latest owner brief (regenerate if stale) 展示最新简报"
2764
- echo " backlog [View] Show all pending tasks from BACKLOG.md 显示待处理任务清单"
3122
+ echo " backlog [View] Show pending tasks (Todo/Blocked/Deferred/Unknown) 显示任务清单"
3123
+ echo " backlog block <pat> [reason] Mark matching items as 🔒 Blocked 标记为已阻塞"
3124
+ echo " backlog defer <pat> [reason] Mark matching items as ⏸ Deferred 标记为已推迟"
3125
+ echo " backlog unblock <pat> Restore matching items to 📋 Todo 恢复为待处理"
2765
3126
  echo " agent [use <name>|list] [Config] Per-project agent selection 切换项目 agent"
2766
3127
  echo " release [Publish] Sync changelog + version bump + npm publish 同步日志并发版"
2767
3128
  echo ""
@@ -2771,7 +3132,9 @@ usage() {
2771
3132
  echo " roll init # New or re-merge project (run in project) 新建或重新合并(项目目录)"
2772
3133
  echo " roll loop on # Enable autonomous loop (cron) 启用自主执行循环"
2773
3134
  echo " roll brief # Show latest brief 查看最新简报"
2774
- echo " roll backlog # Show pending BACKLOG items 查看待处理任务"
3135
+ echo " roll backlog # Show pending/blocked/deferred items 查看待处理任务"
3136
+ echo " roll backlog defer US-DOC '过早引入' # Defer all US-DOC-* items 推迟一类任务"
3137
+ echo " roll backlog block US-HW-001 '硬件未到货' # Block a specific item 标记阻塞"
2775
3138
  echo " roll agent use kimi # Switch this project to kimi 切换当前项目到 kimi"
2776
3139
 
2777
3140
  }
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ loop-fmt.py — stream-json → human-readable formatter for roll loop tmux output.
4
+
5
+ Reads stream-json lines from stdin, emits colored, human-readable events.
6
+ Skips noise (system/init, hook_started, rate_limit_event) and abbreviates
7
+ tool results so the window stays readable.
8
+
9
+ Color codes: no external deps, plain ANSI.
10
+ """
11
+
12
+ import sys
13
+ import json
14
+ import re
15
+ import textwrap
16
+
17
+ # ANSI colors
18
+ CYAN = "\033[36m"
19
+ GREEN = "\033[32m"
20
+ YELLOW = "\033[33m"
21
+ RED = "\033[31m"
22
+ GRAY = "\033[90m"
23
+ BOLD = "\033[1m"
24
+ RESET = "\033[0m"
25
+ DIM = "\033[2m"
26
+
27
+ SKIP_SUBTYPES = {"hook_started", "hook_response", "hook_stop_hook_execution",
28
+ "hook_stop_hook_active_hooks_ran"}
29
+
30
+ def trunc(s, n=120):
31
+ s = str(s).replace("\n", " ").strip()
32
+ return s[:n] + "…" if len(s) > n else s
33
+
34
+ def fmt_tool_input(name, inp):
35
+ if not isinstance(inp, dict):
36
+ return trunc(str(inp), 80)
37
+ if name == "Bash":
38
+ cmd = inp.get("command", "")
39
+ # show first non-empty line
40
+ lines = [l.strip() for l in cmd.splitlines() if l.strip()]
41
+ return trunc(lines[0] if lines else cmd, 100)
42
+ if name in ("Read", "Write", "Edit"):
43
+ path = inp.get("file_path", inp.get("path", ""))
44
+ extra = ""
45
+ if name == "Edit":
46
+ old = inp.get("old_string", "")
47
+ extra = f" ({trunc(old, 40)})"
48
+ return f"{path}{extra}"
49
+ if name in ("Glob", "Grep"):
50
+ return trunc(inp.get("pattern", inp.get("query", str(inp))), 80)
51
+ if name == "Skill":
52
+ return inp.get("skill", "") + (" " + inp.get("args", "") if inp.get("args") else "")
53
+ if name == "Agent":
54
+ return trunc(inp.get("description", str(inp)), 80)
55
+ return trunc(json.dumps(inp, ensure_ascii=False), 80)
56
+
57
+ def fmt_tool_result(content):
58
+ if isinstance(content, list):
59
+ parts = []
60
+ for c in content:
61
+ if isinstance(c, dict):
62
+ t = c.get("type", "")
63
+ if t == "text":
64
+ parts.append(c.get("text", ""))
65
+ elif t == "image":
66
+ parts.append("[image]")
67
+ else:
68
+ parts.append(str(c))
69
+ text = " ".join(parts)
70
+ else:
71
+ text = str(content) if content is not None else ""
72
+ # strip ansi for length check
73
+ clean = re.sub(r'\033\[[0-9;]*m', '', text)
74
+ lines = [l for l in clean.splitlines() if l.strip()]
75
+ if not lines:
76
+ return "(empty)"
77
+ # show first 3 lines, trim long lines
78
+ out = []
79
+ for l in lines[:3]:
80
+ out.append(" " + trunc(l, 100))
81
+ if len(lines) > 3:
82
+ out.append(f" {DIM}… ({len(lines)-3} more lines){RESET}")
83
+ return "\n".join(out)
84
+
85
+ def process_line(line):
86
+ line = line.rstrip()
87
+ if not line:
88
+ return
89
+ try:
90
+ ev = json.loads(line)
91
+ except json.JSONDecodeError:
92
+ # plain text passthrough
93
+ print(line)
94
+ return
95
+
96
+ etype = ev.get("type", "")
97
+
98
+ # ── system events ──────────────────────────────────────────────
99
+ if etype == "system":
100
+ subtype = ev.get("subtype", "")
101
+ if subtype in SKIP_SUBTYPES:
102
+ return
103
+ if subtype == "init":
104
+ model = ev.get("model", "")
105
+ tools = ev.get("tools", [])
106
+ tool_list = ", ".join(tools[:6])
107
+ if len(tools) > 6:
108
+ tool_list += f" +{len(tools)-6}"
109
+ print(f"{DIM}[init] model={model} tools={tool_list}{RESET}")
110
+ return
111
+ # unknown system — show raw briefly
112
+ print(f"{DIM}[sys/{subtype}]{RESET}")
113
+ return
114
+
115
+ # ── rate limit ────────────────────────────────────────────────
116
+ if etype == "rate_limit_event":
117
+ return
118
+
119
+ # ── assistant ────────────────────────────────────────────────
120
+ if etype == "assistant":
121
+ msg = ev.get("message", {})
122
+ for blk in msg.get("content", []):
123
+ btype = blk.get("type", "")
124
+ if btype == "tool_use":
125
+ name = blk.get("name", "?")
126
+ inp = blk.get("input", {})
127
+ summary = fmt_tool_input(name, inp)
128
+ print(f"{CYAN}→ {BOLD}{name}{RESET}{CYAN}: {summary}{RESET}")
129
+ elif btype == "text":
130
+ text = blk.get("text", "").strip()
131
+ if text:
132
+ # wrap long text
133
+ for l in textwrap.wrap(text, 120):
134
+ print(f"{GREEN}{l}{RESET}")
135
+ elif btype == "thinking":
136
+ thought = blk.get("thinking", "").strip()
137
+ if thought:
138
+ first = trunc(thought, 80)
139
+ print(f"{DIM}[thinking] {first}{RESET}")
140
+ return
141
+
142
+ # ── user (tool results) ───────────────────────────────────────
143
+ if etype == "user":
144
+ msg = ev.get("message", {})
145
+ for blk in msg.get("content", []):
146
+ if blk.get("type") == "tool_result":
147
+ is_err = blk.get("is_error", False)
148
+ content = blk.get("content", "")
149
+ result_text = fmt_tool_result(content)
150
+ prefix = f"{RED} ✗{RESET}" if is_err else f"{GRAY} ↩{RESET}"
151
+ print(f"{prefix} {result_text}")
152
+ return
153
+
154
+ # ── result (final) ───────────────────────────────────────────
155
+ if etype == "result":
156
+ dur_ms = ev.get("duration_ms", 0)
157
+ cost_usd = ev.get("total_cost_usd", 0)
158
+ turns = ev.get("num_turns", "?")
159
+ dur_s = dur_ms / 1000
160
+ cost_str = f"${cost_usd:.4f}" if cost_usd else ""
161
+ subtype = ev.get("subtype", "")
162
+ if subtype == "error_max_turns":
163
+ print(f"{RED}✗ max turns reached {dur_s:.1f}s{RESET}")
164
+ else:
165
+ cost_part = f" {YELLOW}{cost_str}{RESET}" if cost_str else ""
166
+ print(f"\n{GREEN}{BOLD}✓ done{RESET} {dur_s:.1f}s {GRAY}{turns} turns{RESET}{cost_part}")
167
+ return
168
+
169
+ # ── fallback ────────────────────────────────────────────────
170
+ print(f"{DIM}{trunc(line, 160)}{RESET}")
171
+
172
+
173
+ def main():
174
+ for line in sys.stdin:
175
+ process_line(line)
176
+ sys.stdout.flush()
177
+
178
+
179
+ if __name__ == "__main__":
180
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.511.8",
3
+ "version": "2026.512.2",
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"
@@ -25,6 +25,7 @@
25
25
  "files": [
26
26
  "bin/",
27
27
  "conventions/",
28
+ "lib/",
28
29
  "skills/",
29
30
  "tools/",
30
31
  "template/",
@@ -140,6 +140,22 @@ without context switching:
140
140
  {列表 或 "无。"}
141
141
  ```
142
142
 
143
+ ### Commit
144
+
145
+ 扫描完成后立即提交,把 dream 发现纳入 git 历史,便于晨报追溯:
146
+
147
+ ```bash
148
+ git add BACKLOG.md docs/dream/YYYY-MM-DD.md
149
+ # 有 REFACTOR 条目时:
150
+ git commit -m "chore: dream scan YYYY-MM-DD — {N} REFACTOR entries"
151
+ # 无发现时:
152
+ git commit -m "chore: dream scan YYYY-MM-DD — no findings"
153
+ ```
154
+
155
+ - BACKLOG.md 和 dream 日志必须在**同一个 commit** 里入库,避免出现"REFACTOR 已加但日志找不到"或反过来的撕裂状态
156
+ - 写文件失败时不要执行 commit;保持工作区干净,由调度器负责重试
157
+ - 仅 `BACKLOG.md` 和 `docs/dream/YYYY-MM-DD.md` 入 commit,不要顺带带入其他无关变更
158
+
143
159
  ## Scheduler Configuration
144
160
 
145
161
  roll-.dream runs **locally** — it reads the local codebase directly.
@@ -151,6 +151,19 @@ A simple heuristic — not a gate, just a signal for the human:
151
151
  **Epic: {Name}** — {done}/{total} ✅
152
152
  ```
153
153
 
154
+ ### Step 4.5 — Commit Brief
155
+
156
+ 写完文件后立即提交,让简报进入 git 历史,便于后续追溯与跨会话审计:
157
+
158
+ ```bash
159
+ git add docs/briefs/YYYY-MM-DD-NN.md
160
+ git commit -m "docs: roll-brief YYYY-MM-DD-NN — {触发原因}"
161
+ ```
162
+
163
+ - 触发原因来自调用上下文(Feature 完成 / 每日 / 手动 / `--feature` / `--since`),用一句话填入
164
+ - 写文件失败时不要执行 commit;保持工作区干净,由调用方处理重试
165
+ - 仅 `docs/briefs/` 下新文件入 commit,不要顺带带入其他无关变更
166
+
154
167
  ### Step 5 — Notify
155
168
 
156
169
  写完文件后在终端或 CI 日志中打印简报路径:
@@ -50,6 +50,12 @@ $roll-design --from-plan docs/features/auth-plan.md
50
50
 
51
51
  # Directly create a Story (auto-detected as User Story → Slice DDD)
52
52
  $roll-design "user login feature"
53
+
54
+ # Non-interactive: read structured input file, skip Clarify/Discuss, write BACKLOG
55
+ $roll-design --from-file docs/requirements/auth-req.md
56
+
57
+ # Promote IDEA to Story: read BACKLOG IDEA-NNN, produce US-XXX, annotate IDEA
58
+ $roll-design --from-idea IDEA-009
53
59
  ```
54
60
 
55
61
  ## DDD Depth Scale
@@ -118,10 +124,82 @@ docs/domain/ # DDD domain model (greenfield / cross-featu
118
124
  3. Plan file: `docs/features/<feature>-plan.md` (create if it doesn't exist)
119
125
  4. BACKLOG.md index row goes under the corresponding Epic > Feature group
120
126
 
127
+ ## Non-Interactive Mode
128
+
129
+ Activated by explicit flags or auto-detected high-confidence input. Skips Clarify and Discuss, writes stories directly to BACKLOG as `📋 Todo`, no confirm gate.
130
+
131
+ ### `--from-file <path>`
132
+
133
+ ```
134
+ Input: structured requirement file (plain text or markdown)
135
+
136
+ Expected file contents (minimum viable):
137
+ - Description: what to build (1–3 sentences)
138
+ - Expected AC: measurable outcomes (bullet list)
139
+ - [Optional] Domain hint, priority, dependencies
140
+
141
+ Execution path:
142
+ [Read file] → [Analyze] → [DDD Slice] → [Solution Design] → [Split Stories]
143
+ → [Write BACKLOG 📋 Todo] → Done (no Clarify, no Discuss, no confirm gate)
144
+ ```
145
+
146
+ Input file example (`docs/requirements/auth-req.md`):
147
+ ```markdown
148
+ ## Requirement: session timeout warning
149
+
150
+ Description: Show a countdown modal 60 seconds before session expires.
151
+ Users can click "Stay logged in" to extend, or let it expire naturally.
152
+
153
+ Expected AC:
154
+ - Modal appears at T-60s with countdown timer
155
+ - "Stay logged in" sends a keepalive and dismisses modal
156
+ - Expiry after countdown logs user out and redirects to /login
157
+ - Works across all authenticated pages
158
+ ```
159
+
160
+ ### `--from-idea IDEA-NNN`
161
+
162
+ ```
163
+ Input: IDEA-NNN identifier from BACKLOG.md
164
+
165
+ Execution path:
166
+ [Read BACKLOG.md IDEA-NNN row] → [Analyze] → [DDD Slice] → [Split Stories]
167
+ → [Write BACKLOG 📋 Todo] → [Annotate IDEA row: → US-XXX] → Done
168
+
169
+ IDEA annotation: append `→ US-XXX` to the IDEA row's Description column.
170
+ Example: | IDEA-009 | ... | ✅ Done → US-AUTO-021 |
171
+ ```
172
+
173
+ ### High-Confidence Auto-Detection
174
+
175
+ When input is not a flag-based mode but already contains all three of: **clear verb**, **explicit scope**, and **verifiable acceptance signal**, skip Clarify automatically. At most retain Discuss (only if approach has genuine divergence).
176
+
177
+ High-confidence signals (all three must be present):
178
+ - Clear verb: add / remove / fix / rename / migrate / split / extract / support ...
179
+ - Explicit scope: named file, command, module, endpoint, or UI element
180
+ - Acceptance signal: "so that X", "when Y then Z", measurable outcome described
181
+
182
+ Examples:
183
+ ```bash
184
+ # High confidence — skip Clarify:
185
+ $roll-design "add --dry-run flag to roll loop on that prints plist without installing"
186
+ $roll-design "rename cmd_status() to cmd_overview() in bin/roll and update all callers"
187
+
188
+ # Low confidence — enter Clarify:
189
+ $roll-design "improve the status command"
190
+ $roll-design "make loop better"
191
+ ```
192
+
193
+ ---
194
+
121
195
  ## Workflow
122
196
 
123
197
  ```
124
198
  User Input
199
+
200
+ ├── --from-file <path> ──→ [Read file] → Step 2 (Analyze) → Steps 3–5 → BACKLOG 📋 → Done
201
+ ├── --from-idea IDEA-N ──→ [Read BACKLOG IDEA] → Step 2 → Steps 3–5 → BACKLOG 📋 → Annotate IDEA → Done
202
+ ├── high-confidence str ──→ Step 2 (Analyze) directly (skip Clarify, keep Discuss if divergence)
125
203
 
126
204
 
127
205
  ┌─────────────────────────────┐
@@ -240,9 +318,14 @@ User Input
240
318
 
241
319
  ├── Yes ──→ $roll-build US-XXX
242
320
 
243
- └── No ──→ Wait for user confirmation
321
+ └── No ──→ Story 已写入 BACKLOG 为 📋 Todo
322
+ loop 下轮将自动执行
323
+ (选 No 仅跳过立即执行)
244
324
  ```
245
325
 
326
+ **Gate 语义澄清**:选 `No` 不是放弃,story 已经入 BACKLOG,下轮 loop 会自动 pickup
327
+ (次日 / `roll loop now`)。若想完全搁置,请显式把状态改为 🚫 Hold。
328
+
246
329
  ---
247
330
 
248
331
  ## DDD Output Formats
@@ -412,7 +495,11 @@ Domain: Order Context > Order Aggregate > OrderItem Entity
412
495
 
413
496
  ## Clarify Phase
414
497
 
415
- **Trigger conditions** — automatically enters if any of these are met:
498
+ **Skip conditions** — silently skip Clarify when any of these hold:
499
+ - Input uses `--from-file` or `--from-idea` flag (non-interactive mode)
500
+ - Input is high-confidence (clear verb + explicit scope + acceptance signal — see Non-Interactive Mode)
501
+
502
+ **Trigger conditions** — automatically enters if none of the skip conditions hold AND any of these are met:
416
503
  - Input is a single vague sentence without clear scope
417
504
  - Missing clear boundaries (what / who / when / where)
418
505
  - Contains ambiguous terms like "优化一下", "改一下", "加个东西", "做个设计"
@@ -32,6 +32,11 @@ sole authority over `roll-release`.
32
32
  - Any Story marked 🚫 Hold or flagged for human review
33
33
  - Destructive operations outside normal skill scope
34
34
 
35
+ **Human bypass path** — roll-loop 是默认调度器,不垄断执行权。任何时刻人可直接
36
+ `$roll-build US-XXX` 或 `$roll-fix FIX-XXX` 绕过 loop 立即执行(紧急 bug、中断插入、
37
+ 故事评审等场景)。loop 通过 LOCK 和 `🔨 In Progress` 状态识别并跳过人正在做的故事,
38
+ 人机并行不会撞车(见 Concurrency Safety)。
39
+
35
40
  ## Configuration
36
41
 
37
42
  ```yaml