@seanyao/roll 2026.521.2 → 2026.522.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.521.2"
7
+ VERSION="2026.522.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"
@@ -100,7 +100,7 @@ config_get() {
100
100
  local default="${2:-}"
101
101
  if [[ -f "$ROLL_CONFIG" ]]; then
102
102
  local val
103
- val=$(grep -E "^${key}:" "$ROLL_CONFIG" 2>/dev/null | head -1 | sed 's/^[^:]*:[[:space:]]*//' | sed 's/[[:space:]]*$//')
103
+ val=$(grep -E "^${key}:" "$ROLL_CONFIG" 2>/dev/null | head -1 | sed 's/^[^:]*:[[:space:]]*//' | sed 's/[[:space:]]*#.*$//' | sed 's/[[:space:]]*$//')
104
104
  if [[ -n "$val" ]]; then
105
105
  echo "${val/#\~/$HOME}"
106
106
  return
@@ -1070,6 +1070,9 @@ cmd_init() {
1070
1070
  _write_backlog "$project_dir/.roll/backlog.md"
1071
1071
  _ensure_features_dir "$project_dir/.roll/features"
1072
1072
  _write_features_md "$project_dir/.roll/features.md"
1073
+ # US-ONBOARD-019: stamp the project so legacy detection can recognise it
1074
+ # as Roll-onboarded without depending on directory-name heuristics.
1075
+ _write_version_stamp "$project_dir"
1073
1076
  } >/dev/null
1074
1077
 
1075
1078
  local sync_status="ok"
@@ -1196,8 +1199,9 @@ _init_is_legacy_project() {
1196
1199
  }
1197
1200
 
1198
1201
  # US-ONBOARD-006: Agent discovery + token consumption notice + onboard guidance.
1199
- # Reuses _for_each_ai_tool to enumerate installed AI agents. Tells the user
1200
- # which agent to open, why their tokens will be used, and what to do next.
1202
+ # US-ONBOARD-018: also auto-launches the chosen agent in interactive mode with
1203
+ # the $roll-onboard skill content pre-loaded as the initial prompt, then chains
1204
+ # into `roll init --apply` when the conversation ends successfully.
1201
1205
  _init_legacy_onboard_guide() {
1202
1206
  local project_dir="$1"
1203
1207
  local count_summary
@@ -1206,40 +1210,25 @@ _init_legacy_onboard_guide() {
1206
1210
  info "Detected: legacy project (${count_summary}) 检测到遗留项目"
1207
1211
  echo ""
1208
1212
 
1209
- # Enumerate installed agents by parsing ROLL_CONFIG inline.
1210
- # Output format in config: ai_<name>: <dir>|<config>|<src>
1211
- local installed=()
1212
- local missing=()
1213
- local _key _value _name _dir
1214
- while IFS=: read -r _key _value; do
1215
- [[ "$_key" =~ ^ai_ ]] || continue
1216
- _name="${_key#ai_}"
1217
- _dir="${_value%%|*}"
1218
- _dir="${_dir# }" # trim leading space
1219
- _dir="${_dir/#\~/$HOME}" # expand ~
1220
- if [[ -d "$_dir" ]]; then
1221
- installed+=("$_name")
1222
- else
1223
- missing+=("$_name")
1224
- fi
1225
- done < "$ROLL_CONFIG"
1213
+ # Discover installed agents (writes to globals: _ONBOARD_INSTALLED, _ONBOARD_MISSING).
1214
+ _onboard_discover_agents
1226
1215
 
1227
1216
  echo " Onboarding 需要一个 AI agent 来读懂这个项目。检测到:"
1228
1217
  echo " Onboarding requires an AI agent to read your code. Detected:"
1229
1218
  echo ""
1230
1219
  local n
1231
- if [[ ${#installed[@]} -gt 0 ]]; then
1232
- for n in "${installed[@]}"; do
1220
+ if [[ ${#_ONBOARD_INSTALLED[@]} -gt 0 ]]; then
1221
+ for n in "${_ONBOARD_INSTALLED[@]}"; do
1233
1222
  printf " %b✓%b %s (installed)\n" "${GREEN}" "${NC}" "$n"
1234
1223
  done
1235
1224
  fi
1236
- if [[ ${#missing[@]} -gt 0 ]]; then
1237
- for n in "${missing[@]}"; do
1225
+ if [[ ${#_ONBOARD_MISSING[@]} -gt 0 ]]; then
1226
+ for n in "${_ONBOARD_MISSING[@]}"; do
1238
1227
  printf " %b✗%b %s (not found)\n" "${RED}" "${NC}" "$n"
1239
1228
  done
1240
1229
  fi
1241
1230
 
1242
- if [[ ${#installed[@]} -eq 0 ]]; then
1231
+ if [[ ${#_ONBOARD_INSTALLED[@]} -eq 0 ]]; then
1243
1232
  echo ""
1244
1233
  err "No AI agent detected. Install one (e.g., 'claude', 'codex', 'kimi') and try again."
1245
1234
  err "未检测到 AI agent。请先安装 (如 claude / codex / kimi) 后重试。"
@@ -1253,17 +1242,169 @@ _init_legacy_onboard_guide() {
1253
1242
  echo " 代码与对话都留在你的 agent 工具里 —— Roll 本身不上传任何内容。"
1254
1243
  echo " Your code and conversation stay in your agent — Roll never uploads anything."
1255
1244
  echo ""
1256
- echo " 下一步 Next step:"
1245
+
1246
+ # US-ONBOARD-018: select an agent. Single installed → auto-pick. Multiple
1247
+ # installed → ask the user (or honour ROLL_ONBOARD_AGENT for non-interactive
1248
+ # callers and tests).
1249
+ local chosen
1250
+ chosen=$(_onboard_select_agent "${_ONBOARD_INSTALLED[@]}") || return 1
1251
+ [[ -n "$chosen" ]] || return 1
1252
+
1253
+ echo ""
1254
+ info "Launching ${chosen}… 正在启动 ${chosen}…"
1255
+ echo " Conversation ends with /exit (or Ctrl-C). On exit Roll will run apply for you."
1256
+ echo " 在 agent 内用 /exit 结束(或 Ctrl-C)。退出后 Roll 会自动衔接 apply。"
1257
1257
  echo ""
1258
- if [[ ${#installed[@]} -eq 1 ]]; then
1259
- echo " Open ${installed[0]} and run: $roll-onboard"
1258
+
1259
+ # US-ONBOARD-018: actually run the agent with the onboard prompt pre-loaded.
1260
+ _run_onboard_agent "$chosen" "$project_dir"
1261
+ }
1262
+
1263
+ # US-ONBOARD-018: discover installed AI agents from ROLL_CONFIG.
1264
+ # Populates global arrays _ONBOARD_INSTALLED and _ONBOARD_MISSING.
1265
+ # Extracted from _init_legacy_onboard_guide so it can be unit-tested.
1266
+ _onboard_discover_agents() {
1267
+ _ONBOARD_INSTALLED=()
1268
+ _ONBOARD_MISSING=()
1269
+ local _key _value _name _dir
1270
+ while IFS=: read -r _key _value; do
1271
+ [[ "$_key" =~ ^ai_ ]] || continue
1272
+ _name="${_key#ai_}"
1273
+ _dir="${_value%%|*}"
1274
+ _dir="${_dir# }"
1275
+ _dir="${_dir/#\~/$HOME}"
1276
+ if [[ -d "$_dir" ]]; then
1277
+ _ONBOARD_INSTALLED+=("$_name")
1278
+ else
1279
+ _ONBOARD_MISSING+=("$_name")
1280
+ fi
1281
+ done < "$ROLL_CONFIG"
1282
+ }
1283
+
1284
+ # US-ONBOARD-018: pick an agent for the onboard flow.
1285
+ # - $ROLL_ONBOARD_AGENT (env) wins if set and present in the candidate list.
1286
+ # - Single candidate → auto-pick (printed to stdout).
1287
+ # - Multiple candidates → prompt user for a number. Stdin EOF / invalid input → return 1.
1288
+ # Echoes the chosen agent name on stdout, nothing else.
1289
+ _onboard_select_agent() {
1290
+ local -a candidates=("$@")
1291
+ [[ ${#candidates[@]} -gt 0 ]] || return 1
1292
+
1293
+ # Explicit override (tests, CI, dotfile aliases).
1294
+ if [[ -n "${ROLL_ONBOARD_AGENT:-}" ]]; then
1295
+ local c
1296
+ for c in "${candidates[@]}"; do
1297
+ if [[ "$c" == "$ROLL_ONBOARD_AGENT" ]]; then
1298
+ printf '%s\n' "$c"
1299
+ return 0
1300
+ fi
1301
+ done
1302
+ err "ROLL_ONBOARD_AGENT='${ROLL_ONBOARD_AGENT}' is not in installed agents." >&2
1303
+ return 1
1304
+ fi
1305
+
1306
+ if [[ ${#candidates[@]} -eq 1 ]]; then
1307
+ printf '%s\n' "${candidates[0]}"
1308
+ return 0
1309
+ fi
1310
+
1311
+ # Multi-agent: prompt the user. To stderr so stdout stays clean for the caller.
1312
+ {
1313
+ echo " 选一个 agent Pick an agent:"
1314
+ local i=1
1315
+ for c in "${candidates[@]}"; do
1316
+ printf " %d) %s\n" "$i" "$c"
1317
+ i=$((i + 1))
1318
+ done
1319
+ printf " Enter number [1-%d]: " "${#candidates[@]}"
1320
+ } >&2
1321
+
1322
+ local choice
1323
+ if ! IFS= read -r choice; then
1324
+ err "No input received. Aborting onboard. 未收到输入,已取消 onboard。" >&2
1325
+ return 1
1326
+ fi
1327
+ if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#candidates[@]} )); then
1328
+ err "Invalid choice: '${choice}'. 无效选择。" >&2
1329
+ return 1
1330
+ fi
1331
+ printf '%s\n' "${candidates[$((choice - 1))]}"
1332
+ }
1333
+
1334
+ # US-ONBOARD-018: compose the initial prompt for the onboard agent —
1335
+ # the $roll-onboard skill body (frontmatter stripped) plus a kickoff line.
1336
+ # Returns 1 if the skill file is missing.
1337
+ _onboard_initial_prompt() {
1338
+ local skill_file="${ROLL_PKG_DIR}/skills/roll-onboard/SKILL.md"
1339
+ [[ -f "$skill_file" ]] || {
1340
+ err "Skill file missing: ${skill_file}" >&2
1341
+ return 1
1342
+ }
1343
+ # Lead line orients the agent before the skill body. Keep it stable; tests
1344
+ # match on this exact prefix.
1345
+ printf '%s\n\n' "Run the \$roll-onboard skill below for this project. Follow it end-to-end and write .roll/onboard-plan.yaml when done."
1346
+ _skill_content "$skill_file"
1347
+ }
1348
+
1349
+ # US-ONBOARD-018: print retry / switch-agent guidance after a failed onboard run.
1350
+ # Extracted so we can unit-test the wording without spawning subprocesses.
1351
+ # $1 = chosen agent name, $2 = exit code from the agent.
1352
+ _onboard_failure_hint() {
1353
+ local agent="$1" code="$2"
1354
+ echo "" >&2
1355
+ if [[ "$code" == "130" ]]; then
1356
+ err "Onboard cancelled (Ctrl-C). Onboard 已取消(Ctrl-C)。" >&2
1260
1357
  else
1261
- echo " Open any installed agent and run: \$roll-onboard"
1358
+ err "Agent '${agent}' exited with code ${code}. agent '${agent}' 异常退出 (code ${code})。" >&2
1359
+ fi
1360
+ echo "" >&2
1361
+ echo " 下一步 Next step:" >&2
1362
+ echo " - 再试一次同一个 agent: roll init" >&2
1363
+ echo " - retry the same agent: roll init" >&2
1364
+ echo " - 换一个 agent: ROLL_ONBOARD_AGENT=<name> roll init" >&2
1365
+ echo " - switch to another: ROLL_ONBOARD_AGENT=<name> roll init" >&2
1366
+ echo "" >&2
1367
+ }
1368
+
1369
+ # US-ONBOARD-018: launch the chosen agent in interactive mode with the onboard
1370
+ # prompt pre-loaded, wait for it to exit, then branch:
1371
+ # exit 0 + .roll/onboard-plan.yaml present → chain into roll init --apply
1372
+ # exit 0 + plan missing → tell user to re-run if they want
1373
+ # exit !0 (incl. 130 from SIGINT) → print retry / switch-agent hint
1374
+ # $1 = chosen agent name, $2 = project dir
1375
+ _run_onboard_agent() {
1376
+ local agent="$1" project_dir="$2"
1377
+ local prompt
1378
+ prompt=$(_onboard_initial_prompt) || return 1
1379
+ _agent_argv "$agent" interactive "$prompt" || {
1380
+ err "Agent '${agent}' has no interactive mode wired up. agent '${agent}' 暂未接入 interactive 模式。" >&2
1381
+ return 1
1382
+ }
1383
+
1384
+ # Run attached to the user's tty so the agent's REPL gets stdin/stdout/stderr.
1385
+ # `set -e` is active script-wide; suppress with `|| rc=$?` so the failure
1386
+ # branch (SIGINT 130, agent error) can be handled instead of aborting init.
1387
+ local rc=0
1388
+ "${_AGENT_ARGV[@]}" || rc=$?
1389
+
1390
+ if [[ "$rc" -ne 0 ]]; then
1391
+ _onboard_failure_hint "$agent" "$rc"
1392
+ return "$rc"
1262
1393
  fi
1263
- echo ""
1264
- echo " 完成对话后回到这里运行 After the conversation, return and run:"
1265
- echo " roll init --apply"
1266
- echo ""
1394
+
1395
+ if [[ ! -f "${project_dir}/.roll/onboard-plan.yaml" ]]; then
1396
+ echo "" >&2
1397
+ err "Agent exited cleanly but did not write .roll/onboard-plan.yaml." >&2
1398
+ err "agent 正常退出但未生成 .roll/onboard-plan.yaml。" >&2
1399
+ echo " Re-run \`roll init\` once you've completed the conversation." >&2
1400
+ echo " 对话完成后再次运行 \`roll init\` 即可。" >&2
1401
+ return 1
1402
+ fi
1403
+
1404
+ # Plan present → chain into apply automatically.
1405
+ echo "" >&2
1406
+ info "Plan written. Running apply… 已写入 plan,正在执行 apply…"
1407
+ ( cd "$project_dir" && _init_apply )
1267
1408
  }
1268
1409
 
1269
1410
  # Helper: human-readable summary of why this is detected as legacy.
@@ -1424,6 +1565,15 @@ print(' '.join(p.get('scope', {}).get('approved', [])))
1424
1565
  _merge_global_to_project "$project_dir"
1425
1566
  _merge_claude_to_project "$project_dir"
1426
1567
 
1568
+ # US-ONBOARD-019: stamp the project at onboard-apply time so subsequent
1569
+ # invocations recognise it as Roll-onboarded (and offboard can sweep it).
1570
+ local _stamp_existed=true
1571
+ [[ -f "$project_dir/.roll/.version" ]] || _stamp_existed=false
1572
+ _write_version_stamp "$project_dir"
1573
+ if [[ "$_stamp_existed" == "false" ]] && [[ -f "$project_dir/.roll/.version" ]]; then
1574
+ _onboard_changeset_record "$project_dir" "files_created" ".roll/.version"
1575
+ fi
1576
+
1427
1577
  # Create .roll/ artifacts based on scope.approved
1428
1578
  if [[ " $approved " == *" backlog "* ]]; then
1429
1579
  _write_backlog "$project_dir/.roll/backlog.md"
@@ -1946,6 +2096,88 @@ _ensure_features_dir() {
1946
2096
  _ROLL_MERGE_SUMMARY+=("created|.roll/features/")
1947
2097
  }
1948
2098
 
2099
+ # US-ONBOARD-019: write a Roll version stamp under .roll/.version when a project
2100
+ # is onboarded. Going forward, this stamp is the canonical "this project was
2101
+ # onboarded with Roll" signal — it lets `_check_structure` distinguish a
2102
+ # genuine pre-2.0 Roll project (needs migrate) from a non-Roll project that
2103
+ # coincidentally has BACKLOG.md / docs/features/ from another tool.
2104
+ #
2105
+ # Idempotent: an existing stamp is never overwritten, so the original install
2106
+ # timestamp is preserved across re-runs of `roll init`.
2107
+ _write_version_stamp() {
2108
+ local project_dir="$1"
2109
+ local stamp_path="$project_dir/.roll/.version"
2110
+ if [[ -f "$stamp_path" ]]; then
2111
+ _ROLL_MERGE_SUMMARY+=("unchanged|.roll/.version")
2112
+ return 0
2113
+ fi
2114
+ mkdir -p "$project_dir/.roll"
2115
+ local installed_at; installed_at=$(date -u +%FT%TZ)
2116
+ cat > "$stamp_path" <<EOF
2117
+ # Roll project version stamp — written by \`roll init\` (US-ONBOARD-019).
2118
+ # Used by \`_check_structure\` to recognise a previously-onboarded Roll project
2119
+ # without depending on directory-name heuristics.
2120
+ roll_version: "${VERSION}"
2121
+ installed_at: "${installed_at}"
2122
+ EOF
2123
+ _ROLL_MERGE_SUMMARY+=("created|.roll/.version")
2124
+ return 0
2125
+ }
2126
+
2127
+ # US-ONBOARD-019: is <root> a Roll-onboarded project (current or pre-2.0)?
2128
+ #
2129
+ # Returns 0 (true) when at least one Roll-specific signal is present:
2130
+ # 1. .roll/.version stamp (post-019 onboard)
2131
+ # 2. BACKLOG.md with a Roll-1.x Story table or "Bug Fixes" section
2132
+ # 3. PROPOSALS.md with a Roll-style "## Proposal" heading
2133
+ # 4. docs/features/ containing US-/FIX-/REFACTOR- named .md files
2134
+ # 5. docs/briefs/ or docs/dream/ directory non-empty
2135
+ #
2136
+ # A bare BACKLOG.md/PROPOSALS.md from another tool, or a generic
2137
+ # docs/features/ folder, does NOT count — that's the bug US-ONBOARD-019
2138
+ # fixes (false-positive migrate prompts on non-Roll projects).
2139
+ _has_roll_signature() {
2140
+ local root="$1"
2141
+
2142
+ # Signal 1 — post-019 version stamp
2143
+ [[ -f "$root/.roll/.version" ]] && return 0
2144
+
2145
+ # Signal 2 — Roll-1.x BACKLOG.md content
2146
+ if [[ -f "$root/BACKLOG.md" ]]; then
2147
+ if grep -qE '^\| Story \| Description \| Status \|' "$root/BACKLOG.md" 2>/dev/null \
2148
+ || grep -qE '^## Epic:' "$root/BACKLOG.md" 2>/dev/null \
2149
+ || grep -qE '^\| ID \| Problem \| Status \|' "$root/BACKLOG.md" 2>/dev/null; then
2150
+ return 0
2151
+ fi
2152
+ fi
2153
+
2154
+ # Signal 3 — Roll-style PROPOSALS.md
2155
+ if [[ -f "$root/PROPOSALS.md" ]]; then
2156
+ if grep -qE '^## Proposal' "$root/PROPOSALS.md" 2>/dev/null; then
2157
+ return 0
2158
+ fi
2159
+ fi
2160
+
2161
+ # Signal 4 — Roll-named files under docs/features/
2162
+ if [[ -d "$root/docs/features" ]]; then
2163
+ if find "$root/docs/features" -maxdepth 2 -type f -name '*.md' 2>/dev/null \
2164
+ | grep -qE '/(US|FIX|REFACTOR)-[0-9]+'; then
2165
+ return 0
2166
+ fi
2167
+ fi
2168
+
2169
+ # Signal 5 — Roll-1.x process artefacts (docs/briefs/ docs/dream/)
2170
+ local dir
2171
+ for dir in docs/briefs docs/dream; do
2172
+ if [[ -d "$root/$dir" ]] \
2173
+ && [[ -n "$(find "$root/$dir" -mindepth 1 -maxdepth 2 -type f 2>/dev/null | head -1)" ]]; then
2174
+ return 0
2175
+ fi
2176
+ done
2177
+
2178
+ return 1
2179
+ }
2180
+
1949
2181
  # ─── Helper: write starter .roll/features.md (no-op if exists) ────────────────
1950
2182
  _write_features_md() {
1951
2183
  if [[ -f "$1" ]]; then
@@ -2708,21 +2940,48 @@ _parse_review_verdict() {
2708
2940
  # Returns 1 on unknown agent. Adding a new agent only needs an entry here.
2709
2941
  _agent_argv() {
2710
2942
  local agent="$1" mode="$2" prompt="$3"
2943
+ # US-ONBOARD-018: `interactive` mode launches the agent's REPL with the prompt
2944
+ # pre-loaded as the first user message. The user can then converse normally,
2945
+ # keep their tty, and exit with Ctrl-C or /exit. Used by `roll init` to auto-
2946
+ # start the chosen agent for $roll-onboard without a copy-paste handoff.
2947
+ # Convention: positional arg as initial prompt; no -p / exec / run / --quiet.
2711
2948
  case "$agent" in
2712
2949
  claude)
2713
2950
  case "$mode" in
2714
- text|peer) _AGENT_ARGV=(claude -p --output-format text "$prompt") ;;
2715
- *) _AGENT_ARGV=(claude -p "$prompt") ;;
2951
+ interactive) _AGENT_ARGV=(claude "$prompt") ;;
2952
+ text|peer) _AGENT_ARGV=(claude -p --output-format text "$prompt") ;;
2953
+ *) _AGENT_ARGV=(claude -p "$prompt") ;;
2954
+ esac ;;
2955
+ kimi)
2956
+ case "$mode" in
2957
+ interactive) _AGENT_ARGV=(kimi "$prompt") ;;
2958
+ *) _AGENT_ARGV=(kimi --quiet -p "$prompt") ;;
2959
+ esac ;;
2960
+ deepseek)
2961
+ # deepseek has the same argv shape in both modes (positional prompt).
2962
+ _AGENT_ARGV=(deepseek "$prompt") ;;
2963
+ pi)
2964
+ case "$mode" in
2965
+ interactive) _AGENT_ARGV=(pi "$prompt") ;;
2966
+ *) _AGENT_ARGV=(pi -p "$prompt") ;;
2716
2967
  esac ;;
2717
- kimi) _AGENT_ARGV=(kimi --quiet -p "$prompt") ;;
2718
- deepseek) _AGENT_ARGV=(deepseek "$prompt") ;;
2719
- pi) _AGENT_ARGV=(pi -p "$prompt") ;;
2720
2968
  codex)
2721
2969
  case "$mode" in
2722
- peer) _AGENT_ARGV=(codex exec --json --output-last-message "$prompt") ;;
2723
- *) _AGENT_ARGV=(codex exec "$prompt") ;;
2970
+ interactive) _AGENT_ARGV=(codex "$prompt") ;;
2971
+ peer) _AGENT_ARGV=(codex exec --json --output-last-message "$prompt") ;;
2972
+ *) _AGENT_ARGV=(codex exec "$prompt") ;;
2973
+ esac ;;
2974
+ opencode)
2975
+ case "$mode" in
2976
+ interactive) _AGENT_ARGV=(opencode "$prompt") ;;
2977
+ *) _AGENT_ARGV=(opencode run "$prompt") ;;
2978
+ esac ;;
2979
+ gemini)
2980
+ # gemini integration is interactive-only for now (used by onboard flow).
2981
+ case "$mode" in
2982
+ interactive) _AGENT_ARGV=(gemini "$prompt") ;;
2983
+ *) return 1 ;;
2724
2984
  esac ;;
2725
- opencode) _AGENT_ARGV=(opencode run "$prompt") ;;
2726
2985
  *) return 1 ;;
2727
2986
  esac
2728
2987
  }
@@ -2766,6 +3025,463 @@ _agent_run_skill() {
2766
3025
  "${_AGENT_ARGV[@]}"
2767
3026
  }
2768
3027
 
3028
+ # ═══════════════════════════════════════════════════════════════════════════════
3029
+ # SLIDES — deck.md → HTML rendering pipeline (US-DECK-003 / 004 / 005)
3030
+ #
3031
+ # roll slides build <slug> render .roll/slides/<slug>/deck.md → .html, open
3032
+ # roll slides new "<topic>" invoke selected agent with roll-deck skill (US-DECK-004)
3033
+ # roll slides list list decks as a table (US-DECK-005)
3034
+ # roll slides preview <slug> open .roll/slides/<slug>.html in browser (US-DECK-005)
3035
+ #
3036
+ # All four subcommands are implemented: `build` (DECK-003), `new` (DECK-004),
3037
+ # `list` / `preview` (DECK-005). The AI authoring step happens in `new`;
3038
+ # everything else is pure bash.
3039
+ # ═══════════════════════════════════════════════════════════════════════════════
3040
+ _slides_help() {
3041
+ cat <<'EOF'
3042
+ roll slides — deck.md → HTML rendering
3043
+ roll slides — 幻灯片 deck.md 渲染管线
3044
+
3045
+ USAGE 用法
3046
+ roll slides build <slug> [--no-open]
3047
+ Render .roll/slides/<slug>/deck.md → .roll/slides/<slug>.html
3048
+ 渲染 deck.md 为 HTML 并自动打开浏览器
3049
+ roll slides new "<topic>" [--template <name>]
3050
+ Generate a new deck.md from a topic via the selected AI agent
3051
+ 通过所选 AI agent 根据主题生成新的 deck.md
3052
+ roll slides list List all decks under .roll/slides/ as a table
3053
+ 列出 .roll/slides/ 下所有幻灯片
3054
+ roll slides preview <slug> [--no-open]
3055
+ Open .roll/slides/<slug>.html in the default browser
3056
+ 在浏览器中打开已渲染的幻灯片
3057
+
3058
+ OPTIONS 选项
3059
+ --no-open Skip auto-opening the rendered HTML in a browser
3060
+ 渲染后不自动打开浏览器
3061
+ --help, -h Show this help
3062
+ 显示本帮助
3063
+ EOF
3064
+ }
3065
+
3066
+ # Resolve the renderer / validator paths (shipped with the roll package).
3067
+ _slides_lib() {
3068
+ printf '%s' "${ROLL_PKG_DIR}/lib"
3069
+ }
3070
+
3071
+ # Resolve the template path for a given template name.
3072
+ # Returns 0 + prints the path if the template exists, else returns 1.
3073
+ _slides_template_path() {
3074
+ local name="$1"
3075
+ local tpl="${ROLL_PKG_DIR}/site/slides/templates/${name}.html"
3076
+ if [[ -f "$tpl" ]]; then
3077
+ printf '%s' "$tpl"
3078
+ return 0
3079
+ fi
3080
+ return 1
3081
+ }
3082
+
3083
+ # Read the `template:` value from a deck.md frontmatter. Defaults to
3084
+ # `introduction-v3` if the field is absent (validator will catch missing field
3085
+ # separately).
3086
+ _slides_template_for_deck() {
3087
+ local deck="$1"
3088
+ local tpl
3089
+ tpl=$(awk '
3090
+ /^---[[:space:]]*$/ { d++; if (d==2) exit; next }
3091
+ d==1 && /^template:[[:space:]]*/ {
3092
+ sub(/^template:[[:space:]]*/, "")
3093
+ gsub(/^["'\'']|["'\'']$/, "")
3094
+ print
3095
+ exit
3096
+ }
3097
+ ' "$deck" 2>/dev/null)
3098
+ [[ -n "$tpl" ]] || tpl="introduction-v3"
3099
+ printf '%s' "$tpl"
3100
+ }
3101
+
3102
+ # Ensure .roll/.gitignore contains `slides/*.html` so the per-build HTML
3103
+ # artefact is ignored by default. deck.md remains committable. Idempotent.
3104
+ _slides_ensure_gitignore() {
3105
+ local gi=".roll/.gitignore"
3106
+ mkdir -p ".roll"
3107
+ if [[ -f "$gi" ]] && grep -qE '^slides/\*\.html$' "$gi" 2>/dev/null; then
3108
+ return 0
3109
+ fi
3110
+ # Preserve a trailing newline before appending.
3111
+ if [[ -f "$gi" ]] && [[ -s "$gi" ]] && [[ "$(tail -c 1 "$gi" 2>/dev/null)" != $'\n' ]]; then
3112
+ printf '\n' >>"$gi"
3113
+ fi
3114
+ printf 'slides/*.html\n' >>"$gi"
3115
+ }
3116
+
3117
+ # Pick the browser-open command for the current OS. Echoes the command name;
3118
+ # returns 1 if no opener is available (tests + headless CI).
3119
+ _slides_open_cmd() {
3120
+ case "$(uname -s 2>/dev/null)" in
3121
+ Darwin) command -v open >/dev/null 2>&1 && { printf 'open'; return 0; } ;;
3122
+ Linux) command -v xdg-open >/dev/null 2>&1 && { printf 'xdg-open'; return 0; } ;;
3123
+ esac
3124
+ return 1
3125
+ }
3126
+
3127
+ cmd_slides_build() {
3128
+ local slug="" no_open=0
3129
+ while [[ $# -gt 0 ]]; do
3130
+ case "$1" in
3131
+ --no-open) no_open=1; shift ;;
3132
+ --help|-h) _slides_help; return 0 ;;
3133
+ --*) err "Unknown option: $1 未知选项: $1"; return 1 ;;
3134
+ *)
3135
+ if [[ -z "$slug" ]]; then
3136
+ slug="$1"; shift
3137
+ else
3138
+ err "Unexpected argument: $1 多余参数: $1"; return 1
3139
+ fi
3140
+ ;;
3141
+ esac
3142
+ done
3143
+
3144
+ if [[ -z "$slug" ]]; then
3145
+ err "Usage: roll slides build <slug> [--no-open]"
3146
+ echo "用法: roll slides build <slug> [--no-open]" >&2
3147
+ return 1
3148
+ fi
3149
+
3150
+ local deck=".roll/slides/${slug}/deck.md"
3151
+ if [[ ! -f "$deck" ]]; then
3152
+ err "Deck not found: ${deck}"
3153
+ echo " 未找到 deck 文件:${deck}" >&2
3154
+ echo " Hint: run 'roll slides new \"<topic>\"' to generate a new deck." >&2
3155
+ echo " 提示:先运行 'roll slides new \"<主题>\"' 生成新的幻灯片。" >&2
3156
+ return 1
3157
+ fi
3158
+
3159
+ local lib_dir; lib_dir=$(_slides_lib)
3160
+ local validator="${lib_dir}/slides-validate.py"
3161
+ local renderer="${lib_dir}/slides-render.py"
3162
+ if [[ ! -f "$validator" || ! -f "$renderer" ]]; then
3163
+ err "Slides toolchain missing — re-run 'roll setup' 渲染工具缺失,请运行 roll setup"
3164
+ return 1
3165
+ fi
3166
+
3167
+ # 1. Validate first (fail-fast on AI-generated decks).
3168
+ if ! python3 "$validator" "$deck"; then
3169
+ err "deck.md validation failed — fix the issues above before building."
3170
+ echo " deck.md 校验失败,请先修复上方提示再重试。" >&2
3171
+ return 1
3172
+ fi
3173
+
3174
+ # 2. Resolve template + render.
3175
+ local tpl_name; tpl_name=$(_slides_template_for_deck "$deck")
3176
+ local tpl_path
3177
+ if ! tpl_path=$(_slides_template_path "$tpl_name"); then
3178
+ err "Template not found: ${tpl_name} 未找到模板:${tpl_name}"
3179
+ return 1
3180
+ fi
3181
+
3182
+ local out=".roll/slides/${slug}.html"
3183
+ mkdir -p ".roll/slides"
3184
+ if ! python3 "$renderer" "$deck" "$tpl_path" "$out"; then
3185
+ err "Render failed for ${deck} 渲染失败:${deck}"
3186
+ return 1
3187
+ fi
3188
+
3189
+ # 3. Default-ignore the HTML artefact so it doesn't accidentally get committed.
3190
+ _slides_ensure_gitignore
3191
+
3192
+ ok "Rendered → ${out} 渲染完成 → ${out}"
3193
+
3194
+ # 4. Auto-open browser unless suppressed (or running inside bats tests).
3195
+ if [[ "$no_open" -eq 1 ]] || [[ -n "${BATS_TEST_NUMBER:-}" ]] || [[ -n "${ROLL_SLIDES_NO_OPEN:-}" ]]; then
3196
+ return 0
3197
+ fi
3198
+ local opener
3199
+ if opener=$(_slides_open_cmd); then
3200
+ "$opener" "$out" >/dev/null 2>&1 || true
3201
+ fi
3202
+ return 0
3203
+ }
3204
+
3205
+ # ─── US-DECK-005 ─────────────────────────────────────────────────────────────
3206
+ # Read a frontmatter field from deck.md. Returns empty string if absent.
3207
+ # Stops scanning at the closing `---` so YAML body keys can't leak through.
3208
+ _slides_frontmatter_field() {
3209
+ local deck="$1" field="$2"
3210
+ awk -v field="$field" '
3211
+ /^---[[:space:]]*$/ { d++; if (d==2) exit; next }
3212
+ d==1 {
3213
+ pat = "^" field "[[:space:]]*:[[:space:]]*"
3214
+ if ($0 ~ pat) {
3215
+ sub(pat, "")
3216
+ gsub(/^["'\'']|["'\'']$/, "")
3217
+ print
3218
+ exit
3219
+ }
3220
+ }
3221
+ ' "$deck" 2>/dev/null
3222
+ }
3223
+
3224
+ # Format a byte count for human-friendly display: 1234 → "1.2K", 2345678 → "2.2M".
3225
+ # Bash arithmetic only — no `bc`, no `numfmt` dependency.
3226
+ _slides_human_size() {
3227
+ local bytes="${1:-0}"
3228
+ if [[ "$bytes" -lt 1024 ]]; then
3229
+ printf '%dB' "$bytes"
3230
+ elif [[ "$bytes" -lt 1048576 ]]; then
3231
+ local tenth=$(( (bytes * 10) / 1024 ))
3232
+ printf '%d.%dK' "$((tenth / 10))" "$((tenth % 10))"
3233
+ else
3234
+ local tenth=$(( (bytes * 10) / 1048576 ))
3235
+ printf '%d.%dM' "$((tenth / 10))" "$((tenth % 10))"
3236
+ fi
3237
+ }
3238
+
3239
+ cmd_slides_list() {
3240
+ while [[ $# -gt 0 ]]; do
3241
+ case "$1" in
3242
+ --help|-h) _slides_help; return 0 ;;
3243
+ --*) err "Unknown option: $1 未知选项: $1"; return 1 ;;
3244
+ *) err "Unexpected argument: $1 多余参数: $1"; return 1 ;;
3245
+ esac
3246
+ done
3247
+
3248
+ local slides_dir=".roll/slides"
3249
+ if [[ ! -d "$slides_dir" ]]; then
3250
+ info "No decks found under .roll/slides/ 无幻灯片"
3251
+ echo " Hint: run 'roll slides new \"<topic>\"' to create one."
3252
+ echo " 提示:运行 'roll slides new \"<主题>\"' 创建第一个幻灯片。"
3253
+ return 0
3254
+ fi
3255
+
3256
+ local -a slugs=()
3257
+ local d slug
3258
+ shopt -s nullglob
3259
+ for d in "$slides_dir"/*/; do
3260
+ slug="${d%/}"
3261
+ slug="${slug##*/}"
3262
+ if [[ -f "${d}deck.md" ]]; then
3263
+ slugs+=("$slug")
3264
+ fi
3265
+ done
3266
+ shopt -u nullglob
3267
+
3268
+ if [[ "${#slugs[@]}" -eq 0 ]]; then
3269
+ info "No decks found under .roll/slides/ 无幻灯片"
3270
+ echo " Hint: run 'roll slides new \"<topic>\"' to create one."
3271
+ echo " 提示:运行 'roll slides new \"<主题>\"' 创建第一个幻灯片。"
3272
+ return 0
3273
+ fi
3274
+
3275
+ local -a sorted_slugs
3276
+ IFS=$'\n' sorted_slugs=($(printf '%s\n' "${slugs[@]}" | sort))
3277
+ unset IFS
3278
+
3279
+ printf '%-20s %-20s %-12s %-12s %-5s %s\n' \
3280
+ "slug" "template" "total_slides" "created" "built" "size"
3281
+ printf '%-20s %-20s %-12s %-12s %-5s %s\n' \
3282
+ "----" "--------" "------------" "-------" "-----" "----"
3283
+
3284
+ local s deck html template total created built size bytes
3285
+ for s in "${sorted_slugs[@]}"; do
3286
+ deck="${slides_dir}/${s}/deck.md"
3287
+ html="${slides_dir}/${s}.html"
3288
+ template=$(_slides_frontmatter_field "$deck" "template")
3289
+ [[ -z "$template" ]] && template="-"
3290
+ total=$(_slides_frontmatter_field "$deck" "total_slides")
3291
+ [[ -z "$total" ]] && total="-"
3292
+ created=$(_slides_frontmatter_field "$deck" "created")
3293
+ [[ -z "$created" ]] && created="-"
3294
+ if [[ -f "$html" ]]; then
3295
+ built="✓"
3296
+ bytes=$(wc -c <"$html" 2>/dev/null | tr -d ' ')
3297
+ [[ -z "$bytes" ]] && bytes=0
3298
+ size=$(_slides_human_size "$bytes")
3299
+ else
3300
+ built="✗"
3301
+ size="-"
3302
+ fi
3303
+ printf '%-20s %-20s %-12s %-12s %-5s %s\n' \
3304
+ "$s" "$template" "$total" "$created" "$built" "$size"
3305
+ done
3306
+ return 0
3307
+ }
3308
+
3309
+ cmd_slides_preview() {
3310
+ local slug="" no_open=0
3311
+ while [[ $# -gt 0 ]]; do
3312
+ case "$1" in
3313
+ --no-open) no_open=1; shift ;;
3314
+ --help|-h) _slides_help; return 0 ;;
3315
+ --*) err "Unknown option: $1 未知选项: $1"; return 1 ;;
3316
+ *)
3317
+ if [[ -z "$slug" ]]; then
3318
+ slug="$1"; shift
3319
+ else
3320
+ err "Unexpected argument: $1 多余参数: $1"; return 1
3321
+ fi
3322
+ ;;
3323
+ esac
3324
+ done
3325
+
3326
+ if [[ -z "$slug" ]]; then
3327
+ err "Usage: roll slides preview <slug> [--no-open]"
3328
+ echo "用法: roll slides preview <slug> [--no-open]" >&2
3329
+ return 1
3330
+ fi
3331
+
3332
+ local html=".roll/slides/${slug}.html"
3333
+ if [[ ! -f "$html" ]]; then
3334
+ err "Rendered HTML not found: ${html}"
3335
+ echo " 未找到已渲染的 HTML:${html}" >&2
3336
+ echo " Hint: run 'roll slides build ${slug}' first to render it." >&2
3337
+ echo " 提示:先运行 'roll slides build ${slug}' 渲染幻灯片。" >&2
3338
+ return 1
3339
+ fi
3340
+
3341
+ ok "Preview → ${html} 打开预览 → ${html}"
3342
+
3343
+ if [[ "$no_open" -eq 1 ]] || [[ -n "${BATS_TEST_NUMBER:-}" ]] || [[ -n "${ROLL_SLIDES_NO_OPEN:-}" ]]; then
3344
+ return 0
3345
+ fi
3346
+ local opener
3347
+ if opener=$(_slides_open_cmd); then
3348
+ "$opener" "$html" >/dev/null 2>&1 || true
3349
+ fi
3350
+ return 0
3351
+ }
3352
+
3353
+ # ─── US-DECK-004 ─────────────────────────────────────────────────────────────
3354
+ # Turn a topic string into a kebab-case slug.
3355
+ # Lower-cases, replaces any run of non-alphanumerics with a single dash,
3356
+ # strips leading/trailing dashes. Matches the convention assumed by
3357
+ # `roll slides build <slug>` (lowercase kebab) and the schema.
3358
+ _slides_topic_slug() {
3359
+ local topic="$1"
3360
+ local slug
3361
+ slug=$(printf '%s' "$topic" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-')
3362
+ # Strip leading/trailing dashes.
3363
+ slug="${slug#-}"
3364
+ slug="${slug%-}"
3365
+ printf '%s' "$slug"
3366
+ }
3367
+
3368
+ # `roll slides new "<topic>" [--template <name>]`
3369
+ # AI authoring entry point. Loads the `roll-deck` skill, builds a single text
3370
+ # prompt containing the skill body + topic + slug + template, and hands it
3371
+ # to the selected project agent. The agent is responsible for writing
3372
+ # `.roll/slides/<slug>/deck.md` (and nothing else). After the agent exits,
3373
+ # we print a bilingual hint pointing at `roll slides build <slug>`.
3374
+ cmd_slides_new() {
3375
+ local topic="" template="introduction-v3"
3376
+ while [[ $# -gt 0 ]]; do
3377
+ case "$1" in
3378
+ --template)
3379
+ [[ -n "${2:-}" ]] || { err "--template requires a value --template 需要一个值"; return 1; }
3380
+ template="$2"; shift 2 ;;
3381
+ --template=*) template="${1#--template=}"; shift ;;
3382
+ --help|-h) _slides_help; return 0 ;;
3383
+ --*) err "Unknown option: $1 未知选项: $1"; return 1 ;;
3384
+ *)
3385
+ if [[ -z "$topic" ]]; then
3386
+ topic="$1"; shift
3387
+ else
3388
+ err "Unexpected argument: $1 多余参数: $1"; return 1
3389
+ fi
3390
+ ;;
3391
+ esac
3392
+ done
3393
+
3394
+ if [[ -z "$topic" ]]; then
3395
+ err "Usage: roll slides new \"<topic>\" [--template <name>]"
3396
+ echo " 用法:roll slides new \"<主题>\" [--template <模板名>]" >&2
3397
+ return 1
3398
+ fi
3399
+
3400
+ local slug; slug=$(_slides_topic_slug "$topic")
3401
+ if [[ -z "$slug" ]]; then
3402
+ err "Could not derive a slug from topic: $topic"
3403
+ echo " 无法从主题派生 slug:$topic" >&2
3404
+ return 1
3405
+ fi
3406
+
3407
+ local skill_file="${ROLL_PKG_DIR}/skills/roll-deck/SKILL.md"
3408
+ [[ -f "$skill_file" ]] || { err "Skill not found: ${skill_file}"; return 1; }
3409
+ local skill_body; skill_body=$(_skill_content "$skill_file")
3410
+
3411
+ local agent; agent=$(_project_agent)
3412
+
3413
+ # Compose the full prompt: skill body + concrete task context. The agent
3414
+ # reads the skill, then sees the topic / slug / template it must use.
3415
+ local prompt
3416
+ prompt="$(cat <<EOF
3417
+ ${skill_body}
3418
+
3419
+ ---
3420
+
3421
+ # Task
3422
+
3423
+ topic: ${topic}
3424
+ slug: ${slug}
3425
+ template: ${template}
3426
+ target_file: .roll/slides/${slug}/deck.md
3427
+
3428
+ Generate the 18-slide bilingual deck.md for the topic above, following the workflow and hard constraints in this skill. Write exactly one file: .roll/slides/${slug}/deck.md. Then print the bilingual "Next" hint.
3429
+
3430
+ 按本 skill 的工作流和硬约束生成 18 张双语 slide 的 deck.md。只写一个文件:.roll/slides/${slug}/deck.md,然后打印双语 "Next" 提示。
3431
+ EOF
3432
+ )"
3433
+
3434
+ _agent_argv "$agent" text "$prompt" || {
3435
+ err "Unknown agent '${agent}'. Run: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"
3436
+ return 1
3437
+ }
3438
+
3439
+ info "Launching ${agent} with roll-deck skill for topic: ${topic}"
3440
+ info "启动 ${agent} 处理主题:${topic}"
3441
+ "${_AGENT_ARGV[@]}"
3442
+ local rc=$?
3443
+
3444
+ # Whether the agent succeeded or not, point the user at the next bash step.
3445
+ # (Build will surface its own validation errors if deck.md is malformed.)
3446
+ echo
3447
+ echo "Next: roll slides build ${slug}"
3448
+ echo "下一步:roll slides build ${slug}"
3449
+
3450
+ return "$rc"
3451
+ }
3452
+
3453
+ cmd_slides() {
3454
+ local subcmd="${1:-}"
3455
+ shift || true
3456
+ case "$subcmd" in
3457
+ build)
3458
+ cmd_slides_build "$@"
3459
+ ;;
3460
+ new)
3461
+ cmd_slides_new "$@"
3462
+ ;;
3463
+ list)
3464
+ cmd_slides_list "$@"
3465
+ ;;
3466
+ preview)
3467
+ cmd_slides_preview "$@"
3468
+ ;;
3469
+ --help|-h|help)
3470
+ _slides_help
3471
+ return 0
3472
+ ;;
3473
+ "")
3474
+ _slides_help
3475
+ return 1
3476
+ ;;
3477
+ *)
3478
+ err "Unknown subcommand: ${subcmd} 未知子命令:${subcmd}"
3479
+ _slides_help >&2
3480
+ return 1
3481
+ ;;
3482
+ esac
3483
+ }
3484
+
2769
3485
  cmd_review_pr() {
2770
3486
  local pr_number="${1:-}"
2771
3487
  [ -n "$pr_number" ] || { err "Usage: roll review-pr <number>"; return 1; }
@@ -3094,7 +3810,40 @@ fi
3094
3810
  # _SHARED_ROOT overrides and silently leaked test runs.jsonl writes into prod.
3095
3811
  _LOOP_RUNS="${_SHARED_ROOT}/loop/runs.jsonl"
3096
3812
  : "${_LOOP_MUTE_FILE:=${_SHARED_ROOT}/loop/mute-${_LOOP_PROJ_SLUG}}"
3097
- _LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
3813
+ # FIX-087: parallel to FIX-065's _SHARED_ROOT auto-sandbox above. Without this,
3814
+ # tests that source bin/roll (directly via BATS or indirectly via a runner-inner
3815
+ # fork under /tmp / /var/folders/) wrote plists into the developer's real
3816
+ # ~/Library/LaunchAgents/ while their runner paths lived in the sandbox. When
3817
+ # the sandbox got cleaned up, those plists outlived their runners and launchd
3818
+ # fired them every hour with EX_CONFIG, silently killing the autonomous loop.
3819
+ # Detection signals mirror the _SHARED_ROOT block; under a sandbox we route
3820
+ # _LAUNCHD_DIR into _SHARED_ROOT so a single teardown removes both.
3821
+ if [ -z "${_LAUNCHD_DIR:-}" ]; then
3822
+ # When HOME itself is a sandbox dir (run_roll-style wrappers, roll_status
3823
+ # setup, etc.) the default ${HOME}/Library/LaunchAgents is ALREADY in the
3824
+ # sandbox — redirecting again would break tests that pre-seed plists under
3825
+ # the sandboxed HOME. Only auto-sandbox when HOME points to a real user dir.
3826
+ case "${HOME:-}" in
3827
+ /tmp/*|/private/tmp/*|*/var/folders/*|*/tmp.*) ;;
3828
+ *)
3829
+ _roll_in_test_ctx=0
3830
+ if [ -n "${BATS_TEST_FILENAME:-}" ]; then
3831
+ _roll_in_test_ctx=1
3832
+ else
3833
+ _roll_caller="${BASH_SOURCE[1]:-}"
3834
+ case "$_roll_caller" in /tmp/*|/private/tmp/*|/var/folders/*) _roll_in_test_ctx=1 ;; esac
3835
+ case "$PWD" in /tmp/*|/private/tmp/*|/var/folders/*) _roll_in_test_ctx=1 ;; esac
3836
+ fi
3837
+ if [ "$_roll_in_test_ctx" = 1 ]; then
3838
+ _LAUNCHD_DIR="${_SHARED_ROOT}/LaunchAgents"
3839
+ mkdir -p "$_LAUNCHD_DIR"
3840
+ export _LAUNCHD_DIR
3841
+ fi
3842
+ unset _roll_in_test_ctx _roll_caller
3843
+ ;;
3844
+ esac
3845
+ fi
3846
+ : "${_LAUNCHD_DIR:=${HOME}/Library/LaunchAgents}"
3098
3847
 
3099
3848
  _config_read_int() {
3100
3849
  local key="$1" default="$2"
@@ -3224,6 +3973,25 @@ _write_launchd_plist() {
3224
3973
  local plist_path="$1" label="$2" project_path="$3"
3225
3974
  local minute="$4" hour="$5" runner_script="$6"
3226
3975
 
3976
+ # FIX-087 tripwire: last line of defense if some caller explicitly set
3977
+ # _LAUNCHD_DIR back to the real path (or built plist_path manually) while
3978
+ # in a test context. Refuse rather than pollute the user's launchd domain.
3979
+ # Skipped when HOME itself has been redirected to a sandbox dir — then
3980
+ # $HOME/Library/LaunchAgents IS the sandbox, not prod.
3981
+ case "${HOME:-}" in
3982
+ /tmp/*|/private/tmp/*|*/var/folders/*|*/tmp.*) ;;
3983
+ *)
3984
+ if [ -n "${HOME:-}" ] && [ "${plist_path#${HOME}/Library/LaunchAgents/}" != "$plist_path" ]; then
3985
+ case "${BATS_TEST_FILENAME:-}${PWD}" in
3986
+ */tmp.*|*/var/folders/*|/tmp/*|/private/tmp/*|*.bats)
3987
+ echo "[FIX-087] refusing prod plist write: $plist_path (test context)" >&2
3988
+ return 1
3989
+ ;;
3990
+ esac
3991
+ fi
3992
+ ;;
3993
+ esac
3994
+
3227
3995
  local hour_xml=""
3228
3996
  [[ -n "$hour" ]] && hour_xml=" <key>Hour</key>
3229
3997
  <integer>${hour}</integer>
@@ -3406,6 +4174,38 @@ _inner_cleanup() {
3406
4174
  _runs_append "failed" 0 "[]" 2>/dev/null || true
3407
4175
  _worktree_alert "cycle \${CYCLE_ID:-unknown}: \${LOOP_CYCLE_TIMEOUT_SEC}s timeout — claude/python killed; in-progress story marked Blocked" 2>/dev/null || true
3408
4176
  fi
4177
+ # FIX-086: aborted-path orphan safety net. When the inner script is killed
4178
+ # (e.g. SIGUSR1, parent process death) after claude has committed TCR work
4179
+ # but before publish runs, the existing aborted fallback below writes
4180
+ # cycle_end aborted and the commits are local-only — if the worktree is
4181
+ # later cleaned up, the work is lost. Before falling through to aborted,
4182
+ # detect unpushed commits in the worktree and push them as an orphan
4183
+ # branch + tag (mirroring FIX-039's PR-publish-failed safety net). On
4184
+ # push success → cycle_end orphan; on failure → fall through to aborted
4185
+ # path below (no regression).
4186
+ # Skip when _CYCLE_TIMED_OUT=1: 45-min hard timeout SIGKILLs claude mid-flight,
4187
+ # so commits may not be atomic — keep human-in-loop via blocked path.
4188
+ # Guard uses inequality form so the FIX-066 audit anchors on the aborted
4189
+ # fallback below, not this block.
4190
+ if [ "\${_CYCLE_END_WRITTEN:-0}" != "1" ] \\
4191
+ && [ "\${_CYCLE_TIMED_OUT:-0}" = "0" ] \\
4192
+ && [ "\${_USE_WORKTREE:-0}" = "1" ] \\
4193
+ && [ -n "\${WT:-}" ] \\
4194
+ && [ -d "\$WT" ] \\
4195
+ && [ -n "\${CYCLE_ID:-}" ]; then
4196
+ _unpushed=\$(cd "\$WT" && git rev-list --count "origin/main..HEAD" 2>/dev/null || echo 0)
4197
+ if [ "\${_unpushed:-0}" -gt 0 ]; then
4198
+ _orphan_tag="loop-orphan-\${CYCLE_ID}"
4199
+ if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \\
4200
+ && git tag "\$_orphan_tag" 2>/dev/null \\
4201
+ && git push origin "\$_orphan_tag" 2>/dev/null ); then
4202
+ _loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "orphan" 2>/dev/null || true
4203
+ _CYCLE_END_WRITTEN=1
4204
+ _runs_append "orphan" 0 "[]" 2>/dev/null || true
4205
+ _worktree_alert "cycle \${CYCLE_ID}: aborted with \${_unpushed} commits; FIX-086 pushed orphan tag \${_orphan_tag}" 2>/dev/null || true
4206
+ fi
4207
+ fi
4208
+ fi
3409
4209
  # IDEA-028 / FIX-066: catch every other abort (SIGKILL, set -e fire, ALERT
3410
4210
  # poisoning that bypasses the retry budget, etc.). Without this, cycle_start
3411
4211
  # is emitted but cycle_end never is, and dashboard renders the cycle as
@@ -3619,6 +4419,10 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
3619
4419
  _worktree_alert "cycle \${CYCLE_ID}: FIX-047: PR not merged within timeout — code may not be in main (BRANCH=\${BRANCH})"
3620
4420
  fi
3621
4421
  fi
4422
+ # US-VIEW-011: emit terminal PR state (merged/closed/open) before cycle_end
4423
+ # so dashboard renders #NN ✓/↩/… correctly. Must run while branch ref
4424
+ # is still resolvable on remote — gh pr view <branch> needs the head ref.
4425
+ _loop_emit_pr_final "\$BRANCH" 2>/dev/null || true
3622
4426
  _worktree_cleanup "\$WT" "\$BRANCH"
3623
4427
  _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true; _CYCLE_END_WRITTEN=1
3624
4428
  echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
@@ -4041,8 +4845,19 @@ _loop_off() {
4041
4845
  warn "Loop not enabled for this project 当前项目 loop 未启用"; return 0
4042
4846
  fi
4043
4847
  local slug; slug=$(_project_slug "$project_path")
4848
+ local uid; uid=$(id -u)
4044
4849
  for svc in loop dream brief; do
4045
4850
  rm -f "${_SHARED_ROOT}/${svc}/run-${slug}.sh"
4851
+ # FIX-081: reverse the FIX-059 auto-bootstrap guard. `_install_launchd_plists`
4852
+ # writes `launchctl disable gui/<UID>/<label>` for every brand-new plist
4853
+ # to block macOS FSEvents from auto-bootstrapping it. That write lands in
4854
+ # the host's /private/var/db/com.apple.xpc.launchd/disabled.<UID>.plist —
4855
+ # it ignores any HOME sandbox. Without a symmetric `enable` on teardown,
4856
+ # every short-lived project leaves 3 permanent ghost labels in the host's
4857
+ # disable list, polluting `launchctl print-disabled` forever even after
4858
+ # the project dir, plists, and ~/.roll are gone.
4859
+ local label; label=$(_launchd_label "$svc" "$project_path")
4860
+ launchctl enable "gui/${uid}/${label}" 2>/dev/null || true
4046
4861
  done
4047
4862
  ok "Loop disabled 已停用"
4048
4863
  return 0
@@ -5426,6 +6241,14 @@ _worktree_sync_meta() {
5426
6241
  --exclude='events.ndjson*' \
5427
6242
  --exclude='runs.jsonl*' \
5428
6243
  .roll/ "$path/.roll/" 2>/dev/null || true
6244
+ # FIX-085: hard-constrain the "skip 🔨 In Progress" rule from the runner
6245
+ # side. SKILL.md tells the agent to skip 🔨 rows but agents don't always
6246
+ # comply, so we strip those rows from the worktree's backlog copy — the
6247
+ # agent literally can't pick a row it can't see. Main backlog untouched.
6248
+ if [ -f "$path/.roll/backlog.md" ]; then
6249
+ sed -i.bak '/| 🔨 In Progress |$/d' "$path/.roll/backlog.md" 2>/dev/null || true
6250
+ rm -f "$path/.roll/backlog.md.bak"
6251
+ fi
5429
6252
  }
5430
6253
 
5431
6254
  # _worktree_merge_back <branch>
@@ -5583,11 +6406,45 @@ _loop_publish_pr() {
5583
6406
  fi
5584
6407
  gh -R "$slug" pr merge "$branch" --auto --squash --delete-branch >/dev/null 2>&1 \
5585
6408
  || _worktree_alert "_loop_publish_pr: gh pr merge --auto failed for ${branch} (PR ${pr_url} left open)"
5586
- _loop_event pr "$branch" "$pr_url" "ok" 2>/dev/null || true
6409
+ # US-VIEW-011: emit 'open' at PR creation; cycle_end path emits a follow-up
6410
+ # event with the terminal outcome (merged / closed) via _loop_emit_pr_final.
6411
+ _loop_event pr "$branch" "$pr_url" "open" 2>/dev/null || true
5587
6412
  echo "$pr_url"
5588
6413
  return 0
5589
6414
  }
5590
6415
 
6416
+ # _loop_emit_pr_final <branch>
6417
+ # US-VIEW-011: after wait_pr_merge resolves, query gh for the PR's terminal
6418
+ # state and emit a second `pr` event so the dashboard renders the correct
6419
+ # landing marker (#NN ✓ merged / #NN ↩ closed / #NN … open).
6420
+ #
6421
+ # gh state mapping:
6422
+ # MERGED → merged (auto-merge landed)
6423
+ # CLOSED → closed (PR closed without merging — wasted cycle)
6424
+ # OPEN → open (still waiting; auto-merge or human reviewer pending)
6425
+ # UNKNOWN/error → open (conservative — don't lie about merged)
6426
+ #
6427
+ # Lenient: returns 0 on any failure (gh missing, slug unparseable, network
6428
+ # error). The earlier 'open' event remains as the conservative default
6429
+ # rendering.
6430
+ _loop_emit_pr_final() {
6431
+ local branch="$1"
6432
+ command -v gh >/dev/null 2>&1 || return 0
6433
+ local slug; _gh_resolve slug || return 0
6434
+ local pr_url state outcome
6435
+ pr_url=$(gh -R "$slug" pr view "$branch" --json url -q .url 2>/dev/null) || pr_url=""
6436
+ state=$(gh -R "$slug" pr view "$branch" --json state -q .state 2>/dev/null || echo "UNKNOWN")
6437
+ case "$state" in
6438
+ MERGED) outcome="merged" ;;
6439
+ CLOSED) outcome="closed" ;;
6440
+ OPEN) outcome="open" ;;
6441
+ *) outcome="open" ;;
6442
+ esac
6443
+ [ -z "$pr_url" ] && return 0
6444
+ _loop_event pr "$branch" "$pr_url" "$outcome" 2>/dev/null || true
6445
+ return 0
6446
+ }
6447
+
5591
6448
  # _loop_wait_pr_merge <branch>
5592
6449
  # FIX-047: poll GitHub until PR for <branch> is merged (confirms delivery).
5593
6450
  # Returns 0: merged. Returns 1: CLOSED or timeout.
@@ -5940,7 +6797,7 @@ cmd_brief() {
5940
6797
  # REFACTOR-021 collapsed the changelog double-pipeline so the release script
5941
6798
  # generates the version header directly from BACKLOG, leaving these two
5942
6799
  # helpers orphaned. Their behaviour is now part of the changelog renderer
5943
- # called from scripts/release.sh.
6800
+ # called from the maintainer-private release script at roll-meta/ops/release.sh.
5944
6801
 
5945
6802
  # ═══════════════════════════════════════════════════════════════════════════════
5946
6803
  # BACKLOG — show pending tasks / manage status
@@ -6764,14 +7621,21 @@ _check_structure() {
6764
7621
  # If new structure exists, allow
6765
7622
  [[ -d "$root/.roll" ]] && return 0
6766
7623
 
6767
- # Check for legacy structure markers (literal old-path strings must NOT be
6768
- # rewritten during Story 5 code-ref migration; this is the detection logic
6769
- # for pre-2.0 user projects).
7624
+ # US-ONBOARD-019: only treat the directory as a legacy *Roll* project when an
7625
+ # old-path marker is present AND a Roll-specific content signature confirms
7626
+ # the project was actually onboarded with Roll. Otherwise we'd block any
7627
+ # non-Roll repo that happens to ship a BACKLOG.md (Jira export, board dump,
7628
+ # different tooling) or a generic docs/features/ folder.
7629
+ local _has_old_path=false
6770
7630
  if [[ -f "$root/BACKLOG.md" ]] \
6771
7631
  || [[ -f "$root/PROPOSALS.md" ]] \
6772
7632
  || [[ -d "$root/docs/features" ]] \
6773
7633
  || [[ -d "$root/docs/briefs" ]] \
6774
7634
  || [[ -d "$root/docs/dream" ]]; then
7635
+ _has_old_path=true
7636
+ fi
7637
+
7638
+ if [[ "$_has_old_path" == "true" ]] && _has_roll_signature "$root"; then
6775
7639
  err "Legacy structure detected at: $root 发现老结构目录"
6776
7640
  echo "" >&2
6777
7641
  echo " This project uses the pre-2.0 layout (BACKLOG.md / docs/*). Roll 2.0 requires" >&2
@@ -6814,6 +7678,7 @@ main() {
6814
7678
  ci) cmd_ci "$@" ;;
6815
7679
  doctor) cmd_doctor "$@" ;;
6816
7680
  review-pr) cmd_review_pr "$@" ;;
7681
+ slides) cmd_slides "$@" ;;
6817
7682
  version|--version|-v) echo "roll v${VERSION}" ;;
6818
7683
  help|--help|-h) _help "$@" ;;
6819
7684
  "") [[ -f ".roll/backlog.md" ]] && _home || { _help; _show_changelog; } ;;