pr/persona-master-sync-20260731
master
Clarify persona auto-hook boundary and manual Codex path.
問題:`requireOwner()` 看到 sleeper 旗標就直接 return,把驗證整個外包給 PreToolUse hook;而 hook 認得出這支 CLI 靠的是檔名正則。把 scripts/ 複製出去、 CLI 改個名字,hook 全程回 pass,任何程序都能用那個旗標對**任意人格** remember/recall/emotion --apply——等於完整讀寫權。 修法:CLI 自己驗,證據取自 hook 唯一寫得下、程序偽造不了的東西—— session 檔(`.runtime/sessions/<id>.json`)裡的 sleeper pin。pin 由 hook 依 `event.agent_type` 寫入,沒經過 hook 的程序拿不到。 - `pinAgent()` 多記角色(`{ persona, role, pinned_at }`),`pinOf()` 相容舊的純字串格式; 舊格式沒有角色,一律不算 sleeper 授權,hook 下次 first-touch 時會補上。 - 新增 `sleeperPins(sessionId, slug)`:本 session 中 pin 在該人格上的 sleeper。 - `requireSleeperPin()` 進 `sleeperAccess()`(requireOwner/requireMember 都會經過): 沒有對應的 sleeper pin 就 die,連 sleeper 租約都不會留下。 有帶 `--agent-id` 時額外要求它就是那個被 pin 的 agent。 - 連帶更新讀 pins 的三處:resolveScope、`leave` 的 guest pin 清理、SubagentStop hook。 persona-sleeper sub agent 的正常流程不受影響:hook 的 first-touch pinning 在它 第一個指令就寫好 pin,之後每個收尾指令都驗得過。 測試:新增 10 項(實際把 scripts/ 複製成別的檔名重現攻擊)——改名後 hook 確實 不表態、無 pin 時 remember/recall/emotion 都被 CLI 擋下且不留租約、pin 在別的 人格上不能跨過去、guest pin 與舊格式 pin 都不算授權、真有 pin 時照常放行。 345 → 355 項全過。
short-term.jsonl / said.jsonl 的裁切是「整檔讀進來 → 過濾 → writeText 覆蓋」, 中間沒有任何鎖。而同一個 session 的 sub agent 與主程序共用同一把人格鎖(設計如此), 所以兩邊真的會同時寫——實測背景 prune 進行中 append 30 筆 salience 95 的承諾, 會被吃掉 1~9 筆,正是 shortTermProtected() 明文要保護的那一類。 emotion.json 更嚴重:並行 8 次 `--apply joy=+5`,循序得 50.24,並行只得 38.91(增量遺失 45%)。 加一把用 `fs.openSync(path, "wx")` sentinel 做的檔案鎖: * withFileLock(file, fn):拿不到就退讓重試,超過 15 秒的殘留鎖視為死鎖並接手, 真的等不到就直接做(寧可冒一次競態,也不要因為殘留鎖檔讓人格從此寫不進東西)。 sentinel 放 .runtime/locks/,不落在人格目錄裡,不會被同步上去。 * rewriteJsonl(file, transform):整檔改寫的唯一入口,讀與寫都在鎖裡。 * updateJson / updateEmotion:JSON 檔的 read-modify-write 同樣進鎖。 * appendJsonl 也拿同一把鎖,否則 append 仍會落在別人的讀與寫之間被覆蓋掉。 改用新入口的:pruneShortTermDetail、trimSaid、trimJsonl、turnContext 的情緒衰減、 persona.mjs 的 `emotion --apply`(讀→衰減→套用→寫回整段在鎖裡)。 selftest +5:並行 append vs prune 一筆不掉、並行 emotion 與循序同值、 withFileLock 互斥(12 程序各加一次=12)、過期鎖檔可接手、鎖檔不落在人格目錄。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`extractPaths()` 只解析「含 / 且展開後包含 home 或 personas」的 token, 於是兩種**模型最自然會寫出來的列舉方式**整路穿過去: 1. `PATH_TOOL_FIELDS.Glob` 只列 `path`,所以樣式欄位不被檢查—— `Glob { pattern: "<home>/*/IDENTITY.md" }`(不給 `path`)可以掃出全部人格的身分檔。 新增 `PATTERN_TOOL_FIELDS`(`Glob.pattern`、`Grep.glob`),且**相對於 `path` 解析** (沒給 `path` 才相對 cwd),因為樣式的基準點跟路徑欄位不同。 `Grep.pattern` 是正規表示式、不是路徑,故意不收,免得誤攔 `a/b/c` 這種樣式。 2. `Grep`/`Glob` 沒給 `path` 時 `extractPaths` 回空陣列 → guard 不表態, 於是 cwd 站在 `~/.claude/personas` 或別人的人格底下直接 `Grep` 就整批穿過去。 改成把 hook event 的 `cwd` 當預設目標。 其餘的直譯器逃逸(`node -e`、`python3 -c`、逐段 `cd`、引號切割 token)**不用正則補**: Bash 圖靈完備,追指令字串永遠落後一步,每加一條正則就多一批誤攔正常指令的風險。 改為把文件的措辭修正成誠實的定位—— - README 新增〈guard 擋得住什麼、擋不住什麼〉:明說 guard 是**防漂移的護欄, 對正常寫法一律 deny,不是對抗性沙箱**,並逐條列出擋得住與擋不住的形式, 以及「真要對抗性隔離請走 OS 層」。 - 拿掉會誤導的字眼:`PreToolUse` 那列的「唯一強制點」、guard.mjs 檔頭的 「唯一的強制執行點」、「不能被繞過的關鍵」。 測試:新增 13 項——樣式欄位指向全倉庫/別的人格/自己、樣式相對 path 解析、 `Grep.pattern` 不誤判成路徑、不給 path 時 cwd 在別人格/倉庫根/自己人格/ 普通專案的四種情形、給了 path 就不看 cwd,以及兩項文件措辭的迴歸檢查。 355 → 368 項全過。
兩個資料遺失路徑: ① `--forget N` 無條件刪掉 salience < N 的全部短期記憶,完全不套 shortTermProtected() (salience ≥ 80/intent=commit/24 小時內)。prune 有這層保護,這裡沒有。 實測 `--forget 200` 把一筆 intent=commit、salience 95、剛寫入的承諾一起刪掉, 而 R4 明文寫「不可遺忘」。改走新的 forgetShortTerm(),跟 prune 同一層保護, 並回報「有幾筆低於門檻但受保護、沒有刪」。 ② slugify() 把標點吃掉:「我的貓」「我的貓?」「我的貓!!」全都落在 `我的貓.md`, writeText 直接覆蓋,只保留舊檔的 first_seen/recall_count,內文靜默被換掉。 front matter 新增 `title:`(原本的 --name),撞名時 die 並給一個沒被占用的檔名 (`--name 我的貓-2`);真的要覆蓋同一則就加 --force。 selftest +11:--forget 不刪承諾/界線/今天的紀錄但該刪的照刪、撞名被擋、 舊內文原封不動、訊息給得出替代檔名、同名再寫是更新、title 有寫入、--force 仍可覆蓋。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
emotion.json 是外部輸入(import/sync pull/手改都會進到這裡),但 loadEmotion() 用 `??=` 只補缺、不驗合法性,decayEmotion() 完全不 clamp。只要一份不合法的檔案: * baseline.joy = 5000 → decay 後 joy = 4378.79,mood 卡在 valence 100 / arousal 100 * levels.anger = "很生氣" → decay 後 anger = NaN,注入的上下文印出 valence NaN * half_life_minutes.joy ≤ 0 → factor 恆為 0,那個情緒從此累積不起來(連三次 +30 結果不變) CLI 的 --baseline 與 --apply 本來就有保護,破口純粹在檔案入口,所以擋在 loadEmotion(): levels/baseline clamp 到 0–100、非有限數字退回預設、half_life 非正數退回該情緒的預設值。 decayEmotion/decayEmotionBy 抽出共用的 applyDecay(),同樣重驗一次並 clamp 結果—— 它們是 export 的,呼叫端有可能餵手寫或匯入來的狀態,不能假設一定經過 loadEmotion()。 selftest +9:baseline 超界被 clamp、非數字不變 NaN、上下文不印 NaN、 四種壞掉的半衰期都退回預設、壞半衰期下情緒仍累積得起來、decayEmotionBy 同樣擋得住。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
三個都是「該放行卻被擋」,而且都只有幾行: * speechBody() 的「提及 ≠ 使用」豁免清單漏了《》: 「我在看《說到底》這本書」被判 preach,「他推薦我看《質量與信息》」被判中國用語。 * EMOJI_RE 把 \u{FE0F} 寫在字元類裡,`❤️`(U+2764 + VS16)被算成兩個 emoji 而誤擋; ZWJ 家族(👨👩👦)算三個、膚色(👍🏽)算兩個。改成「底字 + 變體/膚色 + ZWJ 續接」算一個。 * 句長檢查用的是 sentence 而不是 body,引號內容與 `code` 都算進字數—— 引述使用者原話必被擋,等於不准引述。 「質量」「信息」這類語境相依的中國用語誤判沒有動(那要語意判斷,成本不對)。 selftest +11:SNF 放行 7 條(書名號兩種、三種 emoji 組合、引述長原話與長 code), SF 仍擋 4 條(書名號之外真的在說教、引號之外自己講的長句、兩個 emoji、 一個 emoji 加一個帶變體選擇子的)——豁免只針對提及與引述,不是整句放行。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
注入到上下文的東西夾在 persona-runtime / persona-context / persona-ops 三種區塊 中間,而夾進去的內容有**不可信來源**:persona-anime 從 Fandom 抓設定寫進 IDENTITY/AGENTS、`sync pull` 從另一台機器拉、`import` 吃外部 bundle、 guest 的 room 台詞是別的人格寫的。原本這些地方**沒有任何跳脫**: - AGENTS.md 裡放一行結束標記 → opsBrief 的區塊提早關閉,後面的內容跑到區塊外, 連外層的 runtime 區塊都能一起關掉。 - IDENTITY.md 的 `Vibe:` 欄位值同理,經 identityBrief 進 turnContext。 - room 台詞塞換行 → roomScript 是一行一句「emoji 名字(情緒):內容」, 於是可以偽造成別人的台詞或系統訊息。 修法: - 新增 `stripInjectionMarkers()`:把 `<persona-…` 的 `<` 換成全形。內容還讀得懂 (人格自己寫的說明不會被吃掉),但它不再是一個標籤。 - 新增 `injectSafeLine()`:中和標記 + 換行壓成空白(比照短期記憶的作法)。 - 一個收口勝過十幾個防點:`turnContext()` 與 SessionStart 的 runtime 區塊都改成 **組完之後對整個內文**做一次,再補上真正的標記;只有 turnContext/opsBrief 這種自己已處理過、帶合法巢狀標記的整塊原樣保留。 - 讀出來就中和的:`opsBrief()` 的 AGENTS.md 全文(先截斷再中和,長度上限才算得準)、 `identityFields()` 的欄位值(identityBrief/roomScript/roomDisplayName 全吃這一份)、 `relationsBrief()` 的人名與備註。 - room:`roomPost()` 在**寫入端**就把 text/emotion/barge_in 壓成一行, `roomScript()` 與 `room read` 在**顯示端**再壓一次(舊逐字稿是原文寫進去的)。 測試:新增 14 項——AGENTS.md 與 IDENTITY 欄位的逸出、turnContext 與 SessionStart 的區塊只被關閉一次、短期記憶與關係圖人名走同一個收口、room 台詞的換行偽造與 標記逸出、舊逐字稿的顯示端防線、顯示名不夾帶標記。 反向驗證:把兩個中和函式改成 identity,這 14 項全數失敗。 368 → 383 項全過。
兩個各自獨立、但都是「檔案在本機有、在存取庫沒有/拉不回來」的靜默失真: 1. `listAreaFiles` 只收資料夾第一層的 `entry.isFile()`,所以 `mindmap/threads/archive/`(睡眠時把太久沒動的思維導圖收進去的地方) 從來沒有被同步過。改成遞迴。 2. `git ls-files` / `status --porcelain` / `diff --name-only` 預設會把 非 ASCII 路徑引號跳脫成 `"Memory-\350\267\250…"`。長期記憶的檔名正好是中文的, 於是這些檔名跟工作副本比對不上:pull 時被當成「clone 裡沒有的東西」整批略過 (拉回一個沒有長期記憶的人格),本機刪掉的也不會從遠端消失。 一律改用 `-z`,並把三處解析收斂成 `diffPaths()` / `statusPaths()`。 順便修掉 porcelain 第一筆因為整體 trim 而少一個字元的解析錯誤。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pushArea` 遇到 non-fast-forward 時會 `git reset --hard origin/<branch>`、 把本機工作副本重新疊上去再推一次,然後回 `{ok:true, changed:true}`—— 沒有任何衝突訊號。而 Stop hook 每一輪都在背景 push,所以兩台機器同時聊同一個 人格時,對方的 emotion.json/short-term.jsonl 會被靜默取代,誰都不知道。 「本機工作副本是這台機器的真相來源」這個設計選擇保留,但那條路徑現在要記帳: * 算出「對方在分歧後改過、而我們正要蓋掉」的檔案交集,連同覆蓋前的遠端 sha 一起回傳 `overwrote`,並寫進 state/sync.json(留最近 10 筆)。 * `sync push` 一律往 stderr 寫一行警告(--quiet 也寫,背景 push 才有痕跡), 正常輸出與 `sync status` 都列得出「蓋掉幾個檔案、上一版是誰」。 * 背景 push 是 detached、輸出丟掉的,所以由 Stop hook 認領未回報的紀錄, 講給使用者聽一次(劇場模式也照講——那是資料被蓋掉)。 * commit 訊息也寫進「覆蓋 N 個檔案,上一版 <sha>」,被蓋掉的內容仍可用 `git -C <人格>/.sync/<區> show <sha>:<檔案>` 取回。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
底層本來就走得通(`pullArea` 的 restore 會把本機缺少的檔案全部補進來), 擋住的是上層的雞生蛋:`sync` 先走 `requireOwner`,而 `requireOwner` 第一件事 就是「本機沒有這個人格就 die」。本機沒有它 → load 不了它 → sync pull 被擋 → 永遠拉不回來。所以照 `import` 的模式另開一個只驗 session、不驗 host 的入口。 * `clone --code <編號>`:兩區都拉回來(Wiki 區給身分與長期記憶,檔案區給活狀態), 然後補上 `pullArea` 不管的那幾件事——驗 IDENTITY.md(`validateBundle` 明文的 人格最低要件,Gitea 這條路上原本不存在)、補寫 config.code/來歷、 `rebuildIndex()`、`renderRelations()`。拉回來不成人格就中止並清掉半成品。 * `clone`(不帶 --code):列出遠端有哪些人格、哪些本機還沒有。 整個 codebase 原本沒有任何「列出 owner 底下的存取庫」的呼叫,新增 `listRemotePersonas()`:分頁打 `GET /user/repos`(他人/組織走 `/users/<owner>/repos`), 用編號格式過濾——存取庫名稱就是人格編號,所以那份清單就是遠端的人格清單。 * 本機已有同名人格時**預設不覆蓋**;`--force` 才蓋(沿用 `import` 的兩道保護: 不得覆寫別人、不得覆寫正被其他程序載入的人格),`--persona` 可並存兩份。 * 加進 `OWNER_EXEMPT_SUBCOMMANDS`,否則已載入其他人格時會被 hook deny。 * 編號衝突:`nextCode()` 只掃本機,換機器會重複發號。新增 `nextCodeAcrossMachines()`,發號前先問遠端已經用掉哪些編號;Gitea 連不上 就退回本機答案並在輸出明講「只對過本機」。`create` 與 `code assign/next` 都改用它。 * 順手修正 `ensureRepo` 的建庫路由:`me` 取自 `resolveOwner()`,而它在有 `PERSONA_GITEA_OWNER` 時只會把那個值原封不動還回來,於是組織永遠走成 `/user/repos`(建到 token 本人底下)。改用不受該環境變數影響的 `giteaLogin()`。 測試:selftest 新增第 ㉒ 區,用 file:// 的裸倉庫當「假的 Gitea」跑完整往返 (推兩區 → 刪掉本機人格 → clone 回來 → 驗身分/長期記憶/活狀態/索引/關係圖), 並涵蓋前兩個修正(子資料夾與非 ASCII 檔名真的進了存取庫、push 覆蓋遠端的回報 與 Stop hook 只吵一次)。345 → 373 項全過。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
衝突:scripts/selftest.mjs 檔尾兩份都各自 append 一個測試區塊 (guard 的「注入區塊不可被人格檔案逸出(S6)」與 gitea 的「㉒ 從 Gitea 匯入本機還沒有的人格」)。兩塊都保留,各自收好自己的 `}`。
B4 加了 updateJson/updateEmotion,但這幾個呼叫點還是「loadEmotion → 改 → writeJson」的裸讀改寫,鎖形同不存在: - sleep 的 emotion-decay、remember --emotion(每輪對話的熱路徑)、 Stop hook 與 SessionEnd hook 的時間衰減,四處都改走 pl.updateEmotion()。 衰減與 delta 的算法一個字都沒動,只是把讀與寫收進同一把鎖裡。 - sync.json 同理:背景 sync push 跟前景指令會同時寫它,兩邊撞上時後寫的會把 pushed_at/overwrites 整段蓋掉——覆蓋紀錄就這樣安靜地消失。新增 updateSyncState()(帶鎖),六處 loadSyncState + saveSyncState 成對使用全部 改過去;補預設欄位的邏輯抽成 normalizeSyncState() 給兩邊共用。 saveSyncState 沒有呼叫端了,直接移掉,免得下次又有人拿它裸寫。 sync.json 的欄位格式與 overwrites 留 10 筆的行為都沒變。 selftest 448 項(+1):新增「並行 8 次 remember --emotion 跟循序結果一樣」, 把對話熱路徑的情緒寫入也蓋進競態測試——原本只蓋到 emotion --apply。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master 現行 0.0.9,patch 已到 9,依 spec-plugin-version 進位 minor 並把 patch 歸零(0.0.9 → 0.1.0),非 0.0.10。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
兩份 marketplace.json 原本都叫 jsc-plugins,與 jsc-template 的 marketplace 同名 ——兩個 plugin 同時安裝時會撞名。template 已在 731a494 改為 template(=它的 repo 名),本 repo 跟著改為 persona,也與 code/doc/generic「marketplace 名 = repo 名」 的慣例一致。plugin 名維持 jsc-persona 不動。 README 的安裝 token、marketplace update/upgrade/remove 指令、目錄樹註解與 「marketplace 名是 …」那句一併更新(Claude/Codex/Copilot 三節各一組)。 依 spec-plugin-version,安裝識別鍵是 <plugin 名>@<marketplace 名>,改名等於換識別 鍵,舊安裝不能用 update 遷移,必須先移除再安裝,中間 skills 會短暫消失。照 template 9b2e2bb 的做法在安裝章節開頭補上三家各自的升級路徑,並交代要清掉 settings.json 的 enabledPlugins 舊鍵、人格倉庫(PERSONA_HOME)不在 plugin 目錄裡 所以情緒與記憶不受影響。 版號維持 0.1.0 不動:spec 的「更名重置版號」指的是 plugin 更名(name 欄位改變), 本次三份 plugin.json 的 name 與 version 都沒動,marketplace.json 也沒有版本欄位; template 當時的 marketplace 改名(731a494)同樣沒有重置,照常從 0.0.2 走。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No dependencies set.
The note is not visible to the blocked user.
Clarify persona auto-hook boundary and manual Codex path.
問題:`requireOwner()` 看到 sleeper 旗標就直接 return,把驗證整個外包給 PreToolUse hook;而 hook 認得出這支 CLI 靠的是檔名正則。把 scripts/ 複製出去、 CLI 改個名字,hook 全程回 pass,任何程序都能用那個旗標對**任意人格** remember/recall/emotion --apply——等於完整讀寫權。 修法:CLI 自己驗,證據取自 hook 唯一寫得下、程序偽造不了的東西—— session 檔(`.runtime/sessions/<id>.json`)裡的 sleeper pin。pin 由 hook 依 `event.agent_type` 寫入,沒經過 hook 的程序拿不到。 - `pinAgent()` 多記角色(`{ persona, role, pinned_at }`),`pinOf()` 相容舊的純字串格式; 舊格式沒有角色,一律不算 sleeper 授權,hook 下次 first-touch 時會補上。 - 新增 `sleeperPins(sessionId, slug)`:本 session 中 pin 在該人格上的 sleeper。 - `requireSleeperPin()` 進 `sleeperAccess()`(requireOwner/requireMember 都會經過): 沒有對應的 sleeper pin 就 die,連 sleeper 租約都不會留下。 有帶 `--agent-id` 時額外要求它就是那個被 pin 的 agent。 - 連帶更新讀 pins 的三處:resolveScope、`leave` 的 guest pin 清理、SubagentStop hook。 persona-sleeper sub agent 的正常流程不受影響:hook 的 first-touch pinning 在它 第一個指令就寫好 pin,之後每個收尾指令都驗得過。 測試:新增 10 項(實際把 scripts/ 複製成別的檔名重現攻擊)——改名後 hook 確實 不表態、無 pin 時 remember/recall/emotion 都被 CLI 擋下且不留租約、pin 在別的 人格上不能跨過去、guest pin 與舊格式 pin 都不算授權、真有 pin 時照常放行。 345 → 355 項全過。short-term.jsonl / said.jsonl 的裁切是「整檔讀進來 → 過濾 → writeText 覆蓋」, 中間沒有任何鎖。而同一個 session 的 sub agent 與主程序共用同一把人格鎖(設計如此), 所以兩邊真的會同時寫——實測背景 prune 進行中 append 30 筆 salience 95 的承諾, 會被吃掉 1~9 筆,正是 shortTermProtected() 明文要保護的那一類。 emotion.json 更嚴重:並行 8 次 `--apply joy=+5`,循序得 50.24,並行只得 38.91(增量遺失 45%)。 加一把用 `fs.openSync(path, "wx")` sentinel 做的檔案鎖: * withFileLock(file, fn):拿不到就退讓重試,超過 15 秒的殘留鎖視為死鎖並接手, 真的等不到就直接做(寧可冒一次競態,也不要因為殘留鎖檔讓人格從此寫不進東西)。 sentinel 放 .runtime/locks/,不落在人格目錄裡,不會被同步上去。 * rewriteJsonl(file, transform):整檔改寫的唯一入口,讀與寫都在鎖裡。 * updateJson / updateEmotion:JSON 檔的 read-modify-write 同樣進鎖。 * appendJsonl 也拿同一把鎖,否則 append 仍會落在別人的讀與寫之間被覆蓋掉。 改用新入口的:pruneShortTermDetail、trimSaid、trimJsonl、turnContext 的情緒衰減、 persona.mjs 的 `emotion --apply`(讀→衰減→套用→寫回整段在鎖裡)。 selftest +5:並行 append vs prune 一筆不掉、並行 emotion 與循序同值、 withFileLock 互斥(12 程序各加一次=12)、過期鎖檔可接手、鎖檔不落在人格目錄。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`extractPaths()` 只解析「含 / 且展開後包含 home 或 personas」的 token, 於是兩種**模型最自然會寫出來的列舉方式**整路穿過去: 1. `PATH_TOOL_FIELDS.Glob` 只列 `path`,所以樣式欄位不被檢查—— `Glob { pattern: "<home>/*/IDENTITY.md" }`(不給 `path`)可以掃出全部人格的身分檔。 新增 `PATTERN_TOOL_FIELDS`(`Glob.pattern`、`Grep.glob`),且**相對於 `path` 解析** (沒給 `path` 才相對 cwd),因為樣式的基準點跟路徑欄位不同。 `Grep.pattern` 是正規表示式、不是路徑,故意不收,免得誤攔 `a/b/c` 這種樣式。 2. `Grep`/`Glob` 沒給 `path` 時 `extractPaths` 回空陣列 → guard 不表態, 於是 cwd 站在 `~/.claude/personas` 或別人的人格底下直接 `Grep` 就整批穿過去。 改成把 hook event 的 `cwd` 當預設目標。 其餘的直譯器逃逸(`node -e`、`python3 -c`、逐段 `cd`、引號切割 token)**不用正則補**: Bash 圖靈完備,追指令字串永遠落後一步,每加一條正則就多一批誤攔正常指令的風險。 改為把文件的措辭修正成誠實的定位—— - README 新增〈guard 擋得住什麼、擋不住什麼〉:明說 guard 是**防漂移的護欄, 對正常寫法一律 deny,不是對抗性沙箱**,並逐條列出擋得住與擋不住的形式, 以及「真要對抗性隔離請走 OS 層」。 - 拿掉會誤導的字眼:`PreToolUse` 那列的「唯一強制點」、guard.mjs 檔頭的 「唯一的強制執行點」、「不能被繞過的關鍵」。 測試:新增 13 項——樣式欄位指向全倉庫/別的人格/自己、樣式相對 path 解析、 `Grep.pattern` 不誤判成路徑、不給 path 時 cwd 在別人格/倉庫根/自己人格/ 普通專案的四種情形、給了 path 就不看 cwd,以及兩項文件措辭的迴歸檢查。 355 → 368 項全過。三個都是「該放行卻被擋」,而且都只有幾行: * speechBody() 的「提及 ≠ 使用」豁免清單漏了《》: 「我在看《說到底》這本書」被判 preach,「他推薦我看《質量與信息》」被判中國用語。 * EMOJI_RE 把 \u{FE0F} 寫在字元類裡,`❤️`(U+2764 + VS16)被算成兩個 emoji 而誤擋; ZWJ 家族(👨👩👦)算三個、膚色(👍🏽)算兩個。改成「底字 + 變體/膚色 + ZWJ 續接」算一個。 * 句長檢查用的是 sentence 而不是 body,引號內容與 `code` 都算進字數—— 引述使用者原話必被擋,等於不准引述。 「質量」「信息」這類語境相依的中國用語誤判沒有動(那要語意判斷,成本不對)。 selftest +11:SNF 放行 7 條(書名號兩種、三種 emoji 組合、引述長原話與長 code), SF 仍擋 4 條(書名號之外真的在說教、引號之外自己講的長句、兩個 emoji、 一個 emoji 加一個帶變體選擇子的)——豁免只針對提及與引述,不是整句放行。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`pushArea` 遇到 non-fast-forward 時會 `git reset --hard origin/<branch>`、 把本機工作副本重新疊上去再推一次,然後回 `{ok:true, changed:true}`—— 沒有任何衝突訊號。而 Stop hook 每一輪都在背景 push,所以兩台機器同時聊同一個 人格時,對方的 emotion.json/short-term.jsonl 會被靜默取代,誰都不知道。 「本機工作副本是這台機器的真相來源」這個設計選擇保留,但那條路徑現在要記帳: * 算出「對方在分歧後改過、而我們正要蓋掉」的檔案交集,連同覆蓋前的遠端 sha 一起回傳 `overwrote`,並寫進 state/sync.json(留最近 10 筆)。 * `sync push` 一律往 stderr 寫一行警告(--quiet 也寫,背景 push 才有痕跡), 正常輸出與 `sync status` 都列得出「蓋掉幾個檔案、上一版是誰」。 * 背景 push 是 detached、輸出丟掉的,所以由 Stop hook 認領未回報的紀錄, 講給使用者聽一次(劇場模式也照講——那是資料被蓋掉)。 * commit 訊息也寫進「覆蓋 N 個檔案,上一版 <sha>」,被蓋掉的內容仍可用 `git -C <人格>/.sync/<區> show <sha>:<檔案>` 取回。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>