@seanyao/roll 2026.529.1 → 2026.529.3

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.529.1"
7
+ VERSION="2026.529.3"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -1406,6 +1406,10 @@ cmd_init() {
1406
1406
  _write_backlog "$project_dir/.roll/backlog.md"
1407
1407
  _ensure_features_dir "$project_dir/.roll/features"
1408
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
1409
1413
  # US-ONBOARD-019: stamp the project so legacy detection can recognise it
1410
1414
  # as Roll-onboarded without depending on directory-name heuristics.
1411
1415
  _write_version_stamp "$project_dir"
@@ -1931,6 +1935,26 @@ print(' '.join(p.get('scope', {}).get('approved', [])))
1931
1935
  _write_backlog "$project_dir/.roll/backlog.md"
1932
1936
  _onboard_changeset_record "$project_dir" "files_created" ".roll/backlog.md"
1933
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
1934
1958
  if [[ " $approved " == *" features "* ]]; then
1935
1959
  _ensure_features_dir "$project_dir/.roll/features"
1936
1960
  _write_features_md "$project_dir/.roll/features.md"
@@ -2449,6 +2473,31 @@ EOF
2449
2473
  _ROLL_MERGE_SUMMARY+=("created|.roll/backlog.md")
2450
2474
  }
2451
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
+
2452
2501
  _ensure_features_dir() {
2453
2502
  if [[ -d "$1" ]]; then
2454
2503
  _ROLL_MERGE_SUMMARY+=("unchanged|.roll/features/")
@@ -3340,7 +3389,7 @@ _agent_argv() {
3340
3389
  fi
3341
3390
  case "$mode" in
3342
3391
  interactive) _AGENT_ARGV=("$_kimi_bin" "$prompt") ;;
3343
- *) _AGENT_ARGV=("$_kimi_bin" --quiet -p "$prompt") ;;
3392
+ *) _AGENT_ARGV=("$_kimi_bin" -p "$prompt") ;; # FIX-133: kimi-code 无 --quiet,-p 自带 auto 审批
3344
3393
  esac ;;
3345
3394
  deepseek)
3346
3395
  # deepseek has the same argv shape in both modes (positional prompt).
@@ -5905,6 +5954,10 @@ _write_runner_script() {
5905
5954
  _write_loop_runner_script() {
5906
5955
  local script_path="$1" project_path="$2" cmd="$3" log_path="$4"
5907
5956
  local active_start="${5:-10}" active_end="${6:-18}"
5957
+ # FIX-134: skill md path. When set, the inner script rebuilds the agent
5958
+ # command at runtime from the routed cycle agent; when empty it falls back to
5959
+ # the baked command (backwards compatible with callers that omit it).
5960
+ local skill_path="${7:-}"
5908
5961
  # FIX-054: terminal preference detection removed. Popup is hard-coded to
5909
5962
  # macOS Terminal.app; the 7th positional arg, if any, is ignored for
5910
5963
  # backwards compatibility with existing callers.
@@ -5936,6 +5989,15 @@ _write_loop_runner_script() {
5936
5989
  agent_cmd="${agent_cmd/--output-format stream-json/--output-format stream-json --add-dir \"\$WT\"}"
5937
5990
  fi
5938
5991
  local slug; slug=$(_project_slug "$project_path")
5992
+ # FIX-134: emit a runtime command-builder line when skill_path is known, so
5993
+ # the cycle agent is resolved live (routing-aware). Otherwise leave _CYCLE_CMD
5994
+ # empty and the inner script uses the baked fallback command below.
5995
+ local cycle_cmd_line
5996
+ if [[ -n "$skill_path" ]]; then
5997
+ cycle_cmd_line="_CYCLE_CMD=\$(_loop_cycle_agent_cmd \"${skill_path}\" \"\$CYCLE_AGENT\" \"\$WT\" 2>/dev/null || true)"
5998
+ else
5999
+ cycle_cmd_line="_CYCLE_CMD="
6000
+ fi
5939
6001
  cat > "$inner_path" << INNER
5940
6002
  #!/bin/bash -l
5941
6003
  set -o pipefail
@@ -6106,12 +6168,22 @@ _runs_append() {
6106
6168
  # FIX-123: atomic write — write to .tmp.$$ first, then cat >> to append,
6107
6169
  # then remove. If interrupted between jq and rm, the next call cleans it.
6108
6170
  local _tmp="\$_runs_dst.tmp.\$\$"
6171
+ # US-AGENT-005/010: emit agent + story_type so historical hit rates and
6172
+ # status-page summaries have data to aggregate. Empty when not routed.
6173
+ local _agent_field="\${ROLL_LOOP_ROUTED_AGENT:-\${CYCLE_AGENT:-}}"
6174
+ local _story_field="\${ROLL_LOOP_ROUTED_STORY:-}"
6175
+ local _stype_field=""
6176
+ if [ -n "\$_story_field" ]; then
6177
+ _stype_field="\${_story_field%%-*}"
6178
+ fi
6109
6179
  jq -nc \\
6110
6180
  --arg ts "\$_ts_now" \\
6111
6181
  --arg project "${slug}" \\
6112
6182
  --arg run_id "\$_rid" \\
6113
6183
  --arg status "\$_status" \\
6114
6184
  --arg cycle_id "\$_cid" \\
6185
+ --arg agent "\$_agent_field" \\
6186
+ --arg story_type "\$_stype_field" \\
6115
6187
  --argjson built "\$_built" \\
6116
6188
  --argjson skipped '[]' \\
6117
6189
  --argjson alerts '[]' \\
@@ -6119,7 +6191,7 @@ _runs_append() {
6119
6191
  --argjson duration_sec "\$_dur" \\
6120
6192
  --argjson phases "\$_phases_json" \\
6121
6193
  '{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
6122
- cycle_id:\$cycle_id,
6194
+ cycle_id:\$cycle_id, agent:\$agent, story_type:\$story_type,
6123
6195
  built:\$built, skipped:\$skipped, alerts:\$alerts,
6124
6196
  tcr_count:\$tcr_count, duration_sec:\$duration_sec, phases:\$phases}' \\
6125
6197
  > "\$_tmp" 2>/dev/null || { rm -f "\$_tmp"; return 0; }
@@ -6345,7 +6417,27 @@ if _worktree_fetch_origin main \\
6345
6417
  _worktree_sync_meta "\$WT" 2>/dev/null || true
6346
6418
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
6347
6419
  _loop_event cycle_start "\${CYCLE_ID}" "" "" || true
6348
- _loop_event agent_used "\${CYCLE_ID}" "\$(_project_agent)" "primary" || true
6420
+ # US-AGENT-006: per-story routing pick the next Todo, route to an agent
6421
+ # based on its Agent profile + agent-routes.yaml, fall back to
6422
+ # _project_agent when no story is pickable or routing returns nothing.
6423
+ ROLL_LOOP_ROUTED_STORY=""
6424
+ ROLL_LOOP_ROUTED_AGENT=""
6425
+ ROLL_LOOP_ROUTED_RULE=""
6426
+ ROLL_LOOP_ROUTE_RATIONALE=""
6427
+ ( cd "\$WT" >/dev/null 2>&1 && ROLL_LOOP_ROUTED_STORY=\$(_loop_pick_next_story 2>/dev/null) || true )
6428
+ ROLL_LOOP_ROUTED_STORY=\$( (cd "\$WT" 2>/dev/null && _loop_pick_next_story 2>/dev/null) || echo "" )
6429
+ if [ -n "\$ROLL_LOOP_ROUTED_STORY" ]; then
6430
+ _route_line=\$( (cd "\$WT" 2>/dev/null && _loop_pick_agent_for_story "\$ROLL_LOOP_ROUTED_STORY" 2>/dev/null) || echo "" )
6431
+ if [ -n "\$_route_line" ]; then
6432
+ ROLL_LOOP_ROUTED_AGENT=\$(echo "\$_route_line" | awk '{print \$1}')
6433
+ ROLL_LOOP_ROUTED_RULE=\$(echo "\$_route_line" | awk '{print \$2}')
6434
+ ROLL_LOOP_ROUTE_RATIONALE=\$(echo "\$_route_line" | cut -d' ' -f3-)
6435
+ echo "[loop] story \${ROLL_LOOP_ROUTED_STORY} routed to \${ROLL_LOOP_ROUTED_AGENT} via \${ROLL_LOOP_ROUTED_RULE}"
6436
+ _loop_event story_routed "\${CYCLE_ID}" "\${ROLL_LOOP_ROUTED_STORY}" "\${ROLL_LOOP_ROUTED_AGENT}|\${ROLL_LOOP_ROUTED_RULE}" || true
6437
+ fi
6438
+ fi
6439
+ CYCLE_AGENT="\${ROLL_LOOP_ROUTED_AGENT:-\$(_project_agent)}"
6440
+ _loop_event agent_used "\${CYCLE_ID}" "\${CYCLE_AGENT}" "primary" || true
6349
6441
  _phase_end worktree_setup ok
6350
6442
  else
6351
6443
  # P3 fix: skip the cycle entirely when worktree isolation fails.
@@ -6372,7 +6464,9 @@ export LOOP_CYCLE_ID="\${CYCLE_ID}"
6372
6464
  export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
6373
6465
  # US-LOOP-010: tell loop-fmt.py which agent is running so it can branch
6374
6466
  # rendering: claude → stream-json parser, others → transparent passthrough.
6375
- export ROLL_LOOP_AGENT="\$(_project_agent)"
6467
+ # US-AGENT-006: prefer the per-story routed agent (set above) when present.
6468
+ export ROLL_LOOP_AGENT="\${CYCLE_AGENT:-\$(_project_agent)}"
6469
+ export ROLL_LOOP_ROUTED_STORY ROLL_LOOP_ROUTED_AGENT ROLL_LOOP_ROUTED_RULE
6376
6470
  _phase_begin agent_invoke
6377
6471
  for _attempt in 1 2 3; do
6378
6472
  # FIX-068: defensive reset before each attempt — _CYCLE_TIMED_OUT carries
@@ -6395,10 +6489,15 @@ for _attempt in 1 2 3; do
6395
6489
  pkill -KILL -f "\$WT" 2>/dev/null
6396
6490
  } ) &
6397
6491
  _WATCHDOG_PID=\$!
6492
+ ${cycle_cmd_line}
6493
+ # FIX-134: prefer the runtime-rebuilt command (routing-aware); fall back to
6494
+ # the baked command (project agent at \`roll loop on\` time) when empty.
6398
6495
  if [ -f "\$FMT" ]; then
6399
- ( cd "\$WT" && ${agent_cmd} ) | python3 "\$FMT"
6496
+ if [ -n "\$_CYCLE_CMD" ]; then ( cd "\$WT" && eval "\$_CYCLE_CMD" ) | python3 "\$FMT"
6497
+ else ( cd "\$WT" && ${agent_cmd} ) | python3 "\$FMT"; fi
6400
6498
  else
6401
- ( cd "\$WT" && ${agent_cmd} )
6499
+ if [ -n "\$_CYCLE_CMD" ]; then ( cd "\$WT" && eval "\$_CYCLE_CMD" )
6500
+ else ( cd "\$WT" && ${agent_cmd} ); fi
6402
6501
  fi
6403
6502
  _exit=\$?
6404
6503
  kill "\$_WATCHDOG_PID" 2>/dev/null
@@ -6858,7 +6957,7 @@ _install_launchd_plists() {
6858
6957
  local cmd; cmd=$(_agent_skill_cmd "${sd}/${skill}/SKILL.md" 2>/dev/null || echo "roll loop now")
6859
6958
 
6860
6959
  if [[ "$svc" == "loop" ]]; then
6861
- _write_loop_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log" "$active_start" "$active_end"
6960
+ _write_loop_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log" "$active_start" "$active_end" "${sd}/${skill}/SKILL.md"
6862
6961
  else
6863
6962
  _write_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log"
6864
6963
  fi
@@ -6903,7 +7002,9 @@ _install_launchd_plists() {
6903
7002
 
6904
7003
  _agent_skill_cmd() {
6905
7004
  local skill_path="$1"
6906
- local agent; agent=$(_project_agent)
7005
+ # FIX-134: accept an explicit agent (loop routing passes the resolved cycle
7006
+ # agent); default to the project agent for non-routed callers.
7007
+ local agent="${2:-$(_project_agent)}"
6907
7008
  local strip="awk 'NR==1 && /^---$/{skip=1;next} skip && /^---$/{skip=0;next} !skip{print}' '${skill_path}'"
6908
7009
  _agent_argv "$agent" plain "__PROMPT__" || {
6909
7010
  err "Unknown agent '${agent}'. Run: roll agent use <claude|kimi|deepseek|pi|openai|codex|opencode|qwen|gemini>"
@@ -6924,6 +7025,23 @@ _agent_skill_cmd() {
6924
7025
  echo "${out} \"\$(${strip})\""
6925
7026
  }
6926
7027
 
7028
+ # FIX-134: build the full per-cycle agent command at RUNTIME, routing-aware.
7029
+ # The loop inner script calls this with the resolved cycle agent (CYCLE_AGENT =
7030
+ # ROLL_LOOP_ROUTED_AGENT or project agent) so routing actually switches the
7031
+ # executed binary — instead of running a constant baked at `roll loop on` time.
7032
+ # Reproduces the claude-only verbose / stream-json / add-dir enhancements that
7033
+ # _write_loop_runner_script previously baked into the runner.
7034
+ _loop_cycle_agent_cmd() {
7035
+ local skill_path="$1" agent="${2:-$(_project_agent)}" wt="${3:-$WT}"
7036
+ [ -n "$skill_path" ] || return 1
7037
+ local cmd; cmd=$(_agent_skill_cmd "$skill_path" "$agent") || return 1
7038
+ cmd="${cmd/claude -p/claude -p --verbose --dangerously-skip-permissions --output-format stream-json}"
7039
+ if [[ "$cmd" == *"--output-format stream-json"* ]]; then
7040
+ cmd="${cmd/--output-format stream-json/--output-format stream-json --add-dir \"$wt\"}"
7041
+ fi
7042
+ printf '%s' "$cmd"
7043
+ }
7044
+
6927
7045
  cmd_loop() {
6928
7046
  local subcmd="${1:-status}"; shift || true
6929
7047
  case "$subcmd" in
@@ -6949,6 +7067,8 @@ cmd_loop() {
6949
7067
  precheck-ci) _loop_precheck_ci ;;
6950
7068
  hotfix-head-context) _loop_hotfix_head_context "${1:-}" ;;
6951
7069
  branches) _loop_branches "$(pwd -P)" ;;
7070
+ agent-routes) _loop_agent_routes "${1:-show}" "${@:2}" ;;
7071
+ test-quality-check) _loop_test_quality_check "$@" ;;
6952
7072
  *) cat <<'HELP'
6953
7073
  Usage: roll loop <on|off|now|test|status|monitor|runs|log|story|events|attach|mute|unmute|pause|resume|reset|gc|branches>
6954
7074
 
@@ -6971,6 +7091,7 @@ Usage: roll loop <on|off|now|test|status|monitor|runs|log|story|events|attach|mu
6971
7091
  gc [--dry-run] [--keep-days N]
6972
7092
  Garbage-collect orphan slugs, tmp debris, expired backups
6973
7093
  branches List loop-related branches
7094
+ agent-routes Show / lint agent routing config (.roll/agent-routes.yaml)
6974
7095
 
6975
7096
  Internal (called by roll-loop SKILL):
6976
7097
  notify Send macOS notification
@@ -8720,6 +8841,205 @@ _loop_mark_in_progress() {
8720
8841
  ' "$backlog" > "$tmp" && mv "$tmp" "$backlog"
8721
8842
  }
8722
8843
 
8844
+ # US-AGENT-008: _loop_mark_hold <story-id> <reason> [backlog-path]
8845
+ # Flip "🔨 In Progress" or "📋 Todo" row to "🚫 Hold" with a parenthetical
8846
+ # reason suffix appended to the description column. Idempotent — if the
8847
+ # row is already 🚫 Hold the call is a no-op (status compare is exact).
8848
+ _loop_mark_hold() {
8849
+ local story_id="$1"
8850
+ local reason="${2:-self-downgrade}"
8851
+ local backlog="${3:-${ROLL_MAIN_PROJECT:-$PWD}/.roll/backlog.md}"
8852
+ [ -n "$story_id" ] || return 1
8853
+ [ -f "$backlog" ] || return 0
8854
+ local tmp; tmp=$(mktemp "${backlog}.XXXXXX") || return 1
8855
+ awk -v sid="$story_id" -v reason="$reason" '
8856
+ {
8857
+ line = $0
8858
+ changed = 0
8859
+ if (index(line, "🔨 In Progress") > 0 || index(line, "📋 Todo") > 0) {
8860
+ n = split(line, cols, "|")
8861
+ if (n >= 2) {
8862
+ id_cell = cols[2]
8863
+ gsub(/[[:space:]]/, "", id_cell)
8864
+ sub(/^\[/, "", id_cell)
8865
+ sub(/\].*$/, "", id_cell)
8866
+ if (id_cell == sid) {
8867
+ sub(/🔨 In Progress/, "🚫 Hold", line)
8868
+ sub(/📋 Todo/, "🚫 Hold", line)
8869
+ # Append reason to the description column (cols[3]) only if not
8870
+ # already present.
8871
+ if (index(line, "→ " reason) == 0 && index(line, "(" reason ")") == 0) {
8872
+ # Insert before the trailing " | 🚫 Hold |"
8873
+ sub(/ \| 🚫 Hold \|/, " → " reason " | 🚫 Hold |", line)
8874
+ }
8875
+ changed = 1
8876
+ }
8877
+ }
8878
+ }
8879
+ print line
8880
+ }
8881
+ ' "$backlog" > "$tmp" && mv "$tmp" "$backlog"
8882
+ }
8883
+
8884
+ # US-AGENT-008: self-downgrade primitive.
8885
+ # _loop_self_downgrade <story-id> <reason> <sub-ids-csv>
8886
+ # Flips story to 🚫 Hold (with sub list embedded), writes ALERT, emits a
8887
+ # story_self_downgrade event. The actual sub-story rows + feature md are
8888
+ # produced by the SKILL invocation of roll-design --from-story (this helper
8889
+ # just records the contract).
8890
+ _loop_self_downgrade() {
8891
+ local story_id="$1"
8892
+ local reason="${2:-too_big}"
8893
+ local subs="${3:-}"
8894
+ [ -n "$story_id" ] || return 1
8895
+ local backlog="${ROLL_MAIN_PROJECT:-$PWD}/.roll/backlog.md"
8896
+ local annotation
8897
+ if [ -n "$subs" ]; then
8898
+ annotation="split to ${subs}"
8899
+ else
8900
+ annotation="$reason"
8901
+ fi
8902
+ _loop_mark_hold "$story_id" "$annotation" "$backlog" || true
8903
+
8904
+ # ALERT line for human visibility. Slug derives from main project dir.
8905
+ local main_dir="${ROLL_MAIN_PROJECT:-$PWD}"
8906
+ local slug; slug=$(_project_slug "$main_dir" 2>/dev/null || basename "$main_dir")
8907
+ local shared_root="${_SHARED_ROOT:-$HOME/.shared/roll}"
8908
+ local alert_file="${shared_root}/loop/ALERT-${slug}.md"
8909
+ mkdir -p "$(dirname "$alert_file")"
8910
+ printf '[%s] self-downgrade: %s — reason: %s; subs: %s\n' \
8911
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$story_id" "$reason" "${subs:-<none>}" \
8912
+ >> "$alert_file"
8913
+
8914
+ # Best-effort event emission (tolerates missing helpers).
8915
+ if declare -F _loop_event >/dev/null 2>&1; then
8916
+ _loop_event "agent_self_downgrade" "${LOOP_CYCLE_ID:-$story_id}" "$story_id" "${reason}|${subs}" || true
8917
+ fi
8918
+ echo "[loop] self-downgrade ${story_id}: ${reason}; subs=${subs:-<none>}"
8919
+ }
8920
+
8921
+ # US-AGENT-009: _loop_chain_depth_cap_check <story-id> [backlog]
8922
+ # Returns 0 when auto re-split is still allowed (story's chain_depth < 2),
8923
+ # 1 when the cap is hit (≥ 2 already — the third re-split would be #3 in
8924
+ # the chain). Reads chain_depth from the story's feature md profile;
8925
+ # missing profile is treated as depth 0 (split allowed).
8926
+ _loop_chain_depth_cap_check() {
8927
+ local story_id="$1"
8928
+ local backlog="${2:-${ROLL_MAIN_PROJECT:-$PWD}/.roll/backlog.md}"
8929
+ [ -n "$story_id" ] || return 0
8930
+ [ -f "$backlog" ] || return 0
8931
+
8932
+ # Resolve feature md from backlog link.
8933
+ local md_path
8934
+ md_path=$(grep -E "\[${story_id}\]\(" "$backlog" 2>/dev/null \
8935
+ | head -1 \
8936
+ | sed -E "s/.*\[${story_id}\]\(([^)#]+)#?[^)]*\).*/\1/")
8937
+ [ -n "$md_path" ] || return 0
8938
+ [ -f "$md_path" ] || return 0
8939
+
8940
+ # Find the section for this story id and extract chain_depth.
8941
+ local anchor; anchor=$(echo "$story_id" | tr '[:upper:]' '[:lower:]')
8942
+ local depth
8943
+ depth=$(awk -v anchor="$anchor" '
8944
+ /<a id="/ {
8945
+ if (match($0, /<a id="[^"]+"/)) {
8946
+ cur = substr($0, RSTART + 7, RLENGTH - 8)
8947
+ in_section = (cur == anchor)
8948
+ }
8949
+ next
8950
+ }
8951
+ in_section && /^- chain_depth:/ {
8952
+ gsub(/^- chain_depth:[ \t]*/, "")
8953
+ gsub(/[ \t].*$/, "")
8954
+ print
8955
+ exit
8956
+ }
8957
+ ' "$md_path")
8958
+
8959
+ # Empty / non-numeric → treat as 0.
8960
+ case "$depth" in
8961
+ ''|*[!0-9]*) depth=0 ;;
8962
+ esac
8963
+
8964
+ [ "$depth" -lt 2 ]
8965
+ }
8966
+
8967
+ # US-AGENT-009: cap-hit path. Story has reached chain_depth ≥ 2 — refuse
8968
+ # further auto re-split, flip 🚫 Hold + write a high-priority ALERT with
8969
+ # chain context for human triage.
8970
+ _loop_split_cap_hit() {
8971
+ local story_id="$1"
8972
+ local reason="${2:-cap-hit}"
8973
+ [ -n "$story_id" ] || return 1
8974
+ local backlog="${ROLL_MAIN_PROJECT:-$PWD}/.roll/backlog.md"
8975
+ _loop_mark_hold "$story_id" "StorySplitCapHit: $reason" "$backlog" || true
8976
+
8977
+ local main_dir="${ROLL_MAIN_PROJECT:-$PWD}"
8978
+ local slug; slug=$(_project_slug "$main_dir" 2>/dev/null || basename "$main_dir")
8979
+ local shared_root="${_SHARED_ROOT:-$HOME/.shared/roll}"
8980
+ local alert_file="${shared_root}/loop/ALERT-${slug}.md"
8981
+ mkdir -p "$(dirname "$alert_file")"
8982
+ printf '[%s] StorySplitCapHit: %s — chain_depth >= 2 (third auto-split refused). %s. Human triage required.\n' \
8983
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$story_id" "$reason" \
8984
+ >> "$alert_file"
8985
+
8986
+ if declare -F _loop_event >/dev/null 2>&1; then
8987
+ _loop_event "story_split_cap_hit" "${LOOP_CYCLE_ID:-$story_id}" "$story_id" "$reason" || true
8988
+ fi
8989
+ echo "[loop] StorySplitCapHit ${story_id}: chain_depth >= 2 — held for human triage"
8990
+ }
8991
+
8992
+ # US-SKILL-010: unified self-score note writer for roll-build /
8993
+ # roll-fix / roll-design. Lands under .roll/notes/ with YAML frontmatter
8994
+ # so subsequent stories (US-SKILL-014 trend, US-SKILL-015 docs) can
8995
+ # read/aggregate without parsing free text.
8996
+ #
8997
+ # Args: skill story_id score:int verdict [rationale...]
8998
+ _skill_write_self_score() {
8999
+ local skill="${1:-}"
9000
+ local story="${2:-}"
9001
+ local score="${3:-}"
9002
+ local verdict="${4:-}"
9003
+ shift 4 2>/dev/null || true
9004
+ local rationale="${*:-}"
9005
+
9006
+ case "$skill" in
9007
+ roll-build|roll-fix|roll-design) ;;
9008
+ *) err "_skill_write_self_score: skill must be roll-build / roll-fix / roll-design (got '$skill')"; return 1 ;;
9009
+ esac
9010
+ [ -n "$story" ] || { err "_skill_write_self_score: story id required"; return 1; }
9011
+ case "$score" in
9012
+ ''|*[!0-9]*) err "_skill_write_self_score: score must be integer 1..10"; return 1 ;;
9013
+ esac
9014
+ if [ "$score" -lt 1 ] || [ "$score" -gt 10 ]; then
9015
+ err "_skill_write_self_score: score out of range (1..10): $score"
9016
+ return 1
9017
+ fi
9018
+ case "$verdict" in
9019
+ good|ok|regression) ;;
9020
+ *) err "_skill_write_self_score: verdict must be good / ok / regression (got '$verdict')"; return 1 ;;
9021
+ esac
9022
+
9023
+ local notes_dir=".roll/notes"
9024
+ mkdir -p "$notes_dir"
9025
+ local ts; ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
9026
+ local date_part="${ts%%T*}"
9027
+ local epoch; epoch=$(date -u +%s)
9028
+ local file="${notes_dir}/${date_part}-${skill}-${story}-${epoch}.md"
9029
+
9030
+ {
9031
+ printf -- '---\n'
9032
+ printf 'skill: %s\n' "$skill"
9033
+ printf 'story: %s\n' "$story"
9034
+ printf 'score: %s\n' "$score"
9035
+ printf 'verdict: %s\n' "$verdict"
9036
+ printf 'ts: %s\n' "$ts"
9037
+ printf -- '---\n'
9038
+ printf '\n'
9039
+ printf '%s\n' "$rationale"
9040
+ } > "$file"
9041
+ }
9042
+
8723
9043
  # _loop_mark_todo <story-id> [backlog-path]
8724
9044
  # Revert a row from "🔨 In Progress" back to "📋 Todo". Called when a
8725
9045
  # cycle's executor fails so the next cycle can pick the story up again.
@@ -9250,6 +9570,218 @@ refs/heads/claude/*"
9250
9570
  return 0
9251
9571
  }
9252
9572
 
9573
+ # US-AGENT-002: agent-routes.yaml management.
9574
+ # `roll loop agent-routes [show|lint] [path]` — dispatch + helpers.
9575
+ # Default path order: $ROLL_AGENT_ROUTES (if set) → ./.roll/agent-routes.yaml
9576
+ # → built-in default in templates/agent-routes/default.yaml.
9577
+ _loop_agent_routes_path() {
9578
+ if [ -n "${ROLL_AGENT_ROUTES:-}" ] && [ -f "$ROLL_AGENT_ROUTES" ]; then
9579
+ printf '%s\n' "$ROLL_AGENT_ROUTES"
9580
+ return 0
9581
+ fi
9582
+ if [ -f ".roll/agent-routes.yaml" ]; then
9583
+ printf '%s\n' ".roll/agent-routes.yaml"
9584
+ return 0
9585
+ fi
9586
+ # Fallback: built-in default shipped with the package.
9587
+ # ROLL_INSTALL_DIR is set when installed via npm; fall back to script dir.
9588
+ local install_dir
9589
+ install_dir="${ROLL_INSTALL_DIR:-$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "$0")")")}"
9590
+ local default_tpl="$install_dir/templates/agent-routes/default.yaml"
9591
+ if [ -f "$default_tpl" ]; then
9592
+ printf '%s\n' "$default_tpl"
9593
+ return 0
9594
+ fi
9595
+ return 1
9596
+ }
9597
+
9598
+ _loop_agent_routes_show() {
9599
+ local path
9600
+ path=$(_loop_agent_routes_path) || {
9601
+ echo "agent-routes: no config found (set ROLL_AGENT_ROUTES, drop .roll/agent-routes.yaml, or run roll init)" >&2
9602
+ return 1
9603
+ }
9604
+ echo "# source: $path"
9605
+ cat "$path"
9606
+ }
9607
+
9608
+ _loop_agent_routes_lint() {
9609
+ local path="${1:-}"
9610
+ if [ -z "$path" ]; then
9611
+ path=$(_loop_agent_routes_path) || {
9612
+ echo "agent-routes lint: no config found" >&2
9613
+ return 1
9614
+ }
9615
+ fi
9616
+ if [ ! -f "$path" ]; then
9617
+ echo "agent-routes lint: file not found: $path" >&2
9618
+ return 1
9619
+ fi
9620
+ local install_dir
9621
+ install_dir="${ROLL_INSTALL_DIR:-$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "$0")")")}"
9622
+ python3 "$install_dir/lib/agent_routes_lint.py" "$path"
9623
+ }
9624
+
9625
+ # US-AGENT-006: pick the next eligible 📋 Todo story from .roll/backlog.md
9626
+ # applying the same gates as the roll-loop SKILL Step 2:
9627
+ # 1. Status = 📋 Todo
9628
+ # 2. NOT manual-only:* tagged
9629
+ # 3. depends-on:* (if any) all ✅ Done
9630
+ # 4. Priority order: FIX > US > REFACTOR
9631
+ #
9632
+ # stdout: chosen story id (single line)
9633
+ # exit 0 when picked, 1 when nothing eligible.
9634
+ _loop_pick_next_story() {
9635
+ local backlog="${1:-.roll/backlog.md}"
9636
+ [ -f "$backlog" ] || return 1
9637
+
9638
+ # Two passes over the file, once per type prefix, return first hit.
9639
+ local prefix
9640
+ for prefix in FIX US REFACTOR; do
9641
+ local id
9642
+ while IFS= read -r line; do
9643
+ [ -z "$line" ] && continue
9644
+ # Skip non-Todo rows fast
9645
+ case "$line" in
9646
+ *'📋 Todo'*) ;;
9647
+ *) continue ;;
9648
+ esac
9649
+ # Extract id like FIX-XXX-NNN / US-XXX-NNN / REFACTOR-XXX-NNN.
9650
+ id=$(printf '%s\n' "$line" | grep -oE "${prefix}-[A-Za-z0-9_-]+" | head -1)
9651
+ [ -n "$id" ] || continue
9652
+ # Gate 1: manual-only
9653
+ _loop_is_manual_only "$id" "$backlog" 2>/dev/null && continue
9654
+ # Gate 2: depends-on
9655
+ if ! _loop_check_depends_on "$id" "$backlog" >/dev/null 2>&1; then
9656
+ continue
9657
+ fi
9658
+ printf '%s\n' "$id"
9659
+ return 0
9660
+ done < "$backlog"
9661
+ done
9662
+ return 1
9663
+ }
9664
+
9665
+ # US-AGENT-004: pick the agent for a given backlog story by reading its
9666
+ # Agent profile from the linked feature md and matching against the active
9667
+ # agent-routes.yaml. Falls back to history.cold_start_default when no agent
9668
+ # matches or the story has no profile.
9669
+ #
9670
+ # stdout: "<agent> <rule_kind> <rationale...>"
9671
+ # exit 0 on success, 1 if the story id isn't found.
9672
+ _loop_pick_agent_for_story() {
9673
+ local story_id="${1:-}"
9674
+ if [ -z "$story_id" ]; then
9675
+ echo "_loop_pick_agent_for_story: story id required" >&2
9676
+ return 1
9677
+ fi
9678
+ local routes
9679
+ routes=$(_loop_agent_routes_path 2>/dev/null) || {
9680
+ echo "_loop_pick_agent_for_story: no agent-routes.yaml available" >&2
9681
+ return 1
9682
+ }
9683
+ local backlog=".roll/backlog.md"
9684
+ [ -f "$backlog" ] || {
9685
+ echo "_loop_pick_agent_for_story: $backlog not found" >&2
9686
+ return 1
9687
+ }
9688
+ local install_dir
9689
+ install_dir="${ROLL_INSTALL_DIR:-$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "$0")")")}"
9690
+ # US-AGENT-005: pass runs.jsonl when available so history soft preference
9691
+ # can layer on top of hard rules. Project-local first, then shared.
9692
+ local runs_arg=""
9693
+ if [ -f ".roll/runs.jsonl" ]; then
9694
+ runs_arg="--runs .roll/runs.jsonl"
9695
+ elif [ -f "${HOME}/.shared/roll/loop/runs.jsonl" ]; then
9696
+ runs_arg="--runs ${HOME}/.shared/roll/loop/runs.jsonl"
9697
+ fi
9698
+ # shellcheck disable=SC2086
9699
+ python3 "$install_dir/lib/loop_pick_agent.py" \
9700
+ --story-id "$story_id" \
9701
+ --backlog "$backlog" \
9702
+ --routes "$routes" \
9703
+ $runs_arg
9704
+ }
9705
+
9706
+ # US-QA-012: merge-time test-quality gate. Scan bats files for ❼ + ❽
9707
+ # violations; loop auto-merge waits on a clean exit. PR description
9708
+ # `[skip-test-quality]` marker → US-QA-013 passes --skip here.
9709
+ #
9710
+ # Usage:
9711
+ # roll loop test-quality-check [--skip] file.bats [file.bats ...]
9712
+ _loop_test_quality_check() {
9713
+ local install_dir
9714
+ install_dir="${ROLL_INSTALL_DIR:-$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "$0")")")}"
9715
+ python3 "$install_dir/lib/test_quality_gate.py" "$@"
9716
+ }
9717
+
9718
+ # US-QA-013: PR description marker check. Returns 0 if the body contains
9719
+ # `[skip-test-quality]` (case-insensitive); 1 otherwise.
9720
+ _loop_pr_body_has_skip_test_quality() {
9721
+ local body="${1:-}"
9722
+ [ -n "$body" ] || return 1
9723
+ printf '%s' "$body" | grep -qiE '\[skip-test-quality\]'
9724
+ }
9725
+
9726
+ # US-QA-013: gate + ALERT wrapper. Runs the test-quality gate; on
9727
+ # violations writes a structured ALERT-<slug>.md entry so the human (or
9728
+ # next brief) sees what blocked auto-merge. The wrapper is the entry
9729
+ # point loop calls; it accepts --skip to honor the PR bypass marker.
9730
+ _loop_test_quality_check_with_alert() {
9731
+ local skip=0
9732
+ if [ "${1:-}" = "--skip" ]; then
9733
+ skip=1; shift
9734
+ fi
9735
+ if [ "$skip" -eq 1 ]; then
9736
+ return 0
9737
+ fi
9738
+ local install_dir
9739
+ install_dir="${ROLL_INSTALL_DIR:-$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "$0")")")}"
9740
+ local report
9741
+ report=$(python3 "$install_dir/lib/test_quality_gate.py" "$@" 2>&1)
9742
+ local rc=$?
9743
+ if [ "$rc" -eq 0 ]; then
9744
+ return 0
9745
+ fi
9746
+
9747
+ local main_dir="${ROLL_MAIN_PROJECT:-$PWD}"
9748
+ local slug; slug=$(_project_slug "$main_dir" 2>/dev/null || basename "$main_dir")
9749
+ local shared_root="${_SHARED_ROOT:-$HOME/.shared/roll}"
9750
+ local alert_file="${shared_root}/loop/ALERT-${slug}.md"
9751
+ mkdir -p "$(dirname "$alert_file")"
9752
+ {
9753
+ printf '[%s] test-quality gate blocked auto-merge (rubric ❼ / ❽).\n' \
9754
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
9755
+ printf '%s\n' "$report"
9756
+ printf 'Bypass: add `[skip-test-quality]` to the PR description and re-run.\n'
9757
+ printf '\n'
9758
+ } >> "$alert_file"
9759
+
9760
+ if declare -F _loop_event >/dev/null 2>&1; then
9761
+ _loop_event "test_quality_gate_block" "${LOOP_CYCLE_ID:-test-quality}" "${1:-}" "$report" || true
9762
+ fi
9763
+ echo "[loop] test-quality gate blocked: see $alert_file" >&2
9764
+ return 1
9765
+ }
9766
+
9767
+ _loop_agent_routes() {
9768
+ local sub="${1:-show}"; shift || true
9769
+ case "$sub" in
9770
+ show) _loop_agent_routes_show ;;
9771
+ lint) _loop_agent_routes_lint "${1:-}" ;;
9772
+ path) _loop_agent_routes_path ;;
9773
+ *)
9774
+ cat >&2 <<'HELP'
9775
+ Usage: roll loop agent-routes <show|lint|path> [path]
9776
+
9777
+ show Print active agent-routes config (source + content)
9778
+ lint [path] Validate schema (default: active config). Exit 1 on errors.
9779
+ path Print which file is currently active.
9780
+ HELP
9781
+ return 1 ;;
9782
+ esac
9783
+ }
9784
+
9253
9785
  # US-AUTO-033: publish a loop cycle branch as a GitHub PR with auto-merge.
9254
9786
  #
9255
9787
  # _loop_publish_pr <branch> [title]
@@ -9821,6 +10353,185 @@ cmd_alert() {
9821
10353
  esac
9822
10354
  }
9823
10355
 
10356
+ # ═══════════════════════════════════════════════════════════════════════════════
10357
+ # FEEDBACK — one-shot GitHub issue from the CLI (US-FB-001)
10358
+ # ═══════════════════════════════════════════════════════════════════════════════
10359
+
10360
+ # Derive owner/repo from git origin. Returns "" when not a github remote.
10361
+ _feedback_origin_repo() {
10362
+ local url
10363
+ url=$(git remote get-url origin 2>/dev/null) || return 0
10364
+ case "$url" in
10365
+ git@github.com:*)
10366
+ url="${url#git@github.com:}"
10367
+ url="${url%.git}"
10368
+ printf '%s\n' "$url"
10369
+ ;;
10370
+ https://github.com/*)
10371
+ url="${url#https://github.com/}"
10372
+ url="${url%.git}"
10373
+ printf '%s\n' "$url"
10374
+ ;;
10375
+ *) printf '\n' ;;
10376
+ esac
10377
+ }
10378
+
10379
+ # US-FB-003: feedback target repo precedence:
10380
+ # 1. --repo flag (caller already resolved this; not part of this helper)
10381
+ # 2. ROLL_FEEDBACK_REPO env var
10382
+ # 3. .roll/local.yaml `feedback_repo:`
10383
+ # 4. ~/.roll/config.yaml `feedback_repo:`
10384
+ # 5. origin-derived github owner/repo
10385
+ _feedback_yaml_field() {
10386
+ local file="$1" field="$2"
10387
+ [ -f "$file" ] || return 0
10388
+ awk -v key="$field" '
10389
+ $0 ~ "^"key":" {
10390
+ v=$0; sub("^"key":[[:space:]]*", "", v); gsub("^[\"\x27]|[\"\x27]$", "", v); print v; exit
10391
+ }' "$file"
10392
+ }
10393
+
10394
+ _feedback_default_repo() {
10395
+ if [ -n "${ROLL_FEEDBACK_REPO:-}" ]; then
10396
+ printf '%s\n' "$ROLL_FEEDBACK_REPO"
10397
+ return 0
10398
+ fi
10399
+ local project_local=".roll/local.yaml"
10400
+ local v
10401
+ v=$(_feedback_yaml_field "$project_local" "feedback_repo")
10402
+ if [ -n "$v" ]; then
10403
+ printf '%s\n' "$v"
10404
+ return 0
10405
+ fi
10406
+ local global="${HOME}/.roll/config.yaml"
10407
+ v=$(_feedback_yaml_field "$global" "feedback_repo")
10408
+ if [ -n "$v" ]; then
10409
+ printf '%s\n' "$v"
10410
+ return 0
10411
+ fi
10412
+ _feedback_origin_repo
10413
+ }
10414
+
10415
+ # Map --type to GitHub label list (single, no spaces).
10416
+ _feedback_label_for_type() {
10417
+ case "${1:-}" in
10418
+ bug) printf 'bug,FIX\n' ;;
10419
+ idea) printf 'idea,enhancement,US\n' ;;
10420
+ ux) printf 'ux,enhancement\n' ;;
10421
+ *) printf 'feedback\n' ;;
10422
+ esac
10423
+ }
10424
+
10425
+ # Percent-encode for use in a GitHub issue URL query string.
10426
+ _feedback_urlencode() {
10427
+ python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1"
10428
+ }
10429
+
10430
+ # US-FB-002: compose env info appendix attached to feedback body unless
10431
+ # --no-env is set. Single source of truth so future feedback paths
10432
+ # (web embedded, slack, etc.) can reuse.
10433
+ _feedback_env_block() {
10434
+ local roll_v os_name shell_name agent lang project
10435
+ roll_v="${VERSION:-unknown}"
10436
+ os_name="$(uname -srm 2>/dev/null || echo unknown)"
10437
+ shell_name="$(basename "${SHELL:-/bin/sh}")"
10438
+ agent=$(_project_agent 2>/dev/null || echo "unknown")
10439
+ lang="${LANG:-${LC_ALL:-unknown}}"
10440
+ project="$(basename "$(pwd -P)")"
10441
+ cat <<EOF
10442
+
10443
+ ---
10444
+
10445
+ ### Environment
10446
+ - roll version: $roll_v
10447
+ - OS: $os_name
10448
+ - shell: $shell_name
10449
+ - current agent: $agent
10450
+ - language: $lang
10451
+ - project: $project
10452
+ EOF
10453
+ }
10454
+
10455
+ cmd_feedback() {
10456
+ local type="" title="" body="" repo="" print_url=0 attach_env=1
10457
+ while [ $# -gt 0 ]; do
10458
+ case "$1" in
10459
+ --type) type="$2"; shift 2 ;;
10460
+ --title) title="$2"; shift 2 ;;
10461
+ --body) body="$2"; shift 2 ;;
10462
+ --repo) repo="$2"; shift 2 ;;
10463
+ --no-env) attach_env=0; shift ;;
10464
+ --print-url) print_url=1; shift ;;
10465
+ --help|-h)
10466
+ cat <<'HELP'
10467
+ Usage: roll feedback [options]
10468
+ roll feedback (一句话提反馈)
10469
+
10470
+ Open a GitHub issue from the CLI. Type auto-labels (bug → FIX label;
10471
+ idea → US label; ux → ux label).
10472
+
10473
+ Options:
10474
+ --type <bug|idea|ux> Classify the feedback (default: bug)
10475
+ --title <text> Issue title (required)
10476
+ --body <text> Issue body
10477
+ --repo <owner/repo> Target repo (default: derived from origin)
10478
+ --no-env Skip the auto-attached Environment section
10479
+ (roll version, OS, agent, language, project)
10480
+ --print-url Print the prefilled github.com URL instead of
10481
+ invoking `gh`. Falls back to this automatically
10482
+ when `gh` is not installed.
10483
+ HELP
10484
+ return 0 ;;
10485
+ *)
10486
+ err "feedback: unknown flag $1"
10487
+ return 1 ;;
10488
+ esac
10489
+ done
10490
+
10491
+ if [ -z "$title" ]; then
10492
+ err "feedback: --title is required"
10493
+ return 1
10494
+ fi
10495
+ if [ -z "$type" ]; then
10496
+ type="bug"
10497
+ fi
10498
+ case "$type" in
10499
+ bug|idea|ux) ;;
10500
+ *)
10501
+ err "feedback: unknown --type '$type' (expected one of: bug, idea, ux)"
10502
+ return 1 ;;
10503
+ esac
10504
+
10505
+ if [ -z "$repo" ]; then
10506
+ repo=$(_feedback_default_repo)
10507
+ fi
10508
+ if [ -z "$repo" ]; then
10509
+ err "feedback: cannot derive owner/repo from origin; pass --repo owner/repo"
10510
+ return 1
10511
+ fi
10512
+
10513
+ # US-FB-002: compose final body with optional env appendix.
10514
+ if [ "$attach_env" -eq 1 ]; then
10515
+ body="${body}$(_feedback_env_block)"
10516
+ fi
10517
+
10518
+ local labels; labels=$(_feedback_label_for_type "$type")
10519
+
10520
+ # Decide path: --print-url or gh missing → print URL; else use gh.
10521
+ if [ "$print_url" -eq 1 ] || ! command -v gh >/dev/null 2>&1; then
10522
+ local t_enc b_enc l_enc
10523
+ t_enc=$(_feedback_urlencode "$title")
10524
+ b_enc=$(_feedback_urlencode "$body")
10525
+ l_enc=$(_feedback_urlencode "$labels")
10526
+ printf 'https://github.com/%s/issues/new?title=%s&body=%s&labels=%s\n' \
10527
+ "$repo" "$t_enc" "$b_enc" "$l_enc"
10528
+ return 0
10529
+ fi
10530
+
10531
+ # Real path: gh issue create
10532
+ gh issue create --repo "$repo" --title "$title" --body "$body" --label "$labels"
10533
+ }
10534
+
9824
10535
  # ═══════════════════════════════════════════════════════════════════════════════
9825
10536
  # LANG — switch / inspect Roll's UI language (US-I18N-001)
9826
10537
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -10681,6 +11392,7 @@ main() {
10681
11392
  brief) cmd_brief "$@" ;;
10682
11393
  backlog) cmd_backlog "$@" ;;
10683
11394
  alert) cmd_alert "$@" ;;
11395
+ feedback) cmd_feedback "$@" ;;
10684
11396
  lang) cmd_lang "$@" ;;
10685
11397
  agent) cmd_agent "$@" ;;
10686
11398
  ci) cmd_ci "$@" ;;