feat(role): 新增點名載入,對話中叫名字即可切換角色

- `UserPromptSubmit` hook(`role_call.sh`)比對訊息開頭的角色名稱/ID/`aliases` 別名,
  命中即注入該角色人格與記憶並接手本輪;未點名時不輸出內容也不寫檔案。
- 抽出 `role_context.sh` 共用 context 組裝,SessionStart 與點名載入共用同一份人格與操作規則,
  避免兩種載入方式漂移;實測 SessionStart 注入內容與改動前 byte-identical。
- 新增階段角色狀態(`~/.roles/.sessions/<工作階段>.role`),`Stop` hook 改以「本階段實際角色」
  寫記憶,避免點名換人後把跟 A 的對話記進 B 的記憶。
- `SessionEnd` 改為釋放本階段名下所有角色鎖並清掉階段狀態檔。
- 切換時先確認取得目標角色鎖才釋放原角色鎖,避免出現兩個角色都沒有的空窗。
- 睡眠時段、目標角色已被其他活躍階段佔用、點名的是已在場的角色時,一律不切換。
- 新增 `ROLE_CALL_ENABLED`(總開關)與 `ROLE_CALL_MARKER_ONLY`(只認 `@名字`)。
- 順帶修正 `role_capture.sh` 的 `--postcompact` 分支在 `PROJECT` 賦值前就使用它,
  導致壓縮摘要記憶的 `sources` 一直為空。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-07-29 13:38:25 +08:00
co-authored by Claude Opus 5
parent 4a06d770b6
commit 46caabe4d8
9 changed files with 884 additions and 346 deletions
+28 -15
View File
@@ -2,7 +2,7 @@
# ==============================================================================
# 用途:SessionEnd hook 主程式。工作階段結束時**盡力**釋放角色單一載入鎖,讓使用者
# 關掉 CLI 後可以立刻在新階段叫回同一個角色,不必等閒置逾時自然過期。
# 更新時間:2026/07/29 12:55:00
# 更新時間:2026/07/29 13:25:00
# 相依:bash、node(解析 hook 輸入)、同目錄的 role_lib.sh。
# 退出碼:一律 0 —— hook 絕不可阻斷 CLI 結束。
#
@@ -13,6 +13,10 @@
# 為什麼一定要比對 transcript 才釋放:被鎖擋下的第二個工作階段也會觸發 SessionEnd,
# 若無條件刪鎖,它關閉時就會把「仍在使用中」的第一個階段的鎖一起刪掉,等於讓整個
# 單一實例限制形同虛設。只有鎖確實登記在自己名下時才釋放。
#
# 為什麼掃過所有角色的鎖而非只看 ROLE_NAME/.active:使用者可能在對話中點名換過人
# role_call.sh),結束時實際持有的鎖不一定是靜態解析出的那個角色。以 transcript 比對
# 逐一釋放「登記在自己名下」的鎖,才不會把角色鎖留到閒置逾時才過期。
# ==============================================================================
ROLE_STAGE="role-unload"
@@ -21,17 +25,19 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "${SCRIPT_DIR}/role_lib.sh"
role_is_child && exit 0
role_enabled || exit 0
# 不用 role_enabled 判斷:本階段可能是靠點名載入角色(.active 甚至沒設),
# 那種階段仍然持有角色鎖,照 ROLE_NAME/.active 判斷會提早結束而把鎖留到逾時
case "${ROLE_ENABLED:-}" in
0|false|no) exit 0 ;;
esac
[ -d "$(role_home)" ] || exit 0
role_single_instance_enabled || exit 0
# sub agent 等非對話情境本來就不寫鎖,也就沒有鎖要釋放
role_skip_instance_lock && exit 0
command -v node >/dev/null 2>&1 || role_quit "找不到 node,略過角色鎖釋放" "WRN"
ROLE="$(role_resolve_name)"
[ -n "$ROLE" ] || role_quit "未指定角色,略過角色鎖釋放"
# ------------------------------------------------------------------------------
# 讀取 hook 輸入(transcript_pathreason
# 讀取 hook 輸入(transcript_pathsession_idreason
# ------------------------------------------------------------------------------
HOOK_INPUT="$(cat 2>/dev/null)"
[ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過角色鎖釋放"
@@ -46,23 +52,30 @@ process.stdin.on("end", () => {
process.stdout.write([
data.transcript_path || data.session_path || data.conversation_path || data.path || "",
data.reason || "",
data.session_id || data.thread_id || data.conversation_id || "",
].join("\n"));
});
' 2>/dev/null)"
HOOK_TRANSCRIPT="$(printf '%s' "$HOOK_FIELDS" | sed -n '1p')"
HOOK_REASON="$(printf '%s' "$HOOK_FIELDS" | sed -n '2p')"
HOOK_SESSION="$(printf '%s' "$HOOK_FIELDS" | sed -n '3p')"
# 本階段的角色狀態已無意義,先清掉,避免 session_id 被重用時沿用到舊角色
role_session_clear "$(role_session_key "$HOOK_SESSION" "$HOOK_TRANSCRIPT")"
# 無法識別工作階段就不動鎖:寧可讓它照原本的閒置逾時過期,也不要誤刪別人的鎖
[ -n "$HOOK_TRANSCRIPT" ] || role_quit "hook 未提供 transcript 路徑,略過角色鎖釋放"
LOCK_FILE="$(role_instance_lock_path "$ROLE")"
[ -f "$LOCK_FILE" ] || role_quit "角色 ${ROLE} 目前無鎖,無須釋放"
RELEASED=""
for LOCK_FILE in "$(role_home)"/*.lock; do
[ -f "$LOCK_FILE" ] || continue
HOLDER="$(role_instance_lock_field "$LOCK_FILE" transcript)"
[ "$HOLDER" = "$HOOK_TRANSCRIPT" ] || continue
LOCK_ROLE="$(basename "$LOCK_FILE" .lock)"
role_instance_release "$LOCK_ROLE"
RELEASED="${RELEASED:+${RELEASED} }${LOCK_ROLE}"
done
HOLDER="$(role_instance_lock_field "$LOCK_FILE" transcript)"
if [ "$HOLDER" != "$HOOK_TRANSCRIPT" ]; then
role_quit "角色鎖屬於其他工作階段,不釋放(持有者 ${HOLDER:-未知}"
fi
role_instance_release "$ROLE"
role_log "INF" "工作階段結束(原因 ${HOOK_REASON:-未提供}),已釋放角色鎖:${ROLE}"
[ -n "$RELEASED" ] || role_quit "本階段名下沒有角色鎖,無須釋放"
role_log "INF" "工作階段結束(原因 ${HOOK_REASON:-未提供}),已釋放角色鎖:${RELEASED}"
exit 0