# Changelog

## 0.4.1 — 2026-10-29

只有版本号不同，代码与 0.4.0 完全一致。

0.4.0 没能发布到 npm：发布流程在 `npm publish` 那一步失败，registry 上从未出现
这个版本。失败的形态是 provenance 的 Sigstore 透明度日志（Rekor）冲突：

1. publish 先把 tarball 的 provenance 写进 Rekor —— 成功；
2. 随后的 registry 上传失败（真实原因被 npm 的内部重试覆盖掉了，日志里只剩最后一次的输出）；
3. npm 重试整条 publish，同一个 tarball 的日志条目已经存在，返回
   `409 TLOG_CREATE_ENTRY_ERROR: an equivalent entry already exists`。

Rekor 条目按内容寻址且不可撤销，而 `npm pack` 会把 mtime 归一化成固定值 ——
同样的文件永远产出同样的 shasum，所以**同一个 tarball 无法再带 provenance 发布**，
重跑流水线只会重复撞同一个 409。换版本号让 tarball 内容发生变化，是最干净的出路。

`v0.4.0` 这个 tag 仍指向未发布的 `f817beb`，保留作为这次失败尝试的记录。

## 0.4.0 — 2026-10-29 · 未发布到 npm

面板界面支持英文，并且跟随 DSH 的界面语言实时切换。数据格式仍是 v2，无 API 变更。

### 新增

- **界面语言跟随 DSH，不跟浏览器。** 取值顺序：
  1. 宿主 locale 服务 `ctx.locale.getLocale().active` —— 权威值，同步可读；
  2. `<html lang="…">` —— 同步可读的退路；
  3. `navigator.language` —— 系统语言，仅兜底。

  只看 `navigator.language` 是错的：系统中文而 DSH 设成英文时会判成中文，表现为
  「DSH 明明已经英文，插件还是中文」。
- **切换界面语言立即生效，不用重启。** 订阅 `ctx.locale`，并在 `<html lang>` 上挂
  `MutationObserver`，两条路都会叫醒 React 重渲染。
- **长正文可以展开了。** 档案条目、内置段落、禁用态和未覆盖态四处原本是手工
  `slice` 字符串做截断，截了就再也看不到全文 —— 条目一写长，面板里没法核对内容。
  现在截断处下方多一个「展开全文 / 收起」控件。截断只发生在显示层，数据、提交、
  diff 用的都是原文，展开看到的就是真正存着的内容；展开状态是组件内部的，切语言、
  重渲染都不会丢。

### 修复

- **侧边栏标题、会话页签标题的语言「慢一拍」。** 这两个标题由宿主渲染：侧边栏在
  `ctx.locale.subscribe(syncPanels)` 里重算，会话视图在 `ctx.locale.subscribe(refreshViews)`
  里重算，都会重新调用插件注册的 `label()`。问题在于 `t()` 读的是一份**缓存**的当前语言，
  靠订阅回调去刷新 —— 而宿主的回调注册得更早、跑得更早，于是它重算时缓存还是上一轮的
  语言，算出的标题和上一轮相同，被它自己的去重逻辑挡掉，永远慢一拍。表现就是切换几次
  之后标题「又反了」。改成 `t()` 现场调用 `detectLocale()`，不缓存，时序问题消失。
- Host 侧文案（summary、action 结果、各类错误消息）此前用配置里的语言偏好生成，
  用户在界面上切换语言不会改配置文件，于是和界面不一致。现在由客户端在请求上带
  `?lang=`，Host 以它为准。

### 实现取舍

- **中文原文当 key**，只额外维护一张英文对照表（`client.js` 里的 `const EN = {...}`）。
  重命名 key 是这类改动最容易漏的地方 —— 改一处忘一处，界面上就冒出一个陌生 key。
  中文原文既是 key 又是默认文案，漏翻一处只是显示中文，不会出现 `ui.cancel` 这种字面量。
  改动面也最小：纯静态文案只需外面套一层 `t()`，不必重写。
- 含插值的文案改成 `{name}` 占位符形式，翻译时可以调整语序
  （`t('档案库（{n}）', { n })`）。
- 文案共 **149 条**，其中 136 条静态、13 条带占位符。
- **不把语言存进变量、不靠回调去对齐**，是这次的核心约束：任何「缓存 + 异步对齐」
  的做法都会和宿主自己的渲染时序打架。`localeStore` 只用来叫醒 React，不参与取值。

### 测试

- 新增 `test/i18n.test.mjs`，对 `client.js` 做静态分析，覆盖这类改动真正会坏的地方：
  - 每条 `t()` 文案都有英文译文
  - 翻译表里没有已经用不到的条目（改了中文原文却忘了删旧键）
  - 译文里不残留中文（漏翻）
  - 占位符两边一致（否则界面显示 `{n}` 字面量）
  - `t()` 的缺译文回退必须落到中文原文，而不是显示 key
  - `detectLocale()` 的来源优先级（locale 服务 → `<html lang>` → `navigator.language`）
  - `t()` 必须现场读，不许读缓存（「标题慢一拍」的防回归）
  - 插件必须声明 `locale` 依赖，不能在它到位之前注册槽位
- 三项反向验证：删一条译文、去掉一个占位符、把译文改回中文，对应测试都确认转红。
- 新增 `test/expand.test.mjs`（6 项）测展开控件。静态分析证明不了「点了按钮真能看到
  全文」，所以把 `ClampedText` 的函数体从 `client.js` 里抠出来，配假的 `h` / `React`
  真跑一遍渲染树 —— 假 `useState` 用外部变量存状态，setter 写它，重新调用组件函数
  就等于一次重渲染。覆盖：短文本不给控件、刚好等于上限不切、超长截断、点开显示全文、
  再点收起、往返内容一致。另加一条静态防回归，禁止以后再写回手工 `slice`。
- 两条反向验证：让 `open` 失效、把一处换回手工截断，对应测试都确认转红。

### 已知局限

- 语言能力依赖宿主版本：宿主没有 `ctx.locale`（`@deepseek-ai/dsh-client-locale`）时，
  会退到读 `<html lang>`，再退到 `navigator.language`。

## 0.3.1 — 2026-10-28

安全修复。0.3.0 发布后做市场准入复核时发现的，之前六轮审核都没查过这条。

### 修复

- **端点可被 DNS rebinding 绕过（`Host` 未校验）。** 原先的守卫只比较 `Origin` 与
  `Host` 是否同源。DNS rebinding 让两者**都是**攻击者的域名：攻击者把自己的域名
  解析到 `127.0.0.1`，用户访问该页面后，浏览器朝本机端口发出的请求带
  `Host: evil.example` 与 `Origin: http://evil.example`，同源检查判定通过。实测确认返回 200。

  这个接口可写，所以后果不只是泄露：攻击者能读走全部提示词档案，也能写入新条目，
  而条目内容会进入之后**每一轮**的系统提示词 —— 等于把任意指令长期注入用户的 agent。

  修法是校验 `Host` 必须是回环地址（`127.0.0.1` / `localhost` / `[::1]`，端口可选）。
  `Host` 由浏览器按实际目标填写，页面脚本改不了，所以它是这里唯一不被攻击者控制的量。
  校验放在最前面，`Origin` 检查保留为第二道。

- 缺少 `Host` 的请求直接拒绝（HTTP/1.1 要求带它，缺了就无法判断请求打给谁）。

### 新增

- `config.allowedHosts`：界面不是从回环地址打开时（局域网 IP、反向代理、容器主机名）
  把实际主机名加进白名单，否则自己的正常请求会被一起挡掉。默认只含三个回环写法。

### 测试

- `#31` DNS rebinding 形态的请求被拒（`Host` 与 `Origin` 同为攻击者域名）。
- `#32` `Host` 缺失、局域网 IP、`0.0.0.0` 一律拒绝。
- `#33` 回环地址各种写法（含端口、IPv6、无端口）正常放行。
- 全部做过反向验证：撤掉白名单后 `#31` `#32` 转红。全套 90 项。

## 0.3.0 — 2026-10-27

加固存储的并发与持久化边界。有可观察的行为变化（超时、拒绝写），数据格式仍是 v2。

这一版刻意**没有**解决全部已知问题：跨进程互斥和 `.tmp` 自动清理经评审后判定
「引入的新故障面大于收益」，改为在文档中明确限制。详见 `docs/plan-hardening-2026-10-27.md`。

### 修复

- **加载与写入共享同一条事务队列。** 加载不是纯读：解析失败会把文件 rename 走、
  格式升级会调 `saveStore` 整份重写、任何一步都会发布 `state.document`。此前
  watcher / HTTP GET / 首次加载都在锁外跑，可能和别人的保存交错。现在全部排队。
- **队列跨 `apply` 共用。** 队列原本是 `apply` 闭包里的，热重载会新建一条，
  而旧实例在途的操作不会因为 dispose 就停下 —— 两个实例各排各的队，等于没锁。
  队列挂到 registry 上之后，同一存储文件全局串行。
- **作废实例的排队任务不再开始。** 旧实例的排队事务若在新实例接管后才轮到自己，
  会拿自己那份旧文档写盘，把新内容覆盖掉还报成功。现在开始前校验实例有效性，
  过期任务明确失败（`STALE_INSTANCE`）；已在执行的事务不打断。
- **运行中加载不再隔离坏文件。** 隔离（把原文件挪到 `*.corrupt-*`）原本任何一次
  解析失败都会触发，包括 watcher 在别人刚保存之后读到中间状态。现在只有**首次加载**
  才隔离，运行中只记日志。
- **加载失败时拒绝修改动作。** 此前拿到 `{ ok: false }` 只跳过 agent 迁移，照样执行
  action —— I/O 故障时会拿旧快照整份回写，覆盖磁盘上可能完好的数据。现在只读动作
  （`list` / `preview` / `path` / `reload`）仍可用，修改动作返回 `STORE_UNREADABLE`。
- **写入请求刷新到存储设备。** `writeFile` 加 `flush: true`，降低 rename 完成时内容
  还停在页缓存、掉电后留下空文件或半截文件的可能。
- **临时文件写入失败时不再删除它。** 写失败可能是「写完了但 flush 没成功」，此时
  那份文件是完整可用的，删掉就销毁了唯一的恢复材料。

### 新增

- **排队超时。** 等待超过 10 秒的写事务失败（`WRITE_QUEUE_TIMEOUT`），不再无限期挂起。
  **超时的事务即使后来轮到也绝不执行** —— 只提前 reject 而让任务照跑，等于「报失败
  但文件变了」，比不设超时更糟。已开始的事务不受影响。
- `apply` 接受 `config.io`（I/O 替身）和 `config.writeQueueWaitMs`（超时期限），供测试注入。

### 测试

- 新增 `test/io-barrier.mjs`：I/O 屏障，把某次 `readFile`/`rename`/`writeFile` 精确
  暂停在半路，用确定性的交错点取代 `setTimeout` 猜时序。
- 新增 `test/hardening.test.mjs`：`#22a` `#22b` `#23` `#24a` `#24b` `#28a` `#28b`
  `#29` `#30` 共 9 项。
- 每项都做了**反向验证**：撤掉对应修复后该测试确认转红。过程中发现并修掉了三处
  「测试是绿的但证明不了任何事」——屏障拦住所有同类调用导致假绿、断言读得比任务
  执行更早导致假绿、两道保护互为冗余导致单点移除测不出。

### 仍不解决（明确记录，不做）

- **跨进程互斥。** `wx` 锁文件 + stale 接管的方案有四条失效路径：活进程被误判 stale
  （调试暂停、休眠、慢盘都可能超时）、迟到的接管者搬走新锁、旧持有者恢复后无条件
  `unlink` 删掉新锁、独占创建后写时间戳前崩溃留下空锁。这是互斥协议的正确性缺口，
  加身份令牌和心跳消不掉「检查与删除之间」的竞态。Windows 上「先 rename 再 unlink」
  也绕不过共享删除限制（`FILE_SHARE_DELETE` 同时管辖删除与重命名）。
- **`.tmp` 自动清理。** 按 mtime 保留最近若干个，可能删掉另一个仍在执行的事务的
  临时文件；一次暂停的事务，它的 tmp 完全可能掉出前列。改为文档说明手工清理。
- **任务执行超时。** JS 无法抢占已经开始的 Promise，强行超时只能造成「报失败但实际
  改了」。

## 0.2.5 — 2026-10-26

修 0.2.4 之后引入的四个问题，外加一个一直存在的宿主契约错误。无 API 变更，数据结构仍是 v2。

### 修复

- **assemble hook 现在会调 `next()`**。`system-prompt/assemble` 是 waterfall 事件，listener 必须调 `next()` 才会走到后面的 listener；插件的 hook 一直只声明 `(assembly)` 一个参数、直接 return，等于**独占整条装配链**。现在看着没事只是因为目前没有别的插件监听它 —— 一旦有，对方的加工会被静默跳过。同时按契约改成「先 `next()` 取下游结果、再应用用户规则」，用户配置始终是最终裁决。
- **读失败不再被当成文件损坏**。`loadStore` 原先把「读」「解析」「归一化」放在同一个 catch 里，只按 `ENOENT` 分流，于是 `EACCES` / `EIO` / `EISDIR` 也会把正式文件改名成 `.corrupt-*` 再清空内存 —— 一次读失败就能让一份**刚保存成功**的配置从正式文件上消失，下一次保存只剩空文档。现在只有 JSON 解析失败才隔离；I/O 错误保留最后一份有效文档继续装配。
- **卸载后不再回落到更早的文档**。旧 hook 卸载后经 `currentState` 回落到自己闭包里那份 `state`，那是**更旧**的实例文档。表现为：A 建会话 → 重载成 B → B 删掉某条临时提示词 → 卸载 B，那条提示词复活。现在注册表保留 `lastDocument`，语义是「停在插件停之前的最后状态」而不是「倒退到更早的某次装配」。
- **`workspaces` 改为深拷贝后再改**。`apply.toggle` / `apply.default` / `apply.hotReload` 原先只浅拷贝外层对象，`target` 仍指向已发布文档里的那个对象；`saveStore` 失败时没有回滚，出现「HTTP 500、磁盘没变、内存已改」，下一次装配和界面显示对不上。
- **一次装配只用一份文档**。`matchWorkspaceKey` 此前仍读闭包里的 `state`，规则来自新文档、工作区匹配来自旧文档 —— 热重载后新增的工作区条目在旧会话里不生效。
- **迁移路径不再裸 `writeFile`**。v1 → v2 迁移同样在覆盖用户的正式文件，改走 `saveStore` 的原子写 + rename 重试。

### 第六轮复审追加

- **裸 agent → `WeakRef` 的迁移移出热路径**。上一版把它放在 `registry()` 里，而 `registry()` 会经 `currentState()` 在**每次提示词装配**时被调用——等于每次装配全表扫描一遍，会话越多越慢。现在只在 `apply` 初始化时跑一次。

### 第五轮复审追加

- **实例有效性检查移进 `adoptLegacyAgents` 内部**。上一轮只加在 bootstrap 那一个调用点上，watcher 和工具动作两个入口漏了——过期实例照样能吃掉旧 Set、跑掉旧 cleanup、按它自己那份文档注册段落并冻结基线。而基线只冻结一次，真正活着的实例之后再怎么刷新也回不来。检查放进函数里，三个入口一次覆盖。
- **继承下来的裸 agent 就地转成 `WeakRef`**。更早的版本往表里存的是裸 agent；如果只把值当 WeakRef 解引用、不回写，强引用照旧攥着，`WeakRef` 想解决的泄漏一点没变。
- **`liveAgent` 修掉 `??` 回落错误**。`ref?.deref?.() ?? ref` 在 agent 被回收后会把 `WeakRef` 对象本身当 agent 返回——正是弱引用要防的那种「对象已经没了但代码还以为在」。

### 第四轮复审追加

- **registry 表结构按版本补齐**。热重载时那张表是**上一个实例**留下的，而初始化只在「整个 entry 不存在」时才执行。`3b6ce18` 的 entry 没有 `agents` 字段——升级那一刻表恰恰是「在但不完整」，`runningAgents.set` 直接抛 `TypeError`，新实例一建会话就哑掉（异常还被 `agent/created` 的 catch 吞成一行日志）。现在逐字段补，不覆盖已有的。
- **加载是否成功改为每次加载自带返回值**。原先用实例级 `lastLoadOk`，首次加载与 watcher / 工具动作并发时会互相覆盖：一次失败能抹掉另一次刚置上的成功，一次成功也能盖掉一次失败。现在 `loadStore()` 返回 `{ document, ok }`，谁加载就判谁的结果。
- **agent 表改存 `WeakRef`**。这张表跨实例存活，而插件停用期间宿主销毁 agent 时 `agent/disposed` 监听已随插件摘掉——强引用会永久攥着一个死掉的会话对象。弱引用让回收之后自然落空。
- **首次加载失败后的接管入口补齐**。watcher 与工具动作的成功加载现在也会补一次 `adoptLegacyAgents()`，不再只能等下一次 `apply` 才把旧 agent 接过来。

### 第三轮复审追加

前两轮的修复本身又引入了两处，加上两处原先没修透的：

- **首次加载失败时不做升级迁移**。`loadStore` 在 I/O 或解析失败时会回落到旧文档而不抛错，调用方只看 resolved 分不清「读到了」和「没读到、给了旧的」。原先无条件迁移，于是首次读取一失败就按空文档重注册段落、冻结一份空基线 —— 磁盘上的规则还在，但之后文件恢复正常也不会自己回来。新增 `lastLoadOk` 标志，失败时保留旧注册表，等下一次成功加载再接管。
- **作废实例的异步回调不再去动 agent**。`loadStore().then()` 是异步的，等待期间实例可能已经被卸载、或者又被下一次热重载替换掉。作废的实例注册的 hook 摘不掉，抢着迁移只会把别人的会话弄乱。现在校验 `registry.state === state`。
- **`agentStateOf` 对未知结构的判断写反了**。注释写的是「按已经挂了对待」，代码 `=== true` 却把 `undefined` 变成 `false`，于是又重挂一份，原 hook 的 disposer 也被覆盖掉。改成 `!== false`。
- **agent 表挂到 registry 上，跨重载延续**。原先每次 `apply` 新建一个空 `Map`，第一次升级靠收编旧 Set 补上了，但**第二次**热重载之后旧 agent 的静态段落就停在旧文档上（动态层靠 `currentState` 还能读新文档，只是掩盖了症状）。现在这张表跟 registry 一起存活。

### 复审后追加

同一轮 codex 复审在初版修复上又找出四处，一并修掉：

- **`drainLegacyRegistry` 的判断写错了类型**。0.2.4 的 `all` 是 `Set`，原先用 `Array.isArray` 判断永远为假 —— 一个 disposer 都没执行就把旧注册表删掉了，既没清理又丢掉了以后找回入口，比不迁移更糟。
- **下游 listener 的异常不再被吞掉**。`next()` 原先在加工逻辑的 try 里面，下游装配抛错会被记条日志然后返回原始 assembly，调用方拿到的是「成功」。现在 `next()` 在 try 之外，异常原样抛给宿主；本地加工失败则退回**下游的结果**，不再把别人的加工一起丢掉。
- **`override.reset` 的基线清空移到保存之后**。原先在 `saveStore` 之前 `sessionBaselines.clear()`，一次写盘失败就变成「界面报错、磁盘没变、运行中的提示词已经变了」。
- **`agentStateOf` 只迁移确实认得出的旧结构**。认不出来的形状就地补字段、不动已有 disposer、按「已挂」对待，避免误摘别人的注册或多挂一份 hook。

### 升级

- 从 0.2.4 热重载上来会做一次迁移：旧版把 hook 的 disposer 混在 section 的 `cleanups` 里（那正是「改一次文档 hook 就被摘掉」的根源），迁移时把旧 `cleanups` 全部执行掉，再按「什么都没挂」重来并**重挂 hook**。旧版无实例后缀的注册表键也会被清空，避免旧 route / tool / watcher 泄漏。
- **已存在的会话不需要重建**：热重载不会再发 `agent/created`，新实例的 `runningAgents` 是空的，所以升级时会从 0.2.4 留下的 `AGENT_REGISTRY` 里主动把旧 agent 找出来，各走一次同步完成迁移与重挂。

### 测试

- harness 的 `ctx.on` 改为支持多个 listener（原先 `Map<event, handler>` 会让后挂的覆盖先挂的），`assemble` 按 waterfall 语义串行分发 —— 「listener 不调 `next` 会阻断后面」这类问题以前根本测不出来。
- harness 的 section / tool disposer 改为真的能摘掉（原先返回空函数 / 不返回），「卸载后工具没释放」现在也能被抓到。
- 新增 `test/regressions.test.mjs`：读失败不隔离、卸载不回流、路由与工具都释放、跨版本 state 迁移、工作区匹配用同一份文档。
- **74 个测试，并发 1 / 4 / 8 全绿。**

## 0.2.4 — 2026-10-23

修复热重载后的注册泄漏。无 API 变更，无数据结构变更（仍是 v2）。

### 修复

- **热重载不再撞重复路由**。disposer 注册表原先放在模块作用域，宿主重新求值模块时它被换成新的空表，旧实例留在宿主路由表里的 `/prompt-manager/api` 就再也摘不掉；下一次 apply 注册同一路径直接抛 `duplicate exact route`，新 handler 挂不上去，面板一直拿到旧闭包。注册表改挂 `globalThis`（`Symbol.for` 键），跨模块重载存活。
- **清理钩子提前到 apply 开头**。原先挂在末尾，中间任何一步抛错它就执行不到，整次 apply 的注册全部泄漏。
- **`ctx.tools.register` 的 disposer 纳入清理**，避免宿主里出现两份同名工具。
- **per-agent 状态改挂 agent 对象**（Symbol 键）。section 的 disposers 和「hook 是否已挂」原先存在 apply 闭包里，重载后新实例认不出旧 agent：要么重复挂一遍，要么摘不掉旧的那份。agent 对象跨重载存活，可以据此判断。
- **assemble hook 现取最新 store**。hook 注册在 agent 的 ctx 上，热重载摘不掉它；它原先读闭包里的 `state`，而那份文档不再被 watcher 更新——表现就是面板改了设置、已存在的会话毫无反应。
- **会话页签不再消失**。`slots.inject` 回调里只返回了 `slots.register` 的 disposer 而没有另存一份；热重载时旧注册留在 slot 表里，同一个 id 再次注册撞车。现在两条都收。

### 说明

「全局规则只对新会话生效」不是缺陷：`freezeBaseline` 在会话首次装配时冻结当时的全局规则，避免提示词前缀一变就作废整段服务端缓存。要改当前会话，去会话页单独特调。

## 0.2.3 — 2026-10-23

README 中英双语，发布全自动化。无运行时代码改动。

### 文档

- **README 改为中英逐段对照**。每个中文段落后紧跟对应的英文段，面向英文读者；表格用双语单元格，代码块带双语标签只出现一次，避免同一段命令在两种语言里各写一遍、改的时候漏改一处。
- 安装、生效时机、作用域、数据结构、端点安全、开发流程全部覆盖，并把「哪些结论经过真机验证、哪些只有单测覆盖」也写进了英文部分。

### 交付

- **npm 发布接入 release 流水线**。推 `v*` 标签后依次完成：校验标签与 `package.json` 版本一致 → 59 个测试与两个自检 → `npm publish --provenance` → 轮询确认 registry 可见 → 建 GitHub release 并挂 tarball。任一步失败，后续都不执行。
- **版本一致性校验**：标签与 `package.json` 不符时直接失败。自动化发布最危险的事故是版本号对不上，而 npm 不允许覆盖重发，一旦发错这个版本号就废了。
- **幂等**：目标版本已存在时跳过发布，重推标签只为刷新仓库产物。
- **发布后确认**：轮询 registry 直到目标版本真正可见才继续，避免下游在最终一致的窗口期装到上一版。
- 新增 `id-token: write`（provenance 签名）与 `concurrency` 锁（同一标签不并发，防止两次 publish 互踩）。

## 0.2.2 — 2026-10-23

署名与包元数据。无运行时代码改动。

- **版权主体明确为 Vertixara**。`LICENSE` 原先写的是占位的 `dsh-prompt-manager contributors`，现在改为 `Copyright (c) 2026 Vertixara`。
- **补齐 npm 元数据**。`author`、`repository`、`homepage`、`bugs` 此前全部缺失，npm 包页面因此没有作者与仓库信息。
- **README 明确作者与贡献**。作者栏只署 Vertixara —— AI 不作为作者署名；AI 参与情况单列在「贡献与致谢」：DeepSeek 4.1 Flash 负责实现与核查、GPT 6.1 Sol 负责独立审计。同时标注哪些结论经过真机验证、哪些只有单测覆盖。
- **审计报告标注审计方**。`docs/audit-2026-10-23.md` 头部写明由 GPT 6.1 Sol 独立审查（与实现方不是同一个模型）。

## 0.2.1 — 2026-10-23

按 GPT 6.1 Sol 出具的独立审计（[`docs/audit-2026-10-23.md`](docs/audit-2026-10-23.md)）修复：2 项 P1、8 项 P2。测试从 41 条增加到 59 条，每项修复都有对应的回归用例。

### 安全

- **HTTP 接口加来源校验**（P1）。`/prompt-manager/api` 之前完全无鉴权：任何能连到端口的调用方都能读写提示词，别的站点还可以用 `text/plain` 的「简单请求」向本机端口 POST，绕过 CORS 预检。现在带 `Origin` 且与 `Host` 不同源的请求直接拒绝，写操作必须是 `application/json`（简单请求发不出这个类型，浏览器会先预检，而本路由不返回任何 CORS 头），请求体上限 1 MB。
  - 威胁模型的边界：本机任意进程挡不住，它本来就能直接读 `$DSH_HOME/prompt-manager.json`。这里防的是浏览器跨站。
- **并发写入不再丢数据**（P1）。修改流程是「读整份文档 → 改 → 整份写回」，两个并发调用会各自基于同一份旧版本，后写的把先写的整个抹掉，而两边都返回成功。现在所有 action 串行执行，并在锁内重新读盘；保存改为写临时文件再原子替换，写到一半出错不会留下半截 JSON。

### 修复

- **后注入开关失效**。入口把 action 转成小写，分支却比较 `apply.hotReload`，永远匹配不上。UI 和模型工具都开不了工作区的后注入。
- **父会话的临时条目到不了子代理**。作用域过滤通过后还要求 `sessionId` 与子代理自身 ID 完全相等，而 UI 添加临时条目总是填当前主会话 ID——结果是选「仅子代理」的条目注入不到任何子代理。现在按可信父链解析可见范围，多级子代理同样继承，无关会话仍然隔离。
- **动态条目的 replace 不生效**。规则施加在宿主已装配的段落上，而临时条目和后注入条目的追加发生在那之后，追加阶段只处理 `disable`。现在追加时也应用 `replace`，面板上「覆盖生效中」不再与实际不一致。
- **含双花括号的提示词会让装配失败**。宿主默认把 `{{name}}` 当变量引用，遇到未知变量直接抛异常。档案条目、临时条目和覆盖文本现在都声明 `interpolate: false`，按字面量注入——写模板示例不会再炸掉整个会话。
- **删除档案会关掉其他工作区的后注入**。重建工作区配置时只复制 `enabled`/`disabled`/`default`，丢了 `hotReload`，只有 `hotReload` 的工作区还可能整个消失。
- **全部恢复后重新启用会复活旧规则**。`override.reset` 清了磁盘规则却没清运行中会话冻结的基线，再打开总开关时旧规则又生效了。
- **快照脚本写错目录**。脚本请求已废弃的 `?view=system`（拿不到 agent 作用域的内容），还写到未被忽略的 `docs/`——照着文档跑一次就可能把真实系统提示词提交进仓库。现在改用 `?view=assembled`，输出到 `docs/snapshots/`，`ok:false` 时报错退出而不是写一份空快照。
- **路由与 watcher 未纳入生命周期**。`webServer.register()` 的 disposer 被丢弃，热卸载后旧 handler 仍持有旧 state 继续应答，重新注册同一路径会撞 duplicate route。现在统一登记，`dispose` 时释放。

### 文档

- README 补安全模型、子代理继承规则与插值语义说明。

## 0.2.0 — 2026-10-22

首个公开版本。包名 `@vertixara/dsh-prompt-manager` —— 开发期一直用的 `@local/dsh-prompt-manager` 只是本机占位符，npm 上并不存在；改名同时换了 `cordis.patch.yml` 的插入名和客户端插件 id。

### 新增

- **真实装配快照**：在 `system-prompt/assemble` waterfall 里记录每轮装配结果，按 sessionId 保存（最多 20 个会话）。这是唯一能拿到 agent 作用域内容的位置，首次可以枚举 `team:policy`、`tool:*` 这类此前完全不可见的段落，以及全部 52 个工具。
- **「内置与其他插件」独立页签**：sections / contexts / tools 分组展示，段与 context 可开关、可覆盖，tool 只读。
- **contexts 级改写**：除 section 外，运行时 context（`working-directory:current`、`sandbox:policy`、`approval:policy`、`team:identity` 等）现在也可禁用或覆盖。
- **contexts 按会话隔离**：运行时上下文规则存放在 `overrides.sessionContexts[sessionId]`，只影响该会话；`overrides.contexts` 保留为全局默认（不带 `sessionId` 操作时写入）。会话页的改写一律带 `sessionId`，不会波及别的会话。归档条目也会记录所属会话。
- **一键恢复**：清除全部改写规则并关闭总开关，回到出厂状态。
- **覆盖文本归档**：恢复单条或一键恢复时，replace 的文本自动存入 `overrides.saved`；面板「已保存的覆盖文本」区可点「应用」重新装上、点「删除」移除，最多保留 100 条。
- **测试套件**：27 个用例（`npm test`），用 mock ctx 在进程内跑真实 `apply()`，覆盖存储读写、档案 CRUD、静态注册、后注入、override 的 disable/replace/会话隔离/归档/恢复、总开关与 HTTP 端点，不需要起 DSH 进程。
- **子代理作用域**：条目与临时条目新增 `agentScope`（`inherit` / `main` / `subagent`），主代理和子代理可以拿到不同的提示词。会话类型直接取自 `SessionHeader`（`origin` / `parentSession` / `delegationDepth`），不需要自己推断。会话页显示当前是主代理还是子代理、父会话，以及本次会话拉起过的子代理各自继承了哪些条目。测试增至 38 个用例。
- **改动的生效时机拆成两条路**：主页的 `sections` / `contexts` 是全局基线，会话启动时冻结（`freezeBaseline`），**只对新会话生效**，不会中途把运行中会话的缓存前缀打掉；会话页写 `sessionSections` / `sessionContexts`，**立即生效且只影响本会话**。总开关例外，立即对全部会话生效。
- **会话页补上系统提示词管理**：默认折叠，与主页同一套 disable / replace 操作，但走会话级规则。主页禁用的段落在会话页点「重新开启」会写一条会话级 `enable` 覆盖基线里的 `disable`，不需要动全局配置。
- `override.set` 新增 `mode: "enable"`；`?view=assembled` 增加 `sectionRules`（基线 + 会话级合并结果）。测试增至 41 个用例。
- **覆盖展示改成逐行 diff**：不再整段并列贴全文，而是行级 LCS 找出真正变化的行、未变段落折叠、行内再剥离公共前后缀把改动标到具体字符。段落正文是自由文本没有字段可拆，所以粒度是"行 + 行内改动中段"。算法在 `client.js` 的 `--- diff:start/end ---` 区块内，`node research/check-diff.mjs` 直接对源码求值跑 10 项断言。
- **配色语义化**：红 = 禁用/移除，黄 = 覆盖（`--dsw-alias-state-warn-primary`），绿 = 正常生效。「已覆盖」标签、diff 新增行、行内新增高亮、「覆盖生效中」统一改成黄色，用 `color-mix` 混 token 以跟随明暗主题。
- **补齐可发布元数据**：去掉 `private`，加 `publishConfig.access: public`、`license: MIT` 与 LICENSE 文件、`engines.node` / `engines.dsh`、`peerDependencies`（`@deepseek-ai/cordis` 必需，`react` 可选）、`keywords`；`exports` 补 `./package.json`；`files` 白名单精简为运行时文件，`research/` `test/` `scripts/` 不进包。删掉 `dsh.client.immediately` —— 其他插件都没有这个字段，asar 里也找不到对应语义，属于残留。README 增加安装与发布说明。

### 修复

- **被禁用的条目从面板消失，无法取消禁用**：快照改为记录规则应用**之前**的原始装配，所有条目始终留在列表里，带「已禁用」标记，可直接点回来。
- 覆盖或禁用后看不到实际效果：面板叠加规则状态渲染，显示覆盖后的文本。

### 变更

- 快照内容语义变化：从「过滤后的最终装配」改为「原始装配 + 规则状态」，面板负责叠加。
- `?view=system` 标记为废弃（响应带 `deprecated: true`），保留仅供宿主视角调试；请改用 `?view=assembled`。
- `overrides` 结构新增 `saved` 归档数组。
- **contexts 从主页移到会话页**：运行时上下文随会话变化（工作目录、沙箱策略等），放在全局主页会造成「这是全局配置」的误解。主页只保留 sections 与 tools；contexts 在会话页「本会话上下文」卡片中查看与改写，并注明改写规则是全局的。
- UI 状态更醒目：已禁用项红色边框 + 删除线，已覆盖项用「− 原文 / + 应用」两段对照，顶部增加禁用/覆盖计数角标，总开关关闭但存在规则时给出红色警告。
- **主开关拆成状态与动作两块**：原来那一个可点的 chip 同时承担「显示状态」和「切换」两件事，看不出是按钮还是标签。现在左边是只读状态徽章（绿「改写生效中」/ 红「改写已停用」，带悬浮说明），右边是独立动作按钮（生效时是灰色的「停用改写」，停用时是主色的「启用改写」—— 需要操作的那个才是醒目的）。
- **父子智能体作用范围改成始终可见的标签**：「作用范围」改叫「注入给谁」，默认项写成「主代理 + 子代理（默认）」。档案列表、会话页生效条目、临时条目这三处的范围标签，原来只在选「仅主代理 / 仅子代理」时才出现，选「继承」就等于看不出来 —— 现在三个值都有标签（`主+子代理` / `仅主代理` / `仅子代理`），尺寸与「全局」「临时」「后注入」一致。解释压进 title tooltip（字段名带虚线下划线，悬停显示），不再用一行常驻文字占版面。会话页子代理卡片的继承列表从英文 `(main)` / `(subagent)` 改成中文。
- **工具条目默认折叠**：tools 有五十多项，展开会把 sections 挤到看不见。改成 `<details>` 默认收起，只有带过滤词时自动展开，避免搜到了却看不见。
- 目录整理：调研产物移入 `research/`，快照移入 `docs/snapshots/`，新增 README。

### 数据迁移

旧 store 自动补齐 `overrides.saved: []`，无需手工处理。

## 0.1.1

- 工作区级应用：按目录启用/禁用/设为默认条目。
- 按工作区热重载：条目可选走后注入而非静态注册。
- 会话临时条目：只在指定会话生效。

## 0.1.0

- 初始版本：档案条目、全局应用、后注入、Web 面板。