#!/usr/bin/env bash # ============================================================================== # 用途:角色(role)系統的共用函式庫。提供統一 log、啟用判斷、角色解析、 # 睡眠時段判斷、AI 行程偵測、摘要 CLI 選擇與呼叫、記憶目錄鎖。 # 本檔僅供 source,不可直接執行。 # 更新時間:2026/07/28 12:21:11 # 相依:bash;摘要路徑需 README 定義的任一 headless CLI。 # 機密:不 echo 任何 token;角色與記憶內容僅在程序記憶體與檔案間傳遞。 # ============================================================================== ROLE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROLE_STAGE="${ROLE_STAGE:-role}" ROLE_SUPPORTED_CLIS="claude codex agy opencode copilot" ROLE_FALLBACK_MODEL="claude-haiku-4-5-20251001" # ------------------------------------------------------------------------------ # 共用輸出 # ------------------------------------------------------------------------------ role_now() { # 取得台灣時區的 yyyy/MM/dd HH:mm:ss 時間字串 TZ='Asia/Taipei' date +'%Y/%m/%d %H:%M:%S' } role_log() { # 輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr local level="$1" message="$2" stamp stamp="$(role_now)" printf '[%s][%s][%s]: %s\n' "$stamp" "$ROLE_STAGE" "$level" "$message" >&2 if [ -n "${ROLE_ERRLOG:-}" ] && [ "$level" = "ERR" ]; then printf '[%s][%s][%s]: %s\n' "$stamp" "$ROLE_STAGE" "$level" "$message" >> "${ROLE_ERRLOG}" 2>/dev/null fi } role_quit() { # 記錄原因後以 0 結束:hook 絕不可阻斷使用者流程 role_log "${2:-DBG}" "$1" exit 0 } # ------------------------------------------------------------------------------ # 路徑與啟用判斷 # ------------------------------------------------------------------------------ role_home() { # 角色定義目錄(預設 ~/.roles) printf '%s' "${ROLE_HOME:-${HOME}/.roles}" } role_memory_home() { # 記憶根目錄(預設 ~/.memory),實際記憶放在 /<角色>/ printf '%s' "${ROLE_MEMORY_HOME:-${HOME}/.memory}" } role_is_child() { # 判斷本次執行是否來自摘要用的子 CLI 行程,避免 hook 遞迴 [ -n "${ROLE_CHILD:-}" ] || [ -n "${WORKLOG_CHILD:-}" ] } role_resolve_name() { # 角色決定順序:ROLE_NAME 環境變數 → <角色目錄>/.active;皆無則輸出空字串 local name="" active if [ -n "${ROLE_NAME:-}" ]; then name="${ROLE_NAME}" else active="$(role_home)/.active" [ -f "$active" ] && name="$(head -n 1 "$active" 2>/dev/null | tr -d '[:space:]')" fi printf '%s' "$name" } role_file() { # 指定角色的定義檔路徑 printf '%s/%s.md' "$(role_home)" "$1" } role_enabled() { # 總開關:ROLE_ENABLED=0 強制停用;=1 強制啟用;未設定時「有可解析且存在的角色」才啟用 case "${ROLE_ENABLED:-}" in 0|false|no) return 1 ;; 1|true|yes) return 0 ;; esac local name name="$(role_resolve_name)" [ -n "$name" ] && [ -f "$(role_file "$name")" ] } role_in_scope() { # ROLE_SCOPE 為冒號分隔的路徑前綴,未設定則所有目錄都適用 local cwd="$1" scope [ -n "${ROLE_SCOPE:-}" ] || return 0 IFS=':' read -r -a scopes <<< "${ROLE_SCOPE}" for scope in "${scopes[@]}"; do [ -n "$scope" ] || continue case "$cwd" in "${scope%/}"*) return 0 ;; esac done return 1 } # ------------------------------------------------------------------------------ # 睡眠時段 # ------------------------------------------------------------------------------ role_time_to_minutes() { # 把 HH:MM 轉成當日分鐘數;格式不合法時回傳空字串 local value="$1" hour minute case "$value" in [0-9][0-9]:[0-9][0-9]) ;; *) return 1 ;; esac hour="${value%%:*}" minute="${value##*:}" printf '%s' "$((10#${hour} * 60 + 10#${minute}))" } role_sleep_start() { printf '%s' "${ROLE_SLEEP_START:-22:00}"; } role_sleep_end() { printf '%s' "${ROLE_SLEEP_END:-06:00}"; } role_in_sleep_window() { # 判斷現在是否落在睡眠時段(預設 22:00 至隔日 06:00,跨午夜) local start end now start="$(role_time_to_minutes "$(role_sleep_start)")" || return 1 end="$(role_time_to_minutes "$(role_sleep_end)")" || return 1 now="$(role_time_to_minutes "$(TZ='Asia/Taipei' date +'%H:%M')")" || return 1 if [ "$start" -lt "$end" ]; then [ "$now" -ge "$start" ] && [ "$now" -lt "$end" ] else [ "$now" -ge "$start" ] || [ "$now" -lt "$end" ] fi } # ------------------------------------------------------------------------------ # AI 行程偵測(睡眠排程的前置檢查) # ------------------------------------------------------------------------------ role_ai_running() { # 偵測是否有 AI CLI 正在執行;偵測到任何一個即回傳成功(代表「還不能睡」) local cli pid cmd self="$$" for cli in $ROLE_SUPPORTED_CLIS; do for pid in $(pgrep -x "$cli" 2>/dev/null); do [ "$pid" = "$self" ] && continue return 0 done done for pid in $(pgrep -f '(^|/)(claude|codex|agy|opencode|copilot)([[:space:]]|$)' 2>/dev/null); do if [ "$pid" = "$self" ] || [ "$pid" = "$PPID" ]; then continue fi cmd="$(ps -o args= -p "$pid" 2>/dev/null)" case "$cmd" in *role_sleep.sh*|*role_capture.sh*|*role_load.sh*|*pgrep*) continue ;; esac return 0 done return 1 } # ------------------------------------------------------------------------------ # 摘要 CLI 選擇與呼叫 # ------------------------------------------------------------------------------ role_detect_current_cli() { # 判斷實際觸發本次執行的助理環境,避免 auto 因 PATH 順序誤選其他 CLI if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_CI:-}" ] || [ -n "${CODEX_MANAGED_PACKAGE_ROOT:-}" ]; then printf 'codex'; return 0 fi if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || [ -n "${CLAUDE_CODE_SSE_PORT:-}" ]; then printf 'claude'; return 0 fi if [ -n "${AGY_SESSION_ID:-}" ] || [ -n "${AGY_WORKSPACE_ID:-}" ]; then printf 'agy'; return 0 fi if [ -n "${OPENCODE_SESSION_ID:-}" ] || [ -n "${OPENCODE_CONFIG:-}" ]; then printf 'opencode'; return 0 fi if [ -n "${COPILOT_AGENT_ID:-}" ] || [ -n "${GITHUB_COPILOT_TOKEN:-}" ]; then printf 'copilot'; return 0 fi return 0 } role_select_cli() { # 選擇摘要/整理用的 headless CLI;可用 ROLE_CLI 強制指定,預設 auto local requested="${ROLE_CLI:-auto}" cli current if [ "$requested" != "auto" ]; then case " ${ROLE_SUPPORTED_CLIS} " in *" ${requested} "*) ;; *) role_log "WRN" "ROLE_CLI 不支援:${requested}(可用:auto ${ROLE_SUPPORTED_CLIS})"; return 1 ;; esac command -v "$requested" >/dev/null 2>&1 || { role_log "WRN" "找不到 ${requested} CLI"; return 1; } printf '%s' "$requested"; return 0 fi current="$(role_detect_current_cli)" if [ -n "$current" ] && command -v "$current" >/dev/null 2>&1; then printf '%s' "$current"; return 0 fi for cli in $ROLE_SUPPORTED_CLIS; do if command -v "$cli" >/dev/null 2>&1; then printf '%s' "$cli"; return 0 fi done role_log "WRN" "找不到可用 CLI(需要其一:${ROLE_SUPPORTED_CLIS})" return 1 } role_run_cli() { # 呼叫選定 CLI 執行提示詞;子行程一律帶 ROLE_CHILD=1 阻斷 hook 遞迴 local cli="$1" prompt="$2" seconds="${3:-45}" model="${ROLE_MODEL:-}" [ "$cli" = "claude" ] && [ -z "$model" ] && model="$ROLE_FALLBACK_MODEL" case "$cli" in claude) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" claude -p "$prompt" --model "$model" 2>/dev/null ;; codex) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" codex exec "$prompt" 2>/dev/null ;; agy) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" agy -p "$prompt" 2>/dev/null ;; opencode) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" opencode run "$prompt" 2>/dev/null ;; copilot) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" copilot -p "$prompt" 2>/dev/null ;; esac } # ------------------------------------------------------------------------------ # 記憶目錄鎖:避免睡眠整理與對話寫入同時改動同一份記憶 # ------------------------------------------------------------------------------ role_lock_acquire() { # 以 mkdir 取得鎖(原子操作);逾時視為前次殘留鎖並強制接手 local role="$1" lock="$(role_memory_home)/$1/.lock" age mkdir -p "$(dirname "$lock")" 2>/dev/null if mkdir "$lock" 2>/dev/null; then printf '%s' "$$" > "$lock/pid" 2>/dev/null return 0 fi age="$(find "$lock" -maxdepth 0 -mmin +30 2>/dev/null)" if [ -n "$age" ]; then role_log "WRN" "偵測到超過 30 分鐘的殘留鎖,強制接手:${lock}" rm -rf "$lock" 2>/dev/null mkdir "$lock" 2>/dev/null && { printf '%s' "$$" > "$lock/pid" 2>/dev/null; return 0; } fi return 1 } role_lock_release() { # 釋放記憶目錄鎖 rm -rf "$(role_memory_home)/$1/.lock" 2>/dev/null } role_project_name() { # 專案判定:git remote 的 / 優先,其次目錄名 local cwd="$1" origin cleaned owner_repo [ -d "$cwd" ] || { printf '-'; return 0; } local project project="$(basename "$cwd")" if git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then origin="$(git -C "$cwd" remote get-url origin 2>/dev/null)" if [ -n "$origin" ]; then cleaned="${origin%.git}" cleaned="${cleaned##*://}" cleaned="${cleaned#*@}" owner_repo="$(printf '%s' "$cleaned" | awk -F/ 'NF>=2 {print $(NF-1)"/"$NF}')" [ -n "$owner_repo" ] && project="$owner_repo" fi fi printf '%s' "$project" }