@seanyao/roll 2026.519.3 → 2026.520.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +37 -86
- package/bin/roll +545 -97
- package/conventions/global/AGENTS.md +3 -2
- package/lib/roll-peer.py +242 -0
- package/lib/roll_render.py +21 -0
- package/package.json +1 -1
- package/skills/roll-loop/SKILL.md +16 -8
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -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.520.1"
|
|
8
8
|
ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
|
|
9
9
|
ROLL_CONFIG="${ROLL_HOME}/config.yaml"
|
|
10
10
|
ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
|
|
@@ -986,10 +986,16 @@ cmd_init() {
|
|
|
986
986
|
|
|
987
987
|
# US-ONBOARD-006: Legacy detection
|
|
988
988
|
# A project is "Legacy" if it has substantive code but no AGENTS.md to anchor
|
|
989
|
-
# Roll conventions.
|
|
990
|
-
#
|
|
989
|
+
# Roll conventions. US-ONBOARD-012 widened the recogniser to cover non-canonical
|
|
990
|
+
# layouts (WeChat mini-program, Python flat, Terraform, etc.) — any of:
|
|
991
|
+
# 1. Classic layout: src/app/lib/pkg/cmd contains ≥10 non-empty files.
|
|
992
|
+
# 2. A manifest of any common ecosystem at the project root.
|
|
993
|
+
# 3. Git history exists (at least one commit on HEAD).
|
|
994
|
+
# Either signal alone is enough; the AGENTS.md check happens earlier.
|
|
991
995
|
_init_is_legacy_project() {
|
|
992
996
|
local project_dir="$1"
|
|
997
|
+
|
|
998
|
+
# Signal 1 — classic source layout
|
|
993
999
|
local dir count
|
|
994
1000
|
for dir in src app lib pkg cmd; do
|
|
995
1001
|
if [[ -d "$project_dir/$dir" ]]; then
|
|
@@ -999,6 +1005,29 @@ _init_is_legacy_project() {
|
|
|
999
1005
|
fi
|
|
1000
1006
|
fi
|
|
1001
1007
|
done
|
|
1008
|
+
|
|
1009
|
+
# Signal 2 — manifest file at project root
|
|
1010
|
+
local manifest
|
|
1011
|
+
for manifest in \
|
|
1012
|
+
package.json pyproject.toml requirements.txt setup.py setup.cfg Pipfile \
|
|
1013
|
+
go.mod Cargo.toml Gemfile pom.xml build.gradle build.gradle.kts \
|
|
1014
|
+
Makefile Dockerfile docker-compose.yml docker-compose.yaml \
|
|
1015
|
+
app.json project.config.json \
|
|
1016
|
+
mix.exs composer.json deno.json deno.jsonc; do
|
|
1017
|
+
[[ -f "$project_dir/$manifest" ]] && return 0
|
|
1018
|
+
done
|
|
1019
|
+
# Terraform: any *.tf at the root
|
|
1020
|
+
if compgen -G "$project_dir/*.tf" >/dev/null 2>&1; then
|
|
1021
|
+
return 0
|
|
1022
|
+
fi
|
|
1023
|
+
|
|
1024
|
+
# Signal 3 — git history exists
|
|
1025
|
+
if [[ -d "$project_dir/.git" ]] || [[ -f "$project_dir/.git" ]]; then
|
|
1026
|
+
if ( cd "$project_dir" && git rev-parse --verify HEAD >/dev/null 2>&1 ); then
|
|
1027
|
+
return 0
|
|
1028
|
+
fi
|
|
1029
|
+
fi
|
|
1030
|
+
|
|
1002
1031
|
return 1
|
|
1003
1032
|
}
|
|
1004
1033
|
|
|
@@ -1085,9 +1114,96 @@ _init_legacy_file_summary() {
|
|
|
1085
1114
|
fi
|
|
1086
1115
|
fi
|
|
1087
1116
|
done
|
|
1117
|
+
# US-ONBOARD-012: surface non-canonical signals in the summary too.
|
|
1118
|
+
local manifest
|
|
1119
|
+
for manifest in \
|
|
1120
|
+
package.json pyproject.toml requirements.txt setup.py setup.cfg Pipfile \
|
|
1121
|
+
go.mod Cargo.toml Gemfile pom.xml build.gradle build.gradle.kts \
|
|
1122
|
+
Makefile Dockerfile docker-compose.yml docker-compose.yaml \
|
|
1123
|
+
app.json project.config.json \
|
|
1124
|
+
mix.exs composer.json deno.json deno.jsonc; do
|
|
1125
|
+
if [[ -f "$project_dir/$manifest" ]]; then
|
|
1126
|
+
parts+=("manifest: $manifest")
|
|
1127
|
+
break
|
|
1128
|
+
fi
|
|
1129
|
+
done
|
|
1130
|
+
if compgen -G "$project_dir/*.tf" >/dev/null 2>&1; then
|
|
1131
|
+
parts+=("Terraform .tf files")
|
|
1132
|
+
fi
|
|
1133
|
+
if [[ ${#parts[@]} -eq 0 ]] \
|
|
1134
|
+
&& { [[ -d "$project_dir/.git" ]] || [[ -f "$project_dir/.git" ]]; } \
|
|
1135
|
+
&& ( cd "$project_dir" && git rev-parse --verify HEAD >/dev/null 2>&1 ); then
|
|
1136
|
+
parts+=("git history present")
|
|
1137
|
+
fi
|
|
1088
1138
|
echo "no AGENTS.md, ${parts[*]}"
|
|
1089
1139
|
}
|
|
1090
1140
|
|
|
1141
|
+
# US-ONBOARD-013: changeset recording — onboard writes a manifest of every
|
|
1142
|
+
# side effect (files created, .gitignore entries, scope) into
|
|
1143
|
+
# .roll/onboard-changeset.yaml so `roll offboard` has a rollback record.
|
|
1144
|
+
# Without this, a user who wants to retire Roll from a project has to guess
|
|
1145
|
+
# which files came from onboard vs their own work.
|
|
1146
|
+
_onboard_changeset_path() {
|
|
1147
|
+
echo "$1/.roll/onboard-changeset.yaml"
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
# Begin a fresh changeset record. Overwrites any prior file — every apply
|
|
1151
|
+
# starts from a clean slate; offboard reads the latest record.
|
|
1152
|
+
_onboard_changeset_begin() {
|
|
1153
|
+
local project_dir="$1"
|
|
1154
|
+
local path; path=$(_onboard_changeset_path "$project_dir")
|
|
1155
|
+
mkdir -p "$(dirname "$path")"
|
|
1156
|
+
cat > "$path" <<EOF
|
|
1157
|
+
# Generated by \`roll init --apply\`. Used by \`roll offboard\` to reverse
|
|
1158
|
+
# the changes onboard made. Do not edit by hand.
|
|
1159
|
+
onboarded_at: "$(date -u +%FT%TZ)"
|
|
1160
|
+
roll_version: "$(_pkg_version 2>/dev/null || echo unknown)"
|
|
1161
|
+
scope_approved: []
|
|
1162
|
+
files_created: []
|
|
1163
|
+
dirs_created: []
|
|
1164
|
+
gitignore_entries_added: []
|
|
1165
|
+
launchd_plists_installed: []
|
|
1166
|
+
EOF
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
# Append a YAML list entry to a section in the changeset.
|
|
1170
|
+
_onboard_changeset_record() {
|
|
1171
|
+
local project_dir="$1" section="$2" value="$3"
|
|
1172
|
+
local path; path=$(_onboard_changeset_path "$project_dir")
|
|
1173
|
+
[ -f "$path" ] || return 0
|
|
1174
|
+
# Each section line ends with `: []` — replace with the new value on first
|
|
1175
|
+
# entry, otherwise append a `- <value>` line under the section.
|
|
1176
|
+
if grep -qE "^${section}: \[\]$" "$path"; then
|
|
1177
|
+
local tmp; tmp=$(mktemp)
|
|
1178
|
+
awk -v sec="$section" -v val="$value" '
|
|
1179
|
+
$0 ~ "^" sec ": \\[\\]$" {
|
|
1180
|
+
print sec ":"
|
|
1181
|
+
print " - \"" val "\""
|
|
1182
|
+
next
|
|
1183
|
+
}
|
|
1184
|
+
{ print }
|
|
1185
|
+
' "$path" > "$tmp" && mv "$tmp" "$path"
|
|
1186
|
+
else
|
|
1187
|
+
# Find the section header and insert under it (after the last entry).
|
|
1188
|
+
local tmp; tmp=$(mktemp)
|
|
1189
|
+
awk -v sec="$section" -v val="$value" '
|
|
1190
|
+
$0 ~ "^" sec ":$" {
|
|
1191
|
+
print
|
|
1192
|
+
in_sec=1; next
|
|
1193
|
+
}
|
|
1194
|
+
in_sec && /^[a-z_]+:/ {
|
|
1195
|
+
print " - \"" val "\""
|
|
1196
|
+
in_sec=0
|
|
1197
|
+
print; next
|
|
1198
|
+
}
|
|
1199
|
+
{ print }
|
|
1200
|
+
END {
|
|
1201
|
+
if (in_sec) print " - \"" val "\""
|
|
1202
|
+
}
|
|
1203
|
+
' "$path" > "$tmp" && mv "$tmp" "$path"
|
|
1204
|
+
fi
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1091
1207
|
# US-ONBOARD-009: roll init --apply
|
|
1092
1208
|
# Consume .roll/onboard-plan.yaml (produced by $roll-onboard skill) and execute
|
|
1093
1209
|
# all side effects: create .roll/ structure per scope, sync AI tools, write
|
|
@@ -1124,6 +1240,9 @@ _init_apply() {
|
|
|
1124
1240
|
info "Applying onboard plan... 正在应用 onboard 计划..."
|
|
1125
1241
|
_ROLL_MERGE_SUMMARY=()
|
|
1126
1242
|
|
|
1243
|
+
# US-ONBOARD-013: start a fresh changeset record so offboard can reverse.
|
|
1244
|
+
_onboard_changeset_begin "$project_dir"
|
|
1245
|
+
|
|
1127
1246
|
# Read scope from plan (simple grep — validator confirmed structure)
|
|
1128
1247
|
local approved
|
|
1129
1248
|
approved=$(python3 -c "
|
|
@@ -1132,22 +1251,33 @@ p = yaml.safe_load(open('$plan'))
|
|
|
1132
1251
|
print(' '.join(p.get('scope', {}).get('approved', [])))
|
|
1133
1252
|
" 2>/dev/null || echo "")
|
|
1134
1253
|
|
|
1254
|
+
# Record each approved scope entry for offboard's selective rollback.
|
|
1255
|
+
local item
|
|
1256
|
+
for item in $approved; do
|
|
1257
|
+
_onboard_changeset_record "$project_dir" "scope_approved" "$item"
|
|
1258
|
+
done
|
|
1259
|
+
|
|
1135
1260
|
_merge_global_to_project "$project_dir"
|
|
1136
1261
|
_merge_claude_to_project "$project_dir"
|
|
1137
1262
|
|
|
1138
1263
|
# Create .roll/ artifacts based on scope.approved
|
|
1139
1264
|
if [[ " $approved " == *" backlog "* ]]; then
|
|
1140
1265
|
_write_backlog "$project_dir/.roll/backlog.md"
|
|
1266
|
+
_onboard_changeset_record "$project_dir" "files_created" ".roll/backlog.md"
|
|
1141
1267
|
fi
|
|
1142
1268
|
if [[ " $approved " == *" features "* ]]; then
|
|
1143
1269
|
_ensure_features_dir "$project_dir/.roll/features"
|
|
1144
1270
|
_write_features_md "$project_dir/.roll/features.md"
|
|
1271
|
+
_onboard_changeset_record "$project_dir" "dirs_created" ".roll/features"
|
|
1272
|
+
_onboard_changeset_record "$project_dir" "files_created" ".roll/features.md"
|
|
1145
1273
|
fi
|
|
1146
1274
|
if [[ " $approved " == *" domain "* ]]; then
|
|
1147
1275
|
mkdir -p "$project_dir/.roll/domain"
|
|
1276
|
+
_onboard_changeset_record "$project_dir" "dirs_created" ".roll/domain"
|
|
1148
1277
|
fi
|
|
1149
1278
|
if [[ " $approved " == *" briefs "* ]]; then
|
|
1150
1279
|
mkdir -p "$project_dir/.roll/briefs"
|
|
1280
|
+
_onboard_changeset_record "$project_dir" "dirs_created" ".roll/briefs"
|
|
1151
1281
|
fi
|
|
1152
1282
|
|
|
1153
1283
|
print_merge_summary
|
|
@@ -1164,6 +1294,7 @@ print('true' if p.get('privacy', {}).get('gitignore_dot_roll', False) else 'fals
|
|
|
1164
1294
|
local gi="$project_dir/.gitignore"
|
|
1165
1295
|
if ! grep -qFx ".roll/" "$gi" 2>/dev/null; then
|
|
1166
1296
|
echo ".roll/" >> "$gi"
|
|
1297
|
+
_onboard_changeset_record "$project_dir" "gitignore_entries_added" ".roll/"
|
|
1167
1298
|
ok "Added .roll/ to .gitignore 已将 .roll/ 加入 .gitignore"
|
|
1168
1299
|
fi
|
|
1169
1300
|
fi
|
|
@@ -1176,6 +1307,178 @@ print('true' if p.get('privacy', {}).get('gitignore_dot_roll', False) else 'fals
|
|
|
1176
1307
|
ok "Onboard apply complete. Onboard 应用完成。"
|
|
1177
1308
|
}
|
|
1178
1309
|
|
|
1310
|
+
# US-ONBOARD-014: roll offboard
|
|
1311
|
+
# Reverse what `roll init --apply` (US-ONBOARD-009/013) did, using the
|
|
1312
|
+
# changeset manifest at .roll/onboard-changeset.yaml as the rollback record.
|
|
1313
|
+
# Safety contract:
|
|
1314
|
+
# 1. Refuse when no changeset exists — print manual instructions instead.
|
|
1315
|
+
# 2. Default to dry-run; only `--confirm` (or `-y`) actually deletes.
|
|
1316
|
+
# 3. Only touch entries that are in the manifest. Anything else stays put.
|
|
1317
|
+
# 4. Refuse to delete a file/dir whose path does not resolve under the
|
|
1318
|
+
# current project root, even if the changeset says so (cross-project
|
|
1319
|
+
# guard). Print the safe manual command instead.
|
|
1320
|
+
cmd_offboard() {
|
|
1321
|
+
local confirm=0
|
|
1322
|
+
local arg
|
|
1323
|
+
for arg in "$@"; do
|
|
1324
|
+
case "$arg" in
|
|
1325
|
+
--confirm|-y) confirm=1 ;;
|
|
1326
|
+
--help|-h)
|
|
1327
|
+
echo "Usage: roll offboard [--confirm]"
|
|
1328
|
+
echo " Preview (default) or apply (--confirm) the removal of every"
|
|
1329
|
+
echo " artefact recorded in .roll/onboard-changeset.yaml."
|
|
1330
|
+
return 0
|
|
1331
|
+
;;
|
|
1332
|
+
*)
|
|
1333
|
+
err "Unknown flag: $arg 未知参数"
|
|
1334
|
+
return 1
|
|
1335
|
+
;;
|
|
1336
|
+
esac
|
|
1337
|
+
done
|
|
1338
|
+
|
|
1339
|
+
local project_dir; project_dir="$(pwd -P)"
|
|
1340
|
+
local changeset; changeset=$(_onboard_changeset_path "$project_dir")
|
|
1341
|
+
|
|
1342
|
+
if [[ ! -f "$changeset" ]]; then
|
|
1343
|
+
err "No onboard changeset found at .roll/onboard-changeset.yaml"
|
|
1344
|
+
err "未找到 onboard 变更清单 .roll/onboard-changeset.yaml"
|
|
1345
|
+
echo "" >&2
|
|
1346
|
+
echo " Manual offboard — remove these by hand if they came from Roll:" >&2
|
|
1347
|
+
echo " rm -rf .roll/ # all process artefacts" >&2
|
|
1348
|
+
echo " rm -f AGENTS.md CLAUDE.md # only if they were generated by roll init" >&2
|
|
1349
|
+
echo " Edit .gitignore to remove any '.roll/' entry" >&2
|
|
1350
|
+
return 1
|
|
1351
|
+
fi
|
|
1352
|
+
|
|
1353
|
+
# Parse changeset (Python keeps YAML semantics consistent with apply).
|
|
1354
|
+
local parser
|
|
1355
|
+
parser=$(python3 - "$changeset" <<'PY'
|
|
1356
|
+
import sys, yaml
|
|
1357
|
+
try:
|
|
1358
|
+
data = yaml.safe_load(open(sys.argv[1])) or {}
|
|
1359
|
+
except Exception as e:
|
|
1360
|
+
print(f"PARSE_ERROR:{e}", file=sys.stderr)
|
|
1361
|
+
sys.exit(2)
|
|
1362
|
+
def pr(section):
|
|
1363
|
+
for v in (data.get(section) or []):
|
|
1364
|
+
print(f"{section}\t{v}")
|
|
1365
|
+
pr("files_created")
|
|
1366
|
+
pr("dirs_created")
|
|
1367
|
+
pr("gitignore_entries_added")
|
|
1368
|
+
pr("launchd_plists_installed")
|
|
1369
|
+
PY
|
|
1370
|
+
)
|
|
1371
|
+
if [[ $? -ne 0 ]]; then
|
|
1372
|
+
err "Failed to parse changeset 解析变更清单失败"
|
|
1373
|
+
return 1
|
|
1374
|
+
fi
|
|
1375
|
+
|
|
1376
|
+
local files=() dirs=() gi_entries=() plists=()
|
|
1377
|
+
while IFS=$'\t' read -r section value; do
|
|
1378
|
+
[[ -z "$section" ]] && continue
|
|
1379
|
+
case "$section" in
|
|
1380
|
+
files_created) files+=("$value") ;;
|
|
1381
|
+
dirs_created) dirs+=("$value") ;;
|
|
1382
|
+
gitignore_entries_added) gi_entries+=("$value") ;;
|
|
1383
|
+
launchd_plists_installed) plists+=("$value") ;;
|
|
1384
|
+
esac
|
|
1385
|
+
done <<< "$parser"
|
|
1386
|
+
|
|
1387
|
+
# Cross-project guard — verify every recorded path resolves under
|
|
1388
|
+
# project_dir. Catches the case where a user accidentally points roll
|
|
1389
|
+
# offboard at a directory whose changeset names paths from elsewhere.
|
|
1390
|
+
local item resolved
|
|
1391
|
+
local _all=()
|
|
1392
|
+
[ "${#files[@]}" -gt 0 ] && _all+=("${files[@]}")
|
|
1393
|
+
[ "${#dirs[@]}" -gt 0 ] && _all+=("${dirs[@]}")
|
|
1394
|
+
for item in "${_all[@]:+${_all[@]}}"; do
|
|
1395
|
+
case "$item" in
|
|
1396
|
+
/*) resolved="$item" ;; # absolute — must already start with project_dir
|
|
1397
|
+
*) resolved="$project_dir/$item" ;;
|
|
1398
|
+
esac
|
|
1399
|
+
case "$resolved" in
|
|
1400
|
+
"$project_dir"|"$project_dir"/*) ;;
|
|
1401
|
+
*)
|
|
1402
|
+
err "Refusing to act on '$item' — it does not resolve under $project_dir"
|
|
1403
|
+
err "拒绝处理 '$item' — 路径不在当前项目下,可能是误用"
|
|
1404
|
+
echo " This usually means the changeset was copied from another project." >&2
|
|
1405
|
+
echo " Remove .roll/onboard-changeset.yaml manually, or rerun in the right dir." >&2
|
|
1406
|
+
return 1
|
|
1407
|
+
;;
|
|
1408
|
+
esac
|
|
1409
|
+
done
|
|
1410
|
+
|
|
1411
|
+
# Print the plan.
|
|
1412
|
+
echo ""
|
|
1413
|
+
echo -e " ${BOLD}Offboard plan for ${project_dir}${NC}"
|
|
1414
|
+
echo ""
|
|
1415
|
+
if [[ ${#files[@]} -gt 0 ]]; then
|
|
1416
|
+
echo -e " ${RED}Files to remove:${NC}"
|
|
1417
|
+
for item in "${files[@]}"; do echo " rm $item"; done
|
|
1418
|
+
echo ""
|
|
1419
|
+
fi
|
|
1420
|
+
if [[ ${#dirs[@]} -gt 0 ]]; then
|
|
1421
|
+
echo -e " ${RED}Directories to remove:${NC}"
|
|
1422
|
+
for item in "${dirs[@]}"; do echo " rmdir/r $item"; done
|
|
1423
|
+
echo ""
|
|
1424
|
+
fi
|
|
1425
|
+
if [[ ${#gi_entries[@]} -gt 0 ]]; then
|
|
1426
|
+
echo -e " ${YELLOW}.gitignore entries to remove:${NC}"
|
|
1427
|
+
for item in "${gi_entries[@]}"; do echo " - $item"; done
|
|
1428
|
+
echo ""
|
|
1429
|
+
fi
|
|
1430
|
+
if [[ ${#plists[@]} -gt 0 ]]; then
|
|
1431
|
+
echo -e " ${YELLOW}launchd plists to unload:${NC}"
|
|
1432
|
+
for item in "${plists[@]}"; do echo " unload $item"; done
|
|
1433
|
+
echo ""
|
|
1434
|
+
fi
|
|
1435
|
+
if [[ ${#files[@]} -eq 0 && ${#dirs[@]} -eq 0 && ${#gi_entries[@]} -eq 0 && ${#plists[@]} -eq 0 ]]; then
|
|
1436
|
+
info "Changeset is empty — nothing to offboard."
|
|
1437
|
+
info "变更清单为空,无需 offboard。"
|
|
1438
|
+
return 0
|
|
1439
|
+
fi
|
|
1440
|
+
|
|
1441
|
+
if [[ "$confirm" -ne 1 ]]; then
|
|
1442
|
+
echo " This is a dry-run. Re-run with --confirm to apply."
|
|
1443
|
+
echo " 以上为预演结果。加 --confirm 后才会真正执行。"
|
|
1444
|
+
return 0
|
|
1445
|
+
fi
|
|
1446
|
+
|
|
1447
|
+
# Apply. Guard every loop with a count check — `set -u` upstream makes
|
|
1448
|
+
# naked `"${arr[@]}"` over an empty array a hard error on bash 5.0.
|
|
1449
|
+
echo " Applying offboard... 执行 offboard..."
|
|
1450
|
+
if [ "${#files[@]}" -gt 0 ]; then
|
|
1451
|
+
for item in "${files[@]}"; do
|
|
1452
|
+
rm -f "$project_dir/$item" 2>/dev/null && echo " removed file $item"
|
|
1453
|
+
done
|
|
1454
|
+
fi
|
|
1455
|
+
if [ "${#dirs[@]}" -gt 0 ]; then
|
|
1456
|
+
for item in "${dirs[@]}"; do
|
|
1457
|
+
rm -rf "$project_dir/$item" 2>/dev/null && echo " removed dir $item"
|
|
1458
|
+
done
|
|
1459
|
+
fi
|
|
1460
|
+
if [ "${#gi_entries[@]}" -gt 0 ]; then
|
|
1461
|
+
for item in "${gi_entries[@]}"; do
|
|
1462
|
+
local gi="$project_dir/.gitignore"
|
|
1463
|
+
if [[ -f "$gi" ]] && grep -qFx "$item" "$gi"; then
|
|
1464
|
+
local tmp; tmp=$(mktemp)
|
|
1465
|
+
grep -vFx "$item" "$gi" > "$tmp" || true
|
|
1466
|
+
mv "$tmp" "$gi"
|
|
1467
|
+
echo " .gitignore - $item"
|
|
1468
|
+
fi
|
|
1469
|
+
done
|
|
1470
|
+
fi
|
|
1471
|
+
if [ "${#plists[@]}" -gt 0 ]; then
|
|
1472
|
+
for item in "${plists[@]}"; do
|
|
1473
|
+
launchctl unload -w "$HOME/Library/LaunchAgents/$item" 2>/dev/null && echo " unloaded $item"
|
|
1474
|
+
rm -f "$HOME/Library/LaunchAgents/$item" 2>/dev/null
|
|
1475
|
+
done
|
|
1476
|
+
fi
|
|
1477
|
+
# Finally, remove the changeset file itself.
|
|
1478
|
+
rm -f "$changeset"
|
|
1479
|
+
ok "Offboard complete. Offboard 完成。"
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1179
1482
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
1180
1483
|
# cmd_migrate
|
|
1181
1484
|
# US-ONBOARD-003: One-shot migration from old project layout to .roll/ structure.
|
|
@@ -1916,6 +2219,9 @@ cmd_peer() {
|
|
|
1916
2219
|
local context_file=""
|
|
1917
2220
|
local yolo=false
|
|
1918
2221
|
local subcmd=""
|
|
2222
|
+
# US-VIEW-009: parse --demo before any side effects so the v2 renderer can
|
|
2223
|
+
# run standalone (no peer state, no tmux session, no agent call).
|
|
2224
|
+
local demo=false
|
|
1919
2225
|
|
|
1920
2226
|
while [[ $# -gt 0 ]]; do
|
|
1921
2227
|
case "$1" in
|
|
@@ -1925,6 +2231,7 @@ cmd_peer() {
|
|
|
1925
2231
|
--tag) tag="$2"; shift 2 ;;
|
|
1926
2232
|
--context) context_file="$2"; shift 2 ;;
|
|
1927
2233
|
--yes|--yolo) yolo=true; shift ;;
|
|
2234
|
+
--demo) demo=true; shift ;;
|
|
1928
2235
|
status) subcmd="status"; shift ;;
|
|
1929
2236
|
reset) subcmd="reset"; shift; break ;;
|
|
1930
2237
|
help|--help|-h) subcmd="help"; shift ;;
|
|
@@ -1932,6 +2239,13 @@ cmd_peer() {
|
|
|
1932
2239
|
esac
|
|
1933
2240
|
done
|
|
1934
2241
|
|
|
2242
|
+
# US-VIEW-009: ROLL_UI=v2 (default) + --demo routes to the redesigned Python view.
|
|
2243
|
+
# Set ROLL_UI=v1 to fall back to the legacy bash implementation.
|
|
2244
|
+
if [[ "$demo" == "true" ]] && { [[ "${ROLL_UI:-v2}" == "v2" ]] || [[ "$demo" == "true" ]]; }; then
|
|
2245
|
+
python3 "${ROLL_PKG_DIR}/lib/roll-peer.py" --demo
|
|
2246
|
+
return
|
|
2247
|
+
fi
|
|
2248
|
+
|
|
1935
2249
|
case "$subcmd" in
|
|
1936
2250
|
status) cmd_peer_status; return ;;
|
|
1937
2251
|
reset) cmd_peer_reset "$@"; return ;;
|
|
@@ -2192,8 +2506,22 @@ cmd_peer_help() {
|
|
|
2192
2506
|
# AGENT — per-project agent configuration
|
|
2193
2507
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
2194
2508
|
|
|
2509
|
+
# REFACTOR-040: project agent preference moved from the project root
|
|
2510
|
+
# (`.roll.yaml`) to `.roll/local.yaml`. The new location stays alongside other
|
|
2511
|
+
# per-machine runtime state inside `.roll/`, never reaches git (.roll/ is
|
|
2512
|
+
# gitignored), and keeps the project root clean. The old `.roll.yaml` location
|
|
2513
|
+
# is still read as a fallback so existing checkouts keep working until the next
|
|
2514
|
+
# `roll agent use` rewrites them in place.
|
|
2515
|
+
_project_agent_pref_file() {
|
|
2516
|
+
echo ".roll/local.yaml"
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2195
2519
|
_project_agent() {
|
|
2196
|
-
|
|
2520
|
+
local pref new_pref
|
|
2521
|
+
new_pref=$(_project_agent_pref_file)
|
|
2522
|
+
if [[ -f "$new_pref" ]] && grep -q "^agent:" "$new_pref" 2>/dev/null; then
|
|
2523
|
+
grep "^agent:" "$new_pref" | awk '{print $2}' | tr -d '"' | head -1
|
|
2524
|
+
elif [[ -f ".roll.yaml" ]] && grep -q "^agent:" .roll.yaml 2>/dev/null; then
|
|
2197
2525
|
grep "^agent:" .roll.yaml | awk '{print $2}' | tr -d '"' | head -1
|
|
2198
2526
|
elif [[ -f "$ROLL_CONFIG" ]] && grep -q "primary_agent:" "$ROLL_CONFIG" 2>/dev/null; then
|
|
2199
2527
|
grep "primary_agent:" "$ROLL_CONFIG" | awk '{print $2}' | tr -d '"' | head -1
|
|
@@ -2388,10 +2716,25 @@ cmd_agent() {
|
|
|
2388
2716
|
local name="${1:-}"
|
|
2389
2717
|
[[ -z "$name" ]] && { err "Usage: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"; exit 1; }
|
|
2390
2718
|
command -v "$name" &>/dev/null || warn "${name} not found in PATH — setting anyway 未找到,仍写入配置"
|
|
2391
|
-
|
|
2392
|
-
|
|
2719
|
+
# REFACTOR-040: write to .roll/local.yaml (per-machine state). Migrate
|
|
2720
|
+
# from legacy .roll.yaml in the project root on the spot — copy the
|
|
2721
|
+
# value over once, then delete the old file so the root stays clean.
|
|
2722
|
+
mkdir -p .roll
|
|
2723
|
+
local pref; pref=$(_project_agent_pref_file)
|
|
2724
|
+
if [[ -f "$pref" ]] && grep -q "^agent:" "$pref"; then
|
|
2725
|
+
local tmp; tmp=$(mktemp) && sed "s/^agent:.*/agent: ${name}/" "$pref" > "$tmp" && mv "$tmp" "$pref"
|
|
2393
2726
|
else
|
|
2394
|
-
echo "agent: ${name}" >>
|
|
2727
|
+
echo "agent: ${name}" >> "$pref"
|
|
2728
|
+
fi
|
|
2729
|
+
if [[ -f ".roll.yaml" ]]; then
|
|
2730
|
+
# Drop legacy agent line; remove the file if it's left empty (covers
|
|
2731
|
+
# the common case where .roll.yaml only ever held the agent pref).
|
|
2732
|
+
# grep -v returns 1 when nothing remains after filtering — that's a
|
|
2733
|
+
# success here, so suppress the exit code so the mv still runs.
|
|
2734
|
+
local tmp; tmp=$(mktemp)
|
|
2735
|
+
grep -v "^agent:" .roll.yaml > "$tmp" 2>/dev/null || true
|
|
2736
|
+
mv "$tmp" .roll.yaml
|
|
2737
|
+
[[ -s ".roll.yaml" ]] || rm -f .roll.yaml
|
|
2395
2738
|
fi
|
|
2396
2739
|
ok "Agent set to ${name} for this project 当前项目 agent 已设为 ${name}"
|
|
2397
2740
|
local project_path; project_path=$(pwd -P)
|
|
@@ -2416,7 +2759,13 @@ cmd_agent() {
|
|
|
2416
2759
|
;;
|
|
2417
2760
|
"")
|
|
2418
2761
|
local agent; agent=$(_project_agent)
|
|
2419
|
-
local src="global"
|
|
2762
|
+
local src="global"
|
|
2763
|
+
local pref; pref=$(_project_agent_pref_file)
|
|
2764
|
+
if [[ -f "$pref" ]] && grep -q "^agent:" "$pref" 2>/dev/null; then
|
|
2765
|
+
src="project (.roll/local.yaml)"
|
|
2766
|
+
elif [[ -f ".roll.yaml" ]] && grep -q "^agent:" .roll.yaml 2>/dev/null; then
|
|
2767
|
+
src="project (.roll.yaml, legacy — run \`roll agent use\` to migrate)"
|
|
2768
|
+
fi
|
|
2420
2769
|
echo -e "\n Agent ${CYAN}${agent}${NC} (${src})\n"
|
|
2421
2770
|
echo " roll agent use <name> — switch agent for this project"
|
|
2422
2771
|
echo " roll agent list — show installed agents"; echo ""
|
|
@@ -2656,20 +3005,16 @@ _loop_event() {
|
|
|
2656
3005
|
# stdout: tab-separated for tmux display
|
|
2657
3006
|
printf '%s\t%s\t%s\t%s\t%s\n' "$ts" "$stage" "$label" "$detail" "$outcome"
|
|
2658
3007
|
|
|
2659
|
-
# JSON line appended to NDJSON file
|
|
2660
|
-
#
|
|
3008
|
+
# JSON line appended to NDJSON file. FIX-067: drop the flock/lockf guard.
|
|
3009
|
+
# POSIX requires write() ≤ PIPE_BUF (≥512 bytes, 4 KiB on Linux/macOS) to
|
|
3010
|
+
# a file opened O_APPEND be atomic across concurrent writers, and a single
|
|
3011
|
+
# JSONL event line is well under that limit. The old lockfile path could
|
|
3012
|
+
# stall the EXIT trap added in FIX-066 when the lockfile state was
|
|
3013
|
+
# inconsistent, leaving cycle_end unwritten when the outer SIGHUP fired —
|
|
3014
|
+
# defeating FIX-066's whole purpose.
|
|
2661
3015
|
json=$(printf '{"ts":"%s","stage":"%s","label":"%s","detail":"%s","outcome":"%s"}\n' \
|
|
2662
3016
|
"$ts" "$stage" "$label" "$detail" "$outcome")
|
|
2663
|
-
|
|
2664
|
-
(
|
|
2665
|
-
flock -x 9
|
|
2666
|
-
printf '%s\n' "$json" >> "$evfile"
|
|
2667
|
-
) 9>>"${evfile}.lock"
|
|
2668
|
-
elif command -v lockf >/dev/null 2>&1; then
|
|
2669
|
-
lockf -s "${evfile}.lock" sh -c "printf '%s\n' $(printf '%q' "$json") >> $(printf '%q' "$evfile")"
|
|
2670
|
-
else
|
|
2671
|
-
printf '%s\n' "$json" >> "$evfile"
|
|
2672
|
-
fi
|
|
3017
|
+
printf '%s\n' "$json" >> "$evfile"
|
|
2673
3018
|
|
|
2674
3019
|
# File rotation: if >10MB, rotate keeping last 5
|
|
2675
3020
|
_loop_event_rotate "$evfile"
|
|
@@ -2940,6 +3285,10 @@ _LOOP_MUTE_FILE="\${_SHARED_ROOT}/loop/mute-${slug}"
|
|
|
2940
3285
|
# claude, loop-fmt.py, _loop_event in arbitrary cwd. _project_slug honors this
|
|
2941
3286
|
# env var first, so writes never fragment into tmp-* / cycle-* phantom slugs.
|
|
2942
3287
|
export ROLL_MAIN_SLUG="${slug}"
|
|
3288
|
+
# FIX-070: helpers that need to update the main repo's backlog (e.g. when a
|
|
3289
|
+
# worktree cycle marks a story 🔨 In Progress) read ROLL_MAIN_PROJECT to
|
|
3290
|
+
# locate it — the cycle's own cwd is the worktree, not main.
|
|
3291
|
+
export ROLL_MAIN_PROJECT="${project_path}"
|
|
2943
3292
|
|
|
2944
3293
|
# Pre-claude: try to create a per-cycle isolated worktree on origin/main.
|
|
2945
3294
|
# On any failure (no remote, no main, etc.) fall back to running in the
|
|
@@ -2991,6 +3340,11 @@ if _worktree_fetch_origin main \\
|
|
|
2991
3340
|
&& _worktree_create "\$WT" "\$BRANCH" "origin/main"; then
|
|
2992
3341
|
_USE_WORKTREE=1
|
|
2993
3342
|
_worktree_submodule_init "\$WT" 2>/dev/null || true
|
|
3343
|
+
# FIX-069: copy .roll/ meta (backlog, skills, conventions) into the
|
|
3344
|
+
# worktree as a read-only reference. Without this the cycle no-ops
|
|
3345
|
+
# because .roll/ is gitignored and the clean clone has no backlog
|
|
3346
|
+
# for Claude to read or skill entry points to dispatch to.
|
|
3347
|
+
_worktree_sync_meta "\$WT" 2>/dev/null || true
|
|
2994
3348
|
echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
|
|
2995
3349
|
_loop_event cycle_start "\${CYCLE_ID}" "" "" || true
|
|
2996
3350
|
else
|
|
@@ -3015,11 +3369,25 @@ export LOOP_PROJECT_SLUG="${slug}"
|
|
|
3015
3369
|
export LOOP_CYCLE_ID="\${CYCLE_ID}"
|
|
3016
3370
|
export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
|
|
3017
3371
|
for _attempt in 1 2 3; do
|
|
3018
|
-
# FIX-
|
|
3019
|
-
#
|
|
3020
|
-
#
|
|
3021
|
-
|
|
3022
|
-
|
|
3372
|
+
# FIX-068: defensive reset before each attempt — _CYCLE_TIMED_OUT carries
|
|
3373
|
+
# the SIGTERM result of the previous attempt and would otherwise force an
|
|
3374
|
+
# immediate break on a clean retry.
|
|
3375
|
+
_CYCLE_TIMED_OUT=0
|
|
3376
|
+
# FIX-057 + FIX-068: watchdog — fires SIGTERM at the inner script (and its
|
|
3377
|
+
# direct children) when the cycle exceeds LOOP_CYCLE_TIMEOUT_SEC, then
|
|
3378
|
+
# escalates to SIGKILL after a 5s grace period for any claude process
|
|
3379
|
+
# still alive. claude lives inside a pipeline subshell, so pkill -P \$\$
|
|
3380
|
+
# alone only catches the subshell, not claude itself; matching by the
|
|
3381
|
+
# worktree path (which appears in claude's --add-dir arg, FIX-048) targets
|
|
3382
|
+
# the cycle's claude uniquely without touching other projects' processes.
|
|
3383
|
+
( sleep "\$LOOP_CYCLE_TIMEOUT_SEC" && {
|
|
3384
|
+
kill -TERM \$\$ 2>/dev/null
|
|
3385
|
+
pkill -TERM -P \$\$ 2>/dev/null
|
|
3386
|
+
pkill -TERM -f "\$WT" 2>/dev/null
|
|
3387
|
+
sleep 5
|
|
3388
|
+
pkill -KILL -P \$\$ 2>/dev/null
|
|
3389
|
+
pkill -KILL -f "\$WT" 2>/dev/null
|
|
3390
|
+
} ) &
|
|
3023
3391
|
_WATCHDOG_PID=\$!
|
|
3024
3392
|
if [ -f "\$FMT" ]; then
|
|
3025
3393
|
( cd "\$WT" && ${claude_cmd} ) | python3 "\$FMT"
|
|
@@ -4178,50 +4546,10 @@ _loop_heal_dir() {
|
|
|
4178
4546
|
printf '%s\n' "${ROLL_LOOP_DIR:-${HOME}/.shared/roll/loop}/heal"
|
|
4179
4547
|
}
|
|
4180
4548
|
|
|
4181
|
-
#
|
|
4182
|
-
#
|
|
4183
|
-
#
|
|
4184
|
-
#
|
|
4185
|
-
# Exit 0: another heal attempt is allowed (counter incremented). Caller should
|
|
4186
|
-
# invoke roll-fix with the failure summary, then re-run the CI gate.
|
|
4187
|
-
# Exit 1: heal disabled (ROLL_LOOP_NO_HEAL=1) or exhausted (>= ROLL_LOOP_HEAL_MAX).
|
|
4188
|
-
# Caller should write ALERT and stop.
|
|
4189
|
-
_loop_self_heal_ci() {
|
|
4190
|
-
local story_id="$1"
|
|
4191
|
-
[[ -z "$story_id" ]] && return 1
|
|
4192
|
-
[[ "${ROLL_LOOP_NO_HEAL:-0}" == "1" ]] && return 1
|
|
4193
|
-
local max="${ROLL_LOOP_HEAL_MAX:-2}"
|
|
4194
|
-
local state="$_LOOP_STATE"
|
|
4195
|
-
local current=0
|
|
4196
|
-
if [[ -f "$state" ]]; then
|
|
4197
|
-
local raw; raw=$(grep '^heal_count:' "$state" 2>/dev/null | awk '{print $2}')
|
|
4198
|
-
[[ "$raw" =~ ^[0-9]+$ ]] && current="$raw"
|
|
4199
|
-
fi
|
|
4200
|
-
[[ "$current" -ge "$max" ]] && return 1
|
|
4201
|
-
local new=$((current + 1))
|
|
4202
|
-
mkdir -p "$(dirname "$state")"
|
|
4203
|
-
if grep -q '^heal_count:' "$state" 2>/dev/null; then
|
|
4204
|
-
local tmp; tmp=$(mktemp)
|
|
4205
|
-
sed "s/^heal_count:.*/heal_count: ${new}/" "$state" > "$tmp"
|
|
4206
|
-
mv "$tmp" "$state"
|
|
4207
|
-
else
|
|
4208
|
-
echo "heal_count: ${new}" >> "$state"
|
|
4209
|
-
fi
|
|
4210
|
-
return 0
|
|
4211
|
-
}
|
|
4212
|
-
|
|
4213
|
-
# Reset per-story heal counter. Called when CI eventually turns green or by
|
|
4214
|
-
# `roll loop reset`. Idempotent.
|
|
4215
|
-
_loop_clear_heal_state() {
|
|
4216
|
-
local story_id="$1"
|
|
4217
|
-
[[ -z "$story_id" ]] && return 0
|
|
4218
|
-
local state="$_LOOP_STATE"
|
|
4219
|
-
[[ ! -f "$state" ]] && return 0
|
|
4220
|
-
local tmp; tmp=$(mktemp)
|
|
4221
|
-
grep -v '^heal_count:' "$state" > "$tmp"
|
|
4222
|
-
mv "$tmp" "$state"
|
|
4223
|
-
return 0
|
|
4224
|
-
}
|
|
4549
|
+
# REFACTOR-030: removed `_loop_self_heal_ci` and `_loop_clear_heal_state`.
|
|
4550
|
+
# REFACTOR-023 merged the CI self-heal counter into the main state.yaml flow,
|
|
4551
|
+
# but the two helpers themselves were left behind as dead code. Their job
|
|
4552
|
+
# now lives in the state.yaml read/write paths called from the loop runner.
|
|
4225
4553
|
|
|
4226
4554
|
# Verify TCR rhythm after a story completes. Returns 0 if ok, 1 if no TCR commits.
|
|
4227
4555
|
# On failure: reverts story in .roll/backlog.md to 📋 Todo and writes ALERT.
|
|
@@ -4603,6 +4931,50 @@ _loop_pr_inbox() {
|
|
|
4603
4931
|
return 0
|
|
4604
4932
|
}
|
|
4605
4933
|
|
|
4934
|
+
# FIX-070: flip a story row in the main repo's .roll/backlog.md between
|
|
4935
|
+
# 📋 Todo and 🔨 In Progress. The cycle worktree is gitignored at .roll/,
|
|
4936
|
+
# so editing the worktree copy + committing leaves no trace in git — and
|
|
4937
|
+
# main's backlog (which roll-brief reads) stays stale. These helpers write
|
|
4938
|
+
# directly to ${ROLL_MAIN_PROJECT}/.roll/backlog.md instead.
|
|
4939
|
+
#
|
|
4940
|
+
# _loop_mark_in_progress <story-id> [backlog-path]
|
|
4941
|
+
# Replace "📋 Todo" with "🔨 In Progress" on the row containing <story-id>.
|
|
4942
|
+
# No-op when backlog or row is missing (idempotent retries don't error).
|
|
4943
|
+
_loop_mark_in_progress() {
|
|
4944
|
+
local story_id="$1"
|
|
4945
|
+
local backlog="${2:-${ROLL_MAIN_PROJECT:-$PWD}/.roll/backlog.md}"
|
|
4946
|
+
[ -n "$story_id" ] || return 1
|
|
4947
|
+
[ -f "$backlog" ] || return 0
|
|
4948
|
+
local tmp; tmp=$(mktemp "${backlog}.XXXXXX") || return 1
|
|
4949
|
+
awk -v sid="$story_id" '
|
|
4950
|
+
{
|
|
4951
|
+
if (index($0, sid) > 0 && index($0, "📋 Todo") > 0) {
|
|
4952
|
+
sub(/📋 Todo/, "🔨 In Progress")
|
|
4953
|
+
}
|
|
4954
|
+
print
|
|
4955
|
+
}
|
|
4956
|
+
' "$backlog" > "$tmp" && mv "$tmp" "$backlog"
|
|
4957
|
+
}
|
|
4958
|
+
|
|
4959
|
+
# _loop_mark_todo <story-id> [backlog-path]
|
|
4960
|
+
# Revert a row from "🔨 In Progress" back to "📋 Todo". Called when a
|
|
4961
|
+
# cycle's executor fails so the next cycle can pick the story up again.
|
|
4962
|
+
_loop_mark_todo() {
|
|
4963
|
+
local story_id="$1"
|
|
4964
|
+
local backlog="${2:-${ROLL_MAIN_PROJECT:-$PWD}/.roll/backlog.md}"
|
|
4965
|
+
[ -n "$story_id" ] || return 1
|
|
4966
|
+
[ -f "$backlog" ] || return 0
|
|
4967
|
+
local tmp; tmp=$(mktemp "${backlog}.XXXXXX") || return 1
|
|
4968
|
+
awk -v sid="$story_id" '
|
|
4969
|
+
{
|
|
4970
|
+
if (index($0, sid) > 0 && index($0, "🔨 In Progress") > 0) {
|
|
4971
|
+
sub(/🔨 In Progress/, "📋 Todo")
|
|
4972
|
+
}
|
|
4973
|
+
print
|
|
4974
|
+
}
|
|
4975
|
+
' "$backlog" > "$tmp" && mv "$tmp" "$backlog"
|
|
4976
|
+
}
|
|
4977
|
+
|
|
4606
4978
|
# FIX-048: report story IDs already claimed by open loop/* PRs so a new cycle
|
|
4607
4979
|
# can skip them before scanning BACKLOG. Without this gate, a cycle launched
|
|
4608
4980
|
# before the previous cycle's PR merges would re-pick the same Todo story
|
|
@@ -4879,6 +5251,30 @@ _worktree_submodule_init() {
|
|
|
4879
5251
|
( cd "$path" && git submodule update --init --recursive --quiet )
|
|
4880
5252
|
}
|
|
4881
5253
|
|
|
5254
|
+
# _worktree_sync_meta <path>
|
|
5255
|
+
# FIX-069: Copy main repo's .roll/ meta (backlog, skills, conventions,
|
|
5256
|
+
# features, decisions) into the cycle worktree as a read-only reference.
|
|
5257
|
+
# Without this, the loop runs in a clean git clone with no .roll/ (it's
|
|
5258
|
+
# gitignored), so Claude finds no backlog and no skill entry points —
|
|
5259
|
+
# the whole cycle no-ops.
|
|
5260
|
+
#
|
|
5261
|
+
# Excludes runtime state listed in .roll/.gitignore plus loop event/run
|
|
5262
|
+
# logs, so the worktree never inherits main's live cycle state.
|
|
5263
|
+
# Single-shot: never written back; the worktree copy is thrown away with
|
|
5264
|
+
# the worktree itself.
|
|
5265
|
+
_worktree_sync_meta() {
|
|
5266
|
+
local path="$1"
|
|
5267
|
+
[ -d ".roll" ] || return 0
|
|
5268
|
+
rsync -a \
|
|
5269
|
+
--exclude='state/' \
|
|
5270
|
+
--exclude='scratch/' \
|
|
5271
|
+
--exclude='*.lock' \
|
|
5272
|
+
--exclude='last-test-pass' \
|
|
5273
|
+
--exclude='events.ndjson*' \
|
|
5274
|
+
--exclude='runs.jsonl*' \
|
|
5275
|
+
.roll/ "$path/.roll/" 2>/dev/null || true
|
|
5276
|
+
}
|
|
5277
|
+
|
|
4882
5278
|
# _worktree_merge_back <branch>
|
|
4883
5279
|
# Caller must be in the main worktree (cwd = main). Steps:
|
|
4884
5280
|
# 1. git pull --ff-only origin main (sync local main with remote)
|
|
@@ -5387,34 +5783,11 @@ cmd_brief() {
|
|
|
5387
5783
|
cat "$latest"
|
|
5388
5784
|
}
|
|
5389
5785
|
|
|
5390
|
-
_promote_unreleased
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
sed -i.bak "s/^## Unreleased$/## v${version}/" "$changelog" && rm "${changelog}.bak"
|
|
5396
|
-
}
|
|
5397
|
-
|
|
5398
|
-
_ensure_unreleased() {
|
|
5399
|
-
local changelog="$1"
|
|
5400
|
-
[[ -f "$changelog" ]] || return 0
|
|
5401
|
-
grep -q "^## Unreleased$" "$changelog" && return 0
|
|
5402
|
-
python3 - "$changelog" <<'PYEOF'
|
|
5403
|
-
import sys, pathlib
|
|
5404
|
-
p = pathlib.Path(sys.argv[1])
|
|
5405
|
-
lines = p.read_text().splitlines(keepends=True)
|
|
5406
|
-
out = []
|
|
5407
|
-
inserted = False
|
|
5408
|
-
for line in lines:
|
|
5409
|
-
out.append(line)
|
|
5410
|
-
if not inserted and line.rstrip() == "# Changelog":
|
|
5411
|
-
out.append("\n## Unreleased\n")
|
|
5412
|
-
inserted = True
|
|
5413
|
-
if not inserted:
|
|
5414
|
-
out.insert(0, "## Unreleased\n\n")
|
|
5415
|
-
p.write_text("".join(out))
|
|
5416
|
-
PYEOF
|
|
5417
|
-
}
|
|
5786
|
+
# REFACTOR-030: removed `_promote_unreleased` and `_ensure_unreleased`.
|
|
5787
|
+
# REFACTOR-021 collapsed the changelog double-pipeline so the release script
|
|
5788
|
+
# generates the version header directly from BACKLOG, leaving these two
|
|
5789
|
+
# helpers orphaned. Their behaviour is now part of the changelog renderer
|
|
5790
|
+
# called from scripts/release.sh.
|
|
5418
5791
|
|
|
5419
5792
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
5420
5793
|
# BACKLOG — show pending tasks / manage status
|
|
@@ -5555,6 +5928,73 @@ cmd_ci() {
|
|
|
5555
5928
|
echo "$runs" | jq -r '.[] | "\(.name): \(.status)/\(.conclusion)"'
|
|
5556
5929
|
}
|
|
5557
5930
|
|
|
5931
|
+
# REFACTOR-041: backlog description linter. The global convention bans file
|
|
5932
|
+
# paths, function names, filenames, and "architecture jargon" in description
|
|
5933
|
+
# columns — see conventions/global/AGENTS.md §4. This helper scans each row's
|
|
5934
|
+
# description column for those patterns and prints any findings. Phase 1 is
|
|
5935
|
+
# warn-only (always exit 0) so a noisy ramp-up doesn't block work; Phase 2
|
|
5936
|
+
# will switch to hard-fail. Output format mirrors a linter ("file:line:
|
|
5937
|
+
# message") so editors can navigate from it.
|
|
5938
|
+
_backlog_lint() {
|
|
5939
|
+
local backlog="${1:-.roll/backlog.md}"
|
|
5940
|
+
[ -f "$backlog" ] || { err "backlog not found: $backlog"; return 1; }
|
|
5941
|
+
|
|
5942
|
+
local violations=0
|
|
5943
|
+
local lineno=0
|
|
5944
|
+
while IFS= read -r line; do
|
|
5945
|
+
lineno=$((lineno+1))
|
|
5946
|
+
# Only data rows (start with "|"), skip header/separator/non-table
|
|
5947
|
+
case "$line" in
|
|
5948
|
+
\|*) ;;
|
|
5949
|
+
*) continue ;;
|
|
5950
|
+
esac
|
|
5951
|
+
case "$line" in
|
|
5952
|
+
*Story*Description*Status*|*'---'*) continue ;;
|
|
5953
|
+
esac
|
|
5954
|
+
local desc
|
|
5955
|
+
desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//')
|
|
5956
|
+
[ -n "$desc" ] || continue
|
|
5957
|
+
# Strip the leading `[US-XXX](path)` link / bare `US-XXX` id — those are
|
|
5958
|
+
# structural, not description prose.
|
|
5959
|
+
local body
|
|
5960
|
+
body=$(echo "$desc" \
|
|
5961
|
+
| sed -E 's|^\[[A-Z]+-[0-9]+\]\([^)]*\)[[:space:]]*||' \
|
|
5962
|
+
| sed -E 's|^[A-Z]+-[0-9]+[[:space:]]*||')
|
|
5963
|
+
local issues=""
|
|
5964
|
+
# Filenames: bare `something.ext` for common code/config extensions
|
|
5965
|
+
if echo "$body" | grep -qE '\b[A-Za-z_][A-Za-z0-9_.-]*\.(sh|bash|yaml|yml|json|js|ts|tsx|py|rb|go|rs|c|cpp|h)\b'; then
|
|
5966
|
+
issues="${issues:+${issues}, }filename"
|
|
5967
|
+
fi
|
|
5968
|
+
# Paths: directory/anything pattern not preceded by `(` (links already
|
|
5969
|
+
# stripped above). Hyphens / dots / underscores allowed in path segments.
|
|
5970
|
+
if echo "$body" | grep -qE '[A-Za-z_][A-Za-z0-9_.-]*/[A-Za-z0-9_./-]+'; then
|
|
5971
|
+
issues="${issues:+${issues}, }path"
|
|
5972
|
+
fi
|
|
5973
|
+
# Function names: underscore-prefixed identifier or trailing parens
|
|
5974
|
+
if echo "$body" | grep -qE '\b_[a-zA-Z][a-zA-Z0-9_]+\b|\b[A-Za-z_][A-Za-z0-9_]+\(\)'; then
|
|
5975
|
+
issues="${issues:+${issues}, }function"
|
|
5976
|
+
fi
|
|
5977
|
+
if [ -n "$issues" ]; then
|
|
5978
|
+
violations=$((violations+1))
|
|
5979
|
+
# Extract the story id from column 2 so reports name the offending row.
|
|
5980
|
+
local sid; sid=$(echo "$line" | awk -F'|' '{print $2}' \
|
|
5981
|
+
| sed -E 's/^[[:space:]]*\[?([A-Z]+-[0-9]+).*/\1/' \
|
|
5982
|
+
| tr -d '[:space:]')
|
|
5983
|
+
printf '%s:%d: %s — %s\n %s\n' "$backlog" "$lineno" "$sid" "$issues" "$desc"
|
|
5984
|
+
fi
|
|
5985
|
+
done < "$backlog"
|
|
5986
|
+
|
|
5987
|
+
echo ""
|
|
5988
|
+
if [ "$violations" -gt 0 ]; then
|
|
5989
|
+
echo " ${violations} violation(s) — see conventions/global/AGENTS.md §4"
|
|
5990
|
+
echo " ${violations} 条违规 — Phase 1: warn-only, not blocking"
|
|
5991
|
+
else
|
|
5992
|
+
echo " No violations 无违规"
|
|
5993
|
+
fi
|
|
5994
|
+
# Phase 1: warn-only. Exit 0 regardless.
|
|
5995
|
+
return 0
|
|
5996
|
+
}
|
|
5997
|
+
|
|
5558
5998
|
cmd_backlog() {
|
|
5559
5999
|
local backlog=".roll/backlog.md"
|
|
5560
6000
|
if [[ ! -f "$backlog" ]]; then
|
|
@@ -5566,6 +6006,10 @@ cmd_backlog() {
|
|
|
5566
6006
|
|
|
5567
6007
|
# ── Status management subcommands ─────────────────────────────────────────
|
|
5568
6008
|
case "$subcmd" in
|
|
6009
|
+
lint)
|
|
6010
|
+
_backlog_lint "$backlog"
|
|
6011
|
+
return
|
|
6012
|
+
;;
|
|
5569
6013
|
block|defer|unblock|promote)
|
|
5570
6014
|
local pattern="${2:-}"
|
|
5571
6015
|
local reason="${3:-}"
|
|
@@ -6093,6 +6537,7 @@ _legacy_help() {
|
|
|
6093
6537
|
echo " setup [-f] [Machine] First-time install or re-sync 首次安装或重新同步"
|
|
6094
6538
|
echo " update [Upgrade] npm install latest + re-sync 一键升级到最新版"
|
|
6095
6539
|
echo " init [Project] Create AGENTS.md + .roll/backlog.md + .roll/features/ 初始化项目工作流文件"
|
|
6540
|
+
echo " offboard [--confirm] [Project] Reverse a previous \`roll init --apply\` (dry-run by default) 卸载本项目的 Roll 痕迹"
|
|
6096
6541
|
echo " status [Diagnostic] Show current state 显示当前状态"
|
|
6097
6542
|
echo " peer [Peer Review] Cross-agent negotiation 跨 Agent 协商对审"
|
|
6098
6543
|
echo " loop <on|off|now|status|monitor|resume|reset> [Autonomous] Manage scheduled BACKLOG executor 管理自主执行循环"
|
|
@@ -6101,6 +6546,7 @@ _legacy_help() {
|
|
|
6101
6546
|
echo " backlog block <pat> [reason] Mark matching items as 🔒 Blocked 标记为已阻塞"
|
|
6102
6547
|
echo " backlog defer <pat> [reason] Mark matching items as ⏸ Deferred 标记为已推迟"
|
|
6103
6548
|
echo " backlog unblock <pat> Restore matching items to 📋 Todo 恢复为待处理"
|
|
6549
|
+
echo " backlog lint Check descriptions for path/function/filename violations 检查描述合规"
|
|
6104
6550
|
echo " agent [use <name>|list] [Config] Per-project agent selection 切换项目 agent"
|
|
6105
6551
|
echo " ci [--wait] [CI] Show or wait for current commit's CI status 查看/等待 CI 状态"
|
|
6106
6552
|
echo " review-pr <number> [PR Review] AI-powered code review for a PR AI 代码评审"
|
|
@@ -6151,6 +6597,7 @@ _check_structure() {
|
|
|
6151
6597
|
case "$cmd" in
|
|
6152
6598
|
setup|update|migrate|doctor|version|--version|-v|help|--help|-h|"") return 0 ;;
|
|
6153
6599
|
init) return 0 ;; # cmd_init handles its own structure logic
|
|
6600
|
+
offboard) return 0 ;; # cmd_offboard does its own changeset check
|
|
6154
6601
|
esac
|
|
6155
6602
|
|
|
6156
6603
|
# Determine project root: git root if available, else pwd
|
|
@@ -6202,6 +6649,7 @@ main() {
|
|
|
6202
6649
|
setup) cmd_setup "$@" ;;
|
|
6203
6650
|
update) cmd_update "$@" ;;
|
|
6204
6651
|
init) cmd_init "$@" ;;
|
|
6652
|
+
offboard) cmd_offboard "$@" ;;
|
|
6205
6653
|
migrate) cmd_migrate "$@" ;;
|
|
6206
6654
|
status) cmd_status "$@" ;;
|
|
6207
6655
|
peer) cmd_peer "$@" ;;
|