This commit is contained in:
2026-07-29 14:37:20 +00:00
parent 9c0973ead4
commit 4426ede979
33 changed files with 5128 additions and 216 deletions
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""PreToolUse guard:人格鎖驗證 + 跨人格資料隔離(唯一的強制執行點)。
擋下的情形:
* 讀寫非「本 session 當前人格」的人格目錄(含 Read/Write/Edit/Glob/Grep/Bash
* guestpersona-guest sub agent)寫入任何人格檔案,或換讀別的人格
* CLI 帶假的 --session(冒用其他程序身分)
* 目標人格的鎖屬於其他還活著的程序
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import persona_lib as pl # noqa: E402
def main() -> int:
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
try:
decision, reason = pl.guard_decide(event)
except Exception as exc: # guard 自己壞掉不該擋住整個 session
print(f"persona guard 內部錯誤:{exc}", file=sys.stderr)
return 0
if decision != "deny":
return 0
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"[jsc-persona 隔離] {reason}",
}
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+74
View File
@@ -0,0 +1,74 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py\"",
"timeout": 20,
"statusMessage": "載入人格執行環境…"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/prompt_submit.py\"",
"timeout": 20,
"statusMessage": "回想記憶與情緒…"
}
]
}
],
"PreToolUse": [
{
"matcher": "Read|Write|Edit|MultiEdit|NotebookEdit|Glob|Grep|LS|Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/guard.py\"",
"timeout": 15,
"statusMessage": "檢查人格隔離…"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/turn_end.py\"",
"timeout": 20
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent_stop.py\"",
"timeout": 15
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/session_end.py\"",
"timeout": 20
}
]
}
]
}
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""UserPromptSubmit:每輪注入「人格 + 情緒 + 記憶 + 關係」上下文,並記錄原始逐字。
短期記憶(memory/short-term.jsonl)由 skill 做完語意分析後才寫入(有主題/實體/
顯著度/情緒變化);這裡只寫 journal 原始逐字,避免同一句話被記兩次。
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import persona_lib as pl # noqa: E402
def main() -> int:
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
session_id = event.get("session_id") or "unknown"
prompt = event.get("prompt") or ""
data = pl.load_session(session_id)
host = data.get("host")
if not host or not pl.persona_exists(host):
return 0
pl.heartbeat_lock(host, session_id)
for guest, info in (data.get("guests") or {}).items():
if pl.persona_exists(guest):
pl.add_guest_lease(guest, session_id, info.get("room", ""), host)
pl.append_jsonl(pl.journal_path(host), {
"ts": pl.iso(), "kind": "utterance", "role": "user", "text": prompt[:4000],
})
context = pl.turn_context(host, session_id, prompt)
hint = (
"\n回覆前請:①做語意分析(意圖/主題/實體/情感)②依十二情緒更新狀態"
" ③以人格語氣回覆 ④用 `persona.py remember` 寫回短期記憶。"
"\n情緒與記憶指令請參考 /jsc-persona:persona-chat。"
)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": context + hint,
},
"suppressOutput": True,
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""SessionEnd:釋放人格鎖與所有 guest 租約,人格才能被下一個程序載入。"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import persona_lib as pl # noqa: E402
def main() -> int:
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
session_id = event.get("session_id") or "unknown"
data = pl.load_session(session_id)
host = data.get("host")
if host and pl.persona_exists(host):
state = pl.decay_emotion(pl.load_emotion(host))
pl.write_json(pl.emotion_path(host), state)
pl.append_jsonl(pl.journal_path(host), {
"ts": pl.iso(), "kind": "session-end", "session": session_id[:8],
"mood": pl.mood(state),
})
released = pl.unbind_session(session_id)
pl.gc_runtime()
out: dict = {"suppressOutput": True}
if released["host"]:
out["systemMessage"] = (
f"[jsc-persona] 已釋放 `{released['host']}` 的載入鎖"
+ (f"guest{released['guests']}" if released["guests"] else "")
)
print(json.dumps(out, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""SessionStart:清理死鎖、續租、並把 PERSONA_SESSION 與人格清單注入上下文。
session_id 只有 hook 拿得到,所以這裡把它注入上下文;後續 skill 呼叫 CLI 時
必須帶 `--session <這個值>`guard hook 會驗證,程序無法冒用別人的身分。
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import persona_lib as pl # noqa: E402
def main() -> int:
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
session_id = event.get("session_id") or "unknown"
source = event.get("source") or "startup"
cwd = event.get("cwd")
pl.gc_runtime()
data = pl.load_session(session_id)
host = data.get("host")
lines = [
"<persona-runtime>",
f"PERSONA_SESSION={session_id}",
f"人格倉庫:{pl.persona_home()}",
"規則:",
" 1. 呼叫 persona.py 一律帶 `--session " + session_id + "`(值不符會被 hook 拒絕)。",
" 2. 一個程序只能載入一個人格;要與別的人格對話請用 /jsc-persona:persona-invite。",
" 3. 禁止直接讀寫非當前人格的目錄,hook 會擋下(跨人格資料隔離)。",
]
if host and pl.persona_exists(host):
# resume / compact / clear:把鎖續租回來,並帶回人格狀態
try:
pl.acquire_lock(host, session_id, cwd=cwd)
lines.append(f"已接續人格 `{host}`session 恢復:{source})。")
lines.append(pl.turn_context(host, session_id))
except pl.LockError as exc:
lines.append(f"⚠ 無法接續人格 `{host}`{exc}")
else:
personas = pl.list_personas()
if personas:
lines.append("尚未載入人格。可用人格:")
for slug in personas:
status = pl.lock_status(slug)
mark = "🔒" if status["locked"] else "🔓"
lines.append(f" - {mark} `{slug}` {pl.identity_brief(slug)}")
lines.append("載入方式:/jsc-persona:persona-chat <slug>")
else:
lines.append("尚無任何人格,可用 /jsc-persona:persona-create 建立。")
lines.append("</persona-runtime>")
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "\n".join(lines),
},
"suppressOutput": True,
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""SubagentStopguest sub agent 結束時解除它的 pin(下次啟動要重新 first-touch)。"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import persona_lib as pl # noqa: E402
def main() -> int:
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
session_id = event.get("session_id") or "unknown"
agent_id = event.get("agent_id")
agent_type = str(event.get("agent_type") or "")
if not agent_id or "persona-guest" not in agent_type:
return 0
data = pl.load_session(session_id)
pins = data.get("pins") or {}
slug = pins.pop(agent_id, None)
if slug is not None:
pl.save_session(session_id, data)
info = (data.get("guests") or {}).get(slug) or {}
if info.get("room"):
pl.add_guest_lease(slug, session_id, info["room"], data.get("host") or "")
print(json.dumps({"suppressOutput": True}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Stop:情緒隨時間衰減、續租、記錄逐字、必要時提醒固化記憶。"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import persona_lib as pl # noqa: E402
def main() -> int:
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
session_id = event.get("session_id") or "unknown"
data = pl.load_session(session_id)
host = data.get("host")
if not host or not pl.persona_exists(host):
return 0
pl.heartbeat_lock(host, session_id)
state = pl.decay_emotion(pl.load_emotion(host))
pl.write_json(pl.emotion_path(host), state)
message = event.get("last_assistant_message") or ""
if message:
pl.append_jsonl(pl.journal_path(host), {
"ts": pl.iso(), "kind": "utterance", "role": "persona", "text": message[:4000],
"mood": pl.mood(state),
})
pending = len(pl.read_jsonl(pl.short_term_path(host)))
out: dict = {"suppressOutput": True}
if pending >= pl.CONSOLIDATE_THRESHOLD:
out["systemMessage"] = (
f"[jsc-persona] `{host}` 的短期記憶已 {pending} 筆,"
"建議執行 /jsc-persona:persona-memory 固化為長期記憶。"
)
print(json.dumps(out, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())