@seanyao/roll 2026.524.2 → 2026.525.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 +18 -8
- package/README.md +1 -0
- package/bin/roll +437 -85
- package/lib/__pycache__/roll-home.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/roll-home.py +55 -0
- package/lib/roll-loop-status.py +52 -0
- package/lib/roll_render.py +15 -1
- package/package.json +1 -1
- package/skills/roll-loop/SKILL.md +26 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,33 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## v2026.
|
|
3
|
+
## v2026.525.1
|
|
4
4
|
|
|
5
5
|
### Added
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- **`roll slides new` 可以用项目自己的模板** — 不只用内置的了 `[deck]`
|
|
9
|
-
- **`roll slides list` 一眼看出 slide 状态** — 哪些能看、哪些生成失败、失败原因也能查 `[deck]`
|
|
7
|
+
- loop 触发频率可以按项目单独设 — 每个项目能自定义间隔,不再全局一刀切 `[loop]`
|
|
10
8
|
|
|
11
9
|
### Fixed
|
|
12
10
|
|
|
13
|
-
-
|
|
14
|
-
-
|
|
11
|
+
- `roll` 主页不再把已启用的 loop 误报成"缺失" `[loop]`
|
|
12
|
+
- loop 弹窗关了也能翻回之前的输出 — 不再一关就全丢 `[loop]`
|
|
15
13
|
|
|
16
|
-
## v2026.524.
|
|
14
|
+
## v2026.524.2
|
|
17
15
|
|
|
18
16
|
### Added
|
|
19
17
|
|
|
20
18
|
- **`roll slides new` 不再像卡住** — 分阶段显示进度、当前步骤和耗时 `[deck]`
|
|
19
|
+
- **`roll slides new` 可以用项目自己的模板** — 不只用内置的了 `[deck]`
|
|
20
|
+
- **`roll slides list` 一眼看出 slide 状态** — 哪些能看、哪些生成失败、失败原因也能查 `[deck]`
|
|
21
|
+
- **`roll slides templates` 列出可用模板** — 内置和项目自定义的一眼可见 `[deck]`
|
|
22
|
+
- **`roll slides delete` 删除幻灯片** — 不再需要手动 rm `[deck]`
|
|
23
|
+
- **用 pi / deepseek / kimi 跑的 loop 也能在终端里实时看到进度** — 不再黑屏 `[loop]`
|
|
21
24
|
- **网站语言随浏览器自动切换** — 中文用户看到中文 `[i18n]`
|
|
22
25
|
|
|
23
26
|
### Fixed
|
|
24
27
|
|
|
25
|
-
-
|
|
28
|
+
- **loop 不再一次吞太多故事** — 每次只做一个,时间可预期 `[loop]`
|
|
26
29
|
- **loop 挂了会换备用** — 不再直接放弃 `[loop]`
|
|
27
30
|
- **loop 本地工作区不再堆积** — 合并后自动清理 `[loop]`
|
|
28
31
|
- **标进行中的故事不再永久卡住** — 超时恢复待办 `[loop]`
|
|
32
|
+
- **成本不再虚高** — 支持多模型真实价格 `[dashboard]`
|
|
33
|
+
- **dashboard 不再对非 Claude 模型显示空白** — 能看出每轮谁跑的 `[loop]`
|
|
29
34
|
- **主页 agent 标签不再骗人** — 显示真名 `[dashboard]`
|
|
30
35
|
|
|
36
|
+
### Docs
|
|
37
|
+
|
|
38
|
+
- **新增 `guide/{en,zh}/pricing.md`** — `roll prices` 命令用法、价格快照机制、历史成本固化语义;README 索引同步 `[pricing]`
|
|
39
|
+
- **FAQ 新增 A11** — 价格更新后历史 cycle 成本数字不会变 `[pricing]`
|
|
40
|
+
|
|
31
41
|
## v2026.523.2
|
|
32
42
|
|
|
33
43
|
### Added
|
package/README.md
CHANGED
|
@@ -69,6 +69,7 @@ roll loop on # let AI work through the backlog (optional)
|
|
|
69
69
|
| Configuration (env vars) | [guide/en/configuration.md](guide/en/configuration.md) | [guide/zh/configuration.md](guide/zh/configuration.md) |
|
|
70
70
|
| Skill selection guide | [guide/en/skills.md](guide/en/skills.md) | [guide/zh/skills.md](guide/zh/skills.md) |
|
|
71
71
|
| Slides (deck generator) | [guide/en/slides.md](guide/en/slides.md) | [guide/zh/slides.md](guide/zh/slides.md) |
|
|
72
|
+
| Pricing (cost visibility) | [guide/en/pricing.md](guide/en/pricing.md) | [guide/zh/pricing.md](guide/zh/pricing.md) |
|
|
72
73
|
| FAQ (troubleshooting) | [guide/en/faq.md](guide/en/faq.md) | [guide/zh/faq.md](guide/zh/faq.md) |
|
|
73
74
|
| Adoption patterns | [guide/en/patterns/](guide/en/patterns/) | [guide/zh/patterns/](guide/zh/patterns/) |
|
|
74
75
|
|
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.525.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"
|
|
@@ -2674,7 +2674,13 @@ _peer_auto_attach() {
|
|
|
2674
2674
|
[ "$(uname)" = "Darwin" ] || return 0
|
|
2675
2675
|
[ -f "$_LOOP_MUTE_FILE" ] && return 0
|
|
2676
2676
|
local attach_cmd="${_SHARED_ROOT}/loop/attach-${session}.command"
|
|
2677
|
-
|
|
2677
|
+
# Drop `exec` so the wrapping shell survives `tmux attach` exiting; pause
|
|
2678
|
+
# on `read` afterwards so the user can scroll back through the session's
|
|
2679
|
+
# output before closing the Terminal window. Without this the window
|
|
2680
|
+
# closes the instant the tmux session ends and the entire scrollback
|
|
2681
|
+
# disappears with it.
|
|
2682
|
+
printf '#!/bin/bash\ntmux attach -t %s\necho\necho "================================================================"\necho " session ended. press enter to close this window."\necho "================================================================"\nread _\n' \
|
|
2683
|
+
"$session" > "$attach_cmd" 2>/dev/null || return 0
|
|
2678
2684
|
chmod +x "$attach_cmd" 2>/dev/null || return 0
|
|
2679
2685
|
open -g -a Terminal "$attach_cmd" >/dev/null 2>&1 || true
|
|
2680
2686
|
}
|
|
@@ -3084,18 +3090,6 @@ _project_agent() {
|
|
|
3084
3090
|
fi
|
|
3085
3091
|
}
|
|
3086
3092
|
|
|
3087
|
-
_fallback_agent() {
|
|
3088
|
-
local pref new_pref
|
|
3089
|
-
new_pref=$(_project_agent_pref_file)
|
|
3090
|
-
if [[ -f "$new_pref" ]] && grep -q "^fallback_agent:" "$new_pref" 2>/dev/null; then
|
|
3091
|
-
grep "^fallback_agent:" "$new_pref" | awk '{print $2}' | tr -d '"' | head -1
|
|
3092
|
-
elif [[ -f ".roll.yaml" ]] && grep -q "^fallback_agent:" .roll.yaml 2>/dev/null; then
|
|
3093
|
-
grep "^fallback_agent:" .roll.yaml | awk '{print $2}' | tr -d '"' | head -1
|
|
3094
|
-
elif [[ -f "$ROLL_CONFIG" ]] && grep -q "fallback_agent:" "$ROLL_CONFIG" 2>/dev/null; then
|
|
3095
|
-
grep "fallback_agent:" "$ROLL_CONFIG" | awk '{print $2}' | tr -d '"' | head -1
|
|
3096
|
-
fi
|
|
3097
|
-
}
|
|
3098
|
-
|
|
3099
3093
|
_skill_content() {
|
|
3100
3094
|
# Strip YAML frontmatter (---...---) — it's roll-internal metadata, not agent instructions
|
|
3101
3095
|
awk 'NR==1 && /^---$/{skip=1;next} skip && /^---$/{skip=0;next} !skip{print}' "$1"
|
|
@@ -3349,12 +3343,19 @@ USAGE 用法
|
|
|
3349
3343
|
在浏览器中打开已渲染的幻灯片
|
|
3350
3344
|
roll slides logs <slug> Show the last build failure log for a deck
|
|
3351
3345
|
显示幻灯片上次构建失败日志
|
|
3346
|
+
roll slides templates List available slide templates (built-in + project)
|
|
3347
|
+
列出可用模板(内置 + 项目自定义)
|
|
3348
|
+
roll slides delete <slug> [--force]
|
|
3349
|
+
Delete a deck (dir + HTML) with confirmation prompt
|
|
3350
|
+
删除幻灯片(含目录与 HTML),需确认
|
|
3352
3351
|
|
|
3353
3352
|
OPTIONS 选项
|
|
3354
3353
|
--no-open Skip auto-opening the rendered HTML in a browser
|
|
3355
3354
|
渲染后不自动打开浏览器
|
|
3356
3355
|
--no-build Skip auto-build after agent completes (deck.md only)
|
|
3357
3356
|
仅生成 deck.md,不自动渲染
|
|
3357
|
+
--force Skip confirmation prompt (delete subcommand)
|
|
3358
|
+
跳过确认提示(delete 子命令)
|
|
3358
3359
|
--help, -h Show this help
|
|
3359
3360
|
显示本帮助
|
|
3360
3361
|
EOF
|
|
@@ -3724,6 +3725,114 @@ cmd_slides_logs() {
|
|
|
3724
3725
|
return 0
|
|
3725
3726
|
}
|
|
3726
3727
|
|
|
3728
|
+
# ─── US-DECK-014 ─────────────────────────────────────────────────────────────
|
|
3729
|
+
cmd_slides_delete() {
|
|
3730
|
+
local slug="" force=0
|
|
3731
|
+
while [[ $# -gt 0 ]]; do
|
|
3732
|
+
case "$1" in
|
|
3733
|
+
--force) force=1; shift ;;
|
|
3734
|
+
--help|-h) _slides_help; return 0 ;;
|
|
3735
|
+
--*) err "Unknown option: $1 未知选项: $1"; return 1 ;;
|
|
3736
|
+
*)
|
|
3737
|
+
if [[ -z "$slug" ]]; then
|
|
3738
|
+
slug="$1"; shift
|
|
3739
|
+
else
|
|
3740
|
+
err "Unexpected argument: $1 多余参数: $1"; return 1
|
|
3741
|
+
fi
|
|
3742
|
+
;;
|
|
3743
|
+
esac
|
|
3744
|
+
done
|
|
3745
|
+
|
|
3746
|
+
if [[ -z "$slug" ]]; then
|
|
3747
|
+
err "Usage: roll slides delete <slug> [--force]"
|
|
3748
|
+
echo "用法: roll slides delete <slug> [--force]" >&2
|
|
3749
|
+
return 1
|
|
3750
|
+
fi
|
|
3751
|
+
|
|
3752
|
+
local deck_dir=".roll/slides/${slug}"
|
|
3753
|
+
local html=".roll/slides/${slug}.html"
|
|
3754
|
+
|
|
3755
|
+
if [[ ! -d "$deck_dir" ]] || [[ ! -f "${deck_dir}/deck.md" ]]; then
|
|
3756
|
+
err "Deck not found: ${slug} 未找到幻灯片:${slug}"
|
|
3757
|
+
return 1
|
|
3758
|
+
fi
|
|
3759
|
+
|
|
3760
|
+
# Non-TTY must use --force (skip interactive confirmation)
|
|
3761
|
+
if [[ $force -eq 0 ]]; then
|
|
3762
|
+
if [[ ! -t 0 ]]; then
|
|
3763
|
+
err "Non-interactive terminal: must use --force to delete 非交互终端:需使用 --force 参数"
|
|
3764
|
+
return 1
|
|
3765
|
+
fi
|
|
3766
|
+
printf 'Delete deck "%s"? (y/N) 删除幻灯片 "%s"?(y/N) ' "$slug" "$slug" >&2
|
|
3767
|
+
read -r answer
|
|
3768
|
+
case "$answer" in
|
|
3769
|
+
[yY]|[yY][eE][sS]) : ;;
|
|
3770
|
+
*) info "Cancelled 已取消"; return 0 ;;
|
|
3771
|
+
esac
|
|
3772
|
+
fi
|
|
3773
|
+
|
|
3774
|
+
# Remove deck directory and HTML file
|
|
3775
|
+
rm -rf "$deck_dir" 2>/dev/null || true
|
|
3776
|
+
rm -f "$html" 2>/dev/null || true
|
|
3777
|
+
ok "Deleted ${slug} 已删除 ${slug}"
|
|
3778
|
+
return 0
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
# ─── US-DECK-014 ─────────────────────────────────────────────────────────────
|
|
3782
|
+
cmd_slides_templates() {
|
|
3783
|
+
while [[ $# -gt 0 ]]; do
|
|
3784
|
+
case "$1" in
|
|
3785
|
+
--help|-h) _slides_help; return 0 ;;
|
|
3786
|
+
--*) err "Unknown option: $1 未知选项: $1"; return 1 ;;
|
|
3787
|
+
*) err "Unexpected argument: $1 多余参数: $1"; return 1 ;;
|
|
3788
|
+
esac
|
|
3789
|
+
done
|
|
3790
|
+
|
|
3791
|
+
local seen=""
|
|
3792
|
+
local found=0
|
|
3793
|
+
local name base path source
|
|
3794
|
+
|
|
3795
|
+
printf '%-24s %-12s %s\n' "name" "source" "path"
|
|
3796
|
+
printf '%-24s %-12s %s\n' "----" "------" "----"
|
|
3797
|
+
|
|
3798
|
+
# Built-in templates (shipped with roll package)
|
|
3799
|
+
local builtin_dir="${ROLL_PKG_DIR}/lib/slides/templates"
|
|
3800
|
+
if [[ -d "$builtin_dir" ]]; then
|
|
3801
|
+
shopt -s nullglob
|
|
3802
|
+
for tpl in "$builtin_dir"/*.html; do
|
|
3803
|
+
name="${tpl##*/}"
|
|
3804
|
+
name="${name%.html}"
|
|
3805
|
+
printf '%-24s %-12s %s\n' "$name" "builtin" "$tpl"
|
|
3806
|
+
found=1
|
|
3807
|
+
done
|
|
3808
|
+
shopt -u nullglob
|
|
3809
|
+
fi
|
|
3810
|
+
|
|
3811
|
+
# Project-level overrides (.roll/slides/templates/)
|
|
3812
|
+
local proj_dir=".roll/slides/templates"
|
|
3813
|
+
if [[ -d "$proj_dir" ]]; then
|
|
3814
|
+
shopt -s nullglob
|
|
3815
|
+
for tpl in "$proj_dir"/*.html; do
|
|
3816
|
+
name="${tpl##*/}"
|
|
3817
|
+
name="${name%.html}"
|
|
3818
|
+
# Mark as project override if same name exists in builtin
|
|
3819
|
+
if [[ -f "${builtin_dir}/${name}.html" ]]; then
|
|
3820
|
+
source="project (override)"
|
|
3821
|
+
else
|
|
3822
|
+
source="project"
|
|
3823
|
+
fi
|
|
3824
|
+
printf '%-24s %-12s %s\n' "$name" "$source" "$tpl"
|
|
3825
|
+
found=1
|
|
3826
|
+
done
|
|
3827
|
+
shopt -u nullglob
|
|
3828
|
+
fi
|
|
3829
|
+
|
|
3830
|
+
if [[ $found -eq 0 ]]; then
|
|
3831
|
+
info "No templates found 无可用模板"
|
|
3832
|
+
fi
|
|
3833
|
+
return 0
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3727
3836
|
# ─── US-DECK-004 ─────────────────────────────────────────────────────────────
|
|
3728
3837
|
# Turn a topic string into a kebab-case slug.
|
|
3729
3838
|
# Lower-cases, replaces any run of non-alphanumerics with a single dash,
|
|
@@ -4083,6 +4192,12 @@ cmd_slides() {
|
|
|
4083
4192
|
logs)
|
|
4084
4193
|
cmd_slides_logs "$@"
|
|
4085
4194
|
;;
|
|
4195
|
+
templates)
|
|
4196
|
+
cmd_slides_templates "$@"
|
|
4197
|
+
;;
|
|
4198
|
+
delete)
|
|
4199
|
+
cmd_slides_delete "$@"
|
|
4200
|
+
;;
|
|
4086
4201
|
--help|-h|help)
|
|
4087
4202
|
_slides_help
|
|
4088
4203
|
return 0
|
|
@@ -4290,6 +4405,47 @@ _project_slug() {
|
|
|
4290
4405
|
if [[ -n "$_common" && "$_common" == *"/.git" ]]; then
|
|
4291
4406
|
path="${_common%/.git}"
|
|
4292
4407
|
fi
|
|
4408
|
+
|
|
4409
|
+
# US-OBS-010: derive slug from git remote URL for stable cross-machine
|
|
4410
|
+
# identity. Normalize: strip .git, git@HOST:PATH → https://HOST/PATH,
|
|
4411
|
+
# lowercase. Fallback chain: origin → first available remote → path-based.
|
|
4412
|
+
local remote_url
|
|
4413
|
+
remote_url=$(git -C "$path" remote get-url origin 2>/dev/null)
|
|
4414
|
+
if [[ -z "$remote_url" ]]; then
|
|
4415
|
+
local first_remote
|
|
4416
|
+
first_remote=$(git -C "$path" remote 2>/dev/null | head -1)
|
|
4417
|
+
if [[ -n "$first_remote" ]]; then
|
|
4418
|
+
remote_url=$(git -C "$path" remote get-url "$first_remote" 2>/dev/null)
|
|
4419
|
+
fi
|
|
4420
|
+
fi
|
|
4421
|
+
|
|
4422
|
+
if [[ -n "$remote_url" ]]; then
|
|
4423
|
+
remote_url="${remote_url%.git}"
|
|
4424
|
+
if [[ "$remote_url" =~ ^git@([^:]+):(.+)$ ]]; then
|
|
4425
|
+
remote_url="https://${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
|
|
4426
|
+
fi
|
|
4427
|
+
remote_url=$(printf '%s' "$remote_url" | tr '[:upper:]' '[:lower:]')
|
|
4428
|
+
local base; base=$(basename "$remote_url")
|
|
4429
|
+
local hash
|
|
4430
|
+
if command -v md5 &>/dev/null; then
|
|
4431
|
+
hash=$(printf '%s' "$remote_url" | md5 | cut -c1-6)
|
|
4432
|
+
else
|
|
4433
|
+
hash=$(printf '%s' "$remote_url" | md5sum | cut -c1-6)
|
|
4434
|
+
fi
|
|
4435
|
+
base=$(printf '%s' "$base" | tr -cs '[:alnum:]' '-' | sed 's/-*$//')
|
|
4436
|
+
printf '%s' "${base}-${hash}"
|
|
4437
|
+
return 0
|
|
4438
|
+
fi
|
|
4439
|
+
|
|
4440
|
+
# No remote available — fall back to path-based slug.
|
|
4441
|
+
# If roll_records_remote is configured, warn the user: the slug won't be
|
|
4442
|
+
# stable across machines, so cross-machine sync cannot work.
|
|
4443
|
+
local records_remote
|
|
4444
|
+
records_remote=$(config_get "roll_records_remote" "")
|
|
4445
|
+
if [[ -n "$records_remote" ]]; then
|
|
4446
|
+
printf 'roll: WARNING — roll_records_remote is configured but no git remote URL found; slug will fall back to path-based (cross-machine merge will not work)\n' >&2
|
|
4447
|
+
fi
|
|
4448
|
+
|
|
4293
4449
|
local base; base=$(basename "$path")
|
|
4294
4450
|
local hash
|
|
4295
4451
|
if command -v md5 &>/dev/null; then
|
|
@@ -4389,6 +4545,144 @@ PYEOF
|
|
|
4389
4545
|
rm -f "${loop_dir}/run-${old_slug}.sh" "${loop_dir}/run-${old_slug}-inner.sh"
|
|
4390
4546
|
}
|
|
4391
4547
|
|
|
4548
|
+
# US-OBS-010: path-based slug (without remote URL) — used to detect the
|
|
4549
|
+
# pre-remote slug so migration can merge old records into the new identity.
|
|
4550
|
+
_project_slug_path_based() {
|
|
4551
|
+
local path="${1:-$(pwd -P 2>/dev/null || pwd)}"
|
|
4552
|
+
if [[ "$(uname -s 2>/dev/null)" == "Darwin" ]]; then
|
|
4553
|
+
local _canon
|
|
4554
|
+
_canon=$(realpath "$path" 2>/dev/null) && path="$_canon"
|
|
4555
|
+
fi
|
|
4556
|
+
local _common
|
|
4557
|
+
_common=$(git -C "$path" rev-parse --git-common-dir 2>/dev/null)
|
|
4558
|
+
if [[ -n "$_common" && "$_common" == *"/.git" ]]; then
|
|
4559
|
+
path="${_common%/.git}"
|
|
4560
|
+
fi
|
|
4561
|
+
local base; base=$(basename "$path")
|
|
4562
|
+
local hash
|
|
4563
|
+
if command -v md5 &>/dev/null; then
|
|
4564
|
+
hash=$(printf '%s' "$path" | md5 | cut -c1-6)
|
|
4565
|
+
else
|
|
4566
|
+
hash=$(printf '%s' "$path" | md5sum | cut -c1-6)
|
|
4567
|
+
fi
|
|
4568
|
+
base=$(printf '%s' "$base" | tr -cs '[:alnum:]' '-' | sed 's/-*$//')
|
|
4569
|
+
printf '%s' "${base}-${hash}"
|
|
4570
|
+
}
|
|
4571
|
+
|
|
4572
|
+
# US-OBS-010: migrate loop records from old path-based slug to new
|
|
4573
|
+
# remote-based slug. Dedup by run_id; atomic cp→tmp→mv; keep old as .bak.
|
|
4574
|
+
#
|
|
4575
|
+
# Usage: _slug_migrate_to_remote <project_path> [<loop_dir>]
|
|
4576
|
+
_slug_migrate_to_remote() {
|
|
4577
|
+
local project_path="$1"
|
|
4578
|
+
local loop_dir="${2:-${_SHARED_ROOT}/loop}"
|
|
4579
|
+
|
|
4580
|
+
local new_slug; new_slug=$(_project_slug "$project_path")
|
|
4581
|
+
local old_slug; old_slug=$(_project_slug_path_based "$project_path")
|
|
4582
|
+
|
|
4583
|
+
# Dedup guard: no migration needed
|
|
4584
|
+
[[ "$old_slug" == "$new_slug" ]] && return 0
|
|
4585
|
+
[[ -f "${loop_dir}/events-${old_slug}.ndjson" ]] || return 0
|
|
4586
|
+
|
|
4587
|
+
printf 'roll: migrating loop records %s → %s (cross-machine slug)\n' "$old_slug" "$new_slug" >&2
|
|
4588
|
+
|
|
4589
|
+
# Migrate events with run_id dedup + atomic write
|
|
4590
|
+
local events_file="${loop_dir}/events-${new_slug}.ndjson"
|
|
4591
|
+
local tmp_events; tmp_events=$(mktemp)
|
|
4592
|
+
|
|
4593
|
+
# Collect existing run_ids from new slug file
|
|
4594
|
+
local existing_ids=""
|
|
4595
|
+
if [[ -f "$events_file" ]]; then
|
|
4596
|
+
existing_ids=$(python3 -c "
|
|
4597
|
+
import json, sys
|
|
4598
|
+
try:
|
|
4599
|
+
with open('$events_file') as f:
|
|
4600
|
+
for line in f:
|
|
4601
|
+
d = json.loads(line.strip())
|
|
4602
|
+
if 'label' in d:
|
|
4603
|
+
print(d['label'])
|
|
4604
|
+
except: pass
|
|
4605
|
+
" 2>/dev/null)
|
|
4606
|
+
fi
|
|
4607
|
+
|
|
4608
|
+
# Append old events to new, dedup by label (run_id / cycle_id)
|
|
4609
|
+
python3 - "$old_slug" "$events_file" "$tmp_events" << 'PYEOF'
|
|
4610
|
+
import json, sys
|
|
4611
|
+
old_slug, new_file, tmp_file = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
4612
|
+
|
|
4613
|
+
# Read existing new-file ids
|
|
4614
|
+
seen = set()
|
|
4615
|
+
try:
|
|
4616
|
+
with open(new_file) as f:
|
|
4617
|
+
for line in f:
|
|
4618
|
+
d = json.loads(line.strip())
|
|
4619
|
+
if 'label' in d:
|
|
4620
|
+
seen.add(d['label'])
|
|
4621
|
+
except FileNotFoundError:
|
|
4622
|
+
pass
|
|
4623
|
+
|
|
4624
|
+
# Append old events, deduped
|
|
4625
|
+
old_file = new_file.replace(new_file.split('-')[-1].split('.')[0], old_slug)
|
|
4626
|
+
with open(tmp_file, 'w') as out:
|
|
4627
|
+
# Copy existing new file
|
|
4628
|
+
try:
|
|
4629
|
+
with open(new_file) as f:
|
|
4630
|
+
out.write(f.read())
|
|
4631
|
+
except FileNotFoundError:
|
|
4632
|
+
pass
|
|
4633
|
+
# Append old events not yet seen
|
|
4634
|
+
old_path = '/'.join(new_file.rsplit('/', 1)[:-1] + [f'events-{old_slug}.ndjson'])
|
|
4635
|
+
try:
|
|
4636
|
+
with open(old_path) as f:
|
|
4637
|
+
for line in f:
|
|
4638
|
+
line = line.strip()
|
|
4639
|
+
if not line:
|
|
4640
|
+
continue
|
|
4641
|
+
d = json.loads(line)
|
|
4642
|
+
lid = d.get('label', '')
|
|
4643
|
+
if lid not in seen:
|
|
4644
|
+
seen.add(lid)
|
|
4645
|
+
out.write(json.dumps(d) + '\n')
|
|
4646
|
+
except FileNotFoundError:
|
|
4647
|
+
pass
|
|
4648
|
+
PYEOF
|
|
4649
|
+
|
|
4650
|
+
# Atomic replace
|
|
4651
|
+
mv "$tmp_events" "$events_file"
|
|
4652
|
+
|
|
4653
|
+
# Keep old events as .bak (do not delete)
|
|
4654
|
+
cp "${loop_dir}/events-${old_slug}.ndjson" "${loop_dir}/events-${old_slug}.ndjson.bak"
|
|
4655
|
+
rm "${loop_dir}/events-${old_slug}.ndjson"
|
|
4656
|
+
|
|
4657
|
+
# Migrate runs.jsonl: rewrite project field + dedup by run_id
|
|
4658
|
+
local runs_file="${loop_dir}/runs.jsonl"
|
|
4659
|
+
if [[ -f "$runs_file" ]]; then
|
|
4660
|
+
local tmp; tmp=$(mktemp)
|
|
4661
|
+
python3 - "$old_slug" "$new_slug" "$runs_file" > "$tmp" << 'PYEOF'
|
|
4662
|
+
import json, sys
|
|
4663
|
+
old, new, path = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
4664
|
+
seen = set()
|
|
4665
|
+
with open(path) as f:
|
|
4666
|
+
for line in f:
|
|
4667
|
+
line = line.rstrip('\n')
|
|
4668
|
+
if not line:
|
|
4669
|
+
continue
|
|
4670
|
+
try:
|
|
4671
|
+
d = json.loads(line)
|
|
4672
|
+
if 'project' in d and old in str(d['project']):
|
|
4673
|
+
d['project'] = str(d['project']).replace(old, new)
|
|
4674
|
+
rid = d.get('run_id', '')
|
|
4675
|
+
if rid in seen:
|
|
4676
|
+
continue
|
|
4677
|
+
seen.add(rid)
|
|
4678
|
+
print(json.dumps(d))
|
|
4679
|
+
except Exception:
|
|
4680
|
+
print(line)
|
|
4681
|
+
PYEOF
|
|
4682
|
+
mv "$tmp" "$runs_file"
|
|
4683
|
+
fi
|
|
4684
|
+
}
|
|
4685
|
+
|
|
4392
4686
|
_LOOP_TAG="# roll-loop"
|
|
4393
4687
|
# FIX-065: when sourced in a test context with no explicit override, route
|
|
4394
4688
|
# shared state into a per-process /tmp path instead of falling back to
|
|
@@ -4502,6 +4796,71 @@ _loop_derive_minute() {
|
|
|
4502
4796
|
echo $(( (hash_dec + offset) % 55 + 1 ))
|
|
4503
4797
|
}
|
|
4504
4798
|
|
|
4799
|
+
# US-LOOP-011: validate a (period, offset) pair against the allowed schedule spec.
|
|
4800
|
+
# Allowed periods are the divisors of 60: 60/30/20/15/12/10/6/5.
|
|
4801
|
+
# Offset must be within [0, period).
|
|
4802
|
+
_loop_schedule_valid() {
|
|
4803
|
+
local period="$1" offset="$2"
|
|
4804
|
+
case "$period" in
|
|
4805
|
+
60|30|20|15|12|10|6|5) ;;
|
|
4806
|
+
*) return 1 ;;
|
|
4807
|
+
esac
|
|
4808
|
+
[[ "$offset" =~ ^[0-9]+$ ]] || return 1
|
|
4809
|
+
if (( offset >= period )); then return 1; fi
|
|
4810
|
+
return 0
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4813
|
+
# US-LOOP-011: compute the loop schedule spec for a project.
|
|
4814
|
+
# Resolution order:
|
|
4815
|
+
# 1. .roll/local.yaml loop_schedule.{period_minutes,offset_minute}
|
|
4816
|
+
# 2. ~/.roll/config.yaml loop_minute → period=60, offset=loop_minute
|
|
4817
|
+
# 3. default period=60, offset=hash(project_path)%60
|
|
4818
|
+
# Output: "<period> <offset>" on stdout. Exit 0 on success.
|
|
4819
|
+
# Invalid project config → fallback to global/default + write ALERT.
|
|
4820
|
+
_loop_schedule_spec() {
|
|
4821
|
+
local project_path="$1"
|
|
4822
|
+
|
|
4823
|
+
# 1. Try project-level .roll/local.yaml
|
|
4824
|
+
local local_file="${project_path}/.roll/local.yaml"
|
|
4825
|
+
if [[ -f "$local_file" ]]; then
|
|
4826
|
+
local local_period local_offset
|
|
4827
|
+
# Extract values from under loop_schedule: key (using awk for reliable block parsing)
|
|
4828
|
+
local_period=$(awk '/^loop_schedule:/{found=1;next} found && /^[[:space:]]+period_minutes:/{print $2; exit}' "$local_file")
|
|
4829
|
+
local_offset=$(awk '/^loop_schedule:/{found=1;next} found && /^[[:space:]]+offset_minute:/{print $2; exit}' "$local_file")
|
|
4830
|
+
if [[ -n "$local_period" && -n "$local_offset" ]]; then
|
|
4831
|
+
if _loop_schedule_valid "$local_period" "$local_offset"; then
|
|
4832
|
+
echo "$local_period $local_offset"
|
|
4833
|
+
return 0
|
|
4834
|
+
fi
|
|
4835
|
+
# Invalid: alert, then fall through to global/default
|
|
4836
|
+
local slug; slug=$(_project_slug "$project_path")
|
|
4837
|
+
local alert_file="${_SHARED_ROOT:-$HOME/.shared/roll}/loop/ALERT-${slug}.md"
|
|
4838
|
+
mkdir -p "$(dirname "$alert_file")" 2>/dev/null || true
|
|
4839
|
+
{
|
|
4840
|
+
printf '## ⚠️ US-LOOP-011: Invalid loop_schedule\n\n'
|
|
4841
|
+
printf '**Time**: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')"
|
|
4842
|
+
printf '**Source**: %s\n\n' "${project_path}/.roll/local.yaml"
|
|
4843
|
+
printf '**Values**: period_minutes=%s, offset_minute=%s\n\n' "$local_period" "$local_offset"
|
|
4844
|
+
printf '**Action**: period must be one of 60/30/20/15/12/10/6/5; offset must be 0–(period-1). Falling back to default (period=60).\n\n'
|
|
4845
|
+
printf '%s\n' '---'
|
|
4846
|
+
} >> "$alert_file"
|
|
4847
|
+
fi
|
|
4848
|
+
fi
|
|
4849
|
+
|
|
4850
|
+
# 2. Try global ~/.roll/config.yaml loop_minute (backward compat)
|
|
4851
|
+
local global_minute
|
|
4852
|
+
global_minute=$(_config_read_int "loop_minute" "")
|
|
4853
|
+
if [[ -n "$global_minute" && "$global_minute" =~ ^[0-9]+$ ]]; then
|
|
4854
|
+
echo "60 $global_minute"
|
|
4855
|
+
return 0
|
|
4856
|
+
fi
|
|
4857
|
+
|
|
4858
|
+
# 3. Default: derive from project path hash (never collides across projects)
|
|
4859
|
+
local offset
|
|
4860
|
+
offset=$(_loop_derive_minute "$project_path" 0)
|
|
4861
|
+
echo "60 $offset"
|
|
4862
|
+
}
|
|
4863
|
+
|
|
4505
4864
|
# US-LOOP-001: structured event emission for cycle observability.
|
|
4506
4865
|
# Writes a tab-separated line to stdout (for tmux/attach display) and appends
|
|
4507
4866
|
# a JSON line to the per-project NDJSON event file under _SHARED_ROOT/loop/.
|
|
@@ -4542,7 +4901,7 @@ _loop_event() {
|
|
|
4542
4901
|
startup) _emoji="🚀" ;;
|
|
4543
4902
|
preflight) _emoji="🔍" ;;
|
|
4544
4903
|
worktree_setup) _emoji="🌳" ;;
|
|
4545
|
-
|
|
4904
|
+
agent_invoke) _emoji="🤖" ;;
|
|
4546
4905
|
publish_push) _emoji="📤" ;;
|
|
4547
4906
|
publish_wait_merge) _emoji="⏳" ;;
|
|
4548
4907
|
cleanup) _emoji="🧹" ;;
|
|
@@ -4761,30 +5120,15 @@ _write_loop_runner_script() {
|
|
|
4761
5120
|
# US-AUTO-037: strip leading `cd "<path>" && ` (callers like
|
|
4762
5121
|
# _install_launchd_plists prepend it). The runner now manages cwd itself
|
|
4763
5122
|
# — pointing at the worktree when isolation succeeds, project_path otherwise.
|
|
4764
|
-
local
|
|
5123
|
+
local agent_cmd; agent_cmd="${cmd_verbose#cd \"*\" && }"
|
|
4765
5124
|
# FIX-048: Claude Code resolves project root from the worktree's .git file to
|
|
4766
5125
|
# the main repo, placing worktree absolute paths outside its sandbox. Inject
|
|
4767
5126
|
# --add-dir "$WT" so the worktree directory is explicitly allowed. Only applies
|
|
4768
5127
|
# to claude (the --output-format stream-json flag is exclusive to claude runs).
|
|
4769
|
-
if [[ "$
|
|
4770
|
-
|
|
5128
|
+
if [[ "$agent_cmd" == *"--output-format stream-json"* ]]; then
|
|
5129
|
+
agent_cmd="${agent_cmd/--output-format stream-json/--output-format stream-json --add-dir \"\$WT\"}"
|
|
4771
5130
|
fi
|
|
4772
5131
|
local slug; slug=$(_project_slug "$project_path")
|
|
4773
|
-
# FIX-115: build fallback agent command. When primary agent fails all 3
|
|
4774
|
-
# attempts, the inner runner switches to this command for 3 more attempts.
|
|
4775
|
-
local fallback_agent; fallback_agent=$(_fallback_agent)
|
|
4776
|
-
local fallback_cmd=""
|
|
4777
|
-
if [[ -n "$fallback_agent" ]]; then
|
|
4778
|
-
local fallback_skill_file="${ROLL_HOME}/skills/roll-loop/SKILL.md"
|
|
4779
|
-
if [[ -f "$fallback_skill_file" ]]; then
|
|
4780
|
-
local fallback_prompt; fallback_prompt=$(_skill_content "$fallback_skill_file")
|
|
4781
|
-
if _agent_argv "$fallback_agent" plain "$fallback_prompt" 2>/dev/null; then
|
|
4782
|
-
_AGENT_ARGV[0]=$(command -v "${_AGENT_ARGV[0]}" 2>/dev/null || echo "${_AGENT_ARGV[0]}")
|
|
4783
|
-
printf -v fallback_cmd '%q ' "${_AGENT_ARGV[@]}"
|
|
4784
|
-
fallback_cmd="${fallback_cmd% }"
|
|
4785
|
-
fi
|
|
4786
|
-
fi
|
|
4787
|
-
fi
|
|
4788
5132
|
cat > "$inner_path" << INNER
|
|
4789
5133
|
#!/bin/bash -l
|
|
4790
5134
|
set -o pipefail
|
|
@@ -4816,7 +5160,7 @@ printf '%s:%s\n' "\$\$" "\$(date -u +%s)" > "\$INNER_LOCK"
|
|
|
4816
5160
|
# to detect stale execution without relying on PID reuse heuristics.
|
|
4817
5161
|
# US-LOOP-007: heartbeat also emits phase_tick for the current phase so tmux
|
|
4818
5162
|
# readers see "still alive in <phase>" during long-running silences (e.g.
|
|
4819
|
-
#
|
|
5163
|
+
# agent_invoke 5-45 min). CURRENT_PHASE is maintained by _phase_begin/_phase_end.
|
|
4820
5164
|
HEARTBEAT_FILE="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/.heartbeat-${slug}"
|
|
4821
5165
|
CURRENT_PHASE=""
|
|
4822
5166
|
# bash 3.2 (macOS /bin/bash) lacks associative arrays — use namespaced
|
|
@@ -5193,11 +5537,7 @@ export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
|
|
|
5193
5537
|
# US-LOOP-010: tell loop-fmt.py which agent is running so it can branch
|
|
5194
5538
|
# rendering: claude → stream-json parser, others → transparent passthrough.
|
|
5195
5539
|
export ROLL_LOOP_AGENT="\$(_project_agent)"
|
|
5196
|
-
_phase_begin
|
|
5197
|
-
# FIX-115: fallback agent support — when primary fails 3 attempts, try fallback 3 more
|
|
5198
|
-
FALLBACK_AGENT_NAME="${fallback_agent}"
|
|
5199
|
-
FALLBACK_CMD="${fallback_cmd}"
|
|
5200
|
-
_USE_FALLBACK=0
|
|
5540
|
+
_phase_begin agent_invoke
|
|
5201
5541
|
for _attempt in 1 2 3; do
|
|
5202
5542
|
# FIX-068: defensive reset before each attempt — _CYCLE_TIMED_OUT carries
|
|
5203
5543
|
# the SIGTERM result of the previous attempt and would otherwise force an
|
|
@@ -5220,9 +5560,9 @@ for _attempt in 1 2 3; do
|
|
|
5220
5560
|
} ) &
|
|
5221
5561
|
_WATCHDOG_PID=\$!
|
|
5222
5562
|
if [ -f "\$FMT" ]; then
|
|
5223
|
-
( cd "\$WT" && ${
|
|
5563
|
+
( cd "\$WT" && ${agent_cmd} ) | python3 "\$FMT"
|
|
5224
5564
|
else
|
|
5225
|
-
( cd "\$WT" && ${
|
|
5565
|
+
( cd "\$WT" && ${agent_cmd} )
|
|
5226
5566
|
fi
|
|
5227
5567
|
_exit=\$?
|
|
5228
5568
|
kill "\$_WATCHDOG_PID" 2>/dev/null
|
|
@@ -5235,48 +5575,10 @@ for _attempt in 1 2 3; do
|
|
|
5235
5575
|
fi
|
|
5236
5576
|
done
|
|
5237
5577
|
|
|
5238
|
-
# FIX-115: fallback agent retry — when primary fails all 3 attempts and a
|
|
5239
|
-
# fallback_agent is configured, try the fallback for 3 more attempts.
|
|
5240
|
-
if [ "\$_CYCLE_TIMED_OUT" -eq 0 ] && [ "\$_exit" -ne 0 ] && [ -n "\$FALLBACK_AGENT_NAME" ] && [ -n "\$FALLBACK_CMD" ]; then
|
|
5241
|
-
_loop_event agent_switch "\$FALLBACK_AGENT_NAME" "" "primary failed after 3 attempts" || true
|
|
5242
|
-
_loop_event agent_used "\${CYCLE_ID}" "\$FALLBACK_AGENT_NAME" "fallback" || true
|
|
5243
|
-
echo "[loop] primary agent failed after 3 attempts — switching to fallback: \$FALLBACK_AGENT_NAME"
|
|
5244
|
-
_USE_FALLBACK=1
|
|
5245
|
-
# US-LOOP-010: tell loop-fmt which fallback agent is running so it renders
|
|
5246
|
-
# correctly (passthrough for non-claude, stream-json for claude).
|
|
5247
|
-
export ROLL_LOOP_AGENT="\$FALLBACK_AGENT_NAME"
|
|
5248
|
-
for _attempt in 1 2 3; do
|
|
5249
|
-
_CYCLE_TIMED_OUT=0
|
|
5250
|
-
( sleep "\$LOOP_CYCLE_TIMEOUT_SEC" && {
|
|
5251
|
-
kill -TERM \$\$ 2>/dev/null
|
|
5252
|
-
pkill -TERM -P \$\$ 2>/dev/null
|
|
5253
|
-
pkill -TERM -f "\$WT" 2>/dev/null
|
|
5254
|
-
sleep 5
|
|
5255
|
-
pkill -KILL -P \$\$ 2>/dev/null
|
|
5256
|
-
pkill -KILL -f "\$WT" 2>/dev/null
|
|
5257
|
-
} ) &
|
|
5258
|
-
_WATCHDOG_PID=\$!
|
|
5259
|
-
if [ -f "\$FMT" ]; then
|
|
5260
|
-
( cd "\$WT" && \$FALLBACK_CMD ) | python3 "\$FMT"
|
|
5261
|
-
else
|
|
5262
|
-
( cd "\$WT" && \$FALLBACK_CMD )
|
|
5263
|
-
fi
|
|
5264
|
-
_exit=\$?
|
|
5265
|
-
kill "\$_WATCHDOG_PID" 2>/dev/null
|
|
5266
|
-
wait "\$_WATCHDOG_PID" 2>/dev/null
|
|
5267
|
-
[ "\$_CYCLE_TIMED_OUT" -eq 1 ] && break
|
|
5268
|
-
[ "\$_exit" -eq 0 ] && break
|
|
5269
|
-
if [ "\$_attempt" -lt 3 ]; then
|
|
5270
|
-
echo "[loop] \$FALLBACK_AGENT_NAME exited \$_exit (attempt \$_attempt/3) — retrying in 30s..."
|
|
5271
|
-
sleep 30
|
|
5272
|
-
fi
|
|
5273
|
-
done
|
|
5274
|
-
fi
|
|
5275
|
-
|
|
5276
5578
|
if [ "\$_CYCLE_TIMED_OUT" -eq 1 ] || [ "\$_exit" -ne 0 ]; then
|
|
5277
|
-
_phase_end
|
|
5579
|
+
_phase_end agent_invoke fail
|
|
5278
5580
|
else
|
|
5279
|
-
_phase_end
|
|
5581
|
+
_phase_end agent_invoke ok
|
|
5280
5582
|
fi
|
|
5281
5583
|
|
|
5282
5584
|
# FIX-057: timed out — skip publish; EXIT trap writes cycle_end blocked + ALERT.
|
|
@@ -5323,6 +5625,14 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
|
5323
5625
|
if [ "\$_cycle_commits" -eq 0 ]; then
|
|
5324
5626
|
_worktree_cleanup "\$WT" "\$BRANCH"
|
|
5325
5627
|
_loop_event idle "\${CYCLE_ID}" "" "" || true
|
|
5628
|
+
# FIX-F (2026-05-25): explicitly write the terminal "idle" cycle_end +
|
|
5629
|
+
# runs row here, otherwise the EXIT trap's catch-all fallback (which
|
|
5630
|
+
# writes "aborted" when _CYCLE_END_WRITTEN is still 0) will reclassify
|
|
5631
|
+
# this successful no-op as a failure on the dashboard.
|
|
5632
|
+
_loop_event cycle_end "\${CYCLE_ID}" "" "idle" || true
|
|
5633
|
+
_CYCLE_END_WRITTEN=1
|
|
5634
|
+
_phases_idle=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_idle" ] && _phases_idle='{}'
|
|
5635
|
+
_runs_append "idle" 0 "[]" "\$_phases_idle" 2>/dev/null || true
|
|
5326
5636
|
echo "[loop] cycle \${CYCLE_ID}: idle (no new commits); worktree cleaned"
|
|
5327
5637
|
else
|
|
5328
5638
|
_is_doc_only=0
|
|
@@ -5525,7 +5835,14 @@ if command -v tmux >/dev/null 2>&1; then
|
|
|
5525
5835
|
# Microsoft Teams.app).
|
|
5526
5836
|
if [ -z "\${ROLL_LOOP_NO_POPUP:-}" ] && [ -z "\${BATS_TEST_NUMBER:-}" ] && [ ! -f "\$HOME/.shared/roll/loop/mute-${slug}" ] && [ "\$(uname)" = "Darwin" ]; then
|
|
5527
5837
|
_attach_cmd="\$HOME/.shared/roll/loop/attach-\$SESSION.command"
|
|
5528
|
-
|
|
5838
|
+
# Drop \`exec\` so the wrapping shell survives \`tmux attach\` exiting,
|
|
5839
|
+
# then \`read\` to hold the Terminal open until the user has had a
|
|
5840
|
+
# chance to scroll back through the cycle's output. Without this the
|
|
5841
|
+
# window closes the instant the tmux session ends (cycle_end kills
|
|
5842
|
+
# the session) and the entire scrollback disappears with it; the
|
|
5843
|
+
# cron-<slug>.log file still has the full transcript as a fallback.
|
|
5844
|
+
printf '#!/bin/bash\\ntmux attach -t %s\\necho\\necho "================================================================"\\necho " cycle ended. log: ~/.shared/roll/loop/cron-%s.log"\\necho " press enter to close this window."\\necho "================================================================"\\nread _\\n' \\
|
|
5845
|
+
"\$SESSION" "${slug}" > "\$_attach_cmd" 2>/dev/null || true
|
|
5529
5846
|
chmod +x "\$_attach_cmd" 2>/dev/null || true
|
|
5530
5847
|
open -g -a Terminal "\$_attach_cmd" >/dev/null 2>&1 || true
|
|
5531
5848
|
fi
|
|
@@ -5646,6 +5963,9 @@ _install_launchd_plists() {
|
|
|
5646
5963
|
# FIX-058: after FIX-056 introduced realpath normalization, the slug for an
|
|
5647
5964
|
# existing project may have changed. Migrate state before creating new files.
|
|
5648
5965
|
_slug_migrate_from_legacy "$slug" "${shared}/loop"
|
|
5966
|
+
# US-OBS-010: when slug changed from path-based to remote-based, merge
|
|
5967
|
+
# old records into the new identity (dedup, atomic, .bak backup)
|
|
5968
|
+
_slug_migrate_to_remote "$project_path" "${shared}/loop"
|
|
5649
5969
|
for i in "${!services[@]}"; do
|
|
5650
5970
|
local svc="${services[$i]}"
|
|
5651
5971
|
local skill="${skill_names[$i]}"
|
|
@@ -6160,7 +6480,7 @@ _loop_attach() {
|
|
|
6160
6480
|
# Pretty-print a duration in seconds as "Xs" / "Ym" / "Yh Zm".
|
|
6161
6481
|
# US-VIEW-019: compute slowest phase + % from a JSON line's phases object.
|
|
6162
6482
|
# Returns "<abbr> <pct>%" (e.g. "claude 97%") or empty when no phases data.
|
|
6163
|
-
# Abbreviations match the AC:
|
|
6483
|
+
# Abbreviations match the AC: agent_invoke→agent, publish_wait_merge→pr-wait,
|
|
6164
6484
|
# publish_push→publish, worktree_setup→worktree; others unchanged.
|
|
6165
6485
|
_loop_runs_slowest_phase() {
|
|
6166
6486
|
local line="$1"
|
|
@@ -6173,7 +6493,7 @@ _loop_runs_slowest_phase() {
|
|
|
6173
6493
|
[ -z "$max_name" ] && return 0
|
|
6174
6494
|
local abbr="$max_name"
|
|
6175
6495
|
case "$max_name" in
|
|
6176
|
-
|
|
6496
|
+
agent_invoke) abbr="agent" ;;
|
|
6177
6497
|
publish_wait_merge) abbr="pr-wait" ;;
|
|
6178
6498
|
publish_push) abbr="publish" ;;
|
|
6179
6499
|
worktree_setup) abbr="worktree" ;;
|
|
@@ -6380,9 +6700,14 @@ _notify() {
|
|
|
6380
6700
|
}
|
|
6381
6701
|
|
|
6382
6702
|
# Count `tcr:` prefixed commits in the current git repo since started_at timestamp.
|
|
6703
|
+
# FIX-D 2026-05-25: use --all so commits made on a cycle worktree branch (not
|
|
6704
|
+
# yet merged into the current HEAD) are still counted. Without --all the
|
|
6705
|
+
# enforce-tcr check inside a cycle-worktree cwd would miss the worktree's own
|
|
6706
|
+
# fresh commits when the runner happens to chdir to the main repo before
|
|
6707
|
+
# calling enforce-tcr.
|
|
6383
6708
|
_loop_tcr_count() {
|
|
6384
6709
|
local started_at="$1"
|
|
6385
|
-
git log --oneline --since="${started_at}" 2>/dev/null \
|
|
6710
|
+
git log --all --oneline --since="${started_at}" 2>/dev/null \
|
|
6386
6711
|
| awk '/^[a-f0-9]+ tcr:/{n++} END{print n+0}'
|
|
6387
6712
|
}
|
|
6388
6713
|
|
|
@@ -7416,6 +7741,33 @@ _worktree_merge_back() {
|
|
|
7416
7741
|
_worktree_alert "pull --ff-only origin main failed (remote diverged?)"
|
|
7417
7742
|
return 1
|
|
7418
7743
|
fi
|
|
7744
|
+
# FIX-E (2026-05-25): only doc-only branches may take the ff-merge fast path.
|
|
7745
|
+
# Code changes must go through PR + CI; bypassing that gate has caused
|
|
7746
|
+
# cycle commits to land on main with red CI, undetected. The check is the
|
|
7747
|
+
# same one publish_push uses for the doc-vs-code split; here we apply it
|
|
7748
|
+
# to the loop branch's diff against the (already-pulled) main HEAD so the
|
|
7749
|
+
# bypass is also closed in the gh-unavailable fallback path. The ALERT
|
|
7750
|
+
# body is targeted at the next cycle's agent (human-on-the-loop), telling
|
|
7751
|
+
# it how to retry through the normal flow rather than waiting for a human.
|
|
7752
|
+
local _changed
|
|
7753
|
+
_changed=$(git diff --name-only HEAD.."$branch" 2>/dev/null)
|
|
7754
|
+
if [ -n "$_changed" ] \
|
|
7755
|
+
&& echo "$_changed" | grep -qvE '^(\.roll/|CHANGELOG\.md|guide/|site/|\.claude/|BACKLOG\.md|PROPOSALS\.md|docs/)'; then
|
|
7756
|
+
_worktree_alert "$(printf '%s\n' \
|
|
7757
|
+
"## PRIOR CYCLE FAILED PUBLISH — REQUIRES NORMAL PR FLOW (FIX-E)" \
|
|
7758
|
+
"branch: ${branch}" \
|
|
7759
|
+
"reason: _loop_publish_pr failed (gh unavailable / API error); ff-merge" \
|
|
7760
|
+
" fallback was refused because the branch contains code changes." \
|
|
7761
|
+
" Code must not bypass PR+CI." \
|
|
7762
|
+
"" \
|
|
7763
|
+
"next-cycle action (agent reads this and retries automatically):" \
|
|
7764
|
+
" 1. SKIP normal Todo scan this cycle." \
|
|
7765
|
+
" 2. git push origin ${branch}" \
|
|
7766
|
+
" 3. gh pr create --base main --head ${branch}" \
|
|
7767
|
+
" 4. gh pr merge ${branch} --auto --squash --delete-branch" \
|
|
7768
|
+
" 5. exit cleanly so CI runs and auto-merge takes over.")"
|
|
7769
|
+
return 1
|
|
7770
|
+
fi
|
|
7419
7771
|
if ! git merge --ff-only "$branch" --quiet 2>/dev/null; then
|
|
7420
7772
|
_worktree_alert "merge --ff-only ${branch} failed (not fast-forwardable from main)"
|
|
7421
7773
|
return 1
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/roll-home.py
CHANGED
|
@@ -31,7 +31,41 @@ from roll_render import COLS, c, row, section_head, strw, pad
|
|
|
31
31
|
# ════════════════════════════════════════════════════════════════════════════
|
|
32
32
|
# Paths
|
|
33
33
|
# ════════════════════════════════════════════════════════════════════════════
|
|
34
|
+
def _git_remote_url(repo_path: str) -> Optional[str]:
|
|
35
|
+
"""Mirror lib/roll-loop-status.py::_git_remote_url — origin first, then any."""
|
|
36
|
+
try:
|
|
37
|
+
url = subprocess.check_output(
|
|
38
|
+
["git", "-C", repo_path, "remote", "get-url", "origin"],
|
|
39
|
+
stderr=subprocess.DEVNULL, text=True,
|
|
40
|
+
).strip()
|
|
41
|
+
if url:
|
|
42
|
+
return url
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
try:
|
|
46
|
+
remotes = subprocess.check_output(
|
|
47
|
+
["git", "-C", repo_path, "remote"],
|
|
48
|
+
stderr=subprocess.DEVNULL, text=True,
|
|
49
|
+
).strip().splitlines()
|
|
50
|
+
if remotes:
|
|
51
|
+
url = subprocess.check_output(
|
|
52
|
+
["git", "-C", repo_path, "remote", "get-url", remotes[0]],
|
|
53
|
+
stderr=subprocess.DEVNULL, text=True,
|
|
54
|
+
).strip()
|
|
55
|
+
if url:
|
|
56
|
+
return url
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
34
62
|
def _project_slug(path: Optional[str] = None) -> str:
|
|
63
|
+
# US-LOOP-006: cycle wrapper exports ROLL_MAIN_SLUG — honour it (parity with
|
|
64
|
+
# bin/roll _project_slug and lib/roll-loop-status.py project_slug).
|
|
65
|
+
env_slug = os.environ.get("ROLL_MAIN_SLUG", "").strip()
|
|
66
|
+
if env_slug:
|
|
67
|
+
return env_slug
|
|
68
|
+
|
|
35
69
|
path = os.path.realpath(path or os.getcwd())
|
|
36
70
|
try:
|
|
37
71
|
common = subprocess.check_output(
|
|
@@ -42,6 +76,27 @@ def _project_slug(path: Optional[str] = None) -> str:
|
|
|
42
76
|
path = common[:-5]
|
|
43
77
|
except Exception:
|
|
44
78
|
pass
|
|
79
|
+
|
|
80
|
+
# US-OBS-010: derive slug from git remote URL for stable cross-machine
|
|
81
|
+
# identity. Mirror normalization in bin/roll + lib/roll-loop-status.py
|
|
82
|
+
# so all three callers agree on the slug. Without this, `roll` home dash
|
|
83
|
+
# looks up plists at the old path-based slug while `roll loop status`
|
|
84
|
+
# looks them up at the new remote-based slug — dashboards diverge and
|
|
85
|
+
# the home page falsely reports the loop as "missing".
|
|
86
|
+
remote_url = _git_remote_url(path)
|
|
87
|
+
if remote_url:
|
|
88
|
+
remote_url = remote_url.rstrip("/")
|
|
89
|
+
if remote_url.endswith(".git"):
|
|
90
|
+
remote_url = remote_url[:-4]
|
|
91
|
+
m = re.match(r"^git@([^:]+):(.+)$", remote_url)
|
|
92
|
+
if m:
|
|
93
|
+
remote_url = f"https://{m.group(1)}/{m.group(2)}"
|
|
94
|
+
remote_url = remote_url.lower()
|
|
95
|
+
base = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(remote_url)).strip("-")
|
|
96
|
+
h = hashlib.md5(remote_url.encode()).hexdigest()[:6]
|
|
97
|
+
return f"{base}-{h}"
|
|
98
|
+
|
|
99
|
+
# Fallback: path-based (pre-US-OBS-010 behaviour) when no remote configured.
|
|
45
100
|
base = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(path)).strip("-")
|
|
46
101
|
h = hashlib.md5(path.encode()).hexdigest()[:6]
|
|
47
102
|
return f"{base}-{h}"
|
package/lib/roll-loop-status.py
CHANGED
|
@@ -52,6 +52,11 @@ from roll_render import (
|
|
|
52
52
|
# Paths — must match bin/roll's _project_slug + _SHARED_ROOT defaults
|
|
53
53
|
# ════════════════════════════════════════════════════════════════════════════
|
|
54
54
|
def project_slug(path: Optional[str] = None) -> str:
|
|
55
|
+
# US-LOOP-006: cycle wrapper exports ROLL_MAIN_SLUG — honour it.
|
|
56
|
+
env_slug = os.environ.get("ROLL_MAIN_SLUG", "").strip()
|
|
57
|
+
if env_slug:
|
|
58
|
+
return env_slug
|
|
59
|
+
|
|
55
60
|
path = os.path.realpath(path or os.getcwd())
|
|
56
61
|
try: # resolve git worktree → main tree (FIX-034 in bin/roll)
|
|
57
62
|
common = subprocess.check_output(
|
|
@@ -62,10 +67,57 @@ def project_slug(path: Optional[str] = None) -> str:
|
|
|
62
67
|
path = common[:-5]
|
|
63
68
|
except Exception:
|
|
64
69
|
pass
|
|
70
|
+
|
|
71
|
+
# US-OBS-010: derive slug from git remote URL for stable cross-machine
|
|
72
|
+
# identity. Normalize: strip .git, git@HOST:PATH → https://HOST/PATH,
|
|
73
|
+
# lowercase. Fallback chain: origin → first remote → path-based.
|
|
74
|
+
remote_url = _git_remote_url(path)
|
|
75
|
+
if remote_url:
|
|
76
|
+
# Normalize
|
|
77
|
+
remote_url = remote_url.rstrip("/")
|
|
78
|
+
if remote_url.endswith(".git"):
|
|
79
|
+
remote_url = remote_url[:-4]
|
|
80
|
+
m = re.match(r"^git@([^:]+):(.+)$", remote_url)
|
|
81
|
+
if m:
|
|
82
|
+
remote_url = f"https://{m.group(1)}/{m.group(2)}"
|
|
83
|
+
remote_url = remote_url.lower()
|
|
84
|
+
base = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(remote_url)).strip("-")
|
|
85
|
+
h = hashlib.md5(remote_url.encode()).hexdigest()[:6]
|
|
86
|
+
return f"{base}-{h}"
|
|
87
|
+
|
|
65
88
|
base = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(path)).strip("-")
|
|
66
89
|
h = hashlib.md5(path.encode()).hexdigest()[:6]
|
|
67
90
|
return f"{base}-{h}"
|
|
68
91
|
|
|
92
|
+
|
|
93
|
+
def _git_remote_url(repo_path: str) -> Optional[str]:
|
|
94
|
+
"""Return the normalized remote URL for a git repo, or None."""
|
|
95
|
+
try:
|
|
96
|
+
url = subprocess.check_output(
|
|
97
|
+
["git", "-C", repo_path, "remote", "get-url", "origin"],
|
|
98
|
+
stderr=subprocess.DEVNULL, text=True
|
|
99
|
+
).strip()
|
|
100
|
+
if url:
|
|
101
|
+
return url
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
# Fallback: first available remote
|
|
105
|
+
try:
|
|
106
|
+
remotes = subprocess.check_output(
|
|
107
|
+
["git", "-C", repo_path, "remote"],
|
|
108
|
+
stderr=subprocess.DEVNULL, text=True
|
|
109
|
+
).strip().splitlines()
|
|
110
|
+
if remotes:
|
|
111
|
+
url = subprocess.check_output(
|
|
112
|
+
["git", "-C", repo_path, "remote", "get-url", remotes[0]],
|
|
113
|
+
stderr=subprocess.DEVNULL, text=True
|
|
114
|
+
).strip()
|
|
115
|
+
if url:
|
|
116
|
+
return url
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
return None
|
|
120
|
+
|
|
69
121
|
def shared_root() -> Path:
|
|
70
122
|
return Path(os.environ.get("ROLL_SHARED_ROOT") or os.path.expanduser("~/.shared/roll"))
|
|
71
123
|
|
package/lib/roll_render.py
CHANGED
|
@@ -88,6 +88,17 @@ def fmt_dur(s: int) -> str:
|
|
|
88
88
|
return f"{s // 60}m"
|
|
89
89
|
return f"{s // 3600}h {(s % 3600) // 60}m"
|
|
90
90
|
|
|
91
|
+
# FIX-121: agent → primary model used by `roll loop` dashboard's fallback
|
|
92
|
+
# when an event stream lacks an explicit model name (non-claude agents'
|
|
93
|
+
# stdout isn't stream-json so loop-fmt can't extract model). Keeps the
|
|
94
|
+
# model column consistent with claude's "opus-4-7" style.
|
|
95
|
+
_AGENT_PRIMARY_MODEL = {
|
|
96
|
+
"pi": "deepseek-v4-pro",
|
|
97
|
+
"deepseek": "deepseek-v4-pro",
|
|
98
|
+
"kimi": "kimi-k2-0905",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
91
102
|
def fmt_model(model) -> str:
|
|
92
103
|
"""Short label for the cycle row's model column.
|
|
93
104
|
|
|
@@ -339,8 +350,11 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
|
|
|
339
350
|
# FIX-119: fall back to cy["agent"] (from agent_used event) when model
|
|
340
351
|
# is unknown — non-claude agents (pi, deepseek, kimi) don't expose model
|
|
341
352
|
# info in stream-json, leaving a "—" or "?" on the dashboard.
|
|
353
|
+
# FIX-121: map agent → its configured primary model so the column shows
|
|
354
|
+
# the actual model name (e.g. "deepseek-v4-pro") consistently with
|
|
355
|
+
# claude's "opus-4-7", not the bare agent name ("pi").
|
|
342
356
|
if model_label in ("—", "?") and cy.get("agent"):
|
|
343
|
-
model_label = cy["agent"]
|
|
357
|
+
model_label = _AGENT_PRIMARY_MODEL.get(cy["agent"], cy["agent"])
|
|
344
358
|
# Auto-hide model column on narrow screens — keeps the dashboard readable
|
|
345
359
|
# when terminal is < 100 cols (cost / story IDs are higher-priority).
|
|
346
360
|
show_model = COLS >= 100
|
package/package.json
CHANGED
|
@@ -6,7 +6,8 @@ description: |
|
|
|
6
6
|
Autonomous BACKLOG executor. Runs on a schedule (hourly via cron or GitHub
|
|
7
7
|
Actions), scans .roll/backlog.md for 📋 Todo items, and routes each to the
|
|
8
8
|
appropriate skill: US-XXX → $roll-build, FIX-XXX → $roll-fix,
|
|
9
|
-
REFACTOR-XXX → $roll-build.
|
|
9
|
+
REFACTOR-XXX → $roll-build. Retries the primary agent up to 3 times on
|
|
10
|
+
transient failure; pauses with ALERT on persistent failure.
|
|
10
11
|
Never cuts a release autonomously — release is always a human decision.
|
|
11
12
|
Triggers roll-brief when a Feature completes.
|
|
12
13
|
---
|
|
@@ -73,8 +74,7 @@ denied operations and the cycle will idle-exit.
|
|
|
73
74
|
```yaml
|
|
74
75
|
# ~/.roll/config.yaml
|
|
75
76
|
loop:
|
|
76
|
-
primary_agent: claude # claude | deepseek | kimi
|
|
77
|
-
fallback_agent: deepseek # used when primary fails
|
|
77
|
+
primary_agent: claude # claude | deepseek | kimi | pi | ...
|
|
78
78
|
max_items_per_run: 1 # one story per cycle — atomic delivery, predictable cycle time
|
|
79
79
|
brief_on_feature_complete: true
|
|
80
80
|
retry_backoff: [2, 4, 8, 16] # seconds, exponential
|
|
@@ -103,12 +103,24 @@ this point, any `🔨 In Progress` row in `.roll/backlog.md` belongs to a
|
|
|
103
103
|
previous cycle that crashed before flipping it back; reclaim it before
|
|
104
104
|
scanning.
|
|
105
105
|
|
|
106
|
+
**Important — skip `manual-only:*` rows.** A row tagged `manual-only:*`
|
|
107
|
+
means a human (or another non-loop process) has explicitly claimed it;
|
|
108
|
+
it is not loop's to reclaim. Reverting it would silently undo the
|
|
109
|
+
human's claim and cause confusing churn for `roll-brief` / dashboard
|
|
110
|
+
readers. The rule mirrors the gate in Step 2.
|
|
111
|
+
|
|
106
112
|
1. Scan .roll/backlog.md for all rows whose Status column contains `🔨 In Progress`.
|
|
107
|
-
2. For each row
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
113
|
+
2. For each candidate row, run the manual-only gate before touching it:
|
|
114
|
+
```bash
|
|
115
|
+
bash -c 'source "$(command -v roll)"; _loop_is_manual_only "<story-id>" .roll/backlog.md'
|
|
116
|
+
# exit 0 → row has `manual-only:*` → SKIP (human-claimed; not orphan)
|
|
117
|
+
# exit 1 → reclaimable orphan; continue to step 3
|
|
118
|
+
```
|
|
119
|
+
3. For each row that passes the gate: revert the status back to
|
|
120
|
+
`📋 Todo`, commit `chore: revert orphan 🔨 US-XXX to 📋`, and append
|
|
121
|
+
a line to `~/.shared/roll/loop/ALERT-<slug>.md` recording the orphan
|
|
122
|
+
id and time so the next brief surfaces it.
|
|
123
|
+
4. After orphan sweep, proceed to Step 1.5 (Pre-run CI health check) before scanning.
|
|
112
124
|
|
|
113
125
|
### Step 1.5 — Pre-run CI Health Check
|
|
114
126
|
|
|
@@ -427,21 +439,20 @@ Attempt 1 fails
|
|
|
427
439
|
|
|
428
440
|
```
|
|
429
441
|
Primary agent fails (non-network error)
|
|
430
|
-
→
|
|
431
|
-
→
|
|
432
|
-
→ if fallback also fails → PAUSE
|
|
442
|
+
→ 3 attempts at the agent_invoke phase (with 30s back-off between)
|
|
443
|
+
→ still failing → PAUSE
|
|
433
444
|
```
|
|
434
445
|
|
|
435
446
|
### Pause + Alert
|
|
436
447
|
|
|
437
|
-
When
|
|
448
|
+
When the primary agent exhausts its retry budget:
|
|
438
449
|
|
|
439
450
|
1. Write state:
|
|
440
451
|
```yaml
|
|
441
452
|
status: paused
|
|
442
453
|
paused_at: "2026-05-10T02:07:00+08:00"
|
|
443
454
|
paused_on: US-AUTH-003
|
|
444
|
-
reason: "
|
|
455
|
+
reason: "primary agent (claude) unavailable after 3 attempts"
|
|
445
456
|
```
|
|
446
457
|
|
|
447
458
|
2. Write alert:
|
|
@@ -450,11 +461,11 @@ reason: "both primary (claude) and fallback (deepseek) unavailable"
|
|
|
450
461
|
|
|
451
462
|
**Time**: 2026-05-10 02:07
|
|
452
463
|
**Paused on**: US-AUTH-003
|
|
453
|
-
**Reason**: claude
|
|
464
|
+
**Reason**: claude exited non-zero on 3 consecutive attempts
|
|
454
465
|
|
|
455
466
|
**Action required** (choose one):
|
|
456
467
|
- Top up credits and run: `roll loop resume`
|
|
457
|
-
- Switch agent: edit `~/.roll/config.yaml` → `
|
|
468
|
+
- Switch agent: edit `~/.roll/config.yaml` → `primary_agent`
|
|
458
469
|
- Take over manually: `$roll-build US-AUTH-003`
|
|
459
470
|
```
|
|
460
471
|
|