@seanyao/roll 2026.519.3 → 2026.520.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.3"
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
@@ -2388,10 +2716,25 @@ cmd_agent() {
2388
2716
  local name="${1:-}"
2389
2717
  [[ -z "$name" ]] && { err "Usage: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"; exit 1; }
2390
2718
  command -v "$name" &>/dev/null || warn "${name} not found in PATH — setting anyway 未找到,仍写入配置"
2391
- if [[ -f ".roll.yaml" ]] && grep -q "^agent:" .roll.yaml; then
2392
- 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"
2393
2726
  else
2394
- 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
2395
2738
  fi
2396
2739
  ok "Agent set to ${name} for this project 当前项目 agent 已设为 ${name}"
2397
2740
  local project_path; project_path=$(pwd -P)
@@ -2416,7 +2759,13 @@ cmd_agent() {
2416
2759
  ;;
2417
2760
  "")
2418
2761
  local agent; agent=$(_project_agent)
2419
- local src="global"; [[ -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
2420
2769
  echo -e "\n Agent ${CYAN}${agent}${NC} (${src})\n"
2421
2770
  echo " roll agent use <name> — switch agent for this project"
2422
2771
  echo " roll agent list — show installed agents"; echo ""
@@ -2656,20 +3005,16 @@ _loop_event() {
2656
3005
  # stdout: tab-separated for tmux display
2657
3006
  printf '%s\t%s\t%s\t%s\t%s\n' "$ts" "$stage" "$label" "$detail" "$outcome"
2658
3007
 
2659
- # JSON line appended to NDJSON file; serialized with flock (Linux) or
2660
- # 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.
2661
3015
  json=$(printf '{"ts":"%s","stage":"%s","label":"%s","detail":"%s","outcome":"%s"}\n' \
2662
3016
  "$ts" "$stage" "$label" "$detail" "$outcome")
2663
- if command -v flock >/dev/null 2>&1; then
2664
- (
2665
- flock -x 9
2666
- printf '%s\n' "$json" >> "$evfile"
2667
- ) 9>>"${evfile}.lock"
2668
- elif command -v lockf >/dev/null 2>&1; then
2669
- lockf -s "${evfile}.lock" sh -c "printf '%s\n' $(printf '%q' "$json") >> $(printf '%q' "$evfile")"
2670
- else
2671
- printf '%s\n' "$json" >> "$evfile"
2672
- fi
3017
+ printf '%s\n' "$json" >> "$evfile"
2673
3018
 
2674
3019
  # File rotation: if >10MB, rotate keeping last 5
2675
3020
  _loop_event_rotate "$evfile"
@@ -2940,6 +3285,10 @@ _LOOP_MUTE_FILE="\${_SHARED_ROOT}/loop/mute-${slug}"
2940
3285
  # claude, loop-fmt.py, _loop_event in arbitrary cwd. _project_slug honors this
2941
3286
  # env var first, so writes never fragment into tmp-* / cycle-* phantom slugs.
2942
3287
  export ROLL_MAIN_SLUG="${slug}"
3288
+ # FIX-070: helpers that need to update the main repo's backlog (e.g. when a
3289
+ # worktree cycle marks a story 🔨 In Progress) read ROLL_MAIN_PROJECT to
3290
+ # locate it — the cycle's own cwd is the worktree, not main.
3291
+ export ROLL_MAIN_PROJECT="${project_path}"
2943
3292
 
2944
3293
  # Pre-claude: try to create a per-cycle isolated worktree on origin/main.
2945
3294
  # On any failure (no remote, no main, etc.) fall back to running in the
@@ -2991,6 +3340,11 @@ if _worktree_fetch_origin main \\
2991
3340
  && _worktree_create "\$WT" "\$BRANCH" "origin/main"; then
2992
3341
  _USE_WORKTREE=1
2993
3342
  _worktree_submodule_init "\$WT" 2>/dev/null || true
3343
+ # FIX-069: copy .roll/ meta (backlog, skills, conventions) into the
3344
+ # worktree as a read-only reference. Without this the cycle no-ops
3345
+ # because .roll/ is gitignored and the clean clone has no backlog
3346
+ # for Claude to read or skill entry points to dispatch to.
3347
+ _worktree_sync_meta "\$WT" 2>/dev/null || true
2994
3348
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
2995
3349
  _loop_event cycle_start "\${CYCLE_ID}" "" "" || true
2996
3350
  else
@@ -3015,11 +3369,25 @@ export LOOP_PROJECT_SLUG="${slug}"
3015
3369
  export LOOP_CYCLE_ID="\${CYCLE_ID}"
3016
3370
  export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
3017
3371
  for _attempt in 1 2 3; do
3018
- # FIX-057: watchdog fires SIGTERM at the inner script (and its direct
3019
- # children) when the cycle exceeds LOOP_CYCLE_TIMEOUT_SEC. Signal the inner
3020
- # script first so _on_sigterm sets _CYCLE_TIMED_OUT before pkill takes out
3021
- # the watchdog subshell itself (pkill -P \$\$ matches this subshell too).
3022
- ( 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
+ } ) &
3023
3391
  _WATCHDOG_PID=\$!
3024
3392
  if [ -f "\$FMT" ]; then
3025
3393
  ( cd "\$WT" && ${claude_cmd} ) | python3 "\$FMT"
@@ -4178,50 +4546,10 @@ _loop_heal_dir() {
4178
4546
  printf '%s\n' "${ROLL_LOOP_DIR:-${HOME}/.shared/roll/loop}/heal"
4179
4547
  }
4180
4548
 
4181
- # Bounded CI self-heal gate. Called by the loop SKILL when the
4182
- # post-build CI check goes red. Counter is per-story, persisted under
4183
- # $ROLL_LOOP_DIR/heal/<story-id>.count, so retries survive cycle boundaries.
4184
- #
4185
- # Exit 0: another heal attempt is allowed (counter incremented). Caller should
4186
- # invoke roll-fix with the failure summary, then re-run the CI gate.
4187
- # Exit 1: heal disabled (ROLL_LOOP_NO_HEAL=1) or exhausted (>= ROLL_LOOP_HEAL_MAX).
4188
- # Caller should write ALERT and stop.
4189
- _loop_self_heal_ci() {
4190
- local story_id="$1"
4191
- [[ -z "$story_id" ]] && return 1
4192
- [[ "${ROLL_LOOP_NO_HEAL:-0}" == "1" ]] && return 1
4193
- local max="${ROLL_LOOP_HEAL_MAX:-2}"
4194
- local state="$_LOOP_STATE"
4195
- local current=0
4196
- if [[ -f "$state" ]]; then
4197
- local raw; raw=$(grep '^heal_count:' "$state" 2>/dev/null | awk '{print $2}')
4198
- [[ "$raw" =~ ^[0-9]+$ ]] && current="$raw"
4199
- fi
4200
- [[ "$current" -ge "$max" ]] && return 1
4201
- local new=$((current + 1))
4202
- mkdir -p "$(dirname "$state")"
4203
- if grep -q '^heal_count:' "$state" 2>/dev/null; then
4204
- local tmp; tmp=$(mktemp)
4205
- sed "s/^heal_count:.*/heal_count: ${new}/" "$state" > "$tmp"
4206
- mv "$tmp" "$state"
4207
- else
4208
- echo "heal_count: ${new}" >> "$state"
4209
- fi
4210
- return 0
4211
- }
4212
-
4213
- # Reset per-story heal counter. Called when CI eventually turns green or by
4214
- # `roll loop reset`. Idempotent.
4215
- _loop_clear_heal_state() {
4216
- local story_id="$1"
4217
- [[ -z "$story_id" ]] && return 0
4218
- local state="$_LOOP_STATE"
4219
- [[ ! -f "$state" ]] && return 0
4220
- local tmp; tmp=$(mktemp)
4221
- grep -v '^heal_count:' "$state" > "$tmp"
4222
- mv "$tmp" "$state"
4223
- return 0
4224
- }
4549
+ # REFACTOR-030: removed `_loop_self_heal_ci` and `_loop_clear_heal_state`.
4550
+ # REFACTOR-023 merged the CI self-heal counter into the main state.yaml flow,
4551
+ # but the two helpers themselves were left behind as dead code. Their job
4552
+ # now lives in the state.yaml read/write paths called from the loop runner.
4225
4553
 
4226
4554
  # Verify TCR rhythm after a story completes. Returns 0 if ok, 1 if no TCR commits.
4227
4555
  # On failure: reverts story in .roll/backlog.md to 📋 Todo and writes ALERT.
@@ -4603,6 +4931,50 @@ _loop_pr_inbox() {
4603
4931
  return 0
4604
4932
  }
4605
4933
 
4934
+ # FIX-070: flip a story row in the main repo's .roll/backlog.md between
4935
+ # 📋 Todo and 🔨 In Progress. The cycle worktree is gitignored at .roll/,
4936
+ # so editing the worktree copy + committing leaves no trace in git — and
4937
+ # main's backlog (which roll-brief reads) stays stale. These helpers write
4938
+ # directly to ${ROLL_MAIN_PROJECT}/.roll/backlog.md instead.
4939
+ #
4940
+ # _loop_mark_in_progress <story-id> [backlog-path]
4941
+ # Replace "📋 Todo" with "🔨 In Progress" on the row containing <story-id>.
4942
+ # No-op when backlog or row is missing (idempotent retries don't error).
4943
+ _loop_mark_in_progress() {
4944
+ local story_id="$1"
4945
+ local backlog="${2:-${ROLL_MAIN_PROJECT:-$PWD}/.roll/backlog.md}"
4946
+ [ -n "$story_id" ] || return 1
4947
+ [ -f "$backlog" ] || return 0
4948
+ local tmp; tmp=$(mktemp "${backlog}.XXXXXX") || return 1
4949
+ awk -v sid="$story_id" '
4950
+ {
4951
+ if (index($0, sid) > 0 && index($0, "📋 Todo") > 0) {
4952
+ sub(/📋 Todo/, "🔨 In Progress")
4953
+ }
4954
+ print
4955
+ }
4956
+ ' "$backlog" > "$tmp" && mv "$tmp" "$backlog"
4957
+ }
4958
+
4959
+ # _loop_mark_todo <story-id> [backlog-path]
4960
+ # Revert a row from "🔨 In Progress" back to "📋 Todo". Called when a
4961
+ # cycle's executor fails so the next cycle can pick the story up again.
4962
+ _loop_mark_todo() {
4963
+ local story_id="$1"
4964
+ local backlog="${2:-${ROLL_MAIN_PROJECT:-$PWD}/.roll/backlog.md}"
4965
+ [ -n "$story_id" ] || return 1
4966
+ [ -f "$backlog" ] || return 0
4967
+ local tmp; tmp=$(mktemp "${backlog}.XXXXXX") || return 1
4968
+ awk -v sid="$story_id" '
4969
+ {
4970
+ if (index($0, sid) > 0 && index($0, "🔨 In Progress") > 0) {
4971
+ sub(/🔨 In Progress/, "📋 Todo")
4972
+ }
4973
+ print
4974
+ }
4975
+ ' "$backlog" > "$tmp" && mv "$tmp" "$backlog"
4976
+ }
4977
+
4606
4978
  # FIX-048: report story IDs already claimed by open loop/* PRs so a new cycle
4607
4979
  # can skip them before scanning BACKLOG. Without this gate, a cycle launched
4608
4980
  # before the previous cycle's PR merges would re-pick the same Todo story
@@ -4879,6 +5251,30 @@ _worktree_submodule_init() {
4879
5251
  ( cd "$path" && git submodule update --init --recursive --quiet )
4880
5252
  }
4881
5253
 
5254
+ # _worktree_sync_meta <path>
5255
+ # FIX-069: Copy main repo's .roll/ meta (backlog, skills, conventions,
5256
+ # features, decisions) into the cycle worktree as a read-only reference.
5257
+ # Without this, the loop runs in a clean git clone with no .roll/ (it's
5258
+ # gitignored), so Claude finds no backlog and no skill entry points —
5259
+ # the whole cycle no-ops.
5260
+ #
5261
+ # Excludes runtime state listed in .roll/.gitignore plus loop event/run
5262
+ # logs, so the worktree never inherits main's live cycle state.
5263
+ # Single-shot: never written back; the worktree copy is thrown away with
5264
+ # the worktree itself.
5265
+ _worktree_sync_meta() {
5266
+ local path="$1"
5267
+ [ -d ".roll" ] || return 0
5268
+ rsync -a \
5269
+ --exclude='state/' \
5270
+ --exclude='scratch/' \
5271
+ --exclude='*.lock' \
5272
+ --exclude='last-test-pass' \
5273
+ --exclude='events.ndjson*' \
5274
+ --exclude='runs.jsonl*' \
5275
+ .roll/ "$path/.roll/" 2>/dev/null || true
5276
+ }
5277
+
4882
5278
  # _worktree_merge_back <branch>
4883
5279
  # Caller must be in the main worktree (cwd = main). Steps:
4884
5280
  # 1. git pull --ff-only origin main (sync local main with remote)
@@ -5387,34 +5783,11 @@ cmd_brief() {
5387
5783
  cat "$latest"
5388
5784
  }
5389
5785
 
5390
- _promote_unreleased() {
5391
- local version="$1"
5392
- local changelog="$2"
5393
- [[ -f "$changelog" ]] || return 0
5394
- grep -q "^## Unreleased" "$changelog" || return 0
5395
- sed -i.bak "s/^## Unreleased$/## v${version}/" "$changelog" && rm "${changelog}.bak"
5396
- }
5397
-
5398
- _ensure_unreleased() {
5399
- local changelog="$1"
5400
- [[ -f "$changelog" ]] || return 0
5401
- grep -q "^## Unreleased$" "$changelog" && return 0
5402
- python3 - "$changelog" <<'PYEOF'
5403
- import sys, pathlib
5404
- p = pathlib.Path(sys.argv[1])
5405
- lines = p.read_text().splitlines(keepends=True)
5406
- out = []
5407
- inserted = False
5408
- for line in lines:
5409
- out.append(line)
5410
- if not inserted and line.rstrip() == "# Changelog":
5411
- out.append("\n## Unreleased\n")
5412
- inserted = True
5413
- if not inserted:
5414
- out.insert(0, "## Unreleased\n\n")
5415
- p.write_text("".join(out))
5416
- PYEOF
5417
- }
5786
+ # REFACTOR-030: removed `_promote_unreleased` and `_ensure_unreleased`.
5787
+ # REFACTOR-021 collapsed the changelog double-pipeline so the release script
5788
+ # generates the version header directly from BACKLOG, leaving these two
5789
+ # helpers orphaned. Their behaviour is now part of the changelog renderer
5790
+ # called from scripts/release.sh.
5418
5791
 
5419
5792
  # ═══════════════════════════════════════════════════════════════════════════════
5420
5793
  # BACKLOG — show pending tasks / manage status
@@ -5555,6 +5928,73 @@ cmd_ci() {
5555
5928
  echo "$runs" | jq -r '.[] | "\(.name): \(.status)/\(.conclusion)"'
5556
5929
  }
5557
5930
 
5931
+ # REFACTOR-041: backlog description linter. The global convention bans file
5932
+ # paths, function names, filenames, and "architecture jargon" in description
5933
+ # columns — see conventions/global/AGENTS.md §4. This helper scans each row's
5934
+ # description column for those patterns and prints any findings. Phase 1 is
5935
+ # warn-only (always exit 0) so a noisy ramp-up doesn't block work; Phase 2
5936
+ # will switch to hard-fail. Output format mirrors a linter ("file:line:
5937
+ # message") so editors can navigate from it.
5938
+ _backlog_lint() {
5939
+ local backlog="${1:-.roll/backlog.md}"
5940
+ [ -f "$backlog" ] || { err "backlog not found: $backlog"; return 1; }
5941
+
5942
+ local violations=0
5943
+ local lineno=0
5944
+ while IFS= read -r line; do
5945
+ lineno=$((lineno+1))
5946
+ # Only data rows (start with "|"), skip header/separator/non-table
5947
+ case "$line" in
5948
+ \|*) ;;
5949
+ *) continue ;;
5950
+ esac
5951
+ case "$line" in
5952
+ *Story*Description*Status*|*'---'*) continue ;;
5953
+ esac
5954
+ local desc
5955
+ desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//')
5956
+ [ -n "$desc" ] || continue
5957
+ # Strip the leading `[US-XXX](path)` link / bare `US-XXX` id — those are
5958
+ # structural, not description prose.
5959
+ local body
5960
+ body=$(echo "$desc" \
5961
+ | sed -E 's|^\[[A-Z]+-[0-9]+\]\([^)]*\)[[:space:]]*||' \
5962
+ | sed -E 's|^[A-Z]+-[0-9]+[[:space:]]*||')
5963
+ local issues=""
5964
+ # Filenames: bare `something.ext` for common code/config extensions
5965
+ if echo "$body" | grep -qE '\b[A-Za-z_][A-Za-z0-9_.-]*\.(sh|bash|yaml|yml|json|js|ts|tsx|py|rb|go|rs|c|cpp|h)\b'; then
5966
+ issues="${issues:+${issues}, }filename"
5967
+ fi
5968
+ # Paths: directory/anything pattern not preceded by `(` (links already
5969
+ # stripped above). Hyphens / dots / underscores allowed in path segments.
5970
+ if echo "$body" | grep -qE '[A-Za-z_][A-Za-z0-9_.-]*/[A-Za-z0-9_./-]+'; then
5971
+ issues="${issues:+${issues}, }path"
5972
+ fi
5973
+ # Function names: underscore-prefixed identifier or trailing parens
5974
+ if echo "$body" | grep -qE '\b_[a-zA-Z][a-zA-Z0-9_]+\b|\b[A-Za-z_][A-Za-z0-9_]+\(\)'; then
5975
+ issues="${issues:+${issues}, }function"
5976
+ fi
5977
+ if [ -n "$issues" ]; then
5978
+ violations=$((violations+1))
5979
+ # Extract the story id from column 2 so reports name the offending row.
5980
+ local sid; sid=$(echo "$line" | awk -F'|' '{print $2}' \
5981
+ | sed -E 's/^[[:space:]]*\[?([A-Z]+-[0-9]+).*/\1/' \
5982
+ | tr -d '[:space:]')
5983
+ printf '%s:%d: %s — %s\n %s\n' "$backlog" "$lineno" "$sid" "$issues" "$desc"
5984
+ fi
5985
+ done < "$backlog"
5986
+
5987
+ echo ""
5988
+ if [ "$violations" -gt 0 ]; then
5989
+ echo " ${violations} violation(s) — see conventions/global/AGENTS.md §4"
5990
+ echo " ${violations} 条违规 — Phase 1: warn-only, not blocking"
5991
+ else
5992
+ echo " No violations 无违规"
5993
+ fi
5994
+ # Phase 1: warn-only. Exit 0 regardless.
5995
+ return 0
5996
+ }
5997
+
5558
5998
  cmd_backlog() {
5559
5999
  local backlog=".roll/backlog.md"
5560
6000
  if [[ ! -f "$backlog" ]]; then
@@ -5566,6 +6006,10 @@ cmd_backlog() {
5566
6006
 
5567
6007
  # ── Status management subcommands ─────────────────────────────────────────
5568
6008
  case "$subcmd" in
6009
+ lint)
6010
+ _backlog_lint "$backlog"
6011
+ return
6012
+ ;;
5569
6013
  block|defer|unblock|promote)
5570
6014
  local pattern="${2:-}"
5571
6015
  local reason="${3:-}"
@@ -6093,6 +6537,7 @@ _legacy_help() {
6093
6537
  echo " setup [-f] [Machine] First-time install or re-sync 首次安装或重新同步"
6094
6538
  echo " update [Upgrade] npm install latest + re-sync 一键升级到最新版"
6095
6539
  echo " init [Project] Create AGENTS.md + .roll/backlog.md + .roll/features/ 初始化项目工作流文件"
6540
+ echo " offboard [--confirm] [Project] Reverse a previous \`roll init --apply\` (dry-run by default) 卸载本项目的 Roll 痕迹"
6096
6541
  echo " status [Diagnostic] Show current state 显示当前状态"
6097
6542
  echo " peer [Peer Review] Cross-agent negotiation 跨 Agent 协商对审"
6098
6543
  echo " loop <on|off|now|status|monitor|resume|reset> [Autonomous] Manage scheduled BACKLOG executor 管理自主执行循环"
@@ -6101,6 +6546,7 @@ _legacy_help() {
6101
6546
  echo " backlog block <pat> [reason] Mark matching items as 🔒 Blocked 标记为已阻塞"
6102
6547
  echo " backlog defer <pat> [reason] Mark matching items as ⏸ Deferred 标记为已推迟"
6103
6548
  echo " backlog unblock <pat> Restore matching items to 📋 Todo 恢复为待处理"
6549
+ echo " backlog lint Check descriptions for path/function/filename violations 检查描述合规"
6104
6550
  echo " agent [use <name>|list] [Config] Per-project agent selection 切换项目 agent"
6105
6551
  echo " ci [--wait] [CI] Show or wait for current commit's CI status 查看/等待 CI 状态"
6106
6552
  echo " review-pr <number> [PR Review] AI-powered code review for a PR AI 代码评审"
@@ -6151,6 +6597,7 @@ _check_structure() {
6151
6597
  case "$cmd" in
6152
6598
  setup|update|migrate|doctor|version|--version|-v|help|--help|-h|"") return 0 ;;
6153
6599
  init) return 0 ;; # cmd_init handles its own structure logic
6600
+ offboard) return 0 ;; # cmd_offboard does its own changeset check
6154
6601
  esac
6155
6602
 
6156
6603
  # Determine project root: git root if available, else pwd
@@ -6202,6 +6649,7 @@ main() {
6202
6649
  setup) cmd_setup "$@" ;;
6203
6650
  update) cmd_update "$@" ;;
6204
6651
  init) cmd_init "$@" ;;
6652
+ offboard) cmd_offboard "$@" ;;
6205
6653
  migrate) cmd_migrate "$@" ;;
6206
6654
  status) cmd_status "$@" ;;
6207
6655
  peer) cmd_peer "$@" ;;