#!/usr/bin/env bash
set -euo pipefail

# Roll — AI Agent Convention Manager
# Single source of truth for how all AI coding agents behave.

VERSION="2026.521.3"
ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
ROLL_CONFIG="${ROLL_HOME}/config.yaml"
ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
ROLL_TEMPLATES="${ROLL_HOME}/conventions/templates"

# Find package root (resolve symlinks so it works from ~/.local/bin/roll or npm global bin)
_source="${BASH_SOURCE[0]:-$0}"
while [[ -L "$_source" ]]; do
  _dir="$(cd "$(dirname "$_source")" && pwd)"
  _source="$(readlink "$_source")"
  [[ "$_source" != /* ]] && _source="$_dir/$_source"
done
SCRIPT_DIR="$(cd "$(dirname "$_source")" && pwd)"
ROLL_PKG_DIR="$(dirname "$SCRIPT_DIR")"
ROLL_PKG_CONVENTIONS="${ROLL_PKG_DIR}/conventions"

# Colors
RED=$'\033[0;31m'
GREEN=$'\033[0;32m'
YELLOW=$'\033[0;33m'
CYAN=$'\033[0;36m'
BOLD=$'\033[1m'
NC=$'\033[0m'

# Respect NO_COLOR
if [[ -n "${NO_COLOR:-}" ]]; then
  RED='' GREEN='' YELLOW='' CYAN='' BOLD='' NC=''
fi

info()  { echo -e "${CYAN}[roll]${NC} $*"; }
ok()    { echo -e "${GREEN}[roll]${NC} $*"; }
warn()  { echo -e "${YELLOW}[roll]${NC} $*"; }
err()   { echo -e "${RED}[roll]${NC} $*" >&2; }

# Tracks merge actions across a single init run; reset before each batch
_ROLL_MERGE_SUMMARY=()

canonical_dir() {
  local path="$1"
  [[ -d "$path" ]] || return 1
  (cd "$path" >/dev/null 2>&1 && pwd -P)
}

# Return a human-readable name for an AI tool dir.
# Handles nested paths like ~/.openclaw/workspace → "openclaw", ~/.pi/agent → "pi".
ai_tool_name() {
  local dir="$1"
  local bn
  bn="$(basename "$dir" | sed 's/^\.//')"
  if [[ "$bn" == "workspace" ]]; then
    bn="$(basename "$(dirname "$dir")" | sed 's/^\.//')"
  elif [[ "$bn" == "agent" || "$bn" == "workspace" ]]; then
    bn="$(basename "$(dirname "$dir")" | sed 's/^\.//')"
  fi
  echo "$bn"
}

lower_name() {
  echo "$1" | tr '[:upper:]' '[:lower:]'
}


# Check if an AI tool is actually installed.
# Most tools create their own config dir; Trae on macOS uses Library/Application Support
# and expects roll to manage ~/.trae/ — so we detect via the app directory instead.
_is_ai_installed() {
  local ai_dir="$1"
  [[ -d "$ai_dir" ]] && return 0
  local bn
  bn="$(basename "$ai_dir" | sed 's/^\.//')"
  case "$bn" in
    trae)
      [[ -d "$HOME/Library/Application Support/Trae" ]] ||
      [[ -d "$HOME/.config/Trae" ]]
      return
      ;;
    opencode)
      [[ -x "$HOME/.opencode/bin/opencode" ]]
      return
      ;;
    agent)
      if [[ "$(basename "$(dirname "$ai_dir")")" == ".pi" ]]; then
        command -v pi &>/dev/null && return
      fi
      ;;
  esac
  return 1
}

# ─── Helper: read config value ───────────────────────────────────────────────
config_get() {
  local key="$1"
  local default="${2:-}"
  if [[ -f "$ROLL_CONFIG" ]]; then
    local val
    val=$(grep -E "^${key}:" "$ROLL_CONFIG" 2>/dev/null | head -1 | sed 's/^[^:]*:[[:space:]]*//' | sed 's/[[:space:]]*#.*$//' | sed 's/[[:space:]]*$//')
    if [[ -n "$val" ]]; then
      echo "${val/#\~/$HOME}"
      return
    fi
  fi
  echo "${default/#\~/$HOME}"
}

# ─── Helpers: read ai_* entries from config ──────────────────────────────────
# Returns one "dir|config_file|convention_src" line per ai_* key in config.yaml
_get_ai_tools() {
  grep -E "^ai_[a-z]+:" "$ROLL_CONFIG" 2>/dev/null | sed 's/^[^:]*:[[:space:]]*//' | while IFS= read -r entry; do
    echo "${entry/#\~/$HOME}"
  done
}

# Iterate all configured AI tools, calling: callback entry ai_dir ai_config ai_src [extra_args...]
_for_each_ai_tool() {
  local _feach_cb="$1"; shift
  while IFS= read -r _feach_entry; do
    "$_feach_cb" "$_feach_entry" \
      "$(_ai_dir "$_feach_entry")" \
      "$(_ai_config "$_feach_entry")" \
      "$(_ai_src "$_feach_entry")" \
      "$@"
  done < <(_get_ai_tools)
}

# Add any ai_* keys from the default set that are missing from the user's config.
# Non-destructive: never removes or modifies existing entries.
_ensure_config_entries() {
  [[ -f "$ROLL_CONFIG" ]] || return

  local -a default_keys=(
    "ai_claude:~/.claude|CLAUDE.md|CLAUDE.md"
    "ai_gemini:~/.gemini|GEMINI.md|GEMINI.md"
    "ai_kimi:~/.kimi|AGENTS.md|AGENTS.md"
    "ai_codex:~/.codex|AGENTS.md|AGENTS.md"
    "ai_cursor:~/.cursor|.cursor-rules|.cursor-rules"
    "ai_trae:~/.trae|user_rules.md|project_rules.md"
    "ai_opencode:~/.config/opencode|AGENTS.md|AGENTS.md"
    "ai_openclaw:~/.openclaw/workspace|AGENTS.md|AGENTS.md"
    "ai_pi:~/.pi/agent|AGENTS.md|AGENTS.md"
    "ai_deepseek:~/.deepseek|AGENTS.md|AGENTS.md"
  )

  local added=0
  local tmp
  tmp="$(mktemp)"
  cp "$ROLL_CONFIG" "$tmp"

  for entry in "${default_keys[@]}"; do
    local key="${entry%%:*}"
    local val="${entry#*:}"
    if ! grep -qE "^${key}:" "$ROLL_CONFIG" 2>/dev/null; then
      if grep -q "^# User preferences" "$tmp" 2>/dev/null; then
        local new_tmp
        new_tmp="$(mktemp)"
        while IFS= read -r line; do
          [[ "$line" == "# User preferences" ]] && echo "${key}: ${val}" >> "$new_tmp"
          echo "$line" >> "$new_tmp"
        done < "$tmp"
        mv "$new_tmp" "$tmp"
      else
        echo "${key}: ${val}" >> "$tmp"
      fi
      added=$((added + 1))
      warn "Added missing config entry: ${key}  已添加缺失配置项: ${key}"
    fi
  done

  if [[ $added -gt 0 ]]; then
    cp "$tmp" "$ROLL_CONFIG"
    ok "Config updated with $added new entries  配置已更新，新增 $added 条目"
  fi
  rm -f "$tmp"
}

# Extract fields from a "<dir>|<config>|<src>" entry
_ai_dir()    { echo "$1" | cut -d'|' -f1; }
_ai_config() { echo "$1" | cut -d'|' -f2; }
_ai_src()    { echo "$1" | cut -d'|' -f3; }

# ─── Helper: safe copy with overwrite prompt ─────────────────────────────────
safe_copy() {
  local src="$1"
  local dst="$2"
  local force="${3:-false}"

  if [[ ! -f "$src" ]]; then
    return
  fi

  local dst_dir
  dst_dir="$(dirname "$dst")"
  mkdir -p "$dst_dir"

  if [[ -f "$dst" ]] && [[ "$force" != "true" ]]; then
    if diff -q "$src" "$dst" &>/dev/null; then
      return  # identical, skip silently
    fi
    echo ""
    warn "File exists and differs: ${dst/#$HOME/~}  文件已存在且内容不同: ${dst/#$HOME/~}"
    echo -e "  ${BOLD}Overwrite?${NC} [Y/n/d(iff)] "
    read -r answer
    case "$answer" in
      d|D|diff)
        diff --color=auto "$dst" "$src" || true
        echo ""
        echo -e "  ${BOLD}Overwrite?${NC} [Y/n] "
        read -r answer2
        [[ "$answer2" =~ ^[Nn]$ ]] && { info "Skipped: ${dst/#$HOME/\~}  已跳过: ${dst/#$HOME/\~}"; return; }
        ;;
      n|N) info "Skipped: ${dst/#$HOME/~}  已跳过: ${dst/#$HOME/~}"; return ;;
      *) ;;  # empty answer or 'y' / 'Y' → overwrite (default Yes)
    esac
  fi

  cp "$src" "$dst"
  ok "Wrote: ${dst/#$HOME/~}  已写入: ${dst/#$HOME/~}"
}

# ─── Internal: prune files in $1 that no longer exist in $2 ──────────────────
# Used to clean up stale files left behind when a previous version had them
# but the current package no longer ships them.
_prune_dir() {
  local installed_dir="$1"
  local source_dir="$2"
  local label="${3:-file}"
  [[ -d "$installed_dir" ]] || return 0

  local installed_f installed_fname
  for installed_f in "$installed_dir"/* "$installed_dir"/.*; do
    [[ -f "$installed_f" ]] || continue
    installed_fname="$(basename "$installed_f")"
    [[ "$installed_fname" == "." || "$installed_fname" == ".." ]] && continue
    if [[ ! -f "$source_dir/$installed_fname" ]]; then
      rm -f "$installed_f"
      info "Removed stale $label: ${installed_dir##*/}/$installed_fname  已删除过时$label: ${installed_dir##*/}/$installed_fname"
    fi
  done
}

# ─── Internal: pull skills from repo → ~/.roll/skills ──────────────────────
_pull_skills() {
  if [[ ! -d "$ROLL_PKG_DIR/skills" ]]; then
    err "Skills source not found at: $ROLL_PKG_DIR/skills  技能源目录未找到: $ROLL_PKG_DIR/skills"
    return 1
  fi

  mkdir -p "$ROLL_HOME/skills"

  # Copy/update skills from repo → ~/.roll/skills/
  for skill_dir in "$ROLL_PKG_DIR"/skills/*/; do
    if [[ -d "$skill_dir" ]]; then
      local skill_name
      skill_name="$(basename "$skill_dir")"
      mkdir -p "$ROLL_HOME/skills/$skill_name"
      for f in "$skill_dir"*; do
        [[ -f "$f" ]] && cp "$f" "$ROLL_HOME/skills/$skill_name/$(basename "$f")"
      done
      # File-level prune (dir-level prune below catches whole-skill removals)
      _prune_dir "$ROLL_HOME/skills/$skill_name" "$skill_dir" "skill file"
    fi
  done

  # Prune skills that no longer exist in repo.
  # ~/.roll/skills/ is roll's controlled namespace — safe to clean up.
  for installed_dir in "$ROLL_HOME/skills"/*/; do
    [[ -d "$installed_dir" ]] || continue
    local installed_name
    installed_name="$(basename "$installed_dir")"
    if [[ ! -d "$ROLL_PKG_DIR/skills/$installed_name" ]]; then
      rm -rf "$installed_dir"
      info "Removed stale skill: $installed_name  已删除过时技能: $installed_name"
    fi
  done
}

# ─── Internal: pull conventions from repo → ~/.roll/conventions ────────────
_pull_conventions() {
  local force="${1:-false}"

  if [[ ! -d "$ROLL_PKG_CONVENTIONS" ]]; then
    err "Convention source not found at: $ROLL_PKG_CONVENTIONS  约定源文件未找到: $ROLL_PKG_CONVENTIONS"
    return 1
  fi

  mkdir -p "$ROLL_GLOBAL"
  mkdir -p "$ROLL_TEMPLATES"/{fullstack,frontend-only,backend-service,cli}

  info "Copying global conventions...  正在复制全局约定..."
  for f in "$ROLL_PKG_CONVENTIONS"/global/*; do
    [[ -f "$f" ]] && safe_copy "$f" "$ROLL_GLOBAL/$(basename "$f")" "$force"
  done
  for f in "$ROLL_PKG_CONVENTIONS"/global/.*; do
    [[ -f "$f" ]] && [[ "$(basename "$f")" != "." ]] && [[ "$(basename "$f")" != ".." ]] && \
      safe_copy "$f" "$ROLL_GLOBAL/$(basename "$f")" "$force"
  done
  # Prune stale files in ~/.roll/conventions/global/
  _prune_dir "$ROLL_GLOBAL" "$ROLL_PKG_CONVENTIONS/global" "convention"

  info "Copying project templates...  正在复制项目模板..."
  for tpl_dir in "$ROLL_PKG_CONVENTIONS"/templates/*/; do
    local tpl_name
    tpl_name="$(basename "$tpl_dir")"
    for f in "$tpl_dir"*; do
      [[ -f "$f" ]] && safe_copy "$f" "$ROLL_TEMPLATES/$tpl_name/$(basename "$f")" "$force"
    done
    for f in "$tpl_dir".*; do
      [[ -f "$f" ]] && [[ "$(basename "$f")" != "." ]] && [[ "$(basename "$f")" != ".." ]] && \
        safe_copy "$f" "$ROLL_TEMPLATES/$tpl_name/$(basename "$f")" "$force"
    done
    # Prune stale files in this template dir
    _prune_dir "$ROLL_TEMPLATES/$tpl_name" "$tpl_dir" "template file"
  done
}

# ─── Internal: install local cache from repo source ───────────────────────────
_install_local() {
  local force="${1:-false}"

  if [[ ! -d "$ROLL_PKG_CONVENTIONS" ]]; then
    err "Convention source not found at: $ROLL_PKG_CONVENTIONS  约定源文件未找到: $ROLL_PKG_CONVENTIONS"
    err "Run this from the roll repo, or symlink bin/roll to PATH.  请在 roll 仓库目录下运行，或将 bin/roll 软链接到 PATH。"
    exit 1
  fi

  _pull_conventions "$force"
  _pull_skills

  # Recreate config if it has no ai_* entries (covers legacy sync_* format and blank/broken configs)
  if [[ -f "$ROLL_CONFIG" ]] && ! grep -qE "^ai_[a-z]+:" "$ROLL_CONFIG" 2>/dev/null; then
    warn "Config has no ai_* entries — recreating with defaults (backup saved)  配置无 ai_* 条目，将重建（已备份）"
    cp "$ROLL_CONFIG" "${ROLL_CONFIG}.bak"
    info "Backup saved: ~/.roll/config.yaml.bak  备份已保存: ~/.roll/config.yaml.bak"
    rm "$ROLL_CONFIG"
  fi

  # Create config if it doesn't exist
  if [[ ! -f "$ROLL_CONFIG" ]]; then
    info "Creating default config...  正在创建默认配置..."
    cat > "$ROLL_CONFIG" << 'YAML'
# Roll Configuration
# Edit this file, then run `roll setup` to apply.

# AI tools — each entry controls both convention sync and skill linking
# Format: <name>: <dir>|<config_file>|<convention_src>
ai_claude: ~/.claude|CLAUDE.md|CLAUDE.md
ai_gemini: ~/.gemini|GEMINI.md|GEMINI.md
ai_kimi: ~/.kimi|AGENTS.md|AGENTS.md
ai_codex: ~/.codex|AGENTS.md|AGENTS.md
ai_cursor: ~/.cursor|.cursor-rules|.cursor-rules
ai_trae: ~/.trae|user_rules.md|project_rules.md
ai_opencode: ~/.config/opencode|AGENTS.md|AGENTS.md
ai_openclaw: ~/.openclaw/workspace|AGENTS.md|AGENTS.md
ai_pi: ~/.pi/agent|AGENTS.md|AGENTS.md
ai_deepseek: ~/.deepseek|AGENTS.md|AGENTS.md

# User preferences
default_language: zh
default_project_type: fullstack
editor: ${EDITOR:-vim}

# Loop schedule (24h format, machine local timezone)
# Minute fields auto-derive from project path hash when omitted — avoids contention across projects.
loop_active_start: 10   # loop only fires inside this window (after human reviews brief)
loop_active_end: 18
# loop_minute: 5        # omit to auto-derive from project hash
loop_dream_hour: 3
# loop_dream_minute: 10 # omit to auto-derive
loop_brief_hour: 9
# loop_brief_minute: 15 # omit to auto-derive
primary_agent: claude
YAML
    ok "Created: ~/.roll/config.yaml  已创建: ~/.roll/config.yaml"
  fi

  # Ensure all expected ai_* keys exist (handles upgrades where new tools were added)
  _ensure_config_entries

}

# ─── Internal: create or repair per-skill symlinks (non-destructive) ─────────
_link_skills() {
  local force="${1:-false}"
  local roll_skills_real pkg_skills_real
  roll_skills_real="$(canonical_dir "$ROLL_HOME/skills" 2>/dev/null || true)"
  pkg_skills_real="$(canonical_dir "$ROLL_PKG_DIR/skills" 2>/dev/null || true)"

  while IFS= read -r entry; do
    local ai_dir
    ai_dir="$(_ai_dir "$entry")"
    _is_ai_installed "$ai_dir" || continue
    mkdir -p "$ai_dir"

    local ai_name ai_dir_real skills_dir
    ai_name="$(ai_tool_name "$ai_dir")"
    ai_dir_real="$(canonical_dir "$ai_dir" 2>/dev/null || true)"
    skills_dir="$ai_dir/skills"

    if [[ -n "$ai_dir_real" && \
          ( "$ai_dir_real" == "$ROLL_PKG_DIR" || "$ai_dir_real" == "$ROLL_PKG_DIR"/* ) ]]; then
      warn "Skipped ~/${ai_name} (resolves to repo — refusing to manage skills inside roll worktree)  已跳过 ~/${ai_name}（解析到仓库目录 — 拒绝在 roll worktree 内管理技能）"
      continue
    fi

    # Guard: resolve ALL symlink chains — block writing anywhere inside the repo
    local skills_real
    skills_real="$(canonical_dir "$skills_dir" 2>/dev/null || true)"
    if [[ -n "$skills_real" && -n "$pkg_skills_real" && \
          ( "$skills_real" == "$pkg_skills_real" || "$skills_real" == "$pkg_skills_real"/* ) ]]; then
      warn "Skipped ~/${ai_name}/skills (resolves to repo — check if ~/$(lower_name "$ai_name") symlinks to roll repo)  已跳过 ~/${ai_name}/skills（解析到仓库 — 检查 ~/$(lower_name "$ai_name") 是否软链接到 roll 仓库）"
      continue
    fi

    # Handle whole-dir symlink (legacy or user-created)
    if [[ -L "$skills_dir" ]]; then
      local skills_target
      skills_target="$(readlink "$skills_dir")"
      if [[ -n "$skills_real" && "$skills_real" == "$roll_skills_real" ]]; then
        continue  # Whole-dir symlink to ~/.roll/skills — still functional
      fi
      # Dangling whole-dir symlink — remove and recreate as per-skill links
      if [[ -z "$skills_real" ]]; then
        info "Removing legacy symlink ~/${ai_name}/skills -> ${skills_target/#$HOME/~}  正在移除遗留软链接 ~/${ai_name}/skills -> ${skills_target/#$HOME/~}"
        rm "$skills_dir"
      else
        warn "Skipped ~/${ai_name}/skills -> ${skills_target/#$HOME/~} (unknown symlink target)  已跳过 ~/${ai_name}/skills -> ${skills_target/#$HOME/~}（未知软链接目标）"
        continue
      fi
    fi

    mkdir -p "$skills_dir"
    skills_real="$(canonical_dir "$skills_dir" 2>/dev/null || true)"
    if [[ -n "$skills_real" && -n "$pkg_skills_real" && \
          ( "$skills_real" == "$pkg_skills_real" || "$skills_real" == "$pkg_skills_real"/* ) ]]; then
      warn "Skipped ~/${ai_name}/skills (created path resolves to repo — refusing to write)  已跳过 ~/${ai_name}/skills（创建路径解析到仓库 — 拒绝写入）"
      continue
    fi
    local linked=0 repaired=0 pruned=0

    # Prune stale roll-* symlinks pointing to skills no longer in ~/.roll/skills/
    for link in "$skills_dir"/roll-*; do
      [[ -L "$link" ]] || continue
      local link_target
      link_target="$(readlink "$link")"
      # Only remove symlinks we own (pointing into our skills dir)
      if [[ "$link_target" == "$ROLL_HOME/skills/"* ]] && [[ ! -d "$link" ]]; then
        rm "$link"
        pruned=$((pruned + 1))
      fi
    done

    for skill_dir in "$ROLL_HOME/skills"/*/; do
      [[ -d "$skill_dir" ]] || continue
      local skill_name
      skill_name="$(basename "$skill_dir")"
      local skill_link="$skills_dir/$skill_name"

      if [[ -L "$skill_link" ]]; then
        local current_target
        current_target="$(readlink "$skill_link")"
        if [[ "$current_target" != "$skill_dir" ]]; then
          # macOS ln -sf follows symlinks-to-dirs and creates inside instead of
          # replacing — explicitly remove first to guarantee replacement.
          rm -f "$skill_link" && ln -s "$skill_dir" "$skill_link"
          repaired=$((repaired + 1))
        fi
        # correct symlink: skip silently
      elif [[ ! -e "$skill_link" ]]; then
        ln -s "$skill_dir" "$skill_link"
        linked=$((linked + 1))
      fi
      # real file/dir at that path: skip — never touch user content
    done
    if [[ $((linked + repaired + pruned)) -gt 0 ]]; then
      ok "Skills linked in ~/${ai_name}/skills (+${linked} new, ~${repaired} repaired, -${pruned} pruned)  已在 ~/${ai_name}/skills 中创建软链接（新增 +${linked}，修复 ~${repaired}，清理 -${pruned}）"
    fi
  done < <(_get_ai_tools)
}

# ─── Internal: sync conventions via @include — never overwrites user files ─────
# Writes WK content to {ai_dir}/roll.md, appends @roll.md to main config.
_sync_convention_for_tool() {
  local src="$1"       # source: ~/.roll/conventions/global/CLAUDE.md
  local main_dst="$2"  # target: ~/.claude/CLAUDE.md
  local force="$3"

  [[ -f "$src" ]] || return 0
  local dst_dir
  dst_dir="$(dirname "$main_dst")"

  # Only proceed if Claude (always) or the tool is installed
  if [[ "$dst_dir" != "$HOME/.claude" ]] && ! _is_ai_installed "$dst_dir"; then
    return
  fi
  mkdir -p "$dst_dir"

  # Write/update roll.md — this is our file, always safe to overwrite
  local wk_file="$dst_dir/roll.md"
  if [[ "$force" == "true" ]] || ! diff -q "$src" "$wk_file" &>/dev/null 2>&1; then
    cp "$src" "$wk_file"
    ok "Wrote: ${wk_file/#$HOME/~}  已写入: ${wk_file/#$HOME/~}"
  fi

  # Append @roll.md include to main config — never overwrite existing content
  if [[ ! -f "$main_dst" ]]; then
    printf '@roll.md\n' > "$main_dst"
    ok "Created: ${main_dst/#$HOME/~}  已创建: ${main_dst/#$HOME/~}"
  elif ! grep -qF "@roll.md" "$main_dst" 2>/dev/null; then
    printf '\n@roll.md\n' >> "$main_dst"
    ok "Appended @roll.md to: ${main_dst/#$HOME/~}  已将 @roll.md 追加至: ${main_dst/#$HOME/~}"
  else
    ok "Already included: ${main_dst/#$HOME/~}  已包含: ${main_dst/#$HOME/~}"
  fi
}

_sync_one_tool() {
  local _entry="$1" _ai_dir="$2" _cfg="$3" _src="$4" force="$5"
  _sync_convention_for_tool "$ROLL_GLOBAL/$_src" "$_ai_dir/$_cfg" "$force"
}

_sync_conventions() {
  local force="${1:-false}"
  _for_each_ai_tool _sync_one_tool "$force"
}

# ─── Internal: sync skills (pull + link) ──────────────────────────────────────
_sync_skills() {
  local force="${1:-false}"
  info "Updating skills...  正在更新技能..."
  _pull_skills
  ok "Skills updated in ~/.roll/skills  技能已更新至 ~/.roll/skills"
  info "Creating skill symlinks for AI tools...  正在为 AI 工具创建技能软链接..."
  _link_skills "$force"
}

# ═══════════════════════════════════════════════════════════════════════════════
# COMMAND: setup [--force]
# Initialize ~/.roll/ and sync everything to AI tools in one step
# ═══════════════════════════════════════════════════════════════════════════════
# Ensures tmux is available (US-AUTO-026 promoted it from soft to required
# dependency for visible loop runs). On macOS attempts `brew install tmux`
# when brew exists; elsewhere prints the install command. Never fails the
# setup main flow — returns 0 even if install was not possible so the rest
# of `roll setup` proceeds.
_ensure_tmux() {
  if command -v tmux >/dev/null 2>&1; then
    return 0
  fi

  local os; os="$(uname)"
  if [[ "$os" == "Darwin" ]]; then
    if command -v brew >/dev/null 2>&1; then
      info "tmux not found — installing via brew...  未安装 tmux，正在通过 brew 安装..."
      if brew install tmux >/dev/null 2>&1; then
        ok "tmux installed.  tmux 已安装。"
        return 0
      fi
      warn "brew install tmux failed — install manually: brew install tmux  brew 安装失败，请手动 'brew install tmux'"
      return 0
    fi
    warn "tmux required but brew not available — install manually: brew install tmux  缺少 brew，请手动 'brew install tmux'"
    return 0
  fi

  warn "tmux required — install via your package manager (e.g. apt install tmux / pacman -S tmux)  请用系统包管理器安装 tmux"
  return 0
}

# FIX-075: snapshot the content of watched directories so cmd_setup can detect
# whether a step actually changed any file. Uses `cksum` (mtime-independent) so
# a re-copy with identical content is recognised as a no-op even when the inner
# helper rewrites the file. Watch is a colon-separated list of directories;
# missing dirs are skipped silently.
# FIX-079: also track symlinks (`_link_skills` only creates symlinks) and
# directories (`_peer_ensure_state_dir` only creates dirs). Without these, a
# step that did real work but produced no regular file would falsely render
# as ↷ on a brand-new install.
_setup_snapshot() {
  local watch="$1"
  local -a dirs
  IFS=':' read -r -a dirs <<<"$watch"
  local d
  {
    for d in "${dirs[@]}"; do
      [[ -d "$d" ]] || continue
      find "$d" -type f -print0 2>/dev/null | xargs -0 cksum 2>/dev/null
      while IFS= read -r l; do
        printf 'L %s -> %s\n' "$l" "$(readlink "$l")"
      done < <(find "$d" -type l 2>/dev/null)
      find "$d" -type d -print 2>/dev/null
    done
  } | sort
}

# FIX-075: run a setup step and report changed/unchanged/failed via the global
# _ROLL_SETUP_STATE. Caller passes the watch dir(s) plus the command + args.
# stdout/stderr of the inner command are suppressed (same as the previous
# pattern in cmd_setup) to keep the v2 UI render the only user-visible output.
_run_setup_step() {
  local watch="$1"; shift
  local before after
  before=$(_setup_snapshot "$watch")
  if "$@" >/dev/null 2>&1; then
    after=$(_setup_snapshot "$watch")
    if [[ "$before" == "$after" ]]; then
      _ROLL_SETUP_STATE="unchanged"
    else
      _ROLL_SETUP_STATE="changed"
    fi
  else
    _ROLL_SETUP_STATE="failed"
  fi
}

cmd_setup() {
  local force=false
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --force|-f) force=true; shift ;;
      *) err "Unknown argument: $1  未知参数: $1"; exit 1 ;;
    esac
  done

  # Capture per-step outcomes for the v2 UI render at the end.
  local steps_buf=()
  _record() { steps_buf+=("$1|$2"); }

  # Map snapshot-detected state to v2 UI marker. -f rewrites "changed" to
  # "forced" so the user can tell a forced reinstall apart from a fresh
  # install — both produce diff'd files, only -f was explicitly requested.
  _state_to_marker() {
    local s="$1"
    case "$s" in
      changed)   [[ "$force" == "true" ]] && echo forced || echo ok ;;
      unchanged) echo skip ;;
      failed)    echo fail ;;
      *)         echo fail ;;
    esac
  }

  local _ai_dirs="$HOME/.claude:$HOME/.gemini:$HOME/.kimi:$HOME/.codex:$HOME/.cursor:$HOME/.trae:$HOME/.config/opencode:$HOME/.openclaw:$HOME/.pi:$HOME/.deepseek"

  _run_setup_step "$ROLL_HOME" _install_local "$force"
  _record "$(_state_to_marker "$_ROLL_SETUP_STATE")" "Install templates & conventions to ~/.roll"

  _run_setup_step "$_ai_dirs" _sync_conventions "$force"
  _record "$(_state_to_marker "$_ROLL_SETUP_STATE")" "Sync conventions to AI tools"

  _run_setup_step "$_ai_dirs" _sync_skills "$force"
  _record "$(_state_to_marker "$_ROLL_SETUP_STATE")" "Install skills to ~/.claude"

  _run_setup_step "$ROLL_HOME/.peer-state" _peer_ensure_state_dir
  _record "$(_state_to_marker "$_ROLL_SETUP_STATE")" "Initialize peer-review state directory"

  if command -v tmux >/dev/null 2>&1; then
    _record skip "Ensure tmux is installed (already present)"
  else
    if _ensure_tmux >/dev/null 2>&1 && command -v tmux >/dev/null 2>&1; then
      _record ok "Ensure tmux is installed"
    else
      _record fail "Ensure tmux is installed"
    fi
  fi

  # FIX-078: launchd plist 安装从 setup 里拿掉——plist 是 per-project 资源，
  # setup 是全局安装阶段，不应该给 cwd 留 disabled 的占位。需要时 cmd_init /
  # _loop_on 各自会调 _install_launchd_plists。

  _emit_setup_v2_ui "${steps_buf[@]}"
}

# FIX-073: Render the cmd_setup v2 UI from per-step outcomes captured above.
# FIX-075: footer composition depends on how many steps actually changed —
#   all unchanged → "no changes"; some forced (~) → "re-installed (forced)";
#   any failed → "Setup incomplete"; otherwise → "X items refreshed".
_emit_setup_v2_ui() {
  local color_flag=""
  if [[ -n "${NO_COLOR:-}" ]] || ! [ -t 1 ]; then
    color_flag="--no-color"
  fi

  python3 - "$@" <<'PY' \
    | python3 "${ROLL_PKG_DIR}/lib/roll-setup.py" $color_flag
import json, sys
entries = sys.argv[1:]
steps = []
for i, entry in enumerate(entries, start=1):
    status, _sep, label = entry.partition("|")
    steps.append({"num": i, "label": label, "status": status})

n_failed   = sum(1 for s in steps if s["status"] == "fail")
n_forced   = sum(1 for s in steps if s["status"] == "forced")
n_changed  = sum(1 for s in steps if s["status"] == "ok")

if n_failed:
    footer_status = "fail"
    label = "Setup incomplete"
    hint = None
elif n_forced:
    footer_status = "ok"
    label = f"Setup re-installed (forced — {n_forced} item{'s' if n_forced != 1 else ''})"
    hint = "run roll init inside a project"
elif n_changed == 0:
    footer_status = "ok"
    label = "Setup complete (no changes)"
    hint = "everything already up to date"
else:
    footer_status = "ok"
    label = f"Setup complete ({n_changed} item{'s' if n_changed != 1 else ''} refreshed)"
    hint = "run roll init inside a project"

payload = {
    "header_label": "SETUP",
    "subtitle":     "初始化",
    "steps":        steps,
    "footer": {
        "status": footer_status,
        "label":  label,
        "hint":   hint,
    },
}
print(json.dumps(payload))
PY
}

# ─── PR pipeline hint ────────────────────────────────────────────────────────
# US-AUTO-035: print the one-time branch-protection command that flips repo
# from path A (CI gate only) to path C (CI + AI review double gate). Reading
# this hint is opt-in; the command is destructive (changes branch protection)
# so it is never run automatically.
_print_pr_pipeline_hint() {
  cat <<'HINT'

  Optional — enable AI review as a hard merge gate (path C).
  可选 —— 启用 AI 评审作为合并双门（路径 C）。

  Run once per repo (requires admin token), then claude-code-review.yml
  approvals become a required merge gate alongside CI:
  每个仓库执行一次（需要管理员 token），之后 claude-code-review.yml 的
  approve 将与 CI 一起成为合并必经的双门：

      gh api -X PATCH repos/<owner>/<repo>/branches/main/protection \
        -f required_pull_request_reviews.required_approving_review_count=1

  Escape hatch: add [skip-ai-review] to a PR body, or include
  SKIP_AI_REVIEW in any commit message, to bypass AI review for that PR.
  紧急通道：在 PR body 加 [skip-ai-review]，或在任一 commit message
  里包含 SKIP_AI_REVIEW，可对该 PR 绕过 AI 评审。

HINT
}

# ─── Doctor: PR review extras section (US-PR-004) ────────────────────────────
# `roll doctor` is the single home for "things you could tune". The PR review
# extras section probes whether the two optional gates are enabled and only
# prints install commands for the ones that aren't, so users who already opted
# in (or opted out) don't get spammed each upgrade.
cmd_doctor() {
  _doctor_pr_section
}

_doctor_pr_section() {
  git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0

  echo ""
  echo "PR review extras  PR 评审两档开关"
  echo ""

  local protection_state event_state
  protection_state="$(_doctor_branch_protection_state)"
  event_state="$(_doctor_event_workflow_state)"

  case "$protection_state" in
    enabled)
      echo "  ✅ AI review double gate enabled  AI 评审双门已启用"
      ;;
    disabled)
      echo "  ⚪ AI review double gate not enabled  双门未启用"
      _print_pr_pipeline_hint
      ;;
    *)
      echo "  ⚪ AI review double gate state unknown — requires gh auth  状态未知（需要 gh auth）"
      _print_pr_pipeline_hint
      ;;
  esac

  case "$event_state" in
    present)
      echo "  ✅ Event-driven PR review installed  事件驱动 PR 评审已安装"
      ;;
    *)
      echo "  ⚪ Event-driven PR review not installed  事件驱动 PR 评审未安装"
      _print_pr_event_hint
      ;;
  esac
}

# Returns one of: enabled | disabled | unknown
_doctor_branch_protection_state() {
  command -v gh >/dev/null 2>&1 || { echo unknown; return; }

  local slug
  slug="$(gh repo view --json owner,name --jq '.owner.login + "/" + .name' 2>/dev/null)"
  [[ -n "$slug" ]] || { echo unknown; return; }

  local required
  required="$(gh api "repos/${slug}/branches/main/protection" \
    --jq '.required_pull_request_reviews.required_approving_review_count // 0' \
    2>/dev/null)"

  if [[ -z "$required" ]]; then
    echo unknown
  elif (( required >= 1 )); then
    echo enabled
  else
    echo disabled
  fi
}

# Returns one of: present | absent
_doctor_event_workflow_state() {
  if [[ -f ".github/workflows/pr-review-event.yml" ]]; then
    echo present
  else
    echo absent
  fi
}

_print_pr_event_hint() {
  cat <<'HINT'

  Optional — enable event-driven PR review (seconds-fast, GitHub only).
  可选 —— 启用事件驱动 PR 评审（秒级响应，仅限 GitHub）。

  Without this, Roll reviews PRs each loop cycle (~1h). With it,
  contributors get AI feedback on PR open/update immediately.
  不安装也行 — loop 每轮会兜底评审。安装后 PR 一开即触发 AI 评审。

      cp templates/workflows/pr-review-event.yml .github/workflows/

  Then set the API key secret for your configured agent in GitHub repo settings.
  然后在 GitHub 仓库设置中添加你配置的 agent 对应的 API key secret。

HINT
}

# ═══════════════════════════════════════════════════════════════════════════════
# COMMAND: update
# Thin wrapper: upgrade the npm-installed package, then re-sync via setup.
# Equivalent to: npm install -g @seanyao/roll@latest && roll setup
# ═══════════════════════════════════════════════════════════════════════════════
_check_installed_version_or_retry() {
  local expected installed pkg_dir
  expected="$(npm view @seanyao/roll version 2>/dev/null || true)"
  pkg_dir="$(npm root -g 2>/dev/null || true)"
  installed="$(grep "^VERSION=" "${pkg_dir}/@seanyao/roll/bin/roll" 2>/dev/null | sed 's/VERSION="\([^"]*\)"/\1/' || true)"

  [[ -z "$expected" || -z "$installed" ]] && return 0

  if [[ "$installed" != "$expected" ]]; then
    warn "Version mismatch: installed ${installed}, expected ${expected} — CDN propagation lag, clearing cache and retrying...  版本不一致（已安装 ${installed}，期望 ${expected}），疑似 CDN 未同步，清理缓存后重试..."
    npm cache clean --force &>/dev/null || true
    npm install -g @seanyao/roll@latest &>/dev/null || true
    local after
    after="$(grep "^VERSION=" "${pkg_dir}/@seanyao/roll/bin/roll" 2>/dev/null | sed 's/VERSION="\([^"]*\)"/\1/' || true)"
    if [[ -n "$after" && "$after" != "$expected" ]]; then
      warn "Still on ${after} after retry — registry may not have propagated yet, try again in a minute.  重试后仍为 ${after}，注册表可能尚未同步，请稍后再试。"
    fi
  fi
}

cmd_update() {
  info "Current version: roll v${VERSION}  当前版本: roll v${VERSION}"
  info "Upgrading via npm...  正在通过 npm 升级..."
  echo ""

  if ! npm install -g @seanyao/roll@latest; then
    err "npm install failed. Check network/proxy and try again.  npm 安装失败，请检查网络/代理后重试。"
    exit 1
  fi

  _check_installed_version_or_retry

  echo ""
  info "Re-syncing to AI tools...  正在重新同步到 AI 工具..."
  echo ""
  cmd_setup

  echo ""
  _show_changelog
}

# ─── Helper: merge global AGENTS.md into project (no type prompt) ────────────
# Fresh project: copies global AGENTS.md.
# Existing AGENTS.md: appends any ## sections missing from global.
_merge_global_to_project() {
  local project_dir="$1"
  local src="$ROLL_GLOBAL/AGENTS.md"
  local dst="$project_dir/AGENTS.md"

  [[ -f "$src" ]] || { warn "Global AGENTS.md not found at ${src/#$HOME/~}"; return; }

  # Detect project type — controls which sections are included
  local project_type skip_frontend=false
  project_type="$(scan_project_type_from_files "$project_dir")"
  case "$project_type" in
    cli|backend-service|unknown) skip_frontend=true ;;
  esac

  if [[ ! -f "$dst" ]]; then
    # Fresh create: write sections filtered by project type
    local fc_h="" fc_b="" fc_pre=true fc_want=true
    while IFS= read -r fc_line; do
      if [[ "$fc_line" =~ ^##\  ]]; then
        if [[ -n "$fc_h" && "$fc_want" == "true" ]]; then
          printf '%s\n%s' "$fc_h" "$fc_b" >> "$dst"
        fi
        fc_h="$fc_line"; fc_b=""; fc_pre=false
        fc_want=true
        [[ "$skip_frontend" == "true" && "$fc_h" == "## 7. Frontend Default Stack" ]] && fc_want=false
      elif [[ "$fc_pre" == "true" ]]; then
        printf '%s\n' "$fc_line" >> "$dst"
      else
        fc_b+="$fc_line"$'\n'
      fi
    done < "$src"
    if [[ -n "$fc_h" && "$fc_want" == "true" ]]; then
      printf '%s\n%s' "$fc_h" "$fc_b" >> "$dst"
    fi
    ok "Created: AGENTS.md"
    _ROLL_MERGE_SUMMARY+=("created|AGENTS.md")
    return
  fi

  # Section-merge: append any ## sections from global missing in project
  local added=0 cur_h="" cur_b=""
  while IFS= read -r line; do
    if [[ "$line" =~ ^##\  ]]; then
      if [[ -n "$cur_h" ]] && ! grep -qF "$cur_h" "$dst" 2>/dev/null; then
        local skip_sec=false
        [[ "$skip_frontend" == "true" && "$cur_h" == "## 7. Frontend Default Stack" ]] && skip_sec=true
        if [[ "$skip_sec" == "false" ]]; then
          printf '\n%s\n%s' "$cur_h" "$cur_b" >> "$dst"
          added=$((added + 1))
        fi
      fi
      cur_h="$line"; cur_b=""
    elif [[ -n "$cur_h" ]]; then
      cur_b+="$line"$'\n'
    fi
  done < "$src"
  if [[ -n "$cur_h" ]] && ! grep -qF "$cur_h" "$dst" 2>/dev/null; then
    local skip_sec=false
    [[ "$skip_frontend" == "true" && "$cur_h" == "## 7. Frontend Default Stack" ]] && skip_sec=true
    if [[ "$skip_sec" == "false" ]]; then
      printf '\n%s\n%s' "$cur_h" "$cur_b" >> "$dst"
      added=$((added + 1))
    fi
  fi

  if [[ $added -gt 0 ]]; then
    ok "Merged: AGENTS.md ($added new sections)"
    _ROLL_MERGE_SUMMARY+=("merged|AGENTS.md")
  else
    _ROLL_MERGE_SUMMARY+=("unchanged|AGENTS.md")
  fi
}

_merge_claude_to_project() {
  local project_dir="$1"
  local project_type
  project_type="$(scan_project_type_from_files "$project_dir")"

  local tpl_file="$ROLL_TEMPLATES/$project_type/CLAUDE.md"
  [[ -f "$tpl_file" ]] || return 0  # No template for this project type

  local claude_dir="$project_dir/.claude"
  local out_file="$claude_dir/CLAUDE.md"

  mkdir -p "$claude_dir"

  if [[ ! -f "$out_file" ]]; then
    cp "$tpl_file" "$out_file"
    ok "Created: .claude/CLAUDE.md"
    _ROLL_MERGE_SUMMARY+=("created|.claude/CLAUDE.md")
    return
  fi

  # Append any ## sections from template missing in project file
  local added=0 cur_h="" cur_b=""
  while IFS= read -r line; do
    if [[ "$line" =~ ^##\  ]]; then
      if [[ -n "$cur_h" ]] && ! grep -qF "$cur_h" "$out_file" 2>/dev/null; then
        printf '\n%s\n%s' "$cur_h" "$cur_b" >> "$out_file"
        added=$((added + 1))
      fi
      cur_h="$line"; cur_b=""
    elif [[ -n "$cur_h" ]]; then
      cur_b+="$line"$'\n'
    fi
  done < "$tpl_file"
  if [[ -n "$cur_h" ]] && ! grep -qF "$cur_h" "$out_file" 2>/dev/null; then
    printf '\n%s\n%s' "$cur_h" "$cur_b" >> "$out_file"
    added=$((added + 1))
  fi

  if [[ $added -gt 0 ]]; then
    ok "Merged: .claude/CLAUDE.md ($added new sections)"
    _ROLL_MERGE_SUMMARY+=("merged|.claude/CLAUDE.md")
  else
    _ROLL_MERGE_SUMMARY+=("unchanged|.claude/CLAUDE.md")
  fi
}

# ═══════════════════════════════════════════════════════════════════════════════
# COMMAND: init
# Initialize or re-merge a project. Always operates on the current directory.
# Fresh project: creates AGENTS.md + .roll/backlog.md + .roll/features/
# Existing AGENTS.md: re-merges global conventions (section-level, non-destructive)
# ═══════════════════════════════════════════════════════════════════════════════
cmd_init() {
  # US-ONBOARD-009: --apply consumes onboard-plan.yaml produced by $roll-onboard
  if [[ "${1:-}" == "--apply" ]]; then
    if [[ ! -d "$ROLL_TEMPLATES" ]]; then
      err "No templates found. Run 'roll setup' first.  未找到模板，请先运行 'roll setup'。"
      exit 1
    fi
    shift
    _init_apply "$@"
    return $?
  fi

  if [[ "${1:-}" == -* ]]; then
    err "Unknown flag: $1  未知参数: $1"
    exit 1
  fi

  if [[ ! -d "$ROLL_TEMPLATES" ]]; then
    err "No templates found. Run 'roll setup' first.  未找到模板，请先运行 'roll setup'。"
    exit 1
  fi

  local project_dir
  project_dir="$(pwd)"
  local has_agents=false
  _ROLL_MERGE_SUMMARY=()

  if [[ -f "$project_dir/AGENTS.md" ]]; then
    has_agents=true
  else
    # US-ONBOARD-006: legacy project detection — guide user through $roll-onboard
    # instead of blindly scaffolding files into an existing codebase.
    if _init_is_legacy_project "$project_dir"; then
      _init_legacy_onboard_guide "$project_dir"
      return 0
    fi
  fi

  # FIX-073: Suppress per-step echoes — outcomes are captured into
  # _ROLL_MERGE_SUMMARY and rendered through the v2 UI below.
  {
    _merge_global_to_project "$project_dir"
    _merge_claude_to_project "$project_dir"
    _write_backlog "$project_dir/.roll/backlog.md"
    _ensure_features_dir "$project_dir/.roll/features"
    _write_features_md "$project_dir/.roll/features.md"
    # US-ONBOARD-019: stamp the project so legacy detection can recognise it
    # as Roll-onboarded without depending on directory-name heuristics.
    _write_version_stamp "$project_dir"
  } >/dev/null

  local sync_status="ok"
  if ! _sync_conventions >/dev/null 2>&1; then
    sync_status="fail"
  fi

  _install_launchd_plists "$project_dir" >/dev/null 2>&1 || true

  _emit_init_v2_ui "$project_dir" "$has_agents" "$sync_status"
}

# FIX-073: Build a real-data JSON payload from _ROLL_MERGE_SUMMARY and pipe it
# to lib/roll-init.py for v2 UI rendering. Replaces the previous demo-only
# render path.
_emit_init_v2_ui() {
  local project_dir="$1"
  local has_agents="$2"
  local sync_status="${3:-ok}"

  local header_label="INIT" subtitle="项目初始化" footer_label="Initialized"
  if [[ "$has_agents" == "true" ]]; then
    header_label="REINIT"
    subtitle="重新合并约定"
    footer_label="Re-merged"
  fi

  local color_flag=""
  if [[ -n "${NO_COLOR:-}" ]] || ! [ -t 1 ]; then
    color_flag="--no-color"
  fi

  python3 - "$project_dir" "$header_label" "$subtitle" "$footer_label" "$sync_status" "${_ROLL_MERGE_SUMMARY[@]}" <<'PY' \
    | python3 "${ROLL_PKG_DIR}/lib/roll-init.py" $color_flag
import json, sys
project_dir, header_label, subtitle, footer_label, sync_status, *summary = sys.argv[1:]
STATUS = {"created":"ok","merged":"ok","unchanged":"skip","overwritten":"ok","kept":"skip"}
OP = {"created":"+","merged":"~","unchanged":"·","overwritten":"~","kept":"·"}
by_file = {}
for entry in summary:
    action, _sep, fname = entry.partition("|")
    if fname:
        by_file[fname] = action

def step(num, label, fname):
    act = by_file.get(fname)
    if not act:
        return {"num": num, "label": label, "status": "skip",
                "note": "not modified"}
    return {"num": num, "label": label, "status": STATUS.get(act, "ok"),
            "files": [[OP.get(act, "·"), fname]]}

steps = [
    {"num": 1, "label": "Detect project type", "status": "ok"},
    step(2, "Create AGENTS.md",           "AGENTS.md"),
    step(3, "Create .roll/backlog.md",    ".roll/backlog.md"),
    step(4, "Create .roll/features/",     ".roll/features/"),
    step(5, "Merge existing CLAUDE.md",   ".claude/CLAUDE.md"),
    {"num": 6, "label": "Link skills to AI clients", "status": sync_status},
]
footer_status = "fail" if any(s["status"] == "fail" for s in steps) else "ok"
payload = {
    "header_label": header_label,
    "subtitle":     subtitle,
    "project_path": project_dir,
    "steps":        steps,
    "footer":       {"status": footer_status,
                     "label": footer_label if footer_status == "ok" else "Init incomplete"},
    "next": [
        ["Edit .roll/backlog.md",   "open the backlog and add your first US"],
        ["Run roll loop now",       "execute one cycle manually to test the flow"],
        ["Enable loop scheduling",  "roll loop on  — let it run hourly"],
    ],
}
print(json.dumps(payload))
PY
}

# US-ONBOARD-006: Legacy detection
# A project is "Legacy" if it has substantive code but no AGENTS.md to anchor
# Roll conventions. US-ONBOARD-012 widened the recogniser to cover non-canonical
# layouts (WeChat mini-program, Python flat, Terraform, etc.) — any of:
#   1. Classic layout: src/app/lib/pkg/cmd contains ≥10 non-empty files.
#   2. A manifest of any common ecosystem at the project root.
#   3. Git history exists (at least one commit on HEAD).
# Either signal alone is enough; the AGENTS.md check happens earlier.
_init_is_legacy_project() {
  local project_dir="$1"

  # Signal 1 — classic source layout
  local dir count
  for dir in src app lib pkg cmd; do
    if [[ -d "$project_dir/$dir" ]]; then
      count=$(find "$project_dir/$dir" -type f -not -empty 2>/dev/null | wc -l | tr -d ' ')
      if [[ "$count" -ge 10 ]]; then
        return 0
      fi
    fi
  done

  # Signal 2 — manifest file at project root
  local manifest
  for manifest in \
      package.json pyproject.toml requirements.txt setup.py setup.cfg Pipfile \
      go.mod Cargo.toml Gemfile pom.xml build.gradle build.gradle.kts \
      Makefile Dockerfile docker-compose.yml docker-compose.yaml \
      app.json project.config.json \
      mix.exs composer.json deno.json deno.jsonc; do
    [[ -f "$project_dir/$manifest" ]] && return 0
  done
  # Terraform: any *.tf at the root
  if compgen -G "$project_dir/*.tf" >/dev/null 2>&1; then
    return 0
  fi

  # Signal 3 — git history exists
  if [[ -d "$project_dir/.git" ]] || [[ -f "$project_dir/.git" ]]; then
    if ( cd "$project_dir" && git rev-parse --verify HEAD >/dev/null 2>&1 ); then
      return 0
    fi
  fi

  return 1
}

# US-ONBOARD-006: Agent discovery + token consumption notice + onboard guidance.
# US-ONBOARD-018: also auto-launches the chosen agent in interactive mode with
# the $roll-onboard skill content pre-loaded as the initial prompt, then chains
# into `roll init --apply` when the conversation ends successfully.
_init_legacy_onboard_guide() {
  local project_dir="$1"
  local count_summary
  count_summary=$(_init_legacy_file_summary "$project_dir")

  info "Detected: legacy project (${count_summary})  检测到遗留项目"
  echo ""

  # Discover installed agents (writes to globals: _ONBOARD_INSTALLED, _ONBOARD_MISSING).
  _onboard_discover_agents

  echo "  Onboarding 需要一个 AI agent 来读懂这个项目。检测到："
  echo "  Onboarding requires an AI agent to read your code. Detected:"
  echo ""
  local n
  if [[ ${#_ONBOARD_INSTALLED[@]} -gt 0 ]]; then
    for n in "${_ONBOARD_INSTALLED[@]}"; do
      printf "    %b✓%b %s   (installed)\n" "${GREEN}" "${NC}" "$n"
    done
  fi
  if [[ ${#_ONBOARD_MISSING[@]} -gt 0 ]]; then
    for n in "${_ONBOARD_MISSING[@]}"; do
      printf "    %b✗%b %s   (not found)\n" "${RED}" "${NC}" "$n"
    done
  fi

  if [[ ${#_ONBOARD_INSTALLED[@]} -eq 0 ]]; then
    echo ""
    err "No AI agent detected. Install one (e.g., 'claude', 'codex', 'kimi') and try again."
    err "未检测到 AI agent。请先安装 (如 claude / codex / kimi) 后重试。"
    return 1
  fi

  echo ""
  echo "  后续过程会使用你的 agent 调用模型，token 消耗在你自己的账户上。"
  echo "  Onboarding uses your agent to call models — tokens are billed to your account."
  echo ""
  echo "  代码与对话都留在你的 agent 工具里 —— Roll 本身不上传任何内容。"
  echo "  Your code and conversation stay in your agent — Roll never uploads anything."
  echo ""

  # US-ONBOARD-018: select an agent. Single installed → auto-pick. Multiple
  # installed → ask the user (or honour ROLL_ONBOARD_AGENT for non-interactive
  # callers and tests).
  local chosen
  chosen=$(_onboard_select_agent "${_ONBOARD_INSTALLED[@]}") || return 1
  [[ -n "$chosen" ]] || return 1

  echo ""
  info "Launching ${chosen}…  正在启动 ${chosen}…"
  echo "  Conversation ends with /exit (or Ctrl-C). On exit Roll will run apply for you."
  echo "  在 agent 内用 /exit 结束（或 Ctrl-C）。退出后 Roll 会自动衔接 apply。"
  echo ""

  # US-ONBOARD-018: actually run the agent with the onboard prompt pre-loaded.
  _run_onboard_agent "$chosen" "$project_dir"
}

# US-ONBOARD-018: discover installed AI agents from ROLL_CONFIG.
# Populates global arrays _ONBOARD_INSTALLED and _ONBOARD_MISSING.
# Extracted from _init_legacy_onboard_guide so it can be unit-tested.
_onboard_discover_agents() {
  _ONBOARD_INSTALLED=()
  _ONBOARD_MISSING=()
  local _key _value _name _dir
  while IFS=: read -r _key _value; do
    [[ "$_key" =~ ^ai_ ]] || continue
    _name="${_key#ai_}"
    _dir="${_value%%|*}"
    _dir="${_dir# }"
    _dir="${_dir/#\~/$HOME}"
    if [[ -d "$_dir" ]]; then
      _ONBOARD_INSTALLED+=("$_name")
    else
      _ONBOARD_MISSING+=("$_name")
    fi
  done < "$ROLL_CONFIG"
}

# US-ONBOARD-018: pick an agent for the onboard flow.
# - $ROLL_ONBOARD_AGENT (env) wins if set and present in the candidate list.
# - Single candidate → auto-pick (printed to stdout).
# - Multiple candidates → prompt user for a number. Stdin EOF / invalid input → return 1.
# Echoes the chosen agent name on stdout, nothing else.
_onboard_select_agent() {
  local -a candidates=("$@")
  [[ ${#candidates[@]} -gt 0 ]] || return 1

  # Explicit override (tests, CI, dotfile aliases).
  if [[ -n "${ROLL_ONBOARD_AGENT:-}" ]]; then
    local c
    for c in "${candidates[@]}"; do
      if [[ "$c" == "$ROLL_ONBOARD_AGENT" ]]; then
        printf '%s\n' "$c"
        return 0
      fi
    done
    err "ROLL_ONBOARD_AGENT='${ROLL_ONBOARD_AGENT}' is not in installed agents." >&2
    return 1
  fi

  if [[ ${#candidates[@]} -eq 1 ]]; then
    printf '%s\n' "${candidates[0]}"
    return 0
  fi

  # Multi-agent: prompt the user. To stderr so stdout stays clean for the caller.
  {
    echo "  选一个 agent  Pick an agent:"
    local i=1
    for c in "${candidates[@]}"; do
      printf "    %d) %s\n" "$i" "$c"
      i=$((i + 1))
    done
    printf "  Enter number [1-%d]: " "${#candidates[@]}"
  } >&2

  local choice
  if ! IFS= read -r choice; then
    err "No input received. Aborting onboard.  未收到输入，已取消 onboard。" >&2
    return 1
  fi
  if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#candidates[@]} )); then
    err "Invalid choice: '${choice}'.  无效选择。" >&2
    return 1
  fi
  printf '%s\n' "${candidates[$((choice - 1))]}"
}

# US-ONBOARD-018: compose the initial prompt for the onboard agent —
# the $roll-onboard skill body (frontmatter stripped) plus a kickoff line.
# Returns 1 if the skill file is missing.
_onboard_initial_prompt() {
  local skill_file="${ROLL_PKG_DIR}/skills/roll-onboard/SKILL.md"
  [[ -f "$skill_file" ]] || {
    err "Skill file missing: ${skill_file}" >&2
    return 1
  }
  # Lead line orients the agent before the skill body. Keep it stable; tests
  # match on this exact prefix.
  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."
  _skill_content "$skill_file"
}

# US-ONBOARD-018: print retry / switch-agent guidance after a failed onboard run.
# Extracted so we can unit-test the wording without spawning subprocesses.
# $1 = chosen agent name, $2 = exit code from the agent.
_onboard_failure_hint() {
  local agent="$1" code="$2"
  echo "" >&2
  if [[ "$code" == "130" ]]; then
    err "Onboard cancelled (Ctrl-C).  Onboard 已取消（Ctrl-C）。" >&2
  else
    err "Agent '${agent}' exited with code ${code}.  agent '${agent}' 异常退出 (code ${code})。" >&2
  fi
  echo "" >&2
  echo "  下一步  Next step:" >&2
  echo "    - 再试一次同一个 agent:  roll init" >&2
  echo "    - retry the same agent:   roll init" >&2
  echo "    - 换一个 agent:           ROLL_ONBOARD_AGENT=<name> roll init" >&2
  echo "    - switch to another:      ROLL_ONBOARD_AGENT=<name> roll init" >&2
  echo "" >&2
}

# US-ONBOARD-018: launch the chosen agent in interactive mode with the onboard
# prompt pre-loaded, wait for it to exit, then branch:
#   exit 0  + .roll/onboard-plan.yaml present → chain into roll init --apply
#   exit 0  + plan missing                    → tell user to re-run if they want
#   exit !0 (incl. 130 from SIGINT)           → print retry / switch-agent hint
# $1 = chosen agent name, $2 = project dir
_run_onboard_agent() {
  local agent="$1" project_dir="$2"
  local prompt
  prompt=$(_onboard_initial_prompt) || return 1
  _agent_argv "$agent" interactive "$prompt" || {
    err "Agent '${agent}' has no interactive mode wired up.  agent '${agent}' 暂未接入 interactive 模式。" >&2
    return 1
  }

  # Run attached to the user's tty so the agent's REPL gets stdin/stdout/stderr.
  # `set -e` is active script-wide; suppress with `|| rc=$?` so the failure
  # branch (SIGINT 130, agent error) can be handled instead of aborting init.
  local rc=0
  "${_AGENT_ARGV[@]}" || rc=$?

  if [[ "$rc" -ne 0 ]]; then
    _onboard_failure_hint "$agent" "$rc"
    return "$rc"
  fi

  if [[ ! -f "${project_dir}/.roll/onboard-plan.yaml" ]]; then
    echo "" >&2
    err "Agent exited cleanly but did not write .roll/onboard-plan.yaml." >&2
    err "agent 正常退出但未生成 .roll/onboard-plan.yaml。" >&2
    echo "  Re-run \`roll init\` once you've completed the conversation." >&2
    echo "  对话完成后再次运行 \`roll init\` 即可。" >&2
    return 1
  fi

  # Plan present → chain into apply automatically.
  echo "" >&2
  info "Plan written. Running apply…  已写入 plan，正在执行 apply…"
  ( cd "$project_dir" && _init_apply )
}

# Helper: human-readable summary of why this is detected as legacy.
_init_legacy_file_summary() {
  local project_dir="$1"
  local dir count parts=()
  for dir in src app lib pkg cmd; do
    if [[ -d "$project_dir/$dir" ]]; then
      count=$(find "$project_dir/$dir" -type f -not -empty 2>/dev/null | wc -l | tr -d ' ')
      if [[ "$count" -ge 10 ]]; then
        parts+=("${count} files in ${dir}/")
      fi
    fi
  done
  # US-ONBOARD-012: surface non-canonical signals in the summary too.
  local manifest
  for manifest in \
      package.json pyproject.toml requirements.txt setup.py setup.cfg Pipfile \
      go.mod Cargo.toml Gemfile pom.xml build.gradle build.gradle.kts \
      Makefile Dockerfile docker-compose.yml docker-compose.yaml \
      app.json project.config.json \
      mix.exs composer.json deno.json deno.jsonc; do
    if [[ -f "$project_dir/$manifest" ]]; then
      parts+=("manifest: $manifest")
      break
    fi
  done
  if compgen -G "$project_dir/*.tf" >/dev/null 2>&1; then
    parts+=("Terraform .tf files")
  fi
  if [[ ${#parts[@]} -eq 0 ]] \
      && { [[ -d "$project_dir/.git" ]] || [[ -f "$project_dir/.git" ]]; } \
      && ( cd "$project_dir" && git rev-parse --verify HEAD >/dev/null 2>&1 ); then
    parts+=("git history present")
  fi
  echo "no AGENTS.md, ${parts[*]}"
}

# US-ONBOARD-013: changeset recording — onboard writes a manifest of every
# side effect (files created, .gitignore entries, scope) into
# .roll/onboard-changeset.yaml so `roll offboard` has a rollback record.
# Without this, a user who wants to retire Roll from a project has to guess
# which files came from onboard vs their own work.
_onboard_changeset_path() {
  echo "$1/.roll/onboard-changeset.yaml"
}

# Begin a fresh changeset record. Overwrites any prior file — every apply
# starts from a clean slate; offboard reads the latest record.
_onboard_changeset_begin() {
  local project_dir="$1"
  local path; path=$(_onboard_changeset_path "$project_dir")
  mkdir -p "$(dirname "$path")"
  cat > "$path" <<EOF
# Generated by \`roll init --apply\`. Used by \`roll offboard\` to reverse
# the changes onboard made. Do not edit by hand.
onboarded_at: "$(date -u +%FT%TZ)"
roll_version: "$(_pkg_version 2>/dev/null || echo unknown)"
scope_approved: []
files_created: []
dirs_created: []
gitignore_entries_added: []
launchd_plists_installed: []
EOF
}

# Append a YAML list entry to a section in the changeset.
_onboard_changeset_record() {
  local project_dir="$1" section="$2" value="$3"
  local path; path=$(_onboard_changeset_path "$project_dir")
  [ -f "$path" ] || return 0
  # Each section line ends with `: []` — replace with the new value on first
  # entry, otherwise append a `- <value>` line under the section.
  if grep -qE "^${section}: \[\]$" "$path"; then
    local tmp; tmp=$(mktemp)
    awk -v sec="$section" -v val="$value" '
      $0 ~ "^" sec ": \\[\\]$" {
        print sec ":"
        print "  - \"" val "\""
        next
      }
      { print }
    ' "$path" > "$tmp" && mv "$tmp" "$path"
  else
    # Find the section header and insert under it (after the last entry).
    local tmp; tmp=$(mktemp)
    awk -v sec="$section" -v val="$value" '
      $0 ~ "^" sec ":$" {
        print
        in_sec=1; next
      }
      in_sec && /^[a-z_]+:/ {
        print "  - \"" val "\""
        in_sec=0
        print; next
      }
      { print }
      END {
        if (in_sec) print "  - \"" val "\""
      }
    ' "$path" > "$tmp" && mv "$tmp" "$path"
  fi
}

# US-ONBOARD-009: roll init --apply
# Consume .roll/onboard-plan.yaml (produced by $roll-onboard skill) and execute
# all side effects: create .roll/ structure per scope, sync AI tools, write
# .gitignore based on user's Q7 choice.
#
# Plan validation is delegated to lib/roll-plan-validate.py to avoid bash YAML
# parsing fragility.
_init_apply() {
  local project_dir; project_dir="$(pwd)"
  local plan="${project_dir}/.roll/onboard-plan.yaml"
  local validator="${ROLL_PKG_DIR}/lib/roll-plan-validate.py"

  if [[ ! -f "$plan" ]]; then
    err "No onboard plan found at .roll/onboard-plan.yaml  未找到 onboard 计划。"
    echo "" >&2
    echo "  Run \$roll-onboard in your AI agent first to generate the plan." >&2
    echo "  请先在 AI agent 里运行 \$roll-onboard 生成 plan，再回来执行 apply。" >&2
    return 1
  fi

  if [[ ! -f "$validator" ]]; then
    err "Plan validator missing: $validator  校验器缺失。"
    return 1
  fi

  # Validate plan (schema + generated_at freshness + version)
  if ! python3 "$validator" "$plan"; then
    err "Plan validation failed. See errors above.  Plan 校验失败。"
    echo "" >&2
    echo "  If the plan is stale (>24h), regenerate by running \$roll-onboard again." >&2
    return 1
  fi

  info "Applying onboard plan...  正在应用 onboard 计划..."
  _ROLL_MERGE_SUMMARY=()

  # US-ONBOARD-013: start a fresh changeset record so offboard can reverse.
  _onboard_changeset_begin "$project_dir"

  # Read scope from plan (simple grep — validator confirmed structure)
  local approved
  approved=$(python3 -c "
import yaml, sys
p = yaml.safe_load(open('$plan'))
print(' '.join(p.get('scope', {}).get('approved', [])))
" 2>/dev/null || echo "")

  # Record each approved scope entry for offboard's selective rollback.
  local item
  for item in $approved; do
    _onboard_changeset_record "$project_dir" "scope_approved" "$item"
  done

  _merge_global_to_project "$project_dir"
  _merge_claude_to_project "$project_dir"

  # US-ONBOARD-019: stamp the project at onboard-apply time so subsequent
  # invocations recognise it as Roll-onboarded (and offboard can sweep it).
  local _stamp_existed=true
  [[ -f "$project_dir/.roll/.version" ]] || _stamp_existed=false
  _write_version_stamp "$project_dir"
  if [[ "$_stamp_existed" == "false" ]] && [[ -f "$project_dir/.roll/.version" ]]; then
    _onboard_changeset_record "$project_dir" "files_created" ".roll/.version"
  fi

  # Create .roll/ artifacts based on scope.approved
  if [[ " $approved " == *" backlog "* ]]; then
    _write_backlog "$project_dir/.roll/backlog.md"
    _onboard_changeset_record "$project_dir" "files_created" ".roll/backlog.md"
  fi
  if [[ " $approved " == *" features "* ]]; then
    _ensure_features_dir "$project_dir/.roll/features"
    _write_features_md "$project_dir/.roll/features.md"
    _onboard_changeset_record "$project_dir" "dirs_created" ".roll/features"
    _onboard_changeset_record "$project_dir" "files_created" ".roll/features.md"
  fi
  if [[ " $approved " == *" domain "* ]]; then
    mkdir -p "$project_dir/.roll/domain"
    _onboard_changeset_record "$project_dir" "dirs_created" ".roll/domain"
  fi
  if [[ " $approved " == *" briefs "* ]]; then
    mkdir -p "$project_dir/.roll/briefs"
    _onboard_changeset_record "$project_dir" "dirs_created" ".roll/briefs"
  fi

  print_merge_summary

  # Q7: .gitignore preference
  local gitignore_roll
  gitignore_roll=$(python3 -c "
import yaml
p = yaml.safe_load(open('$plan'))
print('true' if p.get('privacy', {}).get('gitignore_dot_roll', False) else 'false')
" 2>/dev/null || echo "false")

  if [[ "$gitignore_roll" == "true" ]]; then
    local gi="$project_dir/.gitignore"
    if ! grep -qFx ".roll/" "$gi" 2>/dev/null; then
      echo ".roll/" >> "$gi"
      _onboard_changeset_record "$project_dir" "gitignore_entries_added" ".roll/"
      ok "Added .roll/ to .gitignore  已将 .roll/ 加入 .gitignore"
    fi
  fi

  echo ""
  info "Syncing conventions to AI tools...  正在同步约定到 AI 工具..."
  _sync_conventions
  echo ""

  ok "Onboard apply complete.  Onboard 应用完成。"
}

# US-ONBOARD-014: roll offboard
# Reverse what `roll init --apply` (US-ONBOARD-009/013) did, using the
# changeset manifest at .roll/onboard-changeset.yaml as the rollback record.
# Safety contract:
#   1. Refuse when no changeset exists — print manual instructions instead.
#   2. Default to dry-run; only `--confirm` (or `-y`) actually deletes.
#   3. Only touch entries that are in the manifest. Anything else stays put.
#   4. Refuse to delete a file/dir whose path does not resolve under the
#      current project root, even if the changeset says so (cross-project
#      guard). Print the safe manual command instead.
cmd_offboard() {
  local confirm=0
  local arg
  for arg in "$@"; do
    case "$arg" in
      --confirm|-y) confirm=1 ;;
      --help|-h)
        echo "Usage: roll offboard [--confirm]"
        echo "  Preview (default) or apply (--confirm) the removal of every"
        echo "  artefact recorded in .roll/onboard-changeset.yaml."
        return 0
        ;;
      *)
        err "Unknown flag: $arg  未知参数"
        return 1
        ;;
    esac
  done

  local project_dir; project_dir="$(pwd -P)"
  local changeset; changeset=$(_onboard_changeset_path "$project_dir")

  if [[ ! -f "$changeset" ]]; then
    err "No onboard changeset found at .roll/onboard-changeset.yaml"
    err "未找到 onboard 变更清单 .roll/onboard-changeset.yaml"
    echo "" >&2
    echo "  Manual offboard — remove these by hand if they came from Roll:" >&2
    echo "    rm -rf .roll/                  # all process artefacts" >&2
    echo "    rm -f AGENTS.md CLAUDE.md      # only if they were generated by roll init" >&2
    echo "    Edit .gitignore to remove any '.roll/' entry" >&2
    return 1
  fi

  # Parse changeset (Python keeps YAML semantics consistent with apply).
  local parser
  parser=$(python3 - "$changeset" <<'PY'
import sys, yaml
try:
    data = yaml.safe_load(open(sys.argv[1])) or {}
except Exception as e:
    print(f"PARSE_ERROR:{e}", file=sys.stderr)
    sys.exit(2)
def pr(section):
    for v in (data.get(section) or []):
        print(f"{section}\t{v}")
pr("files_created")
pr("dirs_created")
pr("gitignore_entries_added")
pr("launchd_plists_installed")
PY
  )
  if [[ $? -ne 0 ]]; then
    err "Failed to parse changeset  解析变更清单失败"
    return 1
  fi

  local files=() dirs=() gi_entries=() plists=()
  while IFS=$'\t' read -r section value; do
    [[ -z "$section" ]] && continue
    case "$section" in
      files_created)           files+=("$value") ;;
      dirs_created)            dirs+=("$value") ;;
      gitignore_entries_added) gi_entries+=("$value") ;;
      launchd_plists_installed) plists+=("$value") ;;
    esac
  done <<< "$parser"

  # Cross-project guard — verify every recorded path resolves under
  # project_dir. Catches the case where a user accidentally points roll
  # offboard at a directory whose changeset names paths from elsewhere.
  local item resolved
  local _all=()
  [ "${#files[@]}" -gt 0 ] && _all+=("${files[@]}")
  [ "${#dirs[@]}" -gt 0 ]  && _all+=("${dirs[@]}")
  for item in "${_all[@]:+${_all[@]}}"; do
    case "$item" in
      /*) resolved="$item" ;;        # absolute — must already start with project_dir
      *)  resolved="$project_dir/$item" ;;
    esac
    case "$resolved" in
      "$project_dir"|"$project_dir"/*) ;;
      *)
        err "Refusing to act on '$item' — it does not resolve under $project_dir"
        err "拒绝处理 '$item' — 路径不在当前项目下，可能是误用"
        echo "  This usually means the changeset was copied from another project." >&2
        echo "  Remove .roll/onboard-changeset.yaml manually, or rerun in the right dir." >&2
        return 1
        ;;
    esac
  done

  # Print the plan.
  echo ""
  echo -e "  ${BOLD}Offboard plan for ${project_dir}${NC}"
  echo ""
  if [[ ${#files[@]} -gt 0 ]]; then
    echo -e "  ${RED}Files to remove:${NC}"
    for item in "${files[@]}"; do echo "    rm   $item"; done
    echo ""
  fi
  if [[ ${#dirs[@]} -gt 0 ]]; then
    echo -e "  ${RED}Directories to remove:${NC}"
    for item in "${dirs[@]}"; do echo "    rmdir/r $item"; done
    echo ""
  fi
  if [[ ${#gi_entries[@]} -gt 0 ]]; then
    echo -e "  ${YELLOW}.gitignore entries to remove:${NC}"
    for item in "${gi_entries[@]}"; do echo "    -    $item"; done
    echo ""
  fi
  if [[ ${#plists[@]} -gt 0 ]]; then
    echo -e "  ${YELLOW}launchd plists to unload:${NC}"
    for item in "${plists[@]}"; do echo "    unload $item"; done
    echo ""
  fi
  if [[ ${#files[@]} -eq 0 && ${#dirs[@]} -eq 0 && ${#gi_entries[@]} -eq 0 && ${#plists[@]} -eq 0 ]]; then
    info "Changeset is empty — nothing to offboard."
    info "变更清单为空，无需 offboard。"
    return 0
  fi

  if [[ "$confirm" -ne 1 ]]; then
    echo "  This is a dry-run. Re-run with --confirm to apply."
    echo "  以上为预演结果。加 --confirm 后才会真正执行。"
    return 0
  fi

  # Apply. Guard every loop with a count check — `set -u` upstream makes
  # naked `"${arr[@]}"` over an empty array a hard error on bash 5.0.
  echo "  Applying offboard...  执行 offboard..."
  if [ "${#files[@]}" -gt 0 ]; then
    for item in "${files[@]}"; do
      rm -f "$project_dir/$item" 2>/dev/null && echo "    removed file $item"
    done
  fi
  if [ "${#dirs[@]}" -gt 0 ]; then
    for item in "${dirs[@]}"; do
      rm -rf "$project_dir/$item" 2>/dev/null && echo "    removed dir  $item"
    done
  fi
  if [ "${#gi_entries[@]}" -gt 0 ]; then
    for item in "${gi_entries[@]}"; do
      local gi="$project_dir/.gitignore"
      if [[ -f "$gi" ]] && grep -qFx "$item" "$gi"; then
        local tmp; tmp=$(mktemp)
        grep -vFx "$item" "$gi" > "$tmp" || true
        mv "$tmp" "$gi"
        echo "    .gitignore -   $item"
      fi
    done
  fi
  if [ "${#plists[@]}" -gt 0 ]; then
    for item in "${plists[@]}"; do
      launchctl unload -w "$HOME/Library/LaunchAgents/$item" 2>/dev/null && echo "    unloaded     $item"
      rm -f "$HOME/Library/LaunchAgents/$item" 2>/dev/null
    done
  fi
  # Finally, remove the changeset file itself.
  rm -f "$changeset"
  ok "Offboard complete.  Offboard 完成。"
}

# ═══════════════════════════════════════════════════════════════════════════════
# cmd_migrate
# US-ONBOARD-003: One-shot migration from old project layout to .roll/ structure.
#
# Moves process artifacts (.roll/backlog.md, .roll/proposals.md, .roll/features/, .roll/briefs/,
# .roll/dream/, .roll/design/, .roll/domain/) into .roll/. Also relocates user docs
# (guide/ → guide/, site/ → site/, site/slides/ → site/slides/).
#
# Three-state idempotency:
#   - old-only:   execute migration via git mv (single atomic commit)
#   - new-only:   no-op with "already migrated" message
#   - both:       error with conflict list (manual resolution required)
#   - neither:    no-op with "nothing to migrate"
# ═══════════════════════════════════════════════════════════════════════════════
cmd_migrate() {
  local dry_run=false
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dry-run|-n) dry_run=true; shift ;;
      -h|--help) _migrate_help; return 0 ;;
      *) err "Unknown arg: $1  未知参数: $1"; return 1 ;;
    esac
  done

  # Must be in a git repo (git mv preserves history)
  if ! git rev-parse --git-dir >/dev/null 2>&1; then
    err "Not a git repository. roll migrate requires git.  当前目录不是 git 仓库。"
    return 1
  fi

  # Build canonical migration plan (read from stdout to avoid bash 4.3+ nameref)
  local -a moves=()
  while IFS= read -r _line; do moves+=("$_line"); done < <(_migrate_build_moves)

  # Detect state: do old paths exist? does .roll/ exist?
  local has_old=false has_new=false
  [[ -e .roll ]] && has_new=true
  local m src
  for m in "${moves[@]}"; do
    src="${m%%|*}"
    if [[ -e "$src" ]]; then
      has_old=true
      break
    fi
  done

  # Three-state dispatch
  if [[ "$has_new" == "true" && "$has_old" == "true" ]]; then
    err "Both old and new structures exist (partial migration detected).  老结构与新结构同时存在（部分迁移）。"
    echo "" >&2
    echo "Conflicting paths:  冲突路径：" >&2
    for m in "${moves[@]}"; do
      src="${m%%|*}"
      local tgt="${m##*|}"
      if [[ -e "$src" && -e "$tgt" ]]; then
        echo "  - $src  AND  $tgt  both exist" >&2
      fi
    done
    echo "" >&2
    err "Resolve manually then re-run.  请手动解决冲突后重新运行。"
    return 1
  fi

  if [[ "$has_new" == "true" && "$has_old" == "false" ]]; then
    ok "Already migrated. .roll/ exists, no old paths found.  已迁移，无需重复操作。"
    return 0
  fi

  if [[ "$has_old" == "false" ]]; then
    info "No old structure detected. Nothing to migrate.  未发现老结构，无需迁移。"
    return 0
  fi

  # State: old-only — proceed. Filter to actually existing paths.
  local -a active_moves=()
  for m in "${moves[@]}"; do
    src="${m%%|*}"
    [[ -e "$src" ]] && active_moves+=("$m")
  done

  if [[ ${#active_moves[@]} -eq 0 ]]; then
    warn "Old structure markers found but no migratable files.  未找到可迁移文件。"
    return 0
  fi

  if [[ "$dry_run" == "true" ]]; then
    _migrate_preview "${active_moves[@]}"
    return 0
  fi

  # Real execution requires clean working tree (we'll create a single commit)
  if ! git diff --quiet --ignore-submodules HEAD 2>/dev/null; then
    err "Working tree not clean. Commit or stash changes before running migrate.  工作区有未提交改动，请先 commit 或 stash。"
    return 1
  fi

  _migrate_execute "${active_moves[@]}"
}

# Build canonical migration plan as "src|target" pairs (one per line).
# Single source of truth for what migrates where.
# Returns via stdout (bash 3.2-compatible — no nameref).
_migrate_build_moves() {
  # Order matters: directory-renames must precede moves whose target_dir is
  # the same dir. Otherwise mkdir -p pre-creates the target, and git mv then
  # places the source INSIDE rather than renaming. Specifically:
  #   - docs/site → site   must precede docs/intro → site/slides
  #   - docs/guide/en → guide/en  must precede docs/practices/engineering-common-sense.md
  #
  # IMPORTANT: the LEFT side of each mapping is a literal OLD path. These must
  # NOT be sed'd during Story 5 code-ref migration — they drive the migration
  # for OTHER projects (and self-migrate idempotency).
  cat << 'EOF'
BACKLOG.md|.roll/backlog.md
PROPOSALS.md|.roll/proposals.md
docs/features.md|.roll/features.md
docs/features|.roll/features
docs/briefs|.roll/briefs
docs/dream|.roll/dream
docs/design|.roll/design
docs/domain|.roll/domain
docs/practices/loop-autorun-verification.md|.roll/verification/loop-autorun-verification.md
docs/site|site
docs/intro|site/slides
docs/guide/en|guide/en
docs/guide/zh|guide/zh
docs/practices/engineering-common-sense.md|guide/en/practices/engineering-common-sense.md
EOF
}

_migrate_preview() {
  info "Migration preview (dry-run):  迁移预览（dry-run）"
  echo ""
  printf "  %-60s → %s\n" "Old path  老路径" "New path  新路径"
  local sep; sep=$(printf '─%.0s' {1..100})
  printf "  %s\n" "$sep"
  local m
  for m in "$@"; do
    local src="${m%%|*}" tgt="${m##*|}"
    printf "  %-60s → %s\n" "$src" "$tgt"
  done
  echo ""
  info "Run without --dry-run to execute.  去掉 --dry-run 即可真实执行。"
}

_migrate_execute() {
  info "Migrating ${#@} paths via git mv...  正在通过 git mv 迁移 ${#@} 个路径..."
  local moved=0 m
  for m in "$@"; do
    local src="${m%%|*}" tgt="${m##*|}"
    local target_dir; target_dir=$(dirname "$tgt")
    [[ -d "$target_dir" ]] || mkdir -p "$target_dir"
    git mv "$src" "$tgt" || {
      err "git mv failed: $src → $tgt"
      err "Aborting; previous moves are staged but not committed. Run 'git reset --hard' to undo.  已 stage 但未 commit，运行 'git reset --hard' 回滚。"
      return 1
    }
    moved=$((moved + 1))
  done
  # Clean up empty docs/ shells
  if [[ -d "docs" ]]; then
    find docs -type d -empty -delete 2>/dev/null || true
  fi
  # Single atomic commit
  git commit --quiet -m "Migrate project layout to .roll/ structure

Atomic migration via 'roll migrate' command. Process artifacts moved
from root and docs/ into .roll/; user docs relocated to guide/ and site/.

Paths migrated: ${moved}"
  ok "Migrated ${moved} paths in a single commit.  已在单 commit 中迁移 ${moved} 个路径。"
  echo ""
  echo "  Next steps  下一步："
  echo "    git log -1                    # Inspect the migration commit"
  echo "    roll status                   # Verify new structure"
}

_migrate_help() {
  cat << 'EOF'
Usage: roll migrate [--dry-run]

Migrate this project's process artifacts (.roll/backlog.md, .roll/proposals.md,
.roll/features/, .roll/briefs/, .roll/dream/, .roll/design/, .roll/domain/)
into a .roll/ directory. Also relocates guide/ → guide/,
site/ → site/, site/slides/ → site/slides/.

Options:
  --dry-run, -n   Show what would be moved without modifying files
  --help, -h      Show this help

Three-state idempotency:
  - Only old paths present  → migration executes (single atomic commit)
  - Only .roll/ present     → no-op (already migrated)
  - Both present            → error with conflict list (manual review)
  - Neither                 → no-op (nothing to migrate)

Preconditions:
  - Current directory is a git repository
  - Working tree is clean (commit or stash changes first)

Uses git mv to preserve file history. On success, produces a single commit.
EOF
}

# ─── Helper: print a tidy summary of merge actions ───────────────────────────
print_merge_summary() {
  if [[ ${#_ROLL_MERGE_SUMMARY[@]} -eq 0 ]]; then
    return
  fi
  echo ""
  echo "  ┌─ 操作摘要 Summary ──────────────────────────────────┐"
  for entry in "${_ROLL_MERGE_SUMMARY[@]}"; do
    local action="${entry%%|*}"
    local file="${entry##*|}"
    case "$action" in
      merged)      printf "  │  ${GREEN}✦ merged${NC}      %-30s│\n" "$file" ;;
      created)     printf "  │  ${GREEN}+ created${NC}     %-30s│\n" "$file" ;;
      overwritten) printf "  │  ${YELLOW}↺ overwritten${NC} %-30s│\n" "$file" ;;
      kept)        printf "  │  ${CYAN}· kept${NC}        %-30s│\n" "$file" ;;
      unchanged)   printf "  │    unchanged    %-30s│\n" "$file" ;;
    esac
  done
  echo "  └─────────────────────────────────────────────────────┘"
}

# ─── Helper: auto-detect project type by scanning project files ──────────────
scan_project_type_from_files() {
  local dir="${1:-.}"
  local has_frontend=false
  local has_backend=false
  local has_cli=false

  # Frontend signals
  if [[ -f "$dir/package.json" ]]; then
    grep -qiE '"react"|"vue"|"next"|"nuxt"|"vite"|"svelte"' "$dir/package.json" 2>/dev/null \
      && has_frontend=true
  fi
  [[ -d "$dir/src" || -d "$dir/app" || -d "$dir/pages" || -d "$dir/components" ]] \
    && has_frontend=true

  # Backend/API signals
  [[ -d "$dir/server" || -d "$dir/api" || -d "$dir/backend" ]] && has_backend=true
  [[ -f "$dir/go.mod" || -f "$dir/main.go" || -f "$dir/main.py" || -f "$dir/app.py" \
    || -f "$dir/Cargo.toml" || -f "$dir/requirements.txt" || -f "$dir/pyproject.toml" ]] \
    && has_backend=true
  # DB/ORM/server-side deps in package.json → backend signal
  if [[ -f "$dir/package.json" ]]; then
    grep -qiE '"prisma"|"@prisma/client"|"typeorm"|"sequelize"|"mongoose"|"drizzle-orm"|"@neondatabase/serverless"|"pg"|"mysql2"|"mongodb"|"redis"|"ioredis"|"express"|"fastify"|"koa"|"hapi"|"@hapi/hapi"|"apollo-server"|"graphql-yoga"|"trpc"' "$dir/package.json" 2>/dev/null \
      && has_backend=true
  fi
  # Prisma schema file is a definitive backend signal
  [[ -f "$dir/prisma/schema.prisma" ]] && has_backend=true

  # CLI signals (bin/ with executables, or cmd/ layout common in Go CLIs)
  [[ -d "$dir/bin" || -d "$dir/cmd" ]] && has_cli=true

  # Determine type
  if $has_frontend && $has_backend; then
    echo "fullstack"
  elif $has_frontend && ! $has_backend; then
    echo "frontend-only"
  elif $has_cli && ! $has_frontend; then
    echo "cli"
  elif $has_backend && ! $has_frontend; then
    echo "backend-service"
  else
    echo "unknown"
  fi
}

# ─── Helper: write starter .roll/backlog.md (no-op if exists) ──────────────────────
_write_backlog() {
  if [[ -f "$1" ]]; then
    _ROLL_MERGE_SUMMARY+=("unchanged|.roll/backlog.md")
    return
  fi
  mkdir -p "$(dirname "$1")"
  cat > "$1" << 'EOF'
# Project Backlog

## Epic: Initial Setup
| Story | Description | Status |
|-------|-------------|--------|

## Bug Fixes
| ID | Problem | Status |
|----|---------|--------|
EOF
  ok "Created: .roll/backlog.md"
  _ROLL_MERGE_SUMMARY+=("created|.roll/backlog.md")
}

_ensure_features_dir() {
  if [[ -d "$1" ]]; then
    _ROLL_MERGE_SUMMARY+=("unchanged|.roll/features/")
    return
  fi

  mkdir -p "$1"
  ok "Created: .roll/features/"
  _ROLL_MERGE_SUMMARY+=("created|.roll/features/")
}

# US-ONBOARD-019: write a Roll version stamp under .roll/.version when a project
# is onboarded. Going forward, this stamp is the canonical "this project was
# onboarded with Roll" signal — it lets `_check_structure` distinguish a
# genuine pre-2.0 Roll project (needs migrate) from a non-Roll project that
# coincidentally has BACKLOG.md / docs/features/ from another tool.
#
# Idempotent: an existing stamp is never overwritten, so the original install
# timestamp is preserved across re-runs of `roll init`.
_write_version_stamp() {
  local project_dir="$1"
  local stamp_path="$project_dir/.roll/.version"
  if [[ -f "$stamp_path" ]]; then
    _ROLL_MERGE_SUMMARY+=("unchanged|.roll/.version")
    return 0
  fi
  mkdir -p "$project_dir/.roll"
  local installed_at; installed_at=$(date -u +%FT%TZ)
  cat > "$stamp_path" <<EOF
# Roll project version stamp — written by \`roll init\` (US-ONBOARD-019).
# Used by \`_check_structure\` to recognise a previously-onboarded Roll project
# without depending on directory-name heuristics.
roll_version: "${VERSION}"
installed_at: "${installed_at}"
EOF
  _ROLL_MERGE_SUMMARY+=("created|.roll/.version")
  return 0
}

# US-ONBOARD-019: is <root> a Roll-onboarded project (current or pre-2.0)?
#
# Returns 0 (true) when at least one Roll-specific signal is present:
#   1. .roll/.version stamp (post-019 onboard)
#   2. BACKLOG.md with a Roll-1.x Story table or "Bug Fixes" section
#   3. PROPOSALS.md with a Roll-style "## Proposal" heading
#   4. docs/features/ containing US-/FIX-/REFACTOR- named .md files
#   5. docs/briefs/ or docs/dream/ directory non-empty
#
# A bare BACKLOG.md/PROPOSALS.md from another tool, or a generic
# docs/features/ folder, does NOT count — that's the bug US-ONBOARD-019
# fixes (false-positive migrate prompts on non-Roll projects).
_has_roll_signature() {
  local root="$1"

  # Signal 1 — post-019 version stamp
  [[ -f "$root/.roll/.version" ]] && return 0

  # Signal 2 — Roll-1.x BACKLOG.md content
  if [[ -f "$root/BACKLOG.md" ]]; then
    if grep -qE '^\| Story \| Description \| Status \|' "$root/BACKLOG.md" 2>/dev/null \
       || grep -qE '^## Epic:' "$root/BACKLOG.md" 2>/dev/null \
       || grep -qE '^\| ID \| Problem \| Status \|' "$root/BACKLOG.md" 2>/dev/null; then
      return 0
    fi
  fi

  # Signal 3 — Roll-style PROPOSALS.md
  if [[ -f "$root/PROPOSALS.md" ]]; then
    if grep -qE '^## Proposal' "$root/PROPOSALS.md" 2>/dev/null; then
      return 0
    fi
  fi

  # Signal 4 — Roll-named files under docs/features/
  if [[ -d "$root/docs/features" ]]; then
    if find "$root/docs/features" -maxdepth 2 -type f -name '*.md' 2>/dev/null \
         | grep -qE '/(US|FIX|REFACTOR)-[0-9]+'; then
      return 0
    fi
  fi

  # Signal 5 — Roll-1.x process artefacts (docs/briefs/ docs/dream/)
  local dir
  for dir in docs/briefs docs/dream; do
    if [[ -d "$root/$dir" ]] \
       && [[ -n "$(find "$root/$dir" -mindepth 1 -maxdepth 2 -type f 2>/dev/null | head -1)" ]]; then
      return 0
    fi
  done

  return 1
}

# ─── Helper: write starter .roll/features.md (no-op if exists) ────────────────
_write_features_md() {
  if [[ -f "$1" ]]; then
    _ROLL_MERGE_SUMMARY+=("unchanged|.roll/features.md")
    return
  fi
  mkdir -p "$(dirname "$1")"
  cat > "$1" << 'EOF'
# Features

> 产品视角的功能索引。每次发版时更新，使之与 BACKLOG 保持一致。

---

## Features by Epic

<!-- Add feature entries here as epics are completed -->
EOF
  ok "Created: .roll/features.md"
  _ROLL_MERGE_SUMMARY+=("created|.roll/features.md")
}

# ═══════════════════════════════════════════════════════════════════════════════
# COMMAND: status
# Show current state of conventions
# ═══════════════════════════════════════════════════════════════════════════════
_legacy_status() {
  echo -e "${BOLD}Roll Convention Status  Roll 约定状态${NC}"
  echo ""

  if [[ -d "$ROLL_HOME" ]]; then
    ok "~/.roll/ exists  ~/.roll/ 已存在"
  else
    err "~/.roll/ not found — run 'roll setup'  ~/.roll/ 不存在 — 请运行 'roll setup'"
    return
  fi

  echo ""
  echo -e "${BOLD}Global conventions:  全局约定${NC}"
  for f in AGENTS.md CLAUDE.md GEMINI.md .cursor-rules project_rules.md; do
    if [[ -f "$ROLL_GLOBAL/$f" ]]; then
      echo -e "  ${GREEN}+${NC} $f"
    else
      echo -e "  ${RED}-${NC} $f (missing / 缺失)"
    fi
  done

  echo ""
  echo -e "${BOLD}Global skills:  全局技能${NC}"
  if [[ -d "$ROLL_HOME/skills" ]]; then
    local count
    count=$(find "$ROLL_HOME/skills" -maxdepth 1 -type d | wc -l | tr -d ' ')
    count=$((count - 1))
    echo -e "  ${GREEN}+${NC} ~/.roll/skills ($count skills installed / 已安装 $count 个技能)"
  else
    echo -e "  ${RED}-${NC} ~/.roll/skills (missing / 缺失)"
  fi

  echo ""
  echo -e "${BOLD}Sync targets:  同步目标${NC}"

  local _sync_found=0
  while IFS= read -r _entry; do
    _sync_found=1
    local _ai_d _cfg _src _tool_name
    _ai_d="$(_ai_dir "$_entry")"
    _cfg="$(_ai_config "$_entry")"
    _src="$(_ai_src "$_entry")"
    _tool_name="$(ai_tool_name "$_ai_d")"
    check_sync_status "$_tool_name" "$ROLL_GLOBAL/$_src" "$_ai_d/$_cfg"
  done < <(_get_ai_tools)
  if [[ "$_sync_found" -eq 0 ]]; then
    warn "No AI tools configured — check ~/.roll/config.yaml  未配置 AI 工具 — 请检查 ~/.roll/config.yaml"
    info "Add ai_* entries or run 'roll setup' to restore defaults.  添加 ai_* 条目或运行 'roll setup' 恢复默认配置。"
  fi

  echo ""
  echo -e "${BOLD}Skill symlinks:  技能软链接${NC}"
  local total_skills=0
  local wk_skills_real
  if [[ -d "$ROLL_HOME/skills" ]]; then
    # Count roll-* skill dirs to match the linked_count scope below
    total_skills=$(find "$ROLL_HOME/skills" -maxdepth 1 -mindepth 1 -type d -name "roll-*" | wc -l | tr -d ' ')
    wk_skills_real="$(canonical_dir "$ROLL_HOME/skills" 2>/dev/null || true)"
  fi
  local _skills_found=0
  while IFS= read -r _entry; do
    local ai_dir
    ai_dir="$(_ai_dir "$_entry")"
    [[ -d "$ai_dir" ]] || continue
    _skills_found=1
    local name name_lower
    name="$(ai_tool_name "$ai_dir")"
    name="$(echo "$name" | tr '[:lower:]' '[:upper:]' | cut -c1)$(echo "$name" | cut -c2-)"
    name_lower="$(lower_name "$name")"
    local skills_dir="$ai_dir/skills"
    if [[ -d "$skills_dir" ]]; then
      if [[ -L "$skills_dir" ]]; then
        local skills_target skills_real
        skills_target="$(readlink "$skills_dir")"
        skills_real="$(canonical_dir "$skills_dir" 2>/dev/null || true)"
        local skills_display="${skills_dir/#$HOME/~}"
        if [[ -n "$skills_real" && "$skills_real" == "$wk_skills_real" ]]; then
          echo -e "  ${GREEN}=${NC} $name: $skills_display -> ~/.roll/skills (mounted / 已挂载)"
        else
          echo -e "  ${YELLOW}~${NC} $name: $skills_display -> ${skills_target/#$HOME/~} (symlinked dir)"
        fi
        continue
      fi

      local linked_count skills_display
      skills_display="${skills_dir/#$HOME/~}"
      linked_count=$(find "$skills_dir" -maxdepth 1 -mindepth 1 -type l -name "roll-*" 2>/dev/null | wc -l | tr -d ' ')
      if [[ "$linked_count" -eq "$total_skills" ]] && [[ "$total_skills" -gt 0 ]]; then
        echo -e "  ${GREEN}=${NC} $name: $skills_display ($linked_count/$total_skills skills linked)"
      elif [[ "$linked_count" -gt 0 ]]; then
        echo -e "  ${YELLOW}~${NC} $name: $skills_display ($linked_count/$total_skills skills linked)"
      else
        echo -e "  ${RED}-${NC} $name: $skills_display (no roll-* skills linked / 未链接 roll-* 技能)"
      fi
    else
      echo -e "  ${RED}-${NC} $name: ${skills_dir/#$HOME/~} (not found / 未找到)"
    fi
  done < <(_get_ai_tools)
  if [[ "$_skills_found" -eq 0 ]]; then
    warn "No AI tools configured — check ~/.roll/config.yaml  未配置 AI 工具 — 请检查 ~/.roll/config.yaml"
  fi

  echo ""
  echo -e "${BOLD}Templates:  模板${NC}"
  for tpl in fullstack frontend-only backend-service cli; do
    if [[ -d "$ROLL_TEMPLATES/$tpl" ]]; then
      local count
      count=$(find "$ROLL_TEMPLATES/$tpl" -type f | wc -l | tr -d ' ')
      echo -e "  ${GREEN}+${NC} $tpl ($count files)"
    else
      echo -e "  ${RED}-${NC} $tpl (missing / 缺失)"
    fi
  done

  _status_loop_overview
}

cmd_status() {
  if [[ "${ROLL_UI:-v2}" == "v2" ]]; then
    python3 "${ROLL_PKG_DIR}/lib/roll-status.py" "$@"
  else
    _legacy_status "$@"
  fi
}

_status_loop_overview() {
  [[ "$(uname)" != "Darwin" ]] && return 0

  local plists=()
  while IFS= read -r p; do
    [[ -f "$p" ]] && plists+=("$p")
  done < <(ls "${_LAUNCHD_DIR}"/com.roll.loop.*.plist 2>/dev/null)

  [[ "${#plists[@]}" -eq 0 ]] && return 0

  echo ""
  echo -e "${BOLD}Loop Overview:  所有项目 loop 状态${NC}"

  for plist in "${plists[@]}"; do
    local label; label=$(basename "$plist" .plist)

    local proj_path
    proj_path=$(awk '/<key>WorkingDirectory<\/key>/{f=1;next} f{gsub(/^[[:space:]]*<string>|<\/string>[[:space:]]*$/,"");print;exit}' "$plist" 2>/dev/null)

    local proj_name path_note=""
    if [[ -n "$proj_path" && -d "$proj_path" ]]; then
      proj_name=$(basename "$proj_path")
    elif [[ -n "$proj_path" ]]; then
      proj_name=$(basename "$proj_path")
      path_note=" (path missing)"
    else
      proj_name="(unknown)"
    fi

    local state_icon
    if _launchd_is_loaded "$label"; then
      state_icon="${GREEN}●${NC}"
    else
      state_icon="${RED}○${NC}"
    fi

    local minute hour schedule
    minute=$(awk '/<key>Minute<\/key>/{f=1;next} f{gsub(/^[[:space:]]*<integer>|<\/integer>[[:space:]]*$/,"");print;exit}' "$plist" 2>/dev/null)
    hour=$(awk '/<key>Hour<\/key>/{f=1;next} f{gsub(/^[[:space:]]*<integer>|<\/integer>[[:space:]]*$/,"");print;exit}' "$plist" 2>/dev/null)
    if [[ -n "$hour" && -n "$minute" ]]; then
      schedule=$(printf "%02d:%02d" "$hour" "$minute")
    elif [[ -n "$minute" ]]; then
      schedule=":$(printf '%02d' "$minute")"
    else
      schedule="?"
    fi

    local todo_count=0
    if [[ -z "$path_note" && -f "${proj_path}/.roll/backlog.md" ]]; then
      todo_count=$(grep -c '📋 Todo' "${proj_path}/.roll/backlog.md" 2>/dev/null; true)
    fi

    echo -e "  ${state_icon} ${proj_name}${path_note}   ${schedule}   ${todo_count} pending"
  done
}

check_sync_status() {
  local name="$1"
  local src="$2"
  local dst="$3"

  local display="${dst/#$HOME/~}"
  local dst_dir
  dst_dir="$(dirname "$dst")"
  local wk_file="$dst_dir/roll.md"

  # Sync writes content to {dir}/roll.md and appends @roll.md to the main config.
  # So "in sync" means: roll.md exists + matches source + main config contains @roll.md.
  if [[ ! -f "$dst" ]]; then
    echo -e "  ${RED}-${NC} $name: $display (not synced / 未同步)"
  elif [[ ! -f "$wk_file" ]]; then
    echo -e "  ${YELLOW}~${NC} $name: $display (out of sync — roll.md missing / roll.md 缺失)"
  elif ! diff -q "$src" "$wk_file" &>/dev/null 2>&1; then
    echo -e "  ${YELLOW}~${NC} $name: $display (out of sync — roll.md outdated / roll.md 已过期)"
  elif ! grep -qF "@roll.md" "$dst" 2>/dev/null; then
    echo -e "  ${YELLOW}~${NC} $name: $display (out of sync — @roll.md not in config / 未包含 @roll.md)"
  else
    echo -e "  ${GREEN}=${NC} $name: $display (in sync / 已同步)"
  fi
}

# ═══════════════════════════════════════════════════════════════════════════════
# PEER REVIEW
# ═══════════════════════════════════════════════════════════════════════════════

_PEER_STATE_DIR="${ROLL_HOME}/.peer-state"

_peer_ensure_state_dir() {
  mkdir -p "$_PEER_STATE_DIR"
  mkdir -p "${_PEER_STATE_DIR}/logs"
}

_peer_state_file() {
  local pair="$1"
  local key="$2"
  echo "${_PEER_STATE_DIR}/${pair}_${key}"
}

_peer_get_state() {
  local pair="$1"
  local key="$2"
  local file
  file="$(_peer_state_file "$pair" "$key")"
  if [[ -f "$file" ]]; then
    cat "$file"
  else
    echo ""
  fi
}

_peer_set_state() {
  local pair="$1"
  local key="$2"
  local val="$3"
  _peer_ensure_state_dir
  printf '%s\n' "$val" > "$(_peer_state_file "$pair" "$key")"
}

_peer_normalize_pair() {
  local from="$1"
  local to="$2"
  printf '%s→%s\n' "$from" "$to"
}

_peer_detect_peers() {
  local peers=""
  for tool in claude kimi pi codex opencode cursor; do
    if command -v "$tool" &>/dev/null; then
      peers="${peers}${peers:+ }${tool}"
    fi
  done
  printf '%s\n' "$peers"
}

_peer_route() {
  local from="$1"
  local tag="${2:-default}"

  local map_val
  map_val="$(config_get "peer_capability_map_${tag}" "")"
  if [[ -z "$map_val" ]]; then
    map_val="$(config_get "peer_capability_map_default" "kimi claude pi")"
  fi

  local installed
  installed="$(_peer_detect_peers)"

  local candidate
  for candidate in $map_val; do
    [[ "$candidate" == "$from" ]] && continue
    if echo "$installed" | grep -qw "$candidate"; then
      local pair status
      pair="$(_peer_normalize_pair "$from" "$candidate")"
      status="$(_peer_get_state "$pair" "status")"
      if [[ "$status" != "abandoned" ]]; then
        printf '%s\n' "$candidate"
        return 0
      fi
    fi
  done

  printf '%s\n' ""
  return 1
}

# Open a Terminal.app window attached to the given tmux session (peer
# auto-attach). No-ops when muted, non-macOS, or osascript unavailable.
# FIX-054: terminal selection removed — always dispatches to macOS
# Terminal.app for predictability (per-user detection silently failed on
# Ghostty upgrades).
_peer_auto_attach() {
  local session="$1"
  [ "$(uname)" = "Darwin" ] || return 0
  [ -f "$_LOOP_MUTE_FILE" ] && return 0
  command -v osascript >/dev/null 2>&1 || return 0
  osascript \
    -e 'tell application "System Events" to set _prev to name of first application process whose frontmost is true' \
    -e "tell application \"Terminal\" to do script \"tmux attach -t $session\"" \
    -e 'delay 0.3' -e 'try' -e 'tell application _prev to activate' -e 'end try' >/dev/null 2>&1 || true
}

# Dispatch a peer CLI command inside an existing tmux session (window 0).
# Writes stdout to out_file, stderr to err_file. Blocks until done or timeout.
_peer_dispatch_in_tmux() {
  local session="$1" cmd_str="$2" out_file="$3" err_file="$4" timeout="${5:-180}"
  local done_file="${out_file}.done"
  local inner
  inner=$(mktemp /tmp/roll-peer-inner-XXXXXX.sh)
  {
    printf '#!/bin/bash -l\n'
    # FIX-050: portable PATH assembly (was hardcoded /opt/homebrew/bin)
    printf 'for _d in /opt/homebrew/bin /usr/local/bin /opt/local/bin "$HOME/.local/bin"; do\n'
    printf '  case ":$PATH:" in *":$_d:"*) ;; *) [ -d "$_d" ] && PATH="$_d:$PATH" ;; esac\n'
    printf 'done; export PATH\n'
    printf '%s > %q 2> %q || true\n' "$cmd_str" "$out_file" "$err_file"
    printf 'touch %q\n' "$done_file"
  } > "$inner"
  chmod +x "$inner"
  tmux send-keys -t "${session}:0" "bash ${inner}; rm -f ${inner}" Enter
  local elapsed=0
  while [ ! -f "$done_file" ] && [ "$elapsed" -lt "$timeout" ]; do
    sleep 1
    elapsed=$((elapsed + 1))
  done
  rm -f "$done_file"
}

_peer_call() {
  local to="$1"
  local prompt="$2"
  local session="${3:-}"
  local output=""
  local stderr_log
  stderr_log="${_PEER_STATE_DIR}/logs/.last_stderr.log"
  local call_timeout
  call_timeout="$(config_get "peer_call_timeout" "180")"

  info "Peer call timeout: ${call_timeout}s  Peer 调用超时: ${call_timeout}s"

  if [[ -n "$session" ]] && command -v tmux >/dev/null 2>&1 && tmux has-session -t "$session" 2>/dev/null; then
    local out_file
    out_file=$(mktemp)
    local cmd_str
    cmd_str=$(_agent_cmd_str "$to" peer "$prompt") || {
      err "Unsupported peer: $to  不支持的 peer: $to"
      return 1
    }
    _peer_dispatch_in_tmux "$session" "$cmd_str" "$out_file" "$stderr_log" "$call_timeout"
    output="$(cat "$out_file" 2>/dev/null || true)"
    rm -f "$out_file"
  else
    _agent_argv "$to" peer "$prompt" || {
      err "Unsupported peer: $to  不支持的 peer: $to"
      return 1
    }
    output="$("${_AGENT_ARGV[@]}" 2>"$stderr_log" || true)"
  fi

  printf '%s\n' "$output"
}

_peer_parse_resolution() {
  local output="$1"
  local resolution
  # Match AGREE/REFINE/OBJECT/ESCALATE near line start (only non-letters before it)
  # Covers: **AGREE**, ### 结论：AGREE, - AGREE:, * REFINE, OBJECT — ...
  resolution="$(printf '%s\n' "$output" | grep -oiE '^[^a-zA-Z]*\b(AGREE|REFINE|OBJECT|ESCALATE)\b' | head -1 | grep -oiE '\b(AGREE|REFINE|OBJECT|ESCALATE)\b' | tr '[:lower:]' '[:upper:]')"
  printf '%s\n' "$resolution"
}

_peer_update_state() {
  local pair="$1"
  local outcome="$2"
  local streak=0

  local prev_streak
  prev_streak="$(_peer_get_state "$pair" "streak")"
  if [[ "$prev_streak" =~ ^[0-9]+$ ]]; then
    streak="$prev_streak"
  fi

  if [[ "$outcome" == "AGREE" ]]; then
    streak=0
    _peer_set_state "$pair" "status" "active"
  else
    streak=$((streak + 1))
    if [[ "$streak" -ge 3 ]]; then
      _peer_set_state "$pair" "status" "abandoned"
    else
      _peer_set_state "$pair" "status" "degraded"
    fi
  fi

  _peer_set_state "$pair" "streak" "$streak"
  _peer_set_state "$pair" "last_outcome" "$outcome"
  _peer_set_state "$pair" "last_time" "$(date -Iseconds)"
}

cmd_peer() {
  local from_tool=""
  local to_tool=""
  local round=1
  local tag="default"
  local context_file=""
  local yolo=false
  local subcmd=""

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --from) from_tool="$2"; shift 2 ;;
      --to) to_tool="$2"; shift 2 ;;
      --round) round="$2"; shift 2 ;;
      --tag) tag="$2"; shift 2 ;;
      --context) context_file="$2"; shift 2 ;;
      --yes|--yolo) yolo=true; shift ;;
      status) subcmd="status"; shift ;;
      reset) subcmd="reset"; shift; break ;;
      help|--help|-h) subcmd="help"; shift ;;
      *) err "Unknown option: $1  未知选项: $1"; exit 1 ;;
    esac
  done

  case "$subcmd" in
    status) cmd_peer_status; return ;;
    reset) cmd_peer_reset "$@"; return ;;
    help) cmd_peer_help; return ;;
  esac

  if [[ -z "$from_tool" ]]; then
    err "--from is required.  必须指定 --from。"
    echo ""
    cmd_peer_help
    exit 1
  fi

  if [[ -z "$to_tool" ]]; then
    to_tool="$(_peer_route "$from_tool" "$tag")"
    if [[ -z "$to_tool" ]]; then
      err "No available peer found for tag '$tag'.  未找到 tag '$tag' 的可用 peer。"
      echo ""
      info "Installed peers: $(_peer_detect_peers)"
      info "Capability map: $(config_get "peer_capability_map_${tag}" "$(config_get "peer_capability_map_default" "kimi claude pi")")"
      exit 1
    fi
    info "Auto-selected peer: $to_tool  自动选择 peer: $to_tool"
  fi

  local pair
  pair="$(_peer_normalize_pair "$from_tool" "$to_tool")"

  local status
  status="$(_peer_get_state "$pair" "status")"
  if [[ "$status" == "abandoned" ]]; then
    err "Peer pair $pair is abandoned. Run 'roll peer reset $from_tool $to_tool' to restore.  Peer 对 $pair 已废弃。运行 'roll peer reset $from_tool $to_tool' 恢复。"
    exit 1
  fi

  if [[ "$yolo" != "true" ]]; then
    local opt_out
    opt_out="$(config_get "peer_opt_out_seconds" "10")"
    info "Launching peer review: $from_tool → $to_tool (round $round, tag: $tag)"
    info "Press Enter to proceed or type 'n' to abort. Auto-executing in ${opt_out}s..."
    info "启动 peer review: $from_tool → $to_tool (第 $round 轮, tag: $tag)"
    info "按 Enter 执行或输入 n 取消。${opt_out} 秒后自动执行..."

    local answer=""
    if IFS= read -r -t "$opt_out" answer 2>/dev/null; then
      if [[ "$answer" == "n" || "$answer" == "N" ]]; then
        info "Peer review aborted by user.  用户取消 peer review。"
        exit 0
      fi
    fi
  fi

  local context=""
  if [[ -n "$context_file" && -f "$context_file" ]]; then
    context="$(cat "$context_file")"
  fi

  local prompt
  prompt="[PEER_REVIEW round=${round} tool=${from_tool}→${to_tool}]\n\n${context}"

  local peer_session=""
  if command -v tmux >/dev/null 2>&1; then
    peer_session="roll-peer-${from_tool}-${to_tool}"
    if ! tmux has-session -t "$peer_session" 2>/dev/null; then
      tmux new-session -d -s "$peer_session" -x 200 -y 50
    fi
    if [ -z "$(tmux list-clients -t "$peer_session" 2>/dev/null)" ]; then
      _peer_auto_attach "$peer_session"
    fi
  fi

  _peer_ensure_state_dir
  local log_file
  log_file="${_PEER_STATE_DIR}/logs/$(date +%Y%m%d_%H%M%S)_${from_tool}_${to_tool}.md"
  {
    echo "# Peer Review Log"
    echo ""
    echo "- **From**: $from_tool"
    echo "- **To**: $to_tool"
    echo "- **Round**: $round"
    echo "- **Tag**: $tag"
    echo "- **Time**: $(date -Iseconds)"
    echo ""
    echo "## Prompt"
    echo ""
    echo '```'
    printf '%s\n' "$prompt"
    echo '```'
    echo ""
    echo "## Response"
    echo ""
  } > "$log_file"

  info "Calling $to_tool...  调用 $to_tool..."
  local response
  response="$(_peer_call "$to_tool" "$prompt" "$peer_session")"

  local stderr_log
  stderr_log="${_PEER_STATE_DIR}/logs/.last_stderr.log"
  if [[ -f "$stderr_log" && -s "$stderr_log" ]]; then
    echo ""
    echo -e "${BOLD}Peer stderr  Peer 标准错误:${NC}"
    cat "$stderr_log"
    echo ""
  fi

  printf '%s\n' "$response" >> "$log_file"

  local resolution=""
  resolution="$(_peer_parse_resolution "$response")"

  if [[ -z "$resolution" ]]; then
    warn "Could not parse resolution from peer response.  无法解析 peer 响应中的决议状态。"
    resolution="UNKNOWN"
  fi

  _peer_update_state "$pair" "$resolution"

  echo ""
  echo -e "${BOLD}Peer Review Result  Peer Review 结果${NC}"
  echo "  Pair: $pair"
  echo "  Round: $round"
  echo "  Resolution: $resolution"
  echo ""

  case "$resolution" in
    AGREE)
      ok "Consensus reached. Proceed with execution.  达成共识，继续执行。"
      ;;
    REFINE|OBJECT)
      if [[ "$round" -ge 3 ]]; then
        warn "Max rounds reached. Escalating to user.  达到最大轮数，升级给用户。"
      else
        info "Peer requests ${resolution}. Continue to round $((round + 1)).  Peer 请求 ${resolution}，继续第 $((round + 1)) 轮。"
      fi
      ;;
    ESCALATE|UNKNOWN)
      warn "Peer review escalated or failed. Human decision required.  Peer review 升级或失败，需要人类决策。"
      ;;
  esac

  echo ""
  info "Log: $log_file"

  local _should_kill=true
  case "$resolution" in
    REFINE|OBJECT) [[ "$round" -lt 3 ]] && _should_kill=false ;;
  esac
  if [[ "$_should_kill" == "true" ]] && [[ -n "$peer_session" ]] \
     && command -v tmux >/dev/null 2>&1 \
     && tmux has-session -t "$peer_session" 2>/dev/null; then
    tmux kill-session -t "$peer_session" 2>/dev/null || true
  fi

  case "$resolution" in
    AGREE) exit 0 ;;
    REFINE|OBJECT) exit 2 ;;
    *) exit 1 ;;
  esac
}

cmd_peer_status() {
  _peer_ensure_state_dir
  echo -e "${BOLD}Peer Review Status  Peer Review 状态${NC}"
  echo ""

  local found=0
  local status_file
  for status_file in "$_PEER_STATE_DIR"/*_status; do
    [[ -f "$status_file" ]] || continue
    found=1
    local pair status streak last_outcome last_time
    pair="$(basename "$status_file" | sed 's/_status$//')"
    status="$(_peer_get_state "$pair" "status")"
    streak="$(_peer_get_state "$pair" "streak")"
    last_outcome="$(_peer_get_state "$pair" "last_outcome")"
    last_time="$(_peer_get_state "$pair" "last_time")"

    local sc="$GREEN"
    [[ "$status" == "degraded" ]] && sc="$YELLOW"
    [[ "$status" == "abandoned" ]] && sc="$RED"

    echo -e "  ${sc}${pair}${NC}"
    echo "    Status: ${status:-active}"
    echo "    Streak: ${streak:-0}"
    echo "    Last: ${last_outcome:-none} @ ${last_time:-never}"
    echo ""
  done

  if [[ "$found" -eq 0 ]]; then
    info "No peer review history yet.  暂无 peer review 记录。"
  fi

  echo ""
  info "Installed peers: $(_peer_detect_peers)"
}

cmd_peer_reset() {
  local target_pair=""
  local reset_all=false

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --all) reset_all=true; shift ;;
      --from|--to|--round|--tag|--context|--yes|--yolo|status|reset|help|--help|-h) shift ;;
      *)
        if [[ -z "$target_pair" ]]; then
          target_pair="$1"
        fi
        shift
        ;;
    esac
  done

  _peer_ensure_state_dir

  if [[ "$reset_all" == "true" ]]; then
    rm -f "$_PEER_STATE_DIR"/*_status
    rm -f "$_PEER_STATE_DIR"/*_streak
    rm -f "$_PEER_STATE_DIR"/*_last_outcome
    rm -f "$_PEER_STATE_DIR"/*_last_time
    ok "All peer states reset.  所有 peer 状态已重置。"
    return
  fi

  if [[ -z "$target_pair" ]]; then
    err "Usage: roll peer reset <from>→<to> | --all  用法: roll peer reset <from>→<to> | --all"
    exit 1
  fi

  rm -f "$(_peer_state_file "$target_pair" "status")"
  rm -f "$(_peer_state_file "$target_pair" "streak")"
  rm -f "$(_peer_state_file "$target_pair" "last_outcome")"
  rm -f "$(_peer_state_file "$target_pair" "last_time")"
  ok "Peer state reset: $target_pair  Peer 状态已重置: $target_pair"
}

cmd_peer_help() {
  echo -e "${BOLD}roll peer — Cross-Agent Peer Review${NC}"
  echo ""
  echo "Usage: roll peer [options]  用法: roll peer [选项]"
  echo ""
  echo "Options:"
  echo "  --from <tool>       Originating agent (kimi, claude, pi)  发起方"
  echo "  --to <tool>         Target peer (auto-detected if omitted)  对端 peer（省略则自动选择）"
  echo "  --round <N>         Current round (default: 1)  当前轮数"
  echo "  --tag <type>        Task type for routing (architecture, security, test...)  任务类型"
  echo "  --context <file>    Context file to send to peer  上下文文件"
  echo "  --yes, --yolo       Skip opt-out prompt  跳过确认提示"
  echo ""
  echo "Subcommands:"
  echo "  status              Show peer review state  显示状态"
  echo "  reset <pair|--all>  Reset peer state  重置状态"
  echo "  help                Show this help  显示帮助"
}

# ═══════════════════════════════════════════════════════════════════════════════
# AGENT — per-project agent configuration
# ═══════════════════════════════════════════════════════════════════════════════

# REFACTOR-040: project agent preference moved from the project root
# (`.roll.yaml`) to `.roll/local.yaml`. The new location stays alongside other
# per-machine runtime state inside `.roll/`, never reaches git (.roll/ is
# gitignored), and keeps the project root clean. The old `.roll.yaml` location
# is still read as a fallback so existing checkouts keep working until the next
# `roll agent use` rewrites them in place.
_project_agent_pref_file() {
  echo ".roll/local.yaml"
}

_project_agent() {
  local pref new_pref
  new_pref=$(_project_agent_pref_file)
  if [[ -f "$new_pref" ]] && grep -q "^agent:" "$new_pref" 2>/dev/null; then
    grep "^agent:" "$new_pref" | awk '{print $2}' | tr -d '"' | head -1
  elif [[ -f ".roll.yaml" ]] && grep -q "^agent:" .roll.yaml 2>/dev/null; then
    grep "^agent:" .roll.yaml | awk '{print $2}' | tr -d '"' | head -1
  elif [[ -f "$ROLL_CONFIG" ]] && grep -q "primary_agent:" "$ROLL_CONFIG" 2>/dev/null; then
    grep "primary_agent:" "$ROLL_CONFIG" | awk '{print $2}' | tr -d '"' | head -1
  else
    echo "claude"
  fi
}

_skill_content() {
  # Strip YAML frontmatter (---...---) — it's roll-internal metadata, not agent instructions
  awk 'NR==1 && /^---$/{skip=1;next} skip && /^---$/{skip=0;next} !skip{print}' "$1"
}

_parse_review_verdict() {
  local output="$1"
  local line
  line=$(echo "$output" | grep -o '<!--VERDICT:[^>]*-->' | tail -1)
  [ -n "$line" ] || return 0
  local type reason
  type=$(echo "$line" | sed -E 's/<!--VERDICT:([A-Z_]+)(:.*)?-->/\1/')
  reason=$(echo "$line" | sed -E 's/<!--VERDICT:[A-Z_]+:?//; s/-->//' | sed 's/^ *//')
  echo "${type}${reason:+:${reason}}"
}

# REFACTOR-017: single source of truth for agent invocation argv.
# Sets the global _AGENT_ARGV array to the command + args for (agent, mode, prompt).
# Modes:
#   text  — structured text (claude --output-format text; codex exec)
#   plain — default output (claude -p; codex exec)
#   peer  — peer protocol (claude --output-format text; codex --json --output-last-message)
# Returns 1 on unknown agent. Adding a new agent only needs an entry here.
_agent_argv() {
  local agent="$1" mode="$2" prompt="$3"
  # US-ONBOARD-018: `interactive` mode launches the agent's REPL with the prompt
  # pre-loaded as the first user message. The user can then converse normally,
  # keep their tty, and exit with Ctrl-C or /exit. Used by `roll init` to auto-
  # start the chosen agent for $roll-onboard without a copy-paste handoff.
  # Convention: positional arg as initial prompt; no -p / exec / run / --quiet.
  case "$agent" in
    claude)
      case "$mode" in
        interactive) _AGENT_ARGV=(claude "$prompt") ;;
        text|peer)   _AGENT_ARGV=(claude -p --output-format text "$prompt") ;;
        *)           _AGENT_ARGV=(claude -p "$prompt") ;;
      esac ;;
    kimi)
      case "$mode" in
        interactive) _AGENT_ARGV=(kimi "$prompt") ;;
        *)           _AGENT_ARGV=(kimi --quiet -p "$prompt") ;;
      esac ;;
    deepseek)
      # deepseek has the same argv shape in both modes (positional prompt).
      _AGENT_ARGV=(deepseek "$prompt") ;;
    pi)
      case "$mode" in
        interactive) _AGENT_ARGV=(pi "$prompt") ;;
        *)           _AGENT_ARGV=(pi -p "$prompt") ;;
      esac ;;
    codex)
      case "$mode" in
        interactive) _AGENT_ARGV=(codex "$prompt") ;;
        peer)        _AGENT_ARGV=(codex exec --json --output-last-message "$prompt") ;;
        *)           _AGENT_ARGV=(codex exec "$prompt") ;;
      esac ;;
    opencode)
      case "$mode" in
        interactive) _AGENT_ARGV=(opencode "$prompt") ;;
        *)           _AGENT_ARGV=(opencode run "$prompt") ;;
      esac ;;
    gemini)
      # gemini integration is interactive-only for now (used by onboard flow).
      case "$mode" in
        interactive) _AGENT_ARGV=(gemini "$prompt") ;;
        *) return 1 ;;
      esac ;;
    *) return 1 ;;
  esac
}

# Build a printf %q-escaped command string for (agent, mode, prompt).
# Used where the command must be passed as a string (e.g. tmux send-keys).
_agent_cmd_str() {
  _agent_argv "$@" || return 1
  local i out
  printf -v out '%q' "${_AGENT_ARGV[0]}"
  for ((i = 1; i < ${#_AGENT_ARGV[@]}; i++)); do
    printf -v out '%s %q' "$out" "${_AGENT_ARGV[i]}"
  done
  printf '%s' "$out"
}

# Splice --dangerously-skip-permissions into _AGENT_ARGV for claude. Used by
# trusted, human-triggered, or autonomous flows that should not be blocked by
# Claude Code's pre-write "approve diff" UX (which silently never gets
# approved in `claude -p` pipe mode). No-op for non-claude agents and for
# already-bypassed argvs.
_agent_bypass_claude_perms() {
  [[ "${_AGENT_ARGV[0]}" == "claude" ]] || return 0
  local arg
  for arg in "${_AGENT_ARGV[@]}"; do
    [[ "$arg" == "--dangerously-skip-permissions" ]] && return 0
  done
  _AGENT_ARGV=("${_AGENT_ARGV[@]:0:2}" --dangerously-skip-permissions "${_AGENT_ARGV[@]:2}")
}

_agent_run_skill() {
  local skill="$1"
  local agent; agent=$(_project_agent)
  local skill_file="${ROLL_HOME}/skills/${skill}/SKILL.md"
  [[ -f "$skill_file" ]] || { err "Skill not found: ${skill}"; return 1; }
  local content; content=$(_skill_content "$skill_file")
  _agent_argv "$agent" text "$content" || {
    err "Unknown agent '${agent}'. Run: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"
    return 1
  }
  "${_AGENT_ARGV[@]}"
}

# ═══════════════════════════════════════════════════════════════════════════════
# SLIDES — deck.md → HTML rendering pipeline (US-DECK-003 / 004 / 005)
#
#   roll slides build <slug>   render .roll/slides/<slug>/deck.md → .html, open
#   roll slides new "<topic>"  invoke selected agent with roll-deck skill (US-DECK-004)
#   roll slides list           list decks as a table (US-DECK-005)
#   roll slides preview <slug> open .roll/slides/<slug>.html in browser (US-DECK-005)
#
# All four subcommands are implemented: `build` (DECK-003), `new` (DECK-004),
# `list` / `preview` (DECK-005). The AI authoring step happens in `new`;
# everything else is pure bash.
# ═══════════════════════════════════════════════════════════════════════════════
_slides_help() {
  cat <<'EOF'
roll slides — deck.md → HTML rendering
roll slides — 幻灯片 deck.md 渲染管线

USAGE  用法
  roll slides build <slug> [--no-open]
                          Render .roll/slides/<slug>/deck.md → .roll/slides/<slug>.html
                          渲染 deck.md 为 HTML 并自动打开浏览器
  roll slides new "<topic>" [--template <name>]
                          Generate a new deck.md from a topic via the selected AI agent
                          通过所选 AI agent 根据主题生成新的 deck.md
  roll slides list        List all decks under .roll/slides/ as a table
                          列出 .roll/slides/ 下所有幻灯片
  roll slides preview <slug> [--no-open]
                          Open .roll/slides/<slug>.html in the default browser
                          在浏览器中打开已渲染的幻灯片

OPTIONS  选项
  --no-open               Skip auto-opening the rendered HTML in a browser
                          渲染后不自动打开浏览器
  --help, -h              Show this help
                          显示本帮助
EOF
}

# Resolve the renderer / validator paths (shipped with the roll package).
_slides_lib() {
  printf '%s' "${ROLL_PKG_DIR}/lib"
}

# Resolve the template path for a given template name.
# Returns 0 + prints the path if the template exists, else returns 1.
_slides_template_path() {
  local name="$1"
  local tpl="${ROLL_PKG_DIR}/site/slides/templates/${name}.html"
  if [[ -f "$tpl" ]]; then
    printf '%s' "$tpl"
    return 0
  fi
  return 1
}

# Read the `template:` value from a deck.md frontmatter. Defaults to
# `introduction-v3` if the field is absent (validator will catch missing field
# separately).
_slides_template_for_deck() {
  local deck="$1"
  local tpl
  tpl=$(awk '
    /^---[[:space:]]*$/ { d++; if (d==2) exit; next }
    d==1 && /^template:[[:space:]]*/ {
      sub(/^template:[[:space:]]*/, "")
      gsub(/^["'\'']|["'\'']$/, "")
      print
      exit
    }
  ' "$deck" 2>/dev/null)
  [[ -n "$tpl" ]] || tpl="introduction-v3"
  printf '%s' "$tpl"
}

# Ensure .roll/.gitignore contains `slides/*.html` so the per-build HTML
# artefact is ignored by default. deck.md remains committable. Idempotent.
_slides_ensure_gitignore() {
  local gi=".roll/.gitignore"
  mkdir -p ".roll"
  if [[ -f "$gi" ]] && grep -qE '^slides/\*\.html$' "$gi" 2>/dev/null; then
    return 0
  fi
  # Preserve a trailing newline before appending.
  if [[ -f "$gi" ]] && [[ -s "$gi" ]] && [[ "$(tail -c 1 "$gi" 2>/dev/null)" != $'\n' ]]; then
    printf '\n' >>"$gi"
  fi
  printf 'slides/*.html\n' >>"$gi"
}

# Pick the browser-open command for the current OS. Echoes the command name;
# returns 1 if no opener is available (tests + headless CI).
_slides_open_cmd() {
  case "$(uname -s 2>/dev/null)" in
    Darwin) command -v open >/dev/null 2>&1 && { printf 'open'; return 0; } ;;
    Linux)  command -v xdg-open >/dev/null 2>&1 && { printf 'xdg-open'; return 0; } ;;
  esac
  return 1
}

cmd_slides_build() {
  local slug="" no_open=0
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --no-open) no_open=1; shift ;;
      --help|-h) _slides_help; return 0 ;;
      --*) err "Unknown option: $1  未知选项: $1"; return 1 ;;
      *)
        if [[ -z "$slug" ]]; then
          slug="$1"; shift
        else
          err "Unexpected argument: $1  多余参数: $1"; return 1
        fi
        ;;
    esac
  done

  if [[ -z "$slug" ]]; then
    err "Usage: roll slides build <slug> [--no-open]"
    echo "用法: roll slides build <slug> [--no-open]" >&2
    return 1
  fi

  local deck=".roll/slides/${slug}/deck.md"
  if [[ ! -f "$deck" ]]; then
    err "Deck not found: ${deck}"
    echo "  未找到 deck 文件：${deck}" >&2
    echo "  Hint: run 'roll slides new \"<topic>\"' to generate a new deck." >&2
    echo "  提示：先运行 'roll slides new \"<主题>\"' 生成新的幻灯片。" >&2
    return 1
  fi

  local lib_dir; lib_dir=$(_slides_lib)
  local validator="${lib_dir}/slides-validate.py"
  local renderer="${lib_dir}/slides-render.py"
  if [[ ! -f "$validator" || ! -f "$renderer" ]]; then
    err "Slides toolchain missing — re-run 'roll setup'  渲染工具缺失，请运行 roll setup"
    return 1
  fi

  # 1. Validate first (fail-fast on AI-generated decks).
  if ! python3 "$validator" "$deck"; then
    err "deck.md validation failed — fix the issues above before building."
    echo "  deck.md 校验失败，请先修复上方提示再重试。" >&2
    return 1
  fi

  # 2. Resolve template + render.
  local tpl_name; tpl_name=$(_slides_template_for_deck "$deck")
  local tpl_path
  if ! tpl_path=$(_slides_template_path "$tpl_name"); then
    err "Template not found: ${tpl_name}  未找到模板：${tpl_name}"
    return 1
  fi

  local out=".roll/slides/${slug}.html"
  mkdir -p ".roll/slides"
  if ! python3 "$renderer" "$deck" "$tpl_path" "$out"; then
    err "Render failed for ${deck}  渲染失败：${deck}"
    return 1
  fi

  # 3. Default-ignore the HTML artefact so it doesn't accidentally get committed.
  _slides_ensure_gitignore

  ok "Rendered → ${out}  渲染完成 → ${out}"

  # 4. Auto-open browser unless suppressed (or running inside bats tests).
  if [[ "$no_open" -eq 1 ]] || [[ -n "${BATS_TEST_NUMBER:-}" ]] || [[ -n "${ROLL_SLIDES_NO_OPEN:-}" ]]; then
    return 0
  fi
  local opener
  if opener=$(_slides_open_cmd); then
    "$opener" "$out" >/dev/null 2>&1 || true
  fi
  return 0
}

# ─── US-DECK-005 ─────────────────────────────────────────────────────────────
# Read a frontmatter field from deck.md. Returns empty string if absent.
# Stops scanning at the closing `---` so YAML body keys can't leak through.
_slides_frontmatter_field() {
  local deck="$1" field="$2"
  awk -v field="$field" '
    /^---[[:space:]]*$/ { d++; if (d==2) exit; next }
    d==1 {
      pat = "^" field "[[:space:]]*:[[:space:]]*"
      if ($0 ~ pat) {
        sub(pat, "")
        gsub(/^["'\'']|["'\'']$/, "")
        print
        exit
      }
    }
  ' "$deck" 2>/dev/null
}

# Format a byte count for human-friendly display: 1234 → "1.2K", 2345678 → "2.2M".
# Bash arithmetic only — no `bc`, no `numfmt` dependency.
_slides_human_size() {
  local bytes="${1:-0}"
  if [[ "$bytes" -lt 1024 ]]; then
    printf '%dB' "$bytes"
  elif [[ "$bytes" -lt 1048576 ]]; then
    local tenth=$(( (bytes * 10) / 1024 ))
    printf '%d.%dK' "$((tenth / 10))" "$((tenth % 10))"
  else
    local tenth=$(( (bytes * 10) / 1048576 ))
    printf '%d.%dM' "$((tenth / 10))" "$((tenth % 10))"
  fi
}

cmd_slides_list() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --help|-h) _slides_help; return 0 ;;
      --*) err "Unknown option: $1  未知选项: $1"; return 1 ;;
      *) err "Unexpected argument: $1  多余参数: $1"; return 1 ;;
    esac
  done

  local slides_dir=".roll/slides"
  if [[ ! -d "$slides_dir" ]]; then
    info "No decks found under .roll/slides/  无幻灯片"
    echo "  Hint: run 'roll slides new \"<topic>\"' to create one."
    echo "  提示：运行 'roll slides new \"<主题>\"' 创建第一个幻灯片。"
    return 0
  fi

  local -a slugs=()
  local d slug
  shopt -s nullglob
  for d in "$slides_dir"/*/; do
    slug="${d%/}"
    slug="${slug##*/}"
    if [[ -f "${d}deck.md" ]]; then
      slugs+=("$slug")
    fi
  done
  shopt -u nullglob

  if [[ "${#slugs[@]}" -eq 0 ]]; then
    info "No decks found under .roll/slides/  无幻灯片"
    echo "  Hint: run 'roll slides new \"<topic>\"' to create one."
    echo "  提示：运行 'roll slides new \"<主题>\"' 创建第一个幻灯片。"
    return 0
  fi

  local -a sorted_slugs
  IFS=$'\n' sorted_slugs=($(printf '%s\n' "${slugs[@]}" | sort))
  unset IFS

  printf '%-20s  %-20s  %-12s  %-12s  %-5s  %s\n' \
    "slug" "template" "total_slides" "created" "built" "size"
  printf '%-20s  %-20s  %-12s  %-12s  %-5s  %s\n' \
    "----" "--------" "------------" "-------" "-----" "----"

  local s deck html template total created built size bytes
  for s in "${sorted_slugs[@]}"; do
    deck="${slides_dir}/${s}/deck.md"
    html="${slides_dir}/${s}.html"
    template=$(_slides_frontmatter_field "$deck" "template")
    [[ -z "$template" ]] && template="-"
    total=$(_slides_frontmatter_field "$deck" "total_slides")
    [[ -z "$total" ]] && total="-"
    created=$(_slides_frontmatter_field "$deck" "created")
    [[ -z "$created" ]] && created="-"
    if [[ -f "$html" ]]; then
      built="✓"
      bytes=$(wc -c <"$html" 2>/dev/null | tr -d ' ')
      [[ -z "$bytes" ]] && bytes=0
      size=$(_slides_human_size "$bytes")
    else
      built="✗"
      size="-"
    fi
    printf '%-20s  %-20s  %-12s  %-12s  %-5s  %s\n' \
      "$s" "$template" "$total" "$created" "$built" "$size"
  done
  return 0
}

cmd_slides_preview() {
  local slug="" no_open=0
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --no-open) no_open=1; shift ;;
      --help|-h) _slides_help; return 0 ;;
      --*) err "Unknown option: $1  未知选项: $1"; return 1 ;;
      *)
        if [[ -z "$slug" ]]; then
          slug="$1"; shift
        else
          err "Unexpected argument: $1  多余参数: $1"; return 1
        fi
        ;;
    esac
  done

  if [[ -z "$slug" ]]; then
    err "Usage: roll slides preview <slug> [--no-open]"
    echo "用法: roll slides preview <slug> [--no-open]" >&2
    return 1
  fi

  local html=".roll/slides/${slug}.html"
  if [[ ! -f "$html" ]]; then
    err "Rendered HTML not found: ${html}"
    echo "  未找到已渲染的 HTML：${html}" >&2
    echo "  Hint: run 'roll slides build ${slug}' first to render it." >&2
    echo "  提示：先运行 'roll slides build ${slug}' 渲染幻灯片。" >&2
    return 1
  fi

  ok "Preview → ${html}  打开预览 → ${html}"

  if [[ "$no_open" -eq 1 ]] || [[ -n "${BATS_TEST_NUMBER:-}" ]] || [[ -n "${ROLL_SLIDES_NO_OPEN:-}" ]]; then
    return 0
  fi
  local opener
  if opener=$(_slides_open_cmd); then
    "$opener" "$html" >/dev/null 2>&1 || true
  fi
  return 0
}

# ─── US-DECK-004 ─────────────────────────────────────────────────────────────
# Turn a topic string into a kebab-case slug.
# Lower-cases, replaces any run of non-alphanumerics with a single dash,
# strips leading/trailing dashes. Matches the convention assumed by
# `roll slides build <slug>` (lowercase kebab) and the schema.
_slides_topic_slug() {
  local topic="$1"
  local slug
  slug=$(printf '%s' "$topic" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-')
  # Strip leading/trailing dashes.
  slug="${slug#-}"
  slug="${slug%-}"
  printf '%s' "$slug"
}

# `roll slides new "<topic>" [--template <name>]`
# AI authoring entry point. Loads the `roll-deck` skill, builds a single text
# prompt containing the skill body + topic + slug + template, and hands it
# to the selected project agent. The agent is responsible for writing
# `.roll/slides/<slug>/deck.md` (and nothing else). After the agent exits,
# we print a bilingual hint pointing at `roll slides build <slug>`.
cmd_slides_new() {
  local topic="" template="introduction-v3"
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --template)
        [[ -n "${2:-}" ]] || { err "--template requires a value  --template 需要一个值"; return 1; }
        template="$2"; shift 2 ;;
      --template=*) template="${1#--template=}"; shift ;;
      --help|-h) _slides_help; return 0 ;;
      --*) err "Unknown option: $1  未知选项: $1"; return 1 ;;
      *)
        if [[ -z "$topic" ]]; then
          topic="$1"; shift
        else
          err "Unexpected argument: $1  多余参数: $1"; return 1
        fi
        ;;
    esac
  done

  if [[ -z "$topic" ]]; then
    err "Usage: roll slides new \"<topic>\" [--template <name>]"
    echo "  用法：roll slides new \"<主题>\" [--template <模板名>]" >&2
    return 1
  fi

  local slug; slug=$(_slides_topic_slug "$topic")
  if [[ -z "$slug" ]]; then
    err "Could not derive a slug from topic: $topic"
    echo "  无法从主题派生 slug：$topic" >&2
    return 1
  fi

  local skill_file="${ROLL_PKG_DIR}/skills/roll-deck/SKILL.md"
  [[ -f "$skill_file" ]] || { err "Skill not found: ${skill_file}"; return 1; }
  local skill_body; skill_body=$(_skill_content "$skill_file")

  local agent; agent=$(_project_agent)

  # Compose the full prompt: skill body + concrete task context. The agent
  # reads the skill, then sees the topic / slug / template it must use.
  local prompt
  prompt="$(cat <<EOF
${skill_body}

---

# Task

topic: ${topic}
slug: ${slug}
template: ${template}
target_file: .roll/slides/${slug}/deck.md

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.

按本 skill 的工作流和硬约束生成 18 张双语 slide 的 deck.md。只写一个文件：.roll/slides/${slug}/deck.md，然后打印双语 "Next" 提示。
EOF
)"

  _agent_argv "$agent" text "$prompt" || {
    err "Unknown agent '${agent}'. Run: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"
    return 1
  }

  info "Launching ${agent} with roll-deck skill for topic: ${topic}"
  info "启动 ${agent} 处理主题：${topic}"
  "${_AGENT_ARGV[@]}"
  local rc=$?

  # Whether the agent succeeded or not, point the user at the next bash step.
  # (Build will surface its own validation errors if deck.md is malformed.)
  echo
  echo "Next:  roll slides build ${slug}"
  echo "下一步：roll slides build ${slug}"

  return "$rc"
}

cmd_slides() {
  local subcmd="${1:-}"
  shift || true
  case "$subcmd" in
    build)
      cmd_slides_build "$@"
      ;;
    new)
      cmd_slides_new "$@"
      ;;
    list)
      cmd_slides_list "$@"
      ;;
    preview)
      cmd_slides_preview "$@"
      ;;
    --help|-h|help)
      _slides_help
      return 0
      ;;
    "")
      _slides_help
      return 1
      ;;
    *)
      err "Unknown subcommand: ${subcmd}  未知子命令：${subcmd}"
      _slides_help >&2
      return 1
      ;;
  esac
}

cmd_review_pr() {
  local pr_number="${1:-}"
  [ -n "$pr_number" ] || { err "Usage: roll review-pr <number>"; return 1; }

  local slug; slug=$(_gh_repo_slug) || { err "Not a GitHub repo — review-pr requires GitHub remote"; return 1; }

  local pr_json
  pr_json=$(gh -R "$slug" pr view "$pr_number" --json title,body,diff 2>&1) \
    || { err "gh pr view failed: ${pr_json}"; return 1; }

  local title body diff
  title=$(echo "$pr_json" | jq -r '.title // ""')
  body=$(echo "$pr_json" | jq -r '.body // ""')
  diff=$(echo "$pr_json" | jq -r '.diff // ""')

  if echo "$body" | grep -qF '[skip-ai-review]'; then
    gh -R "$slug" pr review "$pr_number" --approve -b "Auto-approved: [skip-ai-review] detected" 2>/dev/null || true
    info "PR #${pr_number}: [skip-ai-review] — auto-approved"
    return 0
  fi

  local template="${ROLL_PKG_DIR}/skills/roll-review-pr/SKILL.md"
  [ -f "$template" ] || { err "Skill template not found: ${template}"; return 1; }

  local content; content=$(_skill_content "$template")

  local tmp; tmp=$(mktemp)
  # shellcheck disable=SC2064
  trap "rm -f '$tmp'" EXIT

  echo "$content" > "$tmp"
  sed -i '' "s|{{PR_TITLE}}|${title}|g" "$tmp" 2>/dev/null \
    || sed -i "s|{{PR_TITLE}}|${title}|g" "$tmp"

  local body_escaped; body_escaped=$(printf '%s' "$body" | sed 's/[&/\]/\\&/g; s/$/\\/' | sed '$ s/\\$//')
  sed -i '' "s|{{PR_BODY}}|${body_escaped}|g" "$tmp" 2>/dev/null \
    || sed -i "s|{{PR_BODY}}|${body_escaped}|g" "$tmp"

  local diff_truncated
  diff_truncated=$(echo "$diff" | head -500)
  local diff_file; diff_file=$(mktemp)
  echo "$diff_truncated" > "$diff_file"
  awk -v f="$diff_file" '{
    if (index($0, "{{PR_DIFF}}")) {
      while ((getline line < f) > 0) print line
      close(f)
    } else print
  }' "$tmp" > "${tmp}.out" && mv "${tmp}.out" "$tmp"
  rm -f "$diff_file"

  local prompt; prompt=$(cat "$tmp")
  rm -f "$tmp"
  trap - EXIT

  local agent; agent=$(_project_agent)
  local output
  info "Reviewing PR #${pr_number} with ${agent}..."
  _agent_argv "$agent" text "$prompt" || { err "Unknown agent '${agent}'"; return 1; }
  local _stderr_log; _stderr_log=$(mktemp)
  output=$("${_AGENT_ARGV[@]}" 2>"$_stderr_log")
  if [[ -z "$output" && -s "$_stderr_log" ]]; then
    err "agent ${agent} produced no output. stderr (first 5 lines):"
    head -5 "$_stderr_log" | sed 's/^/    /' >&2
  fi
  rm -f "$_stderr_log"

  echo "$output"

  local verdict; verdict=$(_parse_review_verdict "$output")
  local vtype; vtype="${verdict%%:*}"
  local vreason; vreason="${verdict#*:}"
  [ "$vreason" = "$vtype" ] && vreason=""

  case "$vtype" in
    APPROVE)
      gh -R "$slug" pr review "$pr_number" --approve -b "AI review: approved" 2>/dev/null || true
      info "PR #${pr_number}: APPROVED"
      ;;
    REQUEST_CHANGES)
      gh -R "$slug" pr review "$pr_number" --request-changes -b "${vreason:-AI review requested changes}" 2>/dev/null || true
      info "PR #${pr_number}: REQUEST_CHANGES — ${vreason}"
      ;;
    UNCERTAIN)
      warn "PR #${pr_number}: UNCERTAIN — ${vreason}"
      # FIX-052: write to per-project ALERT (was global ALERT.md).
      local alert_file="$_LOOP_ALERT"
      mkdir -p "$(dirname "$alert_file")"
      printf '[%s] PR #%s: AI review UNCERTAIN — %s\n' \
        "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$pr_number" "$vreason" >> "$alert_file"
      ;;
    *)
      warn "PR #${pr_number}: no verdict parsed from agent output"
      ;;
  esac
}

cmd_agent() {
  local subcmd="${1:-}"; shift || true
  case "$subcmd" in
    use)
      local name="${1:-}"
      [[ -z "$name" ]] && { err "Usage: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"; exit 1; }
      command -v "$name" &>/dev/null || warn "${name} not found in PATH — setting anyway  未找到，仍写入配置"
      # REFACTOR-040: write to .roll/local.yaml (per-machine state). Migrate
      # from legacy .roll.yaml in the project root on the spot — copy the
      # value over once, then delete the old file so the root stays clean.
      mkdir -p .roll
      local pref; pref=$(_project_agent_pref_file)
      if [[ -f "$pref" ]] && grep -q "^agent:" "$pref"; then
        local tmp; tmp=$(mktemp) && sed "s/^agent:.*/agent: ${name}/" "$pref" > "$tmp" && mv "$tmp" "$pref"
      else
        echo "agent: ${name}" >> "$pref"
      fi
      if [[ -f ".roll.yaml" ]]; then
        # Drop legacy agent line; remove the file if it's left empty (covers
        # the common case where .roll.yaml only ever held the agent pref).
        # grep -v returns 1 when nothing remains after filtering — that's a
        # success here, so suppress the exit code so the mv still runs.
        local tmp; tmp=$(mktemp)
        grep -v "^agent:" .roll.yaml > "$tmp" 2>/dev/null || true
        mv "$tmp" .roll.yaml
        [[ -s ".roll.yaml" ]] || rm -f .roll.yaml
      fi
      ok "Agent set to ${name} for this project  当前项目 agent 已设为 ${name}"
      local project_path; project_path=$(pwd -P)
      local slug; slug=$(_project_slug "$project_path")
      local runner="${_SHARED_ROOT}/loop/run-${slug}.sh"
      if [[ -f "$runner" ]]; then
        _install_launchd_plists "$project_path" >/dev/null
        ok "Loop runner scripts regenerated for new agent  已为新 agent 重新生成 loop 脚本"
      fi
      ;;
    list)
      echo ""; echo "  Available agents  可用 agent:"; echo ""
      local current; current=$(_project_agent)
      for a in claude kimi deepseek opencode codex pi; do
        if command -v "$a" &>/dev/null; then
          [[ "$a" == "$current" ]] && echo -e "    ${GREEN}✓ ${a}${NC}  (current)" \
                                   || echo -e "    ${GREEN}✓ ${a}${NC}"
        else
          echo -e "    ${YELLOW}✗ ${a}${NC}  (not installed)"
        fi
      done; echo ""
      ;;
    "")
      local agent; agent=$(_project_agent)
      local src="global"
      local pref; pref=$(_project_agent_pref_file)
      if [[ -f "$pref" ]] && grep -q "^agent:" "$pref" 2>/dev/null; then
        src="project (.roll/local.yaml)"
      elif [[ -f ".roll.yaml" ]] && grep -q "^agent:" .roll.yaml 2>/dev/null; then
        src="project (.roll.yaml, legacy — run \`roll agent use\` to migrate)"
      fi
      echo -e "\n  Agent  ${CYAN}${agent}${NC}  (${src})\n"
      echo "  roll agent use <name>   — switch agent for this project"
      echo "  roll agent list         — show installed agents"; echo ""
      ;;
    *) err "Unknown subcommand: $subcmd"; echo "Usage: roll agent [use <name>|list]"; exit 1 ;;
  esac
}

# ═══════════════════════════════════════════════════════════════════════════════
# LOOP — autonomous BACKLOG executor management
# ═══════════════════════════════════════════════════════════════════════════════

# Returns a filesystem-safe slug combining the project basename and a 6-char
# hash of the full path, ensuring uniqueness across sibling dirs with same name.
_project_slug() {
  # US-LOOP-006: cycle wrapper exports ROLL_MAIN_SLUG so any subshell — worktree,
  # tmp cwd, or unrelated path — writes events / runs.jsonl under the main project
  # identity instead of fragmenting into tmp-* / cycle-* phantom slugs.
  if [[ -n "${ROLL_MAIN_SLUG:-}" ]]; then
    printf '%s' "$ROLL_MAIN_SLUG"
    return 0
  fi
  local path="${1:-$(pwd -P 2>/dev/null || pwd)}"
  # FIX-056: normalize path to canonical case on macOS case-insensitive filesystem.
  # Two paths differing only in case point to the same directory; realpath
  # resolves both symlinks and case variations to the canonical filesystem path.
  if [[ "$(uname -s 2>/dev/null)" == "Darwin" ]]; then
    local _canon
    _canon=$(realpath "$path" 2>/dev/null) && path="$_canon"
  fi
  # FIX-034: when inside a git worktree, git-common-dir returns the main tree's
  # absolute .git path; resolve to the main tree so worktree and main-tree runs
  # produce the same slug.
  local _common
  _common=$(git -C "$path" rev-parse --git-common-dir 2>/dev/null)
  if [[ -n "$_common" && "$_common" == *"/.git" ]]; then
    path="${_common%/.git}"
  fi
  local base; base=$(basename "$path")
  local hash
  if command -v md5 &>/dev/null; then
    hash=$(printf '%s' "$path" | md5 | cut -c1-6)
  else
    hash=$(printf '%s' "$path" | md5sum | cut -c1-6)
  fi
  base=$(printf '%s' "$base" | tr -cs '[:alnum:]' '-' | sed 's/-*$//')
  printf '%s' "${base}-${hash}"
}

# FIX-058: migrate loop state files when the per-project slug changed due to
# FIX-056 (realpath case-normalization on macOS).  Called by
# _install_launchd_plists before generating new runner/plist so existing state
# (paused/running/etc.) is not silently lost.
#
# Usage: _slug_migrate_from_legacy <new_slug> [<loop_dir>] [<old_slug>]
#   new_slug   — the correct slug computed by the current _project_slug
#   loop_dir   — optional override of ${_SHARED_ROOT}/loop (for unit tests)
#   old_slug   — optional explicit old slug (for unit tests; auto-computed otherwise)
_slug_migrate_from_legacy() {
  local new_slug="$1"
  local loop_dir="${2:-${_SHARED_ROOT}/loop}"
  local old_slug="${3:-}"

  if [[ -z "$old_slug" ]]; then
    [[ "$(uname -s 2>/dev/null)" == "Darwin" ]] || return 0
    # Compute the pre-FIX-056 slug: same algorithm but without realpath.
    local raw_path; raw_path=$(pwd 2>/dev/null)
    local _common
    _common=$(git -C "$raw_path" rev-parse --git-common-dir 2>/dev/null)
    if [[ -n "$_common" && "$_common" == *"/.git" ]]; then
      raw_path="${_common%/.git}"
    fi
    local old_base; old_base=$(basename "$raw_path")
    local old_hash
    if command -v md5 &>/dev/null; then
      old_hash=$(printf '%s' "$raw_path" | md5 | cut -c1-6)
    else
      old_hash=$(printf '%s' "$raw_path" | md5sum | cut -c1-6)
    fi
    old_base=$(printf '%s' "$old_base" | tr -cs '[:alnum:]' '-' | sed 's/-*$//')
    old_slug="${old_base}-${old_hash}"
  fi

  [[ "$old_slug" == "$new_slug" ]] && return 0
  [[ -f "${loop_dir}/state-${old_slug}.yaml" ]] || return 0

  printf 'roll: migrating loop state %s → %s\n' "$old_slug" "$new_slug" >&2

  mv "${loop_dir}/state-${old_slug}.yaml" "${loop_dir}/state-${new_slug}.yaml"

  [[ -f "${loop_dir}/cron-${old_slug}.log" ]] && \
    mv "${loop_dir}/cron-${old_slug}.log" "${loop_dir}/cron-${new_slug}.log"

  if [[ -f "${loop_dir}/events-${old_slug}.ndjson" ]]; then
    if [[ -f "${loop_dir}/events-${new_slug}.ndjson" ]]; then
      cat "${loop_dir}/events-${old_slug}.ndjson" >> "${loop_dir}/events-${new_slug}.ndjson"
      rm "${loop_dir}/events-${old_slug}.ndjson"
    else
      mv "${loop_dir}/events-${old_slug}.ndjson" "${loop_dir}/events-${new_slug}.ndjson"
    fi
  fi

  local runs_file="${loop_dir}/runs.jsonl"
  if [[ -f "$runs_file" ]]; then
    local tmp; tmp=$(mktemp)
    python3 - "$old_slug" "$new_slug" "$runs_file" > "$tmp" << 'PYEOF'
import json, sys
old, new, path = sys.argv[1], sys.argv[2], sys.argv[3]
with open(path) as f:
    for line in f:
        line = line.rstrip('\n')
        if not line:
            continue
        try:
            d = json.loads(line)
            if 'project' in d and old in str(d['project']):
                d['project'] = str(d['project']).replace(old, new)
            print(json.dumps(d))
        except Exception:
            print(line)
PYEOF
    mv "$tmp" "$runs_file"
  fi

  local old_plist=~/Library/LaunchAgents/com.roll.loop.${old_slug}.plist
  if [[ -f "$old_plist" ]]; then
    launchctl unload "$old_plist" 2>/dev/null || true
    rm -f "$old_plist"
  fi

  rm -f "${loop_dir}/run-${old_slug}.sh" "${loop_dir}/run-${old_slug}-inner.sh"
}

_LOOP_TAG="# roll-loop"
# FIX-065: when sourced in a test context with no explicit override, route
# shared state into a per-process /tmp path instead of falling back to
# production ~/.shared/roll/. Without this safety net, tests that source
# bin/roll (directly or via a generated inner runner under /var/folders/)
# would write ALERT / state / events / runs.jsonl into the live loop
# daemon's monitored directory and trigger false aborts.
#
# Test context is detected via three signals (any one is enough):
#   1. BATS_TEST_FILENAME is set (works for direct test invocations)
#   2. The caller's file path lives under /tmp or /var/folders (catches the
#      generated runner-inner.sh path that bats subprocesses spawn —
#      BATS_* env can be lost across `bash -l` + nested forks)
#   3. PWD is under /tmp or /var/folders (catches helpers that cd'd in)
if [ -z "${_SHARED_ROOT:-}" ]; then
  _roll_in_test_ctx=0
  if [ -n "${BATS_TEST_FILENAME:-}" ]; then
    _roll_in_test_ctx=1
  else
    _roll_caller="${BASH_SOURCE[1]:-}"
    case "$_roll_caller" in /tmp/*|/private/tmp/*|/var/folders/*) _roll_in_test_ctx=1 ;; esac
    case "$PWD" in /tmp/*|/private/tmp/*|/var/folders/*) _roll_in_test_ctx=1 ;; esac
  fi
  if [ "$_roll_in_test_ctx" = 1 ]; then
    _SHARED_ROOT="${TMPDIR:-/tmp}/roll-test-shared.$$"
    mkdir -p "${_SHARED_ROOT}/loop"
    export _SHARED_ROOT
  fi
  unset _roll_in_test_ctx _roll_caller
fi
: "${_SHARED_ROOT:=${HOME}/.shared/roll}"
# FIX-052: per-project loop state — ALERT/state/mute were globally shared,
# causing one project's alerts to surface in another project's session and
# letting concurrent cycles overwrite each other's state. Align with the
# existing events-/run-/LOCK-/heartbeat-<slug> namespacing.
: "${_LOOP_PROJ_SLUG:=$(_project_slug 2>/dev/null || echo default)}"
: "${_LOOP_STATE:=${_SHARED_ROOT}/loop/state-${_LOOP_PROJ_SLUG}.yaml}"
: "${_LOOP_ALERT:=${_SHARED_ROOT}/loop/ALERT-${_LOOP_PROJ_SLUG}.md}"
# FIX-065: was hardcoded to ${HOME}/.shared/roll/loop/runs.jsonl which ignored
# _SHARED_ROOT overrides and silently leaked test runs.jsonl writes into prod.
_LOOP_RUNS="${_SHARED_ROOT}/loop/runs.jsonl"
: "${_LOOP_MUTE_FILE:=${_SHARED_ROOT}/loop/mute-${_LOOP_PROJ_SLUG}}"
_LAUNCHD_DIR="${HOME}/Library/LaunchAgents"

_config_read_int() {
  local key="$1" default="$2"
  local val
  val=$(config_get "$key" "")
  if [[ "$val" =~ ^[0-9]+$ ]]; then echo "$val"; else echo "$default"; fi
}

# REFACTOR-031: cross-platform file mtime in epoch seconds.
# Replaces the `stat -c %Y ... || stat -f %m ... || echo 0` pattern that was
# copy-pasted in four places (dashboard age widgets, briefs, dream, peer).
_file_mtime() {
  stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0
}

# Derive a minute in [1,55] from project path hash + offset so different projects
# and different services within a project don't fire at the same time.
# Offsets used: loop=0, dream=2, brief=4 → always three distinct values (2<55).
_loop_derive_minute() {
  local project_path="$1" offset="${2:-0}"
  local hash_hex
  if command -v md5 &>/dev/null; then
    hash_hex=$(printf '%s' "$project_path" | md5 | cut -c1-6)
  else
    hash_hex=$(printf '%s' "$project_path" | md5sum | cut -c1-6)
  fi
  local hash_dec; hash_dec=$(printf '%d' "0x${hash_hex}")
  echo $(( (hash_dec + offset) % 55 + 1 ))
}

# US-LOOP-001: structured event emission for cycle observability.
# Writes a tab-separated line to stdout (for tmux/attach display) and appends
# a JSON line to the per-project NDJSON event file under _SHARED_ROOT/loop/.
# Args: <stage> <label> <detail> <outcome>
_loop_event() {
  local stage="$1" label="$2" detail="$3" outcome="$4"
  local ts slug evfile json
  ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
  slug=$(_project_slug 2>/dev/null || basename "$PWD")
  evfile="${_SHARED_ROOT:-$HOME/.shared/roll}/loop/events-${slug}.ndjson"
  # FIX-065 tripwire: in a test context (BATS or temp cwd), refuse to write
  # into production ~/.shared/roll/. Catching this in code is the last line
  # of defense if some unusual path bypassed the auto-sandbox at source-time.
  # Skipped when HOME itself has been redirected to a sandbox dir — then
  # $HOME/.shared/roll IS the sandbox, not prod.
  case "${HOME:-}" in
    /tmp/*|/private/tmp/*|*/var/folders/*|*/tmp.*) ;;
    *)
      if [ -n "${HOME:-}" ] && [ "${evfile#${HOME}/.shared/roll/}" != "$evfile" ]; then
        case "${BATS_TEST_FILENAME:-}${PWD}" in
          */tmp.*|*/var/folders/*|/tmp/*|/private/tmp/*|*.bats)
            echo "[FIX-065] refusing prod _loop_event write: $evfile (test context)" >&2
            return 1
            ;;
        esac
      fi
      ;;
  esac
  mkdir -p "$(dirname "$evfile")"

  # stdout: tab-separated for tmux display
  printf '%s\t%s\t%s\t%s\t%s\n' "$ts" "$stage" "$label" "$detail" "$outcome"

  # JSON line appended to NDJSON file. FIX-067: drop the flock/lockf guard.
  # POSIX requires write() ≤ PIPE_BUF (≥512 bytes, 4 KiB on Linux/macOS) to
  # a file opened O_APPEND be atomic across concurrent writers, and a single
  # JSONL event line is well under that limit. The old lockfile path could
  # stall the EXIT trap added in FIX-066 when the lockfile state was
  # inconsistent, leaving cycle_end unwritten when the outer SIGHUP fired —
  # defeating FIX-066's whole purpose.
  json=$(printf '{"ts":"%s","stage":"%s","label":"%s","detail":"%s","outcome":"%s"}\n' \
    "$ts" "$stage" "$label" "$detail" "$outcome")
  printf '%s\n' "$json" >> "$evfile"

  # File rotation: if >10MB, rotate keeping last 5
  _loop_event_rotate "$evfile"
}

_loop_event_rotate() {
  local f="$1"
  local size
  size=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f" 2>/dev/null || echo 0)
  if [ "$size" -gt 10485760 ]; then
    # rotate: .4→remove, .3→.4, .2→.3, .1→.2, current→.1
    rm -f "${f}.4"
    for i in 3 2 1; do
      [ -f "${f}.$i" ] && mv "${f}.$i" "${f}.$((i+1))"
    done
    mv "$f" "${f}.1"
    touch "$f"
  fi
}

# FIX-050: probe brew prefix + common tool dirs to build a PATH that survives
# launchd/cron's bare-env launch. Setup-time companion to the runtime
# assembly snippet embedded in runner scripts.
_detect_path_prepend() {
  local dirs=() seen="" d out=""
  if command -v brew >/dev/null 2>&1; then
    local bp; bp=$(brew --prefix 2>/dev/null || true)
    [[ -n "$bp" && -d "$bp/bin" ]] && dirs+=("$bp/bin")
  fi
  [[ -d /opt/homebrew/bin ]] && dirs+=("/opt/homebrew/bin")
  [[ -d /usr/local/bin ]] && dirs+=("/usr/local/bin")
  [[ -d /opt/local/bin ]] && dirs+=("/opt/local/bin")
  [[ -d "$HOME/.local/bin" ]] && dirs+=("$HOME/.local/bin")
  dirs+=("/usr/bin" "/bin" "/usr/sbin" "/sbin")
  for d in "${dirs[@]}"; do
    case ":$seen:" in *":$d:"*) continue ;; esac
    seen="$seen:$d"
    [[ -z "$out" ]] && out="$d" || out="$out:$d"
  done
  printf '%s' "$out"
}

_launchd_label() {
  local service="$1" project_path="$2"
  printf 'com.roll.%s.%s' "$service" "$(_project_slug "$project_path")"
}

_launchd_plist_path() {
  local service="$1" project_path="$2"
  printf '%s/%s.plist' "$_LAUNCHD_DIR" "$(_launchd_label "$service" "$project_path")"
}

_write_launchd_plist() {
  local plist_path="$1" label="$2" project_path="$3"
  local minute="$4" hour="$5" runner_script="$6"

  local hour_xml=""
  [[ -n "$hour" ]] && hour_xml="    <key>Hour</key>
    <integer>${hour}</integer>
"

  # FIX-050: bake PATH into the plist so launchd-spawned bash can find tmux,
  # claude, node, etc. The runner script also re-asserts PATH at runtime as
  # a second layer (covers stale plists where brew was installed after setup).
  local path_value; path_value=$(_detect_path_prepend)

  local content
  content="<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
<plist version=\"1.0\">
<dict>
  <key>Label</key>
  <string>${label}</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>-l</string>
    <string>${runner_script}</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key>
    <string>${path_value}</string>
  </dict>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Minute</key>
    <integer>${minute}</integer>
${hour_xml}  </dict>
  <key>WorkingDirectory</key>
  <string>${project_path}</string>
</dict>
</plist>"

  if [[ -f "$plist_path" ]] && [[ "$(cat "$plist_path")" == "$content" ]]; then
    return 0
  fi
  printf '%s\n' "$content" > "$plist_path"
}

_write_runner_script() {
  local script_path="$1" project_path="$2" cmd="$3" log_path="$4"
  mkdir -p "$(dirname "$script_path")"
  printf '#!/bin/bash -l\ncd "%s" && %s >> "%s" 2>&1\n' "$project_path" "$cmd" "$log_path" > "$script_path"
  chmod +x "$script_path"
}

# Like _write_runner_script but prepends an active window guard.
# Silently exits when current hour is outside [active_start, active_end).
# When tmux is available, wraps the inner command in a detached tmux session
# named `roll-loop-<slug>` so `roll loop attach` can watch in real time.
# Falls back to headless execution when tmux is not installed.
_write_loop_runner_script() {
  local script_path="$1" project_path="$2" cmd="$3" log_path="$4"
  local active_start="${5:-10}" active_end="${6:-18}"
  # FIX-054: terminal preference detection removed. Popup is hard-coded to
  # macOS Terminal.app; the 7th positional arg, if any, is ignored for
  # backwards compatibility with existing callers.
  mkdir -p "$(dirname "$script_path")"

  local inner_path="${script_path%.sh}-inner.sh"
  # Use stream-json + formatter: --verbose alone does nothing in -p mode;
  # stream-json enables realtime streaming; loop-fmt.py humanizes the events.
  local fmt_script="${ROLL_PKG_DIR}/lib/loop-fmt.py"
  local roll_bin="${ROLL_PKG_DIR}/bin/roll"
  # FIX-041: loop cycle is autonomous — permission prompts and sandbox path
  # restrictions only cause the cycle to burn turns asking for approvals
  # it cannot receive. Bypass all permission checks for the inner claude
  # invocation. Worktree isolation contains the blast radius.
  local cmd_verbose="${cmd/claude -p/claude -p --verbose --dangerously-skip-permissions --output-format stream-json}"
  # US-AUTO-037: strip leading `cd "<path>" && ` (callers like
  # _install_launchd_plists prepend it). The runner now manages cwd itself
  # — pointing at the worktree when isolation succeeds, project_path otherwise.
  local claude_cmd; claude_cmd="${cmd_verbose#cd \"*\" && }"
  # FIX-048: Claude Code resolves project root from the worktree's .git file to
  # the main repo, placing worktree absolute paths outside its sandbox. Inject
  # --add-dir "$WT" so the worktree directory is explicitly allowed. Only applies
  # to claude (the --output-format stream-json flag is exclusive to claude runs).
  if [[ "$claude_cmd" == *"--output-format stream-json"* ]]; then
    claude_cmd="${claude_cmd/--output-format stream-json/--output-format stream-json --add-dir \"\$WT\"}"
  fi
  local slug; slug=$(_project_slug "$project_path")
  cat > "$inner_path" << INNER
#!/bin/bash -l
set -o pipefail
# FIX-050: portable PATH assembly — launchd/cron deliver a bare PATH that
# misses brew-installed tools (tmux, claude, node, …). Iterate candidate
# dirs; only prepend when present and not already in PATH. Idempotent.
for _d in /opt/homebrew/bin /usr/local/bin /opt/local/bin "\$HOME/.local/bin"; do
  case ":\$PATH:" in *":\$_d:"*) ;; *) [ -d "\$_d" ] && PATH="\$_d:\$PATH" ;; esac
done
export PATH
# FIX-031: inner-level LOCK (PID + start-ts) — outer runner.sh LOCK can be
# bypassed (recovery / retry / direct invocation); this guards the actual
# claude invocation so a second session can't run under the same project.
INNER_LOCK="\$(dirname "\$0")/.INNER-LOCK-\$(basename "\$0" -inner.sh | sed 's/^run-//')"
if [ -f "\$INNER_LOCK" ]; then
  _prev_pid=""; _prev_ts=""
  IFS=: read -r _prev_pid _prev_ts < "\$INNER_LOCK" 2>/dev/null || true
  _now=\$(date -u +%s)
  if [ -n "\$_prev_pid" ] && [ -n "\$_prev_ts" ] \\
     && kill -0 "\$_prev_pid" 2>/dev/null \\
     && [ "\$((_now - _prev_ts))" -lt 14400 ]; then
    echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] inner loop already running (PID \$_prev_pid), skipping"
    exit 0
  fi
  rm -f "\$INNER_LOCK"
fi
printf '%s:%s\n' "\$\$" "\$(date -u +%s)" > "\$INNER_LOCK"
# FIX-038: background heartbeat writer — outer script uses this as primary liveness signal
# to detect stale execution without relying on PID reuse heuristics.
HEARTBEAT_FILE="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/.heartbeat-${slug}"
_heartbeat_writer() {
  while true; do echo "\$(date -u +%s)" > "\$HEARTBEAT_FILE"; sleep 60; done
}
_heartbeat_writer &
_HEARTBEAT_PID=\$!
# FIX-057: cycle hard timeout — 45 minute SLA per loop cycle. If a cycle runs
# longer, kill claude / loop-fmt.py / all backgrounded children, mark the
# in-progress backlog item Blocked (caller decides), and exit cleanly so the
# next cron tick can proceed. Overridable via env for tests.
LOOP_CYCLE_TIMEOUT_SEC="\${ROLL_LOOP_CYCLE_TIMEOUT_SEC:-2700}"
_CYCLE_TIMED_OUT=0
# IDEA-028 / FIX-066: track whether cycle_end has been emitted via any of the
# explicit completion paths (publish/merge_back/orphan-push/claude-failed).
# When zero, the EXIT trap emits a fallback so cycle_start never orphans the
# dashboard into a phantom "still running" row.
_CYCLE_END_WRITTEN=0
_on_sigterm() { _CYCLE_TIMED_OUT=1; }
trap '_on_sigterm' TERM
# US-LOOP-005: idempotent runs.jsonl writer shared by normal exit, timeout
# trap, and worktree-setup-failure early exit. Guards on jq + run_id dedupe so
# multiple callers in the same cycle are safe.
_runs_append() {
  local _status="\$1"; local _tcr="\${2:-0}"; local _built="\${3:-[]}"
  local _runs_dst="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/runs.jsonl"
  command -v jq >/dev/null 2>&1 || return 0
  local _cid="\${CYCLE_ID:-pre-cycle-\$\$}"
  local _rid="loop-\${_cid%-*}"
  grep -qF "\"run_id\":\"\$_rid\"" "\$_runs_dst" 2>/dev/null && return 0
  mkdir -p "\$(dirname "\$_runs_dst")"
  local _ts_now; _ts_now=\$(date -u +%Y-%m-%dT%H:%M:%SZ)
  local _start="\${CYCLE_START:-\$(date -u +%s)}"
  local _dur=\$(( \$(date -u +%s) - _start ))
  [ "\$_dur" -lt 0 ] && _dur=0
  jq -nc \\
    --arg ts "\$_ts_now" \\
    --arg project "${slug}" \\
    --arg run_id "\$_rid" \\
    --arg status "\$_status" \\
    --arg cycle_id "\$_cid" \\
    --argjson built "\$_built" \\
    --argjson skipped '[]' \\
    --argjson alerts '[]' \\
    --argjson tcr_count "\$_tcr" \\
    --argjson duration_sec "\$_dur" \\
    '{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
      cycle_id:\$cycle_id,
      built:\$built, skipped:\$skipped, alerts:\$alerts,
      tcr_count:\$tcr_count, duration_sec:\$duration_sec}' \\
    >> "\$_runs_dst" 2>/dev/null || true
}
_inner_cleanup() {
  local _rc=\$?
  # Kill heartbeat + every remaining background job (watchdog, orphan
  # loop-fmt.py, publish subshells) — bash's foreground 'wait' for the
  # pipe has already returned by the time the EXIT trap runs.
  kill "\${_HEARTBEAT_PID}" 2>/dev/null
  for _pid in \$(jobs -p); do kill "\$_pid" 2>/dev/null; done
  if [ "\${_CYCLE_TIMED_OUT:-0}" -eq 1 ]; then
    _loop_event cycle_end "\${CYCLE_ID:-unknown}" "\${BRANCH:-}" "blocked" 2>/dev/null || true
    _CYCLE_END_WRITTEN=1
    # US-LOOP-005 T9: timeout path must also write runs.jsonl row so dashboard
    # has a terminal record (cycle_end alone is insufficient — runs.jsonl is
    # the canonical history feed for 'roll loop runs').
    _runs_append "failed" 0 "[]" 2>/dev/null || true
    _worktree_alert "cycle \${CYCLE_ID:-unknown}: \${LOOP_CYCLE_TIMEOUT_SEC}s timeout — claude/python killed; in-progress story marked Blocked" 2>/dev/null || true
  fi
  # FIX-086: aborted-path orphan safety net. When the inner script is killed
  # (e.g. SIGUSR1, parent process death) after claude has committed TCR work
  # but before publish runs, the existing aborted fallback below writes
  # cycle_end aborted and the commits are local-only — if the worktree is
  # later cleaned up, the work is lost. Before falling through to aborted,
  # detect unpushed commits in the worktree and push them as an orphan
  # branch + tag (mirroring FIX-039's PR-publish-failed safety net). On
  # push success → cycle_end orphan; on failure → fall through to aborted
  # path below (no regression).
  # Skip when _CYCLE_TIMED_OUT=1: 45-min hard timeout SIGKILLs claude mid-flight,
  # so commits may not be atomic — keep human-in-loop via blocked path.
  # Guard uses inequality form so the FIX-066 audit anchors on the aborted
  # fallback below, not this block.
  if [ "\${_CYCLE_END_WRITTEN:-0}" != "1" ] \\
     && [ "\${_CYCLE_TIMED_OUT:-0}" = "0" ] \\
     && [ "\${_USE_WORKTREE:-0}" = "1" ] \\
     && [ -n "\${WT:-}" ] \\
     && [ -d "\$WT" ] \\
     && [ -n "\${CYCLE_ID:-}" ]; then
    _unpushed=\$(cd "\$WT" && git rev-list --count "origin/main..HEAD" 2>/dev/null || echo 0)
    if [ "\${_unpushed:-0}" -gt 0 ]; then
      _orphan_tag="loop-orphan-\${CYCLE_ID}"
      if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \\
           && git tag "\$_orphan_tag" 2>/dev/null \\
           && git push origin "\$_orphan_tag" 2>/dev/null ); then
        _loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "orphan" 2>/dev/null || true
        _CYCLE_END_WRITTEN=1
        _runs_append "orphan" 0 "[]" 2>/dev/null || true
        _worktree_alert "cycle \${CYCLE_ID}: aborted with \${_unpushed} commits; FIX-086 pushed orphan tag \${_orphan_tag}" 2>/dev/null || true
      fi
    fi
  fi
  # IDEA-028 / FIX-066: catch every other abort (SIGKILL, set -e fire, ALERT
  # poisoning that bypasses the retry budget, etc.). Without this, cycle_start
  # is emitted but cycle_end never is, and dashboard renders the cycle as
  # "still running" until the next successful cycle rolls past it.
  if [ "\${_CYCLE_END_WRITTEN:-0}" -eq 0 ] && [ -n "\${CYCLE_ID:-}" ]; then
    _loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "aborted" 2>/dev/null || true
    _runs_append "aborted" 0 "[]" 2>/dev/null || true
  fi
  rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE"
  exit "\$_rc"
}
trap '_inner_cleanup' EXIT

# US-AUTO-037: pull in worktree helpers (US-AUTO-036). Sourcing bin/roll is
# safe — its main() only runs when invoked directly (BASH_SOURCE == \$0).
# bin/roll's top-level \`set -euo pipefail\` infects us, so disable -e (the
# retry loop relies on tolerating non-zero exits) while keeping pipefail.
source "${roll_bin}"
set +e

# FIX-052: bin/roll initializes loop state paths from cwd at source time, but
# the inner script may be launched from anywhere. Override to this project's
# slug (baked at template generation) so helpers like _worktree_alert write
# to the correct project's ALERT-<slug>.md / state-<slug>.yaml / mute-<slug>.
_LOOP_PROJ_SLUG="${slug}"
_LOOP_ALERT="\${_SHARED_ROOT}/loop/ALERT-${slug}.md"
_LOOP_STATE="\${_SHARED_ROOT}/loop/state-${slug}.yaml"
_LOOP_MUTE_FILE="\${_SHARED_ROOT}/loop/mute-${slug}"
# US-LOOP-006: ROLL_MAIN_SLUG is the canonical identity for any subprocess —
# claude, loop-fmt.py, _loop_event in arbitrary cwd. _project_slug honors this
# env var first, so writes never fragment into tmp-* / cycle-* phantom slugs.
export ROLL_MAIN_SLUG="${slug}"
# FIX-070: helpers that need to update the main repo's backlog (e.g. when a
# worktree cycle marks a story 🔨 In Progress) read ROLL_MAIN_PROJECT to
# locate it — the cycle's own cwd is the worktree, not main.
export ROLL_MAIN_PROJECT="${project_path}"

# Pre-claude: try to create a per-cycle isolated worktree on origin/main.
# On any failure (no remote, no main, etc.) fall back to running in the
# project's main tree (degraded — no isolation, like pre-037 behavior).
CYCLE_ID="\$(date +%Y%m%d-%H%M%S)-\$\$"
CYCLE_START=\$(date +%s)
WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
BRANCH="loop/cycle-\${CYCLE_ID}"
_USE_WORKTREE=0
cd "${project_path}" 2>/dev/null || true
# FIX-040: orphan worktree recovery — scan for worktrees left by previous failed
# cycles (publish failed or inner script was SIGKILL'd). Attempt to publish each
# before starting the new cycle. Glob is chronological via timestamp in name.
for _orphan_wt in "\${_SHARED_ROOT}/worktrees/${slug}-cycle-"*; do
  [ -d "\$_orphan_wt" ] || continue
  # Confirm it's a real worktree directory (not glob literal when no matches)
  [ -d "\${_orphan_wt}/.git" ] || [ -f "\${_orphan_wt}/.git" ] || continue
  _orphan_branch=\$(cd "\$_orphan_wt" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
  [ -z "\$_orphan_branch" ] && continue
  _orphan_commits=\$(cd "\$_orphan_wt" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
  if [ "\$_orphan_commits" -gt 0 ]; then
    echo "[loop] FIX-040: recovering orphan worktree \$_orphan_wt (branch \$_orphan_branch, \${_orphan_commits} commits)"
    # FIX-045: rebase onto origin/main before publishing — avoids BEHIND state on GitHub
    if ! ( cd "\$_orphan_wt" && git fetch origin main 2>/dev/null && git rebase origin/main 2>/dev/null ); then
      echo "[loop] FIX-045: orphan \$_orphan_branch rebase failed — skipping recovery (conflict or network error)"
      continue
    fi
    _orphan_ok=0
    if ( cd "\$_orphan_wt" && _loop_is_doc_only_change ); then
      ( cd "\$_orphan_wt" && _loop_publish_doc_pr "\$_orphan_branch" "doc: recover orphan \${_orphan_branch}" ) && _orphan_ok=1
    else
      ( cd "\$_orphan_wt" && _loop_publish_pr "\$_orphan_branch" "recover orphan \${_orphan_branch}" ) && _orphan_ok=1
    fi
    if [ "\$_orphan_ok" -eq 1 ]; then
      _worktree_cleanup "\$_orphan_wt" "\$_orphan_branch"
      echo "[loop] FIX-040: orphan recovered and cleaned: \$_orphan_branch"
    else
      echo "[loop] FIX-040: orphan recovery publish failed for \$_orphan_branch — leaving preserved"
    fi
  else
    echo "[loop] FIX-040: orphan worktree \$_orphan_wt has no commits; cleaning up"
    _worktree_cleanup "\$_orphan_wt" "\$_orphan_branch"
  fi
done
# US-AUTO-038: snapshot orphan claude/* branches before claude runs so the
# post-claude cleanup can diff and delete only this session's additions.
CLAUDE_BRANCH_SNAPSHOT="\$(_claude_remote_snapshot "${project_path}")"
if _worktree_fetch_origin main \\
   && _worktree_create "\$WT" "\$BRANCH" "origin/main"; then
  _USE_WORKTREE=1
  _worktree_submodule_init "\$WT" 2>/dev/null || true
  # FIX-069: copy .roll/ meta (backlog, skills, conventions) into the
  # worktree as a read-only reference. Without this the cycle no-ops
  # because .roll/ is gitignored and the clean clone has no backlog
  # for Claude to read or skill entry points to dispatch to.
  _worktree_sync_meta "\$WT" 2>/dev/null || true
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
  _loop_event cycle_start "\${CYCLE_ID}" "" "" || true
else
  # P3 fix: skip the cycle entirely when worktree isolation fails.
  # --dangerously-skip-permissions is only safe paired with worktree isolation;
  # falling back to the main tree without isolation is unacceptable.
  _worktree_alert "cycle \${CYCLE_ID}: worktree setup failed — skipping cycle to avoid running without isolation"
  echo "[loop] cycle \${CYCLE_ID}: worktree setup failed; skipping cycle (no isolation)"
  # US-LOOP-005 T10: worktree-setup-failed path leaves no commits and never
  # emits cycle_start, but dashboard still needs a runs.jsonl row marking the
  # cycle as failed (otherwise the scheduled tick appears to have vanished).
  _runs_append "failed" 0 "[]" 2>/dev/null || true
  exit 0
fi

FMT="${fmt_script}"
# US-LOOP-004: hand loop-fmt the slug + cycle id + shared root so it can
# append a per-cycle 'usage' event into events-<slug>.ndjson with
# tokens / cost / model / duration. Reader (roll loop status) consumes
# that instead of having to scrape the overwritten cron.log.
export LOOP_PROJECT_SLUG="${slug}"
export LOOP_CYCLE_ID="\${CYCLE_ID}"
export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
for _attempt in 1 2 3; do
  # FIX-068: defensive reset before each attempt — _CYCLE_TIMED_OUT carries
  # the SIGTERM result of the previous attempt and would otherwise force an
  # immediate break on a clean retry.
  _CYCLE_TIMED_OUT=0
  # FIX-057 + FIX-068: watchdog — fires SIGTERM at the inner script (and its
  # direct children) when the cycle exceeds LOOP_CYCLE_TIMEOUT_SEC, then
  # escalates to SIGKILL after a 5s grace period for any claude process
  # still alive. claude lives inside a pipeline subshell, so pkill -P \$\$
  # alone only catches the subshell, not claude itself; matching by the
  # worktree path (which appears in claude's --add-dir arg, FIX-048) targets
  # the cycle's claude uniquely without touching other projects' processes.
  ( sleep "\$LOOP_CYCLE_TIMEOUT_SEC" && {
      kill -TERM \$\$ 2>/dev/null
      pkill -TERM -P \$\$ 2>/dev/null
      pkill -TERM -f "\$WT" 2>/dev/null
      sleep 5
      pkill -KILL -P \$\$ 2>/dev/null
      pkill -KILL -f "\$WT" 2>/dev/null
    } ) &
  _WATCHDOG_PID=\$!
  if [ -f "\$FMT" ]; then
    ( cd "\$WT" && ${claude_cmd} ) | python3 "\$FMT"
  else
    ( cd "\$WT" && ${claude_cmd} )
  fi
  _exit=\$?
  kill "\$_WATCHDOG_PID" 2>/dev/null
  wait "\$_WATCHDOG_PID" 2>/dev/null
  [ "\$_CYCLE_TIMED_OUT" -eq 1 ] && break
  [ "\$_exit" -eq 0 ] && break
  if [ "\$_attempt" -lt 3 ]; then
    echo "[loop] claude exited \$_exit (attempt \$_attempt/3) — retrying in 30s..."
    sleep 30
  fi
done

# FIX-057: timed out — skip publish; EXIT trap writes cycle_end blocked + ALERT.
if [ "\$_CYCLE_TIMED_OUT" -eq 1 ]; then
  echo "[loop] cycle \${CYCLE_ID}: \${LOOP_CYCLE_TIMEOUT_SEC}s timeout — aborting cycle (worktree preserved at \$WT)"
  exit 0
fi

# FIX-044: capture cycle data from worktree before cleanup removes it
_cycle_tcr=0
_cycle_status="idle"
_cycle_built="[]"
if [ "\$_USE_WORKTREE" = "1" ]; then
  if [ "\$_exit" -ne 0 ]; then
    _cycle_status="failed"
  else
    _cycle_commits_pre=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
    if [ "\$_cycle_commits_pre" -gt 0 ]; then
      _cycle_status="built"
      _cycle_tcr=\$(cd "\$WT" && git log --oneline origin/main..HEAD -- 2>/dev/null | grep -c ' tcr:' || echo 0)
      if command -v jq >/dev/null 2>&1; then
        _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 "[]")
      fi
    fi
  fi
fi

# US-AUTO-038: diff snapshot vs current and delete any claude/* branches this
# session pushed to origin. Runs regardless of claude's exit code (cleanup is
# orthogonal to success/failure) and is silent on non-GitHub / unreachable.
_claude_cleanup_new_branches "\$CLAUDE_BRANCH_SNAPSHOT" "${project_path}" || true
# REFACTOR-011: also prune local .claude/worktrees/ entries whose branch has
# been merged to main (remote-branch cleanup above doesn't touch local worktrees).
_claude_cleanup_stale_worktrees "${project_path}" || true

# Post-claude: publish cycle branch. Doc-only changes (BACKLOG/docs) merge
# immediately via --admin; code changes use auto-merge (CI gate required).
# When \`gh\` is unavailable, fall back to the legacy ff-merge path.
if [ "\$_USE_WORKTREE" = "1" ]; then
  if [ "\$_exit" -eq 0 ]; then
    # Idle cycle — no commits ahead of origin/main means nothing was built;
    # skip publish and reclaim the worktree immediately.
    _cycle_commits=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
    if [ "\$_cycle_commits" -eq 0 ]; then
      _worktree_cleanup "\$WT" "\$BRANCH"
      _loop_event idle "\${CYCLE_ID}" "" "" || true
      echo "[loop] cycle \${CYCLE_ID}: idle (no new commits); worktree cleaned"
    else
      _is_doc_only=0
      ( cd "\$WT" && _loop_is_doc_only_change ) && _is_doc_only=1
      if [ "\$_is_doc_only" -eq 1 ]; then
        ( cd "\$WT" && _loop_publish_doc_pr "\$BRANCH" "doc: loop cycle \${CYCLE_ID}" )
      else
        ( cd "\$WT" && _loop_publish_pr "\$BRANCH" "loop cycle \${CYCLE_ID}" )
      fi
      _publish_status=\$?
      if [ "\$_publish_status" -eq 0 ]; then
        # FIX-047: CI green ≠ delivered — wait for actual PR merge before cycle complete
        if [ "\$_is_doc_only" -eq 0 ]; then
          if ! ( cd "\$WT" && _loop_wait_pr_merge "\$BRANCH" ); then
            _worktree_alert "cycle \${CYCLE_ID}: FIX-047: PR not merged within timeout — code may not be in main (BRANCH=\${BRANCH})"
          fi
        fi
        # US-VIEW-011: emit terminal PR state (merged/closed/open) before cycle_end
        # so dashboard renders #NN ✓/↩/… correctly. Must run while branch ref
        # is still resolvable on remote — gh pr view <branch> needs the head ref.
        _loop_emit_pr_final "\$BRANCH" 2>/dev/null || true
        _worktree_cleanup "\$WT" "\$BRANCH"
        _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true; _CYCLE_END_WRITTEN=1
        echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
      elif [ "\$_publish_status" -eq 2 ]; then
        if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
          _worktree_cleanup "\$WT" "\$BRANCH"
          # US-LOOP-005 T3: gh unavailable + ff merge_back OK → cycle_end done
          _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true; _CYCLE_END_WRITTEN=1
          echo "[loop] cycle \${CYCLE_ID}: gh unavailable; merged via ff and cleaned up"
        else
          # FIX-039: gh unavailable + merge_back failed — push orphan branch+tag to origin
          # as final safety net so code is never local-only before worktree cleanup.
          _orphan_tag="loop-orphan-\${CYCLE_ID}"
          if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \
               && git tag "\$_orphan_tag" 2>/dev/null \
               && git push origin "\$_orphan_tag" 2>/dev/null ); then
            _worktree_cleanup "\$WT" "\$BRANCH"
            # US-LOOP-005 T4: gh unavailable + orphan push OK → cycle_end orphan
            _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true; _CYCLE_END_WRITTEN=1
            _worktree_alert "cycle \${CYCLE_ID}: gh+merge_back failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
            echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
          else
            # US-LOOP-005 T5: gh unavailable + all failed → cycle_end failed
            _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
            _worktree_alert "cycle \${CYCLE_ID}: gh+merge_back+push all failed; worktree preserved at \$WT"
            echo "[loop] cycle \${CYCLE_ID}: all publish paths failed; worktree preserved at \$WT"
          fi
        fi
      else
        # FIX-039: PR publish failed — push orphan branch+tag to origin as safety net.
        # (_loop_publish_pr may have already pushed the branch; git push is idempotent.)
        _orphan_tag="loop-orphan-\${CYCLE_ID}"
        if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \
             && git tag "\$_orphan_tag" 2>/dev/null \
             && git push origin "\$_orphan_tag" 2>/dev/null ); then
          _worktree_cleanup "\$WT" "\$BRANCH"
          # US-LOOP-005 T6: PR publish failed + orphan push OK → cycle_end orphan
          _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true; _CYCLE_END_WRITTEN=1
          _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
          echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
        else
          # US-LOOP-005 T7: PR publish failed + orphan push failed → cycle_end failed
          _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
          _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
          echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
        fi
      fi
    fi
  else
    # US-LOOP-005 T8: claude session failed after retry budget → cycle_end failed
    _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
    _worktree_alert "cycle \${CYCLE_ID}: claude exited \$_exit; worktree preserved at \$WT (branch \$BRANCH)"
    echo "[loop] cycle \${CYCLE_ID}: claude failed (exit \$_exit); worktree preserved at \$WT"
  fi
fi

# US-AUTO-040: fallback GC — delete remote loop/cycle-* branches already merged to main.
_loop_cleanup_stale_cycle_branches "${project_path}" || true

# FIX-044 / Step 5: Write loop cycle run summary to runs.jsonl
# Deterministic — runs in shell regardless of whether agent executes SKILL.md Step 5.
# US-LOOP-005: now routed through _runs_append so timeout/worktree-setup-fail
# share the same write logic. _runs_append is idempotent on run_id.
_runs_append "\$_cycle_status" "\$_cycle_tcr" "\$_cycle_built" 2>/dev/null || true
INNER
  chmod +x "$inner_path"

  cat > "$script_path" << SCRIPT
#!/bin/bash -l
# FIX-050: portable PATH assembly before any brew-tool lookup (tmux, caffeinate
# on some systems, claude). Mirrors the inner script's bootstrap so even when
# launchd's plist EnvironmentVariables is stale, the runner self-repairs.
for _d in /opt/homebrew/bin /usr/local/bin /opt/local/bin "\$HOME/.local/bin"; do
  case ":\$PATH:" in *":\$_d:"*) ;; *) [ -d "\$_d" ] && PATH="\$_d:\$PATH" ;; esac
done
export PATH
# caffeinate: prevent idle sleep from killing claude during cycles
caffeinate -i -w \$\$ &
# Active-window check — skipped when ROLL_LOOP_FORCE is set (manual 'roll loop now')
if [ -z "\$ROLL_LOOP_FORCE" ]; then
  h=\$(printf '%d' "\$(date +%H)")
  if [ "\$h" -lt ${active_start} ] || [ "\$h" -ge ${active_end} ]; then exit 0; fi
fi
# Pause check — 'roll loop pause' creates this marker to suspend scheduling
PAUSE="\$HOME/.shared/roll/loop/PAUSE-${slug}"
if [ -z "\$ROLL_LOOP_FORCE" ] && [ -f "\$PAUSE" ]; then exit 0; fi
# FIX-037: orphan state detection & self-heal — if state.yaml says running
# but no LOCK process or tmux session exists, the previous cycle was killed
# (e.g. SIGKILL / sleep / terminal close). Heal state to idle so the next
# cycle can proceed normally; write ALERT for transparency.
# FIX-038: heartbeat is the primary liveness signal (avoids PID reuse race);
# LOCK pid check is secondary fallback for backward compatibility.
HEARTBEAT_TIMEOUT="\${ROLL_HEARTBEAT_TIMEOUT:-1800}"
# FIX-052: per-project STATE_FILE (was global state.yaml — caused two projects
# to clobber each other's cycle state).
STATE_FILE="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/state-${slug}.yaml"
if [ -f "\$STATE_FILE" ]; then
  _state=\$(grep '^status:' "\$STATE_FILE" | awk '{print \$2}' 2>/dev/null || echo "")
  if [ "\$_state" = "running" ]; then
    _still_active=false
    # FIX-038: heartbeat is primary signal
    _heartbeat_file="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/.heartbeat-${slug}"
    if [ -f "\$_heartbeat_file" ]; then
      _hb_ts=\$(cat "\$_heartbeat_file" 2>/dev/null || echo "0")
      _now=\$(date -u +%s)
      _hb_age=\$(( _now - _hb_ts ))
      if [ "\$_hb_age" -lt "\$HEARTBEAT_TIMEOUT" ]; then
        _still_active=true
      fi
    fi
    # Fallback: LOCK pid check (for cycles without heartbeat, e.g. pre-FIX-038)
    if [ "\$_still_active" = false ]; then
      _lock_file="\$(dirname "\$0")/.LOCK-\$(basename "\$0" .sh | sed 's/^run-//')"
      if [ -f "\$_lock_file" ]; then
        _lock_pid=\$(head -1 "\$_lock_file" 2>/dev/null || echo "")
        [ -n "\$_lock_pid" ] && kill -0 "\$_lock_pid" 2>/dev/null && _still_active=true
      fi
    fi
    # Final: tmux session check
    if [ "\$_still_active" = false ]; then
      command -v tmux >/dev/null 2>&1 && tmux has-session -t "roll-loop-\$(basename "\$0" .sh | sed 's/^run-//')" 2>/dev/null && _still_active=true
    fi
    if [ "\$_still_active" = false ]; then
      echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] FIX-037: orphan state detected (status=running, heartbeat stale or missing) — healing to idle" >> "\$LOG"
      echo "status: idle" > "\${STATE_FILE}.tmp" && mv "\${STATE_FILE}.tmp" "\$STATE_FILE"
      rm -f "\$_lock_file" 2>/dev/null || true
      # FIX-052: per-project ALERT file (was shared ALERT.md — caused R0
      # auto-heal entries to surface in Roll's alert view).
      _alert_file="\$(dirname "\$0")/ALERT-${slug}.md"
      echo "\$(date '+%Y-%m-%dT%H:%M:%S%z') | FIX-037 auto-heal | Orphan state detected and cleared (status=running → idle)" >> "\$_alert_file" 2>/dev/null || true
      echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] FIX-037: healed to idle, ALERT written" >> "\$LOG"
    fi
  fi
fi
LOCK="\$(dirname "\$0")/.LOCK-\$(basename "\$0" .sh | sed 's/^run-//')"
SESSION="roll-loop-\$(basename "\$0" .sh | sed 's/^run-//')"
INNER_SCRIPT="${inner_path}"
LOG="${log_path}"
if [ -f "\$LOCK" ]; then
  prev_pid=\$(head -1 "\$LOCK" 2>/dev/null || echo "")
  if [ -n "\$prev_pid" ] && kill -0 "\$prev_pid" 2>/dev/null; then
    echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] loop already running (PID \$prev_pid), skipping" >> "\$LOG"
    exit 0
  fi
  rm -f "\$LOCK"
fi
# Guard against stale-LOCK case: if the tmux session is already alive,
# a previous runner's LOCK was removed (e.g. parent terminal closed) but
# the work is still in progress — don't kill it.
if command -v tmux >/dev/null 2>&1 && tmux has-session -t "\$SESSION" 2>/dev/null; then
  echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] tmux session \$SESSION still active, skipping" >> "\$LOG"
  exit 0
fi
echo "\$\$" > "\$LOCK"
trap 'rm -f "\$LOCK"' EXIT
if command -v tmux >/dev/null 2>&1; then
  tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^roll-loop-${slug}\$" | while read _s; do
    tmux kill-session -t "\$_s" 2>/dev/null || true
  done
  tmux new-session -d -s "\$SESSION" -x 200 -y 50 "bash \"\$INNER_SCRIPT\""
  tmux pipe-pane -t "\$SESSION" "cat >> \"\$LOG\""
  # Auto-attach popup: when not muted, spawn a Terminal.app window attached
  # to the tmux session so the user can watch the loop work in real time.
  # FIX-054: terminal selection removed — fixed to macOS Terminal.app for
  # predictability (per-user detection silently failed on Ghostty upgrades).
  # Best-effort focus retention: capture the current frontmost app and
  # re-activate after.
  if [ -z "\${ROLL_LOOP_NO_POPUP:-}" ] && [ -z "\${BATS_TEST_NUMBER:-}" ] && [ ! -f "\$HOME/.shared/roll/loop/mute-${slug}" ] && [ "\$(uname)" = "Darwin" ] && command -v osascript >/dev/null 2>&1; then
    osascript \\
      -e 'tell application "System Events" to set _prev to name of first application process whose frontmost is true' \\
      -e "tell application \"Terminal\" to do script \"tmux attach -t \$SESSION\"" \\
      -e 'delay 0.3' -e 'try' -e 'tell application _prev to activate' -e 'end try' >/dev/null 2>&1 || true
  fi
  _OUTER_TIMEOUT=\$(( \${ROLL_LOOP_CYCLE_TIMEOUT_SEC:-2700} + 300 ))
  _outer_wait_start=\$(date +%s)
  while tmux has-session -t "\$SESSION" 2>/dev/null; do
    sleep 5
    if (( \$(date +%s) - _outer_wait_start > _OUTER_TIMEOUT )); then
      echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] FIX-057: outer timeout (\${_OUTER_TIMEOUT}s) — killing tmux session \$SESSION" >> "\$LOG"
      tmux kill-session -t "\$SESSION" 2>/dev/null || true
      break
    fi
  done
else
  bash "\$INNER_SCRIPT" >> "\$LOG" 2>&1
fi
SCRIPT
  chmod +x "$script_path"
}

_launchd_is_loaded() {
  launchctl print-disabled "gui/$(id -u)" 2>/dev/null | grep -qF "\"$1\" => enabled"
}

_launchd_svc_state() {
  local svc="$1" project_path="$2"
  local label; label=$(_launchd_label "$svc" "$project_path")
  local plist; plist=$(_launchd_plist_path "$svc" "$project_path")
  if _launchd_is_loaded "$label"; then
    echo "enabled"
  elif [[ -f "$plist" ]]; then
    echo "installed-off"
  else
    echo "not-installed"
  fi
}

# Install launchd plist files (disabled by default) and runner scripts for
# a given project path. Idempotent — skips unchanged files. Does NOT load.
# Schedule times are read from ~/.roll/config.yaml; missing fields are
# auto-derived from the project path hash so different projects don't contend.
_install_launchd_plists() {
  local project_path="$1"
  local sd="${ROLL_HOME}/skills"
  local shared="${_SHARED_ROOT}"

  mkdir -p "$_LAUNCHD_DIR"
  mkdir -p "${shared}/loop" "${shared}/dream" "${shared}/brief"

  local active_start active_end loop_minute dream_hour dream_minute brief_hour brief_minute
  active_start=$(_config_read_int "loop_active_start" "10")
  active_end=$(_config_read_int "loop_active_end" "18")
  loop_minute=$(_config_read_int "loop_minute" "$(_loop_derive_minute "$project_path" 0)")
  dream_hour=$(_config_read_int "loop_dream_hour" "3")
  dream_minute=$(_config_read_int "loop_dream_minute" "$(_loop_derive_minute "$project_path" 2)")
  brief_hour=$(_config_read_int "loop_brief_hour" "9")
  brief_minute=$(_config_read_int "loop_brief_minute" "$(_loop_derive_minute "$project_path" 4)")

  # FIX-054: terminal preference removed — runner always uses Terminal.app.

  local services=("loop" "dream" "brief")
  local skill_names=("roll-loop" "roll-.dream" "roll-brief")
  local minutes=("$loop_minute" "$dream_minute" "$brief_minute")
  local hours=("" "$dream_hour" "$brief_hour")

  local updated=0
  local slug; slug=$(_project_slug "$project_path")
  # FIX-058: after FIX-056 introduced realpath normalization, the slug for an
  # existing project may have changed.  Migrate state before creating new files.
  _slug_migrate_from_legacy "$slug" "${shared}/loop"
  for i in "${!services[@]}"; do
    local svc="${services[$i]}"
    local skill="${skill_names[$i]}"
    local minute="${minutes[$i]}"
    local hour="${hours[$i]}"
    local label; label=$(_launchd_label "$svc" "$project_path")
    local plist; plist=$(_launchd_plist_path "$svc" "$project_path")
    local runner="${shared}/${svc}/run-${slug}.sh"
    # FIX-052: per-project cron log so concurrent projects don't interleave.
    local log="${shared}/${svc}/cron-${slug}.log"
    local cmd; cmd=$(_agent_skill_cmd "${sd}/${skill}/SKILL.md" 2>/dev/null || echo "roll loop now")

    if [[ "$svc" == "loop" ]]; then
      _write_loop_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log" "$active_start" "$active_end"
    else
      _write_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log"
    fi

    local before=""
    [[ -f "$plist" ]] && before=$(cat "$plist")
    _write_launchd_plist "$plist" "$label" "$project_path" "$minute" "$hour" "$runner"
    local after; after=$(cat "$plist")
    if [[ "$before" != "$after" ]]; then
      updated=$((updated + 1))
      if _launchd_is_loaded "$label"; then
        # FIX-027: use bootout/bootstrap so we don't disturb the label's
        # enabled flag in the launchd overrides db (which legacy
        # unload/load no-`-w` wipes on macOS Sonoma+, causing
        # `roll loop status` to falsely report off after `roll update`).
        local uid; uid=$(id -u)
        launchctl bootout "gui/${uid}/${label}" 2>/dev/null || true
        launchctl bootstrap "gui/${uid}" "$plist" 2>/dev/null || true
      elif [[ -z "$before" ]]; then
        # FIX-059: brand-new plist — macOS FSEvents auto-bootstraps any new
        # file dropped in ~/Library/LaunchAgents/, so projects never enabled
        # via 'roll loop on' would fire every hour. Immediately mark disabled
        # in the overrides db to block that auto-load.
        local uid; uid=$(id -u)
        launchctl disable "gui/${uid}/${label}" 2>/dev/null || true
      fi
    fi
  done

  if [[ $updated -gt 0 ]]; then
    ok "Launchd plists installed (${updated} updated)  LaunchAgents 已安装"
    echo "  Run: roll loop on  to activate  运行 roll loop on 激活"
  else
    ok "Launchd plists up to date  LaunchAgents 无需更新"
  fi
}

_agent_skill_cmd() {
  local skill_path="$1"
  local agent; agent=$(_project_agent)
  local strip="awk 'NR==1 && /^---$/{skip=1;next} skip && /^---$/{skip=0;next} !skip{print}' '${skill_path}'"
  _agent_argv "$agent" plain "__PROMPT__" || {
    err "Unknown agent '${agent}'. Run: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"
    return 1
  }
  # Cron-installed skills (dream / brief / loop) run autonomously and need to
  # Edit files (.roll/dream/, .roll/briefs/, BACKLOG, etc.). Claude Code 2.1.x's
  # pre-write approval UX silently blocks `claude -p` from applying edits in
  # non-interactive pipe mode — bypass it for the cron context.
  _agent_bypass_claude_perms
  # In cron context, use absolute claude path so a fresh shell can find it.
  [[ "$agent" == "claude" ]] && _AGENT_ARGV[0]="$(command -v claude 2>/dev/null || echo claude)"
  # Drop the prompt sentinel (always last), re-emit head args + quoted $(strip).
  local out="${_AGENT_ARGV[0]}" i prompt_idx=$((${#_AGENT_ARGV[@]} - 1))
  for ((i = 1; i < prompt_idx; i++)); do
    out+=" ${_AGENT_ARGV[i]}"
  done
  echo "${out} \"\$(${strip})\""
}

cmd_loop() {
  local subcmd="${1:-status}"; shift || true
  case "$subcmd" in
    on)      _loop_on ;;
    off)     _loop_off ;;
    now)     _loop_now ;;
    test)    _loop_test ;;
    status)  _loop_status "$@" ;;
    monitor) _loop_monitor "${1:-3}" ;;
    runs)    _loop_runs "$@" ;;
    events)  _loop_event_log "${1:-20}" ;;
    attach)  _loop_attach ;;
    mute)    _loop_mute ;;
    unmute)  _loop_unmute ;;
    pause)   _loop_pause ;;
    resume)  _loop_resume ;;
    reset)   _loop_reset ;;
    notify)       _notify "${1:-roll}" "${2:-}" ;;
    enforce-tcr)  _loop_enforce_tcr "${1:-}" "${2:-}" ;;
    precheck-ci)  _loop_precheck_ci ;;
    *) err "Usage: roll loop <on|off|now|test|status|monitor|runs|events|attach|mute|unmute|pause|resume|reset|notify|enforce-tcr|precheck-ci>"; exit 1 ;;
  esac
}

_loop_on() {
  local project_path; project_path=$(pwd -P)
  local agent; agent=$(_project_agent)

  local active_start active_end loop_minute dream_hour dream_minute brief_hour brief_minute
  active_start=$(_config_read_int "loop_active_start" "10")
  active_end=$(_config_read_int "loop_active_end" "18")
  loop_minute=$(_config_read_int "loop_minute" "$(_loop_derive_minute "$project_path" 0)")
  dream_hour=$(_config_read_int "loop_dream_hour" "3")
  dream_minute=$(_config_read_int "loop_dream_minute" "$(_loop_derive_minute "$project_path" 2)")
  brief_hour=$(_config_read_int "loop_brief_hour" "9")
  brief_minute=$(_config_read_int "loop_brief_minute" "$(_loop_derive_minute "$project_path" 4)")

  if [[ "$(uname)" == "Darwin" ]]; then
    _install_launchd_plists "$project_path" >/dev/null

    local all_loaded=true
    for svc in loop dream brief; do
      local label; label=$(_launchd_label "$svc" "$project_path")
      if ! _launchd_is_loaded "$label"; then
        all_loaded=false
        launchctl load -w "$(_launchd_plist_path "$svc" "$project_path")" 2>/dev/null || true
      fi
    done

    if $all_loaded; then
      warn "Loop already enabled for this project  当前项目 loop 已启用"; return 0
    fi

    ok "Loop enabled  已启用"
    printf "  • roll-loop   every hour :%02d  active %02d:00–%02d:00  每小时 :%02d（窗口 %02d:00–%02d:00）\n" \
      "$loop_minute" "$active_start" "$active_end" "$loop_minute" "$active_start" "$active_end"
    printf "  • roll-.dream daily at %02d:%02d  每天 %02d:%02d\n" "$dream_hour" "$dream_minute" "$dream_hour" "$dream_minute"
    printf "  • roll-brief  daily at %02d:%02d  每天 %02d:%02d\n" "$brief_hour" "$brief_minute" "$brief_hour" "$brief_minute"
    echo "  • Agent: ${agent}  (change: roll agent use <name>)"
    return 0
  fi

  # Linux: crontab
  local sd="${ROLL_HOME}/skills"
  if crontab -l 2>/dev/null | grep -q "${_LOOP_TAG}:${project_path}"; then
    warn "Loop already enabled for this project  当前项目 loop 已启用"; return 0
  fi

  mkdir -p "${_SHARED_ROOT}/loop" "${_SHARED_ROOT}/dream" "${_SHARED_ROOT}/brief"

  # FIX-052: per-project cron logs so concurrent projects don't interleave.
  local slug; slug=$(_project_slug "$project_path")
  local loop_cmd dream_cmd brief_cmd
  loop_cmd="cd \"${project_path}\" && $(_agent_skill_cmd "${sd}/roll-loop/SKILL.md") >> ${_SHARED_ROOT}/loop/cron-${slug}.log 2>&1"
  dream_cmd="cd \"${project_path}\" && $(_agent_skill_cmd "${sd}/roll-.dream/SKILL.md") >> ${_SHARED_ROOT}/dream/cron-${slug}.log 2>&1"
  brief_cmd="cd \"${project_path}\" && $(_agent_skill_cmd "${sd}/roll-brief/SKILL.md") >> ${_SHARED_ROOT}/brief/cron-${slug}.log 2>&1"

  (
    crontab -l 2>/dev/null
    printf "%d * * * * %s %s:%s\n" "$loop_minute" "$loop_cmd" "$_LOOP_TAG" "$project_path"
    printf "%d %d * * * %s %s:%s\n" "$dream_minute" "$dream_hour" "$dream_cmd" "$_LOOP_TAG" "$project_path"
    printf "%d %d * * * %s %s:%s\n" "$brief_minute" "$brief_hour" "$brief_cmd" "$_LOOP_TAG" "$project_path"
  ) | crontab -

  ok "Loop enabled  已启用"
  printf "  • roll-loop   every hour :%02d  active %02d:00–%02d:00  每小时 :%02d（窗口 %02d:00–%02d:00）\n" \
    "$loop_minute" "$active_start" "$active_end" "$loop_minute" "$active_start" "$active_end"
  printf "  • roll-.dream daily at %02d:%02d  每天 %02d:%02d\n" "$dream_hour" "$dream_minute" "$dream_hour" "$dream_minute"
  printf "  • roll-brief  daily at %02d:%02d  每天 %02d:%02d\n" "$brief_hour" "$brief_minute" "$brief_hour" "$brief_minute"
  echo "  • Agent: ${agent}  (change: roll agent use <name>)"
}

_loop_off() {
  local project_path; project_path=$(pwd -P)

  if [[ "$(uname)" == "Darwin" ]]; then
    local any_loaded=false
    for svc in loop dream brief; do
      local label; label=$(_launchd_label "$svc" "$project_path")
      if _launchd_is_loaded "$label"; then
        any_loaded=true
        launchctl unload -w "$(_launchd_plist_path "$svc" "$project_path")" 2>/dev/null || true
      fi
    done
    if ! $any_loaded; then
      warn "Loop not enabled for this project  当前项目 loop 未启用"; return 0
    fi
    local slug; slug=$(_project_slug "$project_path")
    local uid; uid=$(id -u)
    for svc in loop dream brief; do
      rm -f "${_SHARED_ROOT}/${svc}/run-${slug}.sh"
      # FIX-081: reverse the FIX-059 auto-bootstrap guard. `_install_launchd_plists`
      # writes `launchctl disable gui/<UID>/<label>` for every brand-new plist
      # to block macOS FSEvents from auto-bootstrapping it. That write lands in
      # the host's /private/var/db/com.apple.xpc.launchd/disabled.<UID>.plist —
      # it ignores any HOME sandbox. Without a symmetric `enable` on teardown,
      # every short-lived project leaves 3 permanent ghost labels in the host's
      # disable list, polluting `launchctl print-disabled` forever even after
      # the project dir, plists, and ~/.roll are gone.
      local label; label=$(_launchd_label "$svc" "$project_path")
      launchctl enable "gui/${uid}/${label}" 2>/dev/null || true
    done
    ok "Loop disabled  已停用"
    return 0
  fi

  # Linux: crontab
  if ! crontab -l 2>/dev/null | grep -q "${_LOOP_TAG}:${project_path}"; then
    warn "Loop not enabled for this project  当前项目 loop 未启用"; return 0
  fi
  crontab -l 2>/dev/null | grep -v "${_LOOP_TAG}:${project_path}" | crontab -
  ok "Loop disabled  已停用"
}

_loop_is_active() {
  # Three-level liveness probe used by FIX-037 heal and `roll loop now`.
  # Returns 0 if any signal says the cycle is alive, 1 if all signals are dead.
  # Heartbeat is primary (FIX-038); LOCK PID and tmux session are fallbacks.
  # ROLL_HEARTBEAT_TIMEOUT (default 1800s) matches the outer heredoc's threshold.
  local slug="${1:?slug required}"
  local timeout="${ROLL_HEARTBEAT_TIMEOUT:-1800}"
  local hb_file="${_SHARED_ROOT}/loop/.heartbeat-${slug}"
  if [[ -f "$hb_file" ]]; then
    local ts; ts=$(cat "$hb_file" 2>/dev/null || echo "")
    if [[ "$ts" =~ ^[0-9]+$ ]]; then
      local age=$(( $(date -u +%s) - ts ))
      [[ $age -lt $timeout ]] && return 0
    fi
  fi
  local lock="${_SHARED_ROOT}/loop/.LOCK-${slug}"
  if [[ -f "$lock" ]]; then
    local pid; pid=$(head -1 "$lock" 2>/dev/null || echo "")
    [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && return 0
  fi
  command -v tmux >/dev/null 2>&1 && tmux has-session -t "roll-loop-${slug}" 2>/dev/null && return 0
  return 1
}

_loop_now() {
  local project_path; project_path=$(pwd -P)
  local slug; slug=$(_project_slug "$project_path")
  # Manual `roll loop now` must not bypass FIX-037 heal: if state says running
  # but no live signal exists, this trigger is the canonical recovery point.
  if [[ -f "$_LOOP_STATE" ]] && grep -q "status: running" "$_LOOP_STATE" 2>/dev/null; then
    if _loop_is_active "$slug"; then
      warn "Loop already running  loop 正在运行中"; return 0
    fi
    info "Stale running state detected — healing before new cycle  检测到孤儿状态，正在修复..."
    printf "status: idle\n" > "$_LOOP_STATE"
    rm -f "${_SHARED_ROOT}/loop/.LOCK-${slug}" 2>/dev/null || true
  fi
  # Invoke the SAME runner script that launchd would invoke — same tmux,
  # same --verbose, same LOCK, same auto-attach popup. ROLL_LOOP_FORCE
  # bypasses only the active-window check (manual triggers aren't time-gated).
  local runner="${_SHARED_ROOT}/loop/run-${slug}.sh"
  if [[ ! -f "$runner" ]]; then
    err "Runner script not found: ${runner}"
    err "Run 'roll setup' or 'roll loop on' first to generate it."
    return 1
  fi
  info "Starting new loop cycle...  正在启动新的循环..."
  ROLL_LOOP_FORCE=1 bash "$runner"
  # Reset stale running state if the cycle exited without cleanup (e.g. API error, SIGKILL)
  if [[ -f "$_LOOP_STATE" ]] && grep -q "^status: running" "$_LOOP_STATE" 2>/dev/null; then
    printf "status: idle\n" > "$_LOOP_STATE"
  fi
}

_loop_test() {
  local project_path; project_path=$(pwd -P)
  local slug; slug=$(_project_slug "$project_path")
  local runner="${_SHARED_ROOT}/loop/run-${slug}.sh"
  if [[ ! -f "$runner" ]]; then
    err "Runner not found: ${runner}"
    err "Run 'roll loop on' first to generate it."
    return 1
  fi
  # FIX-052: per-project log so test cycle output doesn't mix with other projects'.
  local log="${_SHARED_ROOT}/loop/cron-${slug}.log"
  local test_runner="${_SHARED_ROOT}/loop/run-${slug}-test.sh"

  # FIX-054: terminal preference removed — runner always uses Terminal.app.
  local active_start active_end
  active_start=$(_config_read_int "loop_active_start" "10")
  active_end=$(_config_read_int "loop_active_end" "18")

  info "Generating test runner...  正在生成测试启动脚本..."
  _write_loop_runner_script "$test_runner" "$project_path" \
    'claude -p "Reply with a single word: hello"; sleep 10' \
    "$log" "$active_start" "$active_end"

  info "Starting smoke test (real claude, trivial prompt)...  正在运行 smoke 测试（真实 claude）..."
  info "Watch for: tmux session + terminal popup + stream-json events flowing"
  info "观察：tmux 会话 + 终端弹窗 + stream-json 事件流"

  local start_time; start_time=$(date +%s)
  ROLL_LOOP_FORCE=1 bash "$test_runner"
  local exit_code=$?
  local elapsed=$(( $(date +%s) - start_time ))

  if [[ $exit_code -eq 0 ]]; then
    ok "Smoke test passed (${elapsed}s)  smoke 测试通过 (${elapsed}秒)"
  else
    err "Smoke test failed (exit ${exit_code}, ${elapsed}s)  smoke 测试失败 (退出码 ${exit_code}, ${elapsed}秒)"
    return 1
  fi
}

_loop_status() {
  # FIX-060: backfill merged PRs before rendering — independent of cycle ticks,
  # so dashboard reflects merges that happened while loop was paused.
  _loop_backfill_merged >/dev/null 2>&1 || true
  # ROLL_UI=v2 (default) routes to the redesigned Python view.
  # Set ROLL_UI=v1 to fall back to the legacy bash implementation.
  if [[ "${ROLL_UI:-v2}" == "v2" ]]; then
    python3 "${ROLL_PKG_DIR}/lib/roll-loop-status.py" "$@"
    return
  fi
  _legacy_loop_status "$@"
}

_legacy_loop_status() {
  local project_path; project_path=$(pwd -P)
  local agent; agent=$(_project_agent)
  local _is_paused=false
  [[ -f "$_LOOP_STATE" ]] && grep -q "^status: paused" "$_LOOP_STATE" 2>/dev/null && _is_paused=true
  echo ""
  if [[ "$(uname)" == "Darwin" ]]; then
    echo -e "  Services   Agent: ${CYAN}${agent}${NC}"
    for svc in loop dream brief; do
      local state; state=$(_launchd_svc_state "$svc" "$project_path")
      if [[ "$svc" == "loop" ]] && $_is_paused; then
        local _paused_at; _paused_at=$(grep '^paused_at:' "$_LOOP_STATE" 2>/dev/null | awk '{print $2}' | tr -d '"')
        local _dur=""
        if [[ -n "$_paused_at" ]]; then
          local _pe _ne _sec
          _pe=$(date -d "$_paused_at" +%s 2>/dev/null || date -jf "%Y-%m-%dT%H:%M:%SZ" "$_paused_at" +%s 2>/dev/null || echo 0)
          _ne=$(date +%s); _sec=$(( _ne - _pe ))
          _dur="  ($(( _sec / 3600 ))h $(( (_sec % 3600) / 60 ))m ago)"
        fi
        echo -e "    ${YELLOW}loop     ⏸ paused${NC}${_dur}   run: roll loop resume"
      else
        case "$state" in
          enabled)       echo -e "    ${GREEN}${svc}     ● enabled${NC}" ;;
          installed-off) echo -e "    ${YELLOW}${svc}     ⚠ installed/off${NC}   run: roll loop on" ;;
          not-installed) echo -e "    ${RED}${svc}     ○ not installed${NC}   run: roll setup" ;;
        esac
      fi
    done
  else
    local loop_enabled=false
    crontab -l 2>/dev/null | grep -q "${_LOOP_TAG}:${project_path}" && loop_enabled=true
    if $_is_paused; then
      echo -e "  Scheduler  ${YELLOW}⏸ paused${NC}   run: roll loop resume"
    elif $loop_enabled; then
      echo -e "  Scheduler  ${GREEN}● enabled${NC}   Agent: ${CYAN}${agent}${NC}"
    else
      echo -e "  Scheduler  ${YELLOW}○ disabled${NC}   run: roll loop on"
    fi
  fi
  echo ""
  if [[ -f "$_LOOP_MUTE_FILE" ]]; then
    echo -e "  Auto-attach  ${YELLOW}muted${NC}   run: roll loop unmute"
  else
    echo -e "  Auto-attach  ${GREEN}live${NC}    run: roll loop mute"
  fi
  [[ -f "$_LOOP_ALERT" ]] && { echo ""; echo -e "  ${RED}⚠ ALERT${NC}  (${CYAN}roll alert${NC} to manage)"; sed 's/^/    /' "$_LOOP_ALERT"; }
  [[ -f "$_LOOP_STATE" ]] && { echo ""; echo "  State:"; sed 's/^/    /' "$_LOOP_STATE"; }
  echo ""
}

_loop_pause() {
  local project_path; project_path=$(pwd -P)
  local paused_at; paused_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)

  if [[ "$(uname)" == "Darwin" ]]; then
    local label; label=$(_launchd_label "loop" "$project_path")
    if ! _launchd_is_loaded "$label"; then
      warn "Loop not enabled — nothing to pause  loop 未启用，无需暂停"; return 0
    fi
    launchctl unload -w "$(_launchd_plist_path "loop" "$project_path")" 2>/dev/null || true
  else
    local slug; slug=$(_project_slug "$project_path")
    mkdir -p "${_SHARED_ROOT}/loop"
    touch "${_SHARED_ROOT}/loop/PAUSE-${slug}"
  fi

  mkdir -p "$(dirname "$_LOOP_STATE")"
  printf 'status: paused\npaused_at: "%s"\npaused_reason: manual\n' "$paused_at" > "$_LOOP_STATE"
  ok "Loop paused  已暂停   run: roll loop resume"
}

_loop_resume() {
  local project_path; project_path=$(pwd -P)

  # Scheduler resume: loop was manually paused via `roll loop pause`
  if [[ -f "$_LOOP_STATE" ]] && grep -q "^status: paused" "$_LOOP_STATE" 2>/dev/null; then
    if [[ "$(uname)" == "Darwin" ]]; then
      local label; label=$(_launchd_label "loop" "$project_path")
      local plist; plist=$(_launchd_plist_path "loop" "$project_path")
      if [[ -f "$plist" ]]; then
        launchctl load -w "$plist" 2>/dev/null || true
      fi
    else
      local slug; slug=$(_project_slug "$project_path")
      rm -f "${_SHARED_ROOT}/loop/PAUSE-${slug}"
    fi
    printf "status: idle\n" > "$_LOOP_STATE"
    ok "Loop resumed  已恢复"
    return 0
  fi

  # Interrupt resume: loop was running a Story and crashed
  if [[ ! -f "$_LOOP_STATE" ]]; then
    warn "No loop state found — nothing to resume  未找到 loop 状态，无需恢复"; return 0
  fi
  if grep -q "status: running" "$_LOOP_STATE" 2>/dev/null; then
    warn "Loop already running  loop 正在运行中"; return 0
  fi
  info "Resuming loop from last state...  正在从上次状态恢复..."
  _agent_run_skill "roll-loop"
}

_loop_reset() {
  if [[ -f "$_LOOP_STATE" ]]; then
    rm -f "$_LOOP_STATE"
    ok "Loop state cleared — will start fresh on next run  loop 状态已清除，下次运行将重新开始"
  else
    info "No loop state to clear  无 loop 状态可清除"
  fi
  rm -rf "$(_loop_heal_dir)"
}

# Suppress the auto-attach popup. When the marker file exists, runner scripts
# skip the osascript Terminal-popup step on next fire. Loop output still goes
# to tmux + log; users can run `roll loop attach` manually.
_loop_mute() {
  mkdir -p "$(dirname "$_LOOP_MUTE_FILE")"
  : > "$_LOOP_MUTE_FILE"
  ok "🔇 muted — auto-attach disabled  已静音，自动弹窗已关闭"
}

# Re-enable the auto-attach popup.
_loop_unmute() {
  rm -f "$_LOOP_MUTE_FILE"
  ok "🔔 unmuted — auto-attach live  已恢复，自动弹窗已开启"
}

# Attach to the tmux session a running loop iteration writes to. Returns 1 when
# tmux is missing or no session exists for the current project.
_loop_attach() {
  local project_path; project_path=$(pwd -P)
  local slug; slug=$(_project_slug "$project_path")
  local session="roll-loop-${slug}"

  if ! command -v tmux >/dev/null 2>&1; then
    warn "tmux not installed — install with 'brew install tmux'  请先安装 tmux"
    return 1
  fi

  if ! tmux has-session -t "$session" 2>/dev/null; then
    info "No running loop session for this project  当前项目无运行中的 loop"
    info "Wait for next scheduled fire, or run: roll loop now"
    return 1
  fi

  exec tmux attach -t "$session"
}

# Pretty-print a duration in seconds as "Xs" / "Ym" / "Yh Zm".
_loop_runs_dur() {
  local s="${1:-0}"
  if [[ "$s" -lt 60 ]]; then printf "%ds" "$s"
  elif [[ "$s" -lt 3600 ]]; then printf "%dm" "$((s / 60))"
  else printf "%dh %dm" "$((s / 3600))" "$(((s % 3600) / 60))"
  fi
}

# Format a single JSONL run record for display.
# Reads _LOOP_RUNS_BACKLOG global for ID→description lookup (set by _loop_runs).
_loop_runs_format_line() {
  local line="$1" show_project="$2" is_darwin="$3"
  command -v jq >/dev/null 2>&1 || { echo "  (jq required)"; return 0; }

  local ts status project tcr duration alerts run_id reason built_count skipped_count
  ts=$(jq -r '.ts // ""' <<<"$line")
  status=$(jq -r '.status // ""' <<<"$line")
  project=$(jq -r '.project // ""' <<<"$line")
  tcr=$(jq -r '.tcr_count // 0' <<<"$line")
  duration=$(jq -r '.duration_sec // 0' <<<"$line")
  alerts=$(jq -r '.alerts // 0' <<<"$line")
  run_id=$(jq -r '.run_id // ""' <<<"$line")
  reason=$(jq -r '.reason // ""' <<<"$line")
  built_count=$(jq -r '(.built // []) | length' <<<"$line")
  skipped_count=$(jq -r '(.skipped // []) | length' <<<"$line")

  local hhmm epoch=""
  if [[ "$is_darwin" == "1" ]]; then
    epoch=$(date -j -u -f "%Y-%m-%dT%H:%M:%SZ" "$ts" "+%s" 2>/dev/null) || epoch=""
    [[ -n "$epoch" ]] && hhmm=$(date -j -f "%s" "$epoch" "+%H:%M" 2>/dev/null) || hhmm=""
  else
    hhmm=$(date -d "$ts" "+%H:%M" 2>/dev/null) || hhmm=""
  fi
  [[ -z "$hhmm" ]] && hhmm=$(printf "%s" "$ts" | sed -E 's/.*T([0-9]{2}):([0-9]{2}).*/\1:\2/')
  local prefix=""
  if [[ "$show_project" == "true" ]]; then
    prefix="[$(basename "$project")] "
  fi

  case "$status" in
    built)
      local skipped_note=""
      [[ "$skipped_count" -gt 0 ]] && skipped_note=", ${skipped_count} skipped"
      local items_word; [[ "$built_count" -eq 1 ]] && items_word="item" || items_word="items"
      printf "  %s  %s✅ built %d %s (%d tcr%s, %s)\n" \
        "$hhmm" "$prefix" "$built_count" "$items_word" "$tcr" "$skipped_note" "$(_loop_runs_dur "$duration")"
      local id desc
      while IFS= read -r id; do
        [[ -z "$id" ]] && continue
        desc=""
        if [[ -n "$_LOOP_RUNS_BACKLOG" ]]; then
          desc=$(printf "%s\n" "$_LOOP_RUNS_BACKLOG" | awk -F'|' -v id="$id" '
            NF >= 3 {
              cell = $2; gsub(/^[[:space:]]+|[[:space:]]+$/, "", cell)
              if (cell == id || cell ~ "^\\[" id "\\]") {
                gsub(/^[[:space:]]+|[[:space:]]+$/, "", $3); print $3; exit
              }
            }')
        fi
        if [[ -n "$desc" ]]; then
          [[ ${#desc} -gt 72 ]] && desc="${desc:0:69}..."
          printf "    • %-14s %s\n" "$id" "$desc"
        else
          printf "    • %s\n" "$id"
        fi
      done < <(jq -r '(.built // []) | .[]' <<<"$line")
      ;;
    idle)
      printf "  %s  %s○ idle — no Todo items\n" "$hhmm" "$prefix"
      ;;
    failed)
      local msg="${reason:-unknown}"
      printf "  %s  %s✗ FAILED — %s\n" "$hhmm" "$prefix" "$msg"
      ;;
    *)
      printf "  %s  %s? %s\n" "$hhmm" "$prefix" "$status"
      ;;
  esac
}

# `roll loop runs [N] [--all]` — show recent loop iteration summaries.
_loop_runs() {
  local n=10 all_flag=false
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --all) all_flag=true; shift ;;
      [0-9]*) n="$1"; shift ;;
      *) shift ;;
    esac
  done

  # FIX-060: refresh merged status before reading, so paused-window merges show up.
  _loop_backfill_merged >/dev/null 2>&1 || true

  if [[ ! -f "$_LOOP_RUNS" ]] || [[ ! -s "$_LOOP_RUNS" ]]; then
    echo "No loop runs yet  尚无 loop 运行记录"
    return 0
  fi

  if ! command -v jq >/dev/null 2>&1; then
    err "jq required for 'roll loop runs'  需要 jq"
    return 1
  fi

  local project_path; project_path=$(pwd -P)
  local project_slug; project_slug=$(_project_slug "$project_path")
  local filtered
  if $all_flag; then
    filtered=$(cat "$_LOOP_RUNS")
  else
    filtered=$(jq -c --arg p "$project_slug" 'select(.project == $p)' "$_LOOP_RUNS")
  fi

  if [[ -z "$filtered" ]]; then
    echo "No loop runs for current project  当前项目尚无 loop 运行记录"
    return 0
  fi

  local reversed; reversed=$(printf "%s\n" "$filtered" | awk '{a[NR]=$0} END{for(i=NR; i>=1; i--) print a[i]}')
  local recent; recent=$(printf "%s\n" "$reversed" | head -n "$n")

  local _is_darwin=""
  [[ "$(uname)" == "Darwin" ]] && _is_darwin="1"

  _LOOP_RUNS_BACKLOG=""
  [[ -f "$project_path/.roll/backlog.md" ]] && _LOOP_RUNS_BACKLOG=$(cat "$project_path/.roll/backlog.md")

  while IFS= read -r line; do
    [[ -z "$line" ]] && continue
    _loop_runs_format_line "$line" "$all_flag" "$_is_darwin"
  done <<<"$recent"
  unset _LOOP_RUNS_BACKLOG
}

# Send a macOS system notification. No-op when muted, non-macOS, or osascript unavailable.
_notify() {
  local title="${1:-roll}"
  local body="${2:-}"
  [ "$(uname)" = "Darwin" ] || return 0
  [ -f "$_LOOP_MUTE_FILE" ] && return 0
  command -v osascript >/dev/null 2>&1 || return 0
  osascript -e "display notification \"${body}\" with title \"${title}\"" >/dev/null 2>&1 || true
}

# Count `tcr:` prefixed commits in the current git repo since started_at timestamp.
_loop_tcr_count() {
  local started_at="$1"
  git log --oneline --since="${started_at}" 2>/dev/null \
    | awk '/^[a-f0-9]+ tcr:/{n++} END{print n+0}'
}

# Parse origin remote URL → "owner/repo" for GitHub repos.
# Returns non-zero if no origin, or origin is not github.com.
# Decoupled from `gh` auto-detection so SSH config host rewrites don't break it.
_gh_repo_slug() {
  local url
  url=$(git config --get remote.origin.url 2>/dev/null) || return 1
  case "$url" in
    git@github.com:*)          url="${url#git@github.com:}" ;;
    ssh://git@github.com/*)    url="${url#ssh://git@github.com/}" ;;
    https://github.com/*)      url="${url#https://github.com/}" ;;
    http://github.com/*)       url="${url#http://github.com/}" ;;
    *)                         return 1 ;;
  esac
  url="${url%.git}"
  [[ -z "$url" ]] && return 1
  printf "%s\n" "$url"
}

# Returns 0 if gh CLI is installed and executable, 1 otherwise.
_gh_available() { command -v gh >/dev/null 2>&1; }

# Resolve the GitHub owner/repo slug and set <outvar> to it.
# Returns 0 on success. Returns 1 (no output) if gh is unavailable or the
# remote is not a GitHub URL — caller decides how to handle failure.
_gh_resolve() {
  local _outvar="$1"
  _gh_available || return 1
  local _slug
  _slug=$(_gh_repo_slug 2>/dev/null) || return 1
  printf -v "$_outvar" '%s' "$_slug"
}

# Poll gh run list until current commit's CI completes.
# Returns 0 on success (or when gh binary missing — graceful skip).
# Returns 1 on CI failure, timeout, or any gh call failure.
_ci_wait() {
  local timeout="${1:-300}"
  local interval=15
  local elapsed=0

  _gh_available || { warn "gh not installed — skipping CI gate  gh 未安装，跳过 CI 检查"; return 0; }

  local commit; commit=$(git rev-parse HEAD 2>/dev/null) || { err "Not a git repo  非 git 仓库"; return 1; }
  local short; short=$(git rev-parse --short HEAD 2>/dev/null)

  # Resolve owner/repo from git remote so we don't depend on gh's auto-detection,
  # which breaks when ~/.ssh/config rewrites `Host github.com` → IP address.
  local repo_slug; repo_slug=$(_gh_repo_slug) || {
    err "Cannot determine GitHub repo from origin remote  无法从 origin 推导 GitHub 仓库"
    return 1
  }

  ok "Waiting for CI on ${short} (${repo_slug})  等待 CI: ${short}"

  while (( elapsed < timeout )); do
    local runs
    runs=$(gh -R "$repo_slug" run list --commit "$commit" --json status,conclusion 2>&1) || {
      err "gh run list failed for ${repo_slug}@${short}: ${runs}  gh 调用失败"
      return 1
    }

    if [[ -z "$runs" || "$runs" == "[]" ]]; then
      # FIX-046: CI only fires on pull_request events — without a PR, runs will never appear.
      # Check if an open PR exists; if not, skip the gate gracefully.
      local _branch; _branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
      if [[ -n "$_branch" ]]; then
        local _pr_json; _pr_json=$(gh -R "$repo_slug" pr list --head "$_branch" --state open --json number 2>/dev/null || echo "1")
        local _pr_count; _pr_count=$(echo "$_pr_json" | jq 'length' 2>/dev/null || echo "1")
        if [[ "$_pr_count" == "0" ]]; then
          warn "No open PR for ${_branch} — CI not triggered; skipping CI gate  当前分支无 PR，CI 未触发，跳过"
          return 0
        fi
      fi
      (( elapsed == 0 )) && echo "  No CI runs found yet, waiting...  尚无 CI 记录，等待触发..."
      sleep "$interval"
      elapsed=$(( elapsed + interval ))
      continue
    fi

    local pending
    pending=$(echo "$runs" | jq -r '[.[] | select(.status != "completed")] | length' 2>/dev/null || echo "0")

    if [[ "$pending" -gt 0 ]]; then
      printf "  ⏳ CI running (%ds)...  CI 运行中（%ds）...\n" "$elapsed" "$elapsed"
      sleep "$interval"
      elapsed=$(( elapsed + interval ))
      continue
    fi

    local failed
    failed=$(echo "$runs" | jq -r '[.[] | select(.conclusion != "success" and .conclusion != "skipped" and .conclusion != null)] | length' 2>/dev/null || echo "0")

    if [[ "$failed" -gt 0 ]]; then
      err "CI failed for ${short}  CI 失败: ${short}"
      return 1
    fi

    ok "CI passed  CI 通过"
    return 0
  done

  warn "CI timed out after ${timeout}s  CI 等待超时（${timeout}s）"
  return 1
}

# Pre-run CI health check — call before picking up new stories.
# Refuses to build on a red base (HEAD CI failed). Lenient on unknown states
# (gh missing, repo unparseable, no runs yet) — the post-build _loop_enforce_ci
# is the strict gate.
# Returns 0: ok to proceed (green / pending / unknown / no gh).
# Returns 1: HEAD CI is definitively red → ALERT written, do not build.
_loop_precheck_ci() {
  local slug; _gh_resolve slug || return 0

  local commit; commit=$(git rev-parse HEAD 2>/dev/null) || return 0

  local runs
  runs=$(gh -R "$slug" run list --commit "$commit" --json conclusion 2>/dev/null) || return 0
  [[ -z "$runs" || "$runs" == "[]" ]] && return 0

  local failed
  failed=$(echo "$runs" | jq -r '[.[] | select(.conclusion != null and .conclusion != "success" and .conclusion != "skipped")] | length' 2>/dev/null || echo "0")

  if [[ "$failed" -gt 0 ]]; then
    local short; short=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
    err "Pre-run CI check: HEAD CI is red — refuse to build on broken base (${short})  HEAD CI 红，拒绝在破损的基础上构建"
    mkdir -p "$(dirname "$_LOOP_ALERT")"
    cat > "$_LOOP_ALERT" << EOF
# ALERT — Pre-run CI check failed (red base)

**Time**: $(date '+%Y-%m-%d %H:%M')
**Commit**: ${short}
**Reason**: HEAD CI is red — loop refused to build on a broken base  HEAD CI 红，loop 拒绝在破损的基础上构建

**Action required**:
- Investigate and fix CI: \`gh -R ${slug} run list --commit ${commit}\`
- After fixing and pushing green commit: \`roll loop now\`
EOF
    _loop_diagnose_open_prs "$slug" >> "$_LOOP_ALERT"
    _notify "roll ⚠ CI red" "loop refused to build on broken base (${short})"
    return 1
  fi
  return 0
}

# _loop_diagnose_open_prs <slug>
#   Appended to ALERT when CI is red on HEAD.
#   For each open PR targeting main: lists CI failing tests + changed files,
#   flags whether failures look unrelated to the PR's own changes.
_loop_diagnose_open_prs() {
  local slug="$1"
  local prs
  prs=$(gh -R "$slug" pr list --base main --state open --json number,title,headRefName \
    --jq '.[] | [.number|tostring, .headRefName, .title] | @tsv' 2>/dev/null) || return 0
  [[ -z "$prs" ]] && return 0

  printf '\n## Open PRs (potential fixes)\n'
  while IFS=$'\t' read -r pr_num branch pr_title; do
    printf '\nPR #%s: %s\n' "$pr_num" "$pr_title"

    # Files changed in this PR
    local changed_files
    changed_files=$(gh -R "$slug" pr diff "$pr_num" --name-only 2>/dev/null | head -10) || changed_files="(unable to fetch)"
    printf '  Changed: %s\n' "$(echo "$changed_files" | tr '\n' ' ')"

    # Latest CI run on the PR branch
    local run_id conclusion
    run_id=$(gh -R "$slug" run list --branch "$branch" --json databaseId,conclusion \
      --jq '[.[] | select(.conclusion != null)] | first | .databaseId' 2>/dev/null) || run_id=""
    conclusion=$(gh -R "$slug" run list --branch "$branch" --json databaseId,conclusion \
      --jq '[.[] | select(.conclusion != null)] | first | .conclusion' 2>/dev/null) || conclusion="unknown"

    if [[ "$conclusion" == "success" ]]; then
      printf '  CI: green — blocked only by branch protection (safe to merge)\n'
      printf '  Suggest: gh pr merge %s --admin\n' "$pr_num"
    elif [[ -n "$run_id" ]]; then
      local failing_tests
      failing_tests=$(gh -R "$slug" run view "$run_id" --log-failed 2>/dev/null \
        | grep -oP '(?<=not ok \d{1,4} ).*' | head -8) || failing_tests="(unable to fetch)"

      printf '  CI: %s\n' "$conclusion"
      printf '  Failing tests:\n'
      while IFS= read -r t; do
        [[ -n "$t" ]] && printf '    - %s\n' "$t"
      done <<< "$failing_tests"

      # Heuristic: if no failing test mentions a changed file, flag as likely unrelated
      local related=0
      while IFS= read -r f; do
        local base; base=$(basename "$f")
        echo "$failing_tests" | grep -qi "$base" && { related=1; break; }
      done <<< "$changed_files"
      if [[ "$related" -eq 0 ]]; then
        printf '  Note: failing tests appear UNRELATED to changed files — consider manual merge\n'
        printf '  Suggest: gh pr merge %s --admin  (verify tests manually first)\n' "$pr_num"
      else
        printf '  Note: failing tests may relate to PR changes — investigate before merging\n'
      fi
    fi
  done <<< "$prs"
}

# CI gate before marking a story Done.
# On CI failure: writes ALERT, returns 1 (caller keeps story 🔨 In Progress).
# When gh unavailable: returns 0 (graceful skip).
_loop_enforce_ci() {
  local story_id="$1"
  local _ci_result
  if _ci_wait 300; then
    _loop_event ci "$story_id" "" "ok" 2>/dev/null || true
    return 0
  fi
  _loop_event ci "$story_id" "" "red" 2>/dev/null || true

  mkdir -p "$(dirname "$_LOOP_ALERT")"
  cat > "$_LOOP_ALERT" << EOF
# ALERT — CI gate failed

**Time**: $(date '+%Y-%m-%d %H:%M')
**Story**: ${story_id}
**Commit**: $(git rev-parse --short HEAD 2>/dev/null || echo unknown)
**Reason**: CI did not pass — story kept as 🔨 In Progress  CI 未通过，故事保持进行中

**Action required** (choose one):
- Fix CI and re-run: \`roll loop now\`
- Take over manually: \`\$roll-build ${story_id}\`
- Reset and retry: \`roll loop reset\` then \`roll loop now\`
EOF
  _notify "roll ⚠ CI Failed" "${story_id}: CI did not pass"
  return 1
}

_loop_heal_dir() {
  printf '%s\n' "${ROLL_LOOP_DIR:-${HOME}/.shared/roll/loop}/heal"
}

# REFACTOR-030: removed `_loop_self_heal_ci` and `_loop_clear_heal_state`.
# REFACTOR-023 merged the CI self-heal counter into the main state.yaml flow,
# but the two helpers themselves were left behind as dead code. Their job
# now lives in the state.yaml read/write paths called from the loop runner.

# Verify TCR rhythm after a story completes. Returns 0 if ok, 1 if no TCR commits.
# On failure: reverts story in .roll/backlog.md to 📋 Todo and writes ALERT.
_loop_enforce_tcr() {
  local story_id="$1"
  local started_at="${2:-}"

  [[ -z "$started_at" ]] && return 0

  local count; count=$(_loop_tcr_count "$started_at")

  if [[ "$count" -eq 0 ]]; then
    # Revert story status
    if [[ -f ".roll/backlog.md" ]]; then
      local tmp; tmp=$(mktemp)
      sed "/\[${story_id}\]/s/ | ✅ Done |/ | 📋 Todo |/" .roll/backlog.md > "$tmp" \
        && mv "$tmp" .roll/backlog.md
    fi

    # Write ALERT
    mkdir -p "$(dirname "$_LOOP_ALERT")"
    cat > "$_LOOP_ALERT" << EOF
# ALERT — TCR check failed

**Time**: $(date '+%Y-%m-%d %H:%M')
**Story**: ${story_id}
**Reason**: zero tcr: commits since story start (${started_at})

**Action required** (choose one):
- Add TCR commits and re-run: \`roll loop now\`
- Take over manually: \`\$roll-build ${story_id}\`
- Reset and retry: \`roll loop reset\` then \`roll loop now\`
EOF
    _notify "roll ⚠ TCR Failed" "${story_id}: no tcr: commits found"
    return 1
  fi

  return 0
}

# FIX-032: dependency gate — parses BACKLOG inline tags so the loop SKILL
# can enforce them at Step 2 (story pickup). Pure functions, no side effects.
#
# BACKLOG row format (relevant fragments):
#   | [US-AUTO-033](...) | desc `depends-on:US-AUTO-037` `manual-only:true` | 📋 Todo |
#   | FIX-100 | desc `depends-on:US-A,US-B` | 📋 Todo |
#
# Row matching is anchored on `^| [?<id>\b` so a story-id appearing in some
# other row's depends-on list is not mistaken for the row that defines it.

# _loop_check_depends_on <story-id> [backlog-path]
#   Exit 0: all listed depends-on are ✅ Done, or no depends-on tag present.
#   Exit 1: any dep not ✅ Done, story-id not found, or backlog missing.
#   Stdout (on exit 1 due to unsatisfied deps): space-separated unsatisfied IDs.
_loop_check_depends_on() {
  local id="$1"
  local backlog="${2:-.roll/backlog.md}"
  [ -n "$id" ] || return 1
  [ -f "$backlog" ] || return 1

  local row
  row=$(grep -E "^\| \[?${id}[]| ]" "$backlog" | head -1)
  [ -n "$row" ] || return 1

  local deps
  deps=$(echo "$row" | grep -oE 'depends-on:[A-Z][A-Z0-9,-]+' | head -1 | sed 's/depends-on://')
  [ -n "$deps" ] || return 0

  local unsatisfied=""
  local dep
  local IFS_save="$IFS"
  IFS=','
  for dep in $deps; do
    local dep_row
    dep_row=$(grep -E "^\| \[?${dep}[]| ]" "$backlog" | head -1)
    if [ -z "$dep_row" ] || ! echo "$dep_row" | grep -qF '✅ Done'; then
      unsatisfied="${unsatisfied:+$unsatisfied }${dep}"
    fi
  done
  IFS="$IFS_save"

  if [ -n "$unsatisfied" ]; then
    echo "$unsatisfied"
    return 1
  fi
  return 0
}

# _loop_is_manual_only <story-id> [backlog-path]
#   Exit 0: story's own row carries `manual-only:true`.
#   Exit 1: tag absent, story-id not found, or backlog missing.
_loop_is_manual_only() {
  local id="$1"
  local backlog="${2:-.roll/backlog.md}"
  [ -n "$id" ] || return 1
  [ -f "$backlog" ] || return 1

  local row
  row=$(grep -E "^\| \[?${id}[]| ]" "$backlog" | head -1)
  [ -n "$row" ] || return 1

  echo "$row" | grep -qE 'manual-only:true'
}

# US-AUTO-034: PR-first inbox — loop processes open PRs before scanning BACKLOG.
#
# Three building blocks, kept as pure / mockable functions:
#   _loop_pr_classify        pure routing decision (no side effects)
#   _loop_pr_rebase_circuit  24h sliding-window circuit breaker on rebase retries
#   _loop_pr_inbox           orchestrator that walks `gh pr list` and routes
#                            each open PR to skip / review / rebase
#
# Design notes:
#   - gh missing or any gh failure → return 0 (lenient, like FIX-026's pre-check)
#   - self-authored loop/* PRs are skipped to avoid same-source AI review
#   - latest human review of CHANGES_REQUESTED or APPROVED blocks AI review
#     (Human-review-activity guard from US-AUTO-034 AC)
#   - rebase attempts ≥3 within 24h trip the circuit breaker (writes ALERT)

# _loop_pr_classify <head_ref> <human_review_state> <ci_state> <mergeable_state>
#   Prints one of:
#     loop_self
#     blocked_human_request_changes
#     blocked_human_approved
#     stale
#     eligible
#   Exit 0 always — callers parse the printed token.
_loop_pr_classify() {
  local head_ref="${1:-}"
  local human_review="${2:-}"
  local ci_state="${3:-}"
  local mergeable="${4:-}"

  case "$head_ref" in
    loop/*) echo "loop_self"; return 0 ;;
  esac

  case "$human_review" in
    CHANGES_REQUESTED) echo "blocked_human_request_changes"; return 0 ;;
    APPROVED)          echo "blocked_human_approved";         return 0 ;;
  esac

  if [ "$ci_state" = "failure" ] || [ "$mergeable" = "CONFLICTING" ] || [ "$mergeable" = "BEHIND" ]; then
    echo "stale"
    return 0
  fi

  echo "eligible"
}

# _loop_pr_rebase_circuit <pr_number>
#   Side effect: appends current timestamp to $_LOOP_STATE under
#   pr_state.<PR>.attempts_at, pruning entries older than 24h.
#   Exit 0: attempt allowed and recorded.
#   Exit 1: ≥3 attempts within 24h → blocked; ALERT written.
_loop_pr_rebase_circuit() {
  local pr="$1"
  [ -n "$pr" ] || return 1

  local state="$_LOOP_STATE"
  local now; now=$(date -u +%s)
  local cutoff=$((now - 86400))

  # Extract existing timestamps for this PR (empty if absent).
  local existing=""
  if [ -f "$state" ]; then
    existing=$(awk -v pr="\"$pr\":" '
      $0 ~ "pr_state:" {in_pr=1; next}
      in_pr && $0 ~ pr {in_target=1; next}
      in_target && $0 ~ /attempts_at:/ {
        sub(/^[^"]*"/, ""); sub(/".*$/, ""); print; exit
      }
      in_target && /^[^[:space:]]/ {in_target=0}
    ' "$state" 2>/dev/null)
  fi

  # Prune stale timestamps (>24h ago).
  local fresh=""
  local ts
  for ts in $existing; do
    case "$ts" in
      ''|*[!0-9]*) continue ;;
    esac
    if [ "$ts" -ge "$cutoff" ]; then
      fresh="${fresh:+$fresh }$ts"
    fi
  done

  # Count attempts within window; ≥3 means this would be the 4th retry blocked.
  local count=0
  for ts in $fresh; do count=$((count + 1)); done

  if [ "$count" -ge 3 ]; then
    mkdir -p "$(dirname "${_LOOP_ALERT:-/dev/null}")" 2>/dev/null || true
    cat > "${_LOOP_ALERT}" <<EOF
# ALERT — PR rebase circuit breaker tripped

**Time**: $(date '+%Y-%m-%d %H:%M')
**PR**: #${pr}
**Reason**: PR #${pr} rebased ${count}× within 24h without CI resolution — possible workflow file error  PR rebase 多次未解决，可能是 workflow 文件错误

**Action required**:
- Check PR CI logs and workflow files for breakage
- Resolve manually, then: \`roll loop now\`
EOF
    return 1
  fi

  # Record this attempt and persist.
  fresh="${fresh:+$fresh }$now"
  _loop_pr_state_write "$pr" "$fresh" "$state"
  return 0
}

# Internal: rewrite $state with pr_state.<pr>.attempts_at = "<fresh-ts-list>".
# Minimal YAML writer — we own the schema and only need this one field family.
_loop_pr_state_write() {
  local pr="$1"
  local attempts="$2"
  local state="$3"

  mkdir -p "$(dirname "$state")" 2>/dev/null || true
  [ -f "$state" ] || : > "$state"

  local tmp; tmp=$(mktemp)
  awk -v pr="\"$pr\":" -v attempts="$attempts" '
    BEGIN { in_pr=0; in_target=0; written=0 }
    /^pr_state:/ { in_pr=1; print; next }
    in_pr && $0 ~ pr {
      in_target=1
      print "  " pr
      print "    attempts_at: \"" attempts "\""
      written=1
      next
    }
    in_target && /attempts_at:/ { next }   # skip old value, already written
    in_target && /^[^[:space:]]/ { in_target=0 }
    { print }
    END {
      if (!in_pr) {
        print "pr_state:"
        print "  " pr
        print "    attempts_at: \"" attempts "\""
      } else if (!written) {
        print "  " pr
        print "    attempts_at: \"" attempts "\""
      }
    }
  ' "$state" > "$tmp" && mv "$tmp" "$state"
}

# _loop_pr_review_external <pr_number>
#   Calls cmd_review_pr (US-PR-001) to run AI review on an eligible external PR.
#   Lenient: errors are logged but do not fail the loop.
_loop_pr_review_external() {
  local pr="$1"
  [ -n "$pr" ] || return 0
  cmd_review_pr "$pr" 2>&1 || {
    warn "review-pr failed for PR #${pr} (non-fatal)"
    return 0
  }
}

# _loop_pr_rebase_stale <pr_number> <head_ref>
#   Attempts to rebase a stale PR onto origin/main and push.
#   Fork PRs are skipped (no write access). Conflicts write ALERT.
_loop_pr_rebase_stale() {
  local pr="$1" head_ref="$2"
  [ -n "$pr" ] && [ -n "$head_ref" ] || return 0

  local slug; _gh_resolve slug || return 0

  local pr_json
  pr_json=$(gh -R "$slug" pr view "$pr" --json headRepository,headRepositoryOwner,isCrossRepository 2>/dev/null) || return 0
  local is_fork
  is_fork=$(echo "$pr_json" | jq -r '.isCrossRepository // false' 2>/dev/null)
  if [ "$is_fork" = "true" ]; then
    local alert="$_LOOP_ALERT"
    mkdir -p "$(dirname "$alert")" 2>/dev/null || true
    printf '[%s] PR #%s: fork PR — cannot rebase (no write access)\n' \
      "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$pr" >> "$alert"
    return 0
  fi

  git fetch origin "$head_ref" 2>/dev/null || return 0
  if git checkout "$head_ref" 2>/dev/null \
     && git rebase origin/main 2>/dev/null \
     && git push origin "$head_ref" 2>/dev/null; then
    info "PR #${pr}: rebased ${head_ref} onto origin/main"
  else
    git rebase --abort 2>/dev/null || true
    git checkout - 2>/dev/null || true
    local alert="$_LOOP_ALERT"
    mkdir -p "$(dirname "$alert")" 2>/dev/null || true
    printf '[%s] PR #%s: rebase conflict on %s — please rebase manually\n' \
      "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$pr" "$head_ref" >> "$alert"
  fi
  return 0
}

# _loop_pr_inbox
#   Walks open PRs and routes each by classification.
#   Lenient on gh unavailability — returns 0 so the loop continues to BACKLOG.
_loop_pr_inbox() {
  local slug; _gh_resolve slug || return 0
  local prs_json
  prs_json=$(gh -R "$slug" pr list --state open \
    --json number,headRefName,author,title \
    2>/dev/null) || return 0
  [ -n "$prs_json" ] || return 0
  [ "$prs_json" = "[]" ] && return 0

  local count; count=$(echo "$prs_json" | jq 'length' 2>/dev/null || echo 0)
  [ "${count:-0}" -gt 0 ] || return 0

  local i=0
  while [ "$i" -lt "$count" ]; do
    local num head_ref
    num=$(echo "$prs_json" | jq -r ".[$i].number")
    head_ref=$(echo "$prs_json" | jq -r ".[$i].headRefName")

    # Fetch CI + review state for this PR.
    local view_json
    view_json=$(gh -R "$slug" pr view "$num" \
      --json reviews,mergeStateStatus,statusCheckRollup \
      2>/dev/null) || { i=$((i + 1)); continue; }

    local human_review ci_state mergeable bot_review
    human_review=$(echo "$view_json" | jq -r '
      [.reviews[]? | select(.authorAssociation != "BOT" and .authorAssociation != "APP")]
      | last // {} | .state // ""' 2>/dev/null)
    bot_review=$(echo "$view_json" | jq -r '
      [.reviews[]? | select(.authorAssociation == "BOT" or .authorAssociation == "APP")]
      | last // {} | .state // ""' 2>/dev/null)
    mergeable=$(echo "$view_json" | jq -r '.mergeStateStatus // ""' 2>/dev/null)
    ci_state=$(echo "$view_json" | jq -r '
      if (.statusCheckRollup | length) == 0 then ""
      elif any(.statusCheckRollup[]?; .conclusion == "FAILURE") then "failure"
      elif all(.statusCheckRollup[]?; .conclusion == "SUCCESS" or .conclusion == "SKIPPED") then "success"
      else "pending" end' 2>/dev/null)

    # Bot review gate: if a GHA workflow already handled this PR, defer to it.
    if [ "$bot_review" = "APPROVED" ]; then
      # All gates cleared (bot-approved + CI green + no conflicts) → merge directly.
      # Relying on repo-level auto-merge being configured is not reliable; loop
      # owns the decision here since it already ran the review.
      if [ "$ci_state" = "success" ] && [ "$mergeable" = "MERGEABLE" ]; then
        gh -R "$slug" pr merge "$num" --squash --delete-branch >/dev/null 2>&1 \
          && info "PR #${num}: bot-approved + CI green — merged" \
          || warn "PR #${num}: merge failed (bot-approved + CI green) — left open"
      fi
      i=$((i + 1)); continue
    elif [ "$bot_review" = "CHANGES_REQUESTED" ]; then
      local alert="$_LOOP_ALERT"
      mkdir -p "$(dirname "$alert")" 2>/dev/null || true
      printf '[%s] PR #%s: bot review CHANGES_REQUESTED — loop PR rejected by GHA reviewer\n' \
        "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$num" >> "$alert"
      i=$((i + 1)); continue
    fi

    local verdict
    verdict=$(_loop_pr_classify "$head_ref" "$human_review" "$ci_state" "$mergeable")

    case "$verdict" in
      loop_self|blocked_human_request_changes|blocked_human_approved)
        : # skip — explained by verdict; nothing to do this cycle
        ;;
      stale)
        _loop_pr_rebase_circuit "$num" || true
        _loop_pr_rebase_stale "$num" "$head_ref" || true
        ;;
      eligible)
        _loop_pr_review_external "$num" || true
        ;;
    esac

    i=$((i + 1))
  done
  return 0
}

# FIX-070: flip a story row in the main repo's .roll/backlog.md between
# 📋 Todo and 🔨 In Progress. The cycle worktree is gitignored at .roll/,
# so editing the worktree copy + committing leaves no trace in git — and
# main's backlog (which roll-brief reads) stays stale. These helpers write
# directly to ${ROLL_MAIN_PROJECT}/.roll/backlog.md instead.
#
# _loop_mark_in_progress <story-id> [backlog-path]
#   Replace "📋 Todo" with "🔨 In Progress" on the row containing <story-id>.
#   No-op when backlog or row is missing (idempotent retries don't error).
_loop_mark_in_progress() {
  local story_id="$1"
  local backlog="${2:-${ROLL_MAIN_PROJECT:-$PWD}/.roll/backlog.md}"
  [ -n "$story_id" ] || return 1
  [ -f "$backlog" ] || return 0
  local tmp; tmp=$(mktemp "${backlog}.XXXXXX") || return 1
  awk -v sid="$story_id" '
    {
      if (index($0, sid) > 0 && index($0, "📋 Todo") > 0) {
        sub(/📋 Todo/, "🔨 In Progress")
      }
      print
    }
  ' "$backlog" > "$tmp" && mv "$tmp" "$backlog"
}

# _loop_mark_todo <story-id> [backlog-path]
#   Revert a row from "🔨 In Progress" back to "📋 Todo". Called when a
#   cycle's executor fails so the next cycle can pick the story up again.
_loop_mark_todo() {
  local story_id="$1"
  local backlog="${2:-${ROLL_MAIN_PROJECT:-$PWD}/.roll/backlog.md}"
  [ -n "$story_id" ] || return 1
  [ -f "$backlog" ] || return 0
  local tmp; tmp=$(mktemp "${backlog}.XXXXXX") || return 1
  awk -v sid="$story_id" '
    {
      if (index($0, sid) > 0 && index($0, "🔨 In Progress") > 0) {
        sub(/🔨 In Progress/, "📋 Todo")
      }
      print
    }
  ' "$backlog" > "$tmp" && mv "$tmp" "$backlog"
}

# FIX-048: report story IDs already claimed by open loop/* PRs so a new cycle
# can skip them before scanning BACKLOG. Without this gate, a cycle launched
# before the previous cycle's PR merges would re-pick the same Todo story
# (its worktree branches from main, where the 🔨 mark is not yet visible).
#
# _loop_pr_claimed_stories
#   Stdout: one story ID per line, deduped. Empty when nothing claimed.
#   Exit:   0 always (lenient: gh missing / API failure → empty output).
_loop_pr_claimed_stories() {
  local slug; _gh_resolve slug || return 0
  local branches
  branches=$(gh -R "$slug" pr list --state open \
    --json headRefName \
    --jq '.[] | select(.headRefName | startswith("loop/")) | .headRefName' \
    2>/dev/null) || return 0
  [ -n "$branches" ] || return 0

  local branch claimed=""
  while IFS= read -r branch; do
    [ -n "$branch" ] || continue
    local content
    content=$(gh -R "$slug" api \
      "repos/${slug}/contents/.roll/backlog.md?ref=${branch}" \
      -H "Accept: application/vnd.github.raw" 2>/dev/null) || continue
    [ -n "$content" ] || continue
    local ids
    ids=$(printf '%s\n' "$content" \
      | awk -F'|' '/🔨 In Progress/ {
          gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
          sub(/^\[/, "", $2)
          sub(/\].*$/, "", $2)
          if ($2 != "") print $2
        }')
    [ -n "$ids" ] && claimed="${claimed}${ids}"$'\n'
  done <<< "$branches"

  printf '%s' "$claimed" | awk 'NF' | sort -u
}

# US-CL-004: changelog 风格守门 Phase 1 — mechanical linter.
#
# _changelog_lint_bullet <bullet-text>
#   Stdout: one violation tag per line; empty = bullet passes.
#   Exit:   0 always (callers read the output stream, not the exit code).
#
# Violation tags:
#   backtick-identifier  `…` contains `_` or `()`  (e.g. `_foo`, `bar()`)
#   file-suffix          `.md`/`.sh`/`.yml`/`.ts`/`.bats` outside backticks
#   internal-word        Phase N / Step N / Helper / Schema / Fixture / Refactor
#   over-length          > 50 visible chars (UTF-8 codepoints; 中文按字符计)
#   path-fragment        docs/ / bin/ / tests/ / scripts/ outside backticks
#
# Backticks are treated as the "user-quoted" zone — content there is assumed
# to be a real user command (e.g. `roll edit notes.md`) and is excluded from
# the file-suffix / path-fragment checks.
_changelog_lint_bullet() {
  local bullet="$1"
  local stripped
  stripped=$(printf '%s' "$bullet" | sed -E 's/`[^`]*`//g')

  if printf '%s' "$bullet" | grep -qE '`[^`]*(_|\(\))[^`]*`'; then
    echo "backtick-identifier"
  fi
  if printf '%s' "$stripped" | grep -qE '\.(md|sh|yml|ts|bats)([^A-Za-z0-9]|$)'; then
    echo "file-suffix"
  fi
  if printf '%s' "$bullet" | grep -qE '(Phase|Step)[[:space:]]+[0-9]+|Helper|Schema|Fixture|Refactor'; then
    echo "internal-word"
  fi
  local len
  len=$(printf '%s' "$bullet" | LC_ALL=C.UTF-8 wc -m | tr -d ' ')
  if [ "${len:-0}" -gt 50 ]; then
    echo "over-length"
  fi
  if printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(\.roll|docs|bin|tests|scripts)/'; then
    echo "path-fragment"
  fi
  return 0
}

# US-CL-004: changelog few-shot style anchors — extract bullets from the
# most recent 3 published `## v...` sections of CHANGELOG.md (skipping
# `## Unreleased`). Cap at ~1500 chars so the agent's context stays lean.
#
# _changelog_style_anchors [changelog-path]
#   Stdout: concatenated bullet lines from the last 3 released versions.
#   Exit:   0 (empty output when no CHANGELOG.md or no released sections).
_changelog_style_anchors() {
  local changelog="${1:-CHANGELOG.md}"
  [ -f "$changelog" ] || return 0
  awk '
    /^## v/ { ver++; if (ver > 3) exit; printing = 1; next }
    /^## /  { printing = 0 }
    printing && /^- / { print }
  ' "$changelog" | head -c 1500 || true
}

# US-CL-005: changelog 风格守门 Phase 2 — self-audit gate.
#
# _changelog_audit_bullet <bullet>
#   Stricter than _changelog_lint_bullet: 5 boolean rules, 30-char cap.
#   Stdout: one failed-rule tag per line; empty = bullet passes.
#   Exit:   0 always.
#
# Rules:
#   over-length-30   visible chars > 30 AND no backtick (user-cmd escape hatch)
#   internal-id      backtick content contains `_` or `()`
#   path-or-suffix   .md/.sh/.yml/.ts/.bats or docs/bin/tests/scripts/ outside backticks
#   phase-step       `Phase N` / `Step N` workflow vocabulary
#   bad-shape        no `—` (em dash) AND no `不再` AND no `现在` keyword
_changelog_audit_bullet() {
  local bullet="$1"
  local stripped
  stripped=$(printf '%s' "$bullet" | sed -E 's/`[^`]*`//g')

  # Rule 1: length cap 30 (user-command in backticks bypasses this rule).
  if ! printf '%s' "$bullet" | grep -q '`'; then
    local len
    len=$(printf '%s' "$bullet" | LC_ALL=C.UTF-8 wc -m | tr -d ' ')
    if [ "${len:-0}" -gt 30 ]; then
      echo "over-length-30"
    fi
  fi

  # Rule 2: internal identifier inside backticks.
  if printf '%s' "$bullet" | grep -qE '`[^`]*(_|\(\))[^`]*`'; then
    echo "internal-id"
  fi

  # Rule 3: file suffix / path fragment outside backticks.
  if printf '%s' "$stripped" | grep -qE '\.(md|sh|yml|ts|bats)([^A-Za-z0-9]|$)' \
    || printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(\.roll|docs|bin|tests|scripts)/'; then
    echo "path-or-suffix"
  fi

  # Rule 4: workflow vocabulary.
  if printf '%s' "$bullet" | grep -qE '(Phase|Step)[[:space:]]+[0-9]+'; then
    echo "phase-step"
  fi

  # Rule 5: required shape — em dash, 不再, or 现在.
  if ! printf '%s' "$bullet" | LC_ALL=C.UTF-8 grep -qE '—|不再|现在'; then
    echo "bad-shape"
  fi

  return 0
}

# _changelog_audit_log <verdict> <round> <bullet> [<reason>...]
#   Append a JSONL record to the audit log. Path overridable via
#   ROLL_CHANGELOG_AUDIT_LOG (tests use this to stay out of $HOME).
_changelog_audit_log() {
  local verdict="$1" round="$2" bullet="$3"
  shift 3
  local log="${ROLL_CHANGELOG_AUDIT_LOG:-${_SHARED_ROOT}/loop/changelog-audit.jsonl}"
  mkdir -p "$(dirname "$log")"
  local ts; ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
  local reasons_json='[]'
  if [ "$#" -gt 0 ]; then
    reasons_json=$(printf '%s\n' "$@" | jq -R . | jq -sc .)
  fi
  jq -nc \
    --arg ts "$ts" \
    --arg verdict "$verdict" \
    --argjson round "$round" \
    --arg bullet "$bullet" \
    --argjson reasons "$reasons_json" \
    '{ts:$ts, verdict:$verdict, round:$round, bullet:$bullet, reasons:$reasons}' \
    >> "$log"
}

# _changelog_audit_gate <round1> [<round2> <round3>]
#   Run up to 3 candidate bullets through _changelog_audit_bullet.
#   First clean candidate wins: print bullet to stdout, exit 0.
#   All 3 failed: print ⚠️-prefixed last candidate, append ALERT, exit 1.
#   Each round writes a _changelog_audit_log record.
_changelog_audit_gate() {
  local i=0 last=""
  for candidate in "$@"; do
    i=$((i + 1))
    last="$candidate"
    local viols
    # shellcheck disable=SC2207
    viols=( $(_changelog_audit_bullet "$candidate") )
    if [ "${#viols[@]}" -eq 0 ]; then
      _changelog_audit_log pass "$i" "$candidate"
      printf '%s\n' "$candidate"
      return 0
    fi
    _changelog_audit_log fail "$i" "$candidate" "${viols[@]}"
    [ "$i" -ge 3 ] && break
  done
  # All 3 rounds failed (or fewer if caller passed < 3).
  mkdir -p "$(dirname "$_LOOP_ALERT")" 2>/dev/null
  {
    echo ""
    echo "# ALERT — changelog audit failed after $i rounds"
    echo "**Time**: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
    echo "**Bullet**: $last"
    echo "**Action**: kept under \`## Unreleased\` with ⚠️ prefix; human review recommended."
  } >> "$_LOOP_ALERT"
  printf '⚠️ %s\n' "$last"
  return 1
}

# US-AUTO-036: worktree helpers (loop-safe pure additions).
#
# Phase 1 of worktree isolation — these helpers are NOT yet called by
# runner.sh. US-AUTO-037 (manual-only) wires them into
# _write_loop_runner_script. Do not delete or inline; they are unit-tested
# in tests/unit/roll_worktree.bats.

# _worktree_path <slug> <us-id>
#   Echoes the canonical worktree directory for a (project, story) pair.
_worktree_path() {
  echo "${_SHARED_ROOT}/worktrees/${1}-${2}"
}

# _worktree_alert <msg>
#   Append a timestamped line to $_LOOP_ALERT. Used by failure paths in
#   _worktree_merge_back to surface stuck worktrees.
_worktree_alert() {
  mkdir -p "$(dirname "$_LOOP_ALERT")" 2>/dev/null
  printf '[%s] worktree: %s\n' "$(date -u +%FT%TZ)" "$1" >> "$_LOOP_ALERT"
}

# _worktree_create <path> <branch> <base>
#   Create a worktree at <path> on a new branch <branch> rooted at <base>.
#   Idempotent: if <branch> already exists locally (from a prior failed
#   run) it is deleted first so `git worktree add -b` does not error.
_worktree_create() {
  local path="$1" branch="$2" base="$3"
  mkdir -p "$(dirname "$path")"
  if [ -e "$path" ]; then
    git worktree remove --force "$path" 2>/dev/null || true
    rm -rf "$path" 2>/dev/null || true
  fi
  if git show-ref --verify --quiet "refs/heads/${branch}"; then
    git branch -D "$branch" >/dev/null 2>&1 || true
  fi
  git worktree add "$path" -b "$branch" "$base"
}

# _worktree_cleanup <path> <branch>
#   Remove the worktree at <path> and delete <branch>. Tolerant when
#   either is already absent so retries / partial-failure rollback is safe.
_worktree_cleanup() {
  local path="$1" branch="$2"
  git worktree remove --force "$path" 2>/dev/null || true
  rm -rf "$path" 2>/dev/null || true
  git branch -D "$branch" 2>/dev/null || true
  return 0
}

# _worktree_fetch_origin <branch>
#   `git fetch origin <branch>` quietly. Lenient on failure: a missing
#   remote / network blip must not derail the loop, so we return 0 even
#   when fetch fails (the loop's later ff-only check is the strict gate).
_worktree_fetch_origin() {
  local branch="$1"
  if ! git fetch origin "$branch" --quiet 2>/dev/null; then
    echo "[worktree] fetch origin ${branch} failed (lenient, continuing)" >&2
  fi
  return 0
}

# _worktree_submodule_init <path>
#   Run `git submodule update --init --recursive` inside the worktree at
#   <path> so its working tree is materially complete. Runs in a subshell
#   (cd is local) so the caller's cwd and the parent worktree's submodule
#   state are untouched. Returns submodule update's exit code.
_worktree_submodule_init() {
  local path="$1"
  ( cd "$path" && git submodule update --init --recursive --quiet )
}

# _worktree_sync_meta <path>
#   FIX-069: Copy main repo's .roll/ meta (backlog, skills, conventions,
#   features, decisions) into the cycle worktree as a read-only reference.
#   Without this, the loop runs in a clean git clone with no .roll/ (it's
#   gitignored), so Claude finds no backlog and no skill entry points —
#   the whole cycle no-ops.
#
#   Excludes runtime state listed in .roll/.gitignore plus loop event/run
#   logs, so the worktree never inherits main's live cycle state.
#   Single-shot: never written back; the worktree copy is thrown away with
#   the worktree itself.
_worktree_sync_meta() {
  local path="$1"
  [ -d ".roll" ] || return 0
  rsync -a \
    --exclude='state/' \
    --exclude='scratch/' \
    --exclude='*.lock' \
    --exclude='last-test-pass' \
    --exclude='events.ndjson*' \
    --exclude='runs.jsonl*' \
    .roll/ "$path/.roll/" 2>/dev/null || true
  # FIX-085: hard-constrain the "skip 🔨 In Progress" rule from the runner
  # side. SKILL.md tells the agent to skip 🔨 rows but agents don't always
  # comply, so we strip those rows from the worktree's backlog copy — the
  # agent literally can't pick a row it can't see. Main backlog untouched.
  if [ -f "$path/.roll/backlog.md" ]; then
    sed -i.bak '/| 🔨 In Progress |$/d' "$path/.roll/backlog.md" 2>/dev/null || true
    rm -f "$path/.roll/backlog.md.bak"
  fi
}

# _worktree_merge_back <branch>
#   Caller must be in the main worktree (cwd = main). Steps:
#     1. git pull --ff-only origin main   (sync local main with remote)
#     2. git merge --ff-only <branch>     (linear merge of loop branch)
#     3. git push origin main             (publish)
#   Any failure → write to $_LOOP_ALERT and return 1 (worktree is left
#   in place by the caller for human inspection, per US-AUTO-036 non-goal).
_worktree_merge_back() {
  local branch="$1"
  if ! git pull --ff-only origin main --quiet 2>/dev/null; then
    _worktree_alert "pull --ff-only origin main failed (remote diverged?)"
    return 1
  fi
  if ! git merge --ff-only "$branch" --quiet 2>/dev/null; then
    _worktree_alert "merge --ff-only ${branch} failed (not fast-forwardable from main)"
    return 1
  fi
  if ! git push origin main --quiet 2>/dev/null; then
    _worktree_alert "push origin main failed after merging ${branch}"
    return 1
  fi
  return 0
}

# _claude_remote_snapshot [repo]
#   Echo the current set of remote `claude/*` branch names (sans
#   refs/heads/), one per line, sorted. Silent on remote unreachable / no
#   remote / no matches — empty stdout, exit 0.
_claude_remote_snapshot() {
  local repo="${1:-.}"
  git -C "$repo" ls-remote --heads origin 'refs/heads/claude/*' 2>/dev/null \
    | awk '{print $2}' \
    | sed 's|^refs/heads/||' \
    | sort
}

# _claude_cleanup_new_branches <prior> [repo]
#   Delete remote `claude/*` branches present now but absent from <prior>
#   (newline-separated list, as emitted by _claude_remote_snapshot). Skips
#   silently when origin is not a GitHub remote. Each successful delete logs
#   one INFO line; failures are silently ignored so the loop's main flow is
#   never derailed.
_claude_cleanup_new_branches() {
  local prior="$1"
  local repo="${2:-.}"
  local url; url=$(git -C "$repo" remote get-url origin 2>/dev/null)
  [[ "$url" == *github.com* ]] || return 0
  local current; current=$(_claude_remote_snapshot "$repo")
  [ -z "$current" ] && return 0
  local prior_sorted; prior_sorted=$(printf '%s\n' "$prior" | sort -u)
  local new_branches
  new_branches=$(comm -13 <(printf '%s\n' "$prior_sorted") <(printf '%s\n' "$current"))
  [ -z "$new_branches" ] && return 0
  while IFS= read -r branch; do
    [ -z "$branch" ] && continue
    if git -C "$repo" push origin --delete "$branch" 2>/dev/null; then
      echo "[loop] deleted stale claude branch: $branch"
    fi
  done <<< "$new_branches"
  return 0
}

# _claude_cleanup_stale_worktrees [project_path]
#   Remove local worktrees under <project_path>/.claude/worktrees/ whose
#   branch has been fully merged into main (merge-base --is-ancestor). Active
#   worktrees (branch ahead of main) are preserved. Runs `git worktree prune`
#   afterwards to clear stale metadata. Silent on missing directory or any
#   individual failure so the loop's main flow is never derailed.
_claude_cleanup_stale_worktrees() {
  local project_path="${1:-.}"
  local wt_dir="${project_path}/.claude/worktrees"
  [ -d "$wt_dir" ] || return 0
  local entry branch
  for entry in "$wt_dir"/*/; do
    [ -d "$entry" ] || continue
    branch=$(git -C "$project_path" worktree list --porcelain 2>/dev/null \
      | awk -v p="${entry%/}" '
          /^worktree / { cur=$2; flag=(cur==p) }
          /^branch /   && flag { sub(/^refs\/heads\//, "", $2); print $2; flag=0 }
        ')
    [ -z "$branch" ] && branch=$(git -C "$entry" symbolic-ref --short HEAD 2>/dev/null)
    [ -z "$branch" ] && continue
    if git -C "$project_path" merge-base --is-ancestor "$branch" main 2>/dev/null; then
      git -C "$project_path" worktree remove --force "$entry" 2>/dev/null || true
      rm -rf "$entry" 2>/dev/null || true
      git -C "$project_path" branch -D "$branch" 2>/dev/null || true
      echo "[loop] removed stale worktree: $branch"
    fi
  done
  git -C "$project_path" worktree prune 2>/dev/null || true
  return 0
}

_loop_cleanup_stale_cycle_branches() {
  local project_path="${1:-.}"
  local url; url=$(git -C "$project_path" remote get-url origin 2>/dev/null) || return 0
  [[ "$url" == *github.com* ]] || return 0

  local branches
  branches=$(git -C "$project_path" ls-remote --heads origin 'refs/heads/loop/cycle-*' 2>/dev/null \
    | awk '{print $2}' | sed 's|^refs/heads/||')
  [ -z "$branches" ] && return 0

  while IFS= read -r branch; do
    [ -z "$branch" ] && continue
    if ! git -C "$project_path" merge-base --is-ancestor "$branch" origin/main 2>/dev/null; then
      continue
    fi
    if git -C "$project_path" push origin --delete "$branch" 2>/dev/null; then
      echo "[loop] deleted stale cycle branch: $branch"
    fi
  done <<< "$branches"
  return 0
}

# US-AUTO-033: publish a loop cycle branch as a GitHub PR with auto-merge.
#
# _loop_publish_pr <branch> [title]
#   Caller's cwd: a tree where <branch> exists locally.
#   Steps:
#     1. git push origin <branch>
#     2. gh pr view <branch>  → reuse if a PR is already open
#     3. gh pr create --base main --head <branch> ...
#     4. gh pr merge <branch> --auto --squash --delete-branch
#   Stdout: PR URL (always, even on idempotent reuse).
#   Exit 0 on success / idempotent reuse; non-zero on push or create failure.
#   On auto-merge failure: still returns 0 (PR exists; human can take over).
#   When `gh` is not installed: returns 2 — runner script's fallback path.
_loop_publish_pr() {
  local branch="$1"
  local title="${2:-loop cycle ${branch#loop/}}"
  local slug; _gh_resolve slug || {
    _worktree_alert "_loop_publish_pr: gh not installed or origin is not a github repo; cannot publish PR for ${branch}"
    return 2
  }
  local _push_err
  _push_err=$(git push origin "$branch" 2>&1) || {
    _worktree_alert "_loop_publish_pr: push origin ${branch} failed: ${_push_err}"
    return 1
  }
  local pr_url
  pr_url=$(gh -R "$slug" pr view "$branch" --json url -q .url 2>/dev/null) || pr_url=""
  if [ -z "$pr_url" ]; then
    local body
    body=$(printf 'Auto-opened by roll-loop cycle.\n\n- Branch: %s\n- TCR micro-commits: %s\n\nThis PR will auto-merge once required checks pass.' \
      "$branch" "$(git rev-list --count origin/main.."$branch" 2>/dev/null || echo '?')")
    pr_url=$(gh -R "$slug" pr create --base main --head "$branch" \
      --title "$title" --body "$body" 2>/dev/null) || pr_url=""
    if [ -z "$pr_url" ]; then
      _worktree_alert "_loop_publish_pr: gh pr create failed for ${branch}"
      return 1
    fi
  fi
  gh -R "$slug" pr merge "$branch" --auto --squash --delete-branch >/dev/null 2>&1 \
    || _worktree_alert "_loop_publish_pr: gh pr merge --auto failed for ${branch} (PR ${pr_url} left open)"
  # US-VIEW-011: emit 'open' at PR creation; cycle_end path emits a follow-up
  # event with the terminal outcome (merged / closed) via _loop_emit_pr_final.
  _loop_event pr "$branch" "$pr_url" "open" 2>/dev/null || true
  echo "$pr_url"
  return 0
}

# _loop_emit_pr_final <branch>
#   US-VIEW-011: after wait_pr_merge resolves, query gh for the PR's terminal
#   state and emit a second `pr` event so the dashboard renders the correct
#   landing marker (#NN ✓ merged / #NN ↩ closed / #NN … open).
#
#   gh state mapping:
#     MERGED → merged   (auto-merge landed)
#     CLOSED → closed   (PR closed without merging — wasted cycle)
#     OPEN   → open     (still waiting; auto-merge or human reviewer pending)
#     UNKNOWN/error → open (conservative — don't lie about merged)
#
#   Lenient: returns 0 on any failure (gh missing, slug unparseable, network
#   error). The earlier 'open' event remains as the conservative default
#   rendering.
_loop_emit_pr_final() {
  local branch="$1"
  command -v gh >/dev/null 2>&1 || return 0
  local slug; _gh_resolve slug || return 0
  local pr_url state outcome
  pr_url=$(gh -R "$slug" pr view "$branch" --json url -q .url 2>/dev/null) || pr_url=""
  state=$(gh -R "$slug" pr view "$branch" --json state -q .state 2>/dev/null || echo "UNKNOWN")
  case "$state" in
    MERGED) outcome="merged" ;;
    CLOSED) outcome="closed" ;;
    OPEN)   outcome="open"   ;;
    *)      outcome="open"   ;;
  esac
  [ -z "$pr_url" ] && return 0
  _loop_event pr "$branch" "$pr_url" "$outcome" 2>/dev/null || true
  return 0
}

# _loop_wait_pr_merge <branch>
#   FIX-047: poll GitHub until PR for <branch> is merged (confirms delivery).
#   Returns 0: merged. Returns 1: CLOSED or timeout.
#   Gracefully skips (returns 0) when gh is unavailable or slug unparseable.
#   Timeout: ROLL_PR_MERGE_TIMEOUT (default 600s).
_loop_wait_pr_merge() {
  local branch="$1"
  local timeout="${ROLL_PR_MERGE_TIMEOUT:-600}"
  local interval=30
  local elapsed=0
  local slug; _gh_resolve slug || return 0
  while (( elapsed < timeout )); do
    local state; state=$(gh -R "$slug" pr view "$branch" --json state -q .state 2>/dev/null || echo "UNKNOWN")
    case "$state" in
      MERGED) return 0 ;;
      CLOSED) return 1 ;;
    esac
    sleep "$interval"
    elapsed=$(( elapsed + interval ))
  done
  return 1
}

# _loop_is_doc_only_change
#   Returns 0 if every file changed since origin/main is doc-only
#   (.roll/backlog.md, CHANGELOG.md, .roll/proposals.md, docs/, .claude/).
#   Returns 1 if any code file changed or there are no changes.
_loop_is_doc_only_change() {
  local changed
  changed=$(git diff --name-only origin/main HEAD 2>/dev/null) || return 1
  [ -z "$changed" ] && return 1
  # Post-Phase-1: process artifacts moved into .roll/; user-facing docs at guide/ + site/.
  # Legacy paths (BACKLOG.md, PROPOSALS.md, docs/) kept as fallback for pre-2.0 projects.
  echo "$changed" | grep -qvE '^(\.roll/|CHANGELOG\.md|guide/|site/|\.claude/|BACKLOG\.md|PROPOSALS\.md|docs/)' && return 1
  return 0
}

# _loop_publish_doc_pr <branch> [title]
#   Like _loop_publish_pr but merges immediately with --admin (no CI wait).
#   For doc-only changes where CI is not meaningful.
_loop_publish_doc_pr() {
  local branch="$1"
  local title="${2:-doc update ${branch#loop/}}"
  local slug; _gh_resolve slug || {
    _worktree_alert "_loop_publish_doc_pr: gh not installed or origin is not a github repo; cannot publish PR for ${branch}"
    return 2
  }
  if ! git push origin "$branch" --quiet 2>/dev/null; then
    _worktree_alert "_loop_publish_doc_pr: push origin ${branch} failed"
    return 1
  fi
  local pr_url
  pr_url=$(gh -R "$slug" pr view "$branch" --json url -q .url 2>/dev/null) || pr_url=""
  if [ -z "$pr_url" ]; then
    local body
    body=$(printf 'Doc-only update by roll-loop cycle.\n\n- Branch: %s\n- Files: BACKLOG / docs only\n\nMerging immediately — no CI gate needed for doc-only changes.' "$branch")
    pr_url=$(gh -R "$slug" pr create --base main --head "$branch" \
      --title "$title" --body "$body" 2>/dev/null) || pr_url=""
    if [ -z "$pr_url" ]; then
      _worktree_alert "_loop_publish_doc_pr: gh pr create failed for ${branch}"
      return 1
    fi
  fi
  if ! gh -R "$slug" pr merge "$branch" --admin --squash --delete-branch >/dev/null 2>&1; then
    _worktree_alert "_loop_publish_doc_pr: gh pr merge --admin failed for ${branch} (PR ${pr_url} left open)"
    echo "$pr_url"
    return 1
  fi
  echo "$pr_url"
  return 0
}

# _loop_backfill_merged [runs_jsonl_path]
#   FIX-060: independent PR-merge backfill. Walks runs.jsonl, finds entries
#   with status:"built" and a cycle_id field, queries GitHub for the matching
#   loop/cycle-<id> PR, and rewrites entries whose PR is MERGED to
#   status:"merged" with merged_at + merge_commit fields.
#
#   Designed to run from the outer runner BEFORE the pause check, so the
#   scan fires every scheduled tick even when the loop is paused — fixes
#   the pre-FIX-060 behaviour where merge backfill only happened at next
#   cycle startup and stalled forever during pause.
#
#   Lenient: returns 0 when gh is missing, slug is unresolvable, jq is
#   missing, or runs.jsonl does not exist. Atomic rewrite via temp file.
_loop_backfill_merged() {
  local runs_path="${1:-${HOME}/.shared/roll/loop/runs.jsonl}"
  [ -f "$runs_path" ] || return 0
  command -v gh >/dev/null 2>&1 || return 0
  command -v jq >/dev/null 2>&1 || return 0
  local slug; _gh_resolve slug || return 0

  local tmp="${runs_path}.tmp.$$"
  : > "$tmp"
  local line status cycle_id branch view_json state merged_at merge_commit
  while IFS= read -r line; do
    [ -z "$line" ] && continue
    status=$(printf '%s' "$line" | jq -r '.status // ""' 2>/dev/null)
    cycle_id=$(printf '%s' "$line" | jq -r '.cycle_id // ""' 2>/dev/null)
    if [ "$status" != "built" ] || [ -z "$cycle_id" ]; then
      printf '%s\n' "$line" >> "$tmp"
      continue
    fi
    branch="loop/cycle-${cycle_id}"
    view_json=$(gh -R "$slug" pr view "$branch" --json state,mergedAt,mergeCommit 2>/dev/null) || view_json=""
    if [ -z "$view_json" ]; then
      printf '%s\n' "$line" >> "$tmp"
      continue
    fi
    state=$(printf '%s' "$view_json" | jq -r '.state // ""' 2>/dev/null)
    if [ "$state" != "MERGED" ]; then
      printf '%s\n' "$line" >> "$tmp"
      continue
    fi
    merged_at=$(printf '%s' "$view_json" | jq -r '.mergedAt // ""' 2>/dev/null)
    merge_commit=$(printf '%s' "$view_json" | jq -r '.mergeCommit.oid // ""' 2>/dev/null)
    printf '%s' "$line" | jq -c \
      --arg merged_at "$merged_at" \
      --arg merge_commit "$merge_commit" \
      '.status = "merged" | .merged_at = $merged_at | .merge_commit = $merge_commit' \
      >> "$tmp" 2>/dev/null || printf '%s\n' "$line" >> "$tmp"
  done < "$runs_path"
  mv "$tmp" "$runs_path" 2>/dev/null || rm -f "$tmp"
  return 0
}

_loop_monitor() {
  local interval="${1:-3}"
  local project_path; project_path=$(pwd -P)
  local project_name; project_name=$(basename "$project_path")

  # Determine terminal clear capability
  local clear_cmd="clear"
  command -v clear &>/dev/null || clear_cmd="echo ''"

  while true; do
    $clear_cmd
    local agent; agent=$(_project_agent)
    local now; now=$(date '+%Y-%m-%d %H:%M:%S')

    echo -e "\n  ${BOLD}${CYAN}roll loop monitor${NC}  ${YELLOW}${project_name}${NC}  ${now}  (Ctrl-C to exit)\n"

    # Services status (three services on macOS, single on Linux)
    echo -e "  ${BOLD}Services  服务状态${NC}   Agent: ${CYAN}${agent}${NC}"
    if [[ "$(uname)" == "Darwin" ]]; then
      local active_start active_end loop_minute dream_hour dream_minute brief_hour brief_minute
      active_start=$(_config_read_int "loop_active_start" "10")
      active_end=$(_config_read_int "loop_active_end" "18")
      loop_minute=$(_config_read_int "loop_minute" "$(_loop_derive_minute "$project_path" 0)")
      dream_hour=$(_config_read_int "loop_dream_hour" "3")
      dream_minute=$(_config_read_int "loop_dream_minute" "$(_loop_derive_minute "$project_path" 2)")
      brief_hour=$(_config_read_int "loop_brief_hour" "9")
      brief_minute=$(_config_read_int "loop_brief_minute" "$(_loop_derive_minute "$project_path" 4)")

      local loop_sched dream_sched brief_sched
      loop_sched=$(printf "every hour :%02d  active %02d:00–%02d:00" "$loop_minute" "$active_start" "$active_end")
      dream_sched=$(printf "%02d:%02d" "$dream_hour" "$dream_minute")
      brief_sched=$(printf "%02d:%02d" "$brief_hour" "$brief_minute")

      local svcs=("loop" "dream" "brief")
      local scheds=("$loop_sched" "$dream_sched" "$brief_sched")
      for i in "${!svcs[@]}"; do
        local svc="${svcs[$i]}" schedule="${scheds[$i]}"
        local state; state=$(_launchd_svc_state "$svc" "$project_path")
        case "$state" in
          enabled)       printf "    ${GREEN}%-8s ● enabled${NC}       (%s)\n" "$svc" "$schedule" ;;
          installed-off) printf "    ${YELLOW}%-8s ⚠ installed/off${NC}  (%s)  run: roll loop on\n" "$svc" "$schedule" ;;
          not-installed) printf "    ${RED}%-8s ○ not installed${NC}  (%s)  run: roll setup\n" "$svc" "$schedule" ;;
        esac
      done
    else
      if crontab -l 2>/dev/null | grep -q "${_LOOP_TAG}:${project_path}"; then
        echo -e "    ${GREEN}loop     ● enabled${NC}"
      else
        echo -e "    ${YELLOW}loop     ○ disabled${NC}   run: roll loop on"
      fi
    fi

    # Current state
    if [[ -f "$_LOOP_STATE" ]]; then
      local status current_item started_at run_id
      status=$(grep '^status:' "$_LOOP_STATE" | awk '{print $2}')
      current_item=$(grep '^current_item:' "$_LOOP_STATE" | awk '{print $2}')
      started_at=$(grep '^started_at:' "$_LOOP_STATE" | cut -d' ' -f2- | tr -d '"')
      run_id=$(grep '^run_id:' "$_LOOP_STATE" | awk '{print $2}')
      echo ""
      case "$status" in
        running) echo -e "  State      ${GREEN}▶ running${NC}   ${CYAN}${current_item}${NC}   started: ${started_at}   run: ${run_id}" ;;
        paused)  echo -e "  State      ${RED}‖ paused${NC}    on: ${current_item}" ;;
        idle)    echo -e "  State      ${YELLOW}○ idle${NC}" ;;
        *)       echo -e "  State      ${status}" ;;
      esac
    else
      echo ""
      echo -e "  State      ${YELLOW}○ no state file${NC}"
    fi

    # Alert
    if [[ -f "$_LOOP_ALERT" ]]; then
      echo ""
      echo -e "  ${RED}⚠ ALERT${NC}  (${CYAN}roll alert${NC} to manage)"
      sed 's/^/    /' "$_LOOP_ALERT"
    fi

    # Queue: pending items
    echo ""
    echo -e "  ${BOLD}Queue  待处理队列${NC}"
    local backlog=".roll/backlog.md"
    if [[ -f "$backlog" ]]; then
      local queue_count=0
      local fix_pending us_pending refactor_pending
      fix_pending=$(grep -E '^\| FIX-' "$backlog" | grep -F '| 📋 Todo |' || true)
      us_pending=$(grep -E '^\| \[US-' "$backlog" | grep -F '| 📋 Todo |' || true)
      refactor_pending=$(grep -E '^\| REFACTOR-' "$backlog" | grep -F '| 📋 Todo |' || true)

      # FIX first (priority)
      while IFS= read -r line; do
        [[ -z "$line" ]] && continue
        local id desc
        id=$(echo "$line" | awk -F'|' '{print $2}' | tr -d ' ')
        desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//' | cut -c1-60)
        printf "    ${RED}%-14s${NC}  %s\n" "$id" "$desc"
        (( queue_count++ )) || true
      done <<< "$fix_pending"

      # US stories
      while IFS= read -r line; do
        [[ -z "$line" ]] && continue
        local id desc
        id=$(echo "$line" | sed 's/.*\[\(US-[^]]*\)\].*/\1/')
        desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//' | cut -c1-60)
        printf "    ${CYAN}%-14s${NC}  %s\n" "$id" "$desc"
        (( queue_count++ )) || true
      done <<< "$us_pending"

      # Refactors
      while IFS= read -r line; do
        [[ -z "$line" ]] && continue
        local id desc
        id=$(echo "$line" | awk -F'|' '{print $2}' | tr -d ' ')
        desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//' | cut -c1-60)
        printf "    ${YELLOW}%-14s${NC}  %s\n" "$id" "$desc"
        (( queue_count++ )) || true
      done <<< "$refactor_pending"

      [[ $queue_count -eq 0 ]] && echo -e "    ${GREEN}✓ empty${NC}"
    else
      echo "    .roll/backlog.md not found"
    fi

    # Log tail (launchd.log)
    local log_file="${_SHARED_ROOT}/loop/launchd.log"
    echo ""
    echo -e "  ─────────────────────────────────────────────────────"
    echo -e "  ${BOLD}Log Tail  实时日志${NC}  (~/.shared/roll/loop/launchd.log, last 10 lines)"
    if [[ -f "$log_file" && -s "$log_file" ]]; then
      tail -10 "$log_file" | sed 's/^/    /'
    else
      echo -e "    ${YELLOW}(no log yet)${NC}"
    fi

    # Event stream (US-LOOP-001): last 10 events from NDJSON event file
    local slug; slug=$(_project_slug "$project_path")
    local evfile="${_SHARED_ROOT}/loop/events-${slug}.ndjson"
    echo ""
    echo -e "  ─────────────────────────────────────────────────────"
    echo -e "  ${BOLD}Cycle Events  事件流${NC}  (last 10)"
    if [[ -f "$evfile" && -s "$evfile" ]]; then
      tail -n 10 "$evfile" | python3 -c "
import sys, json
for line in sys.stdin:
    try:
        e = json.loads(line)
        stage = e.get('stage','')
        label = e.get('label','')
        detail = e.get('detail','')
        outcome = e.get('outcome','')
        ts = e.get('ts','')
        print(f'    {ts}  {stage:<14}  {label:<22}  {detail}  {outcome}')
    except: pass
" 2>/dev/null || tail -n 10 "$evfile" | sed 's/^/    /'
    else
      echo -e "    ${YELLOW}(no events yet — events are emitted after the first cycle)${NC}"
    fi

    echo ""
    sleep "$interval"
  done
}

# _loop_event_log: show last N events from the project's NDJSON event file.
# Used by: roll loop events [N]
_loop_event_log() {
  local n="${1:-20}"
  local project_path; project_path=$(pwd -P)
  local slug; slug=$(_project_slug "$project_path")
  local evfile="${_SHARED_ROOT}/loop/events-${slug}.ndjson"
  if [ ! -f "$evfile" ]; then
    echo "[monitor] No event log found for project: $slug"
    return 1
  fi
  # Show last N events, formatted
  tail -n "$n" "$evfile" | python3 -c "
import sys, json
for line in sys.stdin:
    try:
        e = json.loads(line)
        print(f\"  {e.get('ts','')}  {e.get('stage',''):12s}  {e.get('label',''):20s}  {e.get('detail','')}  {e.get('outcome','')}\")
    except: pass
"
}

# ═══════════════════════════════════════════════════════════════════════════════
# BRIEF — owner-facing project digest
# ═══════════════════════════════════════════════════════════════════════════════

cmd_brief() {
  local briefs_dir=".roll/briefs"
  local latest; latest=$(ls "${briefs_dir}"/*.md 2>/dev/null | sort | tail -1 || true)

  if [[ -z "$latest" ]]; then
    info "No brief yet — generating...  暂无简报，正在生成..."
    _agent_run_skill "roll-brief"
    latest=$(ls "${briefs_dir}"/*.md 2>/dev/null | sort | tail -1 || true)
  else
    local mod_time now age
    mod_time=$(_file_mtime "$latest")
    now=$(date +%s); age=$(( now - mod_time ))
    if (( age > 86400 )); then
      info "Brief is $(( age / 3600 ))h old — regenerating...  简报已 $(( age / 3600 )) 小时未更新，重新生成..."
      _agent_run_skill "roll-brief"
      latest=$(ls "${briefs_dir}"/*.md 2>/dev/null | sort | tail -1 || true)
    fi
  fi

  if [[ ! -f "$latest" ]]; then
    return 1
  fi

  # ── Display mode ──────────────────────────────────────────────────────────
  if [[ "${ROLL_UI:-v2}" == "v2" ]]; then
    python3 "${ROLL_PKG_DIR}/lib/roll-brief.py" "$@"
    return
  fi

  cat "$latest"
}

# REFACTOR-030: removed `_promote_unreleased` and `_ensure_unreleased`.
# REFACTOR-021 collapsed the changelog double-pipeline so the release script
# generates the version header directly from BACKLOG, leaving these two
# helpers orphaned. Their behaviour is now part of the changelog renderer
# called from the maintainer-private release script at roll-meta/ops/release.sh.

# ═══════════════════════════════════════════════════════════════════════════════
# BACKLOG — show pending tasks / manage status
# ═══════════════════════════════════════════════════════════════════════════════

# Update status of all BACKLOG rows whose ID field contains <pattern> (case-insensitive).
# Uses Python for reliable emoji/Unicode handling.
_backlog_set_status() {
  local pattern="$1"
  local new_status="$2"
  local backlog=".roll/backlog.md"
  python3 -c "
import sys, re
pattern, new_status, filename = sys.argv[1], sys.argv[2], sys.argv[3]
lines = open(filename, encoding='utf-8').readlines()
count = 0
out = []
for line in lines:
    if line.startswith('|') and line.count('|') >= 4:
        parts = line.split('|')
        if len(parts) >= 5:
            id_field = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', parts[1]).strip()
            if pattern.upper() in id_field.upper():
                parts[-2] = ' ' + new_status + ' '
                line = '|'.join(parts)
                count += 1
    out.append(line)
open(filename, 'w', encoding='utf-8').writelines(out)
print(count)
" "$pattern" "$new_status" "$backlog"
}

_backlog_extract_id() {
  local line="$1"
  if echo "$line" | grep -q '\[US-'; then
    echo "$line" | sed 's/.*\[\(US-[^]]*\)\].*/\1/'
  else
    echo "$line" | awk -F'|' '{print $2}' | tr -d ' '
  fi
}

# Render one pending-group section (FIX / US / REFACTOR / IDEA) — all four
# types share identical row structure, so they share one render path. Format
# changes only need to happen here.
#   $1 title (EN + ZH)   $2 ANSI color   $3 count   $4 id column width   $5 items text
_backlog_render_group() {
  local title="$1" color="$2" count="$3" width="$4" items="$5"
  echo -e "  ${color}${title}  (${count})${NC}"
  while IFS= read -r line; do
    [[ -z "$line" ]] && continue
    local id desc
    id=$(_backlog_extract_id "$line")
    desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//')
    printf "    %-${width}s  %s\n" "$id" "$desc"
  done <<< "$items"
  echo ""
}

# ═══════════════════════════════════════════════════════════════════════════════
# CI — check or wait for current commit's CI status
# ═══════════════════════════════════════════════════════════════════════════════
# ALERT — view / ack / resolve loop alert lifecycle
# ═══════════════════════════════════════════════════════════════════════════════

cmd_alert() {
  local subcmd="${1:-list}"
  shift || true

  case "$subcmd" in
    list|"")
      if [[ ! -f "$_LOOP_ALERT" ]]; then
        ok "No active alerts  暂无告警"
        return 0
      fi
      echo -e "${BOLD}Active Alert  当前告警${NC}"
      echo ""
      cat "$_LOOP_ALERT"
      echo ""
      echo -e "  Run '${CYAN}roll alert ack${NC}' to acknowledge, '${CYAN}roll alert resolve${NC}' to clear."
      echo -e "  运行 'roll alert ack' 确认告警，'roll alert resolve' 清除告警。"
      ;;
    ack)
      if [[ ! -f "$_LOOP_ALERT" ]]; then
        warn "No active alerts to acknowledge  暂无待确认告警"
        return 0
      fi
      local ts; ts=$(date '+%Y-%m-%d %H:%M:%S')
      {
        echo ""
        echo "**Acknowledged**: ${ts}"
      } >> "$_LOOP_ALERT"
      ok "Alert acknowledged at ${ts}  告警已确认"
      ;;
    resolve|clear)
      if [[ ! -f "$_LOOP_ALERT" ]]; then
        ok "No active alerts  暂无告警"
        return 0
      fi
      rm -f "$_LOOP_ALERT"
      ok "Alert resolved and cleared  告警已解决并清除"
      ;;
    *)
      err "Unknown subcommand: $subcmd  未知子命令: $subcmd"
      echo "  Usage: roll alert [list|ack|resolve]"
      echo "  用法: roll alert [list|ack|resolve]"
      return 1
      ;;
  esac
}

# ═══════════════════════════════════════════════════════════════════════════════

cmd_ci() {
  local wait_mode=false
  local timeout=300

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --wait) wait_mode=true; shift ;;
      --timeout=*) timeout="${1#*=}"; shift ;;
      *) err "Usage: roll ci [--wait] [--timeout=N]  用法: roll ci [--wait] [--timeout=N]"; exit 1 ;;
    esac
  done

  if $wait_mode; then
    _ci_wait "$timeout"
    return
  fi

  _gh_available || { warn "gh not installed  gh 未安装"; return 0; }
  local commit; commit=$(git rev-parse HEAD 2>/dev/null) || { err "Not a git repo  非 git 仓库"; return 1; }
  local runs
  runs=$(gh run list --commit "$commit" --json status,conclusion,name 2>/dev/null) || { warn "gh run list failed"; return 0; }
  if [[ -z "$runs" || "$runs" == "[]" ]]; then
    echo "No CI runs for $(git rev-parse --short HEAD)  当前提交无 CI 记录"
    return 0
  fi
  echo "$runs" | jq -r '.[] | "\(.name): \(.status)/\(.conclusion)"'
}

# REFACTOR-041: backlog description linter. The global convention bans file
# paths, function names, filenames, and "architecture jargon" in description
# columns — see conventions/global/AGENTS.md §4. This helper scans each row's
# description column for those patterns and prints any findings. Phase 1 is
# warn-only (always exit 0) so a noisy ramp-up doesn't block work; Phase 2
# will switch to hard-fail. Output format mirrors a linter ("file:line:
# message") so editors can navigate from it.
_backlog_lint() {
  local backlog="${1:-.roll/backlog.md}"
  [ -f "$backlog" ] || { err "backlog not found: $backlog"; return 1; }

  local violations=0
  local lineno=0
  while IFS= read -r line; do
    lineno=$((lineno+1))
    # Only data rows (start with "|"), skip header/separator/non-table
    case "$line" in
      \|*) ;;
      *) continue ;;
    esac
    case "$line" in
      *Story*Description*Status*|*'---'*) continue ;;
    esac
    local desc
    desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//')
    [ -n "$desc" ] || continue
    # Strip the leading `[US-XXX](path)` link / bare `US-XXX` id — those are
    # structural, not description prose.
    local body
    body=$(echo "$desc" \
      | sed -E 's|^\[[A-Z]+-[0-9]+\]\([^)]*\)[[:space:]]*||' \
      | sed -E 's|^[A-Z]+-[0-9]+[[:space:]]*||')
    local issues=""
    # Filenames: bare `something.ext` for common code/config extensions
    if echo "$body" | grep -qE '\b[A-Za-z_][A-Za-z0-9_.-]*\.(sh|bash|yaml|yml|json|js|ts|tsx|py|rb|go|rs|c|cpp|h)\b'; then
      issues="${issues:+${issues}, }filename"
    fi
    # Paths: directory/anything pattern not preceded by `(` (links already
    # stripped above). Hyphens / dots / underscores allowed in path segments.
    if echo "$body" | grep -qE '[A-Za-z_][A-Za-z0-9_.-]*/[A-Za-z0-9_./-]+'; then
      issues="${issues:+${issues}, }path"
    fi
    # Function names: underscore-prefixed identifier or trailing parens
    if echo "$body" | grep -qE '\b_[a-zA-Z][a-zA-Z0-9_]+\b|\b[A-Za-z_][A-Za-z0-9_]+\(\)'; then
      issues="${issues:+${issues}, }function"
    fi
    if [ -n "$issues" ]; then
      violations=$((violations+1))
      # Extract the story id from column 2 so reports name the offending row.
      local sid; sid=$(echo "$line" | awk -F'|' '{print $2}' \
        | sed -E 's/^[[:space:]]*\[?([A-Z]+-[0-9]+).*/\1/' \
        | tr -d '[:space:]')
      printf '%s:%d: %s — %s\n  %s\n' "$backlog" "$lineno" "$sid" "$issues" "$desc"
    fi
  done < "$backlog"

  echo ""
  if [ "$violations" -gt 0 ]; then
    echo "  ${violations} violation(s) — see conventions/global/AGENTS.md §4"
    echo "  ${violations} 条违规 — Phase 1: warn-only, not blocking"
  else
    echo "  No violations  无违规"
  fi
  # Phase 1: warn-only. Exit 0 regardless.
  return 0
}

cmd_backlog() {
  local backlog=".roll/backlog.md"
  if [[ ! -f "$backlog" ]]; then
    err ".roll/backlog.md not found — run 'roll init' first  未找到 .roll/backlog.md，请先运行 roll init"
    return 1
  fi

  local subcmd="${1:-}"

  # ── Status management subcommands ─────────────────────────────────────────
  case "$subcmd" in
    lint)
      _backlog_lint "$backlog"
      return
      ;;
    block|defer|unblock|promote)
      local pattern="${2:-}"
      local reason="${3:-}"
      if [[ -z "$pattern" ]]; then
        err "Usage: roll backlog $subcmd <pattern> [reason]  用法: roll backlog $subcmd <匹配模式> [原因]"
        return 1
      fi
      local new_status
      case "$subcmd" in
        block)           new_status="🔒 Blocked${reason:+ [${reason}]}" ;;
        defer)           new_status="⏸ Deferred${reason:+ [${reason}]}" ;;
        unblock|promote) new_status="📋 Todo" ;;
      esac
      local count
      count=$(_backlog_set_status "$pattern" "$new_status")
      if [[ "$count" -eq 0 ]]; then
        echo "  No items matched: $pattern  未找到匹配项: $pattern"
      else
        echo "  Updated ${count} item(s) → ${new_status}  已更新 ${count} 条目"
      fi
      return
      ;;
  esac

  # ── Display mode ──────────────────────────────────────────────────────────
  if [[ "${ROLL_UI:-v2}" == "v2" ]]; then
    python3 "${ROLL_PKG_DIR}/lib/roll-backlog.py" "$@"
    return
  fi
  local DIM='\033[2m'

  local us_items fix_items refactor_items idea_items total=0
  us_items=$(grep -E '^\| \[US-' "$backlog" | grep -F '| 📋 Todo |' || true)
  fix_items=$(grep -E '^\| FIX-' "$backlog" | grep -F '| 📋 Todo |' || true)
  refactor_items=$(grep -E '^\| REFACTOR-' "$backlog" | grep -F '| 📋 Todo |' || true)
  idea_items=$(grep -E '^\| IDEA-' "$backlog" | grep -F '| 📋 Todo |' || true)

  local us_count fix_count refactor_count idea_count
  us_count=$(echo "$us_items" | grep -c . || true)
  fix_count=$(echo "$fix_items" | grep -c . || true)
  refactor_count=$(echo "$refactor_items" | grep -c . || true)
  idea_count=$(echo "$idea_items" | grep -c . || true)
  [[ -z "$us_items" ]] && us_count=0
  [[ -z "$fix_items" ]] && fix_count=0
  [[ -z "$refactor_items" ]] && refactor_count=0
  [[ -z "$idea_items" ]] && idea_count=0
  total=$(( us_count + fix_count + refactor_count + idea_count ))

  local blocked_items deferred_items unknown_items
  blocked_items=$(grep -E '^\|' "$backlog" | grep '🔒 Blocked' || true)
  deferred_items=$(grep -E '^\|' "$backlog" | grep '⏸ Deferred' || true)
  unknown_items=$( { grep -E '^\| \[US-' "$backlog"; grep -E '^\| FIX-' "$backlog"; grep -E '^\| REFACTOR-' "$backlog"; grep -E '^\| IDEA-' "$backlog"; } \
    | grep -v '📋 Todo\|🔨 In Progress\|✅ Done\|🔒 Blocked\|⏸ Deferred' || true)

  local blocked_count deferred_count unknown_count
  blocked_count=$(echo "$blocked_items" | grep -c . || true)
  deferred_count=$(echo "$deferred_items" | grep -c . || true)
  unknown_count=$(echo "$unknown_items" | grep -c . || true)
  [[ -z "$blocked_items" ]] && blocked_count=0
  [[ -z "$deferred_items" ]] && deferred_count=0
  [[ -z "$unknown_items" ]] && unknown_count=0

  echo ""
  echo -e "  ${BOLD}Pending Backlog  待处理任务${NC}  (${total} items)"
  echo ""

  [[ $fix_count -gt 0 ]]      && _backlog_render_group "Bug Fixes  缺陷修复"   "$RED"    "$fix_count"      12 "$fix_items"
  [[ $us_count -gt 0 ]]       && _backlog_render_group "User Stories  用户故事" "$CYAN"   "$us_count"       14 "$us_items"
  [[ $refactor_count -gt 0 ]] && _backlog_render_group "Refactors  重构"        "$YELLOW" "$refactor_count" 16 "$refactor_items"
  [[ $idea_count -gt 0 ]]     && _backlog_render_group "Ideas  创意"            "$NC"     "$idea_count"     14 "$idea_items"

  if [[ $total -eq 0 ]]; then
    echo -e "  ${GREEN}✓ Nothing pending — backlog is clear  暂无待处理任务${NC}"
    echo ""
  fi

  # ── Blocked ───────────────────────────────────────────────────────────────
  if [[ $blocked_count -gt 0 ]]; then
    echo -e "  ${DIM}Blocked  已阻塞  (${blocked_count})${NC}"
    while IFS= read -r line; do
      [[ -z "$line" ]] && continue
      local id desc reason
      id=$(_backlog_extract_id "$line")
      desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//' | cut -c1-52)
      reason=$(echo "$line" | awk -F'|' '{print $4}' | grep -oE '\[.*\]' | tr -d '[]' || true)
      printf "    ${DIM}🔒 %-14s  %s${NC}" "$id" "$desc"
      [[ -n "$reason" ]] && printf "${DIM}  (%s)${NC}" "$reason"
      printf "\n"
    done <<< "$blocked_items"
    echo ""
  fi

  # ── Deferred ──────────────────────────────────────────────────────────────
  if [[ $deferred_count -gt 0 ]]; then
    echo -e "  ${DIM}Deferred  已推迟  (${deferred_count})${NC}"
    while IFS= read -r line; do
      [[ -z "$line" ]] && continue
      local id desc reason
      id=$(_backlog_extract_id "$line")
      desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//' | cut -c1-52)
      reason=$(echo "$line" | awk -F'|' '{print $4}' | grep -oE '\[.*\]' | tr -d '[]' || true)
      printf "    ${DIM}⏸ %-14s  %s${NC}" "$id" "$desc"
      [[ -n "$reason" ]] && printf "${DIM}  (%s)${NC}" "$reason"
      printf "\n"
    done <<< "$deferred_items"
    echo ""
  fi

  # ── Unknown status (show for human/AI triage) ─────────────────────────────
  if [[ $unknown_count -gt 0 ]]; then
    echo -e "  ${YELLOW}? Unknown Status  未知状态  (${unknown_count})${NC}"
    echo -e "  ${YELLOW}  Fix: roll backlog block/defer/unblock <pattern>  运行命令修正状态${NC}"
    while IFS= read -r line; do
      [[ -z "$line" ]] && continue
      local id desc status_raw
      id=$(_backlog_extract_id "$line")
      desc=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//' | cut -c1-52)
      status_raw=$(echo "$line" | awk -F'|' '{print $4}' | sed 's/^ *//;s/ *$//')
      printf "    ${YELLOW}? %-14s  %s  [%s]${NC}\n" "$id" "$desc" "$status_raw"
    done <<< "$unknown_items"
    echo ""
  fi
}

# ─────────────────────────────────────────────────────────────────────────────
# DASHBOARD — 自治优先六块布局 (US-AUTO-029)
# ─────────────────────────────────────────────────────────────────────────────

# ① Identity — git working tree state.
_dash_git_status() {
  git rev-parse --is-inside-work-tree &>/dev/null || { echo "—"; return; }
  if [[ -z "$(git status --porcelain 2>/dev/null)" ]]; then
    echo "✓"
  else
    echo "dirty"
  fi
}

# ② Loop layer: extract in-progress story id|title|feature-link from .roll/backlog.md.
# Output empty if no row's *status column* is 🔨 In Progress (substring matches
# anywhere on the row would catch description text that mentions the emoji).
_dash_in_progress_story() {
  [[ -f ".roll/backlog.md" ]] || return 0
  local row
  row=$(grep -F '| 🔨 In Progress |' .roll/backlog.md | head -1) || return 0
  [[ -z "$row" ]] && return 0
  local id desc
  id=$(echo "$row" | grep -oE '(US|FIX|REFACTOR)-[A-Z]*-?[0-9]+' | head -1)
  desc=$(echo "$row" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//' | cut -c1-60)
  local link
  link=$(echo "$row" | grep -oE '.roll/features/[^)]+' | head -1 || true)
  printf '%s|%s|%s' "$id" "$desc" "$link"
}

# ② Loop layer: minutes since last "tcr:" commit, or empty if none.
_dash_last_tcr_minutes() {
  git rev-parse --is-inside-work-tree &>/dev/null || return 0
  local last_ts
  last_ts=$(git log --grep='^tcr:' -1 --format=%ct 2>/dev/null)
  [[ -z "$last_ts" ]] && return 0
  local now; now=$(date +%s)
  echo $(( (now - last_ts) / 60 ))
}

# ② Loop layer: tcr: commits since midnight today.
_dash_tcr_today_count() {
  git rev-parse --is-inside-work-tree &>/dev/null || { echo 0; return; }
  local since; since=$(date '+%Y-%m-%d 00:00:00')
  git log --since="$since" --grep='^tcr:' --oneline 2>/dev/null | grep -c '^' || echo 0
}

# ② Dream layer: hours since last dream log entry on disk.
_dash_last_dream_hours() {
  local dream_log="${HOME}/.shared/roll/dream/log.md"
  [[ -f "$dream_log" ]] || return 0
  local mod_time now
  mod_time=$(_file_mtime "$dream_log")
  now=$(date +%s)
  echo $(( (now - mod_time) / 3600 ))
}

# ② Dream layer: count of REFACTOR-XXX rows currently 📋 Todo in BACKLOG.
_dash_refactor_pending() {
  [[ -f ".roll/backlog.md" ]] || { echo 0; return; }
  grep -E '^\| REFACTOR-' .roll/backlog.md 2>/dev/null | grep -F '| 📋 Todo |' | wc -l | tr -d ' '
}

# ② Peer layer: last result + days ago from peer log, empty if no log.
_dash_last_peer() {
  local peer_log_dir="${HOME}/.shared/roll/peer"
  local latest
  latest=$(ls "$peer_log_dir"/*.log 2>/dev/null | sort | tail -1 || true)
  [[ -z "$latest" || ! -f "$latest" ]] && return 0
  local result
  result=$(grep -oE '(AGREE|REFINE|OBJECT|ESCALATE)' "$latest" 2>/dev/null | tail -1 || true)
  local mod_time now days
  mod_time=$(_file_mtime "$latest")
  now=$(date +%s)
  days=$(( (now - mod_time) / 86400 ))
  printf '%s|%s' "${result:-—}" "${days}"
}

# ③ Pipeline counts → Idea Backlog Build (Verify/Release reserved).
_dash_pipeline_counts() {
  [[ -f ".roll/backlog.md" ]] || { echo "0 0 0 0 0"; return; }
  local idea backlog build
  idea=$(grep -E '^\| IDEA-' .roll/backlog.md 2>/dev/null | grep -F '| 📋 Todo |' | wc -l | tr -d ' ')
  backlog=$(grep -E '^\| (\[?US-|FIX-|REFACTOR-)' .roll/backlog.md 2>/dev/null | grep -F '| 📋 Todo |' | wc -l | tr -d ' ')
  build=$(grep -F '| 🔨 In Progress |' .roll/backlog.md 2>/dev/null | wc -l | tr -d ' ')
  printf '%s %s %s 0 0' "$idea" "$backlog" "$build"
}

# ④ DoD AC signal — read [x]/total checkboxes for a US section in feature doc.
# Echoes "x/total"; "0/0" if no checkboxes found.
_dash_ac_completion() {
  local feature_link="$1"
  [[ -z "$feature_link" ]] && { echo "0/0"; return; }
  local path="${feature_link%%#*}"
  local anchor="${feature_link##*#}"
  [[ ! -f "$path" ]] && { echo "0/0"; return; }
  # Extract the section from <a id="anchor"></a> or ## heading to next ## heading.
  local section
  section=$(awk -v anc="$anchor" '
    BEGIN{in_sec=0}
    /^<a id="/{
      gsub(/<a id="|"><\/a>/, "")
      if ($0 == anc) { in_sec=1; next }
    }
    in_sec && /^## /{ if(!started){ started=1; next } else { exit } }
    in_sec && started { print }
    in_sec { started_default=1 }
  ' "$path" 2>/dev/null)
  [[ -z "$section" ]] && {
    # Fallback: match heading line containing the anchor pattern directly.
    section=$(awk -v pat="$anchor" 'BEGIN{IGNORECASE=1}
      tolower($0) ~ pat && /^## /{p=1;next}
      p && /^## /{exit}
      p{print}' "$path" 2>/dev/null)
  }
  local done total
  done=$(echo "$section" | grep -cE '\[x\]' || echo 0)
  total=$(echo "$section" | grep -cE '\[[ x]\]' || echo 0)
  printf '%s/%s' "$done" "$total"
}

# ④ DoD CI signal — query gh for HEAD's most-recent run conclusion.
# Returns: success | pending | failure | none
_dash_ci_status() {
  _gh_available || { echo "none"; return; }
  local commit; commit=$(git rev-parse HEAD 2>/dev/null) || { echo "none"; return; }
  local slug; slug=$(_gh_repo_slug 2>/dev/null) || true
  local out
  if [[ -n "$slug" ]]; then
    out=$(gh -R "$slug" run list --commit "$commit" --json status,conclusion 2>/dev/null) || { echo "none"; return; }
  else
    out=$(gh run list --commit "$commit" --json status,conclusion 2>/dev/null) || { echo "none"; return; }
  fi
  [[ -z "$out" || "$out" == "[]" ]] && { echo "none"; return; }
  local concl status
  concl=$(echo "$out" | jq -r '.[0].conclusion // ""' 2>/dev/null)
  status=$(echo "$out" | jq -r '.[0].status // ""' 2>/dev/null)
  if [[ "$status" == "in_progress" || "$status" == "queued" ]]; then
    echo "pending"
  elif [[ "$concl" == "success" ]]; then
    echo "success"
  elif [[ -n "$concl" ]]; then
    echo "failure"
  else
    echo "pending"
  fi
}

# ⑤ Active ALERT count (number of "# ALERT" headings in ALERT.md, 0 if absent).
_dash_alert_count() {
  [[ -f "$_LOOP_ALERT" ]] || { echo 0; return; }
  grep '^# ALERT' "$_LOOP_ALERT" 2>/dev/null | wc -l | tr -d ' '
}

# ⑤ Pending proposal count — "## PROPOSAL:" entries in .roll/proposals.md.
_dash_proposal_count() {
  [[ -f ".roll/proposals.md" ]] || { echo 0; return; }
  grep '^## PROPOSAL' .roll/proposals.md 2>/dev/null | wc -l | tr -d ' '
}

# ⑤ Release-ready signal — true iff there are releasable commits since the
# latest tag AND the latest brief signals 可发版/Release ready. Releasable =
# any commit since the latest tag whose subject does NOT start with the
# release-irrelevant prefixes `docs:` or `chore:`. Prevents the flag from
# sticking on after a release when only docs rewrites land on top of the tag
# (FIX-033 symptom 2).
_dash_release_ready() {
  local latest_tag
  latest_tag=$(git describe --tags --abbrev=0 2>/dev/null) || return 1
  local commits_with_code
  commits_with_code=$(git log "${latest_tag}..HEAD" --pretty=format:%s 2>/dev/null \
    | grep -vE '^(docs|chore)(\([^)]*\))?:[[:space:]]' \
    | wc -l | tr -d ' ')
  [[ "${commits_with_code:-0}" -gt 0 ]] || return 1
  local latest
  latest=$(ls .roll/briefs/*.md 2>/dev/null | sort | tail -1 || true)
  [[ -z "$latest" ]] && return 1
  grep -qE '✅ 可发版|Release ready' "$latest" 2>/dev/null
}

# ⑥ Latest brief summary — first non-trivial line after frontmatter.
_dash_brief_summary() {
  local latest="$1"
  [[ -z "$latest" || ! -f "$latest" ]] && return 0
  awk '
    NR==1 && /^#/ { next }       # skip H1 title
    /^>/ { next }                # skip blockquote
    /^---$/ { next }
    /^$/ { next }
    /^## /{ gsub(/^## */,""); print; exit }
    /^[^[:space:]]/{ print; exit }
  ' "$latest" 2>/dev/null | head -1 | cut -c1-60
}

_legacy_home() {
  local project_path; project_path=$(pwd -P)
  local project_name; project_name=$(basename "$project_path")
  local agent; agent=$(_project_agent)
  local git_state; git_state=$(_dash_git_status)
  local is_darwin=false
  [[ "$(uname)" == "Darwin" ]] && is_darwin=true

  # ── ① Identity ─────────────────────────────────────────────────────────────
  echo ""
  printf "  ${BOLD}${CYAN}%s${NC}  ${YELLOW}v%s${NC}  ${BOLD}·${NC}  agent ${CYAN}%s${NC}  ${BOLD}·${NC}  git " \
    "$project_name" "$VERSION" "$agent"
  case "$git_state" in
    ✓) printf "${GREEN}✓${NC}\n" ;;
    dirty) printf "${YELLOW}dirty${NC}\n" ;;
    *) printf "${YELLOW}%s${NC}\n" "$git_state" ;;
  esac
  echo ""

  # ── ② AI 自治 — 三层 × 四道防线 (主视觉) ────────────────────────────────
  echo -e "  ${BOLD}╔══ 🤖 AI 自治 — 三层 × 四道防线 ══════════════════════════╗${NC}"

  # Loop layer
  local loop_state="not-installed"
  local _dash_loop_paused=false
  [[ -f "$_LOOP_STATE" ]] && grep -q "^status: paused" "$_LOOP_STATE" 2>/dev/null && _dash_loop_paused=true
  if $is_darwin; then
    loop_state=$(_launchd_svc_state "loop" "$project_path")
  else
    crontab -l 2>/dev/null | grep -q "${_LOOP_TAG}:${project_path}" && loop_state="enabled"
  fi
  local active_start active_end loop_minute dream_hour dream_minute brief_hour brief_minute
  active_start=$(_config_read_int "loop_active_start" "10")
  active_end=$(_config_read_int "loop_active_end" "18")
  loop_minute=$(_config_read_int "loop_minute" "$(_loop_derive_minute "$project_path" 0)")
  dream_hour=$(_config_read_int "loop_dream_hour" "3")
  dream_minute=$(_config_read_int "loop_dream_minute" "$(_loop_derive_minute "$project_path" 2)")
  brief_hour=$(_config_read_int "loop_brief_hour" "9")
  brief_minute=$(_config_read_int "loop_brief_minute" "$(_loop_derive_minute "$project_path" 4)")

  local loop_badge loop_sched
  loop_sched=$(printf "every :%02d  active %02d:00–%02d:00" "$loop_minute" "$active_start" "$active_end")
  case "$loop_state" in
    enabled)       loop_badge="${GREEN}● enabled${NC}" ;;
    installed-off) loop_badge="${YELLOW}⚠ off${NC}" ;;
    *)             loop_badge="${RED}○ missing${NC}" ;;
  esac
  $_dash_loop_paused && loop_badge="${YELLOW}⏸ paused${NC}"
  printf "  Loop Layer    %b  %s\n" "$loop_badge" "$loop_sched"

  # Loop "Now:" line — current in-progress story, if any.
  local in_prog; in_prog=$(_dash_in_progress_story)
  if [[ -n "$in_prog" ]]; then
    local p_id p_desc
    p_id=${in_prog%%|*}
    p_desc=$(echo "$in_prog" | awk -F'|' '{print $2}')
    printf "                Now: ${BOLD}🔨 %s${NC}  %s\n" "$p_id" "$p_desc"
  else
    printf "                Now: ${DIM:-}idle${NC}\n"
  fi

  # last TCR + today count
  local last_tcr_min today_tcr
  last_tcr_min=$(_dash_last_tcr_minutes)
  today_tcr=$(_dash_tcr_today_count)
  if [[ -n "$last_tcr_min" ]]; then
    printf "                last TCR ${CYAN}%smin${NC} ago · ${CYAN}%s${NC} micro-commits today\n" "$last_tcr_min" "$today_tcr"
  else
    printf "                no tcr commits yet\n"
  fi

  # Dream layer
  local dream_state="not-installed"
  $is_darwin && dream_state=$(_launchd_svc_state "dream" "$project_path")
  local dream_badge
  case "$dream_state" in
    enabled)       dream_badge="${GREEN}● enabled${NC}" ;;
    installed-off) dream_badge="${YELLOW}⚠ off${NC}" ;;
    *)             dream_badge="${RED}○ missing${NC}" ;;
  esac
  printf "  Dream Layer   %b  %02d:%02d\n" "$dream_badge" "$dream_hour" "$dream_minute"
  local dream_hours refac_pending
  dream_hours=$(_dash_last_dream_hours)
  refac_pending=$(_dash_refactor_pending)
  if [[ -n "$dream_hours" ]]; then
    printf "                Last scan ${CYAN}%sh${NC} ago → ${CYAN}%s${NC} REFACTOR queued\n" "$dream_hours" "$refac_pending"
  else
    printf "                no scan yet → ${CYAN}%s${NC} REFACTOR queued\n" "$refac_pending"
  fi

  # Peer layer
  local peer; peer=$(_dash_last_peer)
  printf "  Peer Layer    ${GREEN}● ready${NC}    on complexity=large\n"
  if [[ -n "$peer" ]]; then
    local peer_res peer_days
    peer_res=${peer%%|*}
    peer_days=${peer##*|}
    printf "                Last call ${CYAN}%sd${NC} ago · %s\n" "$peer_days" "$peer_res"
  else
    printf "                Last call —\n"
  fi

  # 四道防线
  echo -e "  ${BOLD}─ 四道防线 ─${NC}"
  local def_tcr="${RED}○${NC}" def_review="${GREEN}●${NC}" def_spar="${YELLOW}○${NC}" def_sentinel="${YELLOW}○ off${NC}"
  if [[ -n "$last_tcr_min" ]]; then
    def_tcr="${GREEN}● ${last_tcr_min}min${NC}"
  fi
  printf "  TCR %b   Spar %b   Auto Review %b   Sentinel %b\n" \
    "$def_tcr" "$def_spar" "$def_review" "$def_sentinel"
  echo -e "  ${BOLD}╚══════════════════════════════════════════════════════════╝${NC}"
  echo ""

  # ── ③ Pipeline 全景 ────────────────────────────────────────────────────────
  read -r pl_idea pl_backlog pl_build pl_verify pl_release <<< "$(_dash_pipeline_counts)"
  local build_color="${DIM:-}"
  (( pl_build > 0 )) && build_color="${BOLD}${YELLOW}"
  printf "  ${BOLD}📦 Pipeline${NC}   Idea %s ▸ Backlog %s ▸ Build %b%s🔨${NC} ▸ Verify %s ▸ Release %s\n" \
    "$pl_idea" "$pl_backlog" "$build_color" "$pl_build" "$pl_verify" "$pl_release"
  echo ""

  # ── ④ Current Focus · DoD (仅当 Build > 0) ──────────────────────────────
  if [[ -n "$in_prog" && "$pl_build" -gt 0 ]]; then
    local p_id p_desc p_link
    p_id=${in_prog%%|*}
    p_desc=$(echo "$in_prog" | awk -F'|' '{print $2}')
    p_link=$(echo "$in_prog" | awk -F'|' '{print $3}')
    local ac_ratio; ac_ratio=$(_dash_ac_completion "$p_link")
    local ac_done="${ac_ratio%%/*}" ac_total="${ac_ratio##*/}"
    local ac_badge ci_badge
    if [[ "$ac_total" != "0" && "$ac_done" == "$ac_total" ]]; then
      ac_badge="${GREEN}✓ AC${NC}"
    else
      ac_badge="${YELLOW}○ AC ${ac_done}/${ac_total}${NC}"
    fi
    local ci_state; ci_state=$(_dash_ci_status)
    case "$ci_state" in
      success) ci_badge="${GREEN}✓ CI${NC}" ;;
      pending) ci_badge="${YELLOW}… CI${NC}" ;;
      failure) ci_badge="${RED}✗ CI${NC}" ;;
      *)       ci_badge="${YELLOW}○ CI${NC}" ;;
    esac
    printf "  ${BOLD}📊 Current Focus · DoD${NC}\n"
    printf "    🔨 ${BOLD}%s${NC}  %s\n" "$p_id" "$p_desc"
    printf "    [%b]  [%b]\n" "$ac_badge" "$ci_badge"
    printf "    ${YELLOW}其余 4 项 DoD 信号源待接入：see US-AUTO-030/031, IDEA-013/014${NC}\n"
    echo ""
  fi

  # ── ⑤ Human × AI 介入区 ───────────────────────────────────────────────────
  local alerts proposals release_ready
  alerts=$(_dash_alert_count); alerts=${alerts//[^0-9]/}; alerts=${alerts:-0}
  proposals=$(_dash_proposal_count); proposals=${proposals//[^0-9]/}; proposals=${proposals:-0}
  release_ready=false; _dash_release_ready && release_ready=true
  printf "  ${BOLD}👤 需要你介入${NC}\n"
  if (( alerts == 0 )) && (( proposals == 0 )) && ! $release_ready; then
    printf "    ${GREEN}✓ AI 自驱中 — 无需介入${NC}\n"
  else
    (( alerts > 0 )) && printf "    ${RED}⚠ %s ALERT${NC}          run: roll alert\n" "$alerts"
    (( proposals > 0 )) && printf "    ${YELLOW}📋 %s PROPOSAL${NC}      see: .roll/proposals.md\n" "$proposals"
    $release_ready && printf "    ${GREEN}✓ Release ready${NC}    run: roll release\n"
  fi
  echo ""

  # ── ⑥ Schedules & Last Brief ──────────────────────────────────────────────
  printf "  ${BOLD}⏰ Schedules & Last Brief${NC}\n"
  printf "    loop :%02d · dream %02d:%02d · brief %02d:%02d\n" \
    "$loop_minute" "$dream_hour" "$dream_minute" "$brief_hour" "$brief_minute"
  local latest_brief; latest_brief=$(ls .roll/briefs/*.md 2>/dev/null | sort | tail -1 || true)
  if [[ -n "$latest_brief" ]]; then
    local mod_time now age summary
    mod_time=$(_file_mtime "$latest_brief")
    now=$(date +%s); age=$(( (now - mod_time) / 3600 ))
    summary=$(_dash_brief_summary "$latest_brief")
    printf "    Brief ${CYAN}%sh${NC} ago — %s\n" "$age" "${summary:-—}"
  else
    printf "    Brief: ${YELLOW}none yet${NC} — run: roll brief\n"
  fi
  echo ""
}

_home() {
  if [[ "${ROLL_UI:-v2}" == "v2" ]]; then
    python3 "${ROLL_PKG_DIR}/lib/roll-home.py" "$@"
  else
    _legacy_home "$@"
  fi
}

# ═══════════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════════
_legacy_help() {
  echo -e "${CYAN} ██████╗  ██████╗ ██╗     ██╗     ${NC}"
  echo -e "${CYAN} ██╔══██╗██╔═══██╗██║     ██║     ${NC}"
  echo -e "${CYAN} ██████╔╝██║   ██║██║     ██║     ${NC}"
  echo -e "${CYAN} ██╔══██╗██║   ██║██║     ██║     ${NC}"
  echo -e "${CYAN} ██║  ██║╚██████╔╝███████╗███████╗${NC}"
  echo -e "${CYAN} ╚═╝  ╚═╝ ╚═════╝ ╚══════╝╚══════╝${NC}"
  echo ""
  echo -e "  ${BOLD}v${VERSION}${NC} — Roll out features with AI agents"
  echo ""
  echo "Usage: roll <command> [options]"
  echo "用法:  roll <command> [options]"
  echo ""
  echo "Commands:"
  echo "  setup [-f]             [Machine] First-time install or re-sync             首次安装或重新同步"
  echo "  update                 [Upgrade] npm install latest + re-sync             一键升级到最新版"
  echo "  init                   [Project] Create AGENTS.md + .roll/backlog.md + .roll/features/  初始化项目工作流文件"
  echo "  offboard [--confirm]   [Project] Reverse a previous \`roll init --apply\` (dry-run by default)  卸载本项目的 Roll 痕迹"
  echo "  status                 [Diagnostic] Show current state                    显示当前状态"
  echo "  peer                   [Peer Review] Cross-agent negotiation               跨 Agent 协商对审"
  echo "  loop <on|off|now|status|monitor|resume|reset>  [Autonomous] Manage scheduled BACKLOG executor  管理自主执行循环"
  echo "  brief                  [Digest] Show latest owner brief (regenerate if stale)  展示最新简报"
  echo "  backlog                [View] Show pending tasks (Todo/Blocked/Deferred/Unknown)  显示任务清单"
  echo "  backlog block <pat> [reason]  Mark matching items as 🔒 Blocked  标记为已阻塞"
  echo "  backlog defer <pat> [reason]  Mark matching items as ⏸ Deferred  标记为已推迟"
  echo "  backlog unblock <pat>         Restore matching items to 📋 Todo   恢复为待处理"
  echo "  backlog lint                  Check descriptions for path/function/filename violations  检查描述合规"
  echo "  agent [use <name>|list]   [Config] Per-project agent selection            切换项目 agent"
  echo "  ci [--wait]            [CI] Show or wait for current commit's CI status       查看/等待 CI 状态"
  echo "  review-pr <number>     [PR Review] AI-powered code review for a PR           AI 代码评审"
  echo ""
  echo "Examples / 示例:"
  echo "  roll setup                    # New machine: first-time install            新机器首次安装"
  echo "  roll update                   # Upgrade to latest version + re-sync         升级到最新版并重新同步"
  echo "  roll init                     # New or re-merge project (run in project)   新建或重新合并（项目目录）"
  echo "  roll loop on                  # Enable autonomous loop (cron)              启用自主执行循环"
  echo "  roll brief                    # Show latest brief                          查看最新简报"
  echo "  roll backlog                  # Show pending/blocked/deferred items        查看待处理任务"
  echo "  roll backlog defer US-DOC '过早引入'  # Defer all US-DOC-* items          推迟一类任务"
  echo "  roll backlog block US-HW-001 '硬件未到货'  # Block a specific item        标记阻塞"
  echo "  roll agent use kimi           # Switch this project to kimi               切换当前项目到 kimi"

}

_help() {
  if [[ "${ROLL_UI:-v2}" == "v2" ]]; then
    python3 "${ROLL_PKG_DIR}/lib/roll-help.py" "$@"
  else
    _legacy_help "$@"
  fi
}

# ═══════════════════════════════════════════════════════════════════════════════
# _check_structure — US-ONBOARD-004
#
# Refuse to run project commands on legacy directory structure. Pushes users
# toward `roll migrate` rather than letting commands silently operate on old
# paths and produce confusing results.
#
# Exempt commands (always allowed regardless of structure):
#   setup, update, version/--version/-v, help/--help/-h, migrate, doctor
#   "" (no command — shows home/help)
#   init — has its own structure-aware logic inside cmd_init
#
# Detection walks from pwd up to git root (or stays at pwd if not in a git repo).
# Decision: old structure markers present AND no .roll/ → refuse.
#
# Bypass: ROLL_SKIP_STRUCTURE_CHECK=1 (used by integration tests until Story 5
# migrates the test fixtures to new structure).
# ═══════════════════════════════════════════════════════════════════════════════
_check_structure() {
  [[ "${ROLL_SKIP_STRUCTURE_CHECK:-0}" == "1" ]] && return 0

  local cmd="$1"
  case "$cmd" in
    setup|update|migrate|doctor|version|--version|-v|help|--help|-h|"") return 0 ;;
    init) return 0 ;;  # cmd_init handles its own structure logic
    offboard) return 0 ;;  # cmd_offboard does its own changeset check
  esac

  # Determine project root: git root if available, else pwd
  local root
  if root=$(git rev-parse --show-toplevel 2>/dev/null); then
    :
  else
    root="$(pwd -P)"
  fi

  # If new structure exists, allow
  [[ -d "$root/.roll" ]] && return 0

  # US-ONBOARD-019: only treat the directory as a legacy *Roll* project when an
  # old-path marker is present AND a Roll-specific content signature confirms
  # the project was actually onboarded with Roll. Otherwise we'd block any
  # non-Roll repo that happens to ship a BACKLOG.md (Jira export, board dump,
  # different tooling) or a generic docs/features/ folder.
  local _has_old_path=false
  if [[ -f "$root/BACKLOG.md" ]] \
     || [[ -f "$root/PROPOSALS.md" ]] \
     || [[ -d "$root/docs/features" ]] \
     || [[ -d "$root/docs/briefs" ]] \
     || [[ -d "$root/docs/dream" ]]; then
    _has_old_path=true
  fi

  if [[ "$_has_old_path" == "true" ]] && _has_roll_signature "$root"; then
    err "Legacy structure detected at: $root  发现老结构目录"
    echo "" >&2
    echo "  This project uses the pre-2.0 layout (BACKLOG.md / docs/*). Roll 2.0 requires" >&2
    echo "  process artifacts to live in .roll/. Run the migration to upgrade:" >&2
    echo "" >&2
    echo "    roll migrate --dry-run       # Preview changes" >&2
    echo "    roll migrate                 # Execute (single atomic commit)" >&2
    echo "" >&2
    echo "  Migration guide: ${ROLL_PKG_DIR}/guide/en/migration-2.0.md" >&2
    echo "" >&2
    echo "  To roll back to Roll 1.x temporarily:" >&2
    echo "    npm install -g @seanyao/roll@1" >&2
    exit 1
  fi

  # No structure detected — empty project or non-Roll dir. Allow.
  return 0
}

main() {
  local cmd="${1:-}"
  shift || true

  # US-ONBOARD-004: refuse to run project commands on legacy structure
  _check_structure "$cmd"

  case "$cmd" in
    setup)         cmd_setup "$@" ;;
    update)        cmd_update "$@" ;;
    init)          cmd_init "$@" ;;
    offboard)      cmd_offboard "$@" ;;
    migrate)       cmd_migrate "$@" ;;
    status)        cmd_status "$@" ;;
    peer)          cmd_peer "$@" ;;
    loop)          cmd_loop "$@" ;;
    brief)         cmd_brief "$@" ;;
    backlog)       cmd_backlog "$@" ;;
    alert)         cmd_alert "$@" ;;
    agent)         cmd_agent "$@" ;;
    ci)            cmd_ci "$@" ;;
    doctor)        cmd_doctor "$@" ;;
    review-pr)     cmd_review_pr "$@" ;;
    slides)        cmd_slides "$@" ;;
    version|--version|-v) echo "roll v${VERSION}" ;;
    help|--help|-h) _help "$@" ;;
    "") [[ -f ".roll/backlog.md" ]] && _home || { _help; _show_changelog; } ;;
    *)
      err "Unknown command: $cmd  未知命令: $cmd"
      echo ""
      usage
      exit 1
      ;;
  esac
}

# ─── Show recent changelog entries ────────────────────────────────────────────
_show_changelog() {
  local changelog="${ROLL_PKG_DIR}/CHANGELOG.md"
  [[ -f "$changelog" ]] || return 0

  echo -e "${BOLD}Recent Changes  最近更新:${NC}"

  local count=0 in_section=false
  while IFS= read -r line; do
    if [[ "$line" =~ ^##\  ]]; then
      (( ++count > 3 )) && break
      in_section=true
      echo ""
      echo -e "  ${CYAN}${line#\#\# }${NC}"
    elif [[ "$in_section" == true && -n "$line" ]]; then
      echo "    $line"
    fi
  done < "$changelog"
  echo ""
}

# ─── Version check (background, non-blocking, 24h cache) ─────────────────────
_check_update_async() {
  local cache="${ROLL_HOME}/.update-check"
  local now; now=$(date +%s)
  local last=0
  [[ -f "$cache" ]] && last=$(awk '{print $1}' "$cache" 2>/dev/null || echo 0)
  (( now - last < 86400 )) && return

  {
    local latest
    latest=$(curl -sf --max-time 5 \
      "https://api.github.com/repos/seanyao/roll/releases/latest" \
      | grep '"tag_name"' | sed 's/.*"v\([^"]*\)".*/\1/' 2>/dev/null || true)
    echo "$now ${latest:-}" > "$cache"
  } &
  disown
}

_notify_update() {
  local cache="${ROLL_HOME}/.update-check"
  [[ -f "$cache" ]] || return 0
  local latest; latest=$(awk '{print $2}' "$cache" 2>/dev/null || true)
  [[ -z "$latest" || "$latest" == "$VERSION" ]] && return
  local newer; newer=$(printf '%s\n%s\n' "$VERSION" "$latest" | sort -V | tail -1)
  if [[ "$newer" == "$VERSION" ]]; then
    # Running version is newer than cached — stale cache, clear it
    rm -f "$cache"
    return
  fi
  echo ""
  warn "v${latest} available — run 'roll update' to upgrade  有新版本 v${latest} — 运行 'roll update' 升级"
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  _check_update_async
  main "$@"
  _notify_update
fi
