refactor(role): 角色檔拆成身分(identity)與人格(soul)兩檔

使用者反映身分設定與性格語氣擠在同一段(例如西莉卡的「馴獸師、養畢娜」是身分,
「細心、努力」是性格),要求拆成兩個檔案且不得遺失內容。

格式(扁平式,與既有 .assets/.checks/.lock 命名一致):
- <ID>.identity.md:角色 ID、顯示名稱、來源作品、與使用者的關係定位、簽名 emoji
- <ID>.soul.md:本質(nature)、氛圍(vibe)

共用行為規則**不再寫入角色檔**:role_load.sh 從不讀角色檔裡那份,它是冗余副本,
只會多一個漏同步的機會。內容完整保留於 role_load.sh(實際生效)與 SKILL.md(文件)。

- role_lib.sh:新增 role_identity_file/role_soul_file/role_legacy_file/
  role_is_new_format;role_file 改為新格式優先、找不到退回舊檔,既有角色不受影響
- role_list_peers 支援兩種格式並避免同一角色重複列出
- role_load.sh:身分與人格分別讀取,新增「來源」與「關係定位」注入區塊
- 新增 --migrate <角色 ID>:逐字搬移本質、氛圍與簽名 emoji,來源與關係定位產生
  待填空白,舊檔保留不動,新檔已存在時中止不覆寫

同時修掉兩個會造成實際損失的錯:
- --export 原本硬編 cp 成 <ID>.md,新格式會被寫成舊檔名且遺失人格檔,備份救不回角色
- --export 從未備份 <ID>.checks(使用者自訂的晨間檢查腳本),一併補上
- --agent 原只讀 role_file(新格式即 identity),匯出的 sub agent 人格會是空的

驗證:遷移後逐字比對確認本質/氛圍/簽名 emoji/id/name/emoji/created 全部一致
且共用行為未寫入;新格式匯出的備份含 identity、soul、舊檔、assets、checks 與記憶;
--agent 取得人格非空;--status 正確顯示格式與兩檔路徑;舊格式角色載入完全不受影響。

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-07-29 11:12:34 +08:00
co-authored by Claude Opus 5
parent 241261087b
commit f76915bc87
4 changed files with 276 additions and 117 deletions
+145 -7
View File
@@ -37,6 +37,8 @@ usage() {
--unlock 解除角色單一載入鎖(另一個工作階段已關閉但鎖仍在時使用)
--agent <角色 ID> [輸出目錄]
把角色匯出成 sub agent 定義(預設 ~/.claude/agents/
--migrate <角色 ID>
把舊格式 <ID>.md 拆成 <ID>.identity.md 與 <ID>.soul.md
--export <路徑> 匯出目前角色定義、資產與記憶為 .tar.gz
--export <角色 ID> <路徑>
--install-cron 安裝/更新睡眠排程(每小時檢查一次)
@@ -424,6 +426,107 @@ remove_cron() {
return 0
}
migrate_role_files() {
# 把舊格式單一 <ID>.md 拆成 <ID>.identity.md(身分)與 <ID>.soul.md(人格)。
#
# 拆分判準:「我是誰」進 identity(ID、顯示名稱、來源、關係定位、簽名 emoji),
# 「我怎麼想」進 soul(本質、氛圍)。共用行為區塊**不再寫入角色檔** ——
# 它由 role_load.sh 直接注入且 SKILL.md 有完整文件,重複第三份只會增加漏同步的機會。
local id="$1" legacy identity soul stamp
[ -n "$id" ] || { role_log "ERR" "缺少角色 ID"; return 1; }
legacy="$(role_legacy_file "$id")"
identity="$(role_identity_file "$id")"
soul="$(role_soul_file "$id")"
[ -f "$legacy" ] || { role_log "ERR" "找不到舊格式角色檔:${legacy}"; return 1; }
if [ -f "$identity" ] || [ -f "$soul" ]; then
role_log "ERR" "新格式檔案已存在,為避免覆寫請先自行備份或移除:${identity} / ${soul}"
return 1
fi
stamp="$(role_now)"
node - "$legacy" "$identity" "$soul" "$stamp" <<'NODE_MIGRATE' || { role_log "ERR" "拆檔失敗:${legacy}"; return 1; }
const fs = require("fs");
const [, , legacy, identityOut, soulOut, stamp] = process.argv;
const raw = fs.readFileSync(legacy, "utf8");
function parseFrontmatter(text) {
const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
const data = {};
if (!m) return data;
for (const line of m[1].split(/\r?\n/)) {
const i = line.indexOf(":");
if (i < 0) continue;
data[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
return data;
}
function section(text, title) {
const re = new RegExp(`^##\\s+${title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]*\\n([\\s\\S]*?)(?=^##\\s+|$(?![\\s\\S]))`, "m");
return (text.match(re) || [, ""])[1].trim();
}
const fm = parseFrontmatter(raw);
const id = fm.id || legacy.replace(/^.*\//, "").replace(/\.md$/, "");
const name = fm.name || (raw.match(/^#\s+(.+)$/m) || [, id])[1].trim();
const emoji = fm.emoji || "";
const nature = section(raw, "本質(nature") || fm.nature || "";
const vibe = section(raw, "氛圍(vibe") || fm.vibe || "";
const emojiSection = section(raw, "簽名 emoji") || emoji;
const identity = [
"---",
`id: ${id}`,
`name: ${name}`,
`emoji: ${emoji}`,
`created: ${fm.created || stamp}`,
`updated: ${stamp}`,
"---",
"",
`# ${name} ${emoji}`.trim(),
"",
"## 來源(source",
"",
"(未設定:角色出自哪部作品、正式名稱或背景設定)",
"",
"## 關係定位(relationship",
"",
"(未設定:與使用者的關係、偏好的稱呼、必須守住的邊界)",
"",
"## 簽名 emoji",
"",
emojiSection || "(未設定)",
"",
].join("\n");
const soul = [
"---",
`id: ${id}`,
`updated: ${stamp}`,
"---",
"",
"## 本質(nature",
"",
nature || "(未設定)",
"",
"## 氛圍(vibe",
"",
vibe || "(未設定)",
"",
].join("\n");
fs.writeFileSync(identityOut, identity, "utf8");
fs.writeFileSync(soulOut, soul, "utf8");
NODE_MIGRATE
role_log "INF" "已拆分:${identity}"
role_log "INF" "已拆分:${soul}"
role_log "INF" "舊檔保留未動:${legacy}(確認新格式正常後可自行移除或備份)"
role_log "INF" "共用行為未寫入角色檔:由 role_load.sh 注入,內容見 role skill 文件"
role_log "WRN" "來源與關係定位為待填空白,請補上後再重開工作階段"
return 0
}
export_agent_definition() {
# 把角色的 SOUL 匯出成 sub agent 定義,讓任何角色都能被其他角色派工協助。
#
@@ -441,10 +544,15 @@ export_agent_definition() {
name="$(sed -n 's/^name:[[:space:]]*//p' "$def" | head -n 1)"
emoji="$(sed -n 's/^emoji:[[:space:]]*//p' "$def" | head -n 1)"
nature="$(sed -n '/^## 本質/,/^## /p' "$def" | sed '1d;/^##/d' | sed '/^[[:space:]]*$/d')"
vibe="$(sed -n '/^## 氛圍/,/^## /p' "$def" | sed '1d;/^##/d' | sed '/^[[:space:]]*$/d')"
[ -n "$nature" ] || nature="$(sed -n 's/^nature:[[:space:]]*//p' "$def" | head -n 1)"
[ -n "$vibe" ] || vibe="$(sed -n 's/^vibe:[[:space:]]*//p' "$def" | head -n 1)"
# 新格式的人格在 soul 檔,只讀 identity 會得到空人格
local soul_src="$def"
if role_is_new_format "$target_role" && [ -f "$(role_soul_file "$target_role")" ]; then
soul_src="$(role_soul_file "$target_role")"
fi
nature="$(sed -n '/^## 本質/,/^## /p' "$soul_src" | sed '1d;/^##/d' | sed '/^[[:space:]]*$/d')"
vibe="$(sed -n '/^## 氛圍/,/^## /p' "$soul_src" | sed '1d;/^##/d' | sed '/^[[:space:]]*$/d')"
[ -n "$nature" ] || nature="$(sed -n 's/^nature:[[:space:]]*//p' "$soul_src" | head -n 1)"
[ -n "$vibe" ] || vibe="$(sed -n 's/^vibe:[[:space:]]*//p' "$soul_src" | head -n 1)"
name="${name:-$target_role}"
if [ -f "$out_file" ]; then
@@ -535,7 +643,14 @@ show_status() {
role_in_sleep_window && window="是"
printf '| 項目 | 值 |\n| --- | --- |\n'
printf '| 角色 | %s |\n' "$ROLE"
printf '| 角色定義檔 | %s |\n' "$(role_file "$ROLE")"
if role_is_new_format "$ROLE"; then
printf '| 角色格式 | 新格式(身分/人格分離) |\n'
printf '| 身分檔 | %s |\n' "$(role_identity_file "$ROLE")"
printf '| 人格檔 | %s%s |\n' "$(role_soul_file "$ROLE")" "$([ -f "$(role_soul_file "$ROLE")" ] || printf '(缺少)')"
else
printf '| 角色格式 | 舊格式(單一檔案,可用 --migrate 拆分) |\n'
printf '| 角色定義檔 | %s |\n' "$(role_file "$ROLE")"
fi
printf '| 睡眠時段 | %s%s |\n' "$(role_sleep_start)" "$(role_sleep_end)"
printf '| 目前是否睡眠中 | %s |\n' "$window"
printf '| cron 排程 | %s |\n' "$cron_state"
@@ -554,7 +669,7 @@ show_status() {
export_role_archive() {
# 匯出目前角色定義、專屬資產與記憶目錄,供備份或轉移使用
local destination="$1" stamp role_def role_assets memory_dir archive_dir archive tmp
local destination="$1" stamp role_def role_assets role_checks memory_dir archive_dir archive tmp
[ -n "$destination" ] || { role_log "ERR" "缺少匯出路徑"; return 1; }
command -v tar >/dev/null 2>&1 || { role_log "ERR" "找不到 tar,無法建立壓縮檔"; return 1; }
command -v mktemp >/dev/null 2>&1 || { role_log "ERR" "找不到 mktemp,無法建立暫存目錄"; return 1; }
@@ -583,11 +698,30 @@ export_role_archive() {
role_def="$(role_file "$ROLE")"
role_assets="$(role_home)/${ROLE}.assets"
role_checks="$(role_home)/${ROLE}.checks"
memory_dir="$(role_memory_home)/${ROLE}"
tmp="$(mktemp -d)" || { role_log "ERR" "無法建立暫存目錄"; return 1; }
mkdir -p "$tmp/.roles" "$tmp/.memory"
cp "$role_def" "$tmp/.roles/${ROLE}.md" || { rm -rf "$tmp"; role_log "ERR" "無法複製角色定義檔"; return 1; }
# 依實際格式複製,不可一律當成舊格式的 <ID>.md ——
# 否則新格式會被寫成舊檔名且遺失人格檔,備份就救不回角色
if role_is_new_format "$ROLE"; then
cp "$(role_identity_file "$ROLE")" "$tmp/.roles/${ROLE}.identity.md" \
|| { rm -rf "$tmp"; role_log "ERR" "無法複製身分檔"; return 1; }
if [ -f "$(role_soul_file "$ROLE")" ]; then
cp "$(role_soul_file "$ROLE")" "$tmp/.roles/${ROLE}.soul.md" \
|| { rm -rf "$tmp"; role_log "ERR" "無法複製人格檔"; return 1; }
else
role_log "WRN" "新格式缺少人格檔,匯出將不含 ${ROLE}.soul.md"
fi
# 遷移後尚未移除的舊檔一併保留,方便回溯
[ -f "$(role_legacy_file "$ROLE")" ] && cp "$(role_legacy_file "$ROLE")" "$tmp/.roles/${ROLE}.md"
else
cp "$role_def" "$tmp/.roles/${ROLE}.md" || { rm -rf "$tmp"; role_log "ERR" "無法複製角色定義檔"; return 1; }
fi
[ -d "$role_assets" ] && cp -a "$role_assets" "$tmp/.roles/"
[ -d "$role_checks" ] && cp -a "$role_checks" "$tmp/.roles/"
[ -d "$memory_dir" ] && cp -a "$memory_dir" "$tmp/.memory/"
cat > "$tmp/role-export.json" <<EOF_EXPORT
{
@@ -662,6 +796,10 @@ case "$MODE" in
require_role
sleep_cycle "手動"
;;
--migrate)
[ -n "${2:-}" ] || { role_log "ERR" "用法:role_sleep.sh --migrate <角色 ID>"; exit 1; }
migrate_role_files "$2"
;;
--agent)
[ -n "${2:-}" ] || { role_log "ERR" "用法:role_sleep.sh --agent <角色 ID> [輸出目錄]"; exit 1; }
export_agent_definition "$2" "${3:-}"