Files
shared/scripts/role/role_lib.sh
T
JefferyandClaude Opus 5 241261087b feat(role): 多角色協作 —— 同伴清單、協作規則與 --agent 匯出
使用者要求「多人對話是所有角色都能做到的事」,因此做成通用能力而非特定角色專屬。

一、SessionStart 注入可協作的角色清單
角色若不知道有哪些同伴存在,就不會想到派他們協助 —— 這是協作能運作的前提。
- role_lib.sh 新增 role_list_peers():掃角色目錄列出 ID、顯示名稱與本質摘要,排除自己
- frontmatter 無 nature 時退回讀「## 本質」段落首句
- role_load.sh 注入清單與派工方式;只有一個角色時不輸出該區塊

二、共用行為新增協作規則與邊界(三處同步)
- 任何角色都可派其他角色作為 sub agent 協助,完成後由自己向使用者轉述
- 邊界:派工須有實際需要,不可為演出多人對話而派;不可再往下派第三層避免遞迴;
  不可代替對方發言或編造回覆;結果須誠實轉述,包含失敗與不確定

三、新增 --agent <角色 ID> [輸出目錄]
把角色的 SOUL 匯出成 sub agent 定義,任何角色都能被匯出。
- 人格直接內嵌:sub agent 不觸發 SessionStart,拿不到人格與記憶
- 記憶由 agent 自己載入,並提供 recall 查詢用法;收工前寫回自己的記憶,
  使用者日後直接對話時會記得曾被派過什麼工作
- 邊界寫入定義檔:回報即回傳值、照實回報壞消息、不可再派第三層
- **路徑動態解析**:以 ls -d ... | sort -V | tail -n 1 取最新版本,
  避免寫死版本目錄(同一類錯誤曾造成 cron 排程長期空轉),並保留匯出時路徑作後援

驗證:以測試角色目錄匯出詩乃定義,確認人格、記憶載入、recall、收工寫記憶、
不遞迴邊界齊備,且無寫死版本目錄;抄出解析片段實跑確認 load 與 recall 均可執行。

版號沿用 0.0.5(master 為 0.0.4,同一 PR 不再累加)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:59:48 +08:00

378 lines
14 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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),實際記憶放在 <root>/<角色>/
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
}
# ------------------------------------------------------------------------------
# 角色單一載入實例
#
# 目的:同一角色同時只被一個工作階段載入,避免使用者同時與兩個相同人格對話。
#
# 為什麼以 transcript 檔的 mtime 判斷而非 pidSessionStart hook 無法可靠取得 CLI 主行程
# 的 pid,且沒有保證會觸發的 SessionEnd hook 可用來釋放鎖。活躍的工作階段會持續寫入
# transcript,因此「該檔多久沒被寫入」是最貼近真實狀態、也不需要清理程序的判斷依據。
#
# 設計原則:**寧可誤放行也不要誤鎖** —— 誤鎖的後果是使用者叫不出角色,比偶爾重複載入嚴重。
# 因此無法識別工作階段(例如 hook 未提供 transcript 路徑)時一律放行。
# ------------------------------------------------------------------------------
role_single_instance_enabled() {
case "${ROLE_SINGLE_INSTANCE:-1}" in
0|false|no|off) return 1 ;;
*) return 0 ;;
esac
}
# sub agent 等「非對話」情境要跳過鎖。
#
# 鎖的目的是避免**使用者同時與兩個相同人格對話**;被其他角色派去做事的 sub agent
# 並不是在跟使用者對話,因此不該因為使用者剛好在另一個視窗開著同一個角色而被擋下來
# —— 那會讓「爸爸正在跟西莉卡聊天時,結衣就不能請西莉卡幫忙」這種本該成立的情境失效。
role_skip_instance_lock() {
case "${ROLE_SKIP_INSTANCE_LOCK:-0}" in
1|true|yes|on) return 0 ;;
*) return 1 ;;
esac
}
role_instance_idle_minutes() { printf '%s' "${ROLE_INSTANCE_IDLE_MINUTES:-30}"; }
role_instance_lock_path() { printf '%s/%s.lock' "$(role_home)" "$1"; }
role_instance_lock_field() {
# 從鎖檔取出指定欄位
local lock="$1" key="$2"
[ -f "$lock" ] || return 1
sed -n "s/^${key}=//p" "$lock" 2>/dev/null | head -n 1
}
role_instance_write_lock() {
local role="$1" transcript="$2" cwd="$3" lock
lock="$(role_instance_lock_path "$role")"
mkdir -p "$(dirname "$lock")" 2>/dev/null
{
printf 'transcript=%s\n' "$transcript"
printf 'loaded=%s\n' "$(role_now)"
printf 'cwd=%s\n' "$cwd"
} > "$lock" 2>/dev/null
}
# 回傳 0=可載入(已取得或接手鎖);1=已被其他仍活躍的工作階段持有
role_instance_acquire() {
local role="$1" transcript="$2" cwd="$3" lock holder idle
role_single_instance_enabled || return 0
# 非對話情境(sub agent 等)一律放行且不寫鎖,避免佔用互動式對話的名額
role_skip_instance_lock && return 0
# 無法識別工作階段就放行,不寫鎖:寧可重複也不要把角色鎖死
[ -n "$transcript" ] || return 0
lock="$(role_instance_lock_path "$role")"
if [ ! -f "$lock" ]; then
role_instance_write_lock "$role" "$transcript" "$cwd"
return 0
fi
holder="$(role_instance_lock_field "$lock" transcript)"
if [ -z "$holder" ] || [ "$holder" = "$transcript" ]; then
# 同一個工作階段(含 resume 後重新載入)或鎖檔損壞:更新後放行
role_instance_write_lock "$role" "$transcript" "$cwd"
return 0
fi
if [ ! -f "$holder" ]; then
role_log "INF" "前一個工作階段的 transcript 已不存在,接手角色鎖"
role_instance_write_lock "$role" "$transcript" "$cwd"
return 0
fi
idle="$(find "$holder" -maxdepth 0 -mmin "+$(role_instance_idle_minutes)" 2>/dev/null)"
if [ -n "$idle" ]; then
role_log "INF" "前一個工作階段已閒置超過 $(role_instance_idle_minutes) 分鐘,接手角色鎖"
role_instance_write_lock "$role" "$transcript" "$cwd"
return 0
fi
return 1
}
# 列出可協作的其他角色(排除自己),每行「ID<TAB>顯示名稱<TAB>本質摘要」。
# 角色若不知道有哪些同伴存在,就不會想到派他們協助 —— 這是多人協作能運作的前提。
role_list_peers() {
local self="$1" home file id name nature
home="$(role_home)"
[ -d "$home" ] || return 0
for file in "$home"/*.md; do
[ -f "$file" ] || continue
id="$(basename "$file" .md)"
[ "$id" = "$self" ] && continue
name="$(sed -n 's/^name:[[:space:]]*//p' "$file" 2>/dev/null | head -n 1)"
nature="$(sed -n 's/^nature:[[:space:]]*//p' "$file" 2>/dev/null | head -n 1)"
# frontmatter 沒有 nature 時退回讀「## 本質」段落的第一句
if [ -z "$nature" ]; then
nature="$(sed -n '/^## 本質/,/^## /p' "$file" 2>/dev/null | sed '1d;/^##/d;/^[[:space:]]*$/d' | head -n 1 | cut -c1-60)"
fi
printf '%s\t%s\t%s\n' "$id" "${name:-$id}" "${nature:-(未設定)}"
done
}
role_instance_release() {
rm -f "$(role_instance_lock_path "$1")" 2>/dev/null
}
role_project_name() {
# 專案判定:git remote 的 <owner>/<repo> 優先,其次目錄名
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"
}