@seanyao/roll 2026.519.2 → 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 +39 -0
- package/README.md +40 -70
- package/bin/roll +639 -115
- package/conventions/global/AGENTS.md +3 -2
- package/lib/roll-backlog.py +1 -1
- package/lib/roll-help.py +1 -1
- package/lib/roll-peer.py +242 -0
- package/lib/roll_render.py +21 -0
- package/package.json +1 -1
- package/skills/roll-build/SKILL.md +2 -2
- package/skills/roll-loop/SKILL.md +21 -10
- 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
|
|
@@ -2343,7 +2671,13 @@ cmd_review_pr() {
|
|
|
2343
2671
|
local output
|
|
2344
2672
|
info "Reviewing PR #${pr_number} with ${agent}..."
|
|
2345
2673
|
_agent_argv "$agent" text "$prompt" || { err "Unknown agent '${agent}'"; return 1; }
|
|
2346
|
-
|
|
2674
|
+
local _stderr_log; _stderr_log=$(mktemp)
|
|
2675
|
+
output=$("${_AGENT_ARGV[@]}" 2>"$_stderr_log")
|
|
2676
|
+
if [[ -z "$output" && -s "$_stderr_log" ]]; then
|
|
2677
|
+
err "agent ${agent} produced no output. stderr (first 5 lines):"
|
|
2678
|
+
head -5 "$_stderr_log" | sed 's/^/ /' >&2
|
|
2679
|
+
fi
|
|
2680
|
+
rm -f "$_stderr_log"
|
|
2347
2681
|
|
|
2348
2682
|
echo "$output"
|
|
2349
2683
|
|
|
@@ -2382,10 +2716,25 @@ cmd_agent() {
|
|
|
2382
2716
|
local name="${1:-}"
|
|
2383
2717
|
[[ -z "$name" ]] && { err "Usage: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"; exit 1; }
|
|
2384
2718
|
command -v "$name" &>/dev/null || warn "${name} not found in PATH — setting anyway 未找到,仍写入配置"
|
|
2385
|
-
|
|
2386
|
-
|
|
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"
|
|
2387
2726
|
else
|
|
2388
|
-
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
|
|
2389
2738
|
fi
|
|
2390
2739
|
ok "Agent set to ${name} for this project 当前项目 agent 已设为 ${name}"
|
|
2391
2740
|
local project_path; project_path=$(pwd -P)
|
|
@@ -2410,7 +2759,13 @@ cmd_agent() {
|
|
|
2410
2759
|
;;
|
|
2411
2760
|
"")
|
|
2412
2761
|
local agent; agent=$(_project_agent)
|
|
2413
|
-
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
|
|
2414
2769
|
echo -e "\n Agent ${CYAN}${agent}${NC} (${src})\n"
|
|
2415
2770
|
echo " roll agent use <name> — switch agent for this project"
|
|
2416
2771
|
echo " roll agent list — show installed agents"; echo ""
|
|
@@ -2545,6 +2900,35 @@ PYEOF
|
|
|
2545
2900
|
}
|
|
2546
2901
|
|
|
2547
2902
|
_LOOP_TAG="# roll-loop"
|
|
2903
|
+
# FIX-065: when sourced in a test context with no explicit override, route
|
|
2904
|
+
# shared state into a per-process /tmp path instead of falling back to
|
|
2905
|
+
# production ~/.shared/roll/. Without this safety net, tests that source
|
|
2906
|
+
# bin/roll (directly or via a generated inner runner under /var/folders/)
|
|
2907
|
+
# would write ALERT / state / events / runs.jsonl into the live loop
|
|
2908
|
+
# daemon's monitored directory and trigger false aborts.
|
|
2909
|
+
#
|
|
2910
|
+
# Test context is detected via three signals (any one is enough):
|
|
2911
|
+
# 1. BATS_TEST_FILENAME is set (works for direct test invocations)
|
|
2912
|
+
# 2. The caller's file path lives under /tmp or /var/folders (catches the
|
|
2913
|
+
# generated runner-inner.sh path that bats subprocesses spawn —
|
|
2914
|
+
# BATS_* env can be lost across `bash -l` + nested forks)
|
|
2915
|
+
# 3. PWD is under /tmp or /var/folders (catches helpers that cd'd in)
|
|
2916
|
+
if [ -z "${_SHARED_ROOT:-}" ]; then
|
|
2917
|
+
_roll_in_test_ctx=0
|
|
2918
|
+
if [ -n "${BATS_TEST_FILENAME:-}" ]; then
|
|
2919
|
+
_roll_in_test_ctx=1
|
|
2920
|
+
else
|
|
2921
|
+
_roll_caller="${BASH_SOURCE[1]:-}"
|
|
2922
|
+
case "$_roll_caller" in /tmp/*|/private/tmp/*|/var/folders/*) _roll_in_test_ctx=1 ;; esac
|
|
2923
|
+
case "$PWD" in /tmp/*|/private/tmp/*|/var/folders/*) _roll_in_test_ctx=1 ;; esac
|
|
2924
|
+
fi
|
|
2925
|
+
if [ "$_roll_in_test_ctx" = 1 ]; then
|
|
2926
|
+
_SHARED_ROOT="${TMPDIR:-/tmp}/roll-test-shared.$$"
|
|
2927
|
+
mkdir -p "${_SHARED_ROOT}/loop"
|
|
2928
|
+
export _SHARED_ROOT
|
|
2929
|
+
fi
|
|
2930
|
+
unset _roll_in_test_ctx _roll_caller
|
|
2931
|
+
fi
|
|
2548
2932
|
: "${_SHARED_ROOT:=${HOME}/.shared/roll}"
|
|
2549
2933
|
# FIX-052: per-project loop state — ALERT/state/mute were globally shared,
|
|
2550
2934
|
# causing one project's alerts to surface in another project's session and
|
|
@@ -2553,7 +2937,9 @@ _LOOP_TAG="# roll-loop"
|
|
|
2553
2937
|
: "${_LOOP_PROJ_SLUG:=$(_project_slug 2>/dev/null || echo default)}"
|
|
2554
2938
|
: "${_LOOP_STATE:=${_SHARED_ROOT}/loop/state-${_LOOP_PROJ_SLUG}.yaml}"
|
|
2555
2939
|
: "${_LOOP_ALERT:=${_SHARED_ROOT}/loop/ALERT-${_LOOP_PROJ_SLUG}.md}"
|
|
2556
|
-
|
|
2940
|
+
# FIX-065: was hardcoded to ${HOME}/.shared/roll/loop/runs.jsonl which ignored
|
|
2941
|
+
# _SHARED_ROOT overrides and silently leaked test runs.jsonl writes into prod.
|
|
2942
|
+
_LOOP_RUNS="${_SHARED_ROOT}/loop/runs.jsonl"
|
|
2557
2943
|
: "${_LOOP_MUTE_FILE:=${_SHARED_ROOT}/loop/mute-${_LOOP_PROJ_SLUG}}"
|
|
2558
2944
|
_LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
|
|
2559
2945
|
|
|
@@ -2564,6 +2950,13 @@ _config_read_int() {
|
|
|
2564
2950
|
if [[ "$val" =~ ^[0-9]+$ ]]; then echo "$val"; else echo "$default"; fi
|
|
2565
2951
|
}
|
|
2566
2952
|
|
|
2953
|
+
# REFACTOR-031: cross-platform file mtime in epoch seconds.
|
|
2954
|
+
# Replaces the `stat -c %Y ... || stat -f %m ... || echo 0` pattern that was
|
|
2955
|
+
# copy-pasted in four places (dashboard age widgets, briefs, dream, peer).
|
|
2956
|
+
_file_mtime() {
|
|
2957
|
+
stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2567
2960
|
# Derive a minute in [1,55] from project path hash + offset so different projects
|
|
2568
2961
|
# and different services within a project don't fire at the same time.
|
|
2569
2962
|
# Offsets used: loop=0, dream=2, brief=4 → always three distinct values (2<55).
|
|
@@ -2589,25 +2982,39 @@ _loop_event() {
|
|
|
2589
2982
|
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
2590
2983
|
slug=$(_project_slug 2>/dev/null || basename "$PWD")
|
|
2591
2984
|
evfile="${_SHARED_ROOT:-$HOME/.shared/roll}/loop/events-${slug}.ndjson"
|
|
2985
|
+
# FIX-065 tripwire: in a test context (BATS or temp cwd), refuse to write
|
|
2986
|
+
# into production ~/.shared/roll/. Catching this in code is the last line
|
|
2987
|
+
# of defense if some unusual path bypassed the auto-sandbox at source-time.
|
|
2988
|
+
# Skipped when HOME itself has been redirected to a sandbox dir — then
|
|
2989
|
+
# $HOME/.shared/roll IS the sandbox, not prod.
|
|
2990
|
+
case "${HOME:-}" in
|
|
2991
|
+
/tmp/*|/private/tmp/*|*/var/folders/*|*/tmp.*) ;;
|
|
2992
|
+
*)
|
|
2993
|
+
if [ -n "${HOME:-}" ] && [ "${evfile#${HOME}/.shared/roll/}" != "$evfile" ]; then
|
|
2994
|
+
case "${BATS_TEST_FILENAME:-}${PWD}" in
|
|
2995
|
+
*/tmp.*|*/var/folders/*|/tmp/*|/private/tmp/*|*.bats)
|
|
2996
|
+
echo "[FIX-065] refusing prod _loop_event write: $evfile (test context)" >&2
|
|
2997
|
+
return 1
|
|
2998
|
+
;;
|
|
2999
|
+
esac
|
|
3000
|
+
fi
|
|
3001
|
+
;;
|
|
3002
|
+
esac
|
|
2592
3003
|
mkdir -p "$(dirname "$evfile")"
|
|
2593
3004
|
|
|
2594
3005
|
# stdout: tab-separated for tmux display
|
|
2595
3006
|
printf '%s\t%s\t%s\t%s\t%s\n' "$ts" "$stage" "$label" "$detail" "$outcome"
|
|
2596
3007
|
|
|
2597
|
-
# JSON line appended to NDJSON file
|
|
2598
|
-
#
|
|
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.
|
|
2599
3015
|
json=$(printf '{"ts":"%s","stage":"%s","label":"%s","detail":"%s","outcome":"%s"}\n' \
|
|
2600
3016
|
"$ts" "$stage" "$label" "$detail" "$outcome")
|
|
2601
|
-
|
|
2602
|
-
(
|
|
2603
|
-
flock -x 9
|
|
2604
|
-
printf '%s\n' "$json" >> "$evfile"
|
|
2605
|
-
) 9>>"${evfile}.lock"
|
|
2606
|
-
elif command -v lockf >/dev/null 2>&1; then
|
|
2607
|
-
lockf -s "${evfile}.lock" sh -c "printf '%s\n' $(printf '%q' "$json") >> $(printf '%q' "$evfile")"
|
|
2608
|
-
else
|
|
2609
|
-
printf '%s\n' "$json" >> "$evfile"
|
|
2610
|
-
fi
|
|
3017
|
+
printf '%s\n' "$json" >> "$evfile"
|
|
2611
3018
|
|
|
2612
3019
|
# File rotation: if >10MB, rotate keeping last 5
|
|
2613
3020
|
_loop_event_rotate "$evfile"
|
|
@@ -2779,7 +3186,7 @@ fi
|
|
|
2779
3186
|
printf '%s:%s\n' "\$\$" "\$(date -u +%s)" > "\$INNER_LOCK"
|
|
2780
3187
|
# FIX-038: background heartbeat writer — outer script uses this as primary liveness signal
|
|
2781
3188
|
# to detect stale execution without relying on PID reuse heuristics.
|
|
2782
|
-
HEARTBEAT_FILE="
|
|
3189
|
+
HEARTBEAT_FILE="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/.heartbeat-${slug}"
|
|
2783
3190
|
_heartbeat_writer() {
|
|
2784
3191
|
while true; do echo "\$(date -u +%s)" > "\$HEARTBEAT_FILE"; sleep 60; done
|
|
2785
3192
|
}
|
|
@@ -2791,6 +3198,11 @@ _HEARTBEAT_PID=\$!
|
|
|
2791
3198
|
# next cron tick can proceed. Overridable via env for tests.
|
|
2792
3199
|
LOOP_CYCLE_TIMEOUT_SEC="\${ROLL_LOOP_CYCLE_TIMEOUT_SEC:-2700}"
|
|
2793
3200
|
_CYCLE_TIMED_OUT=0
|
|
3201
|
+
# IDEA-028 / FIX-066: track whether cycle_end has been emitted via any of the
|
|
3202
|
+
# explicit completion paths (publish/merge_back/orphan-push/claude-failed).
|
|
3203
|
+
# When zero, the EXIT trap emits a fallback so cycle_start never orphans the
|
|
3204
|
+
# dashboard into a phantom "still running" row.
|
|
3205
|
+
_CYCLE_END_WRITTEN=0
|
|
2794
3206
|
_on_sigterm() { _CYCLE_TIMED_OUT=1; }
|
|
2795
3207
|
trap '_on_sigterm' TERM
|
|
2796
3208
|
# US-LOOP-005: idempotent runs.jsonl writer shared by normal exit, timeout
|
|
@@ -2798,7 +3210,7 @@ trap '_on_sigterm' TERM
|
|
|
2798
3210
|
# multiple callers in the same cycle are safe.
|
|
2799
3211
|
_runs_append() {
|
|
2800
3212
|
local _status="\$1"; local _tcr="\${2:-0}"; local _built="\${3:-[]}"
|
|
2801
|
-
local _runs_dst="
|
|
3213
|
+
local _runs_dst="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/runs.jsonl"
|
|
2802
3214
|
command -v jq >/dev/null 2>&1 || return 0
|
|
2803
3215
|
local _cid="\${CYCLE_ID:-pre-cycle-\$\$}"
|
|
2804
3216
|
local _rid="loop-\${_cid%-*}"
|
|
@@ -2834,12 +3246,21 @@ _inner_cleanup() {
|
|
|
2834
3246
|
for _pid in \$(jobs -p); do kill "\$_pid" 2>/dev/null; done
|
|
2835
3247
|
if [ "\${_CYCLE_TIMED_OUT:-0}" -eq 1 ]; then
|
|
2836
3248
|
_loop_event cycle_end "\${CYCLE_ID:-unknown}" "\${BRANCH:-}" "blocked" 2>/dev/null || true
|
|
3249
|
+
_CYCLE_END_WRITTEN=1
|
|
2837
3250
|
# US-LOOP-005 T9: timeout path must also write runs.jsonl row so dashboard
|
|
2838
3251
|
# has a terminal record (cycle_end alone is insufficient — runs.jsonl is
|
|
2839
3252
|
# the canonical history feed for 'roll loop runs').
|
|
2840
3253
|
_runs_append "failed" 0 "[]" 2>/dev/null || true
|
|
2841
3254
|
_worktree_alert "cycle \${CYCLE_ID:-unknown}: \${LOOP_CYCLE_TIMEOUT_SEC}s timeout — claude/python killed; in-progress story marked Blocked" 2>/dev/null || true
|
|
2842
3255
|
fi
|
|
3256
|
+
# IDEA-028 / FIX-066: catch every other abort (SIGKILL, set -e fire, ALERT
|
|
3257
|
+
# poisoning that bypasses the retry budget, etc.). Without this, cycle_start
|
|
3258
|
+
# is emitted but cycle_end never is, and dashboard renders the cycle as
|
|
3259
|
+
# "still running" until the next successful cycle rolls past it.
|
|
3260
|
+
if [ "\${_CYCLE_END_WRITTEN:-0}" -eq 0 ] && [ -n "\${CYCLE_ID:-}" ]; then
|
|
3261
|
+
_loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "aborted" 2>/dev/null || true
|
|
3262
|
+
_runs_append "aborted" 0 "[]" 2>/dev/null || true
|
|
3263
|
+
fi
|
|
2843
3264
|
rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE"
|
|
2844
3265
|
exit "\$_rc"
|
|
2845
3266
|
}
|
|
@@ -2864,6 +3285,10 @@ _LOOP_MUTE_FILE="\${_SHARED_ROOT}/loop/mute-${slug}"
|
|
|
2864
3285
|
# claude, loop-fmt.py, _loop_event in arbitrary cwd. _project_slug honors this
|
|
2865
3286
|
# env var first, so writes never fragment into tmp-* / cycle-* phantom slugs.
|
|
2866
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}"
|
|
2867
3292
|
|
|
2868
3293
|
# Pre-claude: try to create a per-cycle isolated worktree on origin/main.
|
|
2869
3294
|
# On any failure (no remote, no main, etc.) fall back to running in the
|
|
@@ -2915,6 +3340,11 @@ if _worktree_fetch_origin main \\
|
|
|
2915
3340
|
&& _worktree_create "\$WT" "\$BRANCH" "origin/main"; then
|
|
2916
3341
|
_USE_WORKTREE=1
|
|
2917
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
|
|
2918
3348
|
echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
|
|
2919
3349
|
_loop_event cycle_start "\${CYCLE_ID}" "" "" || true
|
|
2920
3350
|
else
|
|
@@ -2939,11 +3369,25 @@ export LOOP_PROJECT_SLUG="${slug}"
|
|
|
2939
3369
|
export LOOP_CYCLE_ID="\${CYCLE_ID}"
|
|
2940
3370
|
export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
|
|
2941
3371
|
for _attempt in 1 2 3; do
|
|
2942
|
-
# FIX-
|
|
2943
|
-
#
|
|
2944
|
-
#
|
|
2945
|
-
|
|
2946
|
-
|
|
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
|
+
} ) &
|
|
2947
3391
|
_WATCHDOG_PID=\$!
|
|
2948
3392
|
if [ -f "\$FMT" ]; then
|
|
2949
3393
|
( cd "\$WT" && ${claude_cmd} ) | python3 "\$FMT"
|
|
@@ -3023,13 +3467,13 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
|
3023
3467
|
fi
|
|
3024
3468
|
fi
|
|
3025
3469
|
_worktree_cleanup "\$WT" "\$BRANCH"
|
|
3026
|
-
_loop_event cycle_end "\${CYCLE_ID}" "" "done" || true
|
|
3470
|
+
_loop_event cycle_end "\${CYCLE_ID}" "" "done" || true; _CYCLE_END_WRITTEN=1
|
|
3027
3471
|
echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
|
|
3028
3472
|
elif [ "\$_publish_status" -eq 2 ]; then
|
|
3029
3473
|
if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
|
|
3030
3474
|
_worktree_cleanup "\$WT" "\$BRANCH"
|
|
3031
3475
|
# US-LOOP-005 T3: gh unavailable + ff merge_back OK → cycle_end done
|
|
3032
|
-
_loop_event cycle_end "\${CYCLE_ID}" "" "done" || true
|
|
3476
|
+
_loop_event cycle_end "\${CYCLE_ID}" "" "done" || true; _CYCLE_END_WRITTEN=1
|
|
3033
3477
|
echo "[loop] cycle \${CYCLE_ID}: gh unavailable; merged via ff and cleaned up"
|
|
3034
3478
|
else
|
|
3035
3479
|
# FIX-039: gh unavailable + merge_back failed — push orphan branch+tag to origin
|
|
@@ -3040,12 +3484,12 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
|
3040
3484
|
&& git push origin "\$_orphan_tag" 2>/dev/null ); then
|
|
3041
3485
|
_worktree_cleanup "\$WT" "\$BRANCH"
|
|
3042
3486
|
# US-LOOP-005 T4: gh unavailable + orphan push OK → cycle_end orphan
|
|
3043
|
-
_loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true
|
|
3487
|
+
_loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true; _CYCLE_END_WRITTEN=1
|
|
3044
3488
|
_worktree_alert "cycle \${CYCLE_ID}: gh+merge_back failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
|
|
3045
3489
|
echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
|
|
3046
3490
|
else
|
|
3047
3491
|
# US-LOOP-005 T5: gh unavailable + all failed → cycle_end failed
|
|
3048
|
-
_loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true
|
|
3492
|
+
_loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
|
|
3049
3493
|
_worktree_alert "cycle \${CYCLE_ID}: gh+merge_back+push all failed; worktree preserved at \$WT"
|
|
3050
3494
|
echo "[loop] cycle \${CYCLE_ID}: all publish paths failed; worktree preserved at \$WT"
|
|
3051
3495
|
fi
|
|
@@ -3059,12 +3503,12 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
|
3059
3503
|
&& git push origin "\$_orphan_tag" 2>/dev/null ); then
|
|
3060
3504
|
_worktree_cleanup "\$WT" "\$BRANCH"
|
|
3061
3505
|
# US-LOOP-005 T6: PR publish failed + orphan push OK → cycle_end orphan
|
|
3062
|
-
_loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true
|
|
3506
|
+
_loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true; _CYCLE_END_WRITTEN=1
|
|
3063
3507
|
_worktree_alert "cycle \${CYCLE_ID}: PR publish failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
|
|
3064
3508
|
echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
|
|
3065
3509
|
else
|
|
3066
3510
|
# US-LOOP-005 T7: PR publish failed + orphan push failed → cycle_end failed
|
|
3067
|
-
_loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true
|
|
3511
|
+
_loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
|
|
3068
3512
|
_worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
|
|
3069
3513
|
echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
|
|
3070
3514
|
fi
|
|
@@ -3072,7 +3516,7 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
|
3072
3516
|
fi
|
|
3073
3517
|
else
|
|
3074
3518
|
# US-LOOP-005 T8: claude session failed after retry budget → cycle_end failed
|
|
3075
|
-
_loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true
|
|
3519
|
+
_loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
|
|
3076
3520
|
_worktree_alert "cycle \${CYCLE_ID}: claude exited \$_exit; worktree preserved at \$WT (branch \$BRANCH)"
|
|
3077
3521
|
echo "[loop] cycle \${CYCLE_ID}: claude failed (exit \$_exit); worktree preserved at \$WT"
|
|
3078
3522
|
fi
|
|
@@ -3117,13 +3561,13 @@ if [ -z "\$ROLL_LOOP_FORCE" ] && [ -f "\$PAUSE" ]; then exit 0; fi
|
|
|
3117
3561
|
HEARTBEAT_TIMEOUT="\${ROLL_HEARTBEAT_TIMEOUT:-1800}"
|
|
3118
3562
|
# FIX-052: per-project STATE_FILE (was global state.yaml — caused two projects
|
|
3119
3563
|
# to clobber each other's cycle state).
|
|
3120
|
-
STATE_FILE="
|
|
3564
|
+
STATE_FILE="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/state-${slug}.yaml"
|
|
3121
3565
|
if [ -f "\$STATE_FILE" ]; then
|
|
3122
3566
|
_state=\$(grep '^status:' "\$STATE_FILE" | awk '{print \$2}' 2>/dev/null || echo "")
|
|
3123
3567
|
if [ "\$_state" = "running" ]; then
|
|
3124
3568
|
_still_active=false
|
|
3125
3569
|
# FIX-038: heartbeat is primary signal
|
|
3126
|
-
_heartbeat_file="
|
|
3570
|
+
_heartbeat_file="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/.heartbeat-${slug}"
|
|
3127
3571
|
if [ -f "\$_heartbeat_file" ]; then
|
|
3128
3572
|
_hb_ts=\$(cat "\$_heartbeat_file" 2>/dev/null || echo "0")
|
|
3129
3573
|
_now=\$(date -u +%s)
|
|
@@ -4102,50 +4546,10 @@ _loop_heal_dir() {
|
|
|
4102
4546
|
printf '%s\n' "${ROLL_LOOP_DIR:-${HOME}/.shared/roll/loop}/heal"
|
|
4103
4547
|
}
|
|
4104
4548
|
|
|
4105
|
-
#
|
|
4106
|
-
#
|
|
4107
|
-
#
|
|
4108
|
-
#
|
|
4109
|
-
# Exit 0: another heal attempt is allowed (counter incremented). Caller should
|
|
4110
|
-
# invoke roll-fix with the failure summary, then re-run the CI gate.
|
|
4111
|
-
# Exit 1: heal disabled (ROLL_LOOP_NO_HEAL=1) or exhausted (>= ROLL_LOOP_HEAL_MAX).
|
|
4112
|
-
# Caller should write ALERT and stop.
|
|
4113
|
-
_loop_self_heal_ci() {
|
|
4114
|
-
local story_id="$1"
|
|
4115
|
-
[[ -z "$story_id" ]] && return 1
|
|
4116
|
-
[[ "${ROLL_LOOP_NO_HEAL:-0}" == "1" ]] && return 1
|
|
4117
|
-
local max="${ROLL_LOOP_HEAL_MAX:-2}"
|
|
4118
|
-
local state="$_LOOP_STATE"
|
|
4119
|
-
local current=0
|
|
4120
|
-
if [[ -f "$state" ]]; then
|
|
4121
|
-
local raw; raw=$(grep '^heal_count:' "$state" 2>/dev/null | awk '{print $2}')
|
|
4122
|
-
[[ "$raw" =~ ^[0-9]+$ ]] && current="$raw"
|
|
4123
|
-
fi
|
|
4124
|
-
[[ "$current" -ge "$max" ]] && return 1
|
|
4125
|
-
local new=$((current + 1))
|
|
4126
|
-
mkdir -p "$(dirname "$state")"
|
|
4127
|
-
if grep -q '^heal_count:' "$state" 2>/dev/null; then
|
|
4128
|
-
local tmp; tmp=$(mktemp)
|
|
4129
|
-
sed "s/^heal_count:.*/heal_count: ${new}/" "$state" > "$tmp"
|
|
4130
|
-
mv "$tmp" "$state"
|
|
4131
|
-
else
|
|
4132
|
-
echo "heal_count: ${new}" >> "$state"
|
|
4133
|
-
fi
|
|
4134
|
-
return 0
|
|
4135
|
-
}
|
|
4136
|
-
|
|
4137
|
-
# Reset per-story heal counter. Called when CI eventually turns green or by
|
|
4138
|
-
# `roll loop reset`. Idempotent.
|
|
4139
|
-
_loop_clear_heal_state() {
|
|
4140
|
-
local story_id="$1"
|
|
4141
|
-
[[ -z "$story_id" ]] && return 0
|
|
4142
|
-
local state="$_LOOP_STATE"
|
|
4143
|
-
[[ ! -f "$state" ]] && return 0
|
|
4144
|
-
local tmp; tmp=$(mktemp)
|
|
4145
|
-
grep -v '^heal_count:' "$state" > "$tmp"
|
|
4146
|
-
mv "$tmp" "$state"
|
|
4147
|
-
return 0
|
|
4148
|
-
}
|
|
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.
|
|
4149
4553
|
|
|
4150
4554
|
# Verify TCR rhythm after a story completes. Returns 0 if ok, 1 if no TCR commits.
|
|
4151
4555
|
# On failure: reverts story in .roll/backlog.md to 📋 Todo and writes ALERT.
|
|
@@ -4527,6 +4931,50 @@ _loop_pr_inbox() {
|
|
|
4527
4931
|
return 0
|
|
4528
4932
|
}
|
|
4529
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
|
+
|
|
4530
4978
|
# FIX-048: report story IDs already claimed by open loop/* PRs so a new cycle
|
|
4531
4979
|
# can skip them before scanning BACKLOG. Without this gate, a cycle launched
|
|
4532
4980
|
# before the previous cycle's PR merges would re-pick the same Todo story
|
|
@@ -4803,6 +5251,30 @@ _worktree_submodule_init() {
|
|
|
4803
5251
|
( cd "$path" && git submodule update --init --recursive --quiet )
|
|
4804
5252
|
}
|
|
4805
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
|
+
|
|
4806
5278
|
# _worktree_merge_back <branch>
|
|
4807
5279
|
# Caller must be in the main worktree (cwd = main). Steps:
|
|
4808
5280
|
# 1. git pull --ff-only origin main (sync local main with remote)
|
|
@@ -5289,7 +5761,7 @@ cmd_brief() {
|
|
|
5289
5761
|
latest=$(ls "${briefs_dir}"/*.md 2>/dev/null | sort | tail -1 || true)
|
|
5290
5762
|
else
|
|
5291
5763
|
local mod_time now age
|
|
5292
|
-
mod_time=$(
|
|
5764
|
+
mod_time=$(_file_mtime "$latest")
|
|
5293
5765
|
now=$(date +%s); age=$(( now - mod_time ))
|
|
5294
5766
|
if (( age > 86400 )); then
|
|
5295
5767
|
info "Brief is $(( age / 3600 ))h old — regenerating... 简报已 $(( age / 3600 )) 小时未更新,重新生成..."
|
|
@@ -5311,34 +5783,11 @@ cmd_brief() {
|
|
|
5311
5783
|
cat "$latest"
|
|
5312
5784
|
}
|
|
5313
5785
|
|
|
5314
|
-
_promote_unreleased
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
sed -i.bak "s/^## Unreleased$/## v${version}/" "$changelog" && rm "${changelog}.bak"
|
|
5320
|
-
}
|
|
5321
|
-
|
|
5322
|
-
_ensure_unreleased() {
|
|
5323
|
-
local changelog="$1"
|
|
5324
|
-
[[ -f "$changelog" ]] || return 0
|
|
5325
|
-
grep -q "^## Unreleased$" "$changelog" && return 0
|
|
5326
|
-
python3 - "$changelog" <<'PYEOF'
|
|
5327
|
-
import sys, pathlib
|
|
5328
|
-
p = pathlib.Path(sys.argv[1])
|
|
5329
|
-
lines = p.read_text().splitlines(keepends=True)
|
|
5330
|
-
out = []
|
|
5331
|
-
inserted = False
|
|
5332
|
-
for line in lines:
|
|
5333
|
-
out.append(line)
|
|
5334
|
-
if not inserted and line.rstrip() == "# Changelog":
|
|
5335
|
-
out.append("\n## Unreleased\n")
|
|
5336
|
-
inserted = True
|
|
5337
|
-
if not inserted:
|
|
5338
|
-
out.insert(0, "## Unreleased\n\n")
|
|
5339
|
-
p.write_text("".join(out))
|
|
5340
|
-
PYEOF
|
|
5341
|
-
}
|
|
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.
|
|
5342
5791
|
|
|
5343
5792
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
5344
5793
|
# BACKLOG — show pending tasks / manage status
|
|
@@ -5479,6 +5928,73 @@ cmd_ci() {
|
|
|
5479
5928
|
echo "$runs" | jq -r '.[] | "\(.name): \(.status)/\(.conclusion)"'
|
|
5480
5929
|
}
|
|
5481
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
|
+
|
|
5482
5998
|
cmd_backlog() {
|
|
5483
5999
|
local backlog=".roll/backlog.md"
|
|
5484
6000
|
if [[ ! -f "$backlog" ]]; then
|
|
@@ -5490,6 +6006,10 @@ cmd_backlog() {
|
|
|
5490
6006
|
|
|
5491
6007
|
# ── Status management subcommands ─────────────────────────────────────────
|
|
5492
6008
|
case "$subcmd" in
|
|
6009
|
+
lint)
|
|
6010
|
+
_backlog_lint "$backlog"
|
|
6011
|
+
return
|
|
6012
|
+
;;
|
|
5493
6013
|
block|defer|unblock|promote)
|
|
5494
6014
|
local pattern="${2:-}"
|
|
5495
6015
|
local reason="${3:-}"
|
|
@@ -5666,7 +6186,7 @@ _dash_last_dream_hours() {
|
|
|
5666
6186
|
local dream_log="${HOME}/.shared/roll/dream/log.md"
|
|
5667
6187
|
[[ -f "$dream_log" ]] || return 0
|
|
5668
6188
|
local mod_time now
|
|
5669
|
-
mod_time=$(
|
|
6189
|
+
mod_time=$(_file_mtime "$dream_log")
|
|
5670
6190
|
now=$(date +%s)
|
|
5671
6191
|
echo $(( (now - mod_time) / 3600 ))
|
|
5672
6192
|
}
|
|
@@ -5686,7 +6206,7 @@ _dash_last_peer() {
|
|
|
5686
6206
|
local result
|
|
5687
6207
|
result=$(grep -oE '(AGREE|REFINE|OBJECT|ESCALATE)' "$latest" 2>/dev/null | tail -1 || true)
|
|
5688
6208
|
local mod_time now days
|
|
5689
|
-
mod_time=$(
|
|
6209
|
+
mod_time=$(_file_mtime "$latest")
|
|
5690
6210
|
now=$(date +%s)
|
|
5691
6211
|
days=$(( (now - mod_time) / 86400 ))
|
|
5692
6212
|
printf '%s|%s' "${result:-—}" "${days}"
|
|
@@ -5979,7 +6499,7 @@ _legacy_home() {
|
|
|
5979
6499
|
local latest_brief; latest_brief=$(ls .roll/briefs/*.md 2>/dev/null | sort | tail -1 || true)
|
|
5980
6500
|
if [[ -n "$latest_brief" ]]; then
|
|
5981
6501
|
local mod_time now age summary
|
|
5982
|
-
mod_time=$(
|
|
6502
|
+
mod_time=$(_file_mtime "$latest_brief")
|
|
5983
6503
|
now=$(date +%s); age=$(( (now - mod_time) / 3600 ))
|
|
5984
6504
|
summary=$(_dash_brief_summary "$latest_brief")
|
|
5985
6505
|
printf " Brief ${CYAN}%sh${NC} ago — %s\n" "$age" "${summary:-—}"
|
|
@@ -6016,7 +6536,8 @@ _legacy_help() {
|
|
|
6016
6536
|
echo "Commands:"
|
|
6017
6537
|
echo " setup [-f] [Machine] First-time install or re-sync 首次安装或重新同步"
|
|
6018
6538
|
echo " update [Upgrade] npm install latest + re-sync 一键升级到最新版"
|
|
6019
|
-
echo " init [Project] Create AGENTS.md + .roll/backlog.md +
|
|
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 痕迹"
|
|
6020
6541
|
echo " status [Diagnostic] Show current state 显示当前状态"
|
|
6021
6542
|
echo " peer [Peer Review] Cross-agent negotiation 跨 Agent 协商对审"
|
|
6022
6543
|
echo " loop <on|off|now|status|monitor|resume|reset> [Autonomous] Manage scheduled BACKLOG executor 管理自主执行循环"
|
|
@@ -6025,6 +6546,7 @@ _legacy_help() {
|
|
|
6025
6546
|
echo " backlog block <pat> [reason] Mark matching items as 🔒 Blocked 标记为已阻塞"
|
|
6026
6547
|
echo " backlog defer <pat> [reason] Mark matching items as ⏸ Deferred 标记为已推迟"
|
|
6027
6548
|
echo " backlog unblock <pat> Restore matching items to 📋 Todo 恢复为待处理"
|
|
6549
|
+
echo " backlog lint Check descriptions for path/function/filename violations 检查描述合规"
|
|
6028
6550
|
echo " agent [use <name>|list] [Config] Per-project agent selection 切换项目 agent"
|
|
6029
6551
|
echo " ci [--wait] [CI] Show or wait for current commit's CI status 查看/等待 CI 状态"
|
|
6030
6552
|
echo " review-pr <number> [PR Review] AI-powered code review for a PR AI 代码评审"
|
|
@@ -6075,6 +6597,7 @@ _check_structure() {
|
|
|
6075
6597
|
case "$cmd" in
|
|
6076
6598
|
setup|update|migrate|doctor|version|--version|-v|help|--help|-h|"") return 0 ;;
|
|
6077
6599
|
init) return 0 ;; # cmd_init handles its own structure logic
|
|
6600
|
+
offboard) return 0 ;; # cmd_offboard does its own changeset check
|
|
6078
6601
|
esac
|
|
6079
6602
|
|
|
6080
6603
|
# Determine project root: git root if available, else pwd
|
|
@@ -6126,6 +6649,7 @@ main() {
|
|
|
6126
6649
|
setup) cmd_setup "$@" ;;
|
|
6127
6650
|
update) cmd_update "$@" ;;
|
|
6128
6651
|
init) cmd_init "$@" ;;
|
|
6652
|
+
offboard) cmd_offboard "$@" ;;
|
|
6129
6653
|
migrate) cmd_migrate "$@" ;;
|
|
6130
6654
|
status) cmd_status "$@" ;;
|
|
6131
6655
|
peer) cmd_peer "$@" ;;
|