@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 CHANGED
@@ -1,33 +1,43 @@
1
1
  # Changelog
2
2
 
3
- ## v2026.524.2
3
+ ## v2026.525.1
4
4
 
5
5
  ### Added
6
6
 
7
- - **用 pi / deepseek / kimi 跑的 loop 也能在终端里实时看到进度**不再黑屏 `[loop]`
8
- - **`roll slides new` 可以用项目自己的模板** — 不只用内置的了 `[deck]`
9
- - **`roll slides list` 一眼看出 slide 状态** — 哪些能看、哪些生成失败、失败原因也能查 `[deck]`
7
+ - loop 触发频率可以按项目单独设每个项目能自定义间隔,不再全局一刀切 `[loop]`
10
8
 
11
9
  ### Fixed
12
10
 
13
- - **loop 不再一次吞太多故事** 每次只做一个,时间可预期 `[loop]`
14
- - **dashboard 不再对非 Claude 模型显示空白** 能看出每轮谁跑的 `[loop]`
11
+ - `roll` 主页不再把已启用的 loop 误报成"缺失" `[loop]`
12
+ - loop 弹窗关了也能翻回之前的输出不再一关就全丢 `[loop]`
15
13
 
16
- ## v2026.524.1
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
- - **成本不再虚高**支持多模型真实价格 `[dashboard]`
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.524.2"
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
- printf '#!/bin/bash\nexec tmux attach -t %s\n' "$session" > "$attach_cmd" 2>/dev/null || return 0
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
- claude_invoke) _emoji="🤖" ;;
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 claude_cmd; claude_cmd="${cmd_verbose#cd \"*\" && }"
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 [[ "$claude_cmd" == *"--output-format stream-json"* ]]; then
4770
- claude_cmd="${claude_cmd/--output-format stream-json/--output-format stream-json --add-dir \"\$WT\"}"
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
- # claude_invoke 5-45 min). CURRENT_PHASE is maintained by _phase_begin/_phase_end.
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 claude_invoke
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" && ${claude_cmd} ) | python3 "\$FMT"
5563
+ ( cd "\$WT" && ${agent_cmd} ) | python3 "\$FMT"
5224
5564
  else
5225
- ( cd "\$WT" && ${claude_cmd} )
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 claude_invoke fail
5579
+ _phase_end agent_invoke fail
5278
5580
  else
5279
- _phase_end claude_invoke ok
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
- printf '#!/bin/bash\\nexec tmux attach -t %s\\n' "\$SESSION" > "\$_attach_cmd" 2>/dev/null || true
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: claude_invokeclaude, publish_wait_merge→pr-wait,
6483
+ # Abbreviations match the AC: agent_invokeagent, 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
- claude_invoke) abbr="claude" ;;
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
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}"
@@ -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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.524.2",
3
+ "version": "2026.525.1",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -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. Handles agent fallback on token/network failure.
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 found: revert the status back to `📋 Todo`, commit
108
- `chore: revert orphan 🔨 US-XXX to 📋`, and append a line to
109
- `~/.shared/roll/loop/ALERT-<slug>.md` recording the orphan id and time
110
- so the next brief surfaces it.
111
- 3. After orphan sweep, proceed to Step 1.5 (Pre-run CI health check) before scanning.
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
- switch to fallback_agent from config
431
- retry current item with fallback
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 both agents fail:
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: "both primary (claude) and fallback (deepseek) unavailable"
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: token exhausted; deepseek: network error after 5 retries
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` → `loop.primary_agent`
468
+ - Switch agent: edit `~/.roll/config.yaml` → `primary_agent`
458
469
  - Take over manually: `$roll-build US-AUTH-003`
459
470
  ```
460
471