@seanyao/roll 2026.522.1 → 2026.523.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/roll CHANGED
@@ -4,7 +4,7 @@ set -euo pipefail
4
4
  # Roll — AI Agent Convention Manager
5
5
  # Single source of truth for how all AI coding agents behave.
6
6
 
7
- VERSION="2026.522.1"
7
+ VERSION="2026.523.1"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -59,6 +59,9 @@ ai_tool_name() {
59
59
  elif [[ "$bn" == "agent" || "$bn" == "workspace" ]]; then
60
60
  bn="$(basename "$(dirname "$dir")" | sed 's/^\.//')"
61
61
  fi
62
+ # Antigravity (agy) reuses ~/.gemini/ from the deprecated Gemini CLI for
63
+ # its config dir, so a literal `gemini` basename now identifies agy.
64
+ [[ "$bn" == "gemini" ]] && bn="agy"
62
65
  echo "$bn"
63
66
  }
64
67
 
@@ -94,6 +97,123 @@ _is_ai_installed() {
94
97
  return 1
95
98
  }
96
99
 
100
+ # ─── Spinner: TTY-aware status display for long-running steps (US-REL-003) ───
101
+ # _spin_setup [off]
102
+ # Opens FD 3 for spinner output. Without args: FD 3 → stdout if interactive,
103
+ # else stderr (best-effort visibility for the operator). With "off": FD 3
104
+ # → /dev/null, silencing all _spin output (useful for scripts that drive
105
+ # release.sh in non-interactive contexts but still want exit codes).
106
+ _spin_setup() {
107
+ if [ "${1:-}" = "off" ]; then
108
+ exec 3>/dev/null
109
+ return 0
110
+ fi
111
+ if [ -t 1 ]; then
112
+ # Interactive: spinner shares stdout with normal program output.
113
+ exec 3>&1
114
+ else
115
+ # Non-interactive (stdout redirected to file / piped): route the spinner
116
+ # trail to stderr so it stays visible in CI logs and never pollutes the
117
+ # caller's redirected stdout (e.g. > release_notes.txt). FD 3 will be
118
+ # plain-text mode whenever stderr is also not a TTY.
119
+ exec 3>&2
120
+ fi
121
+ }
122
+
123
+ # _spin <label> <cmd> [args...]
124
+ # Runs cmd with a status indicator written to FD 3 only. cmd's own
125
+ # stdout (FD 1) and stderr (FD 2) pass through untouched, so caller-side
126
+ # `>file` and `2>file` redirections on the _spin call behave normally.
127
+ #
128
+ # FD 3 is a TTY (or ROLL_SPIN_FORCE_TTY=1):
129
+ # ⠋ label [Ns] (refreshed ~10/s, line-cleared with \r\033[2K)
130
+ # final: ✓ label (Ns) on success
131
+ # ✗ label (rc=N, Ns) on failure
132
+ #
133
+ # FD 3 not a TTY (CI, pipes, `2>&1 | tee`):
134
+ # » label... on start
135
+ # done label (Ns) on success
136
+ # fail label (rc=N, Ns) on failure
137
+ #
138
+ # Returns wrapped cmd's exit code (transparent under `set -e` callers
139
+ # when used in `if`/`&&`/`||` contexts).
140
+ _spin() {
141
+ local _label="$1"
142
+ shift
143
+
144
+ local _spin_tty=0
145
+ if [ "${ROLL_SPIN_FORCE_TTY:-}" = "1" ] || [ -t 3 ]; then
146
+ _spin_tty=1
147
+ fi
148
+
149
+ local _spin_start=$SECONDS
150
+ local _spin_pid=""
151
+
152
+ if [ "$_spin_tty" = "1" ]; then
153
+ # Bash 3.2-safe: indexed array of braille frames.
154
+ local _spin_frames
155
+ _spin_frames=( $'\xe2\xa0\x8b' $'\xe2\xa0\x99' $'\xe2\xa0\xb9' $'\xe2\xa0\xb8' $'\xe2\xa0\xbc' $'\xe2\xa0\xb4' $'\xe2\xa0\xa6' $'\xe2\xa0\xa7' $'\xe2\xa0\x87' $'\xe2\xa0\x8f' )
156
+ # Print initial frame at t=0 so users see something immediately.
157
+ printf '\r\033[2K%s %s [0s]' "${_spin_frames[0]}" "$_label" >&3 2>/dev/null || true
158
+ # Only animate when FD 3 is a real TTY — when force-on for tests, skip
159
+ # the background animator so the test gets deterministic output (the
160
+ # initial frame, the cleared final frame).
161
+ if [ -t 3 ]; then
162
+ (
163
+ set +e +u 2>/dev/null
164
+ local _i=1
165
+ local _t0=$_spin_start
166
+ while :; do
167
+ sleep 0.1
168
+ local _e=$(( SECONDS - _t0 ))
169
+ printf '\r\033[2K%s %s [%ds]' "${_spin_frames[$_i]}" "$_label" "$_e" >&3 2>/dev/null || true
170
+ _i=$(( (_i + 1) % 10 ))
171
+ done
172
+ ) &
173
+ _spin_pid=$!
174
+ # Kill spinner on Ctrl-C / SIGTERM / EXIT. Save no prior trap because
175
+ # release.sh and its callers install no traps of their own; if a
176
+ # future caller does, _spin's scope is short and traps are restored
177
+ # to default after reaping.
178
+ # shellcheck disable=SC2064
179
+ trap "kill ${_spin_pid} 2>/dev/null; wait ${_spin_pid} 2>/dev/null; trap - INT TERM EXIT; exit 130" INT TERM
180
+ # shellcheck disable=SC2064
181
+ trap "kill ${_spin_pid} 2>/dev/null; wait ${_spin_pid} 2>/dev/null" EXIT
182
+ fi
183
+ else
184
+ printf '» %s...\n' "$_label" >&3 2>/dev/null || true
185
+ fi
186
+
187
+ local _spin_rc=0
188
+ if [ "$#" -gt 0 ]; then
189
+ "$@" || _spin_rc=$?
190
+ fi
191
+
192
+ if [ -n "$_spin_pid" ]; then
193
+ kill "$_spin_pid" 2>/dev/null || true
194
+ wait "$_spin_pid" 2>/dev/null || true
195
+ trap - INT TERM EXIT
196
+ fi
197
+
198
+ local _spin_elapsed=$(( SECONDS - _spin_start ))
199
+
200
+ if [ "$_spin_tty" = "1" ]; then
201
+ if [ "$_spin_rc" -eq 0 ]; then
202
+ printf '\r\033[2K✓ %s (%ds)\n' "$_label" "$_spin_elapsed" >&3 2>/dev/null || true
203
+ else
204
+ printf '\r\033[2K✗ %s (rc=%d, %ds)\n' "$_label" "$_spin_rc" "$_spin_elapsed" >&3 2>/dev/null || true
205
+ fi
206
+ else
207
+ if [ "$_spin_rc" -eq 0 ]; then
208
+ printf 'done %s (%ds)\n' "$_label" "$_spin_elapsed" >&3 2>/dev/null || true
209
+ else
210
+ printf 'fail %s (rc=%d, %ds)\n' "$_label" "$_spin_rc" "$_spin_elapsed" >&3 2>/dev/null || true
211
+ fi
212
+ fi
213
+
214
+ return "$_spin_rc"
215
+ }
216
+
97
217
  # ─── Helper: read config value ───────────────────────────────────────────────
98
218
  config_get() {
99
219
  local key="$1"
@@ -136,7 +256,7 @@ _ensure_config_entries() {
136
256
 
137
257
  local -a default_keys=(
138
258
  "ai_claude:~/.claude|CLAUDE.md|CLAUDE.md"
139
- "ai_gemini:~/.gemini|GEMINI.md|GEMINI.md"
259
+ "ai_agy:~/.gemini|GEMINI.md|GEMINI.md"
140
260
  "ai_kimi:~/.kimi|AGENTS.md|AGENTS.md"
141
261
  "ai_codex:~/.codex|AGENTS.md|AGENTS.md"
142
262
  "ai_cursor:~/.cursor|.cursor-rules|.cursor-rules"
@@ -202,16 +322,25 @@ safe_copy() {
202
322
  if diff -q "$src" "$dst" &>/dev/null; then
203
323
  return # identical, skip silently
204
324
  fi
325
+ # Non-interactive (stdin is not a terminal): silently overwrite.
326
+ # _run_setup_step / cmd_update redirect stdin to /dev/null and all
327
+ # stdout/stderr is suppressed — prompting here would either hang on a
328
+ # hidden read or silently default to overwrite. Be explicit.
329
+ if [[ ! -t 0 ]]; then
330
+ cp "$src" "$dst"
331
+ ok "Wrote: ${dst/#$HOME/~} 已写入: ${dst/#$HOME/~}"
332
+ return
333
+ fi
205
334
  echo ""
206
335
  warn "File exists and differs: ${dst/#$HOME/~} 文件已存在且内容不同: ${dst/#$HOME/~}"
207
336
  echo -e " ${BOLD}Overwrite?${NC} [Y/n/d(iff)] "
208
- read -r answer
337
+ read -r answer || answer="Y"
209
338
  case "$answer" in
210
339
  d|D|diff)
211
340
  diff --color=auto "$dst" "$src" || true
212
341
  echo ""
213
342
  echo -e " ${BOLD}Overwrite?${NC} [Y/n] "
214
- read -r answer2
343
+ read -r answer2 || answer2="Y"
215
344
  [[ "$answer2" =~ ^[Nn]$ ]] && { info "Skipped: ${dst/#$HOME/\~} 已跳过: ${dst/#$HOME/\~}"; return; }
216
345
  ;;
217
346
  n|N) info "Skipped: ${dst/#$HOME/~} 已跳过: ${dst/#$HOME/~}"; return ;;
@@ -606,7 +735,7 @@ _run_setup_step() {
606
735
  local watch="$1"; shift
607
736
  local before after
608
737
  before=$(_setup_snapshot "$watch")
609
- if "$@" >/dev/null 2>&1; then
738
+ if "$@" </dev/null >/dev/null 2>&1; then
610
739
  after=$(_setup_snapshot "$watch")
611
740
  if [[ "$before" == "$after" ]]; then
612
741
  _ROLL_SETUP_STATE="unchanged"
@@ -763,6 +892,40 @@ HINT
763
892
  # in (or opted out) don't get spammed each upgrade.
764
893
  cmd_doctor() {
765
894
  _doctor_pr_section
895
+ _doctor_launchd_stale_section
896
+ }
897
+
898
+ # FIX-097: scan ${_LAUNCHD_DIR}/com.roll.*.plist for entries whose
899
+ # WorkingDirectory no longer exists on disk. These are the ghost agents left
900
+ # behind when a user manually reproduces a bug under /private/tmp/ or
901
+ # /var/folders/ — the auto-sandbox redirects plist writes but launchctl
902
+ # bootstrap (before this fix) registered them anyway. Print labels +
903
+ # cleanup hint; never auto-delete (host launchctl state is user-owned).
904
+ _doctor_launchd_stale_section() {
905
+ [[ "$(uname)" == "Darwin" ]] || return 0
906
+ local dir="${_LAUNCHD_DIR:-${HOME}/Library/LaunchAgents}"
907
+ [[ -d "$dir" ]] || return 0
908
+
909
+ local found=0 plist label wd
910
+ for plist in "$dir"/com.roll.*.plist; do
911
+ [[ -e "$plist" ]] || continue
912
+ wd=$(awk '
913
+ /<key>WorkingDirectory<\/key>/ { getline; gsub(/.*<string>|<\/string>.*/, ""); print; exit }
914
+ ' "$plist" 2>/dev/null)
915
+ [[ -n "$wd" ]] || continue
916
+ [[ -d "$wd" ]] && continue
917
+ if [[ "$found" -eq 0 ]]; then
918
+ echo ""
919
+ echo "Stale launchd plists 无效的 launchd 服务"
920
+ echo ""
921
+ found=1
922
+ fi
923
+ label=$(basename "$plist" .plist)
924
+ echo " ⚠ ${label}"
925
+ echo " WorkingDirectory missing: ${wd}"
926
+ echo " 路径已失效,可清理: launchctl bootout gui/$(id -u)/${label}; rm '${plist}'"
927
+ done
928
+ return 0
766
929
  }
767
930
 
768
931
  _doctor_pr_section() {
@@ -1784,7 +1947,7 @@ PY
1784
1947
  fi
1785
1948
  if [ "${#plists[@]}" -gt 0 ]; then
1786
1949
  for item in "${plists[@]}"; do
1787
- launchctl unload -w "$HOME/Library/LaunchAgents/$item" 2>/dev/null && echo " unloaded $item"
1950
+ _launchctl_safe unload -w "$HOME/Library/LaunchAgents/$item" 2>/dev/null && echo " unloaded $item"
1788
1951
  rm -f "$HOME/Library/LaunchAgents/$item" 2>/dev/null
1789
1952
  done
1790
1953
  fi
@@ -2495,19 +2658,23 @@ _peer_route() {
2495
2658
  }
2496
2659
 
2497
2660
  # Open a Terminal.app window attached to the given tmux session (peer
2498
- # auto-attach). No-ops when muted, non-macOS, or osascript unavailable.
2661
+ # auto-attach). No-ops when muted or non-macOS.
2499
2662
  # FIX-054: terminal selection removed — always dispatches to macOS
2500
2663
  # Terminal.app for predictability (per-user detection silently failed on
2501
2664
  # Ghostty upgrades).
2665
+ # Uses `open -g` so the window appears in the background and does not steal
2666
+ # focus from the user's active app (replaces a prior osascript-based
2667
+ # capture-frontmost / restore-focus dance that triggered LaunchServices
2668
+ # "where is <app>" prompts when the active process name differed from its
2669
+ # .app bundle name, e.g. MSTeams vs Microsoft Teams.app).
2502
2670
  _peer_auto_attach() {
2503
2671
  local session="$1"
2504
2672
  [ "$(uname)" = "Darwin" ] || return 0
2505
2673
  [ -f "$_LOOP_MUTE_FILE" ] && return 0
2506
- command -v osascript >/dev/null 2>&1 || return 0
2507
- osascript \
2508
- -e 'tell application "System Events" to set _prev to name of first application process whose frontmost is true' \
2509
- -e "tell application \"Terminal\" to do script \"tmux attach -t $session\"" \
2510
- -e 'delay 0.3' -e 'try' -e 'tell application _prev to activate' -e 'end try' >/dev/null 2>&1 || true
2674
+ local attach_cmd="${_SHARED_ROOT}/loop/attach-${session}.command"
2675
+ printf '#!/bin/bash\nexec tmux attach -t %s\n' "$session" > "$attach_cmd" 2>/dev/null || return 0
2676
+ chmod +x "$attach_cmd" 2>/dev/null || return 0
2677
+ open -g -a Terminal "$attach_cmd" >/dev/null 2>&1 || true
2511
2678
  }
2512
2679
 
2513
2680
  # Dispatch a peer CLI command inside an existing tmux session (window 0).
@@ -2976,10 +3143,13 @@ _agent_argv() {
2976
3143
  interactive) _AGENT_ARGV=(opencode "$prompt") ;;
2977
3144
  *) _AGENT_ARGV=(opencode run "$prompt") ;;
2978
3145
  esac ;;
2979
- gemini)
2980
- # gemini integration is interactive-only for now (used by onboard flow).
3146
+ agy)
3147
+ # Antigravity (agy) replaces the deprecated Google Gemini CLI as of
3148
+ # late 2025. agy reuses ~/.gemini/ for config and reads GEMINI.md
3149
+ # natively, so the convention sync target is unchanged — only the
3150
+ # invoked binary changes. Interactive-only (used by onboard flow).
2981
3151
  case "$mode" in
2982
- interactive) _AGENT_ARGV=(gemini "$prompt") ;;
3152
+ interactive) _AGENT_ARGV=(agy -i "$prompt") ;;
2983
3153
  *) return 1 ;;
2984
3154
  esac ;;
2985
3155
  *) return 1 ;;
@@ -3072,7 +3242,7 @@ _slides_lib() {
3072
3242
  # Returns 0 + prints the path if the template exists, else returns 1.
3073
3243
  _slides_template_path() {
3074
3244
  local name="$1"
3075
- local tpl="${ROLL_PKG_DIR}/site/slides/templates/${name}.html"
3245
+ local tpl="${ROLL_PKG_DIR}/lib/slides/templates/${name}.html"
3076
3246
  if [[ -f "$tpl" ]]; then
3077
3247
  printf '%s' "$tpl"
3078
3248
  return 0
@@ -3702,8 +3872,12 @@ _slug_migrate_from_legacy() {
3702
3872
  [[ "$(uname -s 2>/dev/null)" == "Darwin" ]] || return 0
3703
3873
  # Compute the pre-FIX-056 slug: same algorithm but without realpath.
3704
3874
  local raw_path; raw_path=$(pwd 2>/dev/null)
3875
+ # FIX-094: `_common=$(...)` as a standalone assignment statement inherits the
3876
+ # command's exit code; `set -e` aborts the whole script when cwd is non-git
3877
+ # (git rev-parse exits 128). `|| true` keeps probing semantics — we WANT to
3878
+ # detect "no git" by empty output, not by killing the script.
3705
3879
  local _common
3706
- _common=$(git -C "$raw_path" rev-parse --git-common-dir 2>/dev/null)
3880
+ _common=$(git -C "$raw_path" rev-parse --git-common-dir 2>/dev/null) || true
3707
3881
  if [[ -n "$_common" && "$_common" == *"/.git" ]]; then
3708
3882
  raw_path="${_common%/.git}"
3709
3883
  fi
@@ -3761,7 +3935,7 @@ PYEOF
3761
3935
 
3762
3936
  local old_plist=~/Library/LaunchAgents/com.roll.loop.${old_slug}.plist
3763
3937
  if [[ -f "$old_plist" ]]; then
3764
- launchctl unload "$old_plist" 2>/dev/null || true
3938
+ _launchctl_safe unload "$old_plist" 2>/dev/null || true
3765
3939
  rm -f "$old_plist"
3766
3940
  fi
3767
3941
 
@@ -3838,6 +4012,13 @@ if [ -z "${_LAUNCHD_DIR:-}" ]; then
3838
4012
  _LAUNCHD_DIR="${_SHARED_ROOT}/LaunchAgents"
3839
4013
  mkdir -p "$_LAUNCHD_DIR"
3840
4014
  export _LAUNCHD_DIR
4015
+ # FIX-097: same trigger that sandboxed the plist FILE path must also
4016
+ # short-circuit every `launchctl bootstrap/load/unload/enable` against
4017
+ # that path. Otherwise a user who reproduces a bug under /private/tmp/
4018
+ # or /var/folders/ ends up with sandboxed plists registered in their
4019
+ # real gui/<uid> domain — when the tmp dir is cleaned, the agents become
4020
+ # ghosts that fire forever (the historical 23:13 CST Terminal popup).
4021
+ export _LAUNCHD_SKIP_REGISTRY=1
3841
4022
  fi
3842
4023
  unset _roll_in_test_ctx _roll_caller
3843
4024
  ;;
@@ -3964,6 +4145,25 @@ _launchd_label() {
3964
4145
  printf 'com.roll.%s.%s' "$service" "$(_project_slug "$project_path")"
3965
4146
  }
3966
4147
 
4148
+ # FIX-097: central skip predicate consulted by every launchctl invocation that
4149
+ # operates on a plist path Roll wrote. Returns 0 (skip) when either:
4150
+ # - explicit: _LAUNCHD_SKIP_REGISTRY=1 was exported (tests, future opt-out)
4151
+ # - implicit: _LAUNCHD_DIR is a child of _SHARED_ROOT (auto-sandbox active)
4152
+ # Returns 1 (do not skip) in production.
4153
+ #
4154
+ # History: FIX-090 introduced the same logic INSIDE _install_launchd_plists.
4155
+ # FIX-097 hoists it to a helper because the bootstrap call inside
4156
+ # _install_launchd_plists was not the only leak: _loop_on / _loop_off /
4157
+ # _loop_pause / _loop_resume each had bare `launchctl load/unload/enable`
4158
+ # calls that bypassed the gate.
4159
+ _launchd_should_skip_registry() {
4160
+ [[ "${_LAUNCHD_SKIP_REGISTRY:-}" == "1" ]] && return 0
4161
+ case "${_LAUNCHD_DIR:-}/" in
4162
+ "${_SHARED_ROOT:-/nonexistent}"/*) return 0 ;;
4163
+ esac
4164
+ return 1
4165
+ }
4166
+
3967
4167
  _launchd_plist_path() {
3968
4168
  local service="$1" project_path="$2"
3969
4169
  printf '%s/%s.plist' "$_LAUNCHD_DIR" "$(_launchd_label "$service" "$project_path")"
@@ -3992,16 +4192,34 @@ _write_launchd_plist() {
3992
4192
  ;;
3993
4193
  esac
3994
4194
 
3995
- local hour_xml=""
3996
- [[ -n "$hour" ]] && hour_xml=" <key>Hour</key>
3997
- <integer>${hour}</integer>
3998
- "
3999
-
4000
4195
  # FIX-050: bake PATH into the plist so launchd-spawned bash can find tmux,
4001
4196
  # claude, node, etc. The runner script also re-asserts PATH at runtime as
4002
4197
  # a second layer (covers stale plists where brew was installed after setup).
4003
4198
  local path_value; path_value=$(_detect_path_prepend)
4004
4199
 
4200
+ # FIX-105: macOS 26.4 launchd silently refuses to fire StartCalendarInterval
4201
+ # entries that contain BOTH Hour and Minute keys (verified: runs stays 0,
4202
+ # last exit "never exited", no log output, the calendarinterval trigger is
4203
+ # registered but never invoked by UserEventAgent-Aqua). Single-Minute (hourly)
4204
+ # entries still fire fine. Workaround: when an Hour is provided (daily
4205
+ # schedule), emit StartInterval=86400 (24h period) instead. First fire is
4206
+ # bootstrap+24h rather than the exact requested wall-clock time — acceptable
4207
+ # trade since the alternative was "never fires at all" (dream/brief broken
4208
+ # for 4+ days). The Minute/Hour args are still kept in the function signature
4209
+ # for callers that may want to filter at runtime, but they no longer steer
4210
+ # the plist trigger format for daily schedules.
4211
+ local schedule_xml
4212
+ if [[ -n "$hour" ]]; then
4213
+ schedule_xml=" <key>StartInterval</key>
4214
+ <integer>86400</integer>"
4215
+ else
4216
+ schedule_xml=" <key>StartCalendarInterval</key>
4217
+ <dict>
4218
+ <key>Minute</key>
4219
+ <integer>${minute}</integer>
4220
+ </dict>"
4221
+ fi
4222
+
4005
4223
  local content
4006
4224
  content="<?xml version=\"1.0\" encoding=\"UTF-8\"?>
4007
4225
  <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
@@ -4020,11 +4238,7 @@ _write_launchd_plist() {
4020
4238
  <key>PATH</key>
4021
4239
  <string>${path_value}</string>
4022
4240
  </dict>
4023
- <key>StartCalendarInterval</key>
4024
- <dict>
4025
- <key>Minute</key>
4026
- <integer>${minute}</integer>
4027
- ${hour_xml} </dict>
4241
+ ${schedule_xml}
4028
4242
  <key>WorkingDirectory</key>
4029
4243
  <string>${project_path}</string>
4030
4244
  </dict>
@@ -4195,14 +4409,43 @@ _inner_cleanup() {
4195
4409
  && [ -n "\${CYCLE_ID:-}" ]; then
4196
4410
  _unpushed=\$(cd "\$WT" && git rev-list --count "origin/main..HEAD" 2>/dev/null || echo 0)
4197
4411
  if [ "\${_unpushed:-0}" -gt 0 ]; then
4198
- _orphan_tag="loop-orphan-\${CYCLE_ID}"
4199
- if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \\
4200
- && git tag "\$_orphan_tag" 2>/dev/null \\
4201
- && git push origin "\$_orphan_tag" 2>/dev/null ); then
4202
- _loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "orphan" 2>/dev/null || true
4412
+ # FIX-091: prefer a real PR so auto-merge lands the work; tag-only is the
4413
+ # last-resort because it requires manual cherry-pick. Emit cycle_end "done"
4414
+ # (canonical success status the dashboard recognizes) when PR publishes.
4415
+ # FIX-099: compute tcr_count + built[] from the worktree (it's still alive
4416
+ # at EXIT trap time) so runs.jsonl and ALERT carry truthful data.
4417
+ _orphan_tcr=0
4418
+ _orphan_built="[]"
4419
+ if command -v jq >/dev/null 2>&1; then
4420
+ _orphan_tcr=\$(cd "\$WT" && git log --oneline "origin/main..HEAD" 2>/dev/null | grep -c ' tcr:' || echo 0)
4421
+ _orphan_built=\$(cd "\$WT" && git log --oneline "origin/main..HEAD" 2>/dev/null \
4422
+ | grep ' tcr:' \
4423
+ | grep -oE '\b(FIX|US|REFACTOR|CHORE)-[0-9]+\b' \
4424
+ | sort -u \
4425
+ | jq -R -s 'split("\n") | map(select(length>0))' 2>/dev/null || echo "[]")
4426
+ fi
4427
+ _slug=""
4428
+ if _gh_resolve _slug \\
4429
+ && ( cd "\$WT" && _loop_publish_pr "\$BRANCH" "loop cycle \${CYCLE_ID}" ) >/dev/null 2>&1; then
4430
+ _loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "done" 2>/dev/null || true
4203
4431
  _CYCLE_END_WRITTEN=1
4204
- _runs_append "orphan" 0 "[]" 2>/dev/null || true
4205
- _worktree_alert "cycle \${CYCLE_ID}: aborted with \${_unpushed} commits; FIX-086 pushed orphan tag \${_orphan_tag}" 2>/dev/null || true
4432
+ # FIX-099: pass real tcr_count + built[] instead of 0/"[]"
4433
+ _runs_append "done" "\${_orphan_tcr}" "\${_orphan_built}" 2>/dev/null || true
4434
+ # FIX-099: three-field ALERT so callers can distinguish recovered orphan
4435
+ # from a cycle's normally-picked story (was: "FIX-091 published as PR"
4436
+ # which leaked a hardcoded string regardless of what was actually built).
4437
+ _worktree_alert "cycle \${CYCLE_ID}: recovered_from_orphan=yes; tcr_commits=\${_orphan_tcr}; stories=\${_orphan_built}; pr_branch=\${BRANCH:-unknown}" 2>/dev/null || true
4438
+ else
4439
+ _orphan_tag="loop-orphan-\${CYCLE_ID}"
4440
+ if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \\
4441
+ && git tag "\$_orphan_tag" 2>/dev/null \\
4442
+ && git push origin "\$_orphan_tag" 2>/dev/null ); then
4443
+ _loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "orphan" 2>/dev/null || true
4444
+ _CYCLE_END_WRITTEN=1
4445
+ # FIX-099: pass real tcr_count + built[] for the orphan-tag path too
4446
+ _runs_append "orphan" "\${_orphan_tcr}" "\${_orphan_built}" 2>/dev/null || true
4447
+ _worktree_alert "cycle \${CYCLE_ID}: recovered_from_orphan=yes; tcr_commits=\${_orphan_tcr}; stories=\${_orphan_built}; FIX-086 pushed orphan tag \${_orphan_tag}" 2>/dev/null || true
4448
+ fi
4206
4449
  fi
4207
4450
  fi
4208
4451
  fi
@@ -4252,6 +4495,10 @@ WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
4252
4495
  BRANCH="loop/cycle-\${CYCLE_ID}"
4253
4496
  _USE_WORKTREE=0
4254
4497
  cd "${project_path}" 2>/dev/null || true
4498
+ # FIX-104: GC stale merged temp branches at cycle entry — before worktree setup
4499
+ # and before any early-exit gate (pre-run abort, CI red precheck). The post-claude
4500
+ # call site doesn't cover those paths, so merged branches accumulated on origin.
4501
+ _loop_cleanup_stale_cycle_branches "${project_path}" || true
4255
4502
  # FIX-040: orphan worktree recovery — scan for worktrees left by previous failed
4256
4503
  # cycles (publish failed or inner script was SIGKILL'd). Attempt to publish each
4257
4504
  # before starting the new cycle. Glob is chronological via timestamp in name.
@@ -4479,9 +4726,6 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
4479
4726
  fi
4480
4727
  fi
4481
4728
 
4482
- # US-AUTO-040: fallback GC — delete remote loop/cycle-* branches already merged to main.
4483
- _loop_cleanup_stale_cycle_branches "${project_path}" || true
4484
-
4485
4729
  # FIX-044 / Step 5: Write loop cycle run summary to runs.jsonl
4486
4730
  # Deterministic — runs in shell regardless of whether agent executes SKILL.md Step 5.
4487
4731
  # US-LOOP-005: now routed through _runs_append so timeout/worktree-setup-fail
@@ -4588,13 +4832,16 @@ if command -v tmux >/dev/null 2>&1; then
4588
4832
  # to the tmux session so the user can watch the loop work in real time.
4589
4833
  # FIX-054: terminal selection removed — fixed to macOS Terminal.app for
4590
4834
  # predictability (per-user detection silently failed on Ghostty upgrades).
4591
- # Best-effort focus retention: capture the current frontmost app and
4592
- # re-activate after.
4593
- 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
4594
- osascript \\
4595
- -e 'tell application "System Events" to set _prev to name of first application process whose frontmost is true' \\
4596
- -e "tell application \"Terminal\" to do script \"tmux attach -t \$SESSION\"" \\
4597
- -e 'delay 0.3' -e 'try' -e 'tell application _prev to activate' -e 'end try' >/dev/null 2>&1 || true
4835
+ # Uses \`open -g\` so the window appears in the background and does not steal
4836
+ # focus. Replaces a prior osascript capture-frontmost / restore-focus dance
4837
+ # that triggered LaunchServices "where is <app>" prompts when the active
4838
+ # process name differed from its .app bundle name (e.g. MSTeams vs
4839
+ # Microsoft Teams.app).
4840
+ if [ -z "\${ROLL_LOOP_NO_POPUP:-}" ] && [ -z "\${BATS_TEST_NUMBER:-}" ] && [ ! -f "\$HOME/.shared/roll/loop/mute-${slug}" ] && [ "\$(uname)" = "Darwin" ]; then
4841
+ _attach_cmd="\$HOME/.shared/roll/loop/attach-\$SESSION.command"
4842
+ printf '#!/bin/bash\\nexec tmux attach -t %s\\n' "\$SESSION" > "\$_attach_cmd" 2>/dev/null || true
4843
+ chmod +x "\$_attach_cmd" 2>/dev/null || true
4844
+ open -g -a Terminal "\$_attach_cmd" >/dev/null 2>&1 || true
4598
4845
  fi
4599
4846
  _OUTER_TIMEOUT=\$(( \${ROLL_LOOP_CYCLE_TIMEOUT_SEC:-2700} + 300 ))
4600
4847
  _outer_wait_start=\$(date +%s)
@@ -4614,17 +4861,67 @@ SCRIPT
4614
4861
  }
4615
4862
 
4616
4863
  _launchd_is_loaded() {
4617
- launchctl print-disabled "gui/$(id -u)" 2>/dev/null | grep -qF "\"$1\" => enabled"
4864
+ # FIX-098: probe actual launchd registry via `launchctl print`, NOT
4865
+ # `launchctl print-disabled`. The disabled-overrides DB only tracks
4866
+ # labels explicitly enabled/disabled by the user — after `roll loop off`
4867
+ # (bootout) + `roll update` the label stays absent from the overrides DB,
4868
+ # so the old grep returned false-positive "loaded". `launchctl print`
4869
+ # returns exit 0 only when the agent is actually registered in the current
4870
+ # launchd session; non-zero means the label is unknown to launchd.
4871
+ launchctl print "gui/$(id -u)/$1" >/dev/null 2>&1
4872
+ }
4873
+
4874
+ # FIX-101 tripwire: refuse to mutate the host's launchd session when
4875
+ # _LAUNCHD_DIR has been sandboxed (i.e. is not the canonical
4876
+ # ${HOME}/Library/LaunchAgents). Tests that auto-sandbox _LAUNCHD_DIR for
4877
+ # isolation (FIX-087) may still forget to set _LAUNCHD_SKIP_REGISTRY=1 or
4878
+ # stub the launchctl binary; without this defensive layer the production
4879
+ # label's plist path can get overwritten with a transient sandbox path,
4880
+ # leading to launchd EX_CONFIG (exit 78) when the tmp dir is later cleaned
4881
+ # and the next scheduled fire can't find the plist. Read-only ops (print*,
4882
+ # list, version) are always allowed since they have no side effects.
4883
+ _launchctl_safe() {
4884
+ # Read-only ops are always safe (no host launchd state mutation).
4885
+ case "${1:-}" in
4886
+ print|print-disabled|list|version|dumpstate|examine)
4887
+ launchctl "$@"
4888
+ return $?
4889
+ ;;
4890
+ esac
4891
+ # If `launchctl` has been replaced by a function stub (typical in bats tests
4892
+ # that want to assert against captured calls), pass through to the stub.
4893
+ # Stubs by definition don't touch host launchd, so this is safe; and tests
4894
+ # like `_install_launchd_plists: bootout targets gui/<uid>/<label>` rely on
4895
+ # the literal call landing in their captured log.
4896
+ if [[ "$(type -t launchctl 2>/dev/null)" == "function" ]]; then
4897
+ launchctl "$@"
4898
+ return $?
4899
+ fi
4900
+ # Real launchctl binary path: refuse to mutate when _LAUNCHD_DIR has been
4901
+ # sandboxed (i.e. is not the canonical ${HOME}/Library/LaunchAgents). This
4902
+ # is the FIX-101 defensive layer — when a test forgets to stub launchctl
4903
+ # AND has _LAUNCHD_DIR sandboxed, prevent the call from reaching the host's
4904
+ # production launchd and overwriting a live label's plist path.
4905
+ local canonical="${HOME}/Library/LaunchAgents"
4906
+ if [[ "${_LAUNCHD_DIR:-$canonical}" != "$canonical" ]]; then
4907
+ return 0
4908
+ fi
4909
+ launchctl "$@"
4618
4910
  }
4619
4911
 
4620
4912
  _launchd_svc_state() {
4913
+ # FIX-098: three-state classification:
4914
+ # enabled — plist on disk AND registered in launchd
4915
+ # stale — plist on disk BUT NOT registered in launchd
4916
+ # installed-off — kept for back-compat (maps to stale semantics)
4917
+ # not-installed — no plist
4621
4918
  local svc="$1" project_path="$2"
4622
4919
  local label; label=$(_launchd_label "$svc" "$project_path")
4623
4920
  local plist; plist=$(_launchd_plist_path "$svc" "$project_path")
4624
4921
  if _launchd_is_loaded "$label"; then
4625
4922
  echo "enabled"
4626
4923
  elif [[ -f "$plist" ]]; then
4627
- echo "installed-off"
4924
+ echo "stale"
4628
4925
  else
4629
4926
  echo "not-installed"
4630
4927
  fi
@@ -4687,21 +4984,26 @@ _install_launchd_plists() {
4687
4984
  local after; after=$(cat "$plist")
4688
4985
  if [[ "$before" != "$after" ]]; then
4689
4986
  updated=$((updated + 1))
4690
- if _launchd_is_loaded "$label"; then
4691
- # FIX-027: use bootout/bootstrap so we don't disturb the label's
4692
- # enabled flag in the launchd overrides db (which legacy
4693
- # unload/load no-`-w` wipes on macOS Sonoma+, causing
4694
- # `roll loop status` to falsely report off after `roll update`).
4695
- local uid; uid=$(id -u)
4696
- launchctl bootout "gui/${uid}/${label}" 2>/dev/null || true
4697
- launchctl bootstrap "gui/${uid}" "$plist" 2>/dev/null || true
4698
- elif [[ -z "$before" ]]; then
4699
- # FIX-059: brand-new plist — macOS FSEvents auto-bootstraps any new
4700
- # file dropped in ~/Library/LaunchAgents/, so projects never enabled
4701
- # via 'roll loop on' would fire every hour. Immediately mark disabled
4702
- # in the overrides db to block that auto-load.
4703
- local uid; uid=$(id -u)
4704
- launchctl disable "gui/${uid}/${label}" 2>/dev/null || true
4987
+ # FIX-090/FIX-097: gate launchctl writes via central helper so a
4988
+ # sandboxed plist never gets registered into the user's REAL gui/<uid>
4989
+ # domain. See _launchd_should_skip_registry for the predicate rules.
4990
+ if ! _launchd_should_skip_registry; then
4991
+ if _launchd_is_loaded "$label"; then
4992
+ # FIX-027: use bootout/bootstrap so we don't disturb the label's
4993
+ # enabled flag in the launchd overrides db (which legacy
4994
+ # unload/load no-`-w` wipes on macOS Sonoma+, causing
4995
+ # `roll loop status` to falsely report off after `roll update`).
4996
+ local uid; uid=$(id -u)
4997
+ _launchctl_safe bootout "gui/${uid}/${label}" 2>/dev/null || true
4998
+ _launchctl_safe bootstrap "gui/${uid}" "$plist" 2>/dev/null || true
4999
+ elif [[ -z "$before" ]]; then
5000
+ # FIX-059: brand-new plist — macOS FSEvents auto-bootstraps any new
5001
+ # file dropped in ~/Library/LaunchAgents/, so projects never enabled
5002
+ # via 'roll loop on' would fire every hour. Immediately mark disabled
5003
+ # in the overrides db to block that auto-load.
5004
+ local uid; uid=$(id -u)
5005
+ _launchctl_safe disable "gui/${uid}/${label}" 2>/dev/null || true
5006
+ fi
4705
5007
  fi
4706
5008
  fi
4707
5009
  done
@@ -4757,7 +5059,8 @@ cmd_loop() {
4757
5059
  notify) _notify "${1:-roll}" "${2:-}" ;;
4758
5060
  enforce-tcr) _loop_enforce_tcr "${1:-}" "${2:-}" ;;
4759
5061
  precheck-ci) _loop_precheck_ci ;;
4760
- *) err "Usage: roll loop <on|off|now|test|status|monitor|runs|events|attach|mute|unmute|pause|resume|reset|notify|enforce-tcr|precheck-ci>"; exit 1 ;;
5062
+ branches) _loop_branches "$(pwd -P)" ;;
5063
+ *) err "Usage: roll loop <on|off|now|test|status|monitor|runs|events|attach|mute|unmute|pause|resume|reset|notify|enforce-tcr|precheck-ci|branches>"; exit 1 ;;
4761
5064
  esac
4762
5065
  }
4763
5066
 
@@ -4777,12 +5080,25 @@ _loop_on() {
4777
5080
  if [[ "$(uname)" == "Darwin" ]]; then
4778
5081
  _install_launchd_plists "$project_path" >/dev/null
4779
5082
 
5083
+ # FIX-098: use launchctl bootstrap/enable instead of load -w.
5084
+ # `load -w` writes to the disabled-overrides DB which causes FIX-027's
5085
+ # re-source to break after `roll update`. bootstrap is idem-potent and
5086
+ # does not disturb the overrides DB.
5087
+ local uid; uid=$(id -u)
4780
5088
  local all_loaded=true
4781
5089
  for svc in loop dream brief; do
4782
5090
  local label; label=$(_launchd_label "$svc" "$project_path")
5091
+ local plist; plist=$(_launchd_plist_path "$svc" "$project_path")
4783
5092
  if ! _launchd_is_loaded "$label"; then
4784
5093
  all_loaded=false
4785
- launchctl load -w "$(_launchd_plist_path "$svc" "$project_path")" 2>/dev/null || true
5094
+ # FIX-097 guard: skip real launchctl when _LAUNCHD_DIR was auto-sandboxed.
5095
+ _launchd_should_skip_registry && continue
5096
+ # FIX-098 semantic: enable+bootstrap pair (better than load -w).
5097
+ # enable clears any disable-override; bootstrap registers with launchd.
5098
+ # FIX-101 wrapper additionally tripwire-gates each call so a sandboxed
5099
+ # _LAUNCHD_DIR can't accidentally touch host launchd state.
5100
+ _launchctl_safe enable "gui/${uid}/${label}" 2>/dev/null || true
5101
+ _launchctl_safe bootstrap "gui/${uid}" "$plist" 2>/dev/null || true
4786
5102
  fi
4787
5103
  done
4788
5104
 
@@ -4834,11 +5150,15 @@ _loop_off() {
4834
5150
 
4835
5151
  if [[ "$(uname)" == "Darwin" ]]; then
4836
5152
  local any_loaded=false
5153
+ local _skip_off; _launchd_should_skip_registry && _skip_off=1 || _skip_off=0
4837
5154
  for svc in loop dream brief; do
4838
5155
  local label; label=$(_launchd_label "$svc" "$project_path")
4839
5156
  if _launchd_is_loaded "$label"; then
4840
5157
  any_loaded=true
4841
- launchctl unload -w "$(_launchd_plist_path "$svc" "$project_path")" 2>/dev/null || true
5158
+ # FIX-097: skip real launchctl in sandbox to avoid touching the user's
5159
+ # real launchd registry.
5160
+ [[ "$_skip_off" == "1" ]] && continue
5161
+ _launchctl_safe unload -w "$(_launchd_plist_path "$svc" "$project_path")" 2>/dev/null || true
4842
5162
  fi
4843
5163
  done
4844
5164
  if ! $any_loaded; then
@@ -4857,7 +5177,9 @@ _loop_off() {
4857
5177
  # disable list, polluting `launchctl print-disabled` forever even after
4858
5178
  # the project dir, plists, and ~/.roll are gone.
4859
5179
  local label; label=$(_launchd_label "$svc" "$project_path")
4860
- launchctl enable "gui/${uid}/${label}" 2>/dev/null || true
5180
+ # FIX-097: same gate never touch host launchctl from a sandbox.
5181
+ [[ "$_skip_off" == "1" ]] && continue
5182
+ _launchctl_safe enable "gui/${uid}/${label}" 2>/dev/null || true
4861
5183
  done
4862
5184
  ok "Loop disabled 已停用"
4863
5185
  return 0
@@ -5001,7 +5323,7 @@ _legacy_loop_status() {
5001
5323
  else
5002
5324
  case "$state" in
5003
5325
  enabled) echo -e " ${GREEN}${svc} ● enabled${NC}" ;;
5004
- installed-off) echo -e " ${YELLOW}${svc} ⚠ installed/off${NC} run: roll loop on" ;;
5326
+ stale|installed-off) echo -e " ${YELLOW}${svc} ⚠ STALE — plist present but not loaded${NC} run: roll loop on" ;;
5005
5327
  not-installed) echo -e " ${RED}${svc} ○ not installed${NC} run: roll setup" ;;
5006
5328
  esac
5007
5329
  fi
@@ -5037,7 +5359,10 @@ _loop_pause() {
5037
5359
  if ! _launchd_is_loaded "$label"; then
5038
5360
  warn "Loop not enabled — nothing to pause loop 未启用,无需暂停"; return 0
5039
5361
  fi
5040
- launchctl unload -w "$(_launchd_plist_path "loop" "$project_path")" 2>/dev/null || true
5362
+ # FIX-097: never touch host launchctl from a sandboxed plist path.
5363
+ if ! _launchd_should_skip_registry; then
5364
+ _launchctl_safe unload -w "$(_launchd_plist_path "loop" "$project_path")" 2>/dev/null || true
5365
+ fi
5041
5366
  else
5042
5367
  local slug; slug=$(_project_slug "$project_path")
5043
5368
  mkdir -p "${_SHARED_ROOT}/loop"
@@ -5057,8 +5382,9 @@ _loop_resume() {
5057
5382
  if [[ "$(uname)" == "Darwin" ]]; then
5058
5383
  local label; label=$(_launchd_label "loop" "$project_path")
5059
5384
  local plist; plist=$(_launchd_plist_path "loop" "$project_path")
5060
- if [[ -f "$plist" ]]; then
5061
- launchctl load -w "$plist" 2>/dev/null || true
5385
+ if [[ -f "$plist" ]] && ! _launchd_should_skip_registry; then
5386
+ # FIX-097: never touch host launchctl from a sandboxed plist path.
5387
+ _launchctl_safe load -w "$plist" 2>/dev/null || true
5062
5388
  fi
5063
5389
  else
5064
5390
  local slug; slug=$(_project_slug "$project_path")
@@ -5394,15 +5720,28 @@ _loop_precheck_ci() {
5394
5720
 
5395
5721
  local commit; commit=$(git rev-parse HEAD 2>/dev/null) || return 0
5396
5722
 
5723
+ # FIX-103: fetch both `status` and `conclusion`. Pre-run gate must distinguish
5724
+ # a still-running CI (status=in_progress/queued/waiting, conclusion=null) from
5725
+ # a genuinely red CI (conclusion=failure/cancelled/timed_out/...). Treating
5726
+ # in_progress as red kills every cycle started within the first ~30s of a
5727
+ # merge-triggered CI run.
5397
5728
  local runs
5398
- runs=$(gh -R "$slug" run list --commit "$commit" --json conclusion 2>/dev/null) || return 0
5729
+ runs=$(gh -R "$slug" run list --commit "$commit" --json conclusion,status 2>/dev/null) || return 0
5399
5730
  [[ -z "$runs" || "$runs" == "[]" ]] && return 0
5400
5731
 
5401
- local failed
5402
- failed=$(echo "$runs" | jq -r '[.[] | select(.conclusion != null and .conclusion != "success" and .conclusion != "skipped")] | length' 2>/dev/null || echo "0")
5732
+ # Conclusions that block the loop. Anything else (success, skipped, neutral,
5733
+ # or null while still running) is treated as pass/pending.
5734
+ local failed_conclusions
5735
+ failed_conclusions=$(echo "$runs" \
5736
+ | jq -r '[.[] | select(.conclusion=="failure" or .conclusion=="cancelled" or .conclusion=="timed_out" or .conclusion=="action_required" or .conclusion=="startup_failure") | .conclusion] | unique | join(",")' \
5737
+ 2>/dev/null || echo "")
5403
5738
 
5404
- if [[ "$failed" -gt 0 ]]; then
5739
+ if [[ -n "$failed_conclusions" ]]; then
5405
5740
  local short; short=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
5741
+ local run_states
5742
+ run_states=$(echo "$runs" \
5743
+ | jq -r '[.[] | "\(.status // "?")/\(.conclusion // "null")"] | unique | join(", ")' \
5744
+ 2>/dev/null || echo "?")
5406
5745
  err "Pre-run CI check: HEAD CI is red — refuse to build on broken base (${short}) HEAD CI 红,拒绝在破损的基础上构建"
5407
5746
  mkdir -p "$(dirname "$_LOOP_ALERT")"
5408
5747
  cat > "$_LOOP_ALERT" << EOF
@@ -5411,6 +5750,8 @@ _loop_precheck_ci() {
5411
5750
  **Time**: $(date '+%Y-%m-%d %H:%M')
5412
5751
  **Commit**: ${short}
5413
5752
  **Reason**: HEAD CI is red — loop refused to build on a broken base HEAD CI 红,loop 拒绝在破损的基础上构建
5753
+ **Failing conclusions**: ${failed_conclusions}
5754
+ **Run states**: ${run_states}
5414
5755
 
5415
5756
  **Action required**:
5416
5757
  - Investigate and fix CI: \`gh -R ${slug} run list --commit ${commit}\`
@@ -5914,10 +6255,24 @@ _loop_mark_in_progress() {
5914
6255
  [ -n "$story_id" ] || return 1
5915
6256
  [ -f "$backlog" ] || return 0
5916
6257
  local tmp; tmp=$(mktemp "${backlog}.XXXXXX") || return 1
6258
+ # FIX-106: match the story-id column (col 2) for equality instead of doing
6259
+ # substring match on the whole row. Pre-fix, picking US-X-001 also flipped
6260
+ # any row whose description contained "depends-on:US-X-001" — leaving the
6261
+ # dashboard claiming work on stories no one had picked.
5917
6262
  awk -v sid="$story_id" '
5918
6263
  {
5919
- if (index($0, sid) > 0 && index($0, "📋 Todo") > 0) {
5920
- sub(/📋 Todo/, "🔨 In Progress")
6264
+ if (index($0, "📋 Todo") > 0) {
6265
+ n = split($0, cols, "|")
6266
+ if (n >= 2) {
6267
+ id_cell = cols[2]
6268
+ gsub(/[[:space:]]/, "", id_cell)
6269
+ # Markdown link form "[ID](path)" → keep just "ID"
6270
+ sub(/^\[/, "", id_cell)
6271
+ sub(/\].*$/, "", id_cell)
6272
+ if (id_cell == sid) {
6273
+ sub(/📋 Todo/, "🔨 In Progress")
6274
+ }
6275
+ }
5921
6276
  }
5922
6277
  print
5923
6278
  }
@@ -5933,10 +6288,20 @@ _loop_mark_todo() {
5933
6288
  [ -n "$story_id" ] || return 1
5934
6289
  [ -f "$backlog" ] || return 0
5935
6290
  local tmp; tmp=$(mktemp "${backlog}.XXXXXX") || return 1
6291
+ # FIX-106: same column-2 equality match as _loop_mark_in_progress.
5936
6292
  awk -v sid="$story_id" '
5937
6293
  {
5938
- if (index($0, sid) > 0 && index($0, "🔨 In Progress") > 0) {
5939
- sub(/🔨 In Progress/, "📋 Todo")
6294
+ if (index($0, "🔨 In Progress") > 0) {
6295
+ n = split($0, cols, "|")
6296
+ if (n >= 2) {
6297
+ id_cell = cols[2]
6298
+ gsub(/[[:space:]]/, "", id_cell)
6299
+ sub(/^\[/, "", id_cell)
6300
+ sub(/\].*$/, "", id_cell)
6301
+ if (id_cell == sid) {
6302
+ sub(/🔨 In Progress/, "📋 Todo")
6303
+ }
6304
+ }
5940
6305
  }
5941
6306
  print
5942
6307
  }
@@ -6344,14 +6709,29 @@ _claude_cleanup_stale_worktrees() {
6344
6709
  return 0
6345
6710
  }
6346
6711
 
6712
+ # FIX-104: scan multiple ephemeral prefixes (loop/cycle-, worktree-agent-,
6713
+ # claude/) and delete any already merged to origin/main. Unmerged branches
6714
+ # are preserved — they may be active WIP. Caller can pass a custom prefix
6715
+ # list via $2 (newline-separated `refs/heads/<prefix>*` patterns) but the
6716
+ # default whitelist covers every temp prefix the loop / Claude session /
6717
+ # worktree-agent paths create.
6347
6718
  _loop_cleanup_stale_cycle_branches() {
6348
6719
  local project_path="${1:-.}"
6349
6720
  local url; url=$(git -C "$project_path" remote get-url origin 2>/dev/null) || return 0
6350
6721
  [[ "$url" == *github.com* ]] || return 0
6351
6722
 
6352
- local branches
6353
- branches=$(git -C "$project_path" ls-remote --heads origin 'refs/heads/loop/cycle-*' 2>/dev/null \
6354
- | awk '{print $2}' | sed 's|^refs/heads/||')
6723
+ local prefixes="${2:-refs/heads/loop/cycle-*
6724
+ refs/heads/worktree-agent-*
6725
+ refs/heads/claude/*}"
6726
+
6727
+ local branches=""
6728
+ while IFS= read -r pat; do
6729
+ [ -z "$pat" ] && continue
6730
+ local found
6731
+ found=$(git -C "$project_path" ls-remote --heads origin "$pat" 2>/dev/null \
6732
+ | awk '{print $2}' | sed 's|^refs/heads/||')
6733
+ [ -n "$found" ] && branches+="${found}"$'\n'
6734
+ done <<< "$prefixes"
6355
6735
  [ -z "$branches" ] && return 0
6356
6736
 
6357
6737
  while IFS= read -r branch; do
@@ -6366,6 +6746,41 @@ _loop_cleanup_stale_cycle_branches() {
6366
6746
  return 0
6367
6747
  }
6368
6748
 
6749
+ # FIX-104: residual-visibility command. List origin's ephemeral temp branches
6750
+ # (loop/cycle-*, worktree-agent-*, claude/*) with their merge status so the
6751
+ # user can see what GC will clean up next cycle and what's still active WIP.
6752
+ # Output: TAB-separated `<branch>\t<merged|open>` lines, one per branch.
6753
+ # Silent on non-GitHub remote / empty / unreachable.
6754
+ _loop_branches() {
6755
+ local project_path="${1:-.}"
6756
+ local url; url=$(git -C "$project_path" remote get-url origin 2>/dev/null) || return 0
6757
+ [[ "$url" == *github.com* ]] || return 0
6758
+
6759
+ local prefixes="refs/heads/loop/cycle-*
6760
+ refs/heads/worktree-agent-*
6761
+ refs/heads/claude/*"
6762
+
6763
+ local branches=""
6764
+ while IFS= read -r pat; do
6765
+ [ -z "$pat" ] && continue
6766
+ local found
6767
+ found=$(git -C "$project_path" ls-remote --heads origin "$pat" 2>/dev/null \
6768
+ | awk '{print $2}' | sed 's|^refs/heads/||')
6769
+ [ -n "$found" ] && branches+="${found}"$'\n'
6770
+ done <<< "$prefixes"
6771
+ [ -z "$branches" ] && return 0
6772
+
6773
+ while IFS= read -r branch; do
6774
+ [ -z "$branch" ] && continue
6775
+ local status="open"
6776
+ if git -C "$project_path" merge-base --is-ancestor "$branch" origin/main 2>/dev/null; then
6777
+ status="merged"
6778
+ fi
6779
+ printf "%s\t%s\n" "$branch" "$status"
6780
+ done <<< "$branches"
6781
+ return 0
6782
+ }
6783
+
6369
6784
  # US-AUTO-033: publish a loop cycle branch as a GitHub PR with auto-merge.
6370
6785
  #
6371
6786
  # _loop_publish_pr <branch> [title]
@@ -6946,7 +7361,19 @@ cmd_ci() {
6946
7361
  # will switch to hard-fail. Output format mirrors a linter ("file:line:
6947
7362
  # message") so editors can navigate from it.
6948
7363
  _backlog_lint() {
6949
- local backlog="${1:-.roll/backlog.md}"
7364
+ # FIX-102: --gate flag flips Phase 1 warn-only behavior to hard-fail.
7365
+ # When passed, any violation makes the command exit 1 — used by the
7366
+ # PreToolUse / Stop hook in ~/.claude/settings.json to actually block
7367
+ # the assistant from leaving the backlog dirty.
7368
+ local gate=0
7369
+ local backlog=".roll/backlog.md"
7370
+ while [ $# -gt 0 ]; do
7371
+ case "$1" in
7372
+ --gate) gate=1 ;;
7373
+ *) backlog="$1" ;;
7374
+ esac
7375
+ shift
7376
+ done
6950
7377
  [ -f "$backlog" ] || { err "backlog not found: $backlog"; return 1; }
6951
7378
 
6952
7379
  local violations=0
@@ -6971,6 +7398,18 @@ _backlog_lint() {
6971
7398
  | sed -E 's|^\[[A-Z]+-[0-9]+\]\([^)]*\)[[:space:]]*||' \
6972
7399
  | sed -E 's|^[A-Z]+-[0-9]+[[:space:]]*||')
6973
7400
  local issues=""
7401
+ # FIX-102: length check — backlog rows are an index page; descriptions
7402
+ # must be one human sentence (≤120 chars). Longer = technical detail
7403
+ # that belongs in the linked .roll/features/<epic>/<slug>.md.
7404
+ if [ "${#body}" -gt 120 ]; then
7405
+ issues="${issues:+${issues}, }length>${#body}"
7406
+ fi
7407
+ # FIX-102: code-fence check — backticks (`code`) signal technical jargon
7408
+ # (commands, identifiers, paths). Keep description prose plain text;
7409
+ # any code goes in the feature file.
7410
+ if echo "$body" | grep -qF '`'; then
7411
+ issues="${issues:+${issues}, }code-fence"
7412
+ fi
6974
7413
  # Filenames: bare `something.ext` for common code/config extensions
6975
7414
  if echo "$body" | grep -qE '\b[A-Za-z_][A-Za-z0-9_.-]*\.(sh|bash|yaml|yml|json|js|ts|tsx|py|rb|go|rs|c|cpp|h)\b'; then
6976
7415
  issues="${issues:+${issues}, }filename"
@@ -6997,11 +7436,14 @@ _backlog_lint() {
6997
7436
  echo ""
6998
7437
  if [ "$violations" -gt 0 ]; then
6999
7438
  echo " ${violations} violation(s) — see conventions/global/AGENTS.md §4"
7439
+ if [ "$gate" = 1 ]; then
7440
+ echo " ${violations} 条违规 — --gate enabled, exiting 1"
7441
+ return 1
7442
+ fi
7000
7443
  echo " ${violations} 条违规 — Phase 1: warn-only, not blocking"
7001
7444
  else
7002
7445
  echo " No violations 无违规"
7003
7446
  fi
7004
- # Phase 1: warn-only. Exit 0 regardless.
7005
7447
  return 0
7006
7448
  }
7007
7449
 
@@ -7017,7 +7459,8 @@ cmd_backlog() {
7017
7459
  # ── Status management subcommands ─────────────────────────────────────────
7018
7460
  case "$subcmd" in
7019
7461
  lint)
7020
- _backlog_lint "$backlog"
7462
+ shift
7463
+ _backlog_lint "$@" "$backlog"
7021
7464
  return
7022
7465
  ;;
7023
7466
  block|defer|unblock|promote)