@seanyao/roll 2026.522.2 → 2026.523.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/bin/dream-test-quality-scan +110 -0
- package/bin/roll +761 -82
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/prices_fetcher.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/i18n.sh +113 -0
- package/lib/loop-fmt.py +62 -3
- package/lib/model_prices.py +78 -38
- package/lib/prices/snapshot-2026-05-22.json +20 -0
- package/lib/prices_fetcher.py +285 -0
- package/lib/roll-loop-status.py +88 -48
- package/lib/roll_render.py +20 -8
- package/package.json +1 -1
- package/skills/roll-.dream/SKILL.md +59 -0
- package/skills/roll-design/SKILL.md +4 -3
- package/skills/roll-notes/SKILL.md +6 -3
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.2"
|
|
8
8
|
ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
|
|
9
9
|
ROLL_CONFIG="${ROLL_HOME}/config.yaml"
|
|
10
10
|
ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
|
|
@@ -21,6 +21,10 @@ SCRIPT_DIR="$(cd "$(dirname "$_source")" && pwd)"
|
|
|
21
21
|
ROLL_PKG_DIR="$(dirname "$SCRIPT_DIR")"
|
|
22
22
|
ROLL_PKG_CONVENTIONS="${ROLL_PKG_DIR}/conventions"
|
|
23
23
|
|
|
24
|
+
# US-I18N-001: i18n engine (locale resolution + message catalog).
|
|
25
|
+
# shellcheck source=../lib/i18n.sh
|
|
26
|
+
[[ -f "${ROLL_PKG_DIR}/lib/i18n.sh" ]] && source "${ROLL_PKG_DIR}/lib/i18n.sh"
|
|
27
|
+
|
|
24
28
|
# Colors
|
|
25
29
|
RED=$'\033[0;31m'
|
|
26
30
|
GREEN=$'\033[0;32m'
|
|
@@ -322,16 +326,25 @@ safe_copy() {
|
|
|
322
326
|
if diff -q "$src" "$dst" &>/dev/null; then
|
|
323
327
|
return # identical, skip silently
|
|
324
328
|
fi
|
|
329
|
+
# Non-interactive (stdin is not a terminal): silently overwrite.
|
|
330
|
+
# _run_setup_step / cmd_update redirect stdin to /dev/null and all
|
|
331
|
+
# stdout/stderr is suppressed — prompting here would either hang on a
|
|
332
|
+
# hidden read or silently default to overwrite. Be explicit.
|
|
333
|
+
if [[ ! -t 0 ]]; then
|
|
334
|
+
cp "$src" "$dst"
|
|
335
|
+
ok "Wrote: ${dst/#$HOME/~} 已写入: ${dst/#$HOME/~}"
|
|
336
|
+
return
|
|
337
|
+
fi
|
|
325
338
|
echo ""
|
|
326
339
|
warn "File exists and differs: ${dst/#$HOME/~} 文件已存在且内容不同: ${dst/#$HOME/~}"
|
|
327
340
|
echo -e " ${BOLD}Overwrite?${NC} [Y/n/d(iff)] "
|
|
328
|
-
read -r answer
|
|
341
|
+
read -r answer || answer="Y"
|
|
329
342
|
case "$answer" in
|
|
330
343
|
d|D|diff)
|
|
331
344
|
diff --color=auto "$dst" "$src" || true
|
|
332
345
|
echo ""
|
|
333
346
|
echo -e " ${BOLD}Overwrite?${NC} [Y/n] "
|
|
334
|
-
read -r answer2
|
|
347
|
+
read -r answer2 || answer2="Y"
|
|
335
348
|
[[ "$answer2" =~ ^[Nn]$ ]] && { info "Skipped: ${dst/#$HOME/\~} 已跳过: ${dst/#$HOME/\~}"; return; }
|
|
336
349
|
;;
|
|
337
350
|
n|N) info "Skipped: ${dst/#$HOME/~} 已跳过: ${dst/#$HOME/~}"; return ;;
|
|
@@ -726,7 +739,7 @@ _run_setup_step() {
|
|
|
726
739
|
local watch="$1"; shift
|
|
727
740
|
local before after
|
|
728
741
|
before=$(_setup_snapshot "$watch")
|
|
729
|
-
if "$@" >/dev/null 2>&1; then
|
|
742
|
+
if "$@" </dev/null >/dev/null 2>&1; then
|
|
730
743
|
after=$(_setup_snapshot "$watch")
|
|
731
744
|
if [[ "$before" == "$after" ]]; then
|
|
732
745
|
_ROLL_SETUP_STATE="unchanged"
|
|
@@ -883,6 +896,40 @@ HINT
|
|
|
883
896
|
# in (or opted out) don't get spammed each upgrade.
|
|
884
897
|
cmd_doctor() {
|
|
885
898
|
_doctor_pr_section
|
|
899
|
+
_doctor_launchd_stale_section
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
# FIX-097: scan ${_LAUNCHD_DIR}/com.roll.*.plist for entries whose
|
|
903
|
+
# WorkingDirectory no longer exists on disk. These are the ghost agents left
|
|
904
|
+
# behind when a user manually reproduces a bug under /private/tmp/ or
|
|
905
|
+
# /var/folders/ — the auto-sandbox redirects plist writes but launchctl
|
|
906
|
+
# bootstrap (before this fix) registered them anyway. Print labels +
|
|
907
|
+
# cleanup hint; never auto-delete (host launchctl state is user-owned).
|
|
908
|
+
_doctor_launchd_stale_section() {
|
|
909
|
+
[[ "$(uname)" == "Darwin" ]] || return 0
|
|
910
|
+
local dir="${_LAUNCHD_DIR:-${HOME}/Library/LaunchAgents}"
|
|
911
|
+
[[ -d "$dir" ]] || return 0
|
|
912
|
+
|
|
913
|
+
local found=0 plist label wd
|
|
914
|
+
for plist in "$dir"/com.roll.*.plist; do
|
|
915
|
+
[[ -e "$plist" ]] || continue
|
|
916
|
+
wd=$(awk '
|
|
917
|
+
/<key>WorkingDirectory<\/key>/ { getline; gsub(/.*<string>|<\/string>.*/, ""); print; exit }
|
|
918
|
+
' "$plist" 2>/dev/null)
|
|
919
|
+
[[ -n "$wd" ]] || continue
|
|
920
|
+
[[ -d "$wd" ]] && continue
|
|
921
|
+
if [[ "$found" -eq 0 ]]; then
|
|
922
|
+
echo ""
|
|
923
|
+
echo "Stale launchd plists 无效的 launchd 服务"
|
|
924
|
+
echo ""
|
|
925
|
+
found=1
|
|
926
|
+
fi
|
|
927
|
+
label=$(basename "$plist" .plist)
|
|
928
|
+
echo " ⚠ ${label}"
|
|
929
|
+
echo " WorkingDirectory missing: ${wd}"
|
|
930
|
+
echo " 路径已失效,可清理: launchctl bootout gui/$(id -u)/${label}; rm '${plist}'"
|
|
931
|
+
done
|
|
932
|
+
return 0
|
|
886
933
|
}
|
|
887
934
|
|
|
888
935
|
_doctor_pr_section() {
|
|
@@ -1904,7 +1951,7 @@ PY
|
|
|
1904
1951
|
fi
|
|
1905
1952
|
if [ "${#plists[@]}" -gt 0 ]; then
|
|
1906
1953
|
for item in "${plists[@]}"; do
|
|
1907
|
-
|
|
1954
|
+
_launchctl_safe unload -w "$HOME/Library/LaunchAgents/$item" 2>/dev/null && echo " unloaded $item"
|
|
1908
1955
|
rm -f "$HOME/Library/LaunchAgents/$item" 2>/dev/null
|
|
1909
1956
|
done
|
|
1910
1957
|
fi
|
|
@@ -3577,6 +3624,107 @@ EOF
|
|
|
3577
3624
|
return "$rc"
|
|
3578
3625
|
}
|
|
3579
3626
|
|
|
3627
|
+
# ─── cmd_prices (US-VIEW-013) ─────────────────────────────────────────────────
|
|
3628
|
+
# `roll prices show` — print the current price snapshot table.
|
|
3629
|
+
# `roll prices refresh` — fetch live pricing docs, diff vs latest snapshot,
|
|
3630
|
+
# write a new snapshot when rates have changed.
|
|
3631
|
+
_prices_help() {
|
|
3632
|
+
cat <<'EOF'
|
|
3633
|
+
Usage: roll prices <subcommand> [--url URL]
|
|
3634
|
+
roll prices <子命令> [--url 网址]
|
|
3635
|
+
|
|
3636
|
+
Subcommands:
|
|
3637
|
+
show Print the current price snapshot table.
|
|
3638
|
+
显示当前价格快照表。
|
|
3639
|
+
refresh Fetch the official pricing docs, diff against the latest snapshot,
|
|
3640
|
+
and write a new snapshot only when rates have changed.
|
|
3641
|
+
拉取官方价格文档与最新快照对比,有变化才落新快照。
|
|
3642
|
+
EOF
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3645
|
+
cmd_prices_show() {
|
|
3646
|
+
local lib_dir="${ROLL_PKG_DIR}/lib"
|
|
3647
|
+
python3 - "$lib_dir" <<'PY'
|
|
3648
|
+
import json, os, sys
|
|
3649
|
+
lib_dir = sys.argv[1]
|
|
3650
|
+
sys.path.insert(0, lib_dir)
|
|
3651
|
+
import model_prices as mp
|
|
3652
|
+
|
|
3653
|
+
version, effective_at, source_url = mp.snapshot_meta()
|
|
3654
|
+
print(f"price snapshot 价格快照")
|
|
3655
|
+
print(f" version {version}")
|
|
3656
|
+
print(f" effective_at {effective_at}")
|
|
3657
|
+
print(f" source {source_url}")
|
|
3658
|
+
print(f" default_model {mp.DEFAULT}")
|
|
3659
|
+
print()
|
|
3660
|
+
print(f" {'model':<22}{'in':>8}{'out':>8}{'cw':>8}{'cr':>8}")
|
|
3661
|
+
for model in sorted(mp.PRICES):
|
|
3662
|
+
p = mp.PRICES[model]
|
|
3663
|
+
print(f" {model:<22}{p['in']:>8.2f}{p['out']:>8.2f}{p['cache_create']:>8.2f}{p['cache_read']:>8.2f}")
|
|
3664
|
+
print()
|
|
3665
|
+
print("rates per million tokens, USD 单位为每百万 token 美元")
|
|
3666
|
+
PY
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
cmd_prices_refresh() {
|
|
3670
|
+
local url=""
|
|
3671
|
+
while (( $# > 0 )); do
|
|
3672
|
+
case "$1" in
|
|
3673
|
+
--url) url="$2"; shift 2 ;;
|
|
3674
|
+
*) err "Unknown flag: $1 未知参数: $1"; return 1 ;;
|
|
3675
|
+
esac
|
|
3676
|
+
done
|
|
3677
|
+
local lib_dir="${ROLL_PKG_DIR}/lib"
|
|
3678
|
+
python3 - "$lib_dir" "$url" <<'PY'
|
|
3679
|
+
import os, sys
|
|
3680
|
+
lib_dir = sys.argv[1]
|
|
3681
|
+
override_url = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
3682
|
+
sys.path.insert(0, lib_dir)
|
|
3683
|
+
import prices_fetcher as pf
|
|
3684
|
+
|
|
3685
|
+
snapshot_dir = os.path.join(lib_dir, "prices")
|
|
3686
|
+
url = override_url or pf.DEFAULT_SOURCE_URL
|
|
3687
|
+
|
|
3688
|
+
try:
|
|
3689
|
+
action, changes = pf.refresh(snapshot_dir=snapshot_dir, url=url)
|
|
3690
|
+
except pf.FetchError as exc:
|
|
3691
|
+
print(f"[roll] fetch failed: {exc}", file=sys.stderr)
|
|
3692
|
+
print("[roll] keeping existing snapshot, no changes written 保留旧快照,未写入新文件",
|
|
3693
|
+
file=sys.stderr)
|
|
3694
|
+
sys.exit(2)
|
|
3695
|
+
except pf.ParseError as exc:
|
|
3696
|
+
print(f"[roll] parse failed: {exc}", file=sys.stderr)
|
|
3697
|
+
print("[roll] keeping existing snapshot, no changes written 保留旧快照,未写入新文件",
|
|
3698
|
+
file=sys.stderr)
|
|
3699
|
+
sys.exit(3)
|
|
3700
|
+
|
|
3701
|
+
kind, _, _ = (action + "::").split(":", 2)
|
|
3702
|
+
if action == "unchanged":
|
|
3703
|
+
print("[roll] up to date 价格快照已是最新")
|
|
3704
|
+
sys.exit(0)
|
|
3705
|
+
if kind == "first":
|
|
3706
|
+
print(f"[roll] baseline snapshot written 写入首份基线快照")
|
|
3707
|
+
elif kind == "written":
|
|
3708
|
+
print(f"[roll] new snapshot written 写入新版价格快照")
|
|
3709
|
+
print(pf.format_diff(changes, colored=sys.stdout.isatty()))
|
|
3710
|
+
PY
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
cmd_prices() {
|
|
3714
|
+
local subcmd="${1:-}"
|
|
3715
|
+
shift || true
|
|
3716
|
+
case "$subcmd" in
|
|
3717
|
+
show) cmd_prices_show "$@" ;;
|
|
3718
|
+
refresh) cmd_prices_refresh "$@" ;;
|
|
3719
|
+
--help|-h|help|"") _prices_help ;;
|
|
3720
|
+
*)
|
|
3721
|
+
err "Unknown subcommand: ${subcmd} 未知子命令:${subcmd}"
|
|
3722
|
+
_prices_help >&2
|
|
3723
|
+
return 1
|
|
3724
|
+
;;
|
|
3725
|
+
esac
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3580
3728
|
cmd_slides() {
|
|
3581
3729
|
local subcmd="${1:-}"
|
|
3582
3730
|
shift || true
|
|
@@ -3892,7 +4040,7 @@ PYEOF
|
|
|
3892
4040
|
|
|
3893
4041
|
local old_plist=~/Library/LaunchAgents/com.roll.loop.${old_slug}.plist
|
|
3894
4042
|
if [[ -f "$old_plist" ]]; then
|
|
3895
|
-
|
|
4043
|
+
_launchctl_safe unload "$old_plist" 2>/dev/null || true
|
|
3896
4044
|
rm -f "$old_plist"
|
|
3897
4045
|
fi
|
|
3898
4046
|
|
|
@@ -3969,6 +4117,13 @@ if [ -z "${_LAUNCHD_DIR:-}" ]; then
|
|
|
3969
4117
|
_LAUNCHD_DIR="${_SHARED_ROOT}/LaunchAgents"
|
|
3970
4118
|
mkdir -p "$_LAUNCHD_DIR"
|
|
3971
4119
|
export _LAUNCHD_DIR
|
|
4120
|
+
# FIX-097: same trigger that sandboxed the plist FILE path must also
|
|
4121
|
+
# short-circuit every `launchctl bootstrap/load/unload/enable` against
|
|
4122
|
+
# that path. Otherwise a user who reproduces a bug under /private/tmp/
|
|
4123
|
+
# or /var/folders/ ends up with sandboxed plists registered in their
|
|
4124
|
+
# real gui/<uid> domain — when the tmp dir is cleaned, the agents become
|
|
4125
|
+
# ghosts that fire forever (the historical 23:13 CST Terminal popup).
|
|
4126
|
+
export _LAUNCHD_SKIP_REGISTRY=1
|
|
3972
4127
|
fi
|
|
3973
4128
|
unset _roll_in_test_ctx _roll_caller
|
|
3974
4129
|
;;
|
|
@@ -4035,8 +4190,39 @@ _loop_event() {
|
|
|
4035
4190
|
esac
|
|
4036
4191
|
mkdir -p "$(dirname "$evfile")"
|
|
4037
4192
|
|
|
4038
|
-
#
|
|
4039
|
-
|
|
4193
|
+
# US-LOOP-007: human-friendly stdout for phase_* stages so tmux readers
|
|
4194
|
+
# spot phase boundaries amid claude output. Other stages keep the legacy
|
|
4195
|
+
# tab-separated format (consumers like tests grep on it).
|
|
4196
|
+
case "$stage" in
|
|
4197
|
+
phase_start)
|
|
4198
|
+
local _emoji
|
|
4199
|
+
case "$label" in
|
|
4200
|
+
startup) _emoji="🚀" ;;
|
|
4201
|
+
preflight) _emoji="🔍" ;;
|
|
4202
|
+
worktree_setup) _emoji="🌳" ;;
|
|
4203
|
+
claude_invoke) _emoji="🤖" ;;
|
|
4204
|
+
publish_push) _emoji="📤" ;;
|
|
4205
|
+
publish_wait_merge) _emoji="⏳" ;;
|
|
4206
|
+
cleanup) _emoji="🧹" ;;
|
|
4207
|
+
*) _emoji="▶" ;;
|
|
4208
|
+
esac
|
|
4209
|
+
printf '%s %-22s ─────────\n' "$_emoji" "$label"
|
|
4210
|
+
;;
|
|
4211
|
+
phase_tick)
|
|
4212
|
+
printf ' ⏱ %-20s ───── %s\n' "$label" "$detail"
|
|
4213
|
+
;;
|
|
4214
|
+
phase_end)
|
|
4215
|
+
local _mark
|
|
4216
|
+
case "$outcome" in
|
|
4217
|
+
fail) _mark="✗" ;;
|
|
4218
|
+
*) _mark="✓" ;;
|
|
4219
|
+
esac
|
|
4220
|
+
printf ' %s %-20s ───── %s\n' "$_mark" "$label" "$detail"
|
|
4221
|
+
;;
|
|
4222
|
+
*)
|
|
4223
|
+
printf '%s\t%s\t%s\t%s\t%s\n' "$ts" "$stage" "$label" "$detail" "$outcome"
|
|
4224
|
+
;;
|
|
4225
|
+
esac
|
|
4040
4226
|
|
|
4041
4227
|
# JSON line appended to NDJSON file. FIX-067: drop the flock/lockf guard.
|
|
4042
4228
|
# POSIX requires write() ≤ PIPE_BUF (≥512 bytes, 4 KiB on Linux/macOS) to
|
|
@@ -4095,6 +4281,25 @@ _launchd_label() {
|
|
|
4095
4281
|
printf 'com.roll.%s.%s' "$service" "$(_project_slug "$project_path")"
|
|
4096
4282
|
}
|
|
4097
4283
|
|
|
4284
|
+
# FIX-097: central skip predicate consulted by every launchctl invocation that
|
|
4285
|
+
# operates on a plist path Roll wrote. Returns 0 (skip) when either:
|
|
4286
|
+
# - explicit: _LAUNCHD_SKIP_REGISTRY=1 was exported (tests, future opt-out)
|
|
4287
|
+
# - implicit: _LAUNCHD_DIR is a child of _SHARED_ROOT (auto-sandbox active)
|
|
4288
|
+
# Returns 1 (do not skip) in production.
|
|
4289
|
+
#
|
|
4290
|
+
# History: FIX-090 introduced the same logic INSIDE _install_launchd_plists.
|
|
4291
|
+
# FIX-097 hoists it to a helper because the bootstrap call inside
|
|
4292
|
+
# _install_launchd_plists was not the only leak: _loop_on / _loop_off /
|
|
4293
|
+
# _loop_pause / _loop_resume each had bare `launchctl load/unload/enable`
|
|
4294
|
+
# calls that bypassed the gate.
|
|
4295
|
+
_launchd_should_skip_registry() {
|
|
4296
|
+
[[ "${_LAUNCHD_SKIP_REGISTRY:-}" == "1" ]] && return 0
|
|
4297
|
+
case "${_LAUNCHD_DIR:-}/" in
|
|
4298
|
+
"${_SHARED_ROOT:-/nonexistent}"/*) return 0 ;;
|
|
4299
|
+
esac
|
|
4300
|
+
return 1
|
|
4301
|
+
}
|
|
4302
|
+
|
|
4098
4303
|
_launchd_plist_path() {
|
|
4099
4304
|
local service="$1" project_path="$2"
|
|
4100
4305
|
printf '%s/%s.plist' "$_LAUNCHD_DIR" "$(_launchd_label "$service" "$project_path")"
|
|
@@ -4123,16 +4328,34 @@ _write_launchd_plist() {
|
|
|
4123
4328
|
;;
|
|
4124
4329
|
esac
|
|
4125
4330
|
|
|
4126
|
-
local hour_xml=""
|
|
4127
|
-
[[ -n "$hour" ]] && hour_xml=" <key>Hour</key>
|
|
4128
|
-
<integer>${hour}</integer>
|
|
4129
|
-
"
|
|
4130
|
-
|
|
4131
4331
|
# FIX-050: bake PATH into the plist so launchd-spawned bash can find tmux,
|
|
4132
4332
|
# claude, node, etc. The runner script also re-asserts PATH at runtime as
|
|
4133
4333
|
# a second layer (covers stale plists where brew was installed after setup).
|
|
4134
4334
|
local path_value; path_value=$(_detect_path_prepend)
|
|
4135
4335
|
|
|
4336
|
+
# FIX-105: macOS 26.4 launchd silently refuses to fire StartCalendarInterval
|
|
4337
|
+
# entries that contain BOTH Hour and Minute keys (verified: runs stays 0,
|
|
4338
|
+
# last exit "never exited", no log output, the calendarinterval trigger is
|
|
4339
|
+
# registered but never invoked by UserEventAgent-Aqua). Single-Minute (hourly)
|
|
4340
|
+
# entries still fire fine. Workaround: when an Hour is provided (daily
|
|
4341
|
+
# schedule), emit StartInterval=86400 (24h period) instead. First fire is
|
|
4342
|
+
# bootstrap+24h rather than the exact requested wall-clock time — acceptable
|
|
4343
|
+
# trade since the alternative was "never fires at all" (dream/brief broken
|
|
4344
|
+
# for 4+ days). The Minute/Hour args are still kept in the function signature
|
|
4345
|
+
# for callers that may want to filter at runtime, but they no longer steer
|
|
4346
|
+
# the plist trigger format for daily schedules.
|
|
4347
|
+
local schedule_xml
|
|
4348
|
+
if [[ -n "$hour" ]]; then
|
|
4349
|
+
schedule_xml=" <key>StartInterval</key>
|
|
4350
|
+
<integer>86400</integer>"
|
|
4351
|
+
else
|
|
4352
|
+
schedule_xml=" <key>StartCalendarInterval</key>
|
|
4353
|
+
<dict>
|
|
4354
|
+
<key>Minute</key>
|
|
4355
|
+
<integer>${minute}</integer>
|
|
4356
|
+
</dict>"
|
|
4357
|
+
fi
|
|
4358
|
+
|
|
4136
4359
|
local content
|
|
4137
4360
|
content="<?xml version=\"1.0\" encoding=\"UTF-8\"?>
|
|
4138
4361
|
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
|
|
@@ -4151,11 +4374,7 @@ _write_launchd_plist() {
|
|
|
4151
4374
|
<key>PATH</key>
|
|
4152
4375
|
<string>${path_value}</string>
|
|
4153
4376
|
</dict>
|
|
4154
|
-
|
|
4155
|
-
<dict>
|
|
4156
|
-
<key>Minute</key>
|
|
4157
|
-
<integer>${minute}</integer>
|
|
4158
|
-
${hour_xml} </dict>
|
|
4377
|
+
${schedule_xml}
|
|
4159
4378
|
<key>WorkingDirectory</key>
|
|
4160
4379
|
<string>${project_path}</string>
|
|
4161
4380
|
</dict>
|
|
@@ -4238,9 +4457,47 @@ fi
|
|
|
4238
4457
|
printf '%s:%s\n' "\$\$" "\$(date -u +%s)" > "\$INNER_LOCK"
|
|
4239
4458
|
# FIX-038: background heartbeat writer — outer script uses this as primary liveness signal
|
|
4240
4459
|
# to detect stale execution without relying on PID reuse heuristics.
|
|
4460
|
+
# US-LOOP-007: heartbeat also emits phase_tick for the current phase so tmux
|
|
4461
|
+
# readers see "still alive in <phase>" during long-running silences (e.g.
|
|
4462
|
+
# claude_invoke 5-45 min). CURRENT_PHASE is maintained by _phase_begin/_phase_end.
|
|
4241
4463
|
HEARTBEAT_FILE="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/.heartbeat-${slug}"
|
|
4464
|
+
CURRENT_PHASE=""
|
|
4465
|
+
# bash 3.2 (macOS /bin/bash) lacks associative arrays — use namespaced
|
|
4466
|
+
# variables via 'printf -v' + indirect '\${!VAR}' expansion instead.
|
|
4467
|
+
# _PHASE_START_<name> stores the unix-second start ts per phase
|
|
4468
|
+
# _PHASE_DUR_<name> stores the computed duration in seconds per phase
|
|
4469
|
+
# _PHASE_NAMES_DONE space-separated list of completed phase names, in order
|
|
4470
|
+
_PHASE_NAMES_DONE=""
|
|
4471
|
+
_phase_begin() {
|
|
4472
|
+
local _name="\$1"
|
|
4473
|
+
printf -v "_PHASE_START_\${_name}" '%s' "\$(date +%s)"
|
|
4474
|
+
CURRENT_PHASE="\$_name"
|
|
4475
|
+
_loop_event phase_start "\$_name" "" "" || true
|
|
4476
|
+
}
|
|
4477
|
+
_phase_end() {
|
|
4478
|
+
local _name="\$1" _outcome="\${2:-ok}"
|
|
4479
|
+
local _start_var="_PHASE_START_\${_name}"
|
|
4480
|
+
local _start="\${!_start_var:-\$(date +%s)}"
|
|
4481
|
+
local _dur=\$(( \$(date +%s) - _start ))
|
|
4482
|
+
[ "\$_dur" -lt 0 ] && _dur=0
|
|
4483
|
+
printf -v "_PHASE_DUR_\${_name}" '%s' "\$_dur"
|
|
4484
|
+
case " \$_PHASE_NAMES_DONE " in *" \$_name "*) ;; *) _PHASE_NAMES_DONE="\${_PHASE_NAMES_DONE} \$_name" ;; esac
|
|
4485
|
+
CURRENT_PHASE=""
|
|
4486
|
+
_loop_event phase_end "\$_name" "\${_dur}s" "\$_outcome" || true
|
|
4487
|
+
}
|
|
4242
4488
|
_heartbeat_writer() {
|
|
4243
|
-
while true; do
|
|
4489
|
+
while true; do
|
|
4490
|
+
echo "\$(date -u +%s)" > "\$HEARTBEAT_FILE"
|
|
4491
|
+
if [ -n "\$CURRENT_PHASE" ]; then
|
|
4492
|
+
local _start_var="_PHASE_START_\${CURRENT_PHASE}"
|
|
4493
|
+
local _phase_start="\${!_start_var:-}"
|
|
4494
|
+
if [ -n "\$_phase_start" ]; then
|
|
4495
|
+
local _el=\$(( \$(date +%s) - _phase_start ))
|
|
4496
|
+
_loop_event phase_tick "\$CURRENT_PHASE" "\${_el}s elapsed" "" 2>/dev/null || true
|
|
4497
|
+
fi
|
|
4498
|
+
fi
|
|
4499
|
+
sleep 60
|
|
4500
|
+
done
|
|
4244
4501
|
}
|
|
4245
4502
|
_heartbeat_writer &
|
|
4246
4503
|
_HEARTBEAT_PID=\$!
|
|
@@ -4260,8 +4517,65 @@ trap '_on_sigterm' TERM
|
|
|
4260
4517
|
# US-LOOP-005: idempotent runs.jsonl writer shared by normal exit, timeout
|
|
4261
4518
|
# trap, and worktree-setup-failure early exit. Guards on jq + run_id dedupe so
|
|
4262
4519
|
# multiple callers in the same cycle are safe.
|
|
4520
|
+
# US-LOOP-008: build a JSON object {"<phase>": <duration_sec>, ...} from
|
|
4521
|
+
# the ordered list of completed phases. Returns "{}" if no phases ran.
|
|
4522
|
+
_phases_to_json() {
|
|
4523
|
+
command -v jq >/dev/null 2>&1 || { printf '{}'; return 0; }
|
|
4524
|
+
local _name _var _dur _args="" _filter="{}"
|
|
4525
|
+
local _first=1
|
|
4526
|
+
for _name in \$_PHASE_NAMES_DONE; do
|
|
4527
|
+
[ -z "\$_name" ] && continue
|
|
4528
|
+
_var="_PHASE_DUR_\${_name}"
|
|
4529
|
+
_dur="\${!_var:-0}"
|
|
4530
|
+
if [ "\$_first" -eq 1 ]; then
|
|
4531
|
+
_filter="{\\"\${_name}\\": \\\$d_\${_name}}"
|
|
4532
|
+
_first=0
|
|
4533
|
+
else
|
|
4534
|
+
_filter="\${_filter} + {\\"\${_name}\\": \\\$d_\${_name}}"
|
|
4535
|
+
fi
|
|
4536
|
+
_args="\${_args} --argjson d_\${_name} \${_dur}"
|
|
4537
|
+
done
|
|
4538
|
+
if [ "\$_first" -eq 1 ]; then
|
|
4539
|
+
printf '{}'
|
|
4540
|
+
else
|
|
4541
|
+
eval "jq -nc \${_args} '\${_filter}'" 2>/dev/null || printf '{}'
|
|
4542
|
+
fi
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4545
|
+
# US-LOOP-008: print phase breakdown panel sorted by duration desc.
|
|
4546
|
+
# Idle/failed/aborted cycles only list phases that actually entered — no
|
|
4547
|
+
# placeholder rows. Panel is best-effort (skips silently if no phases).
|
|
4548
|
+
_print_phase_breakdown() {
|
|
4549
|
+
[ -n "\$_PHASE_NAMES_DONE" ] || return 0
|
|
4550
|
+
local _name _var _dur _total=0 _rows=""
|
|
4551
|
+
for _name in \$_PHASE_NAMES_DONE; do
|
|
4552
|
+
[ -z "\$_name" ] && continue
|
|
4553
|
+
_var="_PHASE_DUR_\${_name}"
|
|
4554
|
+
_dur="\${!_var:-0}"
|
|
4555
|
+
_total=\$(( _total + _dur ))
|
|
4556
|
+
_rows="\${_rows}\${_dur} \${_name}\n"
|
|
4557
|
+
done
|
|
4558
|
+
[ "\$_total" -le 0 ] && _total=1
|
|
4559
|
+
printf '\\n─── Cycle %s Phase Breakdown ───\\n' "\${CYCLE_ID:-unknown}"
|
|
4560
|
+
printf '%b' "\$_rows" | sort -rn | while read -r _d _n; do
|
|
4561
|
+
[ -z "\$_n" ] && continue
|
|
4562
|
+
local _pct=\$(( (_d * 1000) / _total ))
|
|
4563
|
+
local _pct_str
|
|
4564
|
+
_pct_str=\$(printf '%d.%d%%' \$(( _pct / 10 )) \$(( _pct % 10 )))
|
|
4565
|
+
local _bar="" _bar_len=\$(( (_d * 20) / _total ))
|
|
4566
|
+
[ "\$_bar_len" -gt 0 ] && _bar=\$(printf '█%.0s' \$(seq 1 \$_bar_len))
|
|
4567
|
+
printf ' %-22s %6ds (%6s) %s\\n' "\$_n" "\$_d" "\$_pct_str" "\$_bar"
|
|
4568
|
+
done
|
|
4569
|
+
printf ' %s\\n' "──────────────────────────────────────"
|
|
4570
|
+
printf ' %-22s %6ds\\n\\n' "Total" "\$_total"
|
|
4571
|
+
}
|
|
4572
|
+
|
|
4263
4573
|
_runs_append() {
|
|
4264
4574
|
local _status="\$1"; local _tcr="\${2:-0}"; local _built="\${3:-[]}"
|
|
4575
|
+
# bash parameter expansion \${4:-{}} stops at the first \} so the default
|
|
4576
|
+
# leaks a trailing \} into a real 4th arg ("{...}}"). Test explicit empty.
|
|
4577
|
+
local _phases_json="\${4:-}"
|
|
4578
|
+
[ -z "\$_phases_json" ] && _phases_json="{}"
|
|
4265
4579
|
local _runs_dst="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/runs.jsonl"
|
|
4266
4580
|
command -v jq >/dev/null 2>&1 || return 0
|
|
4267
4581
|
local _cid="\${CYCLE_ID:-pre-cycle-\$\$}"
|
|
@@ -4283,14 +4597,21 @@ _runs_append() {
|
|
|
4283
4597
|
--argjson alerts '[]' \\
|
|
4284
4598
|
--argjson tcr_count "\$_tcr" \\
|
|
4285
4599
|
--argjson duration_sec "\$_dur" \\
|
|
4600
|
+
--argjson phases "\$_phases_json" \\
|
|
4286
4601
|
'{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
|
|
4287
4602
|
cycle_id:\$cycle_id,
|
|
4288
4603
|
built:\$built, skipped:\$skipped, alerts:\$alerts,
|
|
4289
|
-
tcr_count:\$tcr_count, duration_sec:\$duration_sec}' \\
|
|
4604
|
+
tcr_count:\$tcr_count, duration_sec:\$duration_sec, phases:\$phases}' \\
|
|
4290
4605
|
>> "\$_runs_dst" 2>/dev/null || true
|
|
4291
4606
|
}
|
|
4292
4607
|
_inner_cleanup() {
|
|
4293
4608
|
local _rc=\$?
|
|
4609
|
+
# US-LOOP-007: close any CURRENT_PHASE that wasn't ended explicitly
|
|
4610
|
+
# (sigterm / set -e fire / aborted exit paths). Marks as fail so the
|
|
4611
|
+
# breakdown panel shows where we died.
|
|
4612
|
+
if [ -n "\${CURRENT_PHASE:-}" ]; then
|
|
4613
|
+
_phase_end "\$CURRENT_PHASE" fail 2>/dev/null || true
|
|
4614
|
+
fi
|
|
4294
4615
|
# Kill heartbeat + every remaining background job (watchdog, orphan
|
|
4295
4616
|
# loop-fmt.py, publish subshells) — bash's foreground 'wait' for the
|
|
4296
4617
|
# pipe has already returned by the time the EXIT trap runs.
|
|
@@ -4302,7 +4623,8 @@ _inner_cleanup() {
|
|
|
4302
4623
|
# US-LOOP-005 T9: timeout path must also write runs.jsonl row so dashboard
|
|
4303
4624
|
# has a terminal record (cycle_end alone is insufficient — runs.jsonl is
|
|
4304
4625
|
# the canonical history feed for 'roll loop runs').
|
|
4305
|
-
|
|
4626
|
+
_phases_t=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_t" ] && _phases_t='{}'
|
|
4627
|
+
_runs_append "failed" 0 "[]" "\$_phases_t" 2>/dev/null || true
|
|
4306
4628
|
_worktree_alert "cycle \${CYCLE_ID:-unknown}: \${LOOP_CYCLE_TIMEOUT_SEC}s timeout — claude/python killed; in-progress story marked Blocked" 2>/dev/null || true
|
|
4307
4629
|
fi
|
|
4308
4630
|
# FIX-086: aborted-path orphan safety net. When the inner script is killed
|
|
@@ -4329,13 +4651,30 @@ _inner_cleanup() {
|
|
|
4329
4651
|
# FIX-091: prefer a real PR so auto-merge lands the work; tag-only is the
|
|
4330
4652
|
# last-resort because it requires manual cherry-pick. Emit cycle_end "done"
|
|
4331
4653
|
# (canonical success status the dashboard recognizes) when PR publishes.
|
|
4654
|
+
# FIX-099: compute tcr_count + built[] from the worktree (it's still alive
|
|
4655
|
+
# at EXIT trap time) so runs.jsonl and ALERT carry truthful data.
|
|
4656
|
+
_orphan_tcr=0
|
|
4657
|
+
_orphan_built="[]"
|
|
4658
|
+
if command -v jq >/dev/null 2>&1; then
|
|
4659
|
+
_orphan_tcr=\$(cd "\$WT" && git log --oneline "origin/main..HEAD" 2>/dev/null | grep -c ' tcr:' || echo 0)
|
|
4660
|
+
_orphan_built=\$(cd "\$WT" && git log --oneline "origin/main..HEAD" 2>/dev/null \
|
|
4661
|
+
| grep ' tcr:' \
|
|
4662
|
+
| grep -oE '\b(FIX|US|REFACTOR|CHORE)-[0-9]+\b' \
|
|
4663
|
+
| sort -u \
|
|
4664
|
+
| jq -R -s 'split("\n") | map(select(length>0))' 2>/dev/null || echo "[]")
|
|
4665
|
+
fi
|
|
4332
4666
|
_slug=""
|
|
4333
4667
|
if _gh_resolve _slug \\
|
|
4334
4668
|
&& ( cd "\$WT" && _loop_publish_pr "\$BRANCH" "loop cycle \${CYCLE_ID}" ) >/dev/null 2>&1; then
|
|
4335
4669
|
_loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "done" 2>/dev/null || true
|
|
4336
4670
|
_CYCLE_END_WRITTEN=1
|
|
4337
|
-
|
|
4338
|
-
|
|
4671
|
+
# FIX-099: pass real tcr_count + built[] instead of 0/"[]"
|
|
4672
|
+
_phases_t=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_t" ] && _phases_t='{}'
|
|
4673
|
+
_runs_append "done" "\${_orphan_tcr}" "\${_orphan_built}" "\$_phases_t" 2>/dev/null || true
|
|
4674
|
+
# FIX-099: three-field ALERT so callers can distinguish recovered orphan
|
|
4675
|
+
# from a cycle's normally-picked story (was: "FIX-091 published as PR"
|
|
4676
|
+
# which leaked a hardcoded string regardless of what was actually built).
|
|
4677
|
+
_worktree_alert "cycle \${CYCLE_ID}: recovered_from_orphan=yes; tcr_commits=\${_orphan_tcr}; stories=\${_orphan_built}; pr_branch=\${BRANCH:-unknown}" 2>/dev/null || true
|
|
4339
4678
|
else
|
|
4340
4679
|
_orphan_tag="loop-orphan-\${CYCLE_ID}"
|
|
4341
4680
|
if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \\
|
|
@@ -4343,8 +4682,10 @@ _inner_cleanup() {
|
|
|
4343
4682
|
&& git push origin "\$_orphan_tag" 2>/dev/null ); then
|
|
4344
4683
|
_loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "orphan" 2>/dev/null || true
|
|
4345
4684
|
_CYCLE_END_WRITTEN=1
|
|
4346
|
-
|
|
4347
|
-
|
|
4685
|
+
# FIX-099: pass real tcr_count + built[] for the orphan-tag path too
|
|
4686
|
+
_phases_t=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_t" ] && _phases_t='{}'
|
|
4687
|
+
_runs_append "orphan" "\${_orphan_tcr}" "\${_orphan_built}" "\$_phases_t" 2>/dev/null || true
|
|
4688
|
+
_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
|
|
4348
4689
|
fi
|
|
4349
4690
|
fi
|
|
4350
4691
|
fi
|
|
@@ -4355,7 +4696,8 @@ _inner_cleanup() {
|
|
|
4355
4696
|
# "still running" until the next successful cycle rolls past it.
|
|
4356
4697
|
if [ "\${_CYCLE_END_WRITTEN:-0}" -eq 0 ] && [ -n "\${CYCLE_ID:-}" ]; then
|
|
4357
4698
|
_loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "aborted" 2>/dev/null || true
|
|
4358
|
-
|
|
4699
|
+
_phases_t=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_t" ] && _phases_t='{}'
|
|
4700
|
+
_runs_append "aborted" 0 "[]" "\$_phases_t" 2>/dev/null || true
|
|
4359
4701
|
fi
|
|
4360
4702
|
rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE"
|
|
4361
4703
|
exit "\$_rc"
|
|
@@ -4394,7 +4736,16 @@ CYCLE_START=\$(date +%s)
|
|
|
4394
4736
|
WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
|
|
4395
4737
|
BRANCH="loop/cycle-\${CYCLE_ID}"
|
|
4396
4738
|
_USE_WORKTREE=0
|
|
4739
|
+
# US-LOOP-007: startup phase covers env / lock / heartbeat setup. End it now
|
|
4740
|
+
# that cycle vars are bound and we're about to do real work.
|
|
4741
|
+
_phase_begin startup
|
|
4742
|
+
_phase_end startup ok
|
|
4743
|
+
_phase_begin preflight
|
|
4397
4744
|
cd "${project_path}" 2>/dev/null || true
|
|
4745
|
+
# FIX-104: GC stale merged temp branches at cycle entry — before worktree setup
|
|
4746
|
+
# and before any early-exit gate (pre-run abort, CI red precheck). The post-claude
|
|
4747
|
+
# call site doesn't cover those paths, so merged branches accumulated on origin.
|
|
4748
|
+
_loop_cleanup_stale_cycle_branches "${project_path}" || true
|
|
4398
4749
|
# FIX-040: orphan worktree recovery — scan for worktrees left by previous failed
|
|
4399
4750
|
# cycles (publish failed or inner script was SIGKILL'd). Attempt to publish each
|
|
4400
4751
|
# before starting the new cycle. Glob is chronological via timestamp in name.
|
|
@@ -4432,6 +4783,8 @@ done
|
|
|
4432
4783
|
# US-AUTO-038: snapshot orphan claude/* branches before claude runs so the
|
|
4433
4784
|
# post-claude cleanup can diff and delete only this session's additions.
|
|
4434
4785
|
CLAUDE_BRANCH_SNAPSHOT="\$(_claude_remote_snapshot "${project_path}")"
|
|
4786
|
+
_phase_end preflight ok
|
|
4787
|
+
_phase_begin worktree_setup
|
|
4435
4788
|
if _worktree_fetch_origin main \\
|
|
4436
4789
|
&& _worktree_create "\$WT" "\$BRANCH" "origin/main"; then
|
|
4437
4790
|
_USE_WORKTREE=1
|
|
@@ -4443,16 +4796,19 @@ if _worktree_fetch_origin main \\
|
|
|
4443
4796
|
_worktree_sync_meta "\$WT" 2>/dev/null || true
|
|
4444
4797
|
echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
|
|
4445
4798
|
_loop_event cycle_start "\${CYCLE_ID}" "" "" || true
|
|
4799
|
+
_phase_end worktree_setup ok
|
|
4446
4800
|
else
|
|
4447
4801
|
# P3 fix: skip the cycle entirely when worktree isolation fails.
|
|
4448
4802
|
# --dangerously-skip-permissions is only safe paired with worktree isolation;
|
|
4449
4803
|
# falling back to the main tree without isolation is unacceptable.
|
|
4450
4804
|
_worktree_alert "cycle \${CYCLE_ID}: worktree setup failed — skipping cycle to avoid running without isolation"
|
|
4451
4805
|
echo "[loop] cycle \${CYCLE_ID}: worktree setup failed; skipping cycle (no isolation)"
|
|
4806
|
+
_phase_end worktree_setup fail
|
|
4452
4807
|
# US-LOOP-005 T10: worktree-setup-failed path leaves no commits and never
|
|
4453
4808
|
# emits cycle_start, but dashboard still needs a runs.jsonl row marking the
|
|
4454
4809
|
# cycle as failed (otherwise the scheduled tick appears to have vanished).
|
|
4455
|
-
|
|
4810
|
+
_phases_t=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_t" ] && _phases_t='{}'
|
|
4811
|
+
_runs_append "failed" 0 "[]" "\$_phases_t" 2>/dev/null || true
|
|
4456
4812
|
exit 0
|
|
4457
4813
|
fi
|
|
4458
4814
|
|
|
@@ -4464,6 +4820,7 @@ FMT="${fmt_script}"
|
|
|
4464
4820
|
export LOOP_PROJECT_SLUG="${slug}"
|
|
4465
4821
|
export LOOP_CYCLE_ID="\${CYCLE_ID}"
|
|
4466
4822
|
export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
|
|
4823
|
+
_phase_begin claude_invoke
|
|
4467
4824
|
for _attempt in 1 2 3; do
|
|
4468
4825
|
# FIX-068: defensive reset before each attempt — _CYCLE_TIMED_OUT carries
|
|
4469
4826
|
# the SIGTERM result of the previous attempt and would otherwise force an
|
|
@@ -4501,6 +4858,12 @@ for _attempt in 1 2 3; do
|
|
|
4501
4858
|
fi
|
|
4502
4859
|
done
|
|
4503
4860
|
|
|
4861
|
+
if [ "\$_CYCLE_TIMED_OUT" -eq 1 ] || [ "\$_exit" -ne 0 ]; then
|
|
4862
|
+
_phase_end claude_invoke fail
|
|
4863
|
+
else
|
|
4864
|
+
_phase_end claude_invoke ok
|
|
4865
|
+
fi
|
|
4866
|
+
|
|
4504
4867
|
# FIX-057: timed out — skip publish; EXIT trap writes cycle_end blocked + ALERT.
|
|
4505
4868
|
if [ "\$_CYCLE_TIMED_OUT" -eq 1 ]; then
|
|
4506
4869
|
echo "[loop] cycle \${CYCLE_ID}: \${LOOP_CYCLE_TIMEOUT_SEC}s timeout — aborting cycle (worktree preserved at \$WT)"
|
|
@@ -4549,6 +4912,7 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
|
4549
4912
|
else
|
|
4550
4913
|
_is_doc_only=0
|
|
4551
4914
|
( cd "\$WT" && _loop_is_doc_only_change ) && _is_doc_only=1
|
|
4915
|
+
_phase_begin publish_push
|
|
4552
4916
|
if [ "\$_is_doc_only" -eq 1 ]; then
|
|
4553
4917
|
( cd "\$WT" && _loop_publish_doc_pr "\$BRANCH" "doc: loop cycle \${CYCLE_ID}" )
|
|
4554
4918
|
else
|
|
@@ -4556,18 +4920,25 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
|
4556
4920
|
fi
|
|
4557
4921
|
_publish_status=\$?
|
|
4558
4922
|
if [ "\$_publish_status" -eq 0 ]; then
|
|
4923
|
+
_phase_end publish_push ok
|
|
4559
4924
|
# FIX-047: CI green ≠ delivered — wait for actual PR merge before cycle complete
|
|
4560
4925
|
if [ "\$_is_doc_only" -eq 0 ]; then
|
|
4926
|
+
_phase_begin publish_wait_merge
|
|
4561
4927
|
if ! ( cd "\$WT" && _loop_wait_pr_merge "\$BRANCH" ); then
|
|
4562
4928
|
_worktree_alert "cycle \${CYCLE_ID}: FIX-047: PR not merged within timeout — code may not be in main (BRANCH=\${BRANCH})"
|
|
4929
|
+
_phase_end publish_wait_merge fail
|
|
4930
|
+
else
|
|
4931
|
+
_phase_end publish_wait_merge ok
|
|
4563
4932
|
fi
|
|
4564
4933
|
fi
|
|
4934
|
+
_phase_begin cleanup
|
|
4565
4935
|
# US-VIEW-011: emit terminal PR state (merged/closed/open) before cycle_end
|
|
4566
4936
|
# so dashboard renders #NN ✓/↩/… correctly. Must run while branch ref
|
|
4567
4937
|
# is still resolvable on remote — gh pr view <branch> needs the head ref.
|
|
4568
4938
|
_loop_emit_pr_final "\$BRANCH" 2>/dev/null || true
|
|
4569
4939
|
_worktree_cleanup "\$WT" "\$BRANCH"
|
|
4570
4940
|
_loop_event cycle_end "\${CYCLE_ID}" "" "done" || true; _CYCLE_END_WRITTEN=1
|
|
4941
|
+
_phase_end cleanup ok
|
|
4571
4942
|
echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
|
|
4572
4943
|
elif [ "\$_publish_status" -eq 2 ]; then
|
|
4573
4944
|
if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
|
|
@@ -4622,14 +4993,15 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
|
4622
4993
|
fi
|
|
4623
4994
|
fi
|
|
4624
4995
|
|
|
4625
|
-
# US-
|
|
4626
|
-
|
|
4627
|
-
|
|
4996
|
+
# US-LOOP-008: cycle Phase Breakdown panel — printed to stdout (tmux readers)
|
|
4997
|
+
# before runs.jsonl is appended, so the user sees timings even if jq is missing.
|
|
4998
|
+
_print_phase_breakdown 2>/dev/null || true
|
|
4999
|
+
_phases_for_runs=\$(_phases_to_json 2>/dev/null || echo '{}')
|
|
4628
5000
|
# FIX-044 / Step 5: Write loop cycle run summary to runs.jsonl
|
|
4629
5001
|
# Deterministic — runs in shell regardless of whether agent executes SKILL.md Step 5.
|
|
4630
5002
|
# US-LOOP-005: now routed through _runs_append so timeout/worktree-setup-fail
|
|
4631
5003
|
# share the same write logic. _runs_append is idempotent on run_id.
|
|
4632
|
-
_runs_append "\$_cycle_status" "\$_cycle_tcr" "\$_cycle_built" 2>/dev/null || true
|
|
5004
|
+
_runs_append "\$_cycle_status" "\$_cycle_tcr" "\$_cycle_built" "\$_phases_for_runs" 2>/dev/null || true
|
|
4633
5005
|
INNER
|
|
4634
5006
|
chmod +x "$inner_path"
|
|
4635
5007
|
|
|
@@ -4760,17 +5132,67 @@ SCRIPT
|
|
|
4760
5132
|
}
|
|
4761
5133
|
|
|
4762
5134
|
_launchd_is_loaded() {
|
|
4763
|
-
|
|
5135
|
+
# FIX-098: probe actual launchd registry via `launchctl print`, NOT
|
|
5136
|
+
# `launchctl print-disabled`. The disabled-overrides DB only tracks
|
|
5137
|
+
# labels explicitly enabled/disabled by the user — after `roll loop off`
|
|
5138
|
+
# (bootout) + `roll update` the label stays absent from the overrides DB,
|
|
5139
|
+
# so the old grep returned false-positive "loaded". `launchctl print`
|
|
5140
|
+
# returns exit 0 only when the agent is actually registered in the current
|
|
5141
|
+
# launchd session; non-zero means the label is unknown to launchd.
|
|
5142
|
+
launchctl print "gui/$(id -u)/$1" >/dev/null 2>&1
|
|
5143
|
+
}
|
|
5144
|
+
|
|
5145
|
+
# FIX-101 tripwire: refuse to mutate the host's launchd session when
|
|
5146
|
+
# _LAUNCHD_DIR has been sandboxed (i.e. is not the canonical
|
|
5147
|
+
# ${HOME}/Library/LaunchAgents). Tests that auto-sandbox _LAUNCHD_DIR for
|
|
5148
|
+
# isolation (FIX-087) may still forget to set _LAUNCHD_SKIP_REGISTRY=1 or
|
|
5149
|
+
# stub the launchctl binary; without this defensive layer the production
|
|
5150
|
+
# label's plist path can get overwritten with a transient sandbox path,
|
|
5151
|
+
# leading to launchd EX_CONFIG (exit 78) when the tmp dir is later cleaned
|
|
5152
|
+
# and the next scheduled fire can't find the plist. Read-only ops (print*,
|
|
5153
|
+
# list, version) are always allowed since they have no side effects.
|
|
5154
|
+
_launchctl_safe() {
|
|
5155
|
+
# Read-only ops are always safe (no host launchd state mutation).
|
|
5156
|
+
case "${1:-}" in
|
|
5157
|
+
print|print-disabled|list|version|dumpstate|examine)
|
|
5158
|
+
launchctl "$@"
|
|
5159
|
+
return $?
|
|
5160
|
+
;;
|
|
5161
|
+
esac
|
|
5162
|
+
# If `launchctl` has been replaced by a function stub (typical in bats tests
|
|
5163
|
+
# that want to assert against captured calls), pass through to the stub.
|
|
5164
|
+
# Stubs by definition don't touch host launchd, so this is safe; and tests
|
|
5165
|
+
# like `_install_launchd_plists: bootout targets gui/<uid>/<label>` rely on
|
|
5166
|
+
# the literal call landing in their captured log.
|
|
5167
|
+
if [[ "$(type -t launchctl 2>/dev/null)" == "function" ]]; then
|
|
5168
|
+
launchctl "$@"
|
|
5169
|
+
return $?
|
|
5170
|
+
fi
|
|
5171
|
+
# Real launchctl binary path: refuse to mutate when _LAUNCHD_DIR has been
|
|
5172
|
+
# sandboxed (i.e. is not the canonical ${HOME}/Library/LaunchAgents). This
|
|
5173
|
+
# is the FIX-101 defensive layer — when a test forgets to stub launchctl
|
|
5174
|
+
# AND has _LAUNCHD_DIR sandboxed, prevent the call from reaching the host's
|
|
5175
|
+
# production launchd and overwriting a live label's plist path.
|
|
5176
|
+
local canonical="${HOME}/Library/LaunchAgents"
|
|
5177
|
+
if [[ "${_LAUNCHD_DIR:-$canonical}" != "$canonical" ]]; then
|
|
5178
|
+
return 0
|
|
5179
|
+
fi
|
|
5180
|
+
launchctl "$@"
|
|
4764
5181
|
}
|
|
4765
5182
|
|
|
4766
5183
|
_launchd_svc_state() {
|
|
5184
|
+
# FIX-098: three-state classification:
|
|
5185
|
+
# enabled — plist on disk AND registered in launchd
|
|
5186
|
+
# stale — plist on disk BUT NOT registered in launchd
|
|
5187
|
+
# installed-off — kept for back-compat (maps to stale semantics)
|
|
5188
|
+
# not-installed — no plist
|
|
4767
5189
|
local svc="$1" project_path="$2"
|
|
4768
5190
|
local label; label=$(_launchd_label "$svc" "$project_path")
|
|
4769
5191
|
local plist; plist=$(_launchd_plist_path "$svc" "$project_path")
|
|
4770
5192
|
if _launchd_is_loaded "$label"; then
|
|
4771
5193
|
echo "enabled"
|
|
4772
5194
|
elif [[ -f "$plist" ]]; then
|
|
4773
|
-
echo "
|
|
5195
|
+
echo "stale"
|
|
4774
5196
|
else
|
|
4775
5197
|
echo "not-installed"
|
|
4776
5198
|
fi
|
|
@@ -4833,42 +5255,25 @@ _install_launchd_plists() {
|
|
|
4833
5255
|
local after; after=$(cat "$plist")
|
|
4834
5256
|
if [[ "$before" != "$after" ]]; then
|
|
4835
5257
|
updated=$((updated + 1))
|
|
4836
|
-
# FIX-090: gate launchctl writes
|
|
4837
|
-
# registered into the user's REAL gui/<uid>
|
|
4838
|
-
#
|
|
4839
|
-
|
|
4840
|
-
# the label collides with the dev's project slug, displaces the real
|
|
4841
|
-
# registration and kills the autonomous loop. Two gate paths:
|
|
4842
|
-
# - explicit: integration_setup exports _LAUNCHD_SKIP_REGISTRY=1
|
|
4843
|
-
# - implicit: if _LAUNCHD_DIR was auto-sandboxed under _SHARED_ROOT
|
|
4844
|
-
# (FIX-087 inner-runner.sh re-source path) we infer skip — callers
|
|
4845
|
-
# that genuinely want the launchctl flow override _LAUNCHD_DIR to
|
|
4846
|
-
# a path outside _SHARED_ROOT (unit tests; production has no
|
|
4847
|
-
# _SHARED_ROOT match against ~/Library/LaunchAgents).
|
|
4848
|
-
# See helpers.bash and tests/unit/launchd_sandbox.bats.
|
|
4849
|
-
local _skip_reg="${_LAUNCHD_SKIP_REGISTRY:-}"
|
|
4850
|
-
if [[ -z "$_skip_reg" ]]; then
|
|
4851
|
-
case "${_LAUNCHD_DIR:-}/" in
|
|
4852
|
-
"${_SHARED_ROOT:-/nonexistent}"/*) _skip_reg=1 ;;
|
|
4853
|
-
*) _skip_reg=0 ;;
|
|
4854
|
-
esac
|
|
4855
|
-
fi
|
|
4856
|
-
if [[ "$_skip_reg" != "1" ]]; then
|
|
5258
|
+
# FIX-090/FIX-097: gate launchctl writes via central helper so a
|
|
5259
|
+
# sandboxed plist never gets registered into the user's REAL gui/<uid>
|
|
5260
|
+
# domain. See _launchd_should_skip_registry for the predicate rules.
|
|
5261
|
+
if ! _launchd_should_skip_registry; then
|
|
4857
5262
|
if _launchd_is_loaded "$label"; then
|
|
4858
5263
|
# FIX-027: use bootout/bootstrap so we don't disturb the label's
|
|
4859
5264
|
# enabled flag in the launchd overrides db (which legacy
|
|
4860
5265
|
# unload/load no-`-w` wipes on macOS Sonoma+, causing
|
|
4861
5266
|
# `roll loop status` to falsely report off after `roll update`).
|
|
4862
5267
|
local uid; uid=$(id -u)
|
|
4863
|
-
|
|
4864
|
-
|
|
5268
|
+
_launchctl_safe bootout "gui/${uid}/${label}" 2>/dev/null || true
|
|
5269
|
+
_launchctl_safe bootstrap "gui/${uid}" "$plist" 2>/dev/null || true
|
|
4865
5270
|
elif [[ -z "$before" ]]; then
|
|
4866
5271
|
# FIX-059: brand-new plist — macOS FSEvents auto-bootstraps any new
|
|
4867
5272
|
# file dropped in ~/Library/LaunchAgents/, so projects never enabled
|
|
4868
5273
|
# via 'roll loop on' would fire every hour. Immediately mark disabled
|
|
4869
5274
|
# in the overrides db to block that auto-load.
|
|
4870
5275
|
local uid; uid=$(id -u)
|
|
4871
|
-
|
|
5276
|
+
_launchctl_safe disable "gui/${uid}/${label}" 2>/dev/null || true
|
|
4872
5277
|
fi
|
|
4873
5278
|
fi
|
|
4874
5279
|
fi
|
|
@@ -4925,7 +5330,8 @@ cmd_loop() {
|
|
|
4925
5330
|
notify) _notify "${1:-roll}" "${2:-}" ;;
|
|
4926
5331
|
enforce-tcr) _loop_enforce_tcr "${1:-}" "${2:-}" ;;
|
|
4927
5332
|
precheck-ci) _loop_precheck_ci ;;
|
|
4928
|
-
|
|
5333
|
+
branches) _loop_branches "$(pwd -P)" ;;
|
|
5334
|
+
*) 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 ;;
|
|
4929
5335
|
esac
|
|
4930
5336
|
}
|
|
4931
5337
|
|
|
@@ -4945,12 +5351,25 @@ _loop_on() {
|
|
|
4945
5351
|
if [[ "$(uname)" == "Darwin" ]]; then
|
|
4946
5352
|
_install_launchd_plists "$project_path" >/dev/null
|
|
4947
5353
|
|
|
5354
|
+
# FIX-098: use launchctl bootstrap/enable instead of load -w.
|
|
5355
|
+
# `load -w` writes to the disabled-overrides DB which causes FIX-027's
|
|
5356
|
+
# re-source to break after `roll update`. bootstrap is idem-potent and
|
|
5357
|
+
# does not disturb the overrides DB.
|
|
5358
|
+
local uid; uid=$(id -u)
|
|
4948
5359
|
local all_loaded=true
|
|
4949
5360
|
for svc in loop dream brief; do
|
|
4950
5361
|
local label; label=$(_launchd_label "$svc" "$project_path")
|
|
5362
|
+
local plist; plist=$(_launchd_plist_path "$svc" "$project_path")
|
|
4951
5363
|
if ! _launchd_is_loaded "$label"; then
|
|
4952
5364
|
all_loaded=false
|
|
4953
|
-
|
|
5365
|
+
# FIX-097 guard: skip real launchctl when _LAUNCHD_DIR was auto-sandboxed.
|
|
5366
|
+
_launchd_should_skip_registry && continue
|
|
5367
|
+
# FIX-098 semantic: enable+bootstrap pair (better than load -w).
|
|
5368
|
+
# enable clears any disable-override; bootstrap registers with launchd.
|
|
5369
|
+
# FIX-101 wrapper additionally tripwire-gates each call so a sandboxed
|
|
5370
|
+
# _LAUNCHD_DIR can't accidentally touch host launchd state.
|
|
5371
|
+
_launchctl_safe enable "gui/${uid}/${label}" 2>/dev/null || true
|
|
5372
|
+
_launchctl_safe bootstrap "gui/${uid}" "$plist" 2>/dev/null || true
|
|
4954
5373
|
fi
|
|
4955
5374
|
done
|
|
4956
5375
|
|
|
@@ -5002,11 +5421,15 @@ _loop_off() {
|
|
|
5002
5421
|
|
|
5003
5422
|
if [[ "$(uname)" == "Darwin" ]]; then
|
|
5004
5423
|
local any_loaded=false
|
|
5424
|
+
local _skip_off; _launchd_should_skip_registry && _skip_off=1 || _skip_off=0
|
|
5005
5425
|
for svc in loop dream brief; do
|
|
5006
5426
|
local label; label=$(_launchd_label "$svc" "$project_path")
|
|
5007
5427
|
if _launchd_is_loaded "$label"; then
|
|
5008
5428
|
any_loaded=true
|
|
5009
|
-
launchctl
|
|
5429
|
+
# FIX-097: skip real launchctl in sandbox to avoid touching the user's
|
|
5430
|
+
# real launchd registry.
|
|
5431
|
+
[[ "$_skip_off" == "1" ]] && continue
|
|
5432
|
+
_launchctl_safe unload -w "$(_launchd_plist_path "$svc" "$project_path")" 2>/dev/null || true
|
|
5010
5433
|
fi
|
|
5011
5434
|
done
|
|
5012
5435
|
if ! $any_loaded; then
|
|
@@ -5025,7 +5448,9 @@ _loop_off() {
|
|
|
5025
5448
|
# disable list, polluting `launchctl print-disabled` forever even after
|
|
5026
5449
|
# the project dir, plists, and ~/.roll are gone.
|
|
5027
5450
|
local label; label=$(_launchd_label "$svc" "$project_path")
|
|
5028
|
-
|
|
5451
|
+
# FIX-097: same gate — never touch host launchctl from a sandbox.
|
|
5452
|
+
[[ "$_skip_off" == "1" ]] && continue
|
|
5453
|
+
_launchctl_safe enable "gui/${uid}/${label}" 2>/dev/null || true
|
|
5029
5454
|
done
|
|
5030
5455
|
ok "Loop disabled 已停用"
|
|
5031
5456
|
return 0
|
|
@@ -5169,7 +5594,7 @@ _legacy_loop_status() {
|
|
|
5169
5594
|
else
|
|
5170
5595
|
case "$state" in
|
|
5171
5596
|
enabled) echo -e " ${GREEN}${svc} ● enabled${NC}" ;;
|
|
5172
|
-
installed-off) echo -e " ${YELLOW}${svc} ⚠
|
|
5597
|
+
stale|installed-off) echo -e " ${YELLOW}${svc} ⚠ STALE — plist present but not loaded${NC} run: roll loop on" ;;
|
|
5173
5598
|
not-installed) echo -e " ${RED}${svc} ○ not installed${NC} run: roll setup" ;;
|
|
5174
5599
|
esac
|
|
5175
5600
|
fi
|
|
@@ -5205,7 +5630,10 @@ _loop_pause() {
|
|
|
5205
5630
|
if ! _launchd_is_loaded "$label"; then
|
|
5206
5631
|
warn "Loop not enabled — nothing to pause loop 未启用,无需暂停"; return 0
|
|
5207
5632
|
fi
|
|
5208
|
-
|
|
5633
|
+
# FIX-097: never touch host launchctl from a sandboxed plist path.
|
|
5634
|
+
if ! _launchd_should_skip_registry; then
|
|
5635
|
+
_launchctl_safe unload -w "$(_launchd_plist_path "loop" "$project_path")" 2>/dev/null || true
|
|
5636
|
+
fi
|
|
5209
5637
|
else
|
|
5210
5638
|
local slug; slug=$(_project_slug "$project_path")
|
|
5211
5639
|
mkdir -p "${_SHARED_ROOT}/loop"
|
|
@@ -5225,8 +5653,9 @@ _loop_resume() {
|
|
|
5225
5653
|
if [[ "$(uname)" == "Darwin" ]]; then
|
|
5226
5654
|
local label; label=$(_launchd_label "loop" "$project_path")
|
|
5227
5655
|
local plist; plist=$(_launchd_plist_path "loop" "$project_path")
|
|
5228
|
-
if [[ -f "$plist" ]]; then
|
|
5229
|
-
launchctl
|
|
5656
|
+
if [[ -f "$plist" ]] && ! _launchd_should_skip_registry; then
|
|
5657
|
+
# FIX-097: never touch host launchctl from a sandboxed plist path.
|
|
5658
|
+
_launchctl_safe load -w "$plist" 2>/dev/null || true
|
|
5230
5659
|
fi
|
|
5231
5660
|
else
|
|
5232
5661
|
local slug; slug=$(_project_slug "$project_path")
|
|
@@ -5295,6 +5724,71 @@ _loop_attach() {
|
|
|
5295
5724
|
}
|
|
5296
5725
|
|
|
5297
5726
|
# Pretty-print a duration in seconds as "Xs" / "Ym" / "Yh Zm".
|
|
5727
|
+
# US-VIEW-019: compute slowest phase + % from a JSON line's phases object.
|
|
5728
|
+
# Returns "<abbr> <pct>%" (e.g. "claude 97%") or empty when no phases data.
|
|
5729
|
+
# Abbreviations match the AC: claude_invoke→claude, publish_wait_merge→pr-wait,
|
|
5730
|
+
# publish_push→publish, worktree_setup→worktree; others unchanged.
|
|
5731
|
+
_loop_runs_slowest_phase() {
|
|
5732
|
+
local line="$1"
|
|
5733
|
+
command -v jq >/dev/null 2>&1 || return 0
|
|
5734
|
+
local total max_name max_dur
|
|
5735
|
+
total=$(jq -r '(.phases // {}) | to_entries | map(.value) | add // 0' <<<"$line")
|
|
5736
|
+
[ -z "$total" ] || [ "$total" = "0" ] || [ "$total" = "null" ] && return 0
|
|
5737
|
+
max_name=$(jq -r '(.phases // {}) | to_entries | sort_by(-.value) | .[0].key // ""' <<<"$line")
|
|
5738
|
+
max_dur=$(jq -r '(.phases // {}) | to_entries | sort_by(-.value) | .[0].value // 0' <<<"$line")
|
|
5739
|
+
[ -z "$max_name" ] && return 0
|
|
5740
|
+
local abbr="$max_name"
|
|
5741
|
+
case "$max_name" in
|
|
5742
|
+
claude_invoke) abbr="claude" ;;
|
|
5743
|
+
publish_wait_merge) abbr="pr-wait" ;;
|
|
5744
|
+
publish_push) abbr="publish" ;;
|
|
5745
|
+
worktree_setup) abbr="worktree" ;;
|
|
5746
|
+
esac
|
|
5747
|
+
local pct=$(( (max_dur * 100 + total / 2) / total ))
|
|
5748
|
+
printf '%s %d%%' "$abbr" "$pct"
|
|
5749
|
+
}
|
|
5750
|
+
|
|
5751
|
+
# US-VIEW-019: render the full Phase Breakdown panel for a cycle by re-using
|
|
5752
|
+
# the same shape that the inner runner script prints. Reads runs.jsonl,
|
|
5753
|
+
# locates the row by cycle_id, prints sorted-desc table.
|
|
5754
|
+
_loop_runs_detail() {
|
|
5755
|
+
local cycle_id="$1"
|
|
5756
|
+
command -v jq >/dev/null 2>&1 || { err "jq required for --detail"; return 1; }
|
|
5757
|
+
if [[ ! -f "$_LOOP_RUNS" ]]; then
|
|
5758
|
+
echo "No runs.jsonl yet 尚无运行记录"
|
|
5759
|
+
return 0
|
|
5760
|
+
fi
|
|
5761
|
+
local row
|
|
5762
|
+
row=$(jq -c --arg cid "$cycle_id" 'select(.cycle_id == $cid)' "$_LOOP_RUNS" | head -1)
|
|
5763
|
+
if [[ -z "$row" ]]; then
|
|
5764
|
+
echo "Cycle not found: $cycle_id 未找到对应 cycle"
|
|
5765
|
+
return 1
|
|
5766
|
+
fi
|
|
5767
|
+
local has_phases
|
|
5768
|
+
has_phases=$(jq -r '(.phases // {}) | length' <<<"$row")
|
|
5769
|
+
if [[ "$has_phases" == "0" ]]; then
|
|
5770
|
+
echo "Cycle $cycle_id has no phases data (pre-US-LOOP-008) 无 phase 数据"
|
|
5771
|
+
return 0
|
|
5772
|
+
fi
|
|
5773
|
+
echo ""
|
|
5774
|
+
echo "─── Cycle $cycle_id Phase Breakdown ───"
|
|
5775
|
+
local total
|
|
5776
|
+
total=$(jq -r '(.phases // {}) | to_entries | map(.value) | add // 0' <<<"$row")
|
|
5777
|
+
[ "$total" -le 0 ] && total=1
|
|
5778
|
+
jq -r '(.phases // {}) | to_entries | sort_by(-.value) | .[] | "\(.value) \(.key)"' <<<"$row" \
|
|
5779
|
+
| while read -r dur name; do
|
|
5780
|
+
[[ -z "$name" ]] && continue
|
|
5781
|
+
local pct=$(( (dur * 1000) / total ))
|
|
5782
|
+
local pct_str
|
|
5783
|
+
pct_str=$(printf '%d.%d%%' $((pct / 10)) $((pct % 10)))
|
|
5784
|
+
local bar="" bar_len=$(( (dur * 20) / total ))
|
|
5785
|
+
[ "$bar_len" -gt 0 ] && bar=$(printf '█%.0s' $(seq 1 "$bar_len"))
|
|
5786
|
+
printf ' %-22s %6ds (%6s) %s\n' "$name" "$dur" "$pct_str" "$bar"
|
|
5787
|
+
done
|
|
5788
|
+
echo " ──────────────────────────────────────"
|
|
5789
|
+
printf ' %-22s %6ds\n\n' "Total" "$total"
|
|
5790
|
+
}
|
|
5791
|
+
|
|
5298
5792
|
_loop_runs_dur() {
|
|
5299
5793
|
local s="${1:-0}"
|
|
5300
5794
|
if [[ "$s" -lt 60 ]]; then printf "%ds" "$s"
|
|
@@ -5339,8 +5833,12 @@ _loop_runs_format_line() {
|
|
|
5339
5833
|
local skipped_note=""
|
|
5340
5834
|
[[ "$skipped_count" -gt 0 ]] && skipped_note=", ${skipped_count} skipped"
|
|
5341
5835
|
local items_word; [[ "$built_count" -eq 1 ]] && items_word="item" || items_word="items"
|
|
5342
|
-
|
|
5343
|
-
|
|
5836
|
+
# US-VIEW-019: append slowest phase summary when phases data is present.
|
|
5837
|
+
local slowest_note=""
|
|
5838
|
+
local slowest_str; slowest_str=$(_loop_runs_slowest_phase "$line")
|
|
5839
|
+
[[ -n "$slowest_str" ]] && slowest_note=", slowest=${slowest_str}"
|
|
5840
|
+
printf " %s %s✅ built %d %s (%d tcr%s, %s%s)\n" \
|
|
5841
|
+
"$hhmm" "$prefix" "$built_count" "$items_word" "$tcr" "$skipped_note" "$(_loop_runs_dur "$duration")" "$slowest_note"
|
|
5344
5842
|
local id desc
|
|
5345
5843
|
while IFS= read -r id; do
|
|
5346
5844
|
[[ -z "$id" ]] && continue
|
|
@@ -5377,15 +5875,23 @@ _loop_runs_format_line() {
|
|
|
5377
5875
|
|
|
5378
5876
|
# `roll loop runs [N] [--all]` — show recent loop iteration summaries.
|
|
5379
5877
|
_loop_runs() {
|
|
5380
|
-
local n=10 all_flag=false
|
|
5878
|
+
local n=10 all_flag=false detail_cycle=""
|
|
5381
5879
|
while [[ $# -gt 0 ]]; do
|
|
5382
5880
|
case "$1" in
|
|
5383
5881
|
--all) all_flag=true; shift ;;
|
|
5882
|
+
--detail) detail_cycle="${2:-}"; shift 2 ;;
|
|
5883
|
+
--detail=*) detail_cycle="${1#--detail=}"; shift ;;
|
|
5384
5884
|
[0-9]*) n="$1"; shift ;;
|
|
5385
5885
|
*) shift ;;
|
|
5386
5886
|
esac
|
|
5387
5887
|
done
|
|
5388
5888
|
|
|
5889
|
+
# US-VIEW-019: --detail <cycle_id> prints the Phase Breakdown panel.
|
|
5890
|
+
if [[ -n "$detail_cycle" ]]; then
|
|
5891
|
+
_loop_runs_detail "$detail_cycle"
|
|
5892
|
+
return $?
|
|
5893
|
+
fi
|
|
5894
|
+
|
|
5389
5895
|
# FIX-060: refresh merged status before reading, so paused-window merges show up.
|
|
5390
5896
|
_loop_backfill_merged >/dev/null 2>&1 || true
|
|
5391
5897
|
|
|
@@ -5562,15 +6068,28 @@ _loop_precheck_ci() {
|
|
|
5562
6068
|
|
|
5563
6069
|
local commit; commit=$(git rev-parse HEAD 2>/dev/null) || return 0
|
|
5564
6070
|
|
|
6071
|
+
# FIX-103: fetch both `status` and `conclusion`. Pre-run gate must distinguish
|
|
6072
|
+
# a still-running CI (status=in_progress/queued/waiting, conclusion=null) from
|
|
6073
|
+
# a genuinely red CI (conclusion=failure/cancelled/timed_out/...). Treating
|
|
6074
|
+
# in_progress as red kills every cycle started within the first ~30s of a
|
|
6075
|
+
# merge-triggered CI run.
|
|
5565
6076
|
local runs
|
|
5566
|
-
runs=$(gh -R "$slug" run list --commit "$commit" --json conclusion 2>/dev/null) || return 0
|
|
6077
|
+
runs=$(gh -R "$slug" run list --commit "$commit" --json conclusion,status 2>/dev/null) || return 0
|
|
5567
6078
|
[[ -z "$runs" || "$runs" == "[]" ]] && return 0
|
|
5568
6079
|
|
|
5569
|
-
|
|
5570
|
-
|
|
6080
|
+
# Conclusions that block the loop. Anything else (success, skipped, neutral,
|
|
6081
|
+
# or null while still running) is treated as pass/pending.
|
|
6082
|
+
local failed_conclusions
|
|
6083
|
+
failed_conclusions=$(echo "$runs" \
|
|
6084
|
+
| jq -r '[.[] | select(.conclusion=="failure" or .conclusion=="cancelled" or .conclusion=="timed_out" or .conclusion=="action_required" or .conclusion=="startup_failure") | .conclusion] | unique | join(",")' \
|
|
6085
|
+
2>/dev/null || echo "")
|
|
5571
6086
|
|
|
5572
|
-
if [[ "$
|
|
6087
|
+
if [[ -n "$failed_conclusions" ]]; then
|
|
5573
6088
|
local short; short=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
|
6089
|
+
local run_states
|
|
6090
|
+
run_states=$(echo "$runs" \
|
|
6091
|
+
| jq -r '[.[] | "\(.status // "?")/\(.conclusion // "null")"] | unique | join(", ")' \
|
|
6092
|
+
2>/dev/null || echo "?")
|
|
5574
6093
|
err "Pre-run CI check: HEAD CI is red — refuse to build on broken base (${short}) HEAD CI 红,拒绝在破损的基础上构建"
|
|
5575
6094
|
mkdir -p "$(dirname "$_LOOP_ALERT")"
|
|
5576
6095
|
cat > "$_LOOP_ALERT" << EOF
|
|
@@ -5579,6 +6098,8 @@ _loop_precheck_ci() {
|
|
|
5579
6098
|
**Time**: $(date '+%Y-%m-%d %H:%M')
|
|
5580
6099
|
**Commit**: ${short}
|
|
5581
6100
|
**Reason**: HEAD CI is red — loop refused to build on a broken base HEAD CI 红,loop 拒绝在破损的基础上构建
|
|
6101
|
+
**Failing conclusions**: ${failed_conclusions}
|
|
6102
|
+
**Run states**: ${run_states}
|
|
5582
6103
|
|
|
5583
6104
|
**Action required**:
|
|
5584
6105
|
- Investigate and fix CI: \`gh -R ${slug} run list --commit ${commit}\`
|
|
@@ -6082,10 +6603,24 @@ _loop_mark_in_progress() {
|
|
|
6082
6603
|
[ -n "$story_id" ] || return 1
|
|
6083
6604
|
[ -f "$backlog" ] || return 0
|
|
6084
6605
|
local tmp; tmp=$(mktemp "${backlog}.XXXXXX") || return 1
|
|
6606
|
+
# FIX-106: match the story-id column (col 2) for equality instead of doing
|
|
6607
|
+
# substring match on the whole row. Pre-fix, picking US-X-001 also flipped
|
|
6608
|
+
# any row whose description contained "depends-on:US-X-001" — leaving the
|
|
6609
|
+
# dashboard claiming work on stories no one had picked.
|
|
6085
6610
|
awk -v sid="$story_id" '
|
|
6086
6611
|
{
|
|
6087
|
-
if (index($0,
|
|
6088
|
-
|
|
6612
|
+
if (index($0, "📋 Todo") > 0) {
|
|
6613
|
+
n = split($0, cols, "|")
|
|
6614
|
+
if (n >= 2) {
|
|
6615
|
+
id_cell = cols[2]
|
|
6616
|
+
gsub(/[[:space:]]/, "", id_cell)
|
|
6617
|
+
# Markdown link form "[ID](path)" → keep just "ID"
|
|
6618
|
+
sub(/^\[/, "", id_cell)
|
|
6619
|
+
sub(/\].*$/, "", id_cell)
|
|
6620
|
+
if (id_cell == sid) {
|
|
6621
|
+
sub(/📋 Todo/, "🔨 In Progress")
|
|
6622
|
+
}
|
|
6623
|
+
}
|
|
6089
6624
|
}
|
|
6090
6625
|
print
|
|
6091
6626
|
}
|
|
@@ -6101,10 +6636,20 @@ _loop_mark_todo() {
|
|
|
6101
6636
|
[ -n "$story_id" ] || return 1
|
|
6102
6637
|
[ -f "$backlog" ] || return 0
|
|
6103
6638
|
local tmp; tmp=$(mktemp "${backlog}.XXXXXX") || return 1
|
|
6639
|
+
# FIX-106: same column-2 equality match as _loop_mark_in_progress.
|
|
6104
6640
|
awk -v sid="$story_id" '
|
|
6105
6641
|
{
|
|
6106
|
-
if (index($0,
|
|
6107
|
-
|
|
6642
|
+
if (index($0, "🔨 In Progress") > 0) {
|
|
6643
|
+
n = split($0, cols, "|")
|
|
6644
|
+
if (n >= 2) {
|
|
6645
|
+
id_cell = cols[2]
|
|
6646
|
+
gsub(/[[:space:]]/, "", id_cell)
|
|
6647
|
+
sub(/^\[/, "", id_cell)
|
|
6648
|
+
sub(/\].*$/, "", id_cell)
|
|
6649
|
+
if (id_cell == sid) {
|
|
6650
|
+
sub(/🔨 In Progress/, "📋 Todo")
|
|
6651
|
+
}
|
|
6652
|
+
}
|
|
6108
6653
|
}
|
|
6109
6654
|
print
|
|
6110
6655
|
}
|
|
@@ -6512,14 +7057,29 @@ _claude_cleanup_stale_worktrees() {
|
|
|
6512
7057
|
return 0
|
|
6513
7058
|
}
|
|
6514
7059
|
|
|
7060
|
+
# FIX-104: scan multiple ephemeral prefixes (loop/cycle-, worktree-agent-,
|
|
7061
|
+
# claude/) and delete any already merged to origin/main. Unmerged branches
|
|
7062
|
+
# are preserved — they may be active WIP. Caller can pass a custom prefix
|
|
7063
|
+
# list via $2 (newline-separated `refs/heads/<prefix>*` patterns) but the
|
|
7064
|
+
# default whitelist covers every temp prefix the loop / Claude session /
|
|
7065
|
+
# worktree-agent paths create.
|
|
6515
7066
|
_loop_cleanup_stale_cycle_branches() {
|
|
6516
7067
|
local project_path="${1:-.}"
|
|
6517
7068
|
local url; url=$(git -C "$project_path" remote get-url origin 2>/dev/null) || return 0
|
|
6518
7069
|
[[ "$url" == *github.com* ]] || return 0
|
|
6519
7070
|
|
|
6520
|
-
local
|
|
6521
|
-
|
|
6522
|
-
|
|
7071
|
+
local prefixes="${2:-refs/heads/loop/cycle-*
|
|
7072
|
+
refs/heads/worktree-agent-*
|
|
7073
|
+
refs/heads/claude/*}"
|
|
7074
|
+
|
|
7075
|
+
local branches=""
|
|
7076
|
+
while IFS= read -r pat; do
|
|
7077
|
+
[ -z "$pat" ] && continue
|
|
7078
|
+
local found
|
|
7079
|
+
found=$(git -C "$project_path" ls-remote --heads origin "$pat" 2>/dev/null \
|
|
7080
|
+
| awk '{print $2}' | sed 's|^refs/heads/||')
|
|
7081
|
+
[ -n "$found" ] && branches+="${found}"$'\n'
|
|
7082
|
+
done <<< "$prefixes"
|
|
6523
7083
|
[ -z "$branches" ] && return 0
|
|
6524
7084
|
|
|
6525
7085
|
while IFS= read -r branch; do
|
|
@@ -6534,6 +7094,41 @@ _loop_cleanup_stale_cycle_branches() {
|
|
|
6534
7094
|
return 0
|
|
6535
7095
|
}
|
|
6536
7096
|
|
|
7097
|
+
# FIX-104: residual-visibility command. List origin's ephemeral temp branches
|
|
7098
|
+
# (loop/cycle-*, worktree-agent-*, claude/*) with their merge status so the
|
|
7099
|
+
# user can see what GC will clean up next cycle and what's still active WIP.
|
|
7100
|
+
# Output: TAB-separated `<branch>\t<merged|open>` lines, one per branch.
|
|
7101
|
+
# Silent on non-GitHub remote / empty / unreachable.
|
|
7102
|
+
_loop_branches() {
|
|
7103
|
+
local project_path="${1:-.}"
|
|
7104
|
+
local url; url=$(git -C "$project_path" remote get-url origin 2>/dev/null) || return 0
|
|
7105
|
+
[[ "$url" == *github.com* ]] || return 0
|
|
7106
|
+
|
|
7107
|
+
local prefixes="refs/heads/loop/cycle-*
|
|
7108
|
+
refs/heads/worktree-agent-*
|
|
7109
|
+
refs/heads/claude/*"
|
|
7110
|
+
|
|
7111
|
+
local branches=""
|
|
7112
|
+
while IFS= read -r pat; do
|
|
7113
|
+
[ -z "$pat" ] && continue
|
|
7114
|
+
local found
|
|
7115
|
+
found=$(git -C "$project_path" ls-remote --heads origin "$pat" 2>/dev/null \
|
|
7116
|
+
| awk '{print $2}' | sed 's|^refs/heads/||')
|
|
7117
|
+
[ -n "$found" ] && branches+="${found}"$'\n'
|
|
7118
|
+
done <<< "$prefixes"
|
|
7119
|
+
[ -z "$branches" ] && return 0
|
|
7120
|
+
|
|
7121
|
+
while IFS= read -r branch; do
|
|
7122
|
+
[ -z "$branch" ] && continue
|
|
7123
|
+
local status="open"
|
|
7124
|
+
if git -C "$project_path" merge-base --is-ancestor "$branch" origin/main 2>/dev/null; then
|
|
7125
|
+
status="merged"
|
|
7126
|
+
fi
|
|
7127
|
+
printf "%s\t%s\n" "$branch" "$status"
|
|
7128
|
+
done <<< "$branches"
|
|
7129
|
+
return 0
|
|
7130
|
+
}
|
|
7131
|
+
|
|
6537
7132
|
# US-AUTO-033: publish a loop cycle branch as a GitHub PR with auto-merge.
|
|
6538
7133
|
#
|
|
6539
7134
|
# _loop_publish_pr <branch> [title]
|
|
@@ -6625,6 +7220,9 @@ _loop_wait_pr_merge() {
|
|
|
6625
7220
|
local elapsed=0
|
|
6626
7221
|
local slug; _gh_resolve slug || return 0
|
|
6627
7222
|
while (( elapsed < timeout )); do
|
|
7223
|
+
# US-LOOP-007: emit phase_tick at the top of each poll iteration so the
|
|
7224
|
+
# tmux reader sees "still waiting" every 30s during merge wait.
|
|
7225
|
+
(( elapsed > 0 )) && _loop_event phase_tick publish_wait_merge "${elapsed}s elapsed" "" 2>/dev/null || true
|
|
6628
7226
|
local state; state=$(gh -R "$slug" pr view "$branch" --json state -q .state 2>/dev/null || echo "UNKNOWN")
|
|
6629
7227
|
case "$state" in
|
|
6630
7228
|
MERGED) return 0 ;;
|
|
@@ -7076,6 +7674,57 @@ cmd_alert() {
|
|
|
7076
7674
|
esac
|
|
7077
7675
|
}
|
|
7078
7676
|
|
|
7677
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
7678
|
+
# LANG — switch / inspect Roll's UI language (US-I18N-001)
|
|
7679
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
7680
|
+
|
|
7681
|
+
cmd_lang() {
|
|
7682
|
+
local arg="${1:-}"
|
|
7683
|
+
|
|
7684
|
+
case "$arg" in
|
|
7685
|
+
"")
|
|
7686
|
+
unset ROLL_LANG_RESOLVED
|
|
7687
|
+
local current src
|
|
7688
|
+
current=$(_i18n_resolve_lang)
|
|
7689
|
+
if [[ -n "${ROLL_LANG:-}" ]]; then
|
|
7690
|
+
src="ROLL_LANG env"
|
|
7691
|
+
elif [[ -f "$ROLL_CONFIG" ]] && grep -qE '^lang:' "$ROLL_CONFIG"; then
|
|
7692
|
+
src="config (${ROLL_CONFIG})"
|
|
7693
|
+
elif [[ -n "${LC_ALL:-}" || -n "${LANG:-}" ]]; then
|
|
7694
|
+
src="LC_ALL/LANG"
|
|
7695
|
+
else
|
|
7696
|
+
src="default"
|
|
7697
|
+
fi
|
|
7698
|
+
echo "current: ${current}, source: ${src}"
|
|
7699
|
+
;;
|
|
7700
|
+
zh|en)
|
|
7701
|
+
mkdir -p "$(dirname "$ROLL_CONFIG")"
|
|
7702
|
+
[[ -f "$ROLL_CONFIG" ]] || : > "$ROLL_CONFIG"
|
|
7703
|
+
local tmp; tmp=$(mktemp)
|
|
7704
|
+
grep -vE '^lang:' "$ROLL_CONFIG" > "$tmp" || true
|
|
7705
|
+
printf 'lang: %s\n' "$arg" >> "$tmp"
|
|
7706
|
+
mv "$tmp" "$ROLL_CONFIG"
|
|
7707
|
+
unset ROLL_LANG_RESOLVED
|
|
7708
|
+
ok "Language set to ${arg} 语言已设置为 ${arg}"
|
|
7709
|
+
;;
|
|
7710
|
+
--reset)
|
|
7711
|
+
if [[ -f "$ROLL_CONFIG" ]]; then
|
|
7712
|
+
local tmp; tmp=$(mktemp)
|
|
7713
|
+
grep -vE '^lang:' "$ROLL_CONFIG" > "$tmp" || true
|
|
7714
|
+
mv "$tmp" "$ROLL_CONFIG"
|
|
7715
|
+
fi
|
|
7716
|
+
unset ROLL_LANG_RESOLVED
|
|
7717
|
+
ok "Language preference cleared (will follow locale) 语言偏好已清除(跟随系统 locale)"
|
|
7718
|
+
;;
|
|
7719
|
+
*)
|
|
7720
|
+
err "Unknown language: ${arg} 未知语言: ${arg}"
|
|
7721
|
+
echo " Valid values: zh, en, --reset"
|
|
7722
|
+
echo " 可选值: zh, en, --reset"
|
|
7723
|
+
return 1
|
|
7724
|
+
;;
|
|
7725
|
+
esac
|
|
7726
|
+
}
|
|
7727
|
+
|
|
7079
7728
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
7080
7729
|
|
|
7081
7730
|
cmd_ci() {
|
|
@@ -7114,7 +7763,19 @@ cmd_ci() {
|
|
|
7114
7763
|
# will switch to hard-fail. Output format mirrors a linter ("file:line:
|
|
7115
7764
|
# message") so editors can navigate from it.
|
|
7116
7765
|
_backlog_lint() {
|
|
7117
|
-
|
|
7766
|
+
# FIX-102: --gate flag flips Phase 1 warn-only behavior to hard-fail.
|
|
7767
|
+
# When passed, any violation makes the command exit 1 — used by the
|
|
7768
|
+
# PreToolUse / Stop hook in ~/.claude/settings.json to actually block
|
|
7769
|
+
# the assistant from leaving the backlog dirty.
|
|
7770
|
+
local gate=0
|
|
7771
|
+
local backlog=".roll/backlog.md"
|
|
7772
|
+
while [ $# -gt 0 ]; do
|
|
7773
|
+
case "$1" in
|
|
7774
|
+
--gate) gate=1 ;;
|
|
7775
|
+
*) backlog="$1" ;;
|
|
7776
|
+
esac
|
|
7777
|
+
shift
|
|
7778
|
+
done
|
|
7118
7779
|
[ -f "$backlog" ] || { err "backlog not found: $backlog"; return 1; }
|
|
7119
7780
|
|
|
7120
7781
|
local violations=0
|
|
@@ -7139,6 +7800,18 @@ _backlog_lint() {
|
|
|
7139
7800
|
| sed -E 's|^\[[A-Z]+-[0-9]+\]\([^)]*\)[[:space:]]*||' \
|
|
7140
7801
|
| sed -E 's|^[A-Z]+-[0-9]+[[:space:]]*||')
|
|
7141
7802
|
local issues=""
|
|
7803
|
+
# FIX-102: length check — backlog rows are an index page; descriptions
|
|
7804
|
+
# must be one human sentence (≤120 chars). Longer = technical detail
|
|
7805
|
+
# that belongs in the linked .roll/features/<epic>/<slug>.md.
|
|
7806
|
+
if [ "${#body}" -gt 120 ]; then
|
|
7807
|
+
issues="${issues:+${issues}, }length>${#body}"
|
|
7808
|
+
fi
|
|
7809
|
+
# FIX-102: code-fence check — backticks (`code`) signal technical jargon
|
|
7810
|
+
# (commands, identifiers, paths). Keep description prose plain text;
|
|
7811
|
+
# any code goes in the feature file.
|
|
7812
|
+
if echo "$body" | grep -qF '`'; then
|
|
7813
|
+
issues="${issues:+${issues}, }code-fence"
|
|
7814
|
+
fi
|
|
7142
7815
|
# Filenames: bare `something.ext` for common code/config extensions
|
|
7143
7816
|
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
|
|
7144
7817
|
issues="${issues:+${issues}, }filename"
|
|
@@ -7165,11 +7838,14 @@ _backlog_lint() {
|
|
|
7165
7838
|
echo ""
|
|
7166
7839
|
if [ "$violations" -gt 0 ]; then
|
|
7167
7840
|
echo " ${violations} violation(s) — see conventions/global/AGENTS.md §4"
|
|
7841
|
+
if [ "$gate" = 1 ]; then
|
|
7842
|
+
echo " ${violations} 条违规 — --gate enabled, exiting 1"
|
|
7843
|
+
return 1
|
|
7844
|
+
fi
|
|
7168
7845
|
echo " ${violations} 条违规 — Phase 1: warn-only, not blocking"
|
|
7169
7846
|
else
|
|
7170
7847
|
echo " No violations 无违规"
|
|
7171
7848
|
fi
|
|
7172
|
-
# Phase 1: warn-only. Exit 0 regardless.
|
|
7173
7849
|
return 0
|
|
7174
7850
|
}
|
|
7175
7851
|
|
|
@@ -7185,7 +7861,8 @@ cmd_backlog() {
|
|
|
7185
7861
|
# ── Status management subcommands ─────────────────────────────────────────
|
|
7186
7862
|
case "$subcmd" in
|
|
7187
7863
|
lint)
|
|
7188
|
-
|
|
7864
|
+
shift
|
|
7865
|
+
_backlog_lint "$@" "$backlog"
|
|
7189
7866
|
return
|
|
7190
7867
|
;;
|
|
7191
7868
|
block|defer|unblock|promote)
|
|
@@ -7842,11 +8519,13 @@ main() {
|
|
|
7842
8519
|
brief) cmd_brief "$@" ;;
|
|
7843
8520
|
backlog) cmd_backlog "$@" ;;
|
|
7844
8521
|
alert) cmd_alert "$@" ;;
|
|
8522
|
+
lang) cmd_lang "$@" ;;
|
|
7845
8523
|
agent) cmd_agent "$@" ;;
|
|
7846
8524
|
ci) cmd_ci "$@" ;;
|
|
7847
8525
|
doctor) cmd_doctor "$@" ;;
|
|
7848
8526
|
review-pr) cmd_review_pr "$@" ;;
|
|
7849
8527
|
slides) cmd_slides "$@" ;;
|
|
8528
|
+
prices) cmd_prices "$@" ;;
|
|
7850
8529
|
version|--version|-v) echo "roll v${VERSION}" ;;
|
|
7851
8530
|
help|--help|-h) _help "$@" ;;
|
|
7852
8531
|
"") [[ -f ".roll/backlog.md" ]] && _home || { _help; _show_changelog; } ;;
|