@seanyao/roll 2026.518.4 → 2026.519.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.518.4"
7
+ VERSION="2026.519.2"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -913,15 +913,39 @@ _merge_claude_to_project() {
913
913
  # ═══════════════════════════════════════════════════════════════════════════════
914
914
  # COMMAND: init
915
915
  # Initialize or re-merge a project. Always operates on the current directory.
916
- # Fresh project: creates AGENTS.md + BACKLOG.md + docs/features/
916
+ # Fresh project: creates AGENTS.md + .roll/backlog.md + .roll/features/
917
917
  # Existing AGENTS.md: re-merges global conventions (section-level, non-destructive)
918
918
  # ═══════════════════════════════════════════════════════════════════════════════
919
919
  cmd_init() {
920
+ # US-VIEW-008: parse --demo before any side effects so the v2 renderer can
921
+ # run standalone (no templates, no project mutation).
922
+ local demo=false
923
+ local args=()
924
+ while [[ $# -gt 0 ]]; do
925
+ case "$1" in
926
+ --demo) demo=true; shift ;;
927
+ *) args+=("$1"); shift ;;
928
+ esac
929
+ done
930
+ set -- "${args[@]:-}"
931
+
932
+ if [[ "${ROLL_UI:-v2}" == "v2" ]] || [[ "$demo" == "true" ]]; then
933
+ python3 "${ROLL_PKG_DIR}/lib/roll-init.py" --demo
934
+ if [[ "$demo" == "true" ]]; then return; fi
935
+ fi
936
+
920
937
  if [[ ! -d "$ROLL_TEMPLATES" ]]; then
921
938
  err "No templates found. Run 'roll setup' first. 未找到模板,请先运行 'roll setup'。"
922
939
  exit 1
923
940
  fi
924
941
 
942
+ # US-ONBOARD-009: --apply consumes onboard-plan.yaml produced by $roll-onboard
943
+ if [[ "${1:-}" == "--apply" ]]; then
944
+ shift
945
+ _init_apply "$@"
946
+ return $?
947
+ fi
948
+
925
949
  local project_dir
926
950
  project_dir="$(pwd)"
927
951
  local has_agents=false
@@ -930,13 +954,20 @@ cmd_init() {
930
954
  if [[ -f "$project_dir/AGENTS.md" ]]; then
931
955
  has_agents=true
932
956
  info "Re-merging global conventions... 正在重新合并全局约定..."
957
+ else
958
+ # US-ONBOARD-006: legacy project detection — guide user through $roll-onboard
959
+ # instead of blindly scaffolding files into an existing codebase.
960
+ if _init_is_legacy_project "$project_dir"; then
961
+ _init_legacy_onboard_guide "$project_dir"
962
+ return 0
963
+ fi
933
964
  fi
934
965
 
935
966
  _merge_global_to_project "$project_dir"
936
967
  _merge_claude_to_project "$project_dir"
937
- _write_backlog "$project_dir/BACKLOG.md"
938
- _ensure_features_dir "$project_dir/docs/features"
939
- _write_features_md "$project_dir/docs/features.md"
968
+ _write_backlog "$project_dir/.roll/backlog.md"
969
+ _ensure_features_dir "$project_dir/.roll/features"
970
+ _write_features_md "$project_dir/.roll/features.md"
940
971
  print_merge_summary
941
972
 
942
973
  echo ""
@@ -953,6 +984,402 @@ cmd_init() {
953
984
  fi
954
985
  }
955
986
 
987
+ # US-ONBOARD-006: Legacy detection
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.
991
+ _init_is_legacy_project() {
992
+ local project_dir="$1"
993
+ local dir count
994
+ for dir in src app lib pkg cmd; do
995
+ if [[ -d "$project_dir/$dir" ]]; then
996
+ count=$(find "$project_dir/$dir" -type f -not -empty 2>/dev/null | wc -l | tr -d ' ')
997
+ if [[ "$count" -ge 10 ]]; then
998
+ return 0
999
+ fi
1000
+ fi
1001
+ done
1002
+ return 1
1003
+ }
1004
+
1005
+ # US-ONBOARD-006: Agent discovery + token consumption notice + onboard guidance.
1006
+ # Reuses _for_each_ai_tool to enumerate installed AI agents. Tells the user
1007
+ # which agent to open, why their tokens will be used, and what to do next.
1008
+ _init_legacy_onboard_guide() {
1009
+ local project_dir="$1"
1010
+ local count_summary
1011
+ count_summary=$(_init_legacy_file_summary "$project_dir")
1012
+
1013
+ info "Detected: legacy project (${count_summary}) 检测到遗留项目"
1014
+ echo ""
1015
+
1016
+ # Enumerate installed agents by parsing ROLL_CONFIG inline.
1017
+ # Output format in config: ai_<name>: <dir>|<config>|<src>
1018
+ local installed=()
1019
+ local missing=()
1020
+ local _key _value _name _dir
1021
+ while IFS=: read -r _key _value; do
1022
+ [[ "$_key" =~ ^ai_ ]] || continue
1023
+ _name="${_key#ai_}"
1024
+ _dir="${_value%%|*}"
1025
+ _dir="${_dir# }" # trim leading space
1026
+ _dir="${_dir/#\~/$HOME}" # expand ~
1027
+ if [[ -d "$_dir" ]]; then
1028
+ installed+=("$_name")
1029
+ else
1030
+ missing+=("$_name")
1031
+ fi
1032
+ done < "$ROLL_CONFIG"
1033
+
1034
+ echo " Onboarding 需要一个 AI agent 来读懂这个项目。检测到:"
1035
+ echo " Onboarding requires an AI agent to read your code. Detected:"
1036
+ echo ""
1037
+ local n
1038
+ if [[ ${#installed[@]} -gt 0 ]]; then
1039
+ for n in "${installed[@]}"; do
1040
+ printf " %b✓%b %s (installed)\n" "${GREEN}" "${NC}" "$n"
1041
+ done
1042
+ fi
1043
+ if [[ ${#missing[@]} -gt 0 ]]; then
1044
+ for n in "${missing[@]}"; do
1045
+ printf " %b✗%b %s (not found)\n" "${RED}" "${NC}" "$n"
1046
+ done
1047
+ fi
1048
+
1049
+ if [[ ${#installed[@]} -eq 0 ]]; then
1050
+ echo ""
1051
+ err "No AI agent detected. Install one (e.g., 'claude', 'codex', 'kimi') and try again."
1052
+ err "未检测到 AI agent。请先安装 (如 claude / codex / kimi) 后重试。"
1053
+ return 1
1054
+ fi
1055
+
1056
+ echo ""
1057
+ echo " 后续过程会使用你的 agent 调用模型,token 消耗在你自己的账户上。"
1058
+ echo " Onboarding uses your agent to call models — tokens are billed to your account."
1059
+ echo ""
1060
+ echo " 代码与对话都留在你的 agent 工具里 —— Roll 本身不上传任何内容。"
1061
+ echo " Your code and conversation stay in your agent — Roll never uploads anything."
1062
+ echo ""
1063
+ echo " 下一步 Next step:"
1064
+ echo ""
1065
+ if [[ ${#installed[@]} -eq 1 ]]; then
1066
+ echo " Open ${installed[0]} and run: $roll-onboard"
1067
+ else
1068
+ echo " Open any installed agent and run: \$roll-onboard"
1069
+ fi
1070
+ echo ""
1071
+ echo " 完成对话后回到这里运行 After the conversation, return and run:"
1072
+ echo " roll init --apply"
1073
+ echo ""
1074
+ }
1075
+
1076
+ # Helper: human-readable summary of why this is detected as legacy.
1077
+ _init_legacy_file_summary() {
1078
+ local project_dir="$1"
1079
+ local dir count parts=()
1080
+ for dir in src app lib pkg cmd; do
1081
+ if [[ -d "$project_dir/$dir" ]]; then
1082
+ count=$(find "$project_dir/$dir" -type f -not -empty 2>/dev/null | wc -l | tr -d ' ')
1083
+ if [[ "$count" -ge 10 ]]; then
1084
+ parts+=("${count} files in ${dir}/")
1085
+ fi
1086
+ fi
1087
+ done
1088
+ echo "no AGENTS.md, ${parts[*]}"
1089
+ }
1090
+
1091
+ # US-ONBOARD-009: roll init --apply
1092
+ # Consume .roll/onboard-plan.yaml (produced by $roll-onboard skill) and execute
1093
+ # all side effects: create .roll/ structure per scope, sync AI tools, write
1094
+ # .gitignore based on user's Q7 choice.
1095
+ #
1096
+ # Plan validation is delegated to lib/roll-plan-validate.py to avoid bash YAML
1097
+ # parsing fragility.
1098
+ _init_apply() {
1099
+ local project_dir; project_dir="$(pwd)"
1100
+ local plan="${project_dir}/.roll/onboard-plan.yaml"
1101
+ local validator="${ROLL_PKG_DIR}/lib/roll-plan-validate.py"
1102
+
1103
+ if [[ ! -f "$plan" ]]; then
1104
+ err "No onboard plan found at .roll/onboard-plan.yaml 未找到 onboard 计划。"
1105
+ echo "" >&2
1106
+ echo " Run \$roll-onboard in your AI agent first to generate the plan." >&2
1107
+ echo " 请先在 AI agent 里运行 \$roll-onboard 生成 plan,再回来执行 apply。" >&2
1108
+ return 1
1109
+ fi
1110
+
1111
+ if [[ ! -f "$validator" ]]; then
1112
+ err "Plan validator missing: $validator 校验器缺失。"
1113
+ return 1
1114
+ fi
1115
+
1116
+ # Validate plan (schema + generated_at freshness + version)
1117
+ if ! python3 "$validator" "$plan"; then
1118
+ err "Plan validation failed. See errors above. Plan 校验失败。"
1119
+ echo "" >&2
1120
+ echo " If the plan is stale (>24h), regenerate by running \$roll-onboard again." >&2
1121
+ return 1
1122
+ fi
1123
+
1124
+ info "Applying onboard plan... 正在应用 onboard 计划..."
1125
+ _ROLL_MERGE_SUMMARY=()
1126
+
1127
+ # Read scope from plan (simple grep — validator confirmed structure)
1128
+ local approved
1129
+ approved=$(python3 -c "
1130
+ import yaml, sys
1131
+ p = yaml.safe_load(open('$plan'))
1132
+ print(' '.join(p.get('scope', {}).get('approved', [])))
1133
+ " 2>/dev/null || echo "")
1134
+
1135
+ _merge_global_to_project "$project_dir"
1136
+ _merge_claude_to_project "$project_dir"
1137
+
1138
+ # Create .roll/ artifacts based on scope.approved
1139
+ if [[ " $approved " == *" backlog "* ]]; then
1140
+ _write_backlog "$project_dir/.roll/backlog.md"
1141
+ fi
1142
+ if [[ " $approved " == *" features "* ]]; then
1143
+ _ensure_features_dir "$project_dir/.roll/features"
1144
+ _write_features_md "$project_dir/.roll/features.md"
1145
+ fi
1146
+ if [[ " $approved " == *" domain "* ]]; then
1147
+ mkdir -p "$project_dir/.roll/domain"
1148
+ fi
1149
+ if [[ " $approved " == *" briefs "* ]]; then
1150
+ mkdir -p "$project_dir/.roll/briefs"
1151
+ fi
1152
+
1153
+ print_merge_summary
1154
+
1155
+ # Q7: .gitignore preference
1156
+ local gitignore_roll
1157
+ gitignore_roll=$(python3 -c "
1158
+ import yaml
1159
+ p = yaml.safe_load(open('$plan'))
1160
+ print('true' if p.get('privacy', {}).get('gitignore_dot_roll', False) else 'false')
1161
+ " 2>/dev/null || echo "false")
1162
+
1163
+ if [[ "$gitignore_roll" == "true" ]]; then
1164
+ local gi="$project_dir/.gitignore"
1165
+ if ! grep -qFx ".roll/" "$gi" 2>/dev/null; then
1166
+ echo ".roll/" >> "$gi"
1167
+ ok "Added .roll/ to .gitignore 已将 .roll/ 加入 .gitignore"
1168
+ fi
1169
+ fi
1170
+
1171
+ echo ""
1172
+ info "Syncing conventions to AI tools... 正在同步约定到 AI 工具..."
1173
+ _sync_conventions
1174
+ echo ""
1175
+
1176
+ ok "Onboard apply complete. Onboard 应用完成。"
1177
+ }
1178
+
1179
+ # ═══════════════════════════════════════════════════════════════════════════════
1180
+ # cmd_migrate
1181
+ # US-ONBOARD-003: One-shot migration from old project layout to .roll/ structure.
1182
+ #
1183
+ # Moves process artifacts (.roll/backlog.md, .roll/proposals.md, .roll/features/, .roll/briefs/,
1184
+ # .roll/dream/, .roll/design/, .roll/domain/) into .roll/. Also relocates user docs
1185
+ # (guide/ → guide/, site/ → site/, site/slides/ → site/slides/).
1186
+ #
1187
+ # Three-state idempotency:
1188
+ # - old-only: execute migration via git mv (single atomic commit)
1189
+ # - new-only: no-op with "already migrated" message
1190
+ # - both: error with conflict list (manual resolution required)
1191
+ # - neither: no-op with "nothing to migrate"
1192
+ # ═══════════════════════════════════════════════════════════════════════════════
1193
+ cmd_migrate() {
1194
+ local dry_run=false
1195
+ while [[ $# -gt 0 ]]; do
1196
+ case "$1" in
1197
+ --dry-run|-n) dry_run=true; shift ;;
1198
+ -h|--help) _migrate_help; return 0 ;;
1199
+ *) err "Unknown arg: $1 未知参数: $1"; return 1 ;;
1200
+ esac
1201
+ done
1202
+
1203
+ # Must be in a git repo (git mv preserves history)
1204
+ if ! git rev-parse --git-dir >/dev/null 2>&1; then
1205
+ err "Not a git repository. roll migrate requires git. 当前目录不是 git 仓库。"
1206
+ return 1
1207
+ fi
1208
+
1209
+ # Build canonical migration plan (read from stdout to avoid bash 4.3+ nameref)
1210
+ local -a moves=()
1211
+ while IFS= read -r _line; do moves+=("$_line"); done < <(_migrate_build_moves)
1212
+
1213
+ # Detect state: do old paths exist? does .roll/ exist?
1214
+ local has_old=false has_new=false
1215
+ [[ -e .roll ]] && has_new=true
1216
+ local m src
1217
+ for m in "${moves[@]}"; do
1218
+ src="${m%%|*}"
1219
+ if [[ -e "$src" ]]; then
1220
+ has_old=true
1221
+ break
1222
+ fi
1223
+ done
1224
+
1225
+ # Three-state dispatch
1226
+ if [[ "$has_new" == "true" && "$has_old" == "true" ]]; then
1227
+ err "Both old and new structures exist (partial migration detected). 老结构与新结构同时存在(部分迁移)。"
1228
+ echo "" >&2
1229
+ echo "Conflicting paths: 冲突路径:" >&2
1230
+ for m in "${moves[@]}"; do
1231
+ src="${m%%|*}"
1232
+ local tgt="${m##*|}"
1233
+ if [[ -e "$src" && -e "$tgt" ]]; then
1234
+ echo " - $src AND $tgt both exist" >&2
1235
+ fi
1236
+ done
1237
+ echo "" >&2
1238
+ err "Resolve manually then re-run. 请手动解决冲突后重新运行。"
1239
+ return 1
1240
+ fi
1241
+
1242
+ if [[ "$has_new" == "true" && "$has_old" == "false" ]]; then
1243
+ ok "Already migrated. .roll/ exists, no old paths found. 已迁移,无需重复操作。"
1244
+ return 0
1245
+ fi
1246
+
1247
+ if [[ "$has_old" == "false" ]]; then
1248
+ info "No old structure detected. Nothing to migrate. 未发现老结构,无需迁移。"
1249
+ return 0
1250
+ fi
1251
+
1252
+ # State: old-only — proceed. Filter to actually existing paths.
1253
+ local -a active_moves=()
1254
+ for m in "${moves[@]}"; do
1255
+ src="${m%%|*}"
1256
+ [[ -e "$src" ]] && active_moves+=("$m")
1257
+ done
1258
+
1259
+ if [[ ${#active_moves[@]} -eq 0 ]]; then
1260
+ warn "Old structure markers found but no migratable files. 未找到可迁移文件。"
1261
+ return 0
1262
+ fi
1263
+
1264
+ if [[ "$dry_run" == "true" ]]; then
1265
+ _migrate_preview "${active_moves[@]}"
1266
+ return 0
1267
+ fi
1268
+
1269
+ # Real execution requires clean working tree (we'll create a single commit)
1270
+ if ! git diff --quiet --ignore-submodules HEAD 2>/dev/null; then
1271
+ err "Working tree not clean. Commit or stash changes before running migrate. 工作区有未提交改动,请先 commit 或 stash。"
1272
+ return 1
1273
+ fi
1274
+
1275
+ _migrate_execute "${active_moves[@]}"
1276
+ }
1277
+
1278
+ # Build canonical migration plan as "src|target" pairs (one per line).
1279
+ # Single source of truth for what migrates where.
1280
+ # Returns via stdout (bash 3.2-compatible — no nameref).
1281
+ _migrate_build_moves() {
1282
+ # Order matters: directory-renames must precede moves whose target_dir is
1283
+ # the same dir. Otherwise mkdir -p pre-creates the target, and git mv then
1284
+ # places the source INSIDE rather than renaming. Specifically:
1285
+ # - docs/site → site must precede docs/intro → site/slides
1286
+ # - docs/guide/en → guide/en must precede docs/practices/engineering-common-sense.md
1287
+ #
1288
+ # IMPORTANT: the LEFT side of each mapping is a literal OLD path. These must
1289
+ # NOT be sed'd during Story 5 code-ref migration — they drive the migration
1290
+ # for OTHER projects (and self-migrate idempotency).
1291
+ cat << 'EOF'
1292
+ BACKLOG.md|.roll/backlog.md
1293
+ PROPOSALS.md|.roll/proposals.md
1294
+ docs/features.md|.roll/features.md
1295
+ docs/features|.roll/features
1296
+ docs/briefs|.roll/briefs
1297
+ docs/dream|.roll/dream
1298
+ docs/design|.roll/design
1299
+ docs/domain|.roll/domain
1300
+ docs/practices/loop-autorun-verification.md|.roll/verification/loop-autorun-verification.md
1301
+ docs/site|site
1302
+ docs/intro|site/slides
1303
+ docs/guide/en|guide/en
1304
+ docs/guide/zh|guide/zh
1305
+ docs/practices/engineering-common-sense.md|guide/en/practices/engineering-common-sense.md
1306
+ EOF
1307
+ }
1308
+
1309
+ _migrate_preview() {
1310
+ info "Migration preview (dry-run): 迁移预览(dry-run)"
1311
+ echo ""
1312
+ printf " %-60s → %s\n" "Old path 老路径" "New path 新路径"
1313
+ local sep; sep=$(printf '─%.0s' {1..100})
1314
+ printf " %s\n" "$sep"
1315
+ local m
1316
+ for m in "$@"; do
1317
+ local src="${m%%|*}" tgt="${m##*|}"
1318
+ printf " %-60s → %s\n" "$src" "$tgt"
1319
+ done
1320
+ echo ""
1321
+ info "Run without --dry-run to execute. 去掉 --dry-run 即可真实执行。"
1322
+ }
1323
+
1324
+ _migrate_execute() {
1325
+ info "Migrating ${#@} paths via git mv... 正在通过 git mv 迁移 ${#@} 个路径..."
1326
+ local moved=0 m
1327
+ for m in "$@"; do
1328
+ local src="${m%%|*}" tgt="${m##*|}"
1329
+ local target_dir; target_dir=$(dirname "$tgt")
1330
+ [[ -d "$target_dir" ]] || mkdir -p "$target_dir"
1331
+ git mv "$src" "$tgt" || {
1332
+ err "git mv failed: $src → $tgt"
1333
+ err "Aborting; previous moves are staged but not committed. Run 'git reset --hard' to undo. 已 stage 但未 commit,运行 'git reset --hard' 回滚。"
1334
+ return 1
1335
+ }
1336
+ moved=$((moved + 1))
1337
+ done
1338
+ # Clean up empty docs/ shells
1339
+ if [[ -d "docs" ]]; then
1340
+ find docs -type d -empty -delete 2>/dev/null || true
1341
+ fi
1342
+ # Single atomic commit
1343
+ git commit --quiet -m "Migrate project layout to .roll/ structure
1344
+
1345
+ Atomic migration via 'roll migrate' command. Process artifacts moved
1346
+ from root and docs/ into .roll/; user docs relocated to guide/ and site/.
1347
+
1348
+ Paths migrated: ${moved}"
1349
+ ok "Migrated ${moved} paths in a single commit. 已在单 commit 中迁移 ${moved} 个路径。"
1350
+ echo ""
1351
+ echo " Next steps 下一步:"
1352
+ echo " git log -1 # Inspect the migration commit"
1353
+ echo " roll status # Verify new structure"
1354
+ }
1355
+
1356
+ _migrate_help() {
1357
+ cat << 'EOF'
1358
+ Usage: roll migrate [--dry-run]
1359
+
1360
+ Migrate this project's process artifacts (.roll/backlog.md, .roll/proposals.md,
1361
+ .roll/features/, .roll/briefs/, .roll/dream/, .roll/design/, .roll/domain/)
1362
+ into a .roll/ directory. Also relocates guide/ → guide/,
1363
+ site/ → site/, site/slides/ → site/slides/.
1364
+
1365
+ Options:
1366
+ --dry-run, -n Show what would be moved without modifying files
1367
+ --help, -h Show this help
1368
+
1369
+ Three-state idempotency:
1370
+ - Only old paths present → migration executes (single atomic commit)
1371
+ - Only .roll/ present → no-op (already migrated)
1372
+ - Both present → error with conflict list (manual review)
1373
+ - Neither → no-op (nothing to migrate)
1374
+
1375
+ Preconditions:
1376
+ - Current directory is a git repository
1377
+ - Working tree is clean (commit or stash changes first)
1378
+
1379
+ Uses git mv to preserve file history. On success, produces a single commit.
1380
+ EOF
1381
+ }
1382
+
956
1383
  # ─── Helper: print a tidy summary of merge actions ───────────────────────────
957
1384
  print_merge_summary() {
958
1385
  if [[ ${#_ROLL_MERGE_SUMMARY[@]} -eq 0 ]]; then
@@ -1019,12 +1446,13 @@ scan_project_type_from_files() {
1019
1446
  fi
1020
1447
  }
1021
1448
 
1022
- # ─── Helper: write starter BACKLOG.md (no-op if exists) ──────────────────────
1449
+ # ─── Helper: write starter .roll/backlog.md (no-op if exists) ──────────────────────
1023
1450
  _write_backlog() {
1024
1451
  if [[ -f "$1" ]]; then
1025
- _ROLL_MERGE_SUMMARY+=("unchanged|BACKLOG.md")
1452
+ _ROLL_MERGE_SUMMARY+=("unchanged|.roll/backlog.md")
1026
1453
  return
1027
1454
  fi
1455
+ mkdir -p "$(dirname "$1")"
1028
1456
  cat > "$1" << 'EOF'
1029
1457
  # Project Backlog
1030
1458
 
@@ -1036,25 +1464,25 @@ _write_backlog() {
1036
1464
  | ID | Problem | Status |
1037
1465
  |----|---------|--------|
1038
1466
  EOF
1039
- ok "Created: BACKLOG.md"
1040
- _ROLL_MERGE_SUMMARY+=("created|BACKLOG.md")
1467
+ ok "Created: .roll/backlog.md"
1468
+ _ROLL_MERGE_SUMMARY+=("created|.roll/backlog.md")
1041
1469
  }
1042
1470
 
1043
1471
  _ensure_features_dir() {
1044
1472
  if [[ -d "$1" ]]; then
1045
- _ROLL_MERGE_SUMMARY+=("unchanged|docs/features/")
1473
+ _ROLL_MERGE_SUMMARY+=("unchanged|.roll/features/")
1046
1474
  return
1047
1475
  fi
1048
1476
 
1049
1477
  mkdir -p "$1"
1050
- ok "Created: docs/features/"
1051
- _ROLL_MERGE_SUMMARY+=("created|docs/features/")
1478
+ ok "Created: .roll/features/"
1479
+ _ROLL_MERGE_SUMMARY+=("created|.roll/features/")
1052
1480
  }
1053
1481
 
1054
- # ─── Helper: write starter docs/features.md (no-op if exists) ────────────────
1482
+ # ─── Helper: write starter .roll/features.md (no-op if exists) ────────────────
1055
1483
  _write_features_md() {
1056
1484
  if [[ -f "$1" ]]; then
1057
- _ROLL_MERGE_SUMMARY+=("unchanged|docs/features.md")
1485
+ _ROLL_MERGE_SUMMARY+=("unchanged|.roll/features.md")
1058
1486
  return
1059
1487
  fi
1060
1488
  mkdir -p "$(dirname "$1")"
@@ -1069,8 +1497,8 @@ _write_features_md() {
1069
1497
 
1070
1498
  <!-- Add feature entries here as epics are completed -->
1071
1499
  EOF
1072
- ok "Created: docs/features.md"
1073
- _ROLL_MERGE_SUMMARY+=("created|docs/features.md")
1500
+ ok "Created: .roll/features.md"
1501
+ _ROLL_MERGE_SUMMARY+=("created|.roll/features.md")
1074
1502
  }
1075
1503
 
1076
1504
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -1250,8 +1678,8 @@ _status_loop_overview() {
1250
1678
  fi
1251
1679
 
1252
1680
  local todo_count=0
1253
- if [[ -z "$path_note" && -f "${proj_path}/BACKLOG.md" ]]; then
1254
- todo_count=$(grep -c '📋 Todo' "${proj_path}/BACKLOG.md" 2>/dev/null; true)
1681
+ if [[ -z "$path_note" && -f "${proj_path}/.roll/backlog.md" ]]; then
1682
+ todo_count=$(grep -c '📋 Todo' "${proj_path}/.roll/backlog.md" 2>/dev/null; true)
1255
1683
  fi
1256
1684
 
1257
1685
  echo -e " ${state_icon} ${proj_name}${path_note} ${schedule} ${todo_count} pending"
@@ -1998,6 +2426,13 @@ cmd_agent() {
1998
2426
  # Returns a filesystem-safe slug combining the project basename and a 6-char
1999
2427
  # hash of the full path, ensuring uniqueness across sibling dirs with same name.
2000
2428
  _project_slug() {
2429
+ # US-LOOP-006: cycle wrapper exports ROLL_MAIN_SLUG so any subshell — worktree,
2430
+ # tmp cwd, or unrelated path — writes events / runs.jsonl under the main project
2431
+ # identity instead of fragmenting into tmp-* / cycle-* phantom slugs.
2432
+ if [[ -n "${ROLL_MAIN_SLUG:-}" ]]; then
2433
+ printf '%s' "$ROLL_MAIN_SLUG"
2434
+ return 0
2435
+ fi
2001
2436
  local path="${1:-$(pwd -P 2>/dev/null || pwd)}"
2002
2437
  # FIX-056: normalize path to canonical case on macOS case-insensitive filesystem.
2003
2438
  # Two paths differing only in case point to the same directory; realpath
@@ -2358,6 +2793,38 @@ LOOP_CYCLE_TIMEOUT_SEC="\${ROLL_LOOP_CYCLE_TIMEOUT_SEC:-2700}"
2358
2793
  _CYCLE_TIMED_OUT=0
2359
2794
  _on_sigterm() { _CYCLE_TIMED_OUT=1; }
2360
2795
  trap '_on_sigterm' TERM
2796
+ # US-LOOP-005: idempotent runs.jsonl writer shared by normal exit, timeout
2797
+ # trap, and worktree-setup-failure early exit. Guards on jq + run_id dedupe so
2798
+ # multiple callers in the same cycle are safe.
2799
+ _runs_append() {
2800
+ local _status="\$1"; local _tcr="\${2:-0}"; local _built="\${3:-[]}"
2801
+ local _runs_dst="${HOME}/.shared/roll/loop/runs.jsonl"
2802
+ command -v jq >/dev/null 2>&1 || return 0
2803
+ local _cid="\${CYCLE_ID:-pre-cycle-\$\$}"
2804
+ local _rid="loop-\${_cid%-*}"
2805
+ grep -qF "\"run_id\":\"\$_rid\"" "\$_runs_dst" 2>/dev/null && return 0
2806
+ mkdir -p "\$(dirname "\$_runs_dst")"
2807
+ local _ts_now; _ts_now=\$(date -u +%Y-%m-%dT%H:%M:%SZ)
2808
+ local _start="\${CYCLE_START:-\$(date -u +%s)}"
2809
+ local _dur=\$(( \$(date -u +%s) - _start ))
2810
+ [ "\$_dur" -lt 0 ] && _dur=0
2811
+ jq -nc \\
2812
+ --arg ts "\$_ts_now" \\
2813
+ --arg project "${slug}" \\
2814
+ --arg run_id "\$_rid" \\
2815
+ --arg status "\$_status" \\
2816
+ --arg cycle_id "\$_cid" \\
2817
+ --argjson built "\$_built" \\
2818
+ --argjson skipped '[]' \\
2819
+ --argjson alerts '[]' \\
2820
+ --argjson tcr_count "\$_tcr" \\
2821
+ --argjson duration_sec "\$_dur" \\
2822
+ '{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
2823
+ cycle_id:\$cycle_id,
2824
+ built:\$built, skipped:\$skipped, alerts:\$alerts,
2825
+ tcr_count:\$tcr_count, duration_sec:\$duration_sec}' \\
2826
+ >> "\$_runs_dst" 2>/dev/null || true
2827
+ }
2361
2828
  _inner_cleanup() {
2362
2829
  local _rc=\$?
2363
2830
  # Kill heartbeat + every remaining background job (watchdog, orphan
@@ -2367,6 +2834,10 @@ _inner_cleanup() {
2367
2834
  for _pid in \$(jobs -p); do kill "\$_pid" 2>/dev/null; done
2368
2835
  if [ "\${_CYCLE_TIMED_OUT:-0}" -eq 1 ]; then
2369
2836
  _loop_event cycle_end "\${CYCLE_ID:-unknown}" "\${BRANCH:-}" "blocked" 2>/dev/null || true
2837
+ # US-LOOP-005 T9: timeout path must also write runs.jsonl row so dashboard
2838
+ # has a terminal record (cycle_end alone is insufficient — runs.jsonl is
2839
+ # the canonical history feed for 'roll loop runs').
2840
+ _runs_append "failed" 0 "[]" 2>/dev/null || true
2370
2841
  _worktree_alert "cycle \${CYCLE_ID:-unknown}: \${LOOP_CYCLE_TIMEOUT_SEC}s timeout — claude/python killed; in-progress story marked Blocked" 2>/dev/null || true
2371
2842
  fi
2372
2843
  rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE"
@@ -2389,12 +2860,16 @@ _LOOP_PROJ_SLUG="${slug}"
2389
2860
  _LOOP_ALERT="\${_SHARED_ROOT}/loop/ALERT-${slug}.md"
2390
2861
  _LOOP_STATE="\${_SHARED_ROOT}/loop/state-${slug}.yaml"
2391
2862
  _LOOP_MUTE_FILE="\${_SHARED_ROOT}/loop/mute-${slug}"
2863
+ # US-LOOP-006: ROLL_MAIN_SLUG is the canonical identity for any subprocess —
2864
+ # claude, loop-fmt.py, _loop_event in arbitrary cwd. _project_slug honors this
2865
+ # env var first, so writes never fragment into tmp-* / cycle-* phantom slugs.
2866
+ export ROLL_MAIN_SLUG="${slug}"
2392
2867
 
2393
2868
  # Pre-claude: try to create a per-cycle isolated worktree on origin/main.
2394
2869
  # On any failure (no remote, no main, etc.) fall back to running in the
2395
2870
  # project's main tree (degraded — no isolation, like pre-037 behavior).
2396
- CYCLE_ID="\$(date -u +%Y%m%d-%H%M%S)-\$\$"
2397
- CYCLE_START=\$(date -u +%s)
2871
+ CYCLE_ID="\$(date +%Y%m%d-%H%M%S)-\$\$"
2872
+ CYCLE_START=\$(date +%s)
2398
2873
  WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
2399
2874
  BRANCH="loop/cycle-\${CYCLE_ID}"
2400
2875
  _USE_WORKTREE=0
@@ -2448,6 +2923,10 @@ else
2448
2923
  # falling back to the main tree without isolation is unacceptable.
2449
2924
  _worktree_alert "cycle \${CYCLE_ID}: worktree setup failed — skipping cycle to avoid running without isolation"
2450
2925
  echo "[loop] cycle \${CYCLE_ID}: worktree setup failed; skipping cycle (no isolation)"
2926
+ # US-LOOP-005 T10: worktree-setup-failed path leaves no commits and never
2927
+ # emits cycle_start, but dashboard still needs a runs.jsonl row marking the
2928
+ # cycle as failed (otherwise the scheduled tick appears to have vanished).
2929
+ _runs_append "failed" 0 "[]" 2>/dev/null || true
2451
2930
  exit 0
2452
2931
  fi
2453
2932
 
@@ -2501,7 +2980,7 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2501
2980
  _cycle_status="built"
2502
2981
  _cycle_tcr=\$(cd "\$WT" && git log --oneline origin/main..HEAD -- 2>/dev/null | grep -c ' tcr:' || echo 0)
2503
2982
  if command -v jq >/dev/null 2>&1; then
2504
- _cycle_built=\$(cd "\$WT" && git diff origin/main -- BACKLOG.md 2>/dev/null | grep '✅ Done' | grep -oE '\[[A-Z]+-[0-9]+\]' | sed 's/^.//;s/.\$//' | jq -R -s 'split("\n") | map(select(length>0))' 2>/dev/null || echo "[]")
2983
+ _cycle_built=\$(cd "\$WT" && git diff origin/main -- .roll/backlog.md 2>/dev/null | grep '✅ Done' | grep -oE '\[[A-Z]+-[0-9]+\]' | sed 's/^.//;s/.\$//' | jq -R -s 'split("\n") | map(select(length>0))' 2>/dev/null || echo "[]")
2505
2984
  fi
2506
2985
  fi
2507
2986
  fi
@@ -2549,6 +3028,8 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2549
3028
  elif [ "\$_publish_status" -eq 2 ]; then
2550
3029
  if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
2551
3030
  _worktree_cleanup "\$WT" "\$BRANCH"
3031
+ # US-LOOP-005 T3: gh unavailable + ff merge_back OK → cycle_end done
3032
+ _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true
2552
3033
  echo "[loop] cycle \${CYCLE_ID}: gh unavailable; merged via ff and cleaned up"
2553
3034
  else
2554
3035
  # FIX-039: gh unavailable + merge_back failed — push orphan branch+tag to origin
@@ -2558,9 +3039,13 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2558
3039
  && git tag "\$_orphan_tag" 2>/dev/null \
2559
3040
  && git push origin "\$_orphan_tag" 2>/dev/null ); then
2560
3041
  _worktree_cleanup "\$WT" "\$BRANCH"
3042
+ # US-LOOP-005 T4: gh unavailable + orphan push OK → cycle_end orphan
3043
+ _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true
2561
3044
  _worktree_alert "cycle \${CYCLE_ID}: gh+merge_back failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
2562
3045
  echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
2563
3046
  else
3047
+ # US-LOOP-005 T5: gh unavailable + all failed → cycle_end failed
3048
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true
2564
3049
  _worktree_alert "cycle \${CYCLE_ID}: gh+merge_back+push all failed; worktree preserved at \$WT"
2565
3050
  echo "[loop] cycle \${CYCLE_ID}: all publish paths failed; worktree preserved at \$WT"
2566
3051
  fi
@@ -2573,15 +3058,21 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2573
3058
  && git tag "\$_orphan_tag" 2>/dev/null \
2574
3059
  && git push origin "\$_orphan_tag" 2>/dev/null ); then
2575
3060
  _worktree_cleanup "\$WT" "\$BRANCH"
3061
+ # US-LOOP-005 T6: PR publish failed + orphan push OK → cycle_end orphan
3062
+ _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true
2576
3063
  _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
2577
3064
  echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
2578
3065
  else
3066
+ # US-LOOP-005 T7: PR publish failed + orphan push failed → cycle_end failed
3067
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true
2579
3068
  _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
2580
3069
  echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
2581
3070
  fi
2582
3071
  fi
2583
3072
  fi
2584
3073
  else
3074
+ # US-LOOP-005 T8: claude session failed after retry budget → cycle_end failed
3075
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true
2585
3076
  _worktree_alert "cycle \${CYCLE_ID}: claude exited \$_exit; worktree preserved at \$WT (branch \$BRANCH)"
2586
3077
  echo "[loop] cycle \${CYCLE_ID}: claude failed (exit \$_exit); worktree preserved at \$WT"
2587
3078
  fi
@@ -2592,29 +3083,9 @@ _loop_cleanup_stale_cycle_branches "${project_path}" || true
2592
3083
 
2593
3084
  # FIX-044 / Step 5: Write loop cycle run summary to runs.jsonl
2594
3085
  # Deterministic — runs in shell regardless of whether agent executes SKILL.md Step 5.
2595
- # Idempotent: skips if a record for this run_id already exists (agent may also write).
2596
- _runs_dst="${HOME}/.shared/roll/loop/runs.jsonl"
2597
- mkdir -p "\$(dirname "\$_runs_dst")"
2598
- _cycle_end=\$(date -u +%s)
2599
- _cycle_dur=\$(( _cycle_end - CYCLE_START ))
2600
- _ts=\$(date -u +%Y-%m-%dT%H:%M:%SZ)
2601
- _run_id="loop-\${CYCLE_ID%-*}"
2602
- if command -v jq >/dev/null 2>&1 && ! grep -qF "\"run_id\":\"\$_run_id\"" "\$_runs_dst" 2>/dev/null; then
2603
- jq -nc \\
2604
- --arg ts "\$_ts" \\
2605
- --arg project "${slug}" \\
2606
- --arg run_id "\$_run_id" \\
2607
- --arg status "\$_cycle_status" \\
2608
- --argjson built "\$_cycle_built" \\
2609
- --argjson skipped '[]' \\
2610
- --argjson alerts '[]' \\
2611
- --argjson tcr_count "\$_cycle_tcr" \\
2612
- --argjson duration_sec "\$_cycle_dur" \\
2613
- '{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
2614
- built:\$built, skipped:\$skipped, alerts:\$alerts,
2615
- tcr_count:\$tcr_count, duration_sec:\$duration_sec}' \\
2616
- >> "\$_runs_dst" 2>/dev/null || true
2617
- fi
3086
+ # US-LOOP-005: now routed through _runs_append so timeout/worktree-setup-fail
3087
+ # share the same write logic. _runs_append is idempotent on run_id.
3088
+ _runs_append "\$_cycle_status" "\$_cycle_tcr" "\$_cycle_built" 2>/dev/null || true
2618
3089
  INNER
2619
3090
  chmod +x "$inner_path"
2620
3091
 
@@ -2851,7 +3322,7 @@ _agent_skill_cmd() {
2851
3322
  return 1
2852
3323
  }
2853
3324
  # Cron-installed skills (dream / brief / loop) run autonomously and need to
2854
- # Edit files (docs/dream/, docs/briefs/, BACKLOG, etc.). Claude Code 2.1.x's
3325
+ # Edit files (.roll/dream/, .roll/briefs/, BACKLOG, etc.). Claude Code 2.1.x's
2855
3326
  # pre-write approval UX silently blocks `claude -p` from applying edits in
2856
3327
  # non-interactive pipe mode — bypass it for the cron context.
2857
3328
  _agent_bypass_claude_perms
@@ -3083,6 +3554,9 @@ _loop_test() {
3083
3554
  }
3084
3555
 
3085
3556
  _loop_status() {
3557
+ # FIX-060: backfill merged PRs before rendering — independent of cycle ticks,
3558
+ # so dashboard reflects merges that happened while loop was paused.
3559
+ _loop_backfill_merged >/dev/null 2>&1 || true
3086
3560
  # ROLL_UI=v2 (default) routes to the redesigned Python view.
3087
3561
  # Set ROLL_UI=v1 to fall back to the legacy bash implementation.
3088
3562
  if [[ "${ROLL_UI:-v2}" == "v2" ]]; then
@@ -3332,6 +3806,9 @@ _loop_runs() {
3332
3806
  esac
3333
3807
  done
3334
3808
 
3809
+ # FIX-060: refresh merged status before reading, so paused-window merges show up.
3810
+ _loop_backfill_merged >/dev/null 2>&1 || true
3811
+
3335
3812
  if [[ ! -f "$_LOOP_RUNS" ]] || [[ ! -s "$_LOOP_RUNS" ]]; then
3336
3813
  echo "No loop runs yet 尚无 loop 运行记录"
3337
3814
  return 0
@@ -3363,7 +3840,7 @@ _loop_runs() {
3363
3840
  [[ "$(uname)" == "Darwin" ]] && _is_darwin="1"
3364
3841
 
3365
3842
  _LOOP_RUNS_BACKLOG=""
3366
- [[ -f "$project_path/BACKLOG.md" ]] && _LOOP_RUNS_BACKLOG=$(cat "$project_path/BACKLOG.md")
3843
+ [[ -f "$project_path/.roll/backlog.md" ]] && _LOOP_RUNS_BACKLOG=$(cat "$project_path/.roll/backlog.md")
3367
3844
 
3368
3845
  while IFS= read -r line; do
3369
3846
  [[ -z "$line" ]] && continue
@@ -3671,7 +4148,7 @@ _loop_clear_heal_state() {
3671
4148
  }
3672
4149
 
3673
4150
  # Verify TCR rhythm after a story completes. Returns 0 if ok, 1 if no TCR commits.
3674
- # On failure: reverts story in BACKLOG.md to 📋 Todo and writes ALERT.
4151
+ # On failure: reverts story in .roll/backlog.md to 📋 Todo and writes ALERT.
3675
4152
  _loop_enforce_tcr() {
3676
4153
  local story_id="$1"
3677
4154
  local started_at="${2:-}"
@@ -3682,10 +4159,10 @@ _loop_enforce_tcr() {
3682
4159
 
3683
4160
  if [[ "$count" -eq 0 ]]; then
3684
4161
  # Revert story status
3685
- if [[ -f "BACKLOG.md" ]]; then
4162
+ if [[ -f ".roll/backlog.md" ]]; then
3686
4163
  local tmp; tmp=$(mktemp)
3687
- sed "/\[${story_id}\]/s/ | ✅ Done |/ | 📋 Todo |/" BACKLOG.md > "$tmp" \
3688
- && mv "$tmp" BACKLOG.md
4164
+ sed "/\[${story_id}\]/s/ | ✅ Done |/ | 📋 Todo |/" .roll/backlog.md > "$tmp" \
4165
+ && mv "$tmp" .roll/backlog.md
3689
4166
  fi
3690
4167
 
3691
4168
  # Write ALERT
@@ -3725,7 +4202,7 @@ EOF
3725
4202
  # Stdout (on exit 1 due to unsatisfied deps): space-separated unsatisfied IDs.
3726
4203
  _loop_check_depends_on() {
3727
4204
  local id="$1"
3728
- local backlog="${2:-BACKLOG.md}"
4205
+ local backlog="${2:-.roll/backlog.md}"
3729
4206
  [ -n "$id" ] || return 1
3730
4207
  [ -f "$backlog" ] || return 1
3731
4208
 
@@ -3762,7 +4239,7 @@ _loop_check_depends_on() {
3762
4239
  # Exit 1: tag absent, story-id not found, or backlog missing.
3763
4240
  _loop_is_manual_only() {
3764
4241
  local id="$1"
3765
- local backlog="${2:-BACKLOG.md}"
4242
+ local backlog="${2:-.roll/backlog.md}"
3766
4243
  [ -n "$id" ] || return 1
3767
4244
  [ -f "$backlog" ] || return 1
3768
4245
 
@@ -4072,7 +4549,7 @@ _loop_pr_claimed_stories() {
4072
4549
  [ -n "$branch" ] || continue
4073
4550
  local content
4074
4551
  content=$(gh -R "$slug" api \
4075
- "repos/${slug}/contents/BACKLOG.md?ref=${branch}" \
4552
+ "repos/${slug}/contents/.roll/backlog.md?ref=${branch}" \
4076
4553
  -H "Accept: application/vnd.github.raw" 2>/dev/null) || continue
4077
4554
  [ -n "$content" ] || continue
4078
4555
  local ids
@@ -4124,7 +4601,7 @@ _changelog_lint_bullet() {
4124
4601
  if [ "${len:-0}" -gt 50 ]; then
4125
4602
  echo "over-length"
4126
4603
  fi
4127
- if printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(docs|bin|tests|scripts)/'; then
4604
+ if printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(\.roll|docs|bin|tests|scripts)/'; then
4128
4605
  echo "path-fragment"
4129
4606
  fi
4130
4607
  return 0
@@ -4181,7 +4658,7 @@ _changelog_audit_bullet() {
4181
4658
 
4182
4659
  # Rule 3: file suffix / path fragment outside backticks.
4183
4660
  if printf '%s' "$stripped" | grep -qE '\.(md|sh|yml|ts|bats)([^A-Za-z0-9]|$)' \
4184
- || printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(docs|bin|tests|scripts)/'; then
4661
+ || printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(\.roll|docs|bin|tests|scripts)/'; then
4185
4662
  echo "path-or-suffix"
4186
4663
  fi
4187
4664
 
@@ -4511,13 +4988,15 @@ _loop_wait_pr_merge() {
4511
4988
 
4512
4989
  # _loop_is_doc_only_change
4513
4990
  # Returns 0 if every file changed since origin/main is doc-only
4514
- # (BACKLOG.md, CHANGELOG.md, PROPOSALS.md, docs/, .claude/).
4991
+ # (.roll/backlog.md, CHANGELOG.md, .roll/proposals.md, docs/, .claude/).
4515
4992
  # Returns 1 if any code file changed or there are no changes.
4516
4993
  _loop_is_doc_only_change() {
4517
4994
  local changed
4518
4995
  changed=$(git diff --name-only origin/main HEAD 2>/dev/null) || return 1
4519
4996
  [ -z "$changed" ] && return 1
4520
- echo "$changed" | grep -qvE '^(BACKLOG\.md|CHANGELOG\.md|PROPOSALS\.md|docs/|\.claude/)' && return 1
4997
+ # Post-Phase-1: process artifacts moved into .roll/; user-facing docs at guide/ + site/.
4998
+ # Legacy paths (BACKLOG.md, PROPOSALS.md, docs/) kept as fallback for pre-2.0 projects.
4999
+ echo "$changed" | grep -qvE '^(\.roll/|CHANGELOG\.md|guide/|site/|\.claude/|BACKLOG\.md|PROPOSALS\.md|docs/)' && return 1
4521
5000
  return 0
4522
5001
  }
4523
5002
 
@@ -4556,6 +5035,60 @@ _loop_publish_doc_pr() {
4556
5035
  return 0
4557
5036
  }
4558
5037
 
5038
+ # _loop_backfill_merged [runs_jsonl_path]
5039
+ # FIX-060: independent PR-merge backfill. Walks runs.jsonl, finds entries
5040
+ # with status:"built" and a cycle_id field, queries GitHub for the matching
5041
+ # loop/cycle-<id> PR, and rewrites entries whose PR is MERGED to
5042
+ # status:"merged" with merged_at + merge_commit fields.
5043
+ #
5044
+ # Designed to run from the outer runner BEFORE the pause check, so the
5045
+ # scan fires every scheduled tick even when the loop is paused — fixes
5046
+ # the pre-FIX-060 behaviour where merge backfill only happened at next
5047
+ # cycle startup and stalled forever during pause.
5048
+ #
5049
+ # Lenient: returns 0 when gh is missing, slug is unresolvable, jq is
5050
+ # missing, or runs.jsonl does not exist. Atomic rewrite via temp file.
5051
+ _loop_backfill_merged() {
5052
+ local runs_path="${1:-${HOME}/.shared/roll/loop/runs.jsonl}"
5053
+ [ -f "$runs_path" ] || return 0
5054
+ command -v gh >/dev/null 2>&1 || return 0
5055
+ command -v jq >/dev/null 2>&1 || return 0
5056
+ local slug; _gh_resolve slug || return 0
5057
+
5058
+ local tmp="${runs_path}.tmp.$$"
5059
+ : > "$tmp"
5060
+ local line status cycle_id branch view_json state merged_at merge_commit
5061
+ while IFS= read -r line; do
5062
+ [ -z "$line" ] && continue
5063
+ status=$(printf '%s' "$line" | jq -r '.status // ""' 2>/dev/null)
5064
+ cycle_id=$(printf '%s' "$line" | jq -r '.cycle_id // ""' 2>/dev/null)
5065
+ if [ "$status" != "built" ] || [ -z "$cycle_id" ]; then
5066
+ printf '%s\n' "$line" >> "$tmp"
5067
+ continue
5068
+ fi
5069
+ branch="loop/cycle-${cycle_id}"
5070
+ view_json=$(gh -R "$slug" pr view "$branch" --json state,mergedAt,mergeCommit 2>/dev/null) || view_json=""
5071
+ if [ -z "$view_json" ]; then
5072
+ printf '%s\n' "$line" >> "$tmp"
5073
+ continue
5074
+ fi
5075
+ state=$(printf '%s' "$view_json" | jq -r '.state // ""' 2>/dev/null)
5076
+ if [ "$state" != "MERGED" ]; then
5077
+ printf '%s\n' "$line" >> "$tmp"
5078
+ continue
5079
+ fi
5080
+ merged_at=$(printf '%s' "$view_json" | jq -r '.mergedAt // ""' 2>/dev/null)
5081
+ merge_commit=$(printf '%s' "$view_json" | jq -r '.mergeCommit.oid // ""' 2>/dev/null)
5082
+ printf '%s' "$line" | jq -c \
5083
+ --arg merged_at "$merged_at" \
5084
+ --arg merge_commit "$merge_commit" \
5085
+ '.status = "merged" | .merged_at = $merged_at | .merge_commit = $merge_commit' \
5086
+ >> "$tmp" 2>/dev/null || printf '%s\n' "$line" >> "$tmp"
5087
+ done < "$runs_path"
5088
+ mv "$tmp" "$runs_path" 2>/dev/null || rm -f "$tmp"
5089
+ return 0
5090
+ }
5091
+
4559
5092
  _loop_monitor() {
4560
5093
  local interval="${1:-3}"
4561
5094
  local project_path; project_path=$(pwd -P)
@@ -4637,7 +5170,7 @@ _loop_monitor() {
4637
5170
  # Queue: pending items
4638
5171
  echo ""
4639
5172
  echo -e " ${BOLD}Queue 待处理队列${NC}"
4640
- local backlog="BACKLOG.md"
5173
+ local backlog=".roll/backlog.md"
4641
5174
  if [[ -f "$backlog" ]]; then
4642
5175
  local queue_count=0
4643
5176
  local fix_pending us_pending refactor_pending
@@ -4677,7 +5210,7 @@ _loop_monitor() {
4677
5210
 
4678
5211
  [[ $queue_count -eq 0 ]] && echo -e " ${GREEN}✓ empty${NC}"
4679
5212
  else
4680
- echo " BACKLOG.md not found"
5213
+ echo " .roll/backlog.md not found"
4681
5214
  fi
4682
5215
 
4683
5216
  # Log tail (launchd.log)
@@ -4747,7 +5280,7 @@ for line in sys.stdin:
4747
5280
  # ═══════════════════════════════════════════════════════════════════════════════
4748
5281
 
4749
5282
  cmd_brief() {
4750
- local briefs_dir="docs/briefs"
5283
+ local briefs_dir=".roll/briefs"
4751
5284
  local latest; latest=$(ls "${briefs_dir}"/*.md 2>/dev/null | sort | tail -1 || true)
4752
5285
 
4753
5286
  if [[ -z "$latest" ]]; then
@@ -4816,7 +5349,7 @@ PYEOF
4816
5349
  _backlog_set_status() {
4817
5350
  local pattern="$1"
4818
5351
  local new_status="$2"
4819
- local backlog="BACKLOG.md"
5352
+ local backlog=".roll/backlog.md"
4820
5353
  python3 -c "
4821
5354
  import sys, re
4822
5355
  pattern, new_status, filename = sys.argv[1], sys.argv[2], sys.argv[3]
@@ -4947,9 +5480,9 @@ cmd_ci() {
4947
5480
  }
4948
5481
 
4949
5482
  cmd_backlog() {
4950
- local backlog="BACKLOG.md"
5483
+ local backlog=".roll/backlog.md"
4951
5484
  if [[ ! -f "$backlog" ]]; then
4952
- err "BACKLOG.md not found — run 'roll init' first 未找到 BACKLOG.md,请先运行 roll init"
5485
+ err ".roll/backlog.md not found — run 'roll init' first 未找到 .roll/backlog.md,请先运行 roll init"
4953
5486
  return 1
4954
5487
  fi
4955
5488
 
@@ -5095,19 +5628,19 @@ _dash_git_status() {
5095
5628
  fi
5096
5629
  }
5097
5630
 
5098
- # ② Loop layer: extract in-progress story id|title|feature-link from BACKLOG.md.
5631
+ # ② Loop layer: extract in-progress story id|title|feature-link from .roll/backlog.md.
5099
5632
  # Output empty if no row's *status column* is 🔨 In Progress (substring matches
5100
5633
  # anywhere on the row would catch description text that mentions the emoji).
5101
5634
  _dash_in_progress_story() {
5102
- [[ -f "BACKLOG.md" ]] || return 0
5635
+ [[ -f ".roll/backlog.md" ]] || return 0
5103
5636
  local row
5104
- row=$(grep -F '| 🔨 In Progress |' BACKLOG.md | head -1) || return 0
5637
+ row=$(grep -F '| 🔨 In Progress |' .roll/backlog.md | head -1) || return 0
5105
5638
  [[ -z "$row" ]] && return 0
5106
5639
  local id desc
5107
5640
  id=$(echo "$row" | grep -oE '(US|FIX|REFACTOR)-[A-Z]*-?[0-9]+' | head -1)
5108
5641
  desc=$(echo "$row" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//' | cut -c1-60)
5109
5642
  local link
5110
- link=$(echo "$row" | grep -oE 'docs/features/[^)]+' | head -1 || true)
5643
+ link=$(echo "$row" | grep -oE '.roll/features/[^)]+' | head -1 || true)
5111
5644
  printf '%s|%s|%s' "$id" "$desc" "$link"
5112
5645
  }
5113
5646
 
@@ -5140,8 +5673,8 @@ _dash_last_dream_hours() {
5140
5673
 
5141
5674
  # ② Dream layer: count of REFACTOR-XXX rows currently 📋 Todo in BACKLOG.
5142
5675
  _dash_refactor_pending() {
5143
- [[ -f "BACKLOG.md" ]] || { echo 0; return; }
5144
- grep -E '^\| REFACTOR-' BACKLOG.md 2>/dev/null | grep -F '| 📋 Todo |' | wc -l | tr -d ' '
5676
+ [[ -f ".roll/backlog.md" ]] || { echo 0; return; }
5677
+ grep -E '^\| REFACTOR-' .roll/backlog.md 2>/dev/null | grep -F '| 📋 Todo |' | wc -l | tr -d ' '
5145
5678
  }
5146
5679
 
5147
5680
  # ② Peer layer: last result + days ago from peer log, empty if no log.
@@ -5161,11 +5694,11 @@ _dash_last_peer() {
5161
5694
 
5162
5695
  # ③ Pipeline counts → Idea Backlog Build (Verify/Release reserved).
5163
5696
  _dash_pipeline_counts() {
5164
- [[ -f "BACKLOG.md" ]] || { echo "0 0 0 0 0"; return; }
5697
+ [[ -f ".roll/backlog.md" ]] || { echo "0 0 0 0 0"; return; }
5165
5698
  local idea backlog build
5166
- idea=$(grep -E '^\| IDEA-' BACKLOG.md 2>/dev/null | grep -F '| 📋 Todo |' | wc -l | tr -d ' ')
5167
- backlog=$(grep -E '^\| (\[?US-|FIX-|REFACTOR-)' BACKLOG.md 2>/dev/null | grep -F '| 📋 Todo |' | wc -l | tr -d ' ')
5168
- build=$(grep -F '| 🔨 In Progress |' BACKLOG.md 2>/dev/null | wc -l | tr -d ' ')
5699
+ idea=$(grep -E '^\| IDEA-' .roll/backlog.md 2>/dev/null | grep -F '| 📋 Todo |' | wc -l | tr -d ' ')
5700
+ backlog=$(grep -E '^\| (\[?US-|FIX-|REFACTOR-)' .roll/backlog.md 2>/dev/null | grep -F '| 📋 Todo |' | wc -l | tr -d ' ')
5701
+ build=$(grep -F '| 🔨 In Progress |' .roll/backlog.md 2>/dev/null | wc -l | tr -d ' ')
5169
5702
  printf '%s %s %s 0 0' "$idea" "$backlog" "$build"
5170
5703
  }
5171
5704
 
@@ -5235,10 +5768,10 @@ _dash_alert_count() {
5235
5768
  grep '^# ALERT' "$_LOOP_ALERT" 2>/dev/null | wc -l | tr -d ' '
5236
5769
  }
5237
5770
 
5238
- # ⑤ Pending proposal count — "## PROPOSAL:" entries in PROPOSALS.md.
5771
+ # ⑤ Pending proposal count — "## PROPOSAL:" entries in .roll/proposals.md.
5239
5772
  _dash_proposal_count() {
5240
- [[ -f "PROPOSALS.md" ]] || { echo 0; return; }
5241
- grep '^## PROPOSAL' PROPOSALS.md 2>/dev/null | wc -l | tr -d ' '
5773
+ [[ -f ".roll/proposals.md" ]] || { echo 0; return; }
5774
+ grep '^## PROPOSAL' .roll/proposals.md 2>/dev/null | wc -l | tr -d ' '
5242
5775
  }
5243
5776
 
5244
5777
  # ⑤ Release-ready signal — true iff there are releasable commits since the
@@ -5256,7 +5789,7 @@ _dash_release_ready() {
5256
5789
  | wc -l | tr -d ' ')
5257
5790
  [[ "${commits_with_code:-0}" -gt 0 ]] || return 1
5258
5791
  local latest
5259
- latest=$(ls docs/briefs/*.md 2>/dev/null | sort | tail -1 || true)
5792
+ latest=$(ls .roll/briefs/*.md 2>/dev/null | sort | tail -1 || true)
5260
5793
  [[ -z "$latest" ]] && return 1
5261
5794
  grep -qE '✅ 可发版|Release ready' "$latest" 2>/dev/null
5262
5795
  }
@@ -5434,7 +5967,7 @@ _legacy_home() {
5434
5967
  printf " ${GREEN}✓ AI 自驱中 — 无需介入${NC}\n"
5435
5968
  else
5436
5969
  (( alerts > 0 )) && printf " ${RED}⚠ %s ALERT${NC} run: roll alert\n" "$alerts"
5437
- (( proposals > 0 )) && printf " ${YELLOW}📋 %s PROPOSAL${NC} see: PROPOSALS.md\n" "$proposals"
5970
+ (( proposals > 0 )) && printf " ${YELLOW}📋 %s PROPOSAL${NC} see: .roll/proposals.md\n" "$proposals"
5438
5971
  $release_ready && printf " ${GREEN}✓ Release ready${NC} run: roll release\n"
5439
5972
  fi
5440
5973
  echo ""
@@ -5443,7 +5976,7 @@ _legacy_home() {
5443
5976
  printf " ${BOLD}⏰ Schedules & Last Brief${NC}\n"
5444
5977
  printf " loop :%02d · dream %02d:%02d · brief %02d:%02d\n" \
5445
5978
  "$loop_minute" "$dream_hour" "$dream_minute" "$brief_hour" "$brief_minute"
5446
- local latest_brief; latest_brief=$(ls docs/briefs/*.md 2>/dev/null | sort | tail -1 || true)
5979
+ local latest_brief; latest_brief=$(ls .roll/briefs/*.md 2>/dev/null | sort | tail -1 || true)
5447
5980
  if [[ -n "$latest_brief" ]]; then
5448
5981
  local mod_time now age summary
5449
5982
  mod_time=$(stat -c %Y "$latest_brief" 2>/dev/null || stat -f %m "$latest_brief" 2>/dev/null || echo 0)
@@ -5483,7 +6016,7 @@ _legacy_help() {
5483
6016
  echo "Commands:"
5484
6017
  echo " setup [-f] [Machine] First-time install or re-sync 首次安装或重新同步"
5485
6018
  echo " update [Upgrade] npm install latest + re-sync 一键升级到最新版"
5486
- echo " init [Project] Create AGENTS.md + BACKLOG.md + docs/ 初始化项目工作流文件"
6019
+ echo " init [Project] Create AGENTS.md + .roll/backlog.md + docs/ 初始化项目工作流文件"
5487
6020
  echo " status [Diagnostic] Show current state 显示当前状态"
5488
6021
  echo " peer [Peer Review] Cross-agent negotiation 跨 Agent 协商对审"
5489
6022
  echo " loop <on|off|now|status|monitor|resume|reset> [Autonomous] Manage scheduled BACKLOG executor 管理自主执行循环"
@@ -5517,14 +6050,83 @@ _help() {
5517
6050
  fi
5518
6051
  }
5519
6052
 
6053
+ # ═══════════════════════════════════════════════════════════════════════════════
6054
+ # _check_structure — US-ONBOARD-004
6055
+ #
6056
+ # Refuse to run project commands on legacy directory structure. Pushes users
6057
+ # toward `roll migrate` rather than letting commands silently operate on old
6058
+ # paths and produce confusing results.
6059
+ #
6060
+ # Exempt commands (always allowed regardless of structure):
6061
+ # setup, update, version/--version/-v, help/--help/-h, migrate, doctor
6062
+ # "" (no command — shows home/help)
6063
+ # init — has its own structure-aware logic inside cmd_init
6064
+ #
6065
+ # Detection walks from pwd up to git root (or stays at pwd if not in a git repo).
6066
+ # Decision: old structure markers present AND no .roll/ → refuse.
6067
+ #
6068
+ # Bypass: ROLL_SKIP_STRUCTURE_CHECK=1 (used by integration tests until Story 5
6069
+ # migrates the test fixtures to new structure).
6070
+ # ═══════════════════════════════════════════════════════════════════════════════
6071
+ _check_structure() {
6072
+ [[ "${ROLL_SKIP_STRUCTURE_CHECK:-0}" == "1" ]] && return 0
6073
+
6074
+ local cmd="$1"
6075
+ case "$cmd" in
6076
+ setup|update|migrate|doctor|version|--version|-v|help|--help|-h|"") return 0 ;;
6077
+ init) return 0 ;; # cmd_init handles its own structure logic
6078
+ esac
6079
+
6080
+ # Determine project root: git root if available, else pwd
6081
+ local root
6082
+ if root=$(git rev-parse --show-toplevel 2>/dev/null); then
6083
+ :
6084
+ else
6085
+ root="$(pwd -P)"
6086
+ fi
6087
+
6088
+ # If new structure exists, allow
6089
+ [[ -d "$root/.roll" ]] && return 0
6090
+
6091
+ # Check for legacy structure markers (literal old-path strings — must NOT be
6092
+ # rewritten during Story 5 code-ref migration; this is the detection logic
6093
+ # for pre-2.0 user projects).
6094
+ if [[ -f "$root/BACKLOG.md" ]] \
6095
+ || [[ -f "$root/PROPOSALS.md" ]] \
6096
+ || [[ -d "$root/docs/features" ]] \
6097
+ || [[ -d "$root/docs/briefs" ]] \
6098
+ || [[ -d "$root/docs/dream" ]]; then
6099
+ err "Legacy structure detected at: $root 发现老结构目录"
6100
+ echo "" >&2
6101
+ echo " This project uses the pre-2.0 layout (BACKLOG.md / docs/*). Roll 2.0 requires" >&2
6102
+ echo " process artifacts to live in .roll/. Run the migration to upgrade:" >&2
6103
+ echo "" >&2
6104
+ echo " roll migrate --dry-run # Preview changes" >&2
6105
+ echo " roll migrate # Execute (single atomic commit)" >&2
6106
+ echo "" >&2
6107
+ echo " Migration guide: ${ROLL_PKG_DIR}/guide/en/migration-2.0.md" >&2
6108
+ echo "" >&2
6109
+ echo " To roll back to Roll 1.x temporarily:" >&2
6110
+ echo " npm install -g @seanyao/roll@1" >&2
6111
+ exit 1
6112
+ fi
6113
+
6114
+ # No structure detected — empty project or non-Roll dir. Allow.
6115
+ return 0
6116
+ }
6117
+
5520
6118
  main() {
5521
6119
  local cmd="${1:-}"
5522
6120
  shift || true
5523
6121
 
6122
+ # US-ONBOARD-004: refuse to run project commands on legacy structure
6123
+ _check_structure "$cmd"
6124
+
5524
6125
  case "$cmd" in
5525
6126
  setup) cmd_setup "$@" ;;
5526
6127
  update) cmd_update "$@" ;;
5527
6128
  init) cmd_init "$@" ;;
6129
+ migrate) cmd_migrate "$@" ;;
5528
6130
  status) cmd_status "$@" ;;
5529
6131
  peer) cmd_peer "$@" ;;
5530
6132
  loop) cmd_loop "$@" ;;
@@ -5537,7 +6139,7 @@ main() {
5537
6139
  review-pr) cmd_review_pr "$@" ;;
5538
6140
  version|--version|-v) echo "roll v${VERSION}" ;;
5539
6141
  help|--help|-h) _help "$@" ;;
5540
- "") [[ -f "BACKLOG.md" ]] && _home || { _help; _show_changelog; } ;;
6142
+ "") [[ -f ".roll/backlog.md" ]] && _home || { _help; _show_changelog; } ;;
5541
6143
  *)
5542
6144
  err "Unknown command: $cmd 未知命令: $cmd"
5543
6145
  echo ""