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
+46 -6
View File
@@ -69,9 +69,36 @@ role_resolve_name() {
printf '%s' "$name"
}
# ------------------------------------------------------------------------------
# 角色定義檔:身分(IDENTITY)與人格(SOUL)分離
#
# 新格式把「我是誰」與「我怎麼想」拆開,避免身分設定(來源作品、關係定位)與
# 性格語氣擠在同一段裡:
# <角色目錄>/<ID>.identity.md 角色 ID、顯示名稱、來源、關係定位、簽名 emoji
# <角色目錄>/<ID>.soul.md 本質(nature)、氛圍(vibe
#
# 舊格式為單一 <ID>.md,仍完整支援:解析時新格式優先,找不到才退回舊檔,
# 既有角色不會因升級而失效。可用 role_sleep.sh --migrate <ID> 拆成新格式。
# ------------------------------------------------------------------------------
role_identity_file() { printf '%s/%s.identity.md' "$(role_home)" "$1"; }
role_soul_file() { printf '%s/%s.soul.md' "$(role_home)" "$1"; }
role_legacy_file() { printf '%s/%s.md' "$(role_home)" "$1"; }
role_is_new_format() {
# 只要有 identity 檔就視為新格式(soul 缺失時由呼叫端各自處理)
[ -f "$(role_identity_file "$1")" ]
}
role_file() {
# 指定角色的定義檔路徑
printf '%s/%s.md' "$(role_home)" "$1"
# 角色「主定義檔路徑:新格式回傳 identity,否則回傳舊的單一檔。
# 保留此函式是為了不動既有「檔案存在即代表角色存在」的判斷邏輯。
local id="$1"
if [ -f "$(role_identity_file "$id")" ]; then
role_identity_file "$id"
else
role_legacy_file "$id"
fi
}
role_enabled() {
@@ -336,16 +363,29 @@ role_instance_acquire() {
# 列出可協作的其他角色(排除自己),每行「ID<TAB>顯示名稱<TAB>本質摘要」。
# 角色若不知道有哪些同伴存在,就不會想到派他們協助 —— 這是多人協作能運作的前提。
role_list_peers() {
local self="$1" home file id name nature
local self="$1" home file id name nature soul seen_ids=""
home="$(role_home)"
[ -d "$home" ] || return 0
for file in "$home"/*.md; do
for file in "$home"/*.identity.md "$home"/*.md; do
[ -f "$file" ] || continue
id="$(basename "$file" .md)"
case "$file" in
*.soul.md) continue ;; # soul 不是主定義檔
*.identity.md) id="$(basename "$file" .identity.md)" ;;
*) id="$(basename "$file" .md)"
# 舊檔若已有對應的新格式,避免同一角色列兩次
[ -f "$(role_identity_file "$id")" ] && continue ;;
esac
[ "$id" = "$self" ] && continue
case " ${seen_ids} " in *" ${id} "*) continue ;; esac
seen_ids="${seen_ids} ${id}"
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 時退回讀「## 本質」段落的第一
# 新格式的性格在 soul 檔;frontmatter nature 時退回讀「## 本質」段落
soul="$(role_soul_file "$id")"
if [ -z "$nature" ] && [ -f "$soul" ]; then
nature="$(sed -n 's/^nature:[[:space:]]*//p' "$soul" 2>/dev/null | head -n 1)"
[ -n "$nature" ] || nature="$(sed -n '/^## 本質/,/^## /p' "$soul" 2>/dev/null | sed '1d;/^##/d;/^[[:space:]]*$/d' | head -n 1 | cut -c1-60)"
fi
if [ -z "$nature" ]; then
nature="$(sed -n '/^## 本質/,/^## /p' "$file" 2>/dev/null | sed '1d;/^##/d;/^[[:space:]]*$/d' | head -n 1 | cut -c1-60)"
fi
+31 -5
View File
@@ -110,11 +110,24 @@ fi
# ------------------------------------------------------------------------------
# 非睡眠時段:組出角色人格 + 操作規則 + 記憶
# ------------------------------------------------------------------------------
ROLE_PROFILE="$(node - "$ROLE_DEF" <<'NODE_PROFILE' 2>/dev/null
ROLE_SOUL_FILE=""
if role_is_new_format "$ROLE"; then
ROLE_SOUL_FILE="$(role_soul_file "$ROLE")"
[ -f "$ROLE_SOUL_FILE" ] || role_log "WRN" "新格式缺少人格檔:${ROLE_SOUL_FILE}(本質與氛圍將為空)"
fi
ROLE_PROFILE="$(node - "$ROLE_DEF" "$ROLE_SOUL_FILE" <<'NODE_PROFILE' 2>/dev/null
const fs = require("fs");
// 新格式:第一個參數是 <ID>.identity.md(身分),第二個是 <ID>.soul.md(人格)。
// 舊格式:只有第一個參數,身分與人格都在同一個檔案裡。
const file = process.argv[2];
const soulFile = process.argv[3] || "";
const raw = fs.readFileSync(file, "utf8");
let soulRaw = "";
if (soulFile) {
try { soulRaw = fs.readFileSync(soulFile, "utf8"); } catch { soulRaw = ""; }
}
function parseFrontmatter(text) {
const match = text.match(/^---\n([\s\S]*?)\n---\n?/);
@@ -135,15 +148,28 @@ function section(text, title) {
}
const fm = parseFrontmatter(raw);
const soulFm = soulRaw ? parseFrontmatter(soulRaw) : {};
const title = raw.match(/^#\s+(.+)$/m)?.[1]?.trim() || [fm.name, fm.emoji].filter(Boolean).join(" ");
const nature = section(raw, "本質(nature") || fm.nature || "";
const vibe = section(raw, "氛圍(vibe") || fm.vibe || "";
// 人格優先取自 soul 檔;舊格式(無 soul 檔)則沿用原本從單一檔案抽取的行為
const natureSrc = soulRaw || raw;
const natureFm = soulRaw ? soulFm : fm;
const nature = section(natureSrc, "本質(nature") || natureFm.nature || "";
const vibe = section(natureSrc, "氛圍(vibe") || natureFm.vibe || "";
// 身分只可能在 identity/舊檔裡
const emoji = section(raw, "簽名 emoji") || fm.emoji || "";
const source = section(raw, "來源(source") || fm.source || "";
const relationship = section(raw, "關係定位(relationship") || fm.relationship || "";
const lines = [
`- 角色 ID${fm.id || ""}`,
`- 角色 ID${fm.id || soulFm.id || ""}`,
`- 顯示名稱:${fm.name || title || ""}`,
`- 簽名 emoji${fm.emoji || ""}`,
];
if (source) lines.push("", "## 來源(source", "", source);
if (relationship) lines.push("", "## 關係定位(relationship", "", relationship);
lines.push(
"",
"## 本質(nature",
"",
@@ -156,7 +182,7 @@ const lines = [
"## 簽名 emoji",
"",
emoji || fm.emoji || "(未設定)",
];
);
process.stdout.write(lines.join("\n"));
NODE_PROFILE
+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:-}"