@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/CHANGELOG.md +31 -8
- package/README.md +2 -0
- package/bin/roll +917 -50
- package/lib/README.md +42 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/agent_routes_lint.py +203 -0
- package/lib/i18n/README.md +54 -0
- package/lib/i18n/doctor.sh +13 -0
- package/lib/i18n/loop.sh +12 -12
- package/lib/loop_pick_agent.py +245 -0
- package/lib/prices/README.md +35 -0
- package/lib/roll-help.py +1 -0
- package/lib/roll-loop-status.py +109 -0
- package/lib/test_quality_gate.py +143 -0
- package/package.json +1 -1
- package/skills/roll-brief/SKILL.md +7 -0
- package/skills/roll-build/SKILL.md +95 -0
- package/skills/roll-design/SKILL.md +45 -0
- package/skills/roll-fix/SKILL.md +76 -0
- package/skills/roll-loop/SKILL.md +13 -0
- package/skills/roll-onboard/SKILL.md +6 -0
package/bin/roll
CHANGED
|
@@ -4,7 +4,7 @@ set -euo pipefail
|
|
|
4
4
|
# Roll — AI Agent Convention Manager
|
|
5
5
|
# Single source of truth for how all AI coding agents behave.
|
|
6
6
|
|
|
7
|
-
VERSION="2026.
|
|
7
|
+
VERSION="2026.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
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
643
|
-
|
|
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
|
-
|
|
1474
|
-
|
|
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
|
-
|
|
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" '$
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6629
|
-
|
|
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
|
-
|
|
6806
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6855
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6886
|
-
|
|
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
|
-
|
|
7027
|
-
|
|
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
|
-
|
|
9311
|
-
|
|
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
|
-
|
|
10182
|
-
|
|
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 "$@" ;;
|