@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/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.519.2"
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. Definition (per design doc §5.1): no AGENTS.md AND any of
990
- # src/ app/ lib/ pkg/ cmd/ contains ≥10 non-empty files.
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
- if [[ -f ".roll.yaml" ]] && grep -q "^agent:" .roll.yaml 2>/dev/null; then
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
- output=$("${_AGENT_ARGV[@]}" 2>/dev/null)
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
- if [[ -f ".roll.yaml" ]] && grep -q "^agent:" .roll.yaml; then
2386
- local tmp; tmp=$(mktemp) && sed "s/^agent:.*/agent: ${name}/" .roll.yaml > "$tmp" && mv "$tmp" .roll.yaml
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}" >> .roll.yaml
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"; [[ -f ".roll.yaml" ]] && grep -q "^agent:" .roll.yaml 2>/dev/null && src="project (.roll.yaml)"
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
- _LOOP_RUNS="${HOME}/.shared/roll/loop/runs.jsonl"
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; serialized with flock (Linux) or
2598
- # lockf (macOS/BSD) fall back to unguarded append when neither is available.
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
- if command -v flock >/dev/null 2>&1; then
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="${HOME}/.shared/roll/loop/.heartbeat-${slug}"
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="${HOME}/.shared/roll/loop/runs.jsonl"
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-057: watchdog fires SIGTERM at the inner script (and its direct
2943
- # children) when the cycle exceeds LOOP_CYCLE_TIMEOUT_SEC. Signal the inner
2944
- # script first so _on_sigterm sets _CYCLE_TIMED_OUT before pkill takes out
2945
- # the watchdog subshell itself (pkill -P \$\$ matches this subshell too).
2946
- ( sleep "\$LOOP_CYCLE_TIMEOUT_SEC" && { kill -TERM \$\$ 2>/dev/null; pkill -TERM -P \$\$ 2>/dev/null; } ) &
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="${HOME}/.shared/roll/loop/state-${slug}.yaml"
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="${HOME}/.shared/roll/loop/.heartbeat-${slug}"
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
- # Bounded CI self-heal gate. Called by the loop SKILL when the
4106
- # post-build CI check goes red. Counter is per-story, persisted under
4107
- # $ROLL_LOOP_DIR/heal/<story-id>.count, so retries survive cycle boundaries.
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=$(stat -c %Y "$latest" 2>/dev/null || stat -f %m "$latest" 2>/dev/null || echo 0)
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
- local version="$1"
5316
- local changelog="$2"
5317
- [[ -f "$changelog" ]] || return 0
5318
- grep -q "^## Unreleased" "$changelog" || return 0
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=$(stat -c %Y "$dream_log" 2>/dev/null || stat -f %m "$dream_log" 2>/dev/null || echo 0)
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=$(stat -c %Y "$latest" 2>/dev/null || stat -f %m "$latest" 2>/dev/null || echo 0)
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=$(stat -c %Y "$latest_brief" 2>/dev/null || stat -f %m "$latest_brief" 2>/dev/null || echo 0)
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 + docs/ 初始化项目工作流文件"
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 "$@" ;;