@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/CHANGELOG.md +36 -0
- package/bin/dream-test-quality-scan +110 -0
- package/bin/roll +527 -84
- package/conventions/config.yaml +1 -1
- package/conventions/global/AGENTS.md +1 -1
- package/conventions/global/GEMINI.md +8 -3
- package/conventions/templates/backend-service/GEMINI.md +3 -3
- package/conventions/templates/cli/GEMINI.md +3 -3
- package/conventions/templates/frontend-only/GEMINI.md +3 -3
- package/conventions/templates/fullstack/GEMINI.md +3 -3
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
- package/lib/loop-fmt.py +17 -3
- package/lib/model_prices.py +16 -5
- package/lib/roll-loop-status.py +92 -21
- package/lib/roll-peer.py +1 -1
- package/lib/roll-status.py +1 -1
- package/lib/roll_render.py +16 -3
- package/lib/slides/templates/introduction-v3.html +576 -0
- package/package.json +1 -1
- package/skills/roll-.dream/SKILL.md +59 -0
- package/skills/roll-deck/SKILL.md +22 -14
- package/skills/roll-design/SKILL.md +90 -3
- package/skills/roll-doctor/SKILL.md +1 -1
- package/skills/roll-notes/SKILL.md +6 -3
- package/skills/roll-onboard/SKILL.md +1 -1
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
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
|
-
|
|
2980
|
-
#
|
|
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=(
|
|
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}/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
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
|
-
|
|
4205
|
-
|
|
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
|
-
#
|
|
4592
|
-
#
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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} ⚠
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
5402
|
-
|
|
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 [[ "$
|
|
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,
|
|
5920
|
-
|
|
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,
|
|
5939
|
-
|
|
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
|
|
6353
|
-
|
|
6354
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7462
|
+
shift
|
|
7463
|
+
_backlog_lint "$@" "$backlog"
|
|
7021
7464
|
return
|
|
7022
7465
|
;;
|
|
7023
7466
|
block|defer|unblock|promote)
|