@seanyao/roll 2026.528.2 → 2026.529.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.528.2"
7
+ VERSION="2026.529.2"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -83,31 +83,80 @@ lower_name() {
83
83
  }
84
84
 
85
85
 
86
- # Check if an AI tool is actually installed.
87
- # Most tools create their own config dir; Trae on macOS uses Library/Application Support
88
- # and expects roll to manage ~/.trae/ so we detect via the app directory instead.
89
- _is_ai_installed() {
90
- local ai_dir="$1"
91
- [[ -d "$ai_dir" ]] && return 0
92
- local bn
93
- bn="$(basename "$ai_dir" | sed 's/^\.//')"
94
- case "$bn" in
86
+ # FIX-128: agent binary-name(s) lookup. First binary found wins. Used by
87
+ # _agent_installed_by_name to enforce "CLI must exist on PATH" detection
88
+ # instead of the old "config dir exists" check (which Roll's own convention
89
+ # sync would fake — see FIX-128).
90
+ _agent_bin_names() {
91
+ case "$1" in
92
+ claude) echo "claude" ;;
93
+ codex|openai) echo "codex" ;; # openai is a Roll alias for codex
94
+ agy|gemini) echo "agy gemini" ;; # gemini reuses ~/.gemini for agy
95
+ kimi) echo "kimi-code kimi-cli kimi" ;; # FIX-126
96
+ deepseek) echo "deepseek" ;;
97
+ qwen) echo "qwen" ;;
98
+ pi) echo "pi" ;;
99
+ *) return 1 ;;
100
+ esac
101
+ }
102
+
103
+ # FIX-128: detect whether an AI agent (by canonical name) is actually
104
+ # usable on this machine. For CLI-only agents this means "binary on PATH";
105
+ # GUI / bundled-binary agents keep their special-case paths. Falls back
106
+ # to dir-existence only for unknown agents the operator has registered
107
+ # manually (forward-compatible with future additions).
108
+ _agent_installed_by_name() {
109
+ local agent="$1"
110
+ local dir="${2:-}"
111
+ case "$agent" in
95
112
  trae)
96
- [[ -d "$HOME/Library/Application Support/Trae" ]] ||
97
- [[ -d "$HOME/.config/Trae" ]]
113
+ [[ -d "$HOME/Library/Application Support/Trae" ]] || [[ -d "$HOME/.config/Trae" ]]
98
114
  return
99
115
  ;;
100
116
  opencode)
101
117
  [[ -x "$HOME/.opencode/bin/opencode" ]]
102
118
  return
103
119
  ;;
104
- agent)
105
- if [[ "$(basename "$(dirname "$ai_dir")")" == ".pi" ]]; then
106
- command -v pi &>/dev/null && return
107
- fi
120
+ cursor)
121
+ # cursor ships a GUI + an optional CLI; either path counts as "installed".
122
+ command -v cursor >/dev/null 2>&1 || [[ -d "$HOME/.cursor" ]]
123
+ return
124
+ ;;
125
+ openclaw)
126
+ [[ -d "$HOME/.openclaw/workspace" ]]
127
+ return
108
128
  ;;
109
129
  esac
110
- return 1
130
+ local bins
131
+ if bins=$(_agent_bin_names "$agent" 2>/dev/null); then
132
+ local b
133
+ for b in $bins; do
134
+ command -v "$b" >/dev/null 2>&1 && return 0
135
+ done
136
+ return 1
137
+ fi
138
+ # Unknown agent — fall back to dir presence so user-added entries still work.
139
+ [[ -n "$dir" && -d "$dir" ]]
140
+ }
141
+
142
+ # Check if an AI tool is actually installed (back-compat shim around
143
+ # _agent_installed_by_name; preserves the dir-path-based signature used
144
+ # throughout bin/roll).
145
+ _is_ai_installed() {
146
+ local ai_dir="$1"
147
+ local bn
148
+ bn="$(basename "$ai_dir" | sed 's/^\.//')"
149
+ # Nested-dir layouts collapse to their parent agent name.
150
+ case "$bn" in
151
+ agent|workspace)
152
+ bn="$(basename "$(dirname "$ai_dir")" | sed 's/^\.//')"
153
+ ;;
154
+ esac
155
+ # Mirror ai_tool_name's alias normalization so detection routes to the
156
+ # canonical agent record (e.g. ~/.gemini → agy, ~/.kimi-code → kimi).
157
+ [[ "$bn" == "gemini" ]] && bn="agy"
158
+ [[ "$bn" == "kimi-code" ]] && bn="kimi"
159
+ _agent_installed_by_name "$bn" "$ai_dir"
111
160
  }
112
161
 
113
162
  # ─── Spinner: TTY-aware status display for long-running steps (US-REL-003) ───
@@ -512,8 +561,7 @@ editor: ${EDITOR:-vim}
512
561
 
513
562
  # Loop schedule (24h format, machine local timezone)
514
563
  # Minute fields auto-derive from project path hash when omitted — avoids contention across projects.
515
- loop_active_start: 10 # loop only fires inside this window (after human reviews brief)
516
- loop_active_end: 18
564
+ # active_start/active_end moved to per-project .roll/local.yaml loop_schedule block (default 0/24).
517
565
  # loop_minute: 5 # omit to auto-derive from project hash
518
566
  loop_dream_hour: 3
519
567
  # loop_dream_minute: 10 # omit to auto-derive
@@ -522,6 +570,19 @@ loop_brief_hour: 9
522
570
  primary_agent: claude
523
571
  YAML
524
572
  ok "$(msg shared.created_roll_config_yaml)"
573
+
574
+ # FIX-128: the heredoc template hardcodes `primary_agent: claude` for
575
+ # the first-time case. Replace it with the first agent that actually
576
+ # has its CLI on PATH so users without Claude installed don't get a
577
+ # silently-broken default. If nothing detected, leave `claude` so the
578
+ # user still has a clear handle to fix manually.
579
+ local _detected_primary
580
+ _detected_primary="$(_first_installed_agent || true)"
581
+ if [[ -n "$_detected_primary" && "$_detected_primary" != "claude" ]]; then
582
+ _replace_primary_agent "$_detected_primary"
583
+ info "$(msg shared.primary_agent_auto_detected "$_detected_primary" 2>/dev/null \
584
+ || echo "primary_agent → $_detected_primary (auto-detected from installed CLIs)")"
585
+ fi
525
586
  fi
526
587
 
527
588
  # Ensure all expected ai_* keys exist (handles upgrades where new tools were added)
@@ -529,6 +590,32 @@ YAML
529
590
 
530
591
  }
531
592
 
593
+ # FIX-128: pick the first agent whose CLI is on PATH, scanning the same
594
+ # order the default config template lists them. Empty stdout when none
595
+ # detected; never errors.
596
+ _first_installed_agent() {
597
+ local agent
598
+ for agent in claude codex kimi deepseek qwen agy pi cursor opencode trae openclaw; do
599
+ if _agent_installed_by_name "$agent"; then
600
+ echo "$agent"
601
+ return 0
602
+ fi
603
+ done
604
+ return 1
605
+ }
606
+
607
+ # FIX-128: rewrite the `primary_agent:` line in $ROLL_CONFIG to the given
608
+ # value. Single-line in-place edit, preserves the rest of the file.
609
+ _replace_primary_agent() {
610
+ local new="$1"
611
+ [[ -f "$ROLL_CONFIG" && -n "$new" ]] || return 0
612
+ local tmp; tmp="$(mktemp)"
613
+ awk -v new="$new" '
614
+ /^primary_agent:/ { print "primary_agent: " new; next }
615
+ { print }
616
+ ' "$ROLL_CONFIG" > "$tmp" && mv "$tmp" "$ROLL_CONFIG"
617
+ }
618
+
532
619
  # ─── Internal: create or repair per-skill symlinks (non-destructive) ─────────
533
620
  _link_skills() {
534
621
  local force="${1:-false}"
@@ -539,7 +626,18 @@ _link_skills() {
539
626
  while IFS= read -r entry; do
540
627
  local ai_dir
541
628
  ai_dir="$(_ai_dir "$entry")"
542
- _is_ai_installed "$ai_dir" || continue
629
+ # FIX-128: detection is now binary-on-PATH, but skill linking keeps
630
+ # the same Claude-always-syncs semantics as _apply_conventions and
631
+ # tolerates pre-existing config dirs (an agent the user is mid-
632
+ # upgrade or installed via nvm/asdf still has its convention dir;
633
+ # we don't want to silently stop linking skills there). Strict
634
+ # binary detection drives chooser logic (primary_agent /
635
+ # _onboard_discover_agents) — see FIX-128.
636
+ if [[ "$ai_dir" != "$HOME/.claude" ]] \
637
+ && ! _is_ai_installed "$ai_dir" \
638
+ && [[ ! -d "$ai_dir" ]]; then
639
+ continue
640
+ fi
543
641
  mkdir -p "$ai_dir"
544
642
 
545
643
  local ai_name ai_dir_real skills_dir
@@ -639,8 +737,13 @@ _sync_convention_for_tool() {
639
737
  local dst_dir
640
738
  dst_dir="$(dirname "$main_dst")"
641
739
 
642
- # Only proceed if Claude (always) or the tool is installed
643
- if [[ "$dst_dir" != "$HOME/.claude" ]] && ! _is_ai_installed "$dst_dir"; then
740
+ # Only proceed if Claude (always), the tool is installed (binary-on-PATH
741
+ # per FIX-128), or the convention dir already exists (mid-upgrade /
742
+ # nvm-installed binaries that aren't on this shell's PATH still get
743
+ # their convention refresh).
744
+ if [[ "$dst_dir" != "$HOME/.claude" ]] \
745
+ && ! _is_ai_installed "$dst_dir" \
746
+ && [[ ! -d "$dst_dir" ]]; then
644
747
  return
645
748
  fi
646
749
  mkdir -p "$dst_dir"
@@ -927,10 +1030,46 @@ HINT
927
1030
  # prints install commands for the ones that aren't, so users who already opted
928
1031
  # in (or opted out) don't get spammed each upgrade.
929
1032
  cmd_doctor() {
1033
+ _doctor_agent_section
930
1034
  _doctor_pr_section
931
1035
  _doctor_launchd_stale_section
932
1036
  }
933
1037
 
1038
+ # FIX-128: list every ai_* entry from config, tag each with binary-on-PATH
1039
+ # status and config-dir existence so the user can see at a glance which
1040
+ # agents are actually usable vs only have Roll-maintained dirs.
1041
+ _doctor_agent_section() {
1042
+ [[ -f "$ROLL_CONFIG" ]] || return 0
1043
+ echo ""
1044
+ echo "$(ROLL_LANG_RESOLVED=en msg doctor.agent_detection)"
1045
+ echo "$(ROLL_LANG_RESOLVED=zh msg doctor.agent_detection)"
1046
+ echo ""
1047
+ local _key _value _name _dir _installed _dir_exists _is_primary
1048
+ _is_primary=$(grep -E '^primary_agent:' "$ROLL_CONFIG" 2>/dev/null | sed 's/^primary_agent: *//')
1049
+ while IFS=: read -r _key _value; do
1050
+ [[ "$_key" =~ ^ai_ ]] || continue
1051
+ _name="${_key#ai_}"
1052
+ [[ "$_name" == "kimi_code" ]] && continue # dedupe
1053
+ _dir="${_value%%|*}"
1054
+ _dir="${_dir# }"
1055
+ _dir="${_dir/#\~/$HOME}"
1056
+ if _agent_installed_by_name "$_name" "$_dir"; then
1057
+ _installed="$(msg doctor.agent_installed)"
1058
+ else
1059
+ _installed="$(msg doctor.agent_missing)"
1060
+ fi
1061
+ if [[ -d "$_dir" ]]; then
1062
+ _dir_exists="$(msg doctor.agent_dir_exists)"
1063
+ else
1064
+ _dir_exists="$(msg doctor.agent_dir_missing)"
1065
+ fi
1066
+ local _tag=""
1067
+ [[ "$_name" == "$_is_primary" ]] && _tag=" ($(msg doctor.agent_primary_label))"
1068
+ printf " %-10s %-14s %s%s\n" "$_name" "$_installed" "$_dir_exists" "$_tag"
1069
+ done < "$ROLL_CONFIG"
1070
+ return 0
1071
+ }
1072
+
934
1073
  # FIX-097: scan ${_LAUNCHD_DIR}/com.roll.*.plist for entries whose
935
1074
  # WorkingDirectory no longer exists on disk. These are the ghost agents left
936
1075
  # behind when a user manually reproduces a bug under /private/tmp/ or
@@ -1267,6 +1406,10 @@ cmd_init() {
1267
1406
  _write_backlog "$project_dir/.roll/backlog.md"
1268
1407
  _ensure_features_dir "$project_dir/.roll/features"
1269
1408
  _write_features_md "$project_dir/.roll/features.md"
1409
+ # US-AGENT-003: seed agent-routes.yaml from template. Env override:
1410
+ # ROLL_AGENT_ROUTES_TEMPLATE=minimal roll init
1411
+ # Onboard plan can also set agent_routes_template; _init_apply reads it.
1412
+ _init_seed_agent_routes "${ROLL_AGENT_ROUTES_TEMPLATE:-default}" "$project_dir" || true
1270
1413
  # US-ONBOARD-019: stamp the project so legacy detection can recognise it
1271
1414
  # as Roll-onboarded without depending on directory-name heuristics.
1272
1415
  _write_version_stamp "$project_dir"
@@ -1467,15 +1610,31 @@ _onboard_discover_agents() {
1467
1610
  while IFS=: read -r _key _value; do
1468
1611
  [[ "$_key" =~ ^ai_ ]] || continue
1469
1612
  _name="${_key#ai_}"
1613
+ # ai_kimi_code → kimi (avoid listing the same agent twice).
1614
+ [[ "$_name" == "kimi_code" ]] && _name="kimi"
1470
1615
  _dir="${_value%%|*}"
1471
1616
  _dir="${_dir# }"
1472
1617
  _dir="${_dir/#\~/$HOME}"
1473
- if [[ -d "$_dir" ]]; then
1474
- _ONBOARD_INSTALLED+=("$_name")
1618
+ # FIX-128: route via _agent_installed_by_name so "installed" means the
1619
+ # CLI is actually on PATH for known agents, not just the config dir
1620
+ # that Roll's own convention sync would have created.
1621
+ if _agent_installed_by_name "$_name" "$_dir"; then
1622
+ # Dedupe — kimi may appear under both ai_kimi and ai_kimi_code.
1623
+ # `${arr[@]+...}` keeps `set -u` happy when the array is still empty.
1624
+ local _already=0 _existing
1625
+ for _existing in ${_ONBOARD_INSTALLED[@]+"${_ONBOARD_INSTALLED[@]}"}; do
1626
+ if [[ "$_existing" == "$_name" ]]; then _already=1; break; fi
1627
+ done
1628
+ if [[ $_already -eq 0 ]]; then _ONBOARD_INSTALLED+=("$_name"); fi
1475
1629
  else
1476
- _ONBOARD_MISSING+=("$_name")
1630
+ local _already=0 _existing
1631
+ for _existing in ${_ONBOARD_MISSING[@]+"${_ONBOARD_MISSING[@]}"}; do
1632
+ if [[ "$_existing" == "$_name" ]]; then _already=1; break; fi
1633
+ done
1634
+ if [[ $_already -eq 0 ]]; then _ONBOARD_MISSING+=("$_name"); fi
1477
1635
  fi
1478
1636
  done < "$ROLL_CONFIG"
1637
+ return 0
1479
1638
  }
1480
1639
 
1481
1640
  # US-ONBOARD-018: pick an agent for the onboard flow.
@@ -1776,6 +1935,26 @@ print(' '.join(p.get('scope', {}).get('approved', [])))
1776
1935
  _write_backlog "$project_dir/.roll/backlog.md"
1777
1936
  _onboard_changeset_record "$project_dir" "files_created" ".roll/backlog.md"
1778
1937
  fi
1938
+
1939
+ # US-AGENT-003: seed agent-routes.yaml. Template precedence:
1940
+ # 1. plan.agent_routes_template (set by $roll-onboard interactive flow)
1941
+ # 2. ROLL_AGENT_ROUTES_TEMPLATE env var
1942
+ # 3. "default"
1943
+ # Set to "skip" to omit seeding entirely.
1944
+ local _routes_template
1945
+ _routes_template=$(python3 -c "
1946
+ import yaml
1947
+ p = yaml.safe_load(open('$plan')) or {}
1948
+ print(p.get('agent_routes_template', '') or '')
1949
+ " 2>/dev/null || echo "")
1950
+ if [[ -z "$_routes_template" ]]; then
1951
+ _routes_template="${ROLL_AGENT_ROUTES_TEMPLATE:-default}"
1952
+ fi
1953
+ if [[ "$_routes_template" != "skip" ]]; then
1954
+ if _init_seed_agent_routes "$_routes_template" "$project_dir"; then
1955
+ _onboard_changeset_record "$project_dir" "files_created" ".roll/agent-routes.yaml"
1956
+ fi
1957
+ fi
1779
1958
  if [[ " $approved " == *" features "* ]]; then
1780
1959
  _ensure_features_dir "$project_dir/.roll/features"
1781
1960
  _write_features_md "$project_dir/.roll/features.md"
@@ -2294,6 +2473,31 @@ EOF
2294
2473
  _ROLL_MERGE_SUMMARY+=("created|.roll/backlog.md")
2295
2474
  }
2296
2475
 
2476
+ # US-AGENT-003: seed .roll/agent-routes.yaml from a named template (default /
2477
+ # minimal / heavy). Idempotent — leaves an existing file untouched. Templates
2478
+ # live under ${ROLL_TEMPLATES}/agent-routes/<name>.yaml.
2479
+ _init_seed_agent_routes() {
2480
+ local template_name="${1:-default}"
2481
+ local project_dir="${2:-$(pwd)}"
2482
+ local dest="${project_dir}/.roll/agent-routes.yaml"
2483
+
2484
+ if [[ -f "$dest" ]]; then
2485
+ _ROLL_MERGE_SUMMARY+=("unchanged|.roll/agent-routes.yaml")
2486
+ return 0
2487
+ fi
2488
+
2489
+ local src="${ROLL_TEMPLATES}/agent-routes/${template_name}.yaml"
2490
+ if [[ ! -f "$src" ]]; then
2491
+ err "agent-routes template not found: ${template_name} (looked for ${src})"
2492
+ return 1
2493
+ fi
2494
+
2495
+ mkdir -p "$(dirname "$dest")"
2496
+ cp "$src" "$dest"
2497
+ ok "Created: .roll/agent-routes.yaml (template: ${template_name})"
2498
+ _ROLL_MERGE_SUMMARY+=("created|.roll/agent-routes.yaml")
2499
+ }
2500
+
2297
2501
  _ensure_features_dir() {
2298
2502
  if [[ -d "$1" ]]; then
2299
2503
  _ROLL_MERGE_SUMMARY+=("unchanged|.roll/features/")
@@ -4624,13 +4828,16 @@ _isolation_tart_check_binary() {
4624
4828
  # returns 1 silently otherwise. Caller decides what to do.
4625
4829
  _isolation_tart_vm_present() {
4626
4830
  local name; name=$(_isolation_tart_vm_name)
4627
- tart list 2>/dev/null | awk -v n="$name" '$1 == n { found=1 } END { exit !found }'
4831
+ tart list 2>/dev/null | awk -v n="$name" '$2 == n { found=1 } END { exit !found }'
4628
4832
  }
4629
4833
 
4630
4834
  # Returns the VM's IP on stdout when reachable; exit non-zero when the VM
4631
4835
  # is stopped or `tart ip` fails for any other reason.
4632
4836
  _isolation_tart_ip() {
4633
4837
  local name; name=$(_isolation_tart_vm_name)
4838
+ # FIX: tart ip returns a stale DHCP-cached IP even for stopped VMs.
4839
+ # Gate on tart list State field before trusting the IP.
4840
+ tart list 2>/dev/null | awk -v n="$name" '$2 == n && $NF == "running" { found=1 } END { exit !found }' || return 1
4634
4841
  local ip; ip=$(tart ip "$name" 2>/dev/null) || return 1
4635
4842
  [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1
4636
4843
  printf '%s\n' "$ip"
@@ -4680,7 +4887,7 @@ _isolation_tart_provision() {
4680
4887
  local ip; ip=$(_isolation_tart_ip) || { err "tart provision: VM not running"; return 1; }
4681
4888
  local user; user=$(_isolation_tart_ssh_user)
4682
4889
  ssh -o BatchMode=yes -o StrictHostKeyChecking=no \
4683
- "${user}@${ip}" "brew list bats >/dev/null 2>&1 || brew install bats-core; \
4890
+ "${user}@${ip}" "export PATH=/opt/homebrew/bin:/usr/local/bin:\$PATH; brew list bats >/dev/null 2>&1 || brew install bats-core; \
4684
4891
  brew list node >/dev/null 2>&1 || brew install node; \
4685
4892
  brew list bash >/dev/null 2>&1 || brew install bash"
4686
4893
  }
@@ -4695,7 +4902,7 @@ _isolation_tart_exec() {
4695
4902
  if ! ip=$(_isolation_tart_ip); then
4696
4903
  # VM stopped — start it in the background with the repo mounted.
4697
4904
  local repo_root; repo_root="$(pwd -P)"
4698
- tart run --dir="roll:${repo_root}" "$name" >/dev/null 2>&1 &
4905
+ tart run --no-graphics --dir="roll:${repo_root}" "$name" >/dev/null 2>&1 &
4699
4906
  # Wait up to ~30s for IP to come up.
4700
4907
  local i=0
4701
4908
  while (( i < 30 )); do
@@ -4706,7 +4913,9 @@ _isolation_tart_exec() {
4706
4913
  [[ -n "${ip:-}" ]] || { err "tart exec: VM failed to start in 30s"; return 1; }
4707
4914
  fi
4708
4915
  local user; user=$(_isolation_tart_ssh_user)
4709
- ssh -o BatchMode=yes -o StrictHostKeyChecking=no "${user}@${ip}" "$@"
4916
+ local remote_cmd
4917
+ remote_cmd=$(printf '%q ' "$@")
4918
+ ssh -o BatchMode=yes -o StrictHostKeyChecking=no "${user}@${ip}" "export PATH=/opt/homebrew/bin:/usr/local/bin:\$PATH; cd '/Volumes/My Shared Files/roll' && $remote_cmd"
4710
4919
  }
4711
4920
 
4712
4921
  # reset: stop, delete, re-clone from base image, then re-provision.
@@ -4810,7 +5019,8 @@ Flags:
4810
5019
  --help, -h Show this help.
4811
5020
 
4812
5021
  Examples:
4813
- roll test Run the suite in whatever the config says.
5022
+ roll test Run affected tests (default: --affected HEAD~1).
5023
+ roll test -- tests/ Run the full suite explicitly.
4814
5024
  roll test -- --tier=fast Forward arguments to npm test.
4815
5025
  roll test --where Don't run; just report routing.
4816
5026
  roll test --reset Rebuild the VM (or host no-op).
@@ -4855,7 +5065,16 @@ EOF
4855
5065
  fi
4856
5066
 
4857
5067
  # Pass remaining args through to npm test inside the configured adapter.
4858
- _isolation_dispatch exec npm test "$@"
5068
+ # Default to --affected (HEAD~1 base) when the caller passes no extra args —
5069
+ # mirrors the pre-commit hook's intent and keeps VM runs fast.
5070
+ # To run the full suite explicitly: roll test -- tests/
5071
+ local _npm_args=("$@")
5072
+ if [[ "${#_npm_args[@]}" -eq 0 ]]; then
5073
+ _npm_args=(--affected)
5074
+ fi
5075
+ # Always pass args via `--` so npm doesn't intercept flags like --affected
5076
+ # as npm config options (npm warns and silently drops them otherwise).
5077
+ _isolation_dispatch exec npm test -- "${_npm_args[@]}"
4859
5078
  }
4860
5079
 
4861
5080
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -5431,6 +5650,28 @@ _loop_schedule_spec() {
5431
5650
  echo "60 $offset"
5432
5651
  }
5433
5652
 
5653
+ # Read loop active window from .roll/local.yaml loop_schedule block.
5654
+ # Resolution order:
5655
+ # 1. .roll/local.yaml loop_schedule.{active_start,active_end}
5656
+ # 2. default 0 / 24 (full day)
5657
+ # Validation: both values must be integers 0–24, active_start < active_end.
5658
+ # Output: "<start> <end>" on stdout.
5659
+ _loop_read_active_window() {
5660
+ local project_path="${1:-$(pwd -P)}"
5661
+ local local_file="${project_path}/.roll/local.yaml"
5662
+ if [[ -f "$local_file" ]]; then
5663
+ local val_start val_end
5664
+ val_start=$(awk '/^loop_schedule:/{found=1;next} found && /^[[:space:]]+active_start:/{print $2; exit}' "$local_file")
5665
+ val_end=$(awk '/^loop_schedule:/{found=1;next} found && /^[[:space:]]+active_end:/{print $2; exit}' "$local_file")
5666
+ if [[ "$val_start" =~ ^[0-9]+$ && "$val_end" =~ ^[0-9]+$ ]] \
5667
+ && (( val_start < val_end && val_end <= 24 )); then
5668
+ echo "$val_start $val_end"
5669
+ return 0
5670
+ fi
5671
+ fi
5672
+ echo "0 24"
5673
+ }
5674
+
5434
5675
  # US-LOOP-032: human-readable schedule description.
5435
5676
  # Args: period offset [lang]
5436
5677
  # lang: en (default) or zh
@@ -5914,12 +6155,22 @@ _runs_append() {
5914
6155
  # FIX-123: atomic write — write to .tmp.$$ first, then cat >> to append,
5915
6156
  # then remove. If interrupted between jq and rm, the next call cleans it.
5916
6157
  local _tmp="\$_runs_dst.tmp.\$\$"
6158
+ # US-AGENT-005/010: emit agent + story_type so historical hit rates and
6159
+ # status-page summaries have data to aggregate. Empty when not routed.
6160
+ local _agent_field="\${ROLL_LOOP_ROUTED_AGENT:-\${CYCLE_AGENT:-}}"
6161
+ local _story_field="\${ROLL_LOOP_ROUTED_STORY:-}"
6162
+ local _stype_field=""
6163
+ if [ -n "\$_story_field" ]; then
6164
+ _stype_field="\${_story_field%%-*}"
6165
+ fi
5917
6166
  jq -nc \\
5918
6167
  --arg ts "\$_ts_now" \\
5919
6168
  --arg project "${slug}" \\
5920
6169
  --arg run_id "\$_rid" \\
5921
6170
  --arg status "\$_status" \\
5922
6171
  --arg cycle_id "\$_cid" \\
6172
+ --arg agent "\$_agent_field" \\
6173
+ --arg story_type "\$_stype_field" \\
5923
6174
  --argjson built "\$_built" \\
5924
6175
  --argjson skipped '[]' \\
5925
6176
  --argjson alerts '[]' \\
@@ -5927,7 +6178,7 @@ _runs_append() {
5927
6178
  --argjson duration_sec "\$_dur" \\
5928
6179
  --argjson phases "\$_phases_json" \\
5929
6180
  '{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
5930
- cycle_id:\$cycle_id,
6181
+ cycle_id:\$cycle_id, agent:\$agent, story_type:\$story_type,
5931
6182
  built:\$built, skipped:\$skipped, alerts:\$alerts,
5932
6183
  tcr_count:\$tcr_count, duration_sec:\$duration_sec, phases:\$phases}' \\
5933
6184
  > "\$_tmp" 2>/dev/null || { rm -f "\$_tmp"; return 0; }
@@ -6153,7 +6404,27 @@ if _worktree_fetch_origin main \\
6153
6404
  _worktree_sync_meta "\$WT" 2>/dev/null || true
6154
6405
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
6155
6406
  _loop_event cycle_start "\${CYCLE_ID}" "" "" || true
6156
- _loop_event agent_used "\${CYCLE_ID}" "\$(_project_agent)" "primary" || true
6407
+ # US-AGENT-006: per-story routing pick the next Todo, route to an agent
6408
+ # based on its Agent profile + agent-routes.yaml, fall back to
6409
+ # _project_agent when no story is pickable or routing returns nothing.
6410
+ ROLL_LOOP_ROUTED_STORY=""
6411
+ ROLL_LOOP_ROUTED_AGENT=""
6412
+ ROLL_LOOP_ROUTED_RULE=""
6413
+ ROLL_LOOP_ROUTE_RATIONALE=""
6414
+ ( cd "\$WT" >/dev/null 2>&1 && ROLL_LOOP_ROUTED_STORY=\$(_loop_pick_next_story 2>/dev/null) || true )
6415
+ ROLL_LOOP_ROUTED_STORY=\$( (cd "\$WT" 2>/dev/null && _loop_pick_next_story 2>/dev/null) || echo "" )
6416
+ if [ -n "\$ROLL_LOOP_ROUTED_STORY" ]; then
6417
+ _route_line=\$( (cd "\$WT" 2>/dev/null && _loop_pick_agent_for_story "\$ROLL_LOOP_ROUTED_STORY" 2>/dev/null) || echo "" )
6418
+ if [ -n "\$_route_line" ]; then
6419
+ ROLL_LOOP_ROUTED_AGENT=\$(echo "\$_route_line" | awk '{print \$1}')
6420
+ ROLL_LOOP_ROUTED_RULE=\$(echo "\$_route_line" | awk '{print \$2}')
6421
+ ROLL_LOOP_ROUTE_RATIONALE=\$(echo "\$_route_line" | cut -d' ' -f3-)
6422
+ echo "[loop] story \${ROLL_LOOP_ROUTED_STORY} routed to \${ROLL_LOOP_ROUTED_AGENT} via \${ROLL_LOOP_ROUTED_RULE}"
6423
+ _loop_event story_routed "\${CYCLE_ID}" "\${ROLL_LOOP_ROUTED_STORY}" "\${ROLL_LOOP_ROUTED_AGENT}|\${ROLL_LOOP_ROUTED_RULE}" || true
6424
+ fi
6425
+ fi
6426
+ CYCLE_AGENT="\${ROLL_LOOP_ROUTED_AGENT:-\$(_project_agent)}"
6427
+ _loop_event agent_used "\${CYCLE_ID}" "\${CYCLE_AGENT}" "primary" || true
6157
6428
  _phase_end worktree_setup ok
6158
6429
  else
6159
6430
  # P3 fix: skip the cycle entirely when worktree isolation fails.
@@ -6180,7 +6451,9 @@ export LOOP_CYCLE_ID="\${CYCLE_ID}"
6180
6451
  export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
6181
6452
  # US-LOOP-010: tell loop-fmt.py which agent is running so it can branch
6182
6453
  # rendering: claude → stream-json parser, others → transparent passthrough.
6183
- export ROLL_LOOP_AGENT="\$(_project_agent)"
6454
+ # US-AGENT-006: prefer the per-story routed agent (set above) when present.
6455
+ export ROLL_LOOP_AGENT="\${CYCLE_AGENT:-\$(_project_agent)}"
6456
+ export ROLL_LOOP_ROUTED_STORY ROLL_LOOP_ROUTED_AGENT ROLL_LOOP_ROUTED_RULE
6184
6457
  _phase_begin agent_invoke
6185
6458
  for _attempt in 1 2 3; do
6186
6459
  # FIX-068: defensive reset before each attempt — _CYCLE_TIMED_OUT carries
@@ -6625,8 +6898,8 @@ _install_launchd_plists() {
6625
6898
  mkdir -p "${shared}/loop" "${shared}/dream" "${shared}/brief"
6626
6899
 
6627
6900
  local active_start active_end dream_hour dream_minute brief_hour brief_minute loop_period loop_offset
6628
- active_start=$(_config_read_int "loop_active_start" "10")
6629
- active_end=$(_config_read_int "loop_active_end" "18")
6901
+ local _aw; _aw=$(_loop_read_active_window "$project_path")
6902
+ active_start="${_aw%% *}"; active_end="${_aw##* }"
6630
6903
  # US-LOOP-012: use _loop_schedule_spec instead of raw loop_minute
6631
6904
  local loop_spec; loop_spec=$(_loop_schedule_spec "$project_path")
6632
6905
  loop_period="${loop_spec%% *}"
@@ -6757,6 +7030,8 @@ cmd_loop() {
6757
7030
  precheck-ci) _loop_precheck_ci ;;
6758
7031
  hotfix-head-context) _loop_hotfix_head_context "${1:-}" ;;
6759
7032
  branches) _loop_branches "$(pwd -P)" ;;
7033
+ agent-routes) _loop_agent_routes "${1:-show}" "${@:2}" ;;
7034
+ test-quality-check) _loop_test_quality_check "$@" ;;
6760
7035
  *) cat <<'HELP'
6761
7036
  Usage: roll loop <on|off|now|test|status|monitor|runs|log|story|events|attach|mute|unmute|pause|resume|reset|gc|branches>
6762
7037
 
@@ -6779,6 +7054,7 @@ Usage: roll loop <on|off|now|test|status|monitor|runs|log|story|events|attach|mu
6779
7054
  gc [--dry-run] [--keep-days N]
6780
7055
  Garbage-collect orphan slugs, tmp debris, expired backups
6781
7056
  branches List loop-related branches
7057
+ agent-routes Show / lint agent routing config (.roll/agent-routes.yaml)
6782
7058
 
6783
7059
  Internal (called by roll-loop SKILL):
6784
7060
  notify Send macOS notification
@@ -6802,8 +7078,8 @@ _loop_on() {
6802
7078
  local agent; agent=$(_project_agent)
6803
7079
 
6804
7080
  local active_start active_end loop_minute dream_hour dream_minute brief_hour brief_minute
6805
- active_start=$(_config_read_int "loop_active_start" "10")
6806
- active_end=$(_config_read_int "loop_active_end" "18")
7081
+ local _aw; _aw=$(_loop_read_active_window "$project_path")
7082
+ active_start="${_aw%% *}"; active_end="${_aw##* }"
6807
7083
  # US-LOOP-011: read schedule spec from project or global config
6808
7084
  local loop_spec loop_period loop_offset
6809
7085
  loop_spec=$(_loop_schedule_spec "$project_path")
@@ -6849,10 +7125,10 @@ _loop_on() {
6849
7125
  fi
6850
7126
 
6851
7127
  ok "$(msg loop.loop_enabled)"
6852
- printf "$(msg loop.roll_loop_s_active_02d_00)" \
7128
+ msg loop.roll_loop_s_active_02d_00 \
6853
7129
  "$loop_sched_en" "$active_start" "$active_end" "$loop_sched_zh" "$active_start" "$active_end"
6854
- printf "$(msg loop.roll_dream_daily_at_02d_02d)" "$dream_hour" "$dream_minute" "$dream_hour" "$dream_minute"
6855
- printf "$(msg loop.roll_brief_daily_at_02d_02d)" "$brief_hour" "$brief_minute" "$brief_hour" "$brief_minute"
7130
+ msg loop.roll_dream_daily_at_02d_02d "$dream_hour" "$dream_minute" "$dream_hour" "$dream_minute"
7131
+ msg loop.roll_brief_daily_at_02d_02d "$brief_hour" "$brief_minute" "$brief_hour" "$brief_minute"
6856
7132
  echo " • Agent: ${agent} (change: roll agent use <name>)"
6857
7133
  return 0
6858
7134
  fi
@@ -6880,10 +7156,10 @@ _loop_on() {
6880
7156
  ) | crontab -
6881
7157
 
6882
7158
  ok "$(msg loop.loop_enabled_2)"
6883
- printf "$(msg loop.roll_loop_s_active_02d_00_2)" \
7159
+ msg loop.roll_loop_s_active_02d_00_2 \
6884
7160
  "$loop_sched_en" "$active_start" "$active_end" "$loop_sched_zh" "$active_start" "$active_end"
6885
- printf "$(msg loop.roll_dream_daily_at_02d_02d_2)" "$dream_hour" "$dream_minute" "$dream_hour" "$dream_minute"
6886
- printf "$(msg loop.roll_brief_daily_at_02d_02d_2)" "$brief_hour" "$brief_minute" "$brief_hour" "$brief_minute"
7161
+ msg loop.roll_dream_daily_at_02d_02d_2 "$dream_hour" "$dream_minute" "$dream_hour" "$dream_minute"
7162
+ msg loop.roll_brief_daily_at_02d_02d_2 "$brief_hour" "$brief_minute" "$brief_hour" "$brief_minute"
6887
7163
  echo " • Agent: ${agent} (change: roll agent use <name>)"
6888
7164
  }
6889
7165
 
@@ -7023,8 +7299,8 @@ _loop_test() {
7023
7299
 
7024
7300
  # FIX-054: terminal preference removed — runner always uses Terminal.app.
7025
7301
  local active_start active_end
7026
- active_start=$(_config_read_int "loop_active_start" "10")
7027
- active_end=$(_config_read_int "loop_active_end" "18")
7302
+ local _aw; _aw=$(_loop_read_active_window "$project_path")
7303
+ active_start="${_aw%% *}"; active_end="${_aw##* }"
7028
7304
 
7029
7305
  info "$(msg loop.generating_test_runner_agent ${agent})"
7030
7306
  _write_loop_runner_script "$test_runner" "$project_path" \
@@ -8528,6 +8804,205 @@ _loop_mark_in_progress() {
8528
8804
  ' "$backlog" > "$tmp" && mv "$tmp" "$backlog"
8529
8805
  }
8530
8806
 
8807
+ # US-AGENT-008: _loop_mark_hold <story-id> <reason> [backlog-path]
8808
+ # Flip "🔨 In Progress" or "📋 Todo" row to "🚫 Hold" with a parenthetical
8809
+ # reason suffix appended to the description column. Idempotent — if the
8810
+ # row is already 🚫 Hold the call is a no-op (status compare is exact).
8811
+ _loop_mark_hold() {
8812
+ local story_id="$1"
8813
+ local reason="${2:-self-downgrade}"
8814
+ local backlog="${3:-${ROLL_MAIN_PROJECT:-$PWD}/.roll/backlog.md}"
8815
+ [ -n "$story_id" ] || return 1
8816
+ [ -f "$backlog" ] || return 0
8817
+ local tmp; tmp=$(mktemp "${backlog}.XXXXXX") || return 1
8818
+ awk -v sid="$story_id" -v reason="$reason" '
8819
+ {
8820
+ line = $0
8821
+ changed = 0
8822
+ if (index(line, "🔨 In Progress") > 0 || index(line, "📋 Todo") > 0) {
8823
+ n = split(line, cols, "|")
8824
+ if (n >= 2) {
8825
+ id_cell = cols[2]
8826
+ gsub(/[[:space:]]/, "", id_cell)
8827
+ sub(/^\[/, "", id_cell)
8828
+ sub(/\].*$/, "", id_cell)
8829
+ if (id_cell == sid) {
8830
+ sub(/🔨 In Progress/, "🚫 Hold", line)
8831
+ sub(/📋 Todo/, "🚫 Hold", line)
8832
+ # Append reason to the description column (cols[3]) only if not
8833
+ # already present.
8834
+ if (index(line, "→ " reason) == 0 && index(line, "(" reason ")") == 0) {
8835
+ # Insert before the trailing " | 🚫 Hold |"
8836
+ sub(/ \| 🚫 Hold \|/, " → " reason " | 🚫 Hold |", line)
8837
+ }
8838
+ changed = 1
8839
+ }
8840
+ }
8841
+ }
8842
+ print line
8843
+ }
8844
+ ' "$backlog" > "$tmp" && mv "$tmp" "$backlog"
8845
+ }
8846
+
8847
+ # US-AGENT-008: self-downgrade primitive.
8848
+ # _loop_self_downgrade <story-id> <reason> <sub-ids-csv>
8849
+ # Flips story to 🚫 Hold (with sub list embedded), writes ALERT, emits a
8850
+ # story_self_downgrade event. The actual sub-story rows + feature md are
8851
+ # produced by the SKILL invocation of roll-design --from-story (this helper
8852
+ # just records the contract).
8853
+ _loop_self_downgrade() {
8854
+ local story_id="$1"
8855
+ local reason="${2:-too_big}"
8856
+ local subs="${3:-}"
8857
+ [ -n "$story_id" ] || return 1
8858
+ local backlog="${ROLL_MAIN_PROJECT:-$PWD}/.roll/backlog.md"
8859
+ local annotation
8860
+ if [ -n "$subs" ]; then
8861
+ annotation="split to ${subs}"
8862
+ else
8863
+ annotation="$reason"
8864
+ fi
8865
+ _loop_mark_hold "$story_id" "$annotation" "$backlog" || true
8866
+
8867
+ # ALERT line for human visibility. Slug derives from main project dir.
8868
+ local main_dir="${ROLL_MAIN_PROJECT:-$PWD}"
8869
+ local slug; slug=$(_project_slug "$main_dir" 2>/dev/null || basename "$main_dir")
8870
+ local shared_root="${_SHARED_ROOT:-$HOME/.shared/roll}"
8871
+ local alert_file="${shared_root}/loop/ALERT-${slug}.md"
8872
+ mkdir -p "$(dirname "$alert_file")"
8873
+ printf '[%s] self-downgrade: %s — reason: %s; subs: %s\n' \
8874
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$story_id" "$reason" "${subs:-<none>}" \
8875
+ >> "$alert_file"
8876
+
8877
+ # Best-effort event emission (tolerates missing helpers).
8878
+ if declare -F _loop_event >/dev/null 2>&1; then
8879
+ _loop_event "agent_self_downgrade" "${LOOP_CYCLE_ID:-$story_id}" "$story_id" "${reason}|${subs}" || true
8880
+ fi
8881
+ echo "[loop] self-downgrade ${story_id}: ${reason}; subs=${subs:-<none>}"
8882
+ }
8883
+
8884
+ # US-AGENT-009: _loop_chain_depth_cap_check <story-id> [backlog]
8885
+ # Returns 0 when auto re-split is still allowed (story's chain_depth < 2),
8886
+ # 1 when the cap is hit (≥ 2 already — the third re-split would be #3 in
8887
+ # the chain). Reads chain_depth from the story's feature md profile;
8888
+ # missing profile is treated as depth 0 (split allowed).
8889
+ _loop_chain_depth_cap_check() {
8890
+ local story_id="$1"
8891
+ local backlog="${2:-${ROLL_MAIN_PROJECT:-$PWD}/.roll/backlog.md}"
8892
+ [ -n "$story_id" ] || return 0
8893
+ [ -f "$backlog" ] || return 0
8894
+
8895
+ # Resolve feature md from backlog link.
8896
+ local md_path
8897
+ md_path=$(grep -E "\[${story_id}\]\(" "$backlog" 2>/dev/null \
8898
+ | head -1 \
8899
+ | sed -E "s/.*\[${story_id}\]\(([^)#]+)#?[^)]*\).*/\1/")
8900
+ [ -n "$md_path" ] || return 0
8901
+ [ -f "$md_path" ] || return 0
8902
+
8903
+ # Find the section for this story id and extract chain_depth.
8904
+ local anchor; anchor=$(echo "$story_id" | tr '[:upper:]' '[:lower:]')
8905
+ local depth
8906
+ depth=$(awk -v anchor="$anchor" '
8907
+ /<a id="/ {
8908
+ if (match($0, /<a id="[^"]+"/)) {
8909
+ cur = substr($0, RSTART + 7, RLENGTH - 8)
8910
+ in_section = (cur == anchor)
8911
+ }
8912
+ next
8913
+ }
8914
+ in_section && /^- chain_depth:/ {
8915
+ gsub(/^- chain_depth:[ \t]*/, "")
8916
+ gsub(/[ \t].*$/, "")
8917
+ print
8918
+ exit
8919
+ }
8920
+ ' "$md_path")
8921
+
8922
+ # Empty / non-numeric → treat as 0.
8923
+ case "$depth" in
8924
+ ''|*[!0-9]*) depth=0 ;;
8925
+ esac
8926
+
8927
+ [ "$depth" -lt 2 ]
8928
+ }
8929
+
8930
+ # US-AGENT-009: cap-hit path. Story has reached chain_depth ≥ 2 — refuse
8931
+ # further auto re-split, flip 🚫 Hold + write a high-priority ALERT with
8932
+ # chain context for human triage.
8933
+ _loop_split_cap_hit() {
8934
+ local story_id="$1"
8935
+ local reason="${2:-cap-hit}"
8936
+ [ -n "$story_id" ] || return 1
8937
+ local backlog="${ROLL_MAIN_PROJECT:-$PWD}/.roll/backlog.md"
8938
+ _loop_mark_hold "$story_id" "StorySplitCapHit: $reason" "$backlog" || true
8939
+
8940
+ local main_dir="${ROLL_MAIN_PROJECT:-$PWD}"
8941
+ local slug; slug=$(_project_slug "$main_dir" 2>/dev/null || basename "$main_dir")
8942
+ local shared_root="${_SHARED_ROOT:-$HOME/.shared/roll}"
8943
+ local alert_file="${shared_root}/loop/ALERT-${slug}.md"
8944
+ mkdir -p "$(dirname "$alert_file")"
8945
+ printf '[%s] StorySplitCapHit: %s — chain_depth >= 2 (third auto-split refused). %s. Human triage required.\n' \
8946
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$story_id" "$reason" \
8947
+ >> "$alert_file"
8948
+
8949
+ if declare -F _loop_event >/dev/null 2>&1; then
8950
+ _loop_event "story_split_cap_hit" "${LOOP_CYCLE_ID:-$story_id}" "$story_id" "$reason" || true
8951
+ fi
8952
+ echo "[loop] StorySplitCapHit ${story_id}: chain_depth >= 2 — held for human triage"
8953
+ }
8954
+
8955
+ # US-SKILL-010: unified self-score note writer for roll-build /
8956
+ # roll-fix / roll-design. Lands under .roll/notes/ with YAML frontmatter
8957
+ # so subsequent stories (US-SKILL-014 trend, US-SKILL-015 docs) can
8958
+ # read/aggregate without parsing free text.
8959
+ #
8960
+ # Args: skill story_id score:int verdict [rationale...]
8961
+ _skill_write_self_score() {
8962
+ local skill="${1:-}"
8963
+ local story="${2:-}"
8964
+ local score="${3:-}"
8965
+ local verdict="${4:-}"
8966
+ shift 4 2>/dev/null || true
8967
+ local rationale="${*:-}"
8968
+
8969
+ case "$skill" in
8970
+ roll-build|roll-fix|roll-design) ;;
8971
+ *) err "_skill_write_self_score: skill must be roll-build / roll-fix / roll-design (got '$skill')"; return 1 ;;
8972
+ esac
8973
+ [ -n "$story" ] || { err "_skill_write_self_score: story id required"; return 1; }
8974
+ case "$score" in
8975
+ ''|*[!0-9]*) err "_skill_write_self_score: score must be integer 1..10"; return 1 ;;
8976
+ esac
8977
+ if [ "$score" -lt 1 ] || [ "$score" -gt 10 ]; then
8978
+ err "_skill_write_self_score: score out of range (1..10): $score"
8979
+ return 1
8980
+ fi
8981
+ case "$verdict" in
8982
+ good|ok|regression) ;;
8983
+ *) err "_skill_write_self_score: verdict must be good / ok / regression (got '$verdict')"; return 1 ;;
8984
+ esac
8985
+
8986
+ local notes_dir=".roll/notes"
8987
+ mkdir -p "$notes_dir"
8988
+ local ts; ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
8989
+ local date_part="${ts%%T*}"
8990
+ local epoch; epoch=$(date -u +%s)
8991
+ local file="${notes_dir}/${date_part}-${skill}-${story}-${epoch}.md"
8992
+
8993
+ {
8994
+ printf -- '---\n'
8995
+ printf 'skill: %s\n' "$skill"
8996
+ printf 'story: %s\n' "$story"
8997
+ printf 'score: %s\n' "$score"
8998
+ printf 'verdict: %s\n' "$verdict"
8999
+ printf 'ts: %s\n' "$ts"
9000
+ printf -- '---\n'
9001
+ printf '\n'
9002
+ printf '%s\n' "$rationale"
9003
+ } > "$file"
9004
+ }
9005
+
8531
9006
  # _loop_mark_todo <story-id> [backlog-path]
8532
9007
  # Revert a row from "🔨 In Progress" back to "📋 Todo". Called when a
8533
9008
  # cycle's executor fails so the next cycle can pick the story up again.
@@ -9058,6 +9533,218 @@ refs/heads/claude/*"
9058
9533
  return 0
9059
9534
  }
9060
9535
 
9536
+ # US-AGENT-002: agent-routes.yaml management.
9537
+ # `roll loop agent-routes [show|lint] [path]` — dispatch + helpers.
9538
+ # Default path order: $ROLL_AGENT_ROUTES (if set) → ./.roll/agent-routes.yaml
9539
+ # → built-in default in templates/agent-routes/default.yaml.
9540
+ _loop_agent_routes_path() {
9541
+ if [ -n "${ROLL_AGENT_ROUTES:-}" ] && [ -f "$ROLL_AGENT_ROUTES" ]; then
9542
+ printf '%s\n' "$ROLL_AGENT_ROUTES"
9543
+ return 0
9544
+ fi
9545
+ if [ -f ".roll/agent-routes.yaml" ]; then
9546
+ printf '%s\n' ".roll/agent-routes.yaml"
9547
+ return 0
9548
+ fi
9549
+ # Fallback: built-in default shipped with the package.
9550
+ # ROLL_INSTALL_DIR is set when installed via npm; fall back to script dir.
9551
+ local install_dir
9552
+ install_dir="${ROLL_INSTALL_DIR:-$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "$0")")")}"
9553
+ local default_tpl="$install_dir/templates/agent-routes/default.yaml"
9554
+ if [ -f "$default_tpl" ]; then
9555
+ printf '%s\n' "$default_tpl"
9556
+ return 0
9557
+ fi
9558
+ return 1
9559
+ }
9560
+
9561
+ _loop_agent_routes_show() {
9562
+ local path
9563
+ path=$(_loop_agent_routes_path) || {
9564
+ echo "agent-routes: no config found (set ROLL_AGENT_ROUTES, drop .roll/agent-routes.yaml, or run roll init)" >&2
9565
+ return 1
9566
+ }
9567
+ echo "# source: $path"
9568
+ cat "$path"
9569
+ }
9570
+
9571
+ _loop_agent_routes_lint() {
9572
+ local path="${1:-}"
9573
+ if [ -z "$path" ]; then
9574
+ path=$(_loop_agent_routes_path) || {
9575
+ echo "agent-routes lint: no config found" >&2
9576
+ return 1
9577
+ }
9578
+ fi
9579
+ if [ ! -f "$path" ]; then
9580
+ echo "agent-routes lint: file not found: $path" >&2
9581
+ return 1
9582
+ fi
9583
+ local install_dir
9584
+ install_dir="${ROLL_INSTALL_DIR:-$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "$0")")")}"
9585
+ python3 "$install_dir/lib/agent_routes_lint.py" "$path"
9586
+ }
9587
+
9588
+ # US-AGENT-006: pick the next eligible 📋 Todo story from .roll/backlog.md
9589
+ # applying the same gates as the roll-loop SKILL Step 2:
9590
+ # 1. Status = 📋 Todo
9591
+ # 2. NOT manual-only:* tagged
9592
+ # 3. depends-on:* (if any) all ✅ Done
9593
+ # 4. Priority order: FIX > US > REFACTOR
9594
+ #
9595
+ # stdout: chosen story id (single line)
9596
+ # exit 0 when picked, 1 when nothing eligible.
9597
+ _loop_pick_next_story() {
9598
+ local backlog="${1:-.roll/backlog.md}"
9599
+ [ -f "$backlog" ] || return 1
9600
+
9601
+ # Two passes over the file, once per type prefix, return first hit.
9602
+ local prefix
9603
+ for prefix in FIX US REFACTOR; do
9604
+ local id
9605
+ while IFS= read -r line; do
9606
+ [ -z "$line" ] && continue
9607
+ # Skip non-Todo rows fast
9608
+ case "$line" in
9609
+ *'📋 Todo'*) ;;
9610
+ *) continue ;;
9611
+ esac
9612
+ # Extract id like FIX-XXX-NNN / US-XXX-NNN / REFACTOR-XXX-NNN.
9613
+ id=$(printf '%s\n' "$line" | grep -oE "${prefix}-[A-Za-z0-9_-]+" | head -1)
9614
+ [ -n "$id" ] || continue
9615
+ # Gate 1: manual-only
9616
+ _loop_is_manual_only "$id" "$backlog" 2>/dev/null && continue
9617
+ # Gate 2: depends-on
9618
+ if ! _loop_check_depends_on "$id" "$backlog" >/dev/null 2>&1; then
9619
+ continue
9620
+ fi
9621
+ printf '%s\n' "$id"
9622
+ return 0
9623
+ done < "$backlog"
9624
+ done
9625
+ return 1
9626
+ }
9627
+
9628
+ # US-AGENT-004: pick the agent for a given backlog story by reading its
9629
+ # Agent profile from the linked feature md and matching against the active
9630
+ # agent-routes.yaml. Falls back to history.cold_start_default when no agent
9631
+ # matches or the story has no profile.
9632
+ #
9633
+ # stdout: "<agent> <rule_kind> <rationale...>"
9634
+ # exit 0 on success, 1 if the story id isn't found.
9635
+ _loop_pick_agent_for_story() {
9636
+ local story_id="${1:-}"
9637
+ if [ -z "$story_id" ]; then
9638
+ echo "_loop_pick_agent_for_story: story id required" >&2
9639
+ return 1
9640
+ fi
9641
+ local routes
9642
+ routes=$(_loop_agent_routes_path 2>/dev/null) || {
9643
+ echo "_loop_pick_agent_for_story: no agent-routes.yaml available" >&2
9644
+ return 1
9645
+ }
9646
+ local backlog=".roll/backlog.md"
9647
+ [ -f "$backlog" ] || {
9648
+ echo "_loop_pick_agent_for_story: $backlog not found" >&2
9649
+ return 1
9650
+ }
9651
+ local install_dir
9652
+ install_dir="${ROLL_INSTALL_DIR:-$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "$0")")")}"
9653
+ # US-AGENT-005: pass runs.jsonl when available so history soft preference
9654
+ # can layer on top of hard rules. Project-local first, then shared.
9655
+ local runs_arg=""
9656
+ if [ -f ".roll/runs.jsonl" ]; then
9657
+ runs_arg="--runs .roll/runs.jsonl"
9658
+ elif [ -f "${HOME}/.shared/roll/loop/runs.jsonl" ]; then
9659
+ runs_arg="--runs ${HOME}/.shared/roll/loop/runs.jsonl"
9660
+ fi
9661
+ # shellcheck disable=SC2086
9662
+ python3 "$install_dir/lib/loop_pick_agent.py" \
9663
+ --story-id "$story_id" \
9664
+ --backlog "$backlog" \
9665
+ --routes "$routes" \
9666
+ $runs_arg
9667
+ }
9668
+
9669
+ # US-QA-012: merge-time test-quality gate. Scan bats files for ❼ + ❽
9670
+ # violations; loop auto-merge waits on a clean exit. PR description
9671
+ # `[skip-test-quality]` marker → US-QA-013 passes --skip here.
9672
+ #
9673
+ # Usage:
9674
+ # roll loop test-quality-check [--skip] file.bats [file.bats ...]
9675
+ _loop_test_quality_check() {
9676
+ local install_dir
9677
+ install_dir="${ROLL_INSTALL_DIR:-$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "$0")")")}"
9678
+ python3 "$install_dir/lib/test_quality_gate.py" "$@"
9679
+ }
9680
+
9681
+ # US-QA-013: PR description marker check. Returns 0 if the body contains
9682
+ # `[skip-test-quality]` (case-insensitive); 1 otherwise.
9683
+ _loop_pr_body_has_skip_test_quality() {
9684
+ local body="${1:-}"
9685
+ [ -n "$body" ] || return 1
9686
+ printf '%s' "$body" | grep -qiE '\[skip-test-quality\]'
9687
+ }
9688
+
9689
+ # US-QA-013: gate + ALERT wrapper. Runs the test-quality gate; on
9690
+ # violations writes a structured ALERT-<slug>.md entry so the human (or
9691
+ # next brief) sees what blocked auto-merge. The wrapper is the entry
9692
+ # point loop calls; it accepts --skip to honor the PR bypass marker.
9693
+ _loop_test_quality_check_with_alert() {
9694
+ local skip=0
9695
+ if [ "${1:-}" = "--skip" ]; then
9696
+ skip=1; shift
9697
+ fi
9698
+ if [ "$skip" -eq 1 ]; then
9699
+ return 0
9700
+ fi
9701
+ local install_dir
9702
+ install_dir="${ROLL_INSTALL_DIR:-$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "$0")")")}"
9703
+ local report
9704
+ report=$(python3 "$install_dir/lib/test_quality_gate.py" "$@" 2>&1)
9705
+ local rc=$?
9706
+ if [ "$rc" -eq 0 ]; then
9707
+ return 0
9708
+ fi
9709
+
9710
+ local main_dir="${ROLL_MAIN_PROJECT:-$PWD}"
9711
+ local slug; slug=$(_project_slug "$main_dir" 2>/dev/null || basename "$main_dir")
9712
+ local shared_root="${_SHARED_ROOT:-$HOME/.shared/roll}"
9713
+ local alert_file="${shared_root}/loop/ALERT-${slug}.md"
9714
+ mkdir -p "$(dirname "$alert_file")"
9715
+ {
9716
+ printf '[%s] test-quality gate blocked auto-merge (rubric ❼ / ❽).\n' \
9717
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
9718
+ printf '%s\n' "$report"
9719
+ printf 'Bypass: add `[skip-test-quality]` to the PR description and re-run.\n'
9720
+ printf '\n'
9721
+ } >> "$alert_file"
9722
+
9723
+ if declare -F _loop_event >/dev/null 2>&1; then
9724
+ _loop_event "test_quality_gate_block" "${LOOP_CYCLE_ID:-test-quality}" "${1:-}" "$report" || true
9725
+ fi
9726
+ echo "[loop] test-quality gate blocked: see $alert_file" >&2
9727
+ return 1
9728
+ }
9729
+
9730
+ _loop_agent_routes() {
9731
+ local sub="${1:-show}"; shift || true
9732
+ case "$sub" in
9733
+ show) _loop_agent_routes_show ;;
9734
+ lint) _loop_agent_routes_lint "${1:-}" ;;
9735
+ path) _loop_agent_routes_path ;;
9736
+ *)
9737
+ cat >&2 <<'HELP'
9738
+ Usage: roll loop agent-routes <show|lint|path> [path]
9739
+
9740
+ show Print active agent-routes config (source + content)
9741
+ lint [path] Validate schema (default: active config). Exit 1 on errors.
9742
+ path Print which file is currently active.
9743
+ HELP
9744
+ return 1 ;;
9745
+ esac
9746
+ }
9747
+
9061
9748
  # US-AUTO-033: publish a loop cycle branch as a GitHub PR with auto-merge.
9062
9749
  #
9063
9750
  # _loop_publish_pr <branch> [title]
@@ -9307,8 +9994,8 @@ _loop_monitor() {
9307
9994
  echo -e "$(msg loop.services ${BOLD} ${NC} ${CYAN} ${agent})"
9308
9995
  if [[ "$(uname)" == "Darwin" ]]; then
9309
9996
  local active_start active_end dream_hour dream_minute brief_hour brief_minute
9310
- active_start=$(_config_read_int "loop_active_start" "10")
9311
- active_end=$(_config_read_int "loop_active_end" "18")
9997
+ local _aw; _aw=$(_loop_read_active_window "$project_path")
9998
+ active_start="${_aw%% *}"; active_end="${_aw##* }"
9312
9999
  # US-LOOP-013: use schedule spec for display
9313
10000
  local loop_spec loop_period loop_offset
9314
10001
  loop_spec=$(_loop_schedule_spec "$project_path")
@@ -9629,6 +10316,185 @@ cmd_alert() {
9629
10316
  esac
9630
10317
  }
9631
10318
 
10319
+ # ═══════════════════════════════════════════════════════════════════════════════
10320
+ # FEEDBACK — one-shot GitHub issue from the CLI (US-FB-001)
10321
+ # ═══════════════════════════════════════════════════════════════════════════════
10322
+
10323
+ # Derive owner/repo from git origin. Returns "" when not a github remote.
10324
+ _feedback_origin_repo() {
10325
+ local url
10326
+ url=$(git remote get-url origin 2>/dev/null) || return 0
10327
+ case "$url" in
10328
+ git@github.com:*)
10329
+ url="${url#git@github.com:}"
10330
+ url="${url%.git}"
10331
+ printf '%s\n' "$url"
10332
+ ;;
10333
+ https://github.com/*)
10334
+ url="${url#https://github.com/}"
10335
+ url="${url%.git}"
10336
+ printf '%s\n' "$url"
10337
+ ;;
10338
+ *) printf '\n' ;;
10339
+ esac
10340
+ }
10341
+
10342
+ # US-FB-003: feedback target repo precedence:
10343
+ # 1. --repo flag (caller already resolved this; not part of this helper)
10344
+ # 2. ROLL_FEEDBACK_REPO env var
10345
+ # 3. .roll/local.yaml `feedback_repo:`
10346
+ # 4. ~/.roll/config.yaml `feedback_repo:`
10347
+ # 5. origin-derived github owner/repo
10348
+ _feedback_yaml_field() {
10349
+ local file="$1" field="$2"
10350
+ [ -f "$file" ] || return 0
10351
+ awk -v key="$field" '
10352
+ $0 ~ "^"key":" {
10353
+ v=$0; sub("^"key":[[:space:]]*", "", v); gsub("^[\"\x27]|[\"\x27]$", "", v); print v; exit
10354
+ }' "$file"
10355
+ }
10356
+
10357
+ _feedback_default_repo() {
10358
+ if [ -n "${ROLL_FEEDBACK_REPO:-}" ]; then
10359
+ printf '%s\n' "$ROLL_FEEDBACK_REPO"
10360
+ return 0
10361
+ fi
10362
+ local project_local=".roll/local.yaml"
10363
+ local v
10364
+ v=$(_feedback_yaml_field "$project_local" "feedback_repo")
10365
+ if [ -n "$v" ]; then
10366
+ printf '%s\n' "$v"
10367
+ return 0
10368
+ fi
10369
+ local global="${HOME}/.roll/config.yaml"
10370
+ v=$(_feedback_yaml_field "$global" "feedback_repo")
10371
+ if [ -n "$v" ]; then
10372
+ printf '%s\n' "$v"
10373
+ return 0
10374
+ fi
10375
+ _feedback_origin_repo
10376
+ }
10377
+
10378
+ # Map --type to GitHub label list (single, no spaces).
10379
+ _feedback_label_for_type() {
10380
+ case "${1:-}" in
10381
+ bug) printf 'bug,FIX\n' ;;
10382
+ idea) printf 'idea,enhancement,US\n' ;;
10383
+ ux) printf 'ux,enhancement\n' ;;
10384
+ *) printf 'feedback\n' ;;
10385
+ esac
10386
+ }
10387
+
10388
+ # Percent-encode for use in a GitHub issue URL query string.
10389
+ _feedback_urlencode() {
10390
+ python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1"
10391
+ }
10392
+
10393
+ # US-FB-002: compose env info appendix attached to feedback body unless
10394
+ # --no-env is set. Single source of truth so future feedback paths
10395
+ # (web embedded, slack, etc.) can reuse.
10396
+ _feedback_env_block() {
10397
+ local roll_v os_name shell_name agent lang project
10398
+ roll_v="${VERSION:-unknown}"
10399
+ os_name="$(uname -srm 2>/dev/null || echo unknown)"
10400
+ shell_name="$(basename "${SHELL:-/bin/sh}")"
10401
+ agent=$(_project_agent 2>/dev/null || echo "unknown")
10402
+ lang="${LANG:-${LC_ALL:-unknown}}"
10403
+ project="$(basename "$(pwd -P)")"
10404
+ cat <<EOF
10405
+
10406
+ ---
10407
+
10408
+ ### Environment
10409
+ - roll version: $roll_v
10410
+ - OS: $os_name
10411
+ - shell: $shell_name
10412
+ - current agent: $agent
10413
+ - language: $lang
10414
+ - project: $project
10415
+ EOF
10416
+ }
10417
+
10418
+ cmd_feedback() {
10419
+ local type="" title="" body="" repo="" print_url=0 attach_env=1
10420
+ while [ $# -gt 0 ]; do
10421
+ case "$1" in
10422
+ --type) type="$2"; shift 2 ;;
10423
+ --title) title="$2"; shift 2 ;;
10424
+ --body) body="$2"; shift 2 ;;
10425
+ --repo) repo="$2"; shift 2 ;;
10426
+ --no-env) attach_env=0; shift ;;
10427
+ --print-url) print_url=1; shift ;;
10428
+ --help|-h)
10429
+ cat <<'HELP'
10430
+ Usage: roll feedback [options]
10431
+ roll feedback (一句话提反馈)
10432
+
10433
+ Open a GitHub issue from the CLI. Type auto-labels (bug → FIX label;
10434
+ idea → US label; ux → ux label).
10435
+
10436
+ Options:
10437
+ --type <bug|idea|ux> Classify the feedback (default: bug)
10438
+ --title <text> Issue title (required)
10439
+ --body <text> Issue body
10440
+ --repo <owner/repo> Target repo (default: derived from origin)
10441
+ --no-env Skip the auto-attached Environment section
10442
+ (roll version, OS, agent, language, project)
10443
+ --print-url Print the prefilled github.com URL instead of
10444
+ invoking `gh`. Falls back to this automatically
10445
+ when `gh` is not installed.
10446
+ HELP
10447
+ return 0 ;;
10448
+ *)
10449
+ err "feedback: unknown flag $1"
10450
+ return 1 ;;
10451
+ esac
10452
+ done
10453
+
10454
+ if [ -z "$title" ]; then
10455
+ err "feedback: --title is required"
10456
+ return 1
10457
+ fi
10458
+ if [ -z "$type" ]; then
10459
+ type="bug"
10460
+ fi
10461
+ case "$type" in
10462
+ bug|idea|ux) ;;
10463
+ *)
10464
+ err "feedback: unknown --type '$type' (expected one of: bug, idea, ux)"
10465
+ return 1 ;;
10466
+ esac
10467
+
10468
+ if [ -z "$repo" ]; then
10469
+ repo=$(_feedback_default_repo)
10470
+ fi
10471
+ if [ -z "$repo" ]; then
10472
+ err "feedback: cannot derive owner/repo from origin; pass --repo owner/repo"
10473
+ return 1
10474
+ fi
10475
+
10476
+ # US-FB-002: compose final body with optional env appendix.
10477
+ if [ "$attach_env" -eq 1 ]; then
10478
+ body="${body}$(_feedback_env_block)"
10479
+ fi
10480
+
10481
+ local labels; labels=$(_feedback_label_for_type "$type")
10482
+
10483
+ # Decide path: --print-url or gh missing → print URL; else use gh.
10484
+ if [ "$print_url" -eq 1 ] || ! command -v gh >/dev/null 2>&1; then
10485
+ local t_enc b_enc l_enc
10486
+ t_enc=$(_feedback_urlencode "$title")
10487
+ b_enc=$(_feedback_urlencode "$body")
10488
+ l_enc=$(_feedback_urlencode "$labels")
10489
+ printf 'https://github.com/%s/issues/new?title=%s&body=%s&labels=%s\n' \
10490
+ "$repo" "$t_enc" "$b_enc" "$l_enc"
10491
+ return 0
10492
+ fi
10493
+
10494
+ # Real path: gh issue create
10495
+ gh issue create --repo "$repo" --title "$title" --body "$body" --label "$labels"
10496
+ }
10497
+
9632
10498
  # ═══════════════════════════════════════════════════════════════════════════════
9633
10499
  # LANG — switch / inspect Roll's UI language (US-I18N-001)
9634
10500
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -10178,8 +11044,8 @@ _legacy_home() {
10178
11044
  crontab -l 2>/dev/null | grep -q "${_LOOP_TAG}:${project_path}" && loop_state="enabled"
10179
11045
  fi
10180
11046
  local active_start active_end dream_hour dream_minute brief_hour brief_minute
10181
- active_start=$(_config_read_int "loop_active_start" "10")
10182
- active_end=$(_config_read_int "loop_active_end" "18")
11047
+ local _aw; _aw=$(_loop_read_active_window "$project_path")
11048
+ active_start="${_aw%% *}"; active_end="${_aw##* }"
10183
11049
  # US-LOOP-013: use schedule spec for display
10184
11050
  local loop_spec loop_period loop_offset
10185
11051
  loop_spec=$(_loop_schedule_spec "$project_path")
@@ -10489,6 +11355,7 @@ main() {
10489
11355
  brief) cmd_brief "$@" ;;
10490
11356
  backlog) cmd_backlog "$@" ;;
10491
11357
  alert) cmd_alert "$@" ;;
11358
+ feedback) cmd_feedback "$@" ;;
10492
11359
  lang) cmd_lang "$@" ;;
10493
11360
  agent) cmd_agent "$@" ;;
10494
11361
  ci) cmd_ci "$@" ;;