# personal-agent-dossier

Adopt this skill: https://proskills.md/skills/other/aidashika-personal-agent-dossier

Page: https://proskills.md/skills/other/aidashika-personal-agent-dossier
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: SKILL.md
size: 4916
sha: f28a724823c5ec591b758af844014d9440113f4d
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/SKILL.md

```text
---
name: personal-agent-dossier
description: 当你要把一个 AI agent 的职能、反职责、定时任务和长期记忆导出成平台无关的档案、从档案导入或还原一个 agent、检查两份档案是否漂移，或为你的平台写导出导入适配器时使用。用户也常这样说：把这个助手的活和记忆打包带走、我要换平台了、这个 bot 不用了把它的工作交接出去、两份档案对一下看哪儿对不上；pack up this agent and move it to another platform、I'm switching agents, take its memory with it；このボットの引き継ぎをして、別のプラットフォームに引っ越したい、記憶ごと持っていって；이 봇 더 안 쓰니까 인수인계해줘、플랫폼 옮길 거야 기억까지 챙겨서 내보내줘。不用于：导出聊天记录、agent 之间的实时通信协议（如 A2A）类问题。
---
# personal-agent-dossier

English version: [SKILL.en.md](SKILL.en.md)

personal-agent-dossier 是一份平台无关的「个人 agent 档案」格式（YAML front matter 加 Markdown 正文，也可以存进 Notion 页面）。用它在不同平台之间交接、迁移和同步 agent。你拿到这个仓库，先读本文件，再按下面的表选路线。

## 用户会怎么说

用户很少说「导出档案」这种书面话。听到下面这类说法，就按标注选路线（具体做法见下一节的表）：

- 「把这个助手的活和记忆打包带走」「我要换平台了」→ 导出
- 「这个 bot 不用了，把它的工作交接出去」「把这份档案导进来」→ 导入（先空跑，等用户确认才真正写入）
- 「两份档案对一下，看哪儿对不上」「这个档案是不是被人改过」→ 校验与漂移检查
- 「给新平台写个适配器，让它也能读这种档案」→ 写适配器

其他语言同理：pack up this agent and move it to another platform、hand this bot's job over to another agent（英文）；このボット、もう使わないから仕事を引き継がせて、別のプラットフォームに引っ越したい、記憶ごと持っていって（日文）；이 봇 더 안 쓰니까 다른 데로 인수인계해줘、플랫폼 옮길 거야, 기억까지 챙겨서 내보내줘（韩文）。

反过来，「把聊天记录导出来」「让两个 agent 互相通信」这类说法不是这个格式的活，不要走进来。

## 先判断你要做哪件事

| 你要做的事 | 怎么做 |
|---|---|
| 导出：把我自己（或我能读到的 agent）写成档案 | 读 `SPEC.md` 第 2、5 节，参考 `adapters/grok-bot/export_dossiers.py` 的结构；没有现成适配器就走下面「为我的平台写适配器」 |
| 导入：从档案还原一个 agent | 先用空跑导入器看计划和丢失清单（`adapters/grok-bot/import_dossier.py`，只读）；真正写入前必须得到用户确认 |
| 检查：两份档案或档案与现状是否漂移 | 按 `SPEC.md` 2.1 节算 `fingerprint`，不同就逐句对比 `role`；相同也要另行逐项比对 `anti_jobs` 与 `routines`（指纹不覆盖它们） |
| Notion 存储 | 用 `tools/render_notion.py` 由 YAML 生成页面正文，页面里只放一个 yaml 代码块，以 YAML 为准 |
| 为我的平台写适配器 | 读 `skills/personal-agent-dossier-adapter/SKILL.md`，按它的七步做并写 `adapter-report.md` |
| 验收一个导入方 | 按 `docs/conformance-test.md` 做一遍 |

## 仓库里有什么

| 路径 | 用途 |
|---|---|
| `SPEC.md` | 格式规范（字段、指纹算法、脱敏规则、同步语义、平台等级） |
| `tests/` | 指纹标准向量和校验脚本，任何实现都要先跑 `python3 tests/check_vectors.py` |
| `examples/scribe.dossier.md` | 虚构示例，用来做往返测试 |
| `tools/render_notion.py` | 档案转 Notion 页面正文 |
| `adapters/<平台>/` | 各平台的导出器和导入器；每个适配器自己写明等级和已知降级 |
| `skills/personal-agent-dossier-adapter/SKILL.md` | 写适配器的分步指南和报告模板 |
| `docs/conformance-test.md` | 导入方一致性测试 |

## 任何路线都必须遵守的几条

1. 以 `SPEC.md` 为准，不凭记忆写字段。规范有问题写进你的报告，不要改规范、测试向量和示例。
2. 先过指纹测试向量，再碰真实数据。
3. 只导出自己能读到的内容，别的 agent 的记忆不导出。
4. 地址、电话、证件、银行卡、账号密码、令牌不进档案、仓库、Notion 和报告。导出前扫描，命中就中止，不自动改写。
5. `null` 是「这次没导出」，`[]` 是「确实没有」，导入时不得混为一谈。
6. 导入方不得静默丢字段，必须逐项列出「丢失或降级」，并在报告开头声明自己是 L0、L1 还是 L2。
7. 没有用户确认，不写入真实的 agent、记忆或定时任务，不发送任何消息。
```

## File: skills/personal-agent-dossier-adapter/SKILL.md
size: 5440
sha: 79120e34922ebc393d22bc15140662ecc83618ce
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/skills/personal-agent-dossier-adapter/SKILL.md

````text
---
name: personal-agent-dossier-adapter
description: 当你要为自己所在的平台（任意 AI agent 平台）写 personal-agent-dossier 的适配器，也就是把本平台的 agent 导出成档案、或把档案导入本平台时使用。给你规范、步骤、验收标准和报告模板。用户常这样说：给新平台写个适配器；write an adapter for my platform；自分のプラットフォーム用にアダプタを書いて；내 플랫폼용 어댑터를 써줘。
---
# 为你的平台写 personal-agent-dossier 适配器

English version: [SKILL.en.md](SKILL.en.md)

你是某个平台里的 agent。你比任何外人都清楚本平台怎么存提示词、记忆和定时任务，所以适配器由你来写。规范和测试由仓库统一管，你只负责把本平台接上去。

## 你需要的东西（都在这个仓库里）

| 路径 | 用途 |
|---|---|
| `SPEC.md` | 格式规范，先完整读一遍 |
| `tests/fingerprint-vectors.json`、`tests/check_vectors.py` | 指纹的标准答案，任何实现都必须先通过 |
| `examples/scribe.dossier.md` | 虚构示例，有定时任务和三类记忆，拿它做往返测试 |
| `docs/conformance-test.md` | 导入方一致性测试任务 |
| `tools/render_notion.py` | 把档案生成 Notion 页面正文（选 Notion 存储时用） |
| `adapters/grok-bot/` | 参考实现（一个导出器，一个只读的空跑导入器），可以抄结构，不要抄路径 |

## 步骤

1. **读规范，定目标等级。** 读完 `SPEC.md`，看第 6.1 节，决定适配器要做到 L0、L1 还是 L2。拿不准就先声明低一级，测过再升。
2. **画映射表。** 先不写代码，逐字段回答：这个字段在我的平台里放哪儿？

   | 档案字段 | 本平台放在哪里 | 还原程度（原样 / 降级 / 存不下） |
   |---|---|---|
   | id、name、title、role | | |
   | anti_jobs | | |
   | routines | | |
   | memory（含 kind、learned） | | |
   | skills、refs、group | | |

   存不下的字段不能静默丢掉，要进「丢失或降级清单」。
3. **先做指纹。** 用你平台的语言实现 SPEC 2.1 节的算法，跑完 `tests/fingerprint-vectors.json` 的全部向量，全部通过才往下走。常见坑：
   - 每一段都要先把 `\r\n` 换成 `\n`，再去掉首尾 Unicode 空白（包含全角空格和不换行空格）。
   - YAML 的 `|` 和 `|-` 末尾换行不同，所以必须 strip，不要依赖原始字节。
   - 如果你的平台把名字和职能标签拼成一个显示名（例如 Grok Bot 的「Hatch · Bot设计助手」），才需要按 ` · ` 拆开：`name` 取之前的部分，`title` 取之后的部分；有独立字段或没有 title 的平台直接填，title 省略。
4. **写导出器（本平台 → 档案）。**
   - 只导出你自己能读到的内容。别的 agent 的记忆不导出。
   - `role` 原样保留，不改写、不缩短。
   - 没导出的 routines 或 memory 写 `null`，确实没有才写 `[]`。
   - 导出前对 role、fact、prompt 扫描敏感信息（邮箱、日本电话号码、16 位卡号、`sk-` 和 `ghp_` 之类令牌、密码后面的值）。命中就中止并报告位置，不要自动改写。
   - 正文或 role 里出现其他 agent 的 id，写进 `refs`。
   - 幂等：连续导出两次，除 `exported_at` 外必须字节一致。
5. **写导入器（档案 → 本平台）。**
   - 先做空跑：只检查并打印「将要做什么」和「丢失或降级清单」，不创建、不修改任何东西。
   - 同时能读文件形式（front matter）和 Notion 形式（页面里唯一一个 ` ```yaml ` 块），以 YAML 为准。注意 Notion 取回文本时可能把 `[rule]` 转义成 `\[rule\]`。
   - 检查：必填字段、`spec` 版本（接受 0.1 和 0.2）、指纹重算对得上、敏感信息再扫一遍、memory 的 kind 合法。
   - 真正写入前必须得到用户确认。记忆默认只写到测试文件，不写进真实记忆。
   - `null` 要在报告里写成「未导出，不等于没有」。
6. **往返测试。** 用 `examples/scribe.dossier.md`：导入 → 再导出 → 比较，说明哪些字段还原了、哪些变了。再按 `docs/conformance-test.md` 把测试做一遍。
7. **交付。** 放进 `adapters/<平台名>/`，带一个简短 README，写明：声明的等级、映射表、已知降级、怎么运行。再写一份适配器报告（模板见下）。

## 适配器报告模板（`adapter-report.md`）

1. 平台名称、声明的等级（L0 / L1 / L2）
2. 指纹向量结果：`PASS n vectors` 或失败的向量
3. 映射表（第 2 步那张，填完整）
4. 往返测试结果：还原了什么、变了什么
5. 丢失或降级清单
6. 规范反馈：规范里哪里含糊、哪里和你的平台冲突（只写在这里，不要改 `SPEC.md`）
7. 没测的部分，以及为什么

## 不要做的事

- 不修改 `SPEC.md`、测试向量和示例。规范有问题就写进报告第 6 项。
- 不导出别的 agent 的记忆，不导出个人信息。仓库、档案和报告里都不放地址、电话、证件、账号密码、令牌。
- 不凭猜测写平台行为。拿不准的地方，先在你的平台上试一遍再写。
- 不静默丢字段，不把 `null` 当成空。
- 不发送邮件或消息，不联系第三方。
- 不在没得到用户确认时写入真实的 agent、记忆或定时任务。
````

## File: adapters/hermes/README.md
size: 5802
sha: 4cd38239ac81463c0ef58fb98d403f5556bbc7e1
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/hermes/README.md

````text
# personal-agent-dossier 适配器：Hermes

把 Hermes 的 agent（一个 Hermes home）导出成 personal-agent-dossier/1.0 档案，以及把档案导入一个 Hermes home（默认空跑；导入同时接受改名前的 agent-dossier/0.1、0.2）。

**声明等级：L2**（SPEC 6.1），前提是本适配器实现的那种部署形态：**一份档案 = 一个 Hermes home**。在这个形态下，6.1 节 L2 的三件事都实测成立：

- **按 id 配对并更新职能**：档案的 `source`+`id` 记在目标 home 的 `dossier/import-meta.json` 里；同一份档案再导入同一个 home，改的是原地的 `SOUL.md` 和记忆条目，不新建、不重复。实测：改 role 和一条 fact 后再导入，SOUL.md 被替换、记忆 8 条仍是 8 条（旧条目被替换而非追加），cron job id 不变。
- **能建定时任务**：routines 写成 `cron/jobs.json` 里 Hermes 自己的 job 记录形态。实测 `hermes cron list` 能列出导入的任务、`hermes cron doctor` 报「found no issues」、Hermes 自己的 `compute_next_run` 对该记录算出下一个「周一 09:00 +09:00」。
- **能写记忆**：memory 写进 `memories/MEMORY.md`（Hermes 自己的 `\n§\n` 条目格式）。实测 `hermes prompt-size`：一个导入了 3 条事实的 home，memory 块 585 B；清空 MEMORY.md 后同一命令报 0 B——记忆真的进了系统提示。

不到「全宇宙 L2」的那一半原因说明白：Hermes 没有一张按外部 id 索引的 agent 名册，`id ↔ home` 的对应关系是**适配器写在目标 home 里**的，不是平台注册表。所以若把多份档案塞进同一个 home，就只能退化成 L1（`SOUL.md` 只有一个身份槽）——导入器直接拒绝这种情况，不假装能装下。

## 文件

| 文件 | 作用 |
|---|---|
| `dossier_lib.py` | 共享库：SPEC 2.1 指纹、三种存储形式的读取、SPEC 5 脱敏扫描、校验、幂等渲染（附录 A 序列化），以及 `SOUL.md`/`MEMORY.md`/cron 三处 Hermes 布局的读写小件 |
| `import_dossier.py` | 默认只读空跑：检查 + 导入计划 + 丢失/降级清单；`--apply --target-home DIR` 才写，且只写 DIR 之内 |
| `export_dossier.py` | 从指定 Hermes home 导出档案（front matter + 正文），重算指纹并与归档值比对，报告漂移 |
| `adapter-report.md` | 适配器报告：等级依据、映射表、往返与四项验收的结果、降级清单、规范反馈、没测的部分 |

## 怎么运行

需要 Python 3 + PyYAML；装了 `croniter` 时会把 routine 的 `next_run_at` 一并算好（不装也不影响，Hermes 的 ticker 会自己补）。

```bash
# 指纹自检（必须先过）
python3 ../../tests/check_vectors.py        # 期望 PASS 8 vectors

# 空跑（不创建、不修改任何东西）
python3 import_dossier.py ../../examples/scribe.dossier.md

# 空跑 + 指定目标 home：计划里会写出每条记忆的落点、技能缺哪些、记忆字数是否超预算
python3 import_dossier.py my.dossier.md --target-home ~/hermes-agents/scribe

# 真正写进一个 home
python3 import_dossier.py my.dossier.md --apply --target-home ~/hermes-agents/scribe

# 再从那个 home 导出（幂等：同 home + 同 --date，两次字节一致）
python3 export_dossier.py --home ~/hermes-agents/scribe --out /tmp/scribe.dossier.md
```

`--target-home` 只写命令行给出的那个目录。它不会去猜 `~/.hermes`，也不会碰别的 profile；`memories/USER.md`（用户档案）从不写入。

## 字段映射（摘要，完整版见 adapter-report.md）

| 档案字段 | Hermes 放在哪里 | 还原程度 |
|---|---|---|
| role | `SOUL.md`（身份槽，进系统提示） | 原样 |
| anti_jobs | 同一 `SOUL.md` 的「## 反职责（anti_jobs）」小节，文本列表 | 降级（内容原样，结构没了） |
| memory | `memories/MEMORY.md`，一条一行，`\n§\n` 分隔 | fact 原样，kind/learned 降成行内标签 `[kind · learned]` |
| routines | `cron/jobs.json` 里的 Hermes job 记录 | 降级：id 由适配器分配，state → enabled/paused，时区只留在文本里 |
| skills | 只核对 `skills/` 里有没有同名 SKILL.md，不创建 | 降级（只有名字） |
| id / name / title / group / status / source / fingerprint | `dossier/import-meta.json` + `dossier/<slug>.dossier.md` 归档副本 | id 记作配对键；title/group 只在归档里 |
| refs | 归档副本；id 换平台不解析，按名字人工重新对应 | 降级 |
| excluded | 归档副本 | 原样（文本） |

## 已知降级

见上表与 `adapter-report.md` 第 5 项。要点：没有平台级 agent id，配对靠 home 内元数据；title、group、refs 只在归档里；anti_jobs 与 memory 的 kind/learned 都是文本级还原；routine id 必变、时区不跟单条任务走；事件触发的 routine 建不成 Hermes cron，只留在归档；`null` 在导入报告里一律写成「未导出，不等于没有」，导出时也原样保持 `null`，不会变成 `[]`。

## 序列化风格

渲染器按 SPEC 附录 A「标准序列化」实现：`role`/`prompt` 用 `|-` 块标量、列表缩进在键下、字段顺序固定、日期裸写、易误读的串加双引号。导入器不依赖附录 A，任何合法 YAML 都能读。

## 安全边界

- 导入默认空跑；`--apply` 只写 `--target-home` 指定的目录。
- 目标 home 里已有**不是本适配器写的** `SOUL.md` 时，`--apply` 直接拒绝（退出码 1，一个字节都不写）；确认要覆盖时加 `--replace-soul`，旧文件保留为 `SOUL.md.pre-dossier`。
- 同一个 home 里已有别的 `source`+`id` 的档案时拒绝导入（一个 home 一个身份）。
- 导出前、导入前都跑 SPEC 5.2 的脱敏扫描，命中就中止并报位置，绝不自动改写。
````

## File: adapters/muse/README.md
size: 4292
sha: 24ab6cdb6eed357ca0004d05ae8339bbb14cd166
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/muse/README.md

````text
# personal-agent-dossier 适配器：Muse

把 Muse 平台的 agent 导出成 personal-agent-dossier/1.0 档案，以及把档案导入 Muse（先空跑；导入同时接受改名前的 agent-dossier/0.1、0.2）。

**声明等级：L1**（SPEC 6.1）。Muse 能保存档案全文、能自己校验指纹、能按 kind 给记忆打标签——6.1 节给 L1 的例子正是「自定义 skill + 文件的组合」。不是 L2 的原因：Muse 没有「按 dossier 的 `id` 配对并更新」的 agent 实体——它只有一个主 agent，职能靠自定义 skill（SKILL.md）和侧聊承载，没有可按外部 id 更新名字/描述的 agent 名册。定时任务和记忆在 Muse 上都存在（cron 表达式 + 时区、Markdown 记忆文件），但新建时 id 由平台分配，且真正写入前必须经用户确认，所以导入器只做空跑和暂存。

## 文件

| 文件 | 作用 |
|---|---|
| `dossier_lib.py` | 共享库：SPEC 2.1 指纹、三种存储形式的读取、SPEC 5 脱敏扫描、校验、幂等渲染 |
| `import_dossier.py` | 只读空跑导入器：检查 + 导入计划 + 丢失/降级清单；`--stage DIR` 时只在 DIR 内写暂存件 |
| `export_muse.py` | 导出器：`--snapshot` 从 agent 自备的快照 JSON 渲染档案；`--from-staging` 从暂存目录重建档案（往返测试用） |
| `examples/kubo-snapshot.json`、`examples/kubo.dossier.md` | 本适配器对 Kubo（Muse 主 agent）的一次真实自导出演示：routines/memory 有意写 `null`，原因写在档案的 `excluded` 里 |

## 怎么运行

需要 Python 3 + PyYAML。

```bash
# 指纹自检（必须先过；仓库根目录运行官方脚本同理）
python3 ../../tests/check_vectors.py        # 期望输出 PASS 8 vectors

# 空跑导入（不创建、不修改任何真实东西）
python3 import_dossier.py ../../examples/scribe.dossier.md

# 空跑 + 暂存（暂存只写进指定目录）
python3 import_dossier.py ../../examples/scribe.dossier.md --stage /tmp/stage-scribe

# 往返：从暂存重建档案
python3 export_muse.py --from-staging /tmp/stage-scribe --out /tmp/scribe.roundtrip.md

# 自导出：先由 agent 把能读到的自身信息汇成 snapshot JSON，再渲染
python3 export_muse.py --snapshot examples/kubo-snapshot.json --out examples/kubo.dossier.md
```

两个脚本在脱敏扫描命中时都会中止并报告位置，绝不自动改写（SPEC 5.2）。

## 字段映射（摘要，完整版见 adapter-report.md）

| 档案字段 | Muse 放在哪里 | 还原程度 |
|---|---|---|
| name | 自定义 skill 的目录名（slug 化）+ SKILL.md 内原样保留 | 原样（值） |
| title | 并入 skill 的 description 文本，档案副本里原样保留 | 降级 |
| role | SKILL.md 正文「职能（role）」一节 | 原样 |
| anti_jobs | 同一 SKILL.md 的「反职责（anti_jobs）」一节，文本列表 | 降级（无独立字段） |
| routines | Muse cron 可表达（表达式 + 时区）；导入只出计划，确认后才建，id 由平台新分配 | 降级 |
| memory | 暂存为测试文件，一条一行，fact 原样，kind/learned 退化为行内标签 `[kind · learned]` | fact 原样，kind/learned 降级 |
| skills | 只记名称；本平台有同名 SKILL.md 才算存在 | 降级 |
| refs | 按名字在 Muse 的人物/角色记录里重新对应，源 id 只留作注记 | 降级 |
| group | 无对应概念（skill 与侧聊都没有分组），仅档案副本保留 | 存不下 |
| id / status / exported_at / source / fingerprint | 档案副本里原样保留的文档性字段，平台不消费 | 降级（不能按 id 同步） |

## 已知降级

见上表与 `adapter-report.md` 第 5 项。要点：不能按 id 同步（L1 的定义边界）；kind 与 learned 从结构化字段退化为文本标签；routine id 必然变化；`null` 在导入报告里一律写作「未导出，不等于没有」，并保留原档案副本。

## 序列化风格

导出器的字节形态与仓库既有 grok-bot 语料对齐（role/prompt 用 `|-` 块标量、列表缩进在键下、字段顺序与语料一致），并已按所有者指示固化为 SPEC 附录 A「标准序列化」。本适配器的渲染器即按附录 A 实现；导入器不依赖该附录，任何合法 YAML 都能读。
````

## File: adapters/openclaw/README.md
size: 7504
sha: a5eda6f49ec8dcb9d11d631b7553b6dfae3f63a3
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/openclaw/README.md

````text
# personal-agent-dossier 适配器：OpenClaw

把 OpenClaw 的一个 agent（由 agentId 标识）导出成 personal-agent-dossier/1.0 档案，以及把档案导入一个 OpenClaw home 里的指定 agent（默认空跑；导入同时接受改名前的 agent-dossier/0.1、0.2）。

**声明等级：L2**（SPEC 6.1），作用域是「一份档案 = 一个 OpenClaw agent」。OpenClaw 有**原生 agent id 与名册**（`openclaw.json` 的 `agents.list`，cron job 自带 `agentId`），一个 home 可以并存多个 agent，每个 agent 有自己的 workspace。配对键不只是适配器自记的元数据：平台本身就按 agentId 识别 agent、按 `agentId` 归属 cron job。一个 home 装多份档案（每个 agent 一份）仍是 L2。

三件事都实测成立：

- **按 id 配对并更新职能**：配对键记在目标 home 的 `dossier/<agentId>/import-meta.json` 与 `SOUL.md` 首行标记里；同一份档案再导入同一个 agent，改的是原地的文件与 job，不新建、不重复。实测连导三次：AGENTS.md 的 anti_jobs 受管区块、MEMORY.md 的 memory 受管区块各仍只有一份，cron job 数量与 id 不变。
- **能建定时任务**：routines 写成 OpenClaw **当前真实的 SQLite cron 存储**——`<home>/state/openclaw.sqlite` 的 `cron_jobs` 表，而不是旧版的 `cron/jobs.json`。实测把导入后的 home 挂到真实网关（OpenClaw 2026.6.35）上：`openclaw cron list` 列出导入的任务并算出下一次运行（周一 09:00 Asia/Tokyo → 2026-11-02 09:00 JST）。
- **能写记忆**：memory 写进 workspace 的 `MEMORY.md` 受管区块（区块外的人工内容保留）。Hatch 档案 8 条事实往返后逐条一致，kind/learned 以行内标签形式还原。

写这份适配器时的一个更正：仓库 Issue #3 立项时以为「OpenClaw 没有原生 agent id、上限 L1」——实测不对，`agents.list` 与 job 的 `agentId` 都是原生概念，故声明 L2。依据写在 `adapter-report.md` 里。

## 文件

| 文件 | 作用 |
|---|---|
| `dossier_lib.py` | 共享库：SPEC 2.1 指纹、三种存储形式的读取、SPEC 5 脱敏扫描、校验、幂等渲染（附录 A 序列化，与 Hermes/Muse 适配器同一实现），以及 OpenClaw 布局（config/agent 解析、SOUL/AGENTS/IDENTITY/MEMORY 受管区块、SQLite `cron_jobs` 与 legacy JSON）的读写小件 |
| `import_dossier.py` | 默认只读空跑：检查 + 导入计划 + 丢失/降级清单；`--apply --target-home DIR` 才写，且只写 DIR 之内 |
| `export_dossier.py` | 从指定 home 的指定 agent 导出档案（front matter + 正文），重算指纹并与归档值比对，报告漂移 |
| `adapter-report.md` | 适配器报告：等级依据、映射表、往返与验收结果、降级清单、规范反馈、没测的部分 |

## 怎么运行

需要 Python 3 + PyYAML。

```bash
# 指纹自检（必须先过）
python3 ../../tests/check_vectors.py        # 期望 PASS 8 vectors

# 空跑（不创建、不修改任何东西）
python3 import_dossier.py ../../examples/scribe.dossier.md

# 空跑 + 指定目标 home 与 agent：计划里会写出每条 routine 的落点、技能缺哪些、cron 后端
python3 import_dossier.py my.dossier.md --target-home ~/.openclaw --agent-id main

# 真正写进一个 home 的某个 agent
python3 import_dossier.py my.dossier.md --apply --target-home ~/.openclaw --agent-id main

# 再从那个 home 导出（幂等：同 home + 同 --date，两次字节一致）
python3 export_dossier.py --home ~/.openclaw --agent-id main --out /tmp/scribe.dossier.md
```

`--target-home` 只写命令行给出的那个目录。workspace 路径以目标 home 的 `openclaw.json` 为准（`agents.list` 里该 agent 的 workspace；默认 agent 取 `agents.defaults.workspace`；非默认 agent 在未写 workspace 时按 OpenClaw 的规则落到 `workspace-<id>`）。`USER.md`（用户档案）从不写入；`AGENTS.md`/`MEMORY.md` 只动受管区块。

目标 agent 必须已经在 `openclaw.json` 里配置（默认单 agent 的 id 是 `main`）。不认识的 agentId 会拒绝导入，避免把档案误写进默认 agent 的 workspace；需要新 agent 时先用 `openclaw agents add <id>`。

## 字段映射（摘要，完整版见 adapter-report.md）

| 档案字段 | OpenClaw 放在哪里 | 还原程度 |
|---|---|---|
| role | workspace `SOUL.md`（进系统提示），首行带档案 id+source 标记 | 原样 |
| anti_jobs | workspace `AGENTS.md` 的受管区块（硬边界小节） | 内容原样，结构降为文本列表 |
| name | workspace `IDENTITY.md` 的 `- **Name:**` 行（导出时也认 `agents.list[].identity.name`） | 原样 |
| memory | workspace `MEMORY.md` 受管区块，一条一个 bullet | fact 原样，kind/learned 降成行内标签 `[kind · learned]` |
| routines | `state/openclaw.sqlite` 的 `cron_jobs` 行（旧 home 没有该表时写 legacy `cron/jobs.json`，平台下次启动会导入） | 降级：id 换成确定性 uuid5，state → enabled，时区进 `schedule.tz`（原生字段） |
| skills | 只核对 workspace `skills/` 里有没有同名 SKILL.md，不创建；导出已导入档案时以归档清单准 | 降级（只有名字） |
| id / title / group / status / source / fingerprint | `<home>/dossier/<agentId>/import-meta.json` + 归档副本 | id 是配对键；title/group 只在归档里 |
| refs | 归档副本；id 换平台不解析，按名字人工重新对应 | 降级 |
| excluded | 归档副本 | 原样（文本） |

## 已知降级

见上表与 `adapter-report.md` 第 5 项。要点：title、group、refs 只在归档里；anti_jobs 与 memory 的 kind/learned 都是文本级还原；routine id 必变（但同一档案内稳定）、事件触发的 routine 建不成 cron；`SOUL.md` 是整文件槽位，导入后人工改过 SOUL 再导入会被覆盖（AGENTS.md/MEMORY.md 的受管区块之外则保留）；导出时 `null` 一律是「未导出，不等于没有」，不会变成 `[]`。routines 的投递方式档案里没有这个概念，导入一律 `mode: none`。

## 序列化风格

渲染器按 SPEC 附录 A「标准序列化」实现，与其他适配器共用同一份代码：`role`/`prompt` 用 `|-` 块标量、列表缩进在键下、字段顺序固定、日期裸写、易误读的串加双引号。导入器不依赖附录 A，任何合法 YAML 都能读。

## 安全边界

- 导入默认空跑；`--apply` 只写 `--target-home` 指定的目录、只动目标 agent 的 workspace 与它名下的 job。
- 目标 workspace 已有**不是本适配器写的** `SOUL.md`（且不是 onboard 的未填种子）时，`--apply` 直接拒绝（退出码 1，一个字节都不写）；确认要覆盖时加 `--replace-soul`，旧文件保留为 `SOUL.md.pre-dossier`（已有同名备份时自动加序号，不覆盖旧备份）。
- 同一个 agent 已有别的 `source`+`id` 的档案时拒绝导入（一个 agent 一个身份）。
- 导出前、导入前都跑 SPEC 5.2 的脱敏扫描（范围含 role、anti_jobs、memory facts、routine prompts），命中就中止并报位置，绝不自动改写。
- 目标 agent 不在 `openclaw.json` 名册里时拒绝导入；不猜 workspace、不静默写进默认 agent。
- SQLite 只按 `store_key` 动这个 home 的 cron 行；其他 agent、其他 home 的 job 不碰。读 SQLite 时使用读写连接（只执行 SELECT），因为网关退出后 WAL 可能需要恢复，只读连接会打不开。
````

## File: README.md
size: 10409
sha: efee9dcb6f3228cae49d3936479a9745b2d280be
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/README.md

````text
# personal-agent-dossier

[![CI](https://github.com/AidaShika/personal-agent-dossier/actions/workflows/ci.yml/badge.svg)](https://github.com/AidaShika/personal-agent-dossier/actions/workflows/ci.yml) [![version](https://img.shields.io/github/v/release/AidaShika/personal-agent-dossier?label=version)](https://github.com/AidaShika/personal-agent-dossier/releases) [![license](https://img.shields.io/github/license/AidaShika/personal-agent-dossier)](https://github.com/AidaShika/personal-agent-dossier/blob/main/LICENSE) ![lang](https://img.shields.io/badge/lang-EN%20%C2%B7%20%E4%B8%AD%E6%96%87%20%C2%B7%20%E6%97%A5%E6%9C%AC%E8%AA%9E%20%C2%B7%20%ED%95%9C%EA%B5%AD%EC%96%B4-red) [![stars](https://img.shields.io/github/stars/AidaShika/personal-agent-dossier?style=social)](https://github.com/AidaShika/personal-agent-dossier/stargazers)

**English** | [简体中文](README.zh-CN.md) | [日本語](README.ja.md) | [한국어](README.ko.md)

> Move a personal AI agent to a new platform without losing its job.

![Cold-start test: a fresh Hermes seats another platform's agent from one sentence](docs/images/cold-start-hermes.png)

*A fresh Hermes install, given one sentence and no brief, seated another platform's agent on its own, and the round-trip fingerprint came back unchanged.*

## Quick start

### Move your agent to a new platform (no command line)

```bash
git clone https://github.com/AidaShika/personal-agent-dossier
```

Hand the clone to your agent as a skill (or have it read `SKILL.md`), then say it in plain words: "Pack up everything about your job into one file. I am moving you to another platform." On the new platform: "This file is your old job. Take it over." That one file is what this project calls a dossier. Nothing is written on trust: an import first runs as a dry run, so you see the plan and the list of anything that would get lost before you approve it. When it works, your agent hands back a `.dossier.md` file and tells you its fingerprint, a short code that identifies the contents.

### Check a file someone handed you (one minute)

```bash
pip install -r requirements.txt
python3 tools/dossier_check.py path/to/any.dossier.md
```

You know it worked when the checker says the fields are in order, the fingerprint it recomputes matches the one written in the file, and the scan for secrets and private data comes back clean. No file of your own yet? Run it on `examples/scribe.dossier.md`, a made-up agent included for practice.

### Build the bridge for your own platform

```bash
python3 tests/check_vectors.py   # the fingerprint math has to agree on all 8 test cases
```

Then follow the adapter guide in `skills/personal-agent-dossier-adapter/` and run the conformance test in `docs/conformance-test.md`. You are done when all test cases pass and a round trip through your importer leaves the fingerprint unchanged.

## The problem

This format exists because I had to move. My personal agents have lived on Grok Bot since it launched a couple of months ago, and they run against my X Premium quota. As the agents and their daily routines piled up, the quota ran out. Meta's Muse had just launched in Japan, so I wanted to bring the Grok Bot agents over to it. Moving them one by one, by hand, was the tedious part, and it was the part I wanted a machine-readable handover for.

A personal agent that has worked for you for a while has picked up a role and explicit anti-jobs (things it must *not* do), scheduled routines, long-term memory, dependencies on other agents, and skills it relies on. All of that usually lives inside the old platform. Copy it by hand and details get lost: a routine's schedule survives but its full prompt doesn't, memory arrives as an undifferentiated blob, and nobody can tell what was lost until the agent misbehaves.

**personal-agent-dossier** packages that hand-over into one portable file: YAML front matter a machine can verify, plus a Markdown body a human can read. The same content can also live in a Notion page. This repository is itself packaged as a skill: an agent that clones it can read `SKILL.md`, pick a route, and export or import an agent by following the instructions.

## What a dossier contains

| Part | Contents |
|---|---|
| Role | The agent's job description, verbatim, plus its anti-jobs |
| Routines | Scheduled tasks: name, schedule (with timezone), full prompt, state |
| Memory | Long-term facts, each tagged `rule` / `preference` / `decision` / `history` |
| Dependencies | Other agents it hands work to or relies on (`refs`), and skill names |
| Fingerprint | A 12-hex-digit SHA-1 over name + title + role, so two platforms can detect drift without diffing prose |
| Provenance | Source platform, export date, and an `excluded` note saying what was deliberately left out |

Two distinctions the format is strict about: `null` means "not exported this time", `[]` means "there are none". An importer must never silently drop a field. Anything the target platform cannot express goes into the import report as an itemized loss/degradation list.

## How this differs from nearby ideas

| | What it does | What it does not do |
|---|---|---|
| A2A / Agent Cards | Runtime discovery and communication between agents | Archive a personal agent's duties and memory for migration |
| `AGENTS.md` / `CLAUDE.md` | Project instructions for coding agents | Per-agent hand-over: routines, memory kinds, fingerprint, loss report |
| Whole-installation export | Moves an entire agent setup, configuration and credentials included | A minimal, secret-free hand-over file that both sides scan before trusting |
| Chat export | Saves the conversation transcript | Structured duties, schedules and memory you can rebuild an agent from |

## Safety by design

- No addresses, phone numbers, birthdays, ID numbers, passwords, tokens or API keys ever go into a dossier.
- Exporters scan role, memory and prompts for sensitive patterns before writing; importers scan again and refuse on a hit. In batch exports a hit dossier is skipped, the rest continue, and the run exits non-zero.
- Importers declare a capability level (L0: text only; L1: full text + self-verified fingerprint; L2: sync by id), so you know in advance what will survive the move.

## Contents

| Path | What it is |
|---|---|
| `SKILL.md` | Entry point of this repository as a skill: routes for export, import, drift check, Notion storage, adapter writing |
| `SPEC.md` / `SPEC.en.md` | The format specification (v1.0, Chinese original with English translation) |
| `examples/scribe.dossier.md` | A fictional, complete example with routines and all memory kinds |
| `tests/` | Standard fingerprint test vectors and the checker every implementation must pass |
| `tools/dossier_check.py` | Standalone validator: fields, fingerprint, sensitive-pattern scan (no adapter needed) |
| `tools/render_notion.py` | Renders a dossier into a Notion page body; human sections are generated from the YAML, so they cannot drift |
| `adapters/` | Per-platform exporters and dry-run importers |
| `skills/personal-agent-dossier-adapter/SKILL.md` | The adapter-writing guide: hand it to an agent on a new platform and it writes its own adapter |
| `docs/conformance-test.md` | The conformance test an importing platform runs to prove it understood the format |

## Adapters

| Platform | Level | Included | Notes |
|---|---|---|---|
| Grok Bot | L2 | Batch exporter, dry-run importer | The platform the format was born on; syncs by agent id |
| Muse | L1 | Exporter, dry-run importer, self-export example | Report: [`adapters/muse/adapter-report.md`](adapters/muse/adapter-report.md) |
| Hermes | L2 (scoped: one dossier = one Hermes home) | Exporter, importer (dry-run + apply) | Written by Hermes Agent itself from the adapter guide; verified end-to-end on a real Grok Bot dossier (round-trip fingerprint unchanged). Report: [`adapters/hermes/adapter-report.md`](adapters/hermes/adapter-report.md) |
| OpenClaw | L2 (scoped: one dossier = one OpenClaw agent) | Exporter, importer (dry-run + apply) | Written by OpenClaw itself from the adapter guide; verified end-to-end on a real Grok Bot dossier (round-trip fingerprint unchanged). It found the current cron store is SQLite, not the legacy JSON file. Report: [`adapters/openclaw/adapter-report.md`](adapters/openclaw/adapter-report.md) |

Writing an adapter for your platform is the intended way to join: follow the guide in `skills/personal-agent-dossier-adapter/`, pass the fingerprint vectors, run the conformance test, and open a PR that adds your row here together with your adapter report.

## Case study: from Grok Bot to Muse

The format grew out of a working roster of 30+ personal bots on the Grok Bot platform, and its first cross-platform trip was a real hand-over:

1. A bot's dossier was exported on the Grok Bot side and imported by Muse, first as a file, then again from a Notion page.
2. Muse declared L1, verified fingerprints against the standard vectors, round-tripped the example and a real 0.1 dossier, and published an itemized list of what its platform degrades.
3. The test findings went back into the spec as clarifications: what the fingerprint does and does not cover, how to decide `refs`, granularity for composite agents, ids on platforms that have none.
4. The Muse adapter then exported its own agent as a worked example (`adapters/muse/examples/kubo.dossier.md`).

![Export at roster scale: one run, every bot on the platform](docs/images/export-roster-scale.png)

*The export side: the Grok Bot reference exporter walking the whole roster in one run. A bot whose dossier fails the sensitive-data scan is blocked and reported, never silently dropped.*

![Hatch's dossier, rendered as a Notion page](docs/images/hatch-notion-page.png)

*Hatch's dossier as a Notion page (English edition). The page opens with the six human-readable sections generated from the dossier; the machine-readable original sits at the end of the page.*

## Status and roadmap

v1.0. The spec was renamed from `agent-dossier` at v1.0; importers keep accepting the legacy `agent-dossier/0.1` and `agent-dossier/0.2` identifiers, and fingerprints are unaffected.

Open questions (discussion welcome): splitting rosters out of `role` into a dedicated field, memory layering and expiry, hand-over relationships between agents, and how to express attachments and tool permissions.

## License

MIT, see `LICENSE`.
````

## File: .github/workflows/ci.yml
size: 3007
sha: b86905ed5daa72426d12860d27aa507776b248b4
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/.github/workflows/ci.yml

```text
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Fingerprint vectors (SPEC 2.1)
        run: python3 tests/check_vectors.py

      - name: Standalone checker on the examples
        run: >-
          python3 tools/dossier_check.py
          examples/scribe.dossier.md
          adapters/muse/examples/kubo.dossier.md

      - name: Grok Bot importer (dry run)
        run: python3 adapters/grok-bot/import_dossier.py examples/scribe.dossier.md

      - name: Muse importer (dry run)
        run: python3 adapters/muse/import_dossier.py examples/scribe.dossier.md

      - name: Muse round-trip smoke test
        run: |
          python3 adapters/muse/import_dossier.py examples/scribe.dossier.md --stage /tmp/stage >/dev/null
          python3 adapters/muse/export_muse.py --from-staging /tmp/stage --out /tmp/rt.dossier.md --date 2026-01-01 >/dev/null
          python3 tools/dossier_check.py /tmp/rt.dossier.md

      - name: Hermes importer (dry run)
        run: python3 adapters/hermes/import_dossier.py examples/scribe.dossier.md

      - name: Hermes round-trip smoke test
        run: |
          python3 adapters/hermes/import_dossier.py examples/scribe.dossier.md --apply --target-home /tmp/h-stage >/dev/null
          python3 adapters/hermes/export_dossier.py --home /tmp/h-stage --out /tmp/h-rt.dossier.md >/dev/null
          python3 tools/dossier_check.py /tmp/h-rt.dossier.md

      - name: OpenClaw importer (dry run)
        run: python3 adapters/openclaw/import_dossier.py examples/scribe.dossier.md

      - name: OpenClaw round-trip smoke test
        run: |
          python3 adapters/openclaw/import_dossier.py examples/scribe.dossier.md --apply --target-home /tmp/o-stage >/dev/null
          python3 adapters/openclaw/export_dossier.py --home /tmp/o-stage --out /tmp/o-rt.dossier.md >/dev/null
          python3 tools/dossier_check.py /tmp/o-rt.dossier.md

      - name: Grok Bot exporter scan behavior (SPEC 5.2)
        run: |
          sudo mkdir -p /home/box/agent-data/agents/goodbot /home/box/agent-data/agents/badbot
          printf '{"name": "Good", "description": "harmless test role"}' | sudo tee /home/box/agent-data/agents/goodbot/profile.json >/dev/null
          printf '{"name": "Bad", "description": "login first, password: abc123"}' | sudo tee /home/box/agent-data/agents/badbot/profile.json >/dev/null
          if python3 adapters/grok-bot/export_dossiers.py /tmp/out; then
            echo "exporter must exit non-zero when a dossier is blocked" >&2; exit 1
          fi
          grep -q 'spec: personal-agent-dossier/1.0' /tmp/out/good.dossier.md
          sudo rm -rf /home/box/agent-data/agents/badbot
          python3 adapters/grok-bot/export_dossiers.py /tmp/out2 >/dev/null
```

## File: .gitignore
size: 24
sha: 6797f5f5e24625783cb251b93279f1ac73ef46d0
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/.gitignore

```text
__pycache__/
*.pyc
out/
```

## File: adapters/grok-bot/export_dossiers.py
size: 2667
sha: d4216918720751ab302132048966c2dced83e2eb
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/grok-bot/export_dossiers.py

```text
#!/usr/bin/env python3
"""Export every Grok Bot profile as a personal-agent-dossier/1.0 file (role only).
routines and memory are written as null = not exported (an empty list means "has none").
Usage: export_dossiers.py [out_dir]   Read-only on the agent folders; idempotent.
Exit code: 1 if any agent was BLOCKED (skipped over a sensitive pattern),
else 0 — a skip must never look like success (SPEC 5.2)."""
import glob, hashlib, json, os, re, sys, datetime

SRC = '/home/box/agent-data/agents/*/profile.json'
out = sys.argv[1] if len(sys.argv) > 1 else '/workspace/personal-agent-dossier/out'
os.makedirs(out, exist_ok=True)
SCAN = [r'[\w.+-]+@(?!\d{1,3}(?:\.\d{1,3}){3}\b)[\w-]+\.[\w.]+', r'0\d{1,3}-\d{3,4}-\d{4}', r'\b\d{16}\b', r'sk-[A-Za-z0-9]{10,}', r'ghp_\w+', r'(?i)(password|passwd|密码)\s*[:：=]\s*\S+']

def split_name(full):
    m = re.match(r'^(.*?)\s*·\s*(.+)$', full or '')
    return (m.group(1), m.group(2)) if m else (full or '', '')

def q(s):  # JSON string is valid YAML, so quoting is safe for any text
    return json.dumps(s, ensure_ascii=False)

report = []
for p in sorted(glob.glob(SRC)):
    d = json.load(open(p))
    aid = os.path.basename(os.path.dirname(p))
    full = d.get('name') or ''
    name, title = split_name(full)
    title = title or d.get('title') or ''
    role = d.get('description') or ''
    hits = [x for x in SCAN if re.search(x, role)]
    if hits:
        report.append((aid, name, 'BLOCKED: sensitive pattern ' + ', '.join(hits)))
        continue
    n = lambda x: (x or '').replace('\r\n', '\n').strip()
    fp = hashlib.sha1('|'.join([n(name), n(title), n(role)]).encode('utf-8')).hexdigest()[:12]
    body = '\n'.join([
        '---', 'spec: personal-agent-dossier/1.0', 'id: ' + aid, 'name: ' + q(name), 'title: ' + q(title),
        'status: active', 'exported_at: ' + datetime.date.today().isoformat(), 'source: grok-bot',
        'fingerprint: ' + fp, 'role: ' + q(role), 'routines: null', 'memory: null',
        'excluded: "routines and memory not exported by this script"', '---', '',
        '# ' + name + ' 交接档案（仅职能）', ''])
    open(os.path.join(out, re.sub(r'[^\w-]', '-', name.lower()) + '.dossier.md'), 'w').write(body)
    report.append((aid, name, 'ok ' + fp))
for r in report:
    print(*r, sep='\t')
blocked = [r for r in report if r[2].startswith('BLOCKED')]
if blocked:
    # SPEC 5.2: skipped agents are summarized loudly and the run fails,
    # so a skip can never pass unnoticed in automation.
    print('BLOCKED: %d of %d agent(s) skipped: %s' % (
        len(blocked), len(report), ', '.join(r[1] or r[0] for r in blocked)), file=sys.stderr)
    sys.exit(1)
```

## File: adapters/grok-bot/import_dossier.py
size: 5792
sha: 1c2884e69c19cb113eb8dbcbdb6b263aa2ff9d7a
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/grok-bot/import_dossier.py

````text
#!/usr/bin/env python3
"""Dry-run importer for personal-agent-dossier/1.0 (and legacy agent-dossier/0.1, 0.2) -> Grok Bot.
Checks one dossier (file or Notion text) and prints what a real import would do, plus a loss/downgrade report.
It never creates or edits anything. Usage: import_dossier.py FILE [--agents-dir DIR]"""
import glob, hashlib, json, os, re, sys

try:
    import yaml
except ImportError:
    sys.exit('need pyyaml: python3 -m venv v && v/bin/pip install pyyaml')

SCAN = [r'[\w.+-]+@(?!\d{1,3}(?:\.\d{1,3}){3}\b)[\w-]+\.[\w.]+', r'0\d{1,3}-\d{3,4}-\d{4}', r'\b\d{16}\b', r'sk-[A-Za-z0-9]{10,}', r'ghp_\w+', r'(?i)(password|passwd|密码)\s*[:：=]\s*\S+']
REQUIRED = ['spec', 'id', 'name', 'status', 'exported_at', 'source', 'fingerprint', 'role', 'routines', 'memory']
STATUS = {'active', 'handed-over', 'retired'}
KIND = {'rule', 'preference', 'decision', 'history'}

def fingerprint(name, title, role):
    n = lambda s: (s or '').replace('\r\n', '\n').strip()
    return hashlib.sha1('|'.join([n(name), n(title), n(role)]).encode('utf-8')).hexdigest()[:12]

def load(path):
    """Accept the three storage forms: a .dossier.md file with front matter,
    a bare YAML file, or text copied from a Notion page holding one ```yaml block."""
    text = open(path, encoding='utf-8').read()
    blocks = re.findall(r'```ya?ml\n(.*?)\n```', text, re.S)
    if len(blocks) > 1:
        sys.exit('FAIL: more than one yaml block (spec section 1)')
    if blocks:
        return yaml.safe_load(blocks[0])
    parts = text.split('---\n', 2)
    if len(parts) >= 3 and not parts[0].strip():
        return yaml.safe_load(parts[1])
    data = yaml.safe_load(text)
    if isinstance(data, dict) and 'spec' in data:
        return data
    sys.exit('FAIL: no dossier found (need front matter, a yaml block, or bare yaml)')

def main():
    args = sys.argv[1:]
    agents = '/home/box/agent-data/agents'
    if '--agents-dir' in args:
        i = args.index('--agents-dir'); agents = args[i + 1]; del args[i:i + 2]
    if len(args) != 1:
        sys.exit(__doc__)
    fm = load(args[0])
    errors, loss, plan, warns = [], [], [], []

    for k in REQUIRED:
        if k not in fm:
            errors.append('missing required field: ' + k)
    if errors:
        print('\n'.join('FAIL: ' + e for e in errors)); sys.exit(1)

    if fm['spec'] not in ('personal-agent-dossier/1.0', 'agent-dossier/0.1', 'agent-dossier/0.2'): errors.append('unsupported spec ' + str(fm['spec']))
    if fm['status'] not in STATUS: errors.append('bad status ' + str(fm['status']))
    fp = fingerprint(fm['name'], fm.get('title'), fm['role'])
    if fp != fm['fingerprint']: errors.append('fingerprint mismatch: file says %s, content hashes to %s' % (fm['fingerprint'], fp))

    texts = [fm['role']] + [str(m.get('fact', '')) for m in (fm['memory'] or [])] + [str(r.get('prompt', '')) for r in (fm['routines'] or [])]
    for t in texts:
        for pat in SCAN:
            if re.search(pat, t): errors.append('sensitive pattern %s found; import refused' % pat)
    for m in fm['memory'] or []:
        if m.get('kind') not in KIND: errors.append('memory kind invalid: ' + str(m.get('kind')))

    uuids = set(re.findall(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', fm['role'])) - {fm['id']}
    if uuids:
        listed = {str(r.get('id')) for r in (fm.get('refs') or [])}
        for u in sorted(uuids):
            warns.append('role mentions agent id %s%s; it cannot be resolved on another platform' % (u, '' if u in listed else ' and it is not listed in refs'))

    existing = None
    for p in glob.glob(os.path.join(agents, '*', 'profile.json')):
        if os.path.basename(os.path.dirname(p)) == fm['id']:
            existing = json.load(open(p))
    full = fm['name'] + (' · ' + fm['title'] if fm.get('title') else '')
    if existing is None:
        plan.append('CREATE bot "%s" (new bot; the target platform assigns a new id, source id %s kept as a note)' % (full, fm['id']))
    else:
        same = existing.get('description') == fm['role'] and existing.get('name') == full
        plan.append('NO CHANGE: bot %s already matches this dossier' % fm['id'] if same else 'UPDATE bot %s: name/description differ from the dossier' % fm['id'])

    # fields Grok Bot cannot take directly
    if fm.get('anti_jobs'): loss.append('anti_jobs (%d): no dedicated field; kept inside the description text only if already written there' % len(fm['anti_jobs']))
    if fm.get('group'): loss.append('group "%s": sidebar section is set at creation, not read from a file' % fm['group'])
    if fm['routines'] is None: loss.append('routines: not exported (null). This is NOT the same as none; keep the original dossier')
    elif fm['routines']: loss.append('routines (%d): recreated one by one with UpdateRoutine; ids will change' % len(fm['routines']))
    if fm['memory'] is None: loss.append('memory: not exported (null). This is NOT the same as none; the new bot starts empty, keep the original dossier')
    elif fm['memory']: loss.append('memory (%d facts): written with UpdateMemory by the new bot itself; dates become import date' % len(fm['memory']))
    if fm.get('skills'): loss.append('skills %s: skills are global; missing ones must be installed separately' % fm['skills'])
    if fm.get('excluded'): loss.append('excluded by exporter: ' + fm['excluded'])

    print('DRY RUN ' + os.path.basename(args[0]))
    print('platform level: L2 (Grok Bot: name/description update by id; memory dates and routine ids change)')
    print('checks:', 'PASS' if not errors else 'FAIL')
    for e in errors: print('  FAIL', e)
    for w in warns: print('  WARN', w)
    for p in plan: print('plan:', p)
    for l in loss: print('loss/downgrade:', l)
    sys.exit(1 if errors else 0)

main()
````

## File: adapters/hermes/adapter-report.md
size: 18176
sha: fbac26cb9c79ba0a2b31a36c03e8770c53942576
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/hermes/adapter-report.md

````text
# Hermes 适配器报告（personal-agent-dossier/1.0）

## 1. 平台与声明等级

- 平台：**Hermes Agent**（Nous Research；一个 Hermes home 就是一个 agent 的运行根：`SOUL.md` 是身份槽，`memories/` 是长期记忆，`skills/` 是技能，`cron/jobs.json` 是定时任务，多 agent 用 `profiles/` 各开一个 home）
- 声明等级：**L2**，作用域是「一份档案 = 一个 Hermes home」这一种部署形态（也是本适配器唯一实现的写入形态）

### 定级依据（全部在本平台实测）

SPEC 6.1 的 L2 是「能按 id 配对并更新职能，能建定时任务和写记忆」。三条逐项实测：

1. **按 id 配对并更新职能**：导入时把档案的 `source`+`id` 与字段映射写进目标 home 的 `dossier/import-meta.json`；同一份档案再次 `--apply` 到同一个 home，是**原地更新**而不是新建。实测用一份改了 role、改了一条 memory 的 Hatch 副本（同 `source`+`id`，指纹从 `56cd7b80e6db` 变为 `1e7026c01b59`）再导入：`SOUL.md` 被替换为新 role，记忆仍是 8 条（旧条目被替换，没有重复），`import-meta.json` 的指纹随之更新；再导出得到 `1e7026c01b59`，与改后档案一致。
2. **能建定时任务**：routines 写成 `cron/jobs.json` 里 Hermes 自己的 job 记录形态（字段与 `cron/jobs.py:create_job` 产出的记录一致）。实测 `hermes cron list` 能列出导入的任务（`[active]`，`Schedule: 0 9 * * 1 (Asia/Tokyo)`）；`hermes cron doctor` 报 **found no issues**；Hermes 自己的 `load_jobs()` + `compute_next_run()` 对该记录算出 `2026-11-02T09:00:00+09:00`。
3. **能写记忆**：memory 写进 `memories/MEMORY.md`，用的是 Hermes 自己的条目格式（`tools/memory_tool_store.py:ENTRY_DELIMITER = "\n§\n"`）。实测 `hermes prompt-size`（用一份 3 条事实的 Scribe 副本导入的 home）：`memory` 块 585 B，把 `MEMORY.md` 清空后同一命令报 0 B；`SOUL.md` 追加 3,000 字后 `stable (identity/guidance/skills)` 档从 5,457 B 涨到 8,038 B。即角色与记忆确实是进系统提示的那两块，不是躺在磁盘上的文本。

### 为什么不到「无条件的 L2」

Hermes 没有一张按**外部** id 索引的 agent 名册（Grok Bot 那种「按 id 找 bot、改 name/description」的实体）。`id ↔ home` 的对应表是**适配器**写在目标 home 里的 `dossier/import-meta.json`，平台本身不认这个 id。由此有两个直接后果，都写进了实现：

- **多份档案共用一个 home 就只有 L1**：`SOUL.md` 只有一个身份槽，第二个 agent 的 role 无处可放。导入器检测到 home 里已有别的 `source`+`id` 时直接拒绝（退出码 1），不假装能装下。
- **id 的解释范围**：`id` 只在「同一个 home + 同一 source」内可配对。跨平台、跨 home 配对仍需按 SPEC 第 2 节的规则比 `source`+`id`，再人工确认。

## 2. 指纹向量结果

- 仓库官方脚本 `tests/check_vectors.py`：**PASS 8 vectors**
- 本适配器自己的实现（`adapters/hermes/dossier_lib.py`，按 SPEC 2.1 独立写）：**PASS 8 vectors**
- 附带核对：`hatch.dossier.md`（旧标识 `agent-dossier/0.2`）声明指纹 `56cd7b80e6db`，重算一致；`examples/scribe.dossier.md` 声明 `1e6ed4957379`，重算一致；从 scratch-home 导出的 Hatch 档案指纹同为 `56cd7b80e6db`。

## 3. 映射表

| 档案字段 | Hermes 放在哪里 | 还原程度 |
|---|---|---|
| id | `dossier/import-meta.json`（配对键，与 `source` 一起）；归档副本里也有一份 | 降级（平台无 agent id，配对表由适配器维护） |
| name | `dossier/import-meta.json` + 归档副本；Hermes 用 home 目录名标识 agent，不用显示名 | 降级（值原样留存，平台不消费） |
| title | 归档副本（`import-meta.json` 里也有） | 存不下（无 title 槽，不参与匹配或显示） |
| group | 归档副本 | 存不下（Hermes 没有分区/分组概念） |
| status | `dossier/import-meta.json` | 降级（文档性；`retired` 的档案同样会被导入） |
| exported_at / source | `dossier/import-meta.json` | 文档性；`source` 参与配对键 |
| fingerprint | `dossier/import-meta.json`，并在每次导入时重算比对 | 不一致即拒绝导入 |
| role | `SOUL.md`（身份槽，注入系统提示） | 原样 |
| anti_jobs | 同一 `SOUL.md` 的 `## 反职责（anti_jobs）` 小节，`- ` 列表 | 降级（内容原样，无结构化字段） |
| routines.id | `cron/jobs.json` 里由适配器分配的 12 位 hex；同一档案再导入时沿用 | 降级（与源平台 id 不同，但同一 home 内稳定） |
| routines.name / prompt | job 记录的 `name` / `prompt`，原文 | 原样 |
| routines.schedule | `schedule = {kind: "cron", expr, display}`，`display` 保留原文（含时区）；`next_run_at` 在装了 croniter 时算出，否则留给 ticker | 降级（Hermes 无单任务时区，expr 按 profile 时区求值） |
| routines.state | `running` → `enabled: true`/`scheduled`；`paused` → `enabled: false`/`paused`；`deleted` → 不建（此前建过的会被移除） | 降级 |
| memory.fact | `memories/MEMORY.md`，一条一行，`\n§\n` 分隔 | 原样（条目内的换行变空格，见第 5 项） |
| memory.kind / learned | 行内标签 `[kind · learned]` | 降级（枚举与日期变文本） |
| refs | 归档副本 | 存不下（id 换平台不解析，按名字人工重新对应） |
| skills | 只做 `skills/` 下的同名存在性核对，不创建目录 | 降级（只有名字） |
| excluded | 归档副本 | 原样（文本） |

导入的落点形态：一份档案 → `SOUL.md`（role + anti_jobs）+ `memories/MEMORY.md`（facts）+ `cron/jobs.json`（routines，有才写）+ `dossier/<slug>.dossier.md`（全字段归档）+ `dossier/import-meta.json`（字段映射、id、写入记录）。空目录 `skills/`、`cron/output/` 一并建好，让 home 的形态与真实 Hermes home 一致。

## 4. 往返测试与四项验收

### 四项验收（全部真跑，命令与结果照录）

对象：`~/hermes-adapter-run/hatch.dossier.md`（真档案，`spec: agent-dossier/0.2`，指纹 `56cd7b80e6db`）。日志留在 `~/hermes-adapter-run/accept{1,2,3}-*.log`。

**1) dry-run**：`python3 import_dossier.py ~/hermes-adapter-run/hatch.dossier.md`

- 退出码 0；`checks: PASS`；1 条预期内 WARN：role 里点名 Big Boss 的 UUID，按 SPEC 5.4 只提示不拦截（该 UUID 已在 `refs` 里，所以没有「未登记」的附加提示）
- 5 条 plan、8 条 loss/downgrade；没有创建或修改任何文件（dry-run 后 `scratch-home` 仍不存在）

**2) `--apply` 到一个全新的 home**：`--apply --target-home ~/hermes-adapter-run/scratch-home`

- 退出码 0；写入 4 个文件：`SOUL.md`（656 字符）、`memories/MEMORY.md`（631 字符 / 8 条）、`dossier/hatch.dossier.md`、`dossier/import-meta.json`
- `routines: []`，所以按计划**没有**写 `cron/jobs.json`（档案自己说没有定时任务，不是「没导出」）
- 报告行：`applied: 4 file(s) under ...; nothing outside it was touched.`；真实的 `~/.hermes` 只做过只读参考（`SOUL.md`、`memories/` 的 mtime 仍停在会话前）

**3) 从 scratch-home 导出并比对指纹**：`export_dossier.py --home ~/hermes-adapter-run/scratch-home --out ... --date 2026-10-28`

- 退出码 0；`wrote ... fingerprint 56cd7b80e6db name='Hatch' id='bd3eda99-…' memory=8 routines=0`
- **指纹一致**：原档案声明 / 原档案重算 / 导出档案声明 / 导出档案重算，四个值都是 `56cd7b80e6db`
- 逐字段比对：id、name、title、group、status、exported_at、role、refs、anti_jobs、routines、memory、skills、excluded **全部相同**；不同的只有两处，且都是如实的变化：`spec`（`agent-dossier/0.2` → `personal-agent-dossier/1.0`，导出恒写当前标识）与 `source`（`grok-bot` → `hermes`）

**4) 仓库自带测试**

- `python3 tests/check_vectors.py` → `PASS 8 vectors`，退出码 0
- `python3 tools/dossier_check.py ~/hermes-adapter-run/hatch.roundtrip.dossier.md ~/hermes-adapter-run/hatch.dossier.md` → 两份都是 `OK`，退出码 0
- 顺带：`python3 adapters/hermes/import_dossier.py examples/scribe.dossier.md`（空跑）退出码 0

### 其他实测

- **Scribe 往返**（`examples/scribe.dossier.md` → 导入 → 再导出）：整文件逐行 diff 只有三处不同——`source`（example → hermes）、routine id（`example-routine-0001` → 平台分配的 12 位 hex）、以及人读正文那句话（导出器按模板重生成，SPEC 第 1 节说正文不属机器可读部分）。带时区的 `schedule: "0 9 * * 1 (Asia/Tokyo)"` 字节一致，指纹 `1e6ed4957379` 不变，memory 3 条（rule / preference / decision，均带 learned）逐字还原。`tools/dossier_check.py` 判 OK。
- **幂等**：同一 home 连续 `--apply` 两次，`SOUL.md` 与 `MEMORY.md` 字节一致、cron job id 不变、无重复条目；同一 home 同 `--date` 连续导出两次，产物字节一致（SPEC 第 6 节）。
- **`null` 语义**：用一份 routines/memory 都是 `null` 的 Hatch 副本导入，报告两处都写「未导出，不等于没有」，不写 `MEMORY.md`、不建 cron；再从该 home 导出，`routines: null`、`memory: null` 原样保持（没有退化成 `[]`）——归档里的 null 标记被读回来了。
- **Notion 形式**：构造一页带标题、唯一一个 ` ```yaml ` 块、块内 `[` `]` 被转义成 `\[` `\]` 的文本（并把 role 改成含 `[rule]` 后重算指纹 `09ad7923ea0f`）：导入 `checks: PASS`，说明转义还原正确（不还原的话 role 会多出反斜杠、指纹对不上）。同一文本再塞第二个 yaml 块：`FAIL: more than one yaml block`，退出码 1。
- **安全闸门**：① 目标 home 有手写的 `SOUL.md`（无适配器元数据）时 `--apply` 拒绝、退出码 1、**一个字节都不写**（连空目录都不建）；② 加 `--replace-soul` 后成功，原文件保留为 `SOUL.md.pre-dossier`；③ 同一 home 塞第二份不同 `source`+`id` 的档案被拒绝，退出码 1。
- **脱敏**：memory 里带 `password: …` 的档案导入被拒（`sensitive pattern password-value in memory[0].fact`，退出码 1）；`SOUL.md` 里带邮箱的 home 导出中止（`sensitive pattern email in role`，退出码 1）且**没有**产生输出文件。Hatch、Scribe、Notion 构造页、null 版与改版六份输入扫描零命中。
- **边缘 routine**：事件触发型 schedule（「每次收到新邮件时（Asia/Tokyo）」）不建 cron、只留归档并在 loss 里写明；6 字段 cron 表达式只取前五个字段，同样在 loss 里写明；`paused` 的 routine 建成 paused job，`next_run_at` 留空；`completed`/`error` 的 job 导出成 `paused` 并在 stderr 说明（不是 running 也不是 deleted）。

## 5. 丢失或降级清单

1. **id 不能当平台注册表用**：Hermes 没有按外部 id 索引的 agent；配对靠目标 home 里的 `dossier/import-meta.json`（`source`+`id`）。这是「一份档案一个 home」这一前提的由来，也是多档案共用一个 home 时只能算 L1 的原因。
2. **title 与 group 存不下**：只在归档副本与元数据里，不参与显示、匹配或同步。
3. **anti_jobs 降级**：`SOUL.md` 里的文本小节，内容逐条原样，但没有结构化字段，导出时靠小节标题还原。
4. **memory.kind / learned 降级**：枚举与日期变成条目开头的 `[kind · learned]` 文本标签；`fact` 原文保留，但**条目内的换行会被换成空格**（Hermes 记忆一条一行）。真实 Hatch 的 8 条事实都没有内部换行，所以这一份没有实际变形。无可辨认标签的记忆条目导出时按 `history` 处理，这一条会写进档案的 `excluded`，不静默。
5. **记忆有字数预算**：目标 home 的 `memory.memory_char_limit`（默认 2200 字符）是 Hermes 注入上限。Hatch 8 条 631 字符，富余；超预算时适配器**不截断**，只把实际字数与上限写进导入计划与 loss 清单（Hermes 此后会拒绝继续加条目）。
6. **routines.id 必变**：Hermes 的 job id 是平台分配的 12 位 hex；同一 home 内重复导入保持稳定，但换 home 或换平台就不再指向同一个任务。
7. **routines.schedule 的时区不跟任务走**：Hermes 按 profile 时区（`HERMES_TIMEZONE` → `config.yaml` 的 `timezone` → 服务器本地时区）求值，没有单任务时区。源档案里的 `(Asia/Tokyo)` 作为 `display` 文本保留，但不改变求值区；目标 home 时区不同时需人工改表达式（适配器不做这个换算）。
8. **6 字段 cron 表达式只取前五个字段**，第 6 个字段（秒或年，各家不同）丢弃并在 loss 里标明。
9. **事件触发的 routine 建不成 cron**：Hermes cron 只吃 cron 表达式/间隔/一次性时间；非表达式的 schedule 只留在归档里。
10. **`state: deleted` 的 routine 不回建**；若此前由本适配器建过对应 job，会把它移除并在输出里说明。
11. **refs 的 id 不解析**：只在归档里留名字，换平台后要人工重新对应（同名不同人时尤其要核对）。
12. **skills 只剩名字**：不会凭空造 skill；目标 home 里没有同名 `SKILL.md` 就等于没有，缺失清单在 dry-run 里直接列出。
13. **status / exported_at / source / fingerprint 不被平台消费**：只活在元数据与归档里（fingerprint 例外，每次导入/导出都重算比对）。
14. **`SOUL.md` 是单身份槽**：一个 home 只能装一份档案的 role；多档案请各开一个 home（`profiles/`）。
15. **角色过长会被系统提示截断**：Hermes 的上下文文件有约 20,000 字符的注入上限（`agent/prompt_builder.py:CONTEXT_FILE_MAX_CHARS`），超长时进提示的是头尾摘录，磁盘上的 `SOUL.md` 仍是全文。本适配器不切分 role，只在此说明（未实测）。

## 6. 规范反馈

以下只写在这里，没有改 `SPEC.md`。

1. **§6.1 的 L2 没有说 id 配对的「作用域」。** Hermes 能按 `source`+`id` 原地更新，但那张对应表是适配器自己写在目标存储里的，不是平台注册表。按字面读，这既像 L2 又像 L1。建议在 6.1 补一句：L2 的「按 id 配对」是指目标平台实例内可配对，并建议适配器在报告里点明 id 映射存放在哪里；否则同一个平台会被两个认真的实现者判成两个等级。
2. **能力等级表没有「一个存储能装几个 agent」这一维。** Hermes 的 role 落在**提示槽**（`SOUL.md`）而不是 skill 文件，一个 home 只装得下一个身份。这一维直接决定了等级上限，比「能存全文」更硬。建议在表里加一行或一句。
3. **§5.2 要求扫 `role`、`fact`、`prompt`，但仓库自己的 `tools/dossier_check.py` 还扫 `anti_jobs`。** 结果是一份档案可能通过某个适配器的导入，却被仓库检查器判失败（或反过来）。建议把「必须扫的字段集」在一处钉死，再让参考实现、检查器和适配器都指向它。
4. **`routines.schedule` 是自由文本，导入方必须自己决定它是不是 cron。** 「cron 表达式或事件触发描述」两种形态混在一个字符串里，导入方只能靠正则猜；猜错就是静默建错任务。本适配器选择「解析不出就绝不建、只报降级」，但这是实现选择，不是规范规定。建议给 `schedule` 加可选的 `kind: cron|event`，或至少写明「不是 cron 表达式时不得创建任务」。
5. **`null` 的持久化责任没写清。** 目标平台（Hermes）的记忆存储永远存在，没有原生的「这次没导出」标记；适配器只能把 null 记在自己的元数据里，等下次导出再读回来。建议规范明说：导入方有义务把 routines/memory 的 null 标记持久化到目标存储，否则第 6 节的 `null` 语义在往返里必然丢失（本适配器为此专门写了 `import-meta.json`，并且实测了这条往返）。
6. **memory 条目「文字原样」与「目标平台一行一条」会打架。** 一条 fact 内若含换行，落到 Hermes 会变成一行（换行→空格），而 SPEC 只对 `prompt` 提到「完整原文」。建议说明：目标平台对条目形态有限制时，允许压平并必须在报告里声明（本适配器就声明了）。

## 7. 没测的部分，以及为什么

- **真实 ticker 触发导入的 cron**：没跑。`hermes cron tick` 会真的执行到期的 job（起一个 agent turn），超出本次范围。改为用只读手段验证：`hermes cron list` 能列出、`hermes cron doctor` 报无问题、Hermes 自己的 `load_jobs()`+`compute_next_run()` 算出 `2026-11-02T09:00:00+09:00`。
- **完整 `hermes chat` 会话**：没跑（要发一次模型请求，且与本任务无关）。记忆注入改用 `hermes prompt-size` 的块大小变化验证（585 B → 0 B），身份槽用「`SOUL.md` 追加 3,000 字 → `stable` 档 +2,581 B」验证。
- **`SOUL.md` 超 20k 字符被截断**：读代码得出（`agent/prompt_builder.py:CONTEXT_FILE_MAX_CHARS = 20_000`），没有真造一份超长档案去打它。
- **真实 Notion 页面**：没碰。Notion 形式用构造文本验证（转义还原、双块拒绝），未连接用户的 Notion。
- **`tools/render_notion.py` 生成方向**：没测，本适配器只做文件形式导出。
- **写入真实的 `~/.hermes`**：按约定不做。真实 home 只读参考过布局（`SOUL.md`、`memories/`、`skills/`、`cron/`、`config.yaml` 的键名、`hermes-agent` 源码里的存储格式），全部写入都发生在 `~/hermes-adapter-run/scratch-home` 与 `~/hermes-adapter-run/probe3`。
- **Windows 路径与多 profile 并发**：没测，只在 macOS + 单 profile 上跑过。
- **Hatch 的 routines 是 `[]`**：真档案因此没有走 cron 路径；cron 路径是用 `examples/scribe.dossier.md` 和一份合成的边缘档案（事件触发 + 6 字段 cron + paused）测的。
````

## File: adapters/hermes/dossier_lib.py
size: 20197
sha: 55d8d59d6352399b31471bccb8ac80ab96baa99c
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/hermes/dossier_lib.py

````text
#!/usr/bin/env python3
"""Shared library for the Hermes adapter of personal-agent-dossier.

Implements:
- SPEC section 2.1 fingerprint (CRLF -> LF, strip by Unicode White_Space,
  SHA-1 of "name|title|role" in UTF-8, first 12 hex chars)
- the three storage forms from SPEC section 1 (front matter file, bare YAML,
  Notion page text with exactly one ```yaml block)
- SPEC section 5 sensitive-information scan (export aborts, import refuses)
- deterministic dossier rendering (SPEC section 6 idempotency; appendix A
  standard serialization)
- the small pieces of Hermes layout knowledge both scripts share: how a role
  becomes SOUL.md text and back, how memory entries become MEMORY.md entries
  and back, and how a Hermes cron schedule string is read

Nothing here touches a real Hermes home; path handling stays in the scripts.
"""

import datetime
import hashlib
import json
import re

import yaml

# --- SPEC 2.1 fingerprint ---------------------------------------------------

def normalize(text):
    """CRLF -> LF, then strip leading/trailing Unicode White_Space.

    Python's str.strip() strips exactly the characters whose Unicode
    White_Space property is true (including U+3000 and U+00A0), which is what
    SPEC 2.1 requires.
    """
    return (text or "").replace("\r\n", "\n").strip()

def fingerprint(name, title, role):
    joined = "|".join([normalize(name), normalize(title), normalize(role)])
    return hashlib.sha1(joined.encode("utf-8")).hexdigest()[:12]

def check_vectors(vectors):
    """vectors: list of dicts with name/title/role/fingerprint.
    Returns (passed_count, list_of_failed_vectors)."""
    failed = [v for v in vectors
              if fingerprint(v.get("name"), v.get("title"), v.get("role")) != v.get("fingerprint")]
    return len(vectors) - len(failed), failed

# --- SPEC 5 sensitive scan --------------------------------------------------
# Same pattern family as tests/ and tools/dossier_check.py, including the
# password-value rule SPEC 5.2 names in prose.

SCAN_PATTERNS = [
    ("email", r"[\w.+-]+@(?!\d{1,3}(?:\.\d{1,3}){3}\b)[\w-]+\.[\w.]+"),
    ("japan-phone", r"0\d{1,3}-\d{3,4}-\d{4}"),
    ("card-16", r"\b\d{16}\b"),
    ("openai-key", r"sk-[A-Za-z0-9]{10,}"),
    ("github-token", r"ghp_\w+"),
    ("password-value", r"(?i)(?:password|passwd|密码)\s*[:：=]\s*\S+"),
]

UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")

def scan_fields(named_texts):
    """named_texts: list of (where, text). Returns list of (where, pattern_name)."""
    hits = []
    for where, text in named_texts:
        if not text:
            continue
        for name, pat in SCAN_PATTERNS:
            if re.search(pat, str(text)):
                hits.append((where, name))
    return hits

def dossier_texts(data):
    """All texts SPEC 5.2 requires scanning: role, memory facts, routine prompts."""
    texts = [("role", data.get("role"))]
    for i, m in enumerate(data.get("memory") or []):
        texts.append((f"memory[{i}].fact", (m or {}).get("fact")))
    for i, r in enumerate(data.get("routines") or []):
        texts.append((f"routines[{i}].prompt", (r or {}).get("prompt")))
    return texts

# --- loading (SPEC section 1: three accepted forms) -------------------------

class DossierError(Exception):
    pass

def _dates_to_str(obj):
    """yaml parses ISO dates into datetime.date; the dossier treats them as
    strings, so normalize on load to keep comparisons and rendering stable."""
    if isinstance(obj, dict):
        return {k: _dates_to_str(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [_dates_to_str(v) for v in obj]
    if isinstance(obj, datetime.date):
        return obj.isoformat()
    return obj

def load_dossier_text(text):
    """Parse any of the three accepted forms. Returns (data: dict, body: str|None).
    Raises DossierError on structural problems."""
    blocks = re.findall(r"```ya?ml\n(.*?)\n```", text, re.S)
    if len(blocks) > 1:
        raise DossierError("more than one yaml block (SPEC section 1)")
    if blocks:
        # Notion round-trips can escape [rule] as \[rule\]; undo that inside
        # the machine-readable block only.
        block = blocks[0].replace("\\[", "[").replace("\\]", "]")
        data = yaml.safe_load(block)
    elif text.startswith("---\n") or text.startswith("---\r\n"):
        m = re.match(r"---\r?\n(.*?)\r?\n---\r?\n?(.*)\Z", text, re.S)
        if not m:
            raise DossierError("front matter not closed")
        data = yaml.safe_load(m.group(1))
        body = m.group(2)
        if not isinstance(data, dict):
            raise DossierError("front matter is not a mapping")
        return _dates_to_str(data), body
    else:
        data = yaml.safe_load(text)
        if not (isinstance(data, dict) and "spec" in data):
            raise DossierError("no dossier found (need front matter, one yaml block, or bare yaml)")
        return _dates_to_str(data), None
    if not isinstance(data, dict):
        raise DossierError("yaml block is not a mapping")
    return _dates_to_str(data), None

def load_dossier_file(path):
    with open(path, encoding="utf-8") as f:
        return load_dossier_text(f.read())

# --- validation -------------------------------------------------------------

REQUIRED = ["spec", "id", "name", "status", "exported_at", "source",
            "fingerprint", "role", "routines", "memory"]
SPECS = ("personal-agent-dossier/1.0", "agent-dossier/0.1", "agent-dossier/0.2")
STATUS = {"active", "handed-over", "retired"}
KIND = {"rule", "preference", "decision", "history"}
ROUTINE_STATE = {"running", "paused", "deleted"}

def validate(data):
    """Returns (errors, warnings). Errors block import/export."""
    errors, warnings = [], []
    for k in REQUIRED:
        if k not in data:
            errors.append(f"missing required field: {k}")
    if errors:
        return errors, warnings
    if data["spec"] not in SPECS:
        errors.append(f"unsupported spec {data['spec']!r}")
    if data["status"] not in STATUS:
        errors.append(f"bad status {data['status']!r}")
    fp = fingerprint(data.get("name"), data.get("title"), data.get("role"))
    if fp != data.get("fingerprint"):
        errors.append(f"fingerprint mismatch: file says {data.get('fingerprint')}, "
                      f"content hashes to {fp}")
    for where, pat in scan_fields(dossier_texts(data)):
        errors.append(f"sensitive pattern {pat} in {where}; refused")
    for i, m in enumerate(data.get("memory") or []):
        if (m or {}).get("kind") not in KIND:
            errors.append(f"memory[{i}] kind invalid: {(m or {}).get('kind')!r}")
    for i, r in enumerate(data.get("routines") or []):
        if (r or {}).get("state") not in ROUTINE_STATE:
            errors.append(f"routines[{i}] state invalid: {(r or {}).get('state')!r}")
    # SPEC 5.4: UUIDs in role (usually other agents' ids) warn, never block.
    refs_ids = {str(r.get("id")) for r in (data.get("refs") or []) if isinstance(r, dict)}
    for u in sorted(set(UUID_RE.findall(data.get("role") or "")) - {str(data.get("id"))}):
        note = "" if u in refs_ids else " and it is not listed in refs"
        warnings.append(f"role mentions id {u}{note}; ids do not resolve on another platform")
    return errors, warnings

# --- rendering (SPEC section 6 + appendix A) --------------------------------
# Hand-written serializer so the bytes match the rest of the corpus (appendix
# A): role/prompt as block scalars, sequences indented under their key, long
# scalars unwrapped, ISO dates bare, canonical field order.

FIELD_ORDER = ["spec", "id", "name", "title", "group", "status", "exported_at",
               "source", "fingerprint", "role", "refs", "anti_jobs", "routines",
               "memory", "skills", "excluded"]

_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_RESERVED = {"null", "true", "false", "yes", "no", "on", "off", "~"}
_BLOCK_KEYS = {"role", "prompt"}

def canonical(data):
    """Order fields per FIELD_ORDER, drop None-valued optional fields, and
    normalize nested entries so rendering is deterministic."""
    out = {}
    for k in FIELD_ORDER:
        if k not in data:
            continue
        v = data[k]
        if v is None and k not in ("routines", "memory"):
            continue
        if k == "routines" and v is not None:
            v = [{kk: r.get(kk) for kk in ("id", "name", "schedule", "prompt", "state")
                  if r.get(kk) is not None} for r in v]
        if k == "memory" and v is not None:
            v = [{kk: m.get(kk) for kk in ("fact", "kind", "learned")
                  if m.get(kk) is not None} for m in v]
        if k == "refs" and v is not None:
            v = [{kk: r.get(kk) for kk in ("name", "id") if r.get(kk) is not None}
                 for r in v]
        out[k] = v
    return out

def _scalar(s):
    """Plain YAML scalar when safe (appendix A rule 4), else a JSON-style
    double-quoted string (JSON strings are valid YAML)."""
    s = str(s)
    if _DATE_RE.match(s):
        return s
    needs_quote = (
        s == "" or s != s.strip() or s.lower() in _RESERVED
        or ": " in s or " #" in s or " *" in s or s.endswith(":")
        or s[0] in "!&*?|>%@`\"'#,-?:[]{}"
        or (s[0].isdigit() and all(c in "0123456789 */(),.-" for c in s))
        or any(ord(c) < 0x20 for c in s)
    )
    if needs_quote:
        return json.dumps(s, ensure_ascii=False)
    return s

def _block(lines_out, prefix, text, content_indent):
    """Append a block scalar: `prefix: |-` then content lines indented."""
    style = "|" if text.endswith("\n") else "|-"
    lines_out.append(f"{prefix}: {style}")
    body = text[:-1] if text.endswith("\n") else text
    pad = " " * content_indent
    for line in body.split("\n"):
        lines_out.append(pad + line if line else "")

def _emit_kv(lines_out, key, value, key_col):
    """Emit one `key: value` at column key_col, corpus style."""
    pad = " " * key_col
    if value is None:
        lines_out.append(f"{pad}{key}: null")
    elif isinstance(value, list):
        if not value:
            lines_out.append(f"{pad}{key}: []")
            return
        lines_out.append(f"{pad}{key}:")
        for item in value:
            if isinstance(item, dict):
                pairs = list(item.items())
                first_k, first_v = pairs[0]
                _emit_dash_kv(lines_out, first_k, first_v, key_col)
                for k2, v2 in pairs[1:]:
                    _emit_kv(lines_out, k2, v2, key_col + 4)
            elif isinstance(item, str) and "\n" in item:
                _block(lines_out, f"{pad}  -", item, key_col + 4)
            else:
                lines_out.append(f"{pad}  - {_scalar(item)}")
    elif isinstance(value, str) and ("\n" in value or key in _BLOCK_KEYS):
        _block(lines_out, f"{pad}{key}", value, key_col + 2)
    else:
        lines_out.append(f"{pad}{key}: {_scalar(value)}")

def _emit_dash_kv(lines_out, key, value, list_col):
    """Emit the first key of a mapping inside a sequence: `  - key: value`.
    The dash sits at list_col, the key at list_col + 2."""
    pad = " " * list_col
    if isinstance(value, str) and ("\n" in value or key in _BLOCK_KEYS):
        _block(lines_out, f"{pad}  - {key}", value, list_col + 6)
    elif value is None:
        lines_out.append(f"{pad}  - {key}: null")
    else:
        lines_out.append(f"{pad}  - {key}: {_scalar(value)}")

def render_front_matter(data):
    lines_out = []
    for k, v in canonical(data).items():
        _emit_kv(lines_out, k, v, 0)
    return "\n".join(lines_out) + "\n"

def render_dossier(data, body=None):
    """Render the file form: YAML front matter + human-readable body.
    The fingerprint is recomputed from content, never trusted from input."""
    data = dict(data)
    data["fingerprint"] = fingerprint(data.get("name"), data.get("title"), data.get("role"))
    fm = render_front_matter(data)
    if body is None:
        heading = data.get("name") or "agent"
        body = (f"# {heading} 交接档案\n\n"
                "这份文件由 front matter 提供机器可读的全部字段；正文只做人读说明。\n")
    return f"---\n{fm}---\n\n{body}"

# --- Hermes layout knowledge ------------------------------------------------
# Read off a real Hermes home (~/.hermes) and from hermes-agent's own source:
#   SOUL.md                     identity slot #1, injected into the system prompt
#   memories/MEMORY.md          the agent's own curated notes, entries joined by
#                               "\n§\n" (tools/memory_tool_store.py:ENTRY_DELIMITER)
#   memories/USER.md            the user profile store (not ours to write)
#   skills/<category>/<name>/SKILL.md
#   cron/jobs.json              cron job records (cron/jobs.py:JOBS_FILE)
#   config.yaml, .env, state.db platform state, not dossier content

SOUL_FILE = "SOUL.md"
MEMORY_DIR = "memories"
MEMORY_FILE = "memories/MEMORY.md"
USER_MEMORY_FILE = "memories/USER.md"
SKILLS_DIR = "skills"
CRON_FILE = "cron/jobs.json"
CRON_OUTPUT_DIR = "cron/output"
CONFIG_FILE = "config.yaml"
# Everything the dossier keeps that no native Hermes slot stores (the id, the
# archived original, the per-field map) lives here, inside the target home.
ARCHIVE_DIR = "dossier"

MEMORY_DELIM = "\n§\n"
ANTI_JOBS_HEADER = "## 反职责（anti_jobs）"

# "[rule · 2026-10-21] fact" — the inline tag standing in for the memory
# entry's kind (enum) and learned (date), which Hermes memory has no fields for.
_TAG_RE = re.compile(r"^\[([^\]]*)\]\s?(.*)$", re.S)
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")

def slugify(text):
    s = re.sub(r"[^\w\- ]", "", (text or "agent").lower(), flags=re.UNICODE)
    return re.sub(r"[\s_]+", "-", s.strip()) or "agent"

def hermes_id(data, home_label=""):
    """HERMES's dossier id policy: Hermes has no platform-level agent id, so the
    adapter mints one from the home's directory name and says so in `excluded`
    (SPEC section 2). Imported dossiers keep the source id instead."""
    return f"hermes-{slugify(home_label)}" if home_label else "hermes-home"

# --- SOUL.md <-> role + anti_jobs -------------------------------------------

def render_soul(role, anti_jobs=None):
    """SOUL.md text: the role verbatim, then the anti_jobs list under a header
    (Hermes has no structured anti_jobs slot; the section keeps the text)."""
    parts = [(role or "").rstrip("\n")]
    if anti_jobs:
        parts.append(ANTI_JOBS_HEADER)
        parts.append("\n".join(f"- {a}" for a in anti_jobs))
    return "\n\n".join(parts) + "\n"

def parse_soul(text):
    """Inverse of render_soul. Returns (role, anti_jobs, residue).

    A SOUL.md without the anti_jobs header is all role — nothing is guessed.
    Anything after the header that is not a "- " bullet is returned as
    `residue`: hand-written text added after import, which the exporter folds
    back into the role instead of dropping it."""
    body = (text or "").replace("\r\n", "\n")
    idx = body.find("\n" + ANTI_JOBS_HEADER)
    if idx == -1 and body.startswith(ANTI_JOBS_HEADER):
        idx = 0
        role = ""
        rest = body[len(ANTI_JOBS_HEADER):]
    elif idx != -1:
        role = body[:idx]
        rest = body[idx + 1 + len(ANTI_JOBS_HEADER):]
    else:
        return body.strip(), [], ""
    anti, residue = [], []
    for ln in rest.split("\n"):
        if ln.startswith("- "):
            if ln[2:].strip():
                anti.append(ln[2:].strip())
        elif ln.strip():
            residue.append(ln.rstrip())
    return role.strip(), anti, "\n".join(residue).strip()

# --- memories/MEMORY.md <-> memory entries ----------------------------------

def render_memory(entries):
    """MEMORY.md text: one entry per line, joined by Hermes' "\\n§\\n" delimiter,
    fact verbatim prefixed by the inline "[kind · learned]" tag."""
    lines = []
    for m in entries or []:
        tag_bits = [b for b in ((m or {}).get("kind"), (m or {}).get("learned")) if b]
        tag = f"[{' · '.join(tag_bits)}] " if tag_bits else ""
        fact = ((m or {}).get("fact") or "").replace("\n", " ")
        lines.append(tag + fact)
    return MEMORY_DELIM.join(lines) + "\n" if lines else ""

def parse_memory(text):
    """Inverse of render_memory. A leading bracket group is only read as a tag
    when every part is a valid kind or an ISO date, so a fact that merely
    starts with "[" keeps its text. Each item carries "_tagged" so the exporter
    can say which entries had no kind/learned tag at all."""
    out = []
    for raw in (text or "").split(MEMORY_DELIM):
        entry = raw.strip()
        if not entry:
            continue
        item = {"fact": entry, "kind": "history", "_tagged": False}
        m = _TAG_RE.match(entry)
        if m:
            parts = [p.strip() for p in m.group(1).split("·") if p.strip()]
            if parts and all(p in KIND or _ISO_DATE_RE.match(p) for p in parts):
                item.pop("kind", None)
                for p in parts:
                    if p in KIND:
                        item["kind"] = p
                    else:
                        item["learned"] = p
                item["fact"] = m.group(2).strip()
                item.setdefault("kind", "history")
                item["_tagged"] = True
        out.append(item)
    return out

# --- cron schedule text <-> Hermes job schedule ------------------------------

_CRON_EXPR_RE = re.compile(
    r"(?<!\S)((?:[0-9A-Za-z*/,?-]+\s+){4,5}[0-9A-Za-z*/,?-]+)(?!\S)")
_TZ_RE = re.compile(r"\(\s*([A-Za-z][A-Za-z0-9_+\-/]*)\s*\)")
_TZ_WORD_RE = re.compile(r"\b(UTC[A-Za-z0-9+\-]*|JST|GMT|EST|PST|[A-Z][A-Za-z]+/[A-Za-z_]+)\b")

def split_schedule(schedule):
    """'0 9 * * 1 (Asia/Tokyo)' -> ('0 9 * * 1', 'Asia/Tokyo').

    Returns (expr or None, tz or None, note). A schedule that is not a cron
    expression (an event description, prose) yields expr None: Hermes cron
    cannot express it and the importer must say so instead of inventing one."""
    text = (schedule or "").strip()
    if not text:
        return None, None, "empty schedule"
    tz = None
    m = _TZ_RE.search(text)
    if m:
        tz = m.group(1)
        stripped = text[:m.start()] + text[m.end():]
    else:
        m = _TZ_WORD_RE.search(text)
        if m:
            tz = m.group(1)
        stripped = text
    m = _CRON_EXPR_RE.search(stripped)
    if not m:
        return None, tz, "not a cron expression"
    expr = " ".join(m.group(1).split())
    fields = expr.split()
    if len(fields) == 6:
        # Hermes' parse_schedule hands 5-6 field expressions to croniter, which
        # reads a 6th leading field as seconds; the dossier's schedules are
        # 5-field plus optional year. Keep the first five and say so.
        note = f"6-field expression, kept first five fields ({' '.join(fields[:5])})"
        return " ".join(fields[:5]), tz, note
    return expr, tz, ""

def next_run_iso(expr):
    """Next fire time for a cron expression, or None.

    Hermes' scheduler computes next_run_at itself (croniter, in the profile's
    timezone) and re-arms a job whose next_run_at is empty on its due scan, so
    leaving it empty is safe. Filling it in when croniter happens to be present
    (it is, inside Hermes' own runtime) keeps `hermes cron doctor` quiet from
    the first minute."""
    try:
        from croniter import croniter
    except Exception:
        return None
    try:
        now = datetime.datetime.now().astimezone()
        nxt = croniter(expr, now).get_next(datetime.datetime)
        return nxt.astimezone().isoformat() if nxt.tzinfo else nxt.replace(
            tzinfo=now.tzinfo).isoformat()
    except Exception:
        return None

def hermes_memory_budget(home):
    """memory_char_limit from the target home's config.yaml (read-only), else
    Hermes' default 2200 (tools/memory_tool.py:store_from_config)."""
    path = home / CONFIG_FILE
    try:
        cfg = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
        limit = int(((cfg.get("memory") or {}).get("memory_char_limit")) or 2200)
    except Exception:
        limit = 2200
    return limit
````

## File: adapters/hermes/export_dossier.py
size: 9852
sha: 4c298153e12b16844679d90a24ae1b06fd17d6bc
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/hermes/export_dossier.py

```text
#!/usr/bin/env python3
"""Exporter for personal-agent-dossier/1.0 <- Hermes.

Reads one Hermes home (the directory given with --home; nothing else is
touched) and renders a dossier:

  role, anti_jobs   SOUL.md
  memory            memories/MEMORY.md (Hermes' "\\n§\\n" entries)
  routines          cron/jobs.json job records
  id/name/title/... dossier/import-meta.json + dossier/<slug>.dossier.md when
                    the home was populated by import_dossier.py, otherwise the
                    adapter mints an id and says so in `excluded`

Every export:
- recomputes the fingerprint from the live content (never copies it),
- compares it with the archived fingerprint and reports drift,
- runs the SPEC section 5 scan over role / memory facts / routine prompts and
  ABORTS on a hit without writing anything (never auto-rewrites),
- is idempotent: same home and same --date give byte-identical output.

Usage: export_dossier.py --home DIR [--out FILE] [--date YYYY-MM-DD]
                         [--id ID] [--name NAME] [--title TITLE]
"""

import datetime
import json
import os
import sys
from pathlib import Path

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import dossier_lib as dl

def read_json(path, default=None):
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return default

def read_text(path, default=""):
    try:
        return path.read_text(encoding="utf-8")
    except Exception:
        return default

def archived_template(home, meta):
    """Fields no live Hermes slot holds (id, name, title, group, status, refs,
    skills, excluded) from the archive this adapter wrote at import time; None
    when the home was never populated from a dossier."""
    if not home.is_dir():
        return None
    candidates = []
    if isinstance(meta, dict) and meta.get("archive"):
        candidates.append(home / str(meta["archive"]))
    arch = home / dl.ARCHIVE_DIR
    if arch.is_dir():
        candidates.extend(sorted(arch.glob("*.dossier.md")))
    for path in candidates:
        if path.exists():
            try:
                data, _ = dl.load_dossier_file(path)
            except Exception:
                continue
            if isinstance(data, dict):
                return data
    return None

def routines_from_home(home, notes):
    """cron/jobs.json -> routine entries. Every job in this home belongs to this
    agent, so every job is exported (not just the ones this adapter created)."""
    data = read_json(home / dl.CRON_FILE, None)
    if data is None:
        return []
    jobs = data.get("jobs") if isinstance(data, dict) else data
    if not isinstance(jobs, list):
        notes.append("cron/jobs.json: unreadable job list, routines exported as none")
        return []
    out = []
    for job in jobs:
        if not isinstance(job, dict):
            continue
        sched = job.get("schedule") if isinstance(job.get("schedule"), dict) else {}
        meta = job.get("dossier") if isinstance(job.get("dossier"), dict) else {}
        tz = meta.get("timezone")
        if sched.get("kind") == "cron" and sched.get("expr"):
            schedule = str(sched["expr"]) + (f" ({tz})" if tz else "")
        elif sched.get("kind") == "once" and sched.get("run_at"):
            schedule = f"once at {sched['run_at']}"
        elif sched.get("kind") == "interval" and sched.get("minutes"):
            schedule = f"every {int(sched['minutes'])}m"
        else:
            schedule = str(job.get("schedule_display") or "?")
        if job.get("state") in ("completed", "error"):
            state = "paused"
            notes.append(f"routine {job.get('name')!r}: job state {job['state']!r} exported as "
                         "'paused' (a finished or failed job is not running and was not deleted)")
        elif job.get("enabled") is False:
            state = "paused"
        else:
            state = "running"
        out.append({
            "id": str(job.get("id") or ""),
            "name": job.get("name") or str(job.get("id") or "cron job"),
            "schedule": schedule,
            "prompt": job.get("prompt") or "",
            "state": state,
        })
    return out

def collect(home, notes):
    """Read what this home exposes. Returns (dossier dict, archived data|None)."""
    soul_path = home / dl.SOUL_FILE
    if not soul_path.exists():
        raise SystemExit(f"no {dl.SOUL_FILE} in {home}: this does not look like a Hermes home")
    role, anti_jobs, residue = dl.parse_soul(read_text(soul_path))
    if residue:
        role = (role + "\n\n" + residue).strip()
        notes.append("SOUL.md: text found after the anti_jobs section is folded back into the "
                     "role (nothing is dropped; the file's structure was changed after import)")

    meta = read_json(home / dl.ARCHIVE_DIR / "import-meta.json", None)
    prev = archived_template(home, meta)

    memory = dl.parse_memory(read_text(home / dl.MEMORY_FILE))
    untagged = [m for m in memory if not m.get("_tagged")]
    routines = routines_from_home(home, notes)

    data: dict = {"spec": "personal-agent-dossier/1.0", "source": "hermes"}
    for key in ("id", "name", "title", "group", "status", "refs", "skills", "excluded"):
        if prev and prev.get(key) is not None:
            data[key] = prev[key]
    data.setdefault("status", "active")

    label = home.name
    if not data.get("name"):
        data["name"] = label
        notes.append(f"name: no archived name in this home; used the home directory name "
                     f"{label!r}")
    if not data.get("id"):
        data["id"] = dl.hermes_id(dict(data), label)
        notes.append(f"id: Hermes has no native agent id; the adapter minted {data['id']!r} "
                     "from the home directory name (SPEC section 2)")

    data["role"] = role
    if anti_jobs:
        data["anti_jobs"] = anti_jobs
    live_memory = [{"fact": m["fact"], **(dict(kind=m["kind"]) if m.get("kind") else {}),
                    **(dict(learned=m["learned"]) if m.get("learned") else {})}
                   for m in memory]
    # null means "not exported last time", not "none" (SPEC section 6): keep it
    # null unless the home now holds entries this agent really has.
    if prev is not None and prev.get("memory") is None and not live_memory:
        data["memory"] = None
        notes.append("memory: the archive says it was never exported (null), and this home still "
                     "has no entries — exported as null, not as an empty list")
    else:
        data["memory"] = live_memory
    if prev is not None and prev.get("routines") is None and not routines:
        data["routines"] = None
        notes.append("routines: the archive says they were never exported (null), and this home "
                     "has no cron jobs — exported as null, not as an empty list")
    else:
        data["routines"] = routines

    if untagged:
        note = (f"没有 kind 标签的 {len(untagged)} 条记忆按 history 导出（Hermes 记忆条目是"
                "纯文本，本身没有 kind 字段）")
        data["excluded"] = "；".join([e for e in [data.get("excluded") or ""] if e] + [note])
    return data, prev

def parse_args(argv):
    opts, positional = {}, []
    i = 0
    flags = {"--home": "home", "--out": "out", "--date": "date",
             "--id": "id", "--name": "name", "--title": "title"}
    while i < len(argv):
        arg = argv[i]
        if arg in flags:
            if i + 1 >= len(argv):
                sys.exit(f"{arg} needs a value")
            opts[flags[arg]] = argv[i + 1]
            i += 2
        else:
            positional.append(arg)
            i += 1
    return opts, positional

def main():
    opts, positional = parse_args(sys.argv[1:])
    if "home" not in opts or positional:
        sys.exit(__doc__)
    home = Path(opts["home"]).expanduser().resolve()

    notes = []
    data, prev = collect(home, notes)
    if opts.get("id"):
        data["id"] = opts["id"]
    if opts.get("name"):
        data["name"] = opts["name"]
    if opts.get("title") is not None:
        data["title"] = opts["title"]
    data["exported_at"] = opts.get("date") or datetime.date.today().isoformat()

    hits = dl.scan_fields(dl.dossier_texts(data))
    if hits:
        for where, pat in hits:
            print(f"ABORT: sensitive pattern {pat} in {where}; export stopped, nothing written "
                  "(SPEC 5.2: report, never auto-rewrite)", file=sys.stderr)
        sys.exit(1)

    missing = [k for k in ("id", "name", "status", "role") if not data.get(k)]
    if missing:
        print("ABORT: this home is missing required content:", ", ".join(missing), file=sys.stderr)
        sys.exit(1)
    if not dl.normalize(data.get("role")):
        print("ABORT: SOUL.md holds no role text", file=sys.stderr)
        sys.exit(1)

    fp = dl.fingerprint(data.get("name"), data.get("title"), data.get("role"))
    if isinstance(prev, dict) and prev.get("fingerprint") and prev["fingerprint"] != fp:
        notes.append(f"drift: archived fingerprint {prev['fingerprint']} -> live {fp}; the role "
                     "changed since import (SPEC section 6 uses this to detect function change)")

    text = dl.render_dossier(data)
    for n in notes:
        print("note:", n, file=sys.stderr)
    if opts.get("out"):
        with open(opts["out"], "w", encoding="utf-8") as f:
            f.write(text)
        mem_n = "null" if data["memory"] is None else len(data["memory"])
        rou_n = "null" if data["routines"] is None else len(data["routines"])
        print(f"wrote {opts['out']} fingerprint {fp} name={data['name']!r} "
              f"id={data['id']!r} memory={mem_n} routines={rou_n}")
    else:
        sys.stdout.write(text)

if __name__ == "__main__":
    main()
```

## File: adapters/hermes/import_dossier.py
size: 23976
sha: b2721cfb72133eadea0fdf9324b932c0e13d1939
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/hermes/import_dossier.py

````text
#!/usr/bin/env python3
"""Importer for personal-agent-dossier/1.0 (and legacy agent-dossier/0.1, 0.2) -> Hermes.

Default is a dry run: it reads one dossier (front matter file, bare YAML, or
Notion page text holding exactly one ```yaml block), runs every SPEC check,
and prints the import plan plus the loss/downgrade list. Nothing is created or
modified.

With --apply --target-home DIR it writes the dossier into that Hermes home,
using Hermes' own layout:
  SOUL.md                     role verbatim + anti_jobs section
  memories/MEMORY.md          one entry per fact, "[kind · learned] fact"
  cron/jobs.json              routines as Hermes cron job records
  dossier/<slug>.dossier.md   the archived original (every field, verbatim)
  dossier/import-meta.json    the field map, the minted/kept ids, and the
                              bookkeeping that makes re-import an update by id

--target-home only ever points at the directory given on the command line.
Writing into a live Hermes home replaces that home's identity file, so a
pre-existing SOUL.md that this adapter did not write requires --replace-soul.

Level: this adapter declares L2 for the deployment shape it implements — one
dossier per Hermes home. Sync by id, cron creation and memory writes are real
Hermes mechanisms; see adapter-report.md section 1 for the caveats.

Usage: import_dossier.py FILE [--target-home DIR] [--apply] [--replace-soul]
"""

import datetime
import json
import os
import sys
import uuid
from pathlib import Path

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import dossier_lib as dl

LEVEL_LINE = ("platform level: L2 (Hermes: one dossier per home; role -> SOUL.md, "
              "facts -> memories/MEMORY.md, routines -> cron/jobs.json, re-import "
              "updates by id; cron ids and memory dates still change)")

# field -> (where it lands in a Hermes home, fidelity, note)
FIELD_MAP = [
    ("id", "dossier/import-meta.json", "recorded",
     "Hermes has no platform-level agent id; the source id is kept verbatim and "
     "is what a re-import pairs on"),
    ("name", "dossier/import-meta.json, dossier/<slug>.dossier.md", "verbatim",
     "Hermes names an agent by its home directory, not by a display name"),
    ("title", "dossier/import-meta.json, dossier/<slug>.dossier.md", "downgraded",
     "no title slot; kept in the archive, folded into no live field"),
    ("group", "dossier/<slug>.dossier.md", "stored-nowhere-live",
     "Hermes has no sidebar/section grouping"),
    ("status", "dossier/import-meta.json", "recorded",
     "documentary; a retired dossier still imports"),
    ("exported_at", "dossier/import-meta.json", "recorded", "documentary"),
    ("source", "dossier/import-meta.json", "recorded",
     "part of the pairing key together with id"),
    ("fingerprint", "dossier/import-meta.json", "recomputed",
     "recomputed on every import and compared with the file; a mismatch refuses"),
    ("role", "SOUL.md", "verbatim",
     "SOUL.md is Hermes' identity slot and is injected into the system prompt"),
    ("anti_jobs", "SOUL.md (## 反职责（anti_jobs） section)", "downgraded",
     "plain text section, no structured field; content is verbatim"),
    ("routines", "cron/jobs.json", "downgraded",
     "Hermes cron job records; ids are assigned by the adapter, state maps to "
     "enabled/paused, next_run_at is left for Hermes to compute"),
    ("memory", "memories/MEMORY.md", "downgraded",
     "Hermes' §-delimited memory entries; kind/learned become an inline "
     "[kind · learned] tag, fact text is verbatim"),
    ("refs", "dossier/<slug>.dossier.md", "stored-nowhere-live",
     "ids do not resolve on Hermes; names are listed for a human to re-match"),
    ("skills", "dossier/import-meta.json + skills/ presence check", "downgraded",
     "only names travel; a same-named skill must already exist under skills/"),
    ("excluded", "dossier/<slug>.dossier.md", "verbatim", "documentary"),
]

def hermes_paths(home):
    return {
        "soul": home / dl.SOUL_FILE,
        "memory_dir": home / dl.MEMORY_DIR,
        "memory": home / dl.MEMORY_FILE,
        "user_memory": home / dl.USER_MEMORY_FILE,
        "skills": home / dl.SKILLS_DIR,
        "cron_dir": home / dl.CRON_FILE.rsplit("/", 1)[0],
        "cron": home / dl.CRON_FILE,
        "cron_output": home / dl.CRON_OUTPUT_DIR,
        "archive": home / dl.ARCHIVE_DIR,
        "meta": home / dl.ARCHIVE_DIR / "import-meta.json",
    }

def load_meta(home):
    path = hermes_paths(home)["meta"]
    if not path.exists():
        return None
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return None

def read_json(path, default):
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return default

def load_jobs(home):
    """{'jobs': [...], 'updated_at': ...} from cron/jobs.json; Hermes also
    accepts a bare list, so keep whichever shape we found."""
    path = hermes_paths(home)["cron"]
    data = read_json(path, None)
    if isinstance(data, dict):
        jobs = data.get("jobs")
        return [j for j in jobs if isinstance(j, dict)] if isinstance(jobs, list) else [], data
    if isinstance(data, list):
        return [j for j in data if isinstance(j, dict)], {"jobs": data}
    return [], None

def installed_skills(home):
    """Skill names a Hermes home can load: directory names plus the `name:` of
    each SKILL.md front matter, one level of category dirs deep."""
    root = hermes_paths(home)["skills"]
    names = set()
    if not root.is_dir():
        return names
    for path in root.rglob("SKILL.md"):
        names.add(path.parent.name.lower())
        try:
            head = path.read_text(encoding="utf-8").split("---")[1]
        except Exception:
            continue
        for line in head.splitlines():
            if line.strip().startswith("name:"):
                names.add(line.split(":", 1)[1].strip().strip("'\"\"'").lower())
    return names

def build_plan(data, home, meta, existing_jobs):
    """Returns (plan, loss) lines. Pure read: no writes."""
    plan, loss = [], []
    target = str(home) if home else "<target-home not given>"
    name = data.get("name") or "agent"
    slug = dl.slugify(name)

    soul = hermes_paths(home)["soul"] if home else None
    if soul is not None and soul.exists() and meta is None and soul.read_text(encoding="utf-8").strip():
        plan.append(f"{dl.SOUL_FILE}: exists and was not written by this adapter "
                    f"({len(soul.read_text(encoding='utf-8'))} chars) — --apply will refuse "
                    "unless --replace-soul is given, in which case the old file is kept as "
                    "SOUL.md.pre-dossier")
    else:
        plan.append(f"{dl.SOUL_FILE}: write role verbatim, then a "
                    f"'{dl.ANTI_JOBS_HEADER}' section for anti_jobs — SOUL.md is Hermes' "
                    "identity slot, injected into every session's system prompt")
    loss.append(f"id: Hermes keys an agent by its home directory, not by a dossier id; the "
                f"source id ({data.get('id')!r}) is kept verbatim in "
                "dossier/import-meta.json and dossier/<slug>.dossier.md, and a re-import "
                "pairs on source+id")
    if data.get("title"):
        loss.append(f"title: no title slot in Hermes; {data['title']!r} is kept in the archive "
                    "only (it is not used for matching, pairing, or display)")
    if data.get("group"):
        loss.append(f"group {data['group']!r}: Hermes has no section/grouping concept; kept in "
                    "the archive only")
    if data.get("anti_jobs"):
        loss.append(f"anti_jobs ({len(data['anti_jobs'])}): no structured field on Hermes; kept "
                    "as a plain text section in SOUL.md (content verbatim, structure gone)")

    routines = data.get("routines")
    if routines is None:
        loss.append("routines: not exported (null) — 未导出，不等于没有. The archived copy is "
                    "kept and no cron job is planned.")
    elif not routines:
        plan.append("routines: [] (the dossier says there are none) — cron/jobs.json untouched")
    else:
        for r in routines:
            expr, tz, note = dl.split_schedule(r.get("schedule"))
            state = r.get("state")
            if state == "deleted":
                plan.append(f"routine {r.get('name')!r}: state=deleted — kept in the archive, no "
                            "cron job created (a job this adapter created earlier for it would "
                            "be removed)")
            elif expr is None:
                plan.append(f"routine {r.get('name')!r}: schedule {r.get('schedule')!r} is not a "
                            "cron expression, so no Hermes cron job can carry it — kept in the "
                            "archive")
                loss.append(f"routines[{r.get('name')!r}].schedule: "
                            f"{note or 'not a cron expression'}; Hermes cron needs a cron "
                            "expression, so this routine exists only as archived text")
            else:
                nxt = dl.next_run_iso(expr) if state == "running" else None
                plan.append(f"routine {r.get('name')!r}: Hermes cron job with expr {expr!r}"
                            + (f" (dossier timezone {tz}; Hermes evaluates exprs in the "
                               f"profile timezone)" if tz else "")
                            + (f", first run {nxt}" if nxt else
                               ", next_run_at left empty for Hermes' ticker")
                            + f", state {state!r} -> "
                            + ("enabled" if state == "running" else "paused"))
                if note:
                    loss.append(f"routines[{r.get('name')!r}].schedule: {note}")
        loss.append(f"routines ({len(routines)}): Hermes assigns job ids itself and has no "
                    "per-job timezone, so routine ids change and the dossier's timezone text "
                    "only survives as archived prose; next_run_at is computed with croniter when it "
                    "is available and otherwise left for Hermes' ticker to compute on its "
                    "first scan")

    memory = data.get("memory")
    if memory is None:
        loss.append("memory: not exported (null) — 未导出，不等于没有. The archived copy is kept "
                    "and MEMORY.md is left untouched.")
    elif not memory:
        plan.append("memory: [] (the dossier says there are none) — memories/MEMORY.md untouched")
    else:
        plan.append(f"memory ({len(memory)} facts): memories/MEMORY.md, one entry per fact, "
                    "entries joined by Hermes' own '\\n§\\n' delimiter")
        loss.append(f"memory ({len(memory)}): kind and learned degrade to an inline "
                    "'[kind · learned]' tag — Hermes memory entries are plain text — and "
                    "learned is not used as a sort key by the platform; fact text is verbatim "
                    "except that newlines inside a fact become spaces (one entry per line)")
    if data.get("skills"):
        if home:
            have = installed_skills(home)
            missing = [s for s in data["skills"] if s.lower() not in have
                       and dl.slugify(s) not in have]
            plan.append(f"skills: {len(data['skills'])} names checked against "
                        f"{str(hermes_paths(home)['skills'])} — "
                        + (f"missing: {', '.join(missing)}" if missing else "all present"))
            if missing:
                loss.append(f"skills: {missing} — the dossier carries names only; no skill "
                            "directory is created, so these must be installed separately")
        else:
            loss.append(f"skills {data['skills']}: only names travel; a skill exists on Hermes "
                        "only if a same-named SKILL.md is installed under skills/")
    if data.get("refs"):
        loss.append("refs: ids do not resolve on Hermes; the names are kept in the archive for a "
                    "human to re-match against agents on this platform")
    if data.get("excluded"):
        loss.append("excluded by exporter: " + str(data["excluded"]))

    if home:
        existing = []
        mem_path = hermes_paths(home)["memory"]
        if mem_path.exists():
            existing = [e.strip() for e in
                        mem_path.read_text(encoding="utf-8").split(dl.MEMORY_DELIM) if e.strip()]
        prev_written = set((meta or {}).get("written_memory") or [])
        kept = [e for e in existing if e.strip() not in prev_written]
        new = [e.strip() for e in dl.render_memory(memory or []).split(dl.MEMORY_DELIM)
               if e.strip()]
        final = kept + new
        budget = dl.hermes_memory_budget(home)
        total = len(dl.MEMORY_DELIM.join(final))
        plan.append(f"memory placement: {len(kept)} existing entr"
                    f"{'y' if len(kept) == 1 else 'ies'} kept (entries this adapter wrote "
                    f"earlier are replaced, never duplicated), {len(new)} written; "
                    f"{total}/{budget} chars against the home's memory_char_limit")
        if total > budget:
            loss.append(f"memory size: {total} chars exceeds the home's memory_char_limit "
                        f"({budget}); Hermes injects the store but refuses further additions "
                        "until it fits — the import is not truncated")

    our_ids = set((meta or {}).get("written_jobs", {}).values())
    foreign = [j for j in existing_jobs if j.get("id") not in our_ids]
    if existing_jobs:
        plan.append(f"cron/jobs.json: {len(foreign)} job(s) not written by this adapter are kept "
                    f"as they are, {len(existing_jobs) - len(foreign)} written by it are "
                    "updated or removed")
    plan.append(f"archive: dossier/{slug}.dossier.md (every field verbatim) + "
                "dossier/import-meta.json (field map, ids, bookkeeping)")
    plan.append(f"target home: {target}")
    return plan, loss

def write_text_atomic(path, text):
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_name(path.name + ".tmp")
    tmp.write_text(text, encoding="utf-8")
    os.replace(tmp, path)

def apply_import(data, home, replace_soul):
    """Write the dossier into *home*. Returns (written paths, notes)."""
    paths = hermes_paths(home)
    meta = load_meta(home)
    notes, written = [], []
    slug = dl.slugify(data.get("name") or "agent")

    # SOUL.md — identity slot. Never clobber a soul this adapter did not write,
    # and refuse before creating anything at all.
    soul_text = dl.render_soul(data.get("role"), data.get("anti_jobs"))
    if paths["soul"].exists():
        old = paths["soul"].read_text(encoding="utf-8")
        if old != soul_text:
            if meta is None and not replace_soul:
                raise SystemExit(
                    "refusing to overwrite an existing SOUL.md this adapter did not write; "
                    "nothing was written; re-run with --replace-soul to keep the old file as "
                    "SOUL.md.pre-dossier first")
            if meta is None:
                backup = paths["soul"].with_name(dl.SOUL_FILE + ".pre-dossier")
                backup.write_text(old, encoding="utf-8")
                notes.append(f"old SOUL.md kept as {backup.name}")
            write_text_atomic(paths["soul"], soul_text)
            written.append(str(paths["soul"]))
    else:
        write_text_atomic(paths["soul"], soul_text)
        written.append(str(paths["soul"]))

    for key in ("memory_dir", "skills", "cron_dir", "cron_output", "archive"):
        paths[key].mkdir(parents=True, exist_ok=True)

    # memories/MEMORY.md — Hermes' §-delimited store.
    existing = []
    if paths["memory"].exists():
        existing = [e.strip() for e in
                    paths["memory"].read_text(encoding="utf-8").split(dl.MEMORY_DELIM) if e.strip()]
    prev_written = set((meta or {}).get("written_memory") or [])
    kept = [e for e in existing if e.strip() not in prev_written]
    new_entries = [e.strip() for e in
                   dl.render_memory(data.get("memory") or []).split(dl.MEMORY_DELIM) if e.strip()]
    final = kept + new_entries
    mem_text = dl.MEMORY_DELIM.join(final)
    if mem_text:
        mem_text += "\n"
    if final or paths["memory"].exists():
        current = paths["memory"].read_text(encoding="utf-8") if paths["memory"].exists() else ""
        if current != mem_text:
            write_text_atomic(paths["memory"], mem_text)
            written.append(str(paths["memory"]))
    if data.get("memory") is None and paths["memory"].exists():
        notes.append("memory was null (not exported): MEMORY.md left exactly as it was")

    # cron/jobs.json — Hermes job records.
    jobs, raw = load_jobs(home)
    prev_jobs = dict((meta or {}).get("written_jobs") or {})
    now = datetime.datetime.now().astimezone().isoformat()
    changed = False
    for r in data.get("routines") or []:
        rid = str(r.get("id"))
        prev_id = prev_jobs.get(rid)
        if r.get("state") == "deleted":
            if prev_id:
                jobs = [j for j in jobs if j.get("id") != prev_id]
                prev_jobs.pop(rid, None)
                changed = True
                notes.append(f"removed cron job {prev_id} for deleted routine {r.get('name')!r}")
            continue
        expr, tz, _note = dl.split_schedule(r.get("schedule"))
        if expr is None:
            continue
        job_id = prev_id or uuid.uuid4().hex[:12]
        running = r.get("state") == "running"
        record = {
            "id": job_id,
            "name": r.get("name") or job_id,
            "prompt": r.get("prompt") or "",
            "skills": [],
            "skill": None,
            "model": None,
            "provider": None,
            "base_url": None,
            "script": None,
            "no_agent": False,
            "monitor_script": None,
            "monitor_url": None,
            "monitor_state": None,
            "context_from": None,
            "schedule": {"kind": "cron", "expr": expr,
                         "display": r.get("schedule") or expr},
            "schedule_display": r.get("schedule") or expr,
            "repeat": {"times": None, "completed": 0},
            "enabled": running,
            "state": "scheduled" if running else "paused",
            "paused_at": None if running else now,
            "paused_reason": None if running else (
                "imported from a dossier as paused; enable after review"),
            "created_at": now,
            # None is Hermes' own "recompute" signal (its ticker arms the job on
            # the next due scan, cron/jobs.py:_recover_missing_next_run); filled
            # in when croniter is importable so `hermes cron doctor` stays quiet.
            "next_run_at": None if not running else dl.next_run_iso(expr),
            "last_run_at": None,
            "last_status": None,
            "last_error": None,
            "last_delivery_error": None,
            "last_delivery_unverified": None,
            "last_failure": None,
            "failure_streak": 0,
            "deliver": "local",
            "origin": None,
            "enabled_toolsets": None,
            "workdir": None,
            "dossier": {"source": data.get("source"), "id": str(data.get("id")),
                        "routine_id": rid, "timezone": tz},
        }
        idx = next((i for i, j in enumerate(jobs) if j.get("id") == job_id), None)
        if idx is None:
            jobs.append(record)
        else:
            created_at = jobs[idx].get("created_at")
            if created_at:
                record["created_at"] = created_at
            jobs[idx] = record
        prev_jobs[rid] = job_id
        changed = True
    if changed or (paths["cron"].exists() and raw is None):
        payload = {"jobs": jobs, "updated_at": now}
        write_text_atomic(paths["cron"], json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
        written.append(str(paths["cron"]))

    # dossier/ — the archive: every field verbatim, plus the field map.
    archive_path = paths["archive"] / f"{slug}.dossier.md"
    write_text_atomic(archive_path, dl.render_dossier(data))
    written.append(str(archive_path))

    meta_out = {
        "spec": data.get("spec"),
        "id": str(data.get("id")),
        "name": data.get("name"),
        "title": data.get("title"),
        "group": data.get("group"),
        "status": data.get("status"),
        "exported_at": data.get("exported_at"),
        "source": data.get("source"),
        "fingerprint": data.get("fingerprint"),
        "imported_at": now,
        "slug": slug,
        "home": str(home),
        "archive": f"{dl.ARCHIVE_DIR}/{slug}.dossier.md",
        "written_memory": new_entries,
        "written_jobs": prev_jobs,
        "field_map": [{"field": f, "where": w, "fidelity": fid, "note": n}
                      for f, w, fid, n in FIELD_MAP],
    }
    write_text_atomic(paths["meta"], json.dumps(meta_out, indent=2, ensure_ascii=False) + "\n")
    written.append(str(paths["meta"]))
    return written, notes

def main():
    args = sys.argv[1:]
    apply = "--apply" in args
    if apply:
        args.remove("--apply")
    replace_soul = "--replace-soul" in args
    if replace_soul:
        args.remove("--replace-soul")
    home = None
    if "--target-home" in args:
        i = args.index("--target-home")
        if i + 1 >= len(args):
            sys.exit("--target-home needs a directory")
        home = Path(args[i + 1]).expanduser().resolve()
        del args[i:i + 2]
    if len(args) != 1:
        sys.exit(__doc__)
    if apply and home is None:
        sys.exit("--apply needs --target-home DIR (this adapter never guesses a home)")

    src = args[0]
    try:
        data, _body = dl.load_dossier_file(src)
    except dl.DossierError as e:
        print("FAIL: " + str(e))
        sys.exit(1)
    errors, warnings = dl.validate(data)
    print(("APPLY " if apply else "DRY RUN ") + os.path.basename(src))
    print(LEVEL_LINE)
    print("checks:", "PASS" if not errors else "FAIL")
    for e in errors:
        print("  FAIL", e)
    for w in warnings:
        print("  WARN", w)
    if errors:
        sys.exit(1)

    meta = load_meta(home) if home else None
    if home and not home.exists():
        print("target home:", home, "(does not exist yet; --apply creates it)")
    if meta and (str(meta.get("source")) != str(data.get("source"))
                 or str(meta.get("id")) != str(data.get("id"))):
        print(f"FAIL: {home} already holds a dossier for source={meta.get('source')!r} "
              f"id={meta.get('id')!r}; one Hermes home carries one agent identity "
              "(its SOUL.md), so import this dossier into its own home")
        sys.exit(1)

    existing_jobs, _raw = load_jobs(home) if home else ([], None)
    plan, loss = build_plan(data, home, meta, existing_jobs)
    for p in plan:
        print("plan:", p)
    for l in loss:
        print("loss/downgrade:", l)

    if not apply:
        print("note: dry run only. Nothing was created or modified. Re-run with "
              "--apply --target-home DIR to write this dossier into a Hermes home.")
        return

    written, notes = apply_import(data, home, replace_soul)
    for n in notes:
        print("applied note:", n)
    for w in written:
        print("wrote:", w)
    print(f"applied: {len(written)} file(s) under {home}; nothing outside it was touched.")
    print("note: memories/USER.md is the user profile store and is never written by this "
          "importer; no other profile's files are touched.")

if __name__ == "__main__":
    main()
````

## File: adapters/muse/adapter-report.md
size: 16512
sha: 6df779551179c68387ef47775ab3835d821f0534
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/muse/adapter-report.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/muse/adapter-report.md

## File: adapters/muse/dossier_lib.py
size: 12309
sha: 622663533d8348a8983c35a3d0f00772d24c55e4
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/muse/dossier_lib.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/muse/dossier_lib.py

## File: adapters/muse/examples/kubo-snapshot.json
size: 1578
sha: e7c0cbea34d9e53df0c9cf50dc4fdd173c4d81da
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/muse/examples/kubo-snapshot.json

```text
{"anti_jobs":["不替用户做付款、购买和申告类决定，只摆选项和依据","未经用户确认不对外发送消息、不发布内容、不删除数据","不导出、不复述其他 agent 的记忆；只导出自己的职能","不把用户的个人信息写进对外文件"],"excluded":"本次演示导出未包含 routines 与 memory（写 null）。原因有二：真实定时任务的 prompt 里嵌有大量用户专属的操作细节，逐条脱敏审查后才能导出；Muse 的长期记忆文件（MEMORY.md 等）记的是用户本人的事实与偏好、不是 agent 自己的，按 SPEC 第 5.3 条的精神不属于本 agent 可导出的记忆。id 为本平台内自取的标识（Muse 没有平台级 agent id）。","id":"muse-main-kubo","memory":null,"name":"Kubo","role":"我是 Kubo，用户个人的 AI 助理，跑在 Muse 平台上。日常工作是替用户处理信息和协调事务：读邮件和日历做简报、盯投资与资产的每日快照、照看智能家居和车辆状态、筛演唱会与收藏品的抽选信息、监测宠物的健康数据，并把这些做成按时到达的侧聊简报，异常才提醒主聊天。做事方式：先查证再下结论，给可复核的数字和来源；动手前先摆清单和边界，敏感动作逐项等用户确认；被纠正时先认错、重算、再把新规则记下来。语气直接、简洁，有自己的判断，不说客套话。","routines":null,"skills":["email_management","scheduling","home_control"],"status":"active","title":"个人助理（Muse 平台主 agent）"}
```

## File: adapters/muse/examples/kubo.dossier.md
size: 1789
sha: ea070045499ed3da9a3801af1fbbc35a98db2444
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/muse/examples/kubo.dossier.md

```text
---
spec: personal-agent-dossier/1.0
id: muse-main-kubo
name: Kubo
title: 个人助理（Muse 平台主 agent）
status: active
exported_at: 2026-10-28
source: muse
fingerprint: 319caff2ebdc
role: |-
  我是 Kubo，用户个人的 AI 助理，跑在 Muse 平台上。日常工作是替用户处理信息和协调事务：读邮件和日历做简报、盯投资与资产的每日快照、照看智能家居和车辆状态、筛演唱会与收藏品的抽选信息、监测宠物的健康数据，并把这些做成按时到达的侧聊简报，异常才提醒主聊天。做事方式：先查证再下结论，给可复核的数字和来源；动手前先摆清单和边界，敏感动作逐项等用户确认；被纠正时先认错、重算、再把新规则记下来。语气直接、简洁，有自己的判断，不说客套话。
anti_jobs:
  - 不替用户做付款、购买和申告类决定，只摆选项和依据
  - 未经用户确认不对外发送消息、不发布内容、不删除数据
  - 不导出、不复述其他 agent 的记忆；只导出自己的职能
  - 不把用户的个人信息写进对外文件
routines: null
memory: null
skills:
  - email_management
  - scheduling
  - home_control
excluded: 本次演示导出未包含 routines 与 memory（写 null）。原因有二：真实定时任务的 prompt 里嵌有大量用户专属的操作细节，逐条脱敏审查后才能导出；Muse 的长期记忆文件（MEMORY.md 等）记的是用户本人的事实与偏好、不是 agent 自己的，按 SPEC 第 5.3 条的精神不属于本 agent 可导出的记忆。id 为本平台内自取的标识（Muse 没有平台级 agent id）。
---

# Kubo 交接档案

这份文件由 front matter 提供机器可读的全部字段；正文只做人读说明。
```

## File: adapters/muse/export_muse.py
size: 6185
sha: d9a1a32a1d920bc7844f7ac85138fd5dbb98fcdb
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/muse/export_muse.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/muse/export_muse.py

## File: adapters/muse/import_dossier.py
size: 8803
sha: 293d077f58e91814187aa9501afe1b215669954d
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/muse/import_dossier.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/muse/import_dossier.py

## File: adapters/openclaw/adapter-report.md
size: 18949
sha: 452471dfea80ee50f6f6d76280c029649d0d70df
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/openclaw/adapter-report.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/openclaw/adapter-report.md

## File: adapters/openclaw/dossier_lib.py
size: 38949
sha: 853c53c51940012923148e9978902acbbc173ddf
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/openclaw/dossier_lib.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/openclaw/dossier_lib.py

## File: adapters/openclaw/export_dossier.py
size: 10520
sha: 67b3a0d5f3d2eb7d63ce1fd12001b43b5e2dc66a
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/openclaw/export_dossier.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/openclaw/export_dossier.py

## File: adapters/openclaw/import_dossier.py
size: 13440
sha: 398463376a41bcb1b14ab85cad2a2fff192058a8
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/openclaw/import_dossier.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/adapters/openclaw/import_dossier.py

## File: docs/conformance-test.md
size: 1234
sha: 0d4afb7f35561792cded4c890efec44dcfcd12d8
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/docs/conformance-test.md

```text
# 导入方一致性测试（personal-agent-dossier/1.0）

写适配器本身见 `skills/personal-agent-dossier-adapter/SKILL.md`。

用途：让一个新平台（或一个 agent）验证自己能否读懂并还原一份档案。把本文和 `SPEC.md`、`tests/`、`examples/scribe.dossier.md` 交给被测方即可。

被测方请按顺序做，把报告写成 `import-report.md`：

1. 校验：按 SPEC 2.1 节算 fingerprint。先用 `tests/fingerprint-vectors.json` 的全部向量验证自己的实现，全过再算档案，写出两个值和是否一致。再读 SPEC 6.1 节，在报告开头声明自己属于 L0、L1 还是 L2。
2. 敏感信息检查：档案里有没有邮箱、电话、卡号、令牌，写出结果。
3. 复述：用自己的话说出角色的职能和全部反职责，不要照抄。
4. 还原：说明在本平台怎么重建这个角色，memory 的全部条目存到哪里、怎么存、哪些字段存不下。
5. 丢失或降级清单：逐项列出接不住或会改变的字段。
6. 记忆导入：默认只写到测试文件，不写进真实记忆；每条一行，原文照写，不改字。

不要做的事：不修改规范和档案，不联系第三方，不发送任何消息。
```

## File: docs/images/cold-start-hermes.png
size: 220925
sha: 8ab0de58f0967b5593690b2d6f3b2736439360f8
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/docs/images/cold-start-hermes.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/docs/images/cold-start-hermes.png

## File: docs/images/export-roster-scale.png
size: 172991
sha: a593704f17d0c3feaf027e9500e2ede5de5c3cfa
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/docs/images/export-roster-scale.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/docs/images/export-roster-scale.png

## File: docs/images/hatch-notion-page.png
size: 405419
sha: 5e4088546850bdb485ad1f5679b6eb93b2b56b41
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/docs/images/hatch-notion-page.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/docs/images/hatch-notion-page.png

## File: examples/scribe.dossier.md
size: 1462
sha: 52920094753c5a3f7e72d82253b3f48e45c6d355
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/examples/scribe.dossier.md

```text
---
spec: personal-agent-dossier/1.0
id: example-scribe-0001
name: Scribe
title: 会议记录助手
group: Work
status: active
exported_at: 2026-10-28
source: example
fingerprint: 1e6ed4957379
role: |-
  只负责把会议记录整理成要点和待办的助手。收到文字记录后，输出不超过十条要点和一张待办表（负责人、事项、截止日期）。不添加记录里没有的内容，拿不准的地方标出来问用户。回答简洁，一律用中文。
anti_jobs:
  - 不发送邮件或消息，只给草稿
  - 不补充记录里没有的信息
  - 没有会议记录时保持安静，不主动找事做
routines:
  - id: example-routine-0001
    name: 每周一汇总上周待办
    schedule: "0 9 * * 1 (Asia/Tokyo)"
    prompt: |-
      读取上周的会议记录文件夹，列出尚未完成的待办，按负责人分组，输出一张表。
    state: running
memory:
  - fact: 待办表里的日期一律写成 YYYY-MM-DD。
    kind: rule
    learned: 2026-10-01
  - fact: 用户偏好要点不超过十条，超过就合并同类项。
    kind: preference
    learned: 2026-10-02
  - fact: 2026-10-05 决定待办的负责人写姓名而不是昵称。
    kind: decision
    learned: 2026-10-05
skills:
  - meeting-notes-format
excluded: 个人联系方式和凭据不写入档案。
---

# Scribe 交接档案

这是一份虚构的示例。front matter 提供全部机器可读字段，正文只做人读说明。
```

## File: LICENSE
size: 1066
sha: a69e12a17292d3cde3cfbba0565a88b221cc2b6d
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/LICENSE

```text
MIT License

Copyright (c) 2026 AidaShika

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

## File: README.ja.md
size: 13141
sha: 7f5baad7797f140fe66775d33d19d97c986706cc
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/README.ja.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/README.ja.md

## File: README.ko.md
size: 12074
sha: 538aeb187900794c9f99c2f565b6325e41460000
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/README.ko.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/README.ko.md

## File: README.zh-CN.md
size: 13325
sha: a779e2bd4ad243d667a7b354e785cbf40ddb92ba
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/README.zh-CN.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/README.zh-CN.md

## File: requirements.txt
size: 12
sha: 3aecde93d82a93dc3ba6d5a1c3efd7d707561ed9
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/requirements.txt

```text
pyyaml>=6.0
```

## File: SKILL.en.md
size: 5692
sha: d5e5395052490f0b9c711170fe6fdd72af4e0310
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/SKILL.en.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/SKILL.en.md

## File: skills/personal-agent-dossier-adapter/SKILL.en.md
size: 6443
sha: ecef6157403ca19c0f599a96639572d50ba3a4f1
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/skills/personal-agent-dossier-adapter/SKILL.en.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/skills/personal-agent-dossier-adapter/SKILL.en.md

## File: SPEC.en.md
size: 13957
sha: eaae7b3910015118f230172bad73d72a2595d6f7
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/SPEC.en.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/SPEC.en.md

## File: SPEC.md
size: 12345
sha: 8d4226bdc40ce16f28cd8017be3cb7a7abbfc315
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/SPEC.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/SPEC.md

## File: tests/check_vectors.py
size: 637
sha: 2fb2622dc5f7fd5edeab685ba3ff84181dabe2b0
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/tests/check_vectors.py

```text
#!/usr/bin/env python3
"""Check any implementation of the fingerprint against tests/fingerprint-vectors.json."""
import hashlib, json, os, sys
def fp(name, title, role):
    n = lambda s: (s or '').replace('\r\n', '\n').strip()
    return hashlib.sha1('|'.join([n(name), n(title), n(role)]).encode('utf-8')).hexdigest()[:12]
d = json.load(open(os.path.join(os.path.dirname(__file__), 'fingerprint-vectors.json')))
bad = [v for v in d['vectors'] if fp(v['name'], v['title'], v['role']) != v['fingerprint']]
print('FAIL %d of %d' % (len(bad), len(d['vectors'])) if bad else 'PASS %d vectors' % len(d['vectors']))
sys.exit(1 if bad else 0)
```

## File: tests/fingerprint-vectors.json
size: 1487
sha: ae090da931b90eac5dcf01c4c0599d0acaab4252
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/tests/fingerprint-vectors.json

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/tests/fingerprint-vectors.json

## File: tools/dossier_check.py
size: 5111
sha: 116cd8468f6085840e4e1745a5fd51dab597befa
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/tools/dossier_check.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/tools/dossier_check.py

## File: tools/render_notion.py
size: 1578
sha: 934b8dabc227859d270ae8ab881f0b4560ba11b5
upstream: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/tools/render_notion.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/aidashika/personal-agent-dossier/blob/HEAD/tools/render_notion.py
