@seanyao/roll 2026.529.1 → 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.529.1"
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"
@@ -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/")
@@ -6106,12 +6155,22 @@ _runs_append() {
6106
6155
  # FIX-123: atomic write — write to .tmp.$$ first, then cat >> to append,
6107
6156
  # then remove. If interrupted between jq and rm, the next call cleans it.
6108
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
6109
6166
  jq -nc \\
6110
6167
  --arg ts "\$_ts_now" \\
6111
6168
  --arg project "${slug}" \\
6112
6169
  --arg run_id "\$_rid" \\
6113
6170
  --arg status "\$_status" \\
6114
6171
  --arg cycle_id "\$_cid" \\
6172
+ --arg agent "\$_agent_field" \\
6173
+ --arg story_type "\$_stype_field" \\
6115
6174
  --argjson built "\$_built" \\
6116
6175
  --argjson skipped '[]' \\
6117
6176
  --argjson alerts '[]' \\
@@ -6119,7 +6178,7 @@ _runs_append() {
6119
6178
  --argjson duration_sec "\$_dur" \\
6120
6179
  --argjson phases "\$_phases_json" \\
6121
6180
  '{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
6122
- cycle_id:\$cycle_id,
6181
+ cycle_id:\$cycle_id, agent:\$agent, story_type:\$story_type,
6123
6182
  built:\$built, skipped:\$skipped, alerts:\$alerts,
6124
6183
  tcr_count:\$tcr_count, duration_sec:\$duration_sec, phases:\$phases}' \\
6125
6184
  > "\$_tmp" 2>/dev/null || { rm -f "\$_tmp"; return 0; }
@@ -6345,7 +6404,27 @@ if _worktree_fetch_origin main \\
6345
6404
  _worktree_sync_meta "\$WT" 2>/dev/null || true
6346
6405
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
6347
6406
  _loop_event cycle_start "\${CYCLE_ID}" "" "" || true
6348
- _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
6349
6428
  _phase_end worktree_setup ok
6350
6429
  else
6351
6430
  # P3 fix: skip the cycle entirely when worktree isolation fails.
@@ -6372,7 +6451,9 @@ export LOOP_CYCLE_ID="\${CYCLE_ID}"
6372
6451
  export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
6373
6452
  # US-LOOP-010: tell loop-fmt.py which agent is running so it can branch
6374
6453
  # rendering: claude → stream-json parser, others → transparent passthrough.
6375
- 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
6376
6457
  _phase_begin agent_invoke
6377
6458
  for _attempt in 1 2 3; do
6378
6459
  # FIX-068: defensive reset before each attempt — _CYCLE_TIMED_OUT carries
@@ -6949,6 +7030,8 @@ cmd_loop() {
6949
7030
  precheck-ci) _loop_precheck_ci ;;
6950
7031
  hotfix-head-context) _loop_hotfix_head_context "${1:-}" ;;
6951
7032
  branches) _loop_branches "$(pwd -P)" ;;
7033
+ agent-routes) _loop_agent_routes "${1:-show}" "${@:2}" ;;
7034
+ test-quality-check) _loop_test_quality_check "$@" ;;
6952
7035
  *) cat <<'HELP'
6953
7036
  Usage: roll loop <on|off|now|test|status|monitor|runs|log|story|events|attach|mute|unmute|pause|resume|reset|gc|branches>
6954
7037
 
@@ -6971,6 +7054,7 @@ Usage: roll loop <on|off|now|test|status|monitor|runs|log|story|events|attach|mu
6971
7054
  gc [--dry-run] [--keep-days N]
6972
7055
  Garbage-collect orphan slugs, tmp debris, expired backups
6973
7056
  branches List loop-related branches
7057
+ agent-routes Show / lint agent routing config (.roll/agent-routes.yaml)
6974
7058
 
6975
7059
  Internal (called by roll-loop SKILL):
6976
7060
  notify Send macOS notification
@@ -8720,6 +8804,205 @@ _loop_mark_in_progress() {
8720
8804
  ' "$backlog" > "$tmp" && mv "$tmp" "$backlog"
8721
8805
  }
8722
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
+
8723
9006
  # _loop_mark_todo <story-id> [backlog-path]
8724
9007
  # Revert a row from "🔨 In Progress" back to "📋 Todo". Called when a
8725
9008
  # cycle's executor fails so the next cycle can pick the story up again.
@@ -9250,6 +9533,218 @@ refs/heads/claude/*"
9250
9533
  return 0
9251
9534
  }
9252
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
+
9253
9748
  # US-AUTO-033: publish a loop cycle branch as a GitHub PR with auto-merge.
9254
9749
  #
9255
9750
  # _loop_publish_pr <branch> [title]
@@ -9821,6 +10316,185 @@ cmd_alert() {
9821
10316
  esac
9822
10317
  }
9823
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
+
9824
10498
  # ═══════════════════════════════════════════════════════════════════════════════
9825
10499
  # LANG — switch / inspect Roll's UI language (US-I18N-001)
9826
10500
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -10681,6 +11355,7 @@ main() {
10681
11355
  brief) cmd_brief "$@" ;;
10682
11356
  backlog) cmd_backlog "$@" ;;
10683
11357
  alert) cmd_alert "$@" ;;
11358
+ feedback) cmd_feedback "$@" ;;
10684
11359
  lang) cmd_lang "$@" ;;
10685
11360
  agent) cmd_agent "$@" ;;
10686
11361
  ci) cmd_ci "$@" ;;