# Changelog

## 2.8.0 — 卡渲染兼容：外壳 + 沙盒

用户反馈「有很多很多角色卡，兼容不到位」。根因不在渲染细节，而在**导入就丢了东西**：
`chara_card_v3` 的卡把状态栏 / 变量折叠 / 选项卡片 / 思维链全写在
`data.extensions.regex_scripts` 里，旧链路只取 `name / desc / first_mes`，
脚本在导入那一刻就没了 ⇒ 插件只能靠白名单硬编码，每张新卡都要补一条正则。

- **导入保真**：`addCard()` 保留 `regexScripts`（只挑需要的字段，第三方扩展不整包存）；
  `cleanObjectStrings()` 跳过正则键 —— 那里的 `{{…}}` / `<!-- … -->` 是语法，不是 ST 宏。
- **卡脚本引擎**：新增 `lib/st-regex.js`（服务端，提示词侧）与 bundle 内的等价实现
  （显示侧）。对齐 ST `engine.js`：`placement` / `markdownOnly` / `promptOnly` /
  `minDepth` / `maxDepth` / `trimStrings` / `$1..$9` / `{{char}}` / `{{random::a::b}}`。
- **HUD 沙盒（三层）**：① 识别管线（document / fragment / details / text）
  ② 沙盒核心（iframe `sandbox="allow-scripts allow-forms"`，**无** `allow-same-origin`）
  ③ 外壳适配器（内嵌 / 浮窗）。卡的 CSS 不再外溢到宿主页面，卡之间也不互相污染。
- **通信**：高度自适应、上下文请求、开浮窗、关闭 四条 `postMessage`；只认自己那个
  iframe（校验 `e.source`）；上下文只给角色名/玩家名/会话 id/当前消息（截断 2 万字符）。
- **状态栏置底**：状态类脚本的产物统一落到消息末尾的 `.tavern-status-trailer`。
- **模型漏写占位符时**：用卡自己的脚本补一次（不再绕 muv-engine），卡没有状态栏脚本就
  什么都不做。
- **提示词侧裁剪**：`agent/pre-step` 钩子按卡里的 `promptOnly` 脚本删掉历史里的
  状态栏占位符 / 旧变量块（省上下文，避免模型照抄上一轮变量）。`cardRegex: false` 可关。
- 测试：新增 `tests/st-regex.test.js`（7）、`tests/hud-sandbox.test.js`（6）、
  `tests/card-import-fidelity.test.js`（4）。全量 `pass=703 fail=0`。
- 转义棘轮：新增 1 处 `srcdoc`（沙盒渲染），基线 24 → 25，已论证（sandbox 断言通过）。

## v2.7.36 (2026-11-03)

> 一句话：读了一张 154 条世界书的大卡之后，按卡的真实构造改 —— 过程块（剧情设计 / 状态核对）折起来
> 而不是摊平，空列表项清掉，模型漏写 `<StatusPlaceHolderImpl/>` 时自动补状态栏。

### 🔍 读卡结论（样本卡：154 条世界书 / 6 条 regex_scripts）

- 状态栏**只有一个入口**：`<StatusPlaceHolderImpl/>`，由卡的 regex_scripts「状态栏美化」
  替换成 6087 字符的卡片 HTML；酒馆/muv 负责把它渲染成 iframe。
- `<draft_notes>` / `<VariableCheck>` / `<bginfor>` / `<catsay>` / `<CEstuff>` **卡里根本没有**，
  全是模型自己造的包裹壳。它们的内容有用（剧情设计、状态实体核对），但不该摊在正文里。
- 这一轮模型**漏写了** `<StatusPlaceHolderImpl/>`（会话日志确认：整条 4492 字里没有这个串），
  所以状态栏消失不是被清掉的 —— 但观感上"末尾那段状态描写"确实是本应变成状态栏的东西。

### ✨ 过程块折叠，而不是剥标签摊平

- `RAW_TAG_RE` / `RAW_BLOCK_RE` 增加 `<draft_notes>`（→「📝 剧情设计」）与
  `<VariableCheck>`（→「🧾 状态核对」）：渲染成折叠块，**默认收起，内容一字不丢**。
- 之前这两个是"孤儿标签"白名单，剥完标签后内容摊平成一大段正文（真机："末尾一段状态描写"）。

### 🧹 清掉空列表项（"选项前有三行点"）

- 模型偶尔输出 `- ` 空条目，markdown 渲染成 `<li></li>`，界面上就是几个孤零零的圆点。
- `removeEmptyListItems()`：只删"文本为空且没有图片/按钮"的 li，空列表本身一并收走。

### ✨ 模型漏写占位符时，自动补状态栏

- `ensureStatusBar()`：只在**最后一条**消息、且「既没有占位符也没有状态栏」时，
  向 muv 要一次状态栏 HTML 补在末尾。
- 严格前置：会话必须是酒馆会话（`auth !== 'default'`）、卡必须真有 `regexScripts`
  （没有状态栏脚本就不硬塞空框）、60 秒内复用同一份 HTML。
- iframe 只给 `sandbox="allow-scripts"`（**绝不给 allow-same-origin**，否则 srcdoc 变同源注入点）。
- 转义基线 23 → 24（新增的 srcdoc），sandbox 断言通过。

### 🧪 测试

- `tests/raw-tag-render.test.js` ⑦ / ⑦-b / ⑦-c：两种过程块折叠且内容不丢、
  状态栏补齐的四个前置与 sandbox、空 li 清理的保守判据。
- 全套：`pass=686 fail=0`（54 个文件）。

## v2.7.35 (2026-11-03)

> 一句话：状态栏搬到回复末尾；`<draft_notes>` / `<bginfor>` / `<catsay>` / `<CEstuff>` /
> `<VariableCheck>` 这类"模型自造壳"不再露尖括号；折叠块配色统一。

### 🐛 状态栏卡在剧情中央 → 统一置底

- 现象（真机截图）：状态卡出现在「剧情设计」和后续正文之间，看起来像状态栏插进了剧情中间。
- 根因：状态卡是 muv-engine 按**模型输出顺序**渲染的，模型把这轮的状态块写在了正文中间；
  酒馆只给自己认识的卡片（`tavern-status-*`）做了置底，认不出 `.muv-statusbar-wrap`。
- 修法：新增 `moveMuvStatusToBottom()`，在每轮装饰时把 `.muv-statusbar-wrap` 搬进消息末尾的
  `.tavern-status-trailer`（只移动节点、不改内容）。
- **防抖**：muv 自己挂了 MutationObserver，来回搬会互相触发 ⇒ 同一个正文容器最多搬 3 次
  （`data-tavern-sb-moves`），超过就放弃。已经在末尾则 no-op。
- 副作用说明：状态卡是 iframe，移动会重新加载一次（静态卡片，无状态损失）。

### 🐛 标签外溢（第二批）：draft_notes / bginfor / catsay / CEstuff / VariableCheck

- 白名单扩到这一批；同时补一条**通用规则**：一个文本节点如果「整行只有一个或多个标签」
  （`</catsay> <CEstuff>` 这种），直接整行清掉 —— 模型自造壳的名字是穷举不完的。
- 仍然只丢标签、正文一字不动，仍然跳过 `pre / code`。
- 行内包裹（`<VariableCheck>…正文…</VariableCheck>`）走白名单剥离，正文保留。

### 🎨 折叠块配色统一

- `.tavern-updatevar` 原来的米黄（`rgba(197,160,101,…)` + 金色标题）和 DSH 原生 `<details>`
  差异明显，"变量更新 / 咪咪点评 / 平行事件 / 小结"深浅不一。
- 改成中性：底色 `--dsw-alias-bg-layer-1`、边框 `--dsw-alias-border-l1`、标题
  `--dsw-alias-label-secondary`。顺带让样式预算的颜色字面量下降（bareRgba 146→145）。
- 说明：muv-engine 自己的 `.muv-varedit` / `.muv-varthink` 仍是米黄（那是它插件的样式，
  酒馆不去覆盖）；如果还想统一，说一声可以再加一层覆盖。

### 🧪 测试

- `tests/raw-tag-render.test.js` ⑥ / ⑥-b：整行标签通用清理（含未知标签名）、白名单覆盖本轮
  全部名字、状态栏置底的幂等与 3 次封顶。
- 全套：`pass=683 fail=0`（54 个文件）。

## v2.7.34 (2026-11-03)

> 一句话：分类栏从「左侧一列」改回**顶部下划线式页签**，去掉外框、和内容连成一片。

### 🎨 分类栏：左侧纵向 → 顶部下划线

- 反馈（真机截图）：左侧那列只有四个短项，高度和右侧长内容完全不成比例，
  看起来像"硬贴在旁边的一个方框"，整体感差。
- 改法：
  - 分类回到**顶部一条**，`flex-wrap` 自适应；
  - 去掉外框背景与圆角，只留一条 `border-bottom` 分隔线；
  - 选中态从"填充块"改成**品牌色下划线 + 加粗**（更像原生应用的 tab）；
  - 仍然 `position:sticky`，滚动时长内容时分类一直可点。
- 左侧那套 media query 整段删掉 —— 不再随宽度在两种形态间跳。

## v2.7.33 (2026-11-03)

> 一句话：修 2.7.31 单例改造带出来的两个真机问题 —— 设置页「顶部预设下拉点不开」、
> 侧边栏浮窗「点开一片空白」。

### 🐛 顶部预设下拉点不动 / 面板里按钮全没反应

- 根因：`ensureTavernPanelMounted()` **先 mount 再 append**。`mountTavernManager` 内部大量用
  `document.getElementById(...)` 绑事件，而那时宿主还游离在文档外（`parentElement` 为 null）
  ⇒ 所有查询返回 null，事件一个都没绑上。面板能画出来（`innerHTML` 已经写了），但点哪都没反应。
- 修法：`ensureTavernPanelMounted(container)` 接收容器，**先 `appendChild` 再 mount**。
- 为什么以前没暴露：单例改造之前是 `mountTavernManager(ref.current)`，容器本来就是挂载中的 DOM。

### 🐛 侧边栏浮窗「点开一片空白」

- 根因：`TavernPanelView` 里「把宿主挪进浮窗」的那段 `useEffect` 带了 `[]` 依赖。
  `viewRoot` 是复用的（关闭只 `display:none`，不 unmount），所以 effect **只在第一次打开时跑**；
  关掉浮窗时宿主被还给设置页，再打开就没有人把它挪回来 ⇒ 空白。
- 修法：去掉依赖数组（每次渲染都跑），并统一走 `ensureTavernPanelMounted(bodyRef.current)`。
  设置页那边的 effect 同理改成每次渲染都跑（浮窗正开着时跳过，不抢节点）。

### 🧪 测试

- `tests/sidebar-entry.test.js` ②-c：钉「先 append 再 mount」的顺序（`appendChild` 必须在
  `mountTavernManager` 之前），以及「浮窗 effect 不许写 `}, []);`」。
- 全套：`pass=681 fail=0`（54 个文件）。

## v2.7.32 (2026-11-03)

> 一句话：模型没按 `<option>` 写、改成 Markdown 有序列表时，只要前面是「接下来你想怎么做？」
> 这类引导句，列表也会变成可点按钮 —— 不再出现"上一轮是卡片、这一轮是死文字"的错位感。

### ✨ 引导句 + 有序列表 → 选项按钮

- 现象（真机截图）：同一位置上一轮渲染成 A/B/C 卡片，这一轮变成 `1. 2. 3.` 纯文字。
  根因不是渲染坏了，而是**模型这两轮输出格式不同**：这轮写的是 Markdown 有序列表。
- 现在补一条兜底：`renderListOptions()` 在标签兜底之后跑，把「引导句 + 有序列表」转成
  `.tavern-options` 里的可点按钮（复用既有样式与文档级点击委托，翻页/切会话都不重复渲染）。
- **判据刻意保守**（宁可不换，也不许把正文步骤列表做成按钮）：
  - 只处理消息里**最后一个** `<ol>`；
  - 它后面不许还有正文（只允许空节点、变量更新/折叠块这类自己渲染的块）；
  - 紧邻的上一段必须是**短引导句**：≤60 字 + 以 `？/?/：/:` 收尾 + 含「怎么做 / 怎么办 /
    如何行动 / 选择 / 行动 / 何去何从」这类词；
  - 2–6 项、每项 ≤240 字、项内没有链接/图片/代码/嵌套列表；代码块里的列表一律不动。
- 转换后按钮带 `data-opt`，点击走的是已有的 `sendTavernMessage` 桥（与 `<option>` 完全同一条路径）。

### 🧪 测试

- `tests/raw-tag-render.test.js` ⑤ / ⑤-b：引导句判据（正例含"接下来你想怎么做？"，
  反例含无标点、陈述句、超长段落）+ 转换条件（只动最后一个、项数/长度上限、跳过代码块、
  必须复用 `tavern-option-btn`）。
- 全套：`pass=680 fail=0`（54 个文件）。

## v2.7.31 (2026-11-03)

> 一句话：面板改成「左侧分类导航 + 右侧内容」（和侧边栏一个形态），
> 修掉「Agent 预设选择栏一直加载中」，并清掉聊天里外溢的 `<theater>` / `<recap>` / 裸 `<summary>`。

### 🐛 修复：设置页里「🎭 当前 Agent 预设」一直显示「加载中…」

- 根因是**双实例**：2.7.29 加了侧边栏浮窗后，浮窗与设置页各挂了一份 `mountTavernManager`。
  面板内部大量使用 `document.getElementById(...)`（历史写法），两份 DOM 的 id 全撞车 ——
  `loadSessionPresets` 选中了另一个实例里的节点，真正显示的那个永远等不到数据；
  页签栏也因为这个原因不渲染。
- 修法：**全局唯一宿主**。`tavernPanelHost()` 只创建一个节点，`mountTavernManager` 只跑一次；
  设置页与浮窗谁被打开就把这个节点挪进谁的容器（`__tavernPanelOwner` 仲裁，关闭浮窗自动挪回设置页）。
  设置页卸载时**不再销毁**面板（否则浮窗会拿到一个空壳）。

### 🎨 分类：页签栏改成左侧纵向导航

- 页签栏 + 内容一起装进 `.t-tabshell`：宽面板（≥640px）是**左侧分类 + 右侧内容**，
  和侧边栏一个形态；窄面板自动退回横向页签（纯 CSS media query，拖动缩放时即时跟随）。
- 分类仍是四类：🎭 会话 / 📚 内容 / 🎲 玩法 / ⚙️ 增强。

### 🐛 修复：聊天里标签外溢（`<theater>` / `<recap>` / 裸 `<summary>`）

- 现象（真机截图）：模型把折叠块套在 `<theater>…</theater><recap>…</recap>` 里时，
  界面上原样露出这几行尖括号；单独出现的 `<summary>…</summary>` 也会整段露出来。
- 根因：这两类标签**没有任何渲染器** —— muv 只认 `<choices>`，本插件的
  `findRawTagBlocks` 只收「完整的 details/options」，孤儿标签就留在文本里。
- 修法：`renderRawTagBlocks` 在块渲染**之后**跑一遍 `stripOrphanTags()`：
  只丢标签、正文一个字符不动，并跳过 `pre / code / textarea`（回答里贴的代码示例是字面量）。
- 顺序很关键：先清洗会把 `<details>` 里的 `<summary>` 一起删掉 ⇒ 折叠块退化成纯文本。
  `tests/raw-tag-render.test.js` ④-b 用「调用位置」把这条顺序钉住了。

### 🧪 测试

- 新增 / 更新：单例宿主（`②-b`）、左侧分类（`.t-tabshell`）、孤儿标签（`④`/`④-b`）。
- 全套：`pass=678 fail=0`（54 个文件）；四道闸全绿。

## v2.7.30 (2026-11-03)

> 一句话：面板排版重做了一遍（卡片标题不再被挤成竖排），侧边栏打开的酒馆面板
> 可以**拖动 / 拉大拉小 / 双击最大化**，位置和尺寸会记住。

### 🎨 排版：卡片标题被挤成一列竖排（真机截图确认的 bug）

- 根因：`.t-card-desc` 是 **inline**，它和标题挤在同一行；面板一窄，中文标题就被逐字断行
  成「写/作/模/块」一列，右侧还全是描述。
- 修法：描述改为 `display:block` 独占一行，标题就能吃满整行；行高 1.6、与标题间距 4px；
  描述限宽 `82ch`（超宽屏下不会拉成一整行）。
- 连带修：
  - `#tavern-manager` 最大宽度 820 → 100%（大屏不再右侧一片空白），内容左右留 10px；
  - `.t-row` 里的输入框/下拉不再被全局 `width:100%` 撑到把同一行的按钮挤走；
  - 列表项 hover 时边框提亮，一眼看出可点；
  - 独立面板里隐藏面板自带的 `🍺 酒馆管理（原生）` h2（标题栏已有，重复）。

### ✨ 面板可拖动 / 可缩放

- **拖动**：按住标题栏拖（`cursor: move`）。
- **缩放**：右下角三角手柄拖（`cursor: nwse-resize`），最小 560×360。
- **双击标题栏**：最大化 / 还原上一次的矩形。
- **记住位置**：矩形写 `localStorage['dsh-tavern-panel-rect']`，下次打开还是那个位置和大小；
  窗口缩小后面板会被夹回可视区（不会跑到屏幕外）。
- 收尾逻辑抽成纯函数 `clampPanelRect(rect, vw, vh)`，`tests/sidebar-entry.test.js` ⑥ 真跑它
  （过小抬到最小值、跑出右下角拉回来、合法矩形原样、超视口截断）。

### 🧪 测试

- 全套：`pass=675 fail=0`（54 个文件）；四道闸全绿。

## v2.7.29 (2026-11-03)

> 一句话：修「ST 预设导入不进去写作模块」，并把酒馆面板搬进侧边栏（酒杯图标），
> 不用再钻进设置页。

### 🐛 修复：导入的 ST 预设"消失"了（其实是被偷偷建成了新的 Agent 预设）

- 现象：在「内容 → 📝 写作模块」里点导入，选一份 ST chat-completion 预设（如
  `Kemini Aether-fr-3.72.json`），界面上什么都没多出来。
- 根因：`/api/tavern/preset/import-st` **不带目标预设**，一律 `createPreset(conv.name)`
  ⇒ 模块落进**新目录**（顶部多一个同名 Agent 预设），当前预设的 `presets.json` 一个字节没变。
  前端又只刷新了顶部预设列表，没重读当前预设 ⇒ 用户眼前就是"没导入进去"。
- 修法：
  - 前端带 `presetId`（当前编辑的预设）；没有当前预设时**明确拒绝**并提示先选一个；
    成功后 `loadCurrent(targetPid)` 重读，新包立刻出现在列表里。
  - 服务端带 `presetId` 时改为**往该预设的 `presets.json` 追加一个包**：
    不建目录、不写 `agent.cordis.yml`、不动角色卡/世界书。
  - 同名包不覆盖：自动加 `(2)`/`(3)`，两份 ST 预设可以并存对比。
  - `preset.yml` 的摘要同步成「📝 N个写作模块」。
- 不带 `presetId` 的调用（脚本 / CLI / 旧客户端）仍走建新预设的老路径，保持兼容。
- 新增 `tests/st-import-append.test.js`（4 项）：转换层能吃下 ST 预设、前端必须带 presetId、
  服务端追加分支不许建目录/写 agent 组合文件、兜底路径保留。

### ✨ 侧边栏入口「酒馆」（不再只能进设置页）

- 侧边栏「新会话」下面新增一项 **🍺 酒馆**（线性高脚杯 + 星芒图标），和
  「MUV 表格 / 插件 / 自动化任务 / 更多」排在一起。
- 点击打开一个**独立面板 overlay**（1080×86vh，Esc 或点遮罩关闭），里面就是原来那套
  「会话 / 内容 / 玩法 / 增强」面板；关闭只是隐藏，**未保存的编辑不会丢**。
- 实现沿用隔壁 `dsh-muv-table` 的定位口径（宿主的 `[data-pane="sidebar"]` +
  `newSession` 按钮 + 复用 nav item 的 className），所以字号/配色/悬停跟随宿主题。
- 图标用 `createElementNS` 逐个搭（不用 `innerHTML`/`DOMParser`）：
  后两者都是转义棘轮盯着的注入入口，常量图标也不开例外。
- 设置页里的「🍺 酒馆管理」入口保留（两处都能进）。
- 新增 `tests/sidebar-entry.test.js`（5 项）：定位口径、关闭只隐藏、图标不用 sink、幂等、
  启动点 try/catch。

### 🧪 测试

- 全套：`pass=674 fail=0`（54 个文件）；`check` / `check:style` / `check:innerhtml` /
  `check:integrity` 全绿。

## v2.7.28 (2026-11-03)

> 一句话：把「预设模块」改名成「**写作模块**」，从此与 DSH 的 Agent 预设彻底分家；
> 同时在新会话顶部的 Agent 预设下拉里，给酒馆预设加上 ✎ 改名 / 🗑 删除。

### 🔤 改名：「预设模块」→「写作模块」

- 背景：那个卡片装的是**写作要求素材**（ST chat-completion 预设导入进来的词条），
  和角色卡 / 世界书同级，跟 DSH 的 Agent 预设（一个角色卡容器，新会话在顶部选）
  完全不是一回事。两边都叫「预设」，AI 助手改代码时反复把内容素材当成 agent 预设处理。
- UI：卡片标题改为「📝 写作模块」，描述里写明「不是 Agent 预设」；搜索框、拖入区、
  切换按钮（「切换到此模块」）、批量删除提示、状态行统计一并改口径。
- 提示词：注入段标题从 `# 预设模块（…）` 改为 `# 写作模块（…）`；
  skill 生成里的 `### 写作模块` / `### 写作模块包（…）` 同步。
- 设定优先级：显示名「预设优先」改为「**内容优先**」（底层值仍是 `preset`，行为不变），
  裁决顺序文案改为「角色卡 / 世界书 / 写作模块」；`skill` 模式下的「预设只提供事实设定」
  改为「角色卡 / 世界书只提供事实设定」。
- Agent 预设的描述摘要（顶部下拉里那行小字）从「⚙️ N个预设模块」改为「📝 N个写作模块」。
- **代码字段一个都没改**：`presets[]` / `modules[]` 是历史命名，改了才是真混淆（见 AGENTS.md §0）。

### ✨ 新会话顶部 Agent 预设下拉：酒馆预设可直接改名 / 删除

- 那个下拉是 DSH 宿主渲染的（`@deepseek-ai/dsh-client-ui-agent-preset` 的 AgentPresetSeat），
  插件没有条目插槽；但条目就是普通的 `[role="menuitem"]`，于是按「预设名精确匹配」把
  操作条挂上去（鼠标悬停显示 ✎ / 🗑）。
- **只认 `isTavern` 的酒馆预设**：内置与第三方预设是 DSH 自己的资产，从这里删掉没法恢复。
- 改名走 `POST /api/tavern/preset/rename`（会同步预设声明，顶部名字跟着变）；
  删除走 `POST /api/tavern/agent-presets`（走 `deletePreset`，会话绑定一并处理）。
- 点击用**捕获阶段**拦下并 `stopPropagation`，不会顺带把那个预设选中。
- 顺带把「浮动条」里重复的提示条 / 输入框实现提到 factory 顶层（`tavernToast` / `tavernPrompt`），
  三处共用一份 —— 重复实现会顶穿样式预算棘轮，也会漂。

### 🧪 测试

- `panel-html-identity`（段/产物指纹）、`panel-tabs`（页签标题）、
  `golden-host-assembly*`（组装产物）、`skill-priority`（裁决文案）按新文案重新冻结。
- 全套：`pass=660 fail=0`（51 个文件），`check` / `check:style` / `check:integrity` / `check:innerhtml` 全绿。

## v2.7.27 (2026-11-03)

> 一句话：修三个真机问题 —— 选项点了没反应、标签块刚生成完是碎的、代码块里的示例被当成真标签渲染。

### 🐛 修复：选项按钮点了没反应

- 现象：`<options><option>` 渲染出的按钮**点下去什么都不发生**（控制台只有一条未捕获异常）。
- 根因是**作用域**：`sendTavernMessage()` 定义在 `initMessageBeautifier()` 内部（局部函数），
  而选项点击的 document 委托注册在 `apply()` 里 —— 那里看不见它 ⇒ 每次点击 `ReferenceError`；
  更糟的是它前面的 `e.stopPropagation()` 已经把 muv-engine 的委托掐掉，
  于是两个插件都"以为自己处理了"，用户看到的就是完全没反应。
- 修法：`initMessageBeautifier` 里把发送函数挂到 `window.__tavernSendMessage`，委托走这个桥；
  **拿不到桥就直接 return**（连 `preventDefault` 都不加），把点击原样交回 muv 的委托。
- 新增 `tests/option-click-bridge.test.js`（4 项）：`apply` 里不许裸调、桥必须赋值在 beautifier 内、
  「先判桥、后 preventDefault」、以及**反例**（旧代码形态必须被判红）。

### 🐛 修复：标签块刚生成完是碎的，切一次会话才对

- 现象：每轮刚生成完，折叠块标题变成默认的「展开查看」、选项文字被截断；
  切一次会话回来又正常了。
- 根因：DSH 用 markdown 渲染，**每一行各自一个 `<p>`** ⇒ `<options>` 与它的 `<option>`、
  `<details>` 与它的 `<summary>` 常常**不在同一个文本节点**里。旧实现「逐个文本节点切片」，
  每个节点里只有半个标签；切会话回来时整条重渲染，那一拍恰好落在同一节点里，所以看起来又对了。
- 修法：把整段文本**拼起来**找完整块（`findRawTagBlocks`），再按「首尾节点」就地替换
  （`applyRawTagBlock`）—— 块外的正文与 markdown 包装基本不动。
- 顺带修掉一个正则坑：`<\/options?>` 里的 `s` 是可选的，**它也能匹配 `</option>`**，
  于是惰性量词在第一个 `</option>` 处收尾，一个选项组被切成「1 块 + 一堆游离 option」。
  收尾标签现在写死 `</options>` / `</option>`。
- 「流式输出先不渲染」原来是无条件等待；现在只在**确实还在流式**（`[data-streaming]`）时才等 ——
  否则模型真把 `</options>` 忘了时，选项会永远停在碎文本状态。
- 新增 `tests/raw-tag-crossnode.test.js`（5 项）：拆散的 `<details>` 出正确标题、`<options>` 出全部选项、
  块外正文一字不丢、`<option>` 不重复计数、以及**反例**（旧写法切出来的碎片形态必须被抓住）。

### 🐛 修复：代码块里的 `<details>` / `<options>` 被当成真标签渲染

- 现象：回答里贴一段讲本功能的**代码示例**时，代码块中间"莫名变成折叠条和选项按钮"、
  而且**真的可以点**，代码本身被吃掉半截。
- 根因：收集可改写文本节点时只跳过了 `select` / `textarea`，**没跳过 `pre` / `code`**
  （`applyDisplayRulesIn` 早就在跳这几个，这里漏了，属于同一类漏项）。
- 修法：跳过 `pre, code, select, textarea, …`；并且跳过之后**不许把两侧文本首尾相接**
  —— 否则 `<details>` 在代码块前、`</details>` 在代码块后时会凭空凑出一个"完整块"，
  替换时连中间的代码整段摘掉（比不渲染更糟）。现在收集的是**片段列表**，块**不许跨片段**。
- 测试扩到 7 项，新增两道门：代码块字面量不被渲染、代码块两侧不许拼出假块。

### 🧪 工程

- 新增测试文件 2 个，全量测试文件数与断言数按 `npm test` 输出为准（不手抄）。
- 修掉测试桩的两处缺陷（会让测试假绿/假红，不是产品问题）：
  `ENode.textContent` 只有 getter（赋值静默无效）、`TNode` 缺 `parentElement`
  （导致「跳过 pre/code」这一判断在测试里永远不生效）。

## v2.7.26 (2026-11-01)

> 一句话：Skill 里终于带上**你的角色卡正文**；重复段落去掉一半；新增 ST 式「显示正则」
> （`\uXXXX` 乱码在界面上直接还原成中文）。

### 🐛 修复：`/技能名` 加载后看不到自己的角色卡

- 现象（用户实测）：一让模型读 skill，酒馆里的角色卡「就不见了」。
- 根因两条，缺一不可：
  ① `buildSkillMarkdown` 的「角色设定（照此扮演）」用的是 `extractCardText(agentYml)`
     —— 那是**预设模块的 persona**，`characters.json` 里的角色卡正文（`desc` / `first`）
     **一个字都没进过 skill**；
  ② 会话没挂上酒馆预设时 system prompt 里也没有卡 ⇒ 模型手上只剩 persona。
- 现在 skill 的「角色设定」= persona + **每张启用中角色卡的正文**；
  另加独立一节「开场白（语气与文风范本，不是设定陈述）」保留开场白原文
  （卡作者用它立的腔调，比任何"请模仿"的指示都准）。
- 开场白里常裹着 `<content>…</content>` 草稿容器：现在导入时剥掉**容器标签本身**，
  里面正文一个字不动（否则 Markdown 里的尖括号会被当 HTML 吃掉）。

### 🧹 改进：与 persona 重复的预设模块不再抄第二遍

- 实测某预设的 SKILL.md 有 168 KB，其中「角色描写 / 场景描写 / 动作描写 / 情绪表达 /
  段落编排」整段出现了**两次**：一次来自 persona，一次来自「文风（选一）」模块的正文。
- 去重判据看的是**正文包含**而不是模块名（那两条根本不同名）；判据阈值 40 字，
  短要求（"正文用 `<game>` 包上"这种）不受影响，只跳过整段复制的模块。
- 被跳过的模块**保留一行指针**（"本条要求与下方「角色设定」的正文相同，内容已并入"），
  用户仍看得出它确实在预设里启用着。

### ✨ 新增：显示正则（ST 式输出侧脚本）

- 背景：预设里的「防截断」要求模型把露骨内容写成 `\uXXXX`，而酒馆此前**没有任何输出侧
  改写能力** ⇒ 每轮开头都是一片 `\u55b5\u545c…` 反斜杠乱码（解压会话日志确认：
  一条消息里 184 处 `\u`，`喵呜` 0 处 —— 模型是**照做**的，不是编码坏了）。
- 照 ST 的做法补一张规则表：面板 → 内容 → **🧹 显示正则**，每行「查找(正则) / 替换 / 启用」，
  整表一起存；出厂自带一条 `unicode 反转义`（`\uXXXX → 原文`），保存后立刻重画当前页面。
- **只改显示**：作用对象是 DOM 文本节点，**不进提示词、不改会话日志**
  （DSH 会话日志是 zstd 多帧格式，红线 §7.1 明令不许解析/改写）。
- 引擎**只有一份**，在客户端 bundle 里；`lib/server/display-rules.js` 只做规则表校验
  （非法正则逐条丢弃，一条写坏不会把整条消息打空）。这条口径有测试盯着：
  服务端那个文件里出现 `.replace(` 直接判红。
- 导入 ST 卡时，卡里携带的输出侧 `regex_scripts`（`data.extensions.regex_scripts`）
  会自动收进规则表；`promptOnly` / `disabled` 的脚本一律跳过（它们不属于显示层）。
- 幂等：同一段文本跑两遍结果不变，切会话/重复装饰不会二次改字。

### 🧪 工程

- 新增 `tests/display-rules.test.js`（16 项）：出厂默认、归一化幂等、非法规则逐条丢弃、
  ST `/pattern/flags` 解析、卡脚本导入、客户端引擎（还原 / 幂等 / 开关 / `$1` 反引 /
  坏正则隔离 / state 未就绪的退路）、以及"服务端不许有 replace"的红线。
- `tests/skill-gen.test.js` 增 ④b–④f（卡正文进 skill、开场白独立成节、容器标签剥离、去重正反例）。
- `panel-html-identity` / `panel-tabs` 的冻结值随新卡片重测（有意改动，已在原处写明理由）。

### ⚠️ 升级后要做一次「生成 / 更新技能」

`SKILL.md` 是**保存预设时**生成的文件，不会因为升级插件而自己变。已有的旧技能里仍然没有
卡正文、仍带着重复段落。升级后到 **内容 → 🎓 技能 → ⚙️ 生成 / 更新技能** 点一次即可
（每个用到的预设各点一次）。

⚠️ 默认预设（酒馆默认）请**用「目录名」那一侧绑定**：它的技能目录名规范是 `tavern-lite`
（`skillNameForPreset('tavern-lite')`），不是注册表 id `default`。用 `default` 去生成会得到
`tavern-default` 这个**另一个目录**，于是同一个预设在技能面板里出现两行、还可能互相覆盖绑定。

## v2.7.25 (2026-11-01)

### 🔐 脱敏：示例数据全部换成通用占位符

> 这个版本**没有功能改动**，只把仓库里残留的真实数据换成占位符 —— 请优先升级到本版。

- 测试夹具与源码注释里曾出现真实角色名、世界书条目名与剧情正文，现已全部替换：
  角色名 → `角色甲` / `角色乙` / `角色丙`，卡片 → `示例卡`，剧情与选项 → `正文首行甲` / `选项甲` 等。
- 仓库的全部历史提交已重写（旧提交已从对象库清除），因此**本版之前的 tag 指向的提交 SHA 已变化**；
  如果你 clone 过旧历史，请重新 clone。
- 2.7.22 / 2.7.23 / 2.7.24 三个 npm 版本的 `lib/server/worldbook.js` 含上述旧注释，
  已在 npm 上标记为 deprecated —— 新装请直接用 2.7.25。
- 功能与 2.7.24 完全一致（含 `<options>` / `<details>` / `<game>` 原生渲染与切会话幂等）。

## v2.7.24 (2026-11-01)

### 🐛 修复：`<options>` / `<details>` / `<game>` 原样露出尖括号文本

- 现象：模型在正文后追加 `</game> <summary>…</summary> <details><summary>…</summary>…</details>
  <options><option>…</option>…</options>` 时，界面上**原样显示这些标签原文**，既没有折叠块也没有选项按钮。
- 实测两张标签表都不认这批标签：muv-engine 的 `_tavernRenderTags` / `MUV_TAG_RULES` 只处理
  `<choices>` / `<choice>`；`<game>` 与裸 `<summary>` 两边都没有渲染器。
- 本插件补一段兜底 `renderRawTagBlocks()`：`<option>` → 复用既有 `.tavern-option-btn` 按钮
  （走文档级点击委托，点了直接发送）；`<details><summary>` → 折叠块（默认收起）。
  只用 `createElement` / `textContent` 建节点，**不新增 innerHTML sink、不新增样式、不新增监听**。
  执行顺序在 `delegateBeautify()` **之后** —— muv 会整段重写正文，兜底必须跑在它后面才不被覆盖。
- **容错**：标签不配对时只丢标签、正文一个字都不吞（用贪婪 `[\s\S]*` 配对的实现会把 `</game>`
  之后整段吃掉，测试里专门配了这条反例）。

### ⚡ 改进：切会话不再重复渲染

- 幂等判据从「一次性打标记」升级成**内容指纹**：整条消息算一次 FNV-1a，渲染完写回
  `data-tavern-raw-sig`，下一轮读到同一个值直接返回 —— 来回切会话、流式刷新、muv 重复装饰
  都不会重复造节点（不靠时间窗口去猜）。
- MutationObserver 的触发合并到 `requestAnimationFrame`：观察的是整份 body（`childList + subtree`），
  流式输出时每帧都触发，而每轮要全页查询 + 逐条算指纹；合并后这层开销不再压进打字/滚动的帧里。
- 流式输出中若末尾还挂着未闭合的 `<opt…`，本拍**先不渲染**（否则选项按钮会闪一下再散开），
  等标签闭合或流结束再处理。

### 🧪 工程

- 新增 `tests/raw-tag-render.test.js`（4 项）：三类片段切开、正文不吞、贪婪实现反例、幂等判据在位。

## v2.7.23 (2026-10-29)

### 🐛 修复：快速上手引导收起后打不开

- 引导卡的操作行（🔄 重新检查 / 收起引导）原来挂在**正文容器**里，收起时 `display:none`
  把**「展开引导」按钮也一起藏掉了** ⇒ 收起一次就再也打不开（只能手动清 localStorage）。
- 现在操作行固定在卡片底部：收起只隐藏步骤正文，按钮常驻，标签在「收起引导 / 展开引导」之间切换。
- 新增 `tests/quick-guide-toggle.test.js` 钉住位置（含反证：`body.appendChild(actions)` 不许出现）。

### 📖 文档

- CHANGELOG 的 2.7.22 段落整理成发布说明（一句话摘要 + 新增/改进/清理/升级提示）。
- TUTORIAL 整份重写为当前版本的使用指南（上手、预设 vs 预设模块、开场白、导出、技能、常见问题）。

## v2.7.22 (2026-10-29)

> 一句话：新增「导出角色卡 / 世界书 / 预设」；面板加了上手引导；预设能原位改名/删除；
> 开场白可选用备选开局；顺手删掉 7 个点了没反应的按钮。

### ✨ 新增：导出（角色卡 / 世界书 / 预设）

- **内容页签 → 📤 导出**：勾选要导出的三类内容，选择范围（当前预设 / 全部酒馆预设），
  点「📁 选择文件夹并导出」，会弹出**系统原生**的文件夹选择框（Windows / macOS / Linux 各自实现，
  异步弹出，不阻塞界面；路径用 UTF-8 临时文件回传，中文路径不乱码）。
- 落盘结构：`<你选的目录>/酒馆导出-<时间戳>/<预设名>/{角色卡,世界书,预设}/`，
  另附 `manifest.json` 与 `说明.txt`（写明怎么导回来）。导出**只读**预设目录，不改任何原文件。
- **双格式**，既能导回本插件，也能给 SillyTavern / 卡站：
  · 角色卡 → 原生 `characters.json` + 每张卡一份 ST `chara_card_v2`（**备选开场白一起带**）；
  · 世界书 → 原生 `worldbooks.json` + 每本一份 `{name, entries[]}` + `.st.json`（ST world info）；
  · 预设 → `preset.yml`、`agent.cordis.yml`、`presets.json`（写作模块）+ `st-preset.json`
    （只带 `prompts`，导入时按「模块」处理，不会意外新建一个 agent 预设）。
- 新接口 `POST /api/tavern/export`：不传 `parentDir` 也不传 `pickFolder:true` 时直接 400，
  所以测试/脚本不会误弹对话框；上次选择的目录记在 `state.lastExportDir`。

### ✨ 新增：设定优先级 / 末尾写作模式

- **设定优先级**：新增 `state.skillPriority`（`preset` 默认 / `skill`），在 `tavern:card` 顶部注入
  【设定优先级】声明；🎓 技能卡片可切换。预设优先时 skill 只补流程与工艺；技能优先时预设只提供事实设定，
  两边的安全红线始终有效。生成的 `SKILL.md` 与 `buildSkillsHintText()` 引用同一口径。
- **末尾写作模式**（默认关）：开启后每个用户回合在消息列表**末尾**追加一条**中性**位置提示
  （`source.kind = "tavern-post-history"`）。只借"最后一条是写正文"的位置，**不搬运破限正文**；
  仅对酒馆会话、仅 step 1 注入，会话隔离不破。

### ✨ 新增：上手引导

- 会话页签顶部新增「🚀 快速上手」：四步（导入角色卡 → 世界书 → 选预设开新会话 → 注入开场白），
  每一步按**当前真实数据**自检（已导入几张卡 / 几本世界书 / 当前预设名），可「🔄 重新检查」。
- 首次打开自动展开，收起状态记在 localStorage，随时可再展开。

### ✨ 改进：预设管理

- 预设下拉每行右侧新增 **✎ 重命名 / 🗑 删除**：**不用先切过去**就能改名或删除；
  只对酒馆预设开放，内置/其它自定义预设标「只读」（避免误删要重装的东西）。
- 新增名称筛选框；删除当前预设会自动把本会话绑到剩下的第一个酒馆预设，避免下一轮静默不注入。
- 修复：切换预设后下拉里的 **✓ 与标签不同步**（标签已是新预设、打勾还停在旧预设）。
- 下拉顶部文案讲清「这里的列表 = DSH 预设」与「内容 → 预设模块 = 本预设的写作模块」。

### ✨ 改进：开场白支持备选开局

- 导入角色卡时保留 ST 的 `alternate_greetings`（落盘为 `alternates`，以前会被整段丢掉）。
- 面板改为「选角色卡 + 选版本（第一 / 备选 N）」并带开头预览；
  新增 `GET /api/tavern/greeting/options`，`POST /api/tavern/greeting/insert` 支持 `greetingIndex`。
- ⚠️ **旧版导入的卡**在导入时备选就已经丢了，需要把同一张卡**重新导入一次**才能选备选开局；
  面板里对「只读到 1 段」的情况会直接提示这一点。

### 🧹 清理：删掉点了没反应的按钮

逐个核对每个控件的「监听器 + 后端路由 + 路由结果的消费方」，只删**点了不改变任何行为**的：

- **「🎯 生效范围」三个按钮 + 两个名单**（所有会话 / 仅当前会话 / 当前工作区）：2.7.19 起
  注入闸门是「DSH 顶部预设」，`decideInjectionScope()` 的结果只剩排障日志在用。
  同一张卡里真正生效的「📚 世界书注入量」保留并单独成卡。
- **「📦 上下文压缩」**：它调用的就是 `/api/tavern/summarize`，与「🚀 手动总结」同一个接口；
  DSH 没有"压缩上下文"这个能力，按钮名承诺了做不到的事。
- **「✅ 保存并关闭」**：保存与「💾 保存预设」完全重复，"关闭"是靠猜一个 `[class*=close]` 去点。
- 「预设」更名为 **「预设模块」**，描述里直接写明它不是 DSH 预设列表。

### 🧪 工程 / 测试

- 新增测试：`export-bundle`（转换器 + 端到端落盘 + "原预设目录一字节未动"）、`greeting-alternates`、
  `post-history`、`skill-priority`。
- `panel-html-identity` 重新冻结（段/产物/指纹）：markup 本轮是**有意**改的，两条旧断言按新语义降级
  并写明理由（0 命中必须登记在 `fingerprints.retired`；「历史产物 == 当前产物」降级为历史锚点）。
- `panel-tabs`（13 张卡）、`preset-idx`（改成正向反证：删掉的控件不许复活）、`client-integrity`（覆盖下限）同步。

### ⚠️ 升级提示

- 本轮**新增了服务端路由**（导出、开场白清单）⇒ 升级后必须**重启 DSH** 才生效；
  只点插件的"重载"不会加载新路由。
- 若你的机器上有多个 DSH profile（如 `web` 与 `desktop`），它们共用插件文件；
  手工更新时**新增文件**要两边都补齐，否则一边会因 `ERR_MODULE_NOT_FOUND` 整插件挂载失败。

## v2.7.20 (2026-10-18)

### 🔒 决议兼容：旧版 DSH（没有原生投影服务）退回账本

- `nativeAgentPresetOf()` 读不到值有三种原因，2.7.19 把它们压成同一种「零注入」：
  ① 服务不存在（旧版 DSH / 单测夹具）；② 服务在、会话不在内存；③ 服务在、值是标准预设。
  现在 ②③ 仍是零注入（用户定稿「没选就不读」），① 退回「日志 → 账本」——
  旧版 DSH 用户不会一个字符都拿不到。
- 新增 `nativePresetServiceReady(ctx)`；当前 DSH 永远有该服务 ⇒ 线上行为与 2.7.19 一致。

### 🧪 测试对齐新语义（9 个文件，49 项）

- 作用范围（allowlist / allowCwds / disabledCwds）不再参与注入：相关断言改为「照常注入」。
- 投影夹具：core / nsfw-slot / golden-host-assembly / golden-host-assembly-rich /
  native-bind-route 补上 `sessionProjections.stateOf(...) → agentPreset`。
- `native-preset-binding` [17][28] 断言反转：投影 == standard ⇒ 零注入（账本不再覆盖）。
- 全量：**609 通过 / 0 失败**（旧数值 547/49 来自滞后夹具）。

## v2.7.19 (2026-10-17)

### 🔒 会话隔离：注入只认「顶部 agent 预设」

- `resolveAuthoritativePreset()` 改为 **唯一权威 = 会话的原生 agent 预设**（DSH 顶部选择器）。
  账本 / 历史日志**完全不参与**；投影可读时只认投影（是酒馆预设就注入，不是就一个字符都不注入）；
  投影读不到时也不注入（没选就不读）。
- 移除 `tavern:card` / `tavern:nsfw` 的**作用范围闸门**（global / allowlist / allowCwds /
  disabledCwds 不再参与）——`isTavernSession()`（顶部预设是不是酒馆预设）是唯一闸门。
- 代价（明确写清）：已开跑且 DSH 锁死预设本体的会话，面板里的「绑定」不再覆盖投影；
  要换内容请在**顶部选择器**改。

> 面板里的「🎯 生效范围」与「🔗 当前会话绑定」从此**不再影响注入**（仅保留数据兼容）。
> 下一次大版本会把这两张卡从面板移除。

### 🎭 SillyTavern 预设导入（通用）

- 新增 `lib/st-preset.js`：把 ST 的 chat completion preset（`prompts[]` + `prompt_order[]`）
  转换成酒馆模块包；丢掉结构占位（`worldInfoBefore/After`、`charDescription` 等）、
  `废稿`/`^char$`/`SPreset` 一类非内容条目、`{{//…}}` 注释；保留被禁用的条目。
- 新增 `POST /api/tavern/preset/import-st`：导入即创建独立 agent 预设
  （`preset.yml` / `agent.cordis.yml` / `characters.json` / `worldbooks.json` / `presets.json`），
  `patch` 声明模式下自动写声明块。
- 面板「导入预设」现在识别 ST 预设文件并把结果回显（模块数 / 启用条数 / 字数 / 警告）。

### 📝 文档

- README 增加「agent 预设为唯一来源」的使用说明与本次更新要点。
- 兼容性：无破坏性 API 变更；`GET/POST /api/tavern/state` 的 `mode/allowCwds/disabledCwds`
  字段保留但不再影响注入。

## v2.7.18 (2026-10-17)

### 📝 文档：README 三处过期信息修正（无代码变更）

npm 包页面展示的 README 一直停留在 v2.5.4 时代，导致读者误以为最新版是 2.5.4。本次只改文档，
**运行时代码一字未改**。

1. **删掉失效的「v3.0.0 版本锁」警告**。README 首行曾警告
   `v3.0.0 不兼容 1.9.1：添加版本锁，旧版预设无法使用新功能`，但——
   - v3.0.0 从未发布（当前 latest 是 2.7.x）；
   - 那条「版本锁」标记**代码里早已移除**，`lib/server/assemble.js` 留着注释写明原委：
     「版本号本身是错的（实际 2.3.x），而且每轮都白占提示词，已按要求取消」。
   即：警告一个不存在的版本、且所指机制已不存在，属纯误导，删除。
2. **顶部「更新要点」章节从 v2.5.4 更正为 v2.7.17**。原标题写 v2.5.4，
   但正文内容其实已经写到 v2.6.0——标题与内容不符，且 v2.6.0 之后又有多个版本从未反映到 README。
   现补上 v2.7.16 / v2.7.17 的要点，旧的 v2.5.2~v2.6.0 降级为次级小节保留。

   > **判定依据（防止再犯同类错）**：v2.7.16 的要点不能只看 CHANGELOG 里 `## v2.7.16` 那一节的标题，
   > 因为它上方还挂着一段 `## 未发布`（10-10 四笔修复 + CSS 令牌修正）——**那段其实已随 2.7.16 发出**，
   > 只是节标题没改名。实证：npm 上 2.7.16 的 tarball 里 `setPresetStatusHtml` 出现 6 处
   > （`lib/client.manager.bundle.js` 内 5 处、包内 `CHANGELOG.md` 内 1 处），
   > 而 2.7.2 的包里是 0 处。另注意 `tests/` 与 git 钩子不在 `files` 里、不随 npm 包发布，
   > 所以「包里搜不到」不等于「没发布」。
   > ⇒ **遗留待办**：CHANGELOG 里那段 `## 未发布` 应正式归档到 v2.7.16 名下，本次未动（改历史归属需单独确认）。
3. **配套伴生插件版本更正**：原写「需配合 dsh-muv-engine 0.3.4 与 dsh-muv-table 0.2.6」，
   与 v2.7.17 的 `^0.4.1` / `^0.3.1` 冲突，且照它装会正好踩回 v2.7.17 修的那个坑
   （0.x caret 锁 minor ⇒ 拿不到已发布的修复）。已更正，并在「伴生插件」表格加了版本列。

## v2.7.17 (2026-10-12)

### 🐛 修复：两个伴生插件的依赖范围都钉在旧版本 ⇒ npm 上的用户一个修复都拿不到

发 `dsh-muv-table@0.3.1` 前回头核对版本范围才发现的。原先声明：

```json
"dsh-muv-table": "^0.2.2",
"dsh-muv-engine": "^0.3.2"
```

**0.x 的 caret 锁的是 minor**，所以 `^0.2.2` 实际是 `>=0.2.2 <0.3.0`、`^0.3.2` 是 `>=0.3.2 <0.4.0`。
用 npm 自带的 semver 实测：

| 判定 | 结果 |
|---|---|
| `satisfies('0.3.1', '^0.2.2')` | **false** |
| `satisfies('0.4.0', '^0.3.2')` | **false** |
| `maxSatisfying([0.2.2 … 0.3.1], '^0.2.2')` | **0.2.12** |

即：**装 `dsh-tavern` 的人拿到的是 `muv-table@0.2.12` + `muv-engine@0.3.x`**，而这两边已经发布的修复
—— table 0.3.1 的「世界书 JSON initvar 读不出 ⇒ 状态栏恒『未知』」、engine 0.4.0 的状态栏两笔
（真卡围栏形态 + 空状态栏刷屏）以及 issue #1 的导入路径纠错 —— **全部到不了用户手里**。

改为 `^0.3.1` / `^0.4.1`。本次只动版本号与依赖声明，**运行时代码一字未改**。

> 同类项提醒：0.x caret 锁 minor，所以 muv-table 将来再跨 minor（如 0.4.0）时，这里必须同步抬一次，
> 否则同样的事会重演。

## 未发布

### 本批（2026-10-10）：四笔修复推送 main

- **`presetStatus` 双 setter（task-26）**：26 处「写文本/HTML + 设色」手写配对收进
  `setPresetStatus(msg, color)` / `setPresetStatusHtml(html, color)`——漏设色 / 漏赋值
  从此不可能；4 处富文本走 HTML 版（`<br>`/`<span>` 不塌行），其余走文本版；
  `undefined` 守卫内聚。innerHTML 棘轮基线同步（25 → 24 条，净减）。实现 `0a585f6`。
- **golden 测试临时目录治理（task-17）**：测试自建的临时 `DSH_HOME` 用完即删
  （收尾整体进 `try`，目录已不存在时优雅跳过；三重路径守卫原样保留），
  不再在系统临时目录累积残留。实现 `61657db` + 覆盖笔 `c3fa49c`。
- **字符串字面量工具去重（task-28）**：解码/扫描两处逐字抄本归一为
  `tests/_helpers/string-literal.mjs`，判据输入不再可能静默分叉。实现 `32accdf`。
- **pre-commit 钩子退出码三态（task-33）**：仓库卫生闸门把「环境拿不到文件清单」
  与「真有违规」拆开（0 = 通过 / 1 = 违规 / 2 = 环境不可用），钩子对后者响亮报因
  并提供**显式**跳过开关（打印留痕）——不再静默放行、不再输出误导性报错。
  实现 `626699e` + 修复笔 `257008b`。
- 门禁：CI 全绿（`npm test` + 四道静态闸门，run 37935791661）。

### 修正 4 种主题里不存在的 CSS 令牌

客户端用了 4 个**主题里从未定义**的 `--dsw-*` 令牌。写法完全合规（名字格式对、语法对），
所以 `check:style` / `check:integrity` / `check:innerhtml` 三道闸门全都放行 —— 但运行时静默失效。

| 令牌 | 处理 | 后果 |
|---|---|---|
| `--dsw-alias-border-default` | → `--dsw-alias-border-l1` | 无回退 ⇒ **整条声明被丢弃，边框从未渲染** |
| `--dsw-alias-label-accent` | → `--dsw-alias-brand-primary` | 有回退 `#ffb464` ⇒ **永远写死橙色，主题切换无效** |
| `--dsw-alias-bg-elevated` | → `--dsw-alias-bg-layer-2` | 有回退 ⇒ 浅色主题下冒出深蓝底 |
| `--dsw-alias-bg-accent` | → `--dsw-alias-button-primary-fill` | 无回退 ⇒ 违禁词「保存」按钮**白字无背景** |

用户可见的修复：

- 违禁词弹层的「保存」按钮恢复了背景色（此前浅色主题下白字落在透明背景上，近乎不可见）；
- 世界书条目编辑器的输入框恢复了边框（此前与卡片底色对比极弱，分不清哪里可以输入）；
- 关系网图标题 / 提示文字改为跟随主题（此前写死橙色）。

核对方式：与 `dsh-client-ui-theme` 的令牌定义做**子串级**比对（排除拼写差异）。
**改动限定在 `panelHTML` 段之外** —— 该段的指纹由 `tests/panel-html-identity.test.js` 冻结，
段内还有 8 处同类令牌，留待与面板结构改动一起处理。

## v2.7.16 (2026-10-08) — 🧱 S2-C2（部分）：`tavern:card` 的正文组装搬进 `lib/server/assemble.js`

`apply(ctx)` 的 `tavern:card` text 回调原先把「取数据」和「拼正文」混在同一个函数里。本版把**拼正文**
那一半搬到新模块 `lib/server/assemble.js`（导出 `assembleCardBody(deps)`），`lib/index.js` 只留装配：

- 注册段 → 判闸门（会话隔离 / 生效范围 / 预设决议 / 子 Agent 继承）→ 调 `assembleCardBody` →
  记 `sectionSizes.card` / `sectionSizes.wb` → `observeInjection` 观测。
- 依赖一律**显式传参**（`ROOT` / `readState` / `readWorldbook` / `sanitizePromptText` …），
  新模块**不** import `lib/index.js`（AGENTS §5 第 5 条：禁止循环依赖）。
- 搬运方式：整块**逐字搬** —— 只按新层级重排缩进，并把原本就地算出的 `mode` 改成入参。
  分两笔落地：第一笔只搬「纯拼接」那一行（字节级等价显然成立），第二笔才搬各部件的计算。

**未搬走（切片锚点，仍在 `lib/index.js`）**：`★ 记忆总结注入` 块、`tavern:nsfw` 注册段、
`flushPromptStats(); return ''` 与 `sectionSizes.nsfw`、`try { observeInjection(…) } catch {}`。

**行为等价**：产物逐字节不变 —— `tests/golden-host-assembly.test.js`（宿主侧回归网）与
`tests/golden-prompt.test.js` 都绿，**fixture 未改动**（不是靠改快照换来的绿）。

**后半段未做**：`apply(ctx)` 里的 HTTP 路由注册尚未抽出，仍属 S2-C2。

**另加一张富夹具回归网**（`tests/golden-host-assembly-rich.test.js`）：可选段全空的那张网看不见
「两个非空段换序」与「依赖忘传」这两类错误 —— 后者会被装配体自己的 `try/catch` 吞掉，表现为**静默少一段**。
本网把夹具扩成「世界书 / 会话记忆 / 关系网 / 技能（两条分支）/ 联网 / 工具开关 / 反八股**全非空**」
并覆盖 `roleplay` 与 `creative` 两种 mode，逐字节钉住产物；另附一条**静态契约断言**：
`assemble.js` 解构出的依赖名集合必须等于 `index.js` 调用点传入的键集合。

fixture 在**搬家前那一版（`aa76a20`）**生成、在搬家后断言逐字节一致（不是"自己拍自己"）；技能指针段会带出
`skillsRoot()` 的绝对路径，已归一化为占位符后再入库（否则每次跑都不一样、且会把本机路径写进仓库）。
⚠️ **修：fixture 不许冻结「环境相关量」**（CI run #9 的真实红项）—— 技能指针段那条绝对路径还让产物的**原始长度**
随临时目录基路径长度变化（本机 tmpdir 与 CI 的另一种形态差 19 个字符 ⇒ `sectionSizes.card` 从 2121 变 2102，
于是"体积快照"那条断言在 CI 上必红、在本机却绿）。现在 fixture **只冻环境无关量**：归一化后正文的
`sha256` / `bytes`（等价性核心证据，一个字节没动）与 `sectionSizes.wb` / `nsfw`；`sectionSizes.card` 改成
**同一次运行内**的当场比对（`=== 当场量到的原始长度`），并补一条可推导关系：
`原始长度 − 归一化长度 === 产物里 tmp 路径的出现次数 × 路径长度差`（把"归一化走了几次"也钉住了）。
两种环境形态（本机 `C:\Users\…\Temp` / CI 形态）实测都绿。

⚠️ 已知后续项：这份 fixture 的**来源验证**目前只在本批材料里做过一次，尚未像 `golden-prompt` 那样接进
`tools/check-golden-origin.mjs` 由 CI 每次复查。

> 验证数字不写在这里（写了必漂）：以 `npm test` / `npm run check:*` 输出为准。

## v2.7.15 (2026-10-08) — 🛡️ 落库边界净化：让写进库的数据本身就是干净的

议题：`docs/issues/2026-10-08-parseSummaryOutput-边界净化.md`（原「待决策」，本版落地）。

**为什么是这个位置**：`parseSummaryOutput` 的产物有**两个消费者** —— ① DOM 路（`relations[]` → 客户端
`innerHTML`，渲染层已用 `esc()` + 结构化棘轮守住）② **提示词路**（`summary` 正文 + 记忆正文 → 系统提示段，
且被框成「AI 请优先参考此总结」）。渲染层对提示词路**完全无效**（文字即指令），所以只能在落库前削。

**已定的三条口径**：
1. **只做字符级 / 标签 / 协议剥离**（HTML 标签、`on\w+=` 事件属性、`javascript:`/`vbscript:`/`data:`、控制字符）；
   **不做**「指令性文本」识别 —— 后者误伤剧情文本且无法穷举。
2. **只在写入时净化**（旧数据不动）。
3. 不删 `relations[]` 语义字段，只做字符级处理。

**判据**：`tests/summary-boundary-sanitize.test.js` —— 坏样本必须被净化（6 类），
好样本必须**逐字节保留**（含 `3 < 5`、`a <b`、引号、emoji、制表符），并验幂等。

⚠️ 这是**纵深防御**，不是渲染层转义的替代。

> 验证数字不写在这里（写了必漂）：以 `npm test` / `npm run check:*` 输出为准。

## v2.7.14 (2026-10-08) — 🔍 sink 提取器修四处失明（复核方第二轮发现）

> 复核方（2026-10-08）用 13 个对抗样例证明 `captureExpr` 在四种形态上失明。**前三处导致漏报**，
> 第四处造成假阳性。这些形态当前代码库**一处都没有**（所以棘轮永远不会因为它们报红）——
> 但"现在没有" ≠ "将来不会有人写"，故本轮把 13 个样例固化为常驻测试 `tests/capture-expr.test.js`。

### 1. 修：续行用**行首**运算符时整行漏判（真·假阴性）
旧实现只认「行尾」开放运算符，于是 `el.innerHTML = esc(a)` 换行 `+ rawHtml;` 只捕获首行，
**续行里的裸值完全不被看见**。现补「下一行以顶层运算符开头也算续行」。

### 2. 修：行尾注释未剥离
三个可复现形态：① 注释文本进了表达式；② 注释以运算符结尾时**吞掉下一行**（实测把 `next()`
判成不安全段 ⇒ 假阳性）；③ 注释里括号/引号不配对同样吞行。现于引号跟踪循环里剥掉行尾注释。

### 3. 修：正则字面量里的引号污染引号状态
`String(x).replace(/['"]/g, '')` 里的 `'` 会被当成字符串开头，导致捕获把行尾 `;` 也吃进去
（结论仍偏保守，但报错信息会误导）。现识别正则起点并整段跳过（含字符类与转义）。

### 4. 修：字符串字面量里的 sink 文本被当成真 sink（假阳性）
`const msg = "el.innerHTML = " + esc(x)` 只是普通字符串。新增 `insideStringSink()`：
逐字符走到匹配位置并数引号，落在字符串里的匹配直接跳过。

### 5. 测试① 之外补「静默遗漏」断言
棘轮测试① 只查「新增」，不查「消失」—— 提取器改动若把某条漏掉，那边不会报。
新增 `tests/capture-expr.test.js` ⑥：基线里每一条都必须**仍然被扫到**；有意消除请跑 `--update` 并说明。

### 6. golden 的**来源**从「自述」升级为「机器结论」（复核方指出的命门）

golden 差分是「重构没改行为」这套证据的命门 —— 若快照其实来自当前版本，它就退化成"自己跟自己比"。
而快照文件本身**没有来源戳记**（实测 0 个元信息键），所以「在 `a816afd` 上生成」此前只能靠自述。

- 新增 `tools/check-golden-origin.mjs`（`npm run check:golden-origin`）：在 `a816afd` 的干净 worktree 里
  用 `UPDATE_GOLDEN=1` **重新生成**，与仓库里的 fixture **逐字节比**（只归一化 CRLF）。
- 实测结果：**逐字节一致**（两边同为 3424 字节、11 个采集键）⇒ 来源声明成立，且此后**每次 CI 都会复查**。
- 浅克隆会**响亮失败**并提示加 `fetch-depth: 0` —— 不做"取不到就跳过"（那是静默变绿）。
- 已接入 CI 与 `npm run ci:local`（护栏 ④-b/④-d 仍全过）；留痕进 `docs/VERIFICATION.md` §二。

### 7. 文档收口（六处过期 / 自相矛盾）

- `AGENTS.md` §2 依赖图：原写「S2-B/C 待做」并列出**并不存在**的 `inject.js` / `relations.js` / `routes/`；
  现改为与 §10 一致（已整块搬出；routes/ 尚未抽出属 S2-C2）。
- `AGENTS.md` §6⑤：撤掉手抄的一组样式基线值（护栏没覆盖这一类；S3 一改样式就全过期），
  改为指向 `tools/style-budget.json`。
- `AGENTS.md` 头部版本与页脚「最后更新」同步到本版（原为 2.7.13 / 2.7.10）。
- `AGENTS.md` §9：「CI 10/10 step」**对不上任何口径**（GitHub 记 11 步，工作流 7 个 step）⇒
  改为「**5 个实质步骤全过**」并注明收尾步。
- `tools/check-client-integrity.mjs` 注释里的「depth=1」与实测矛盾 ⇒ 改为「比同级一致性；现库实测 depth=2」。
- `docs/VERIFICATION.md` §四：把「待裁定 / 本仓倾向 B」落成**已裁定选 A** + 四条约束 + 实现要点。

### 8. `param-by-callers` 的两条按复核方裁定拆开处置

这两条（helper 把入参当 HTML 写）是本套护栏里**证据最弱**的一类：`mustContain` 只能钉住"某些片段还在"，
挡不住"往同一条拼装链里新加一个未转义片段"。复核方的裁定是**拆开判**：

1. **开场白面板的 helper → 改代码**：实测它的 5 个调用点**全部是纯文本**（字面量或 `esc(...)` 拼接，无标签），
   故改 `textContent` —— 渲染结果不变，而「入参被当 HTML 写」这一类**结构上**消失。
   棘轮随之**已消除 1 条**（基线 23 → 22，单调收缩）。
2. **全局正则面板的 helper → 不改代码，改守「值来源」**：它有一个调用点刻意渲染 `<br><span>` 标记，
   改 `textContent` 会把标签当文字显示。新增 `ORIGINS` + `valueOriginUnsafe()`：
   从变量声明到 sink 调用之间，每一条对该变量的赋值/累加语句，其每个顶层 `+` 分段都必须是
   「字面量 / esc 函数族 / 计数 / 数字型惯用法（`xxx || 0`）」。
   **锚点（声明行 / 调用行）找不到 ⇒ 响亮失败**，不许静默通过。

验收条件（复核方原话）：**往同一条链里新加一个未转义片段必须报红** —— 已由
`tests/value-origin-assert.test.js` ②③（坏样本必须报红）、④（锚点消失必须响亮失败）钉死。

⚠️ 已知窄口径（写进判据注释供审计）：`(d.note || 0)` 这类"标识符 + `|| 0`"会被当成数字；
若该位置将来真的放文本数据，要**改判据**而不是放宽它。
### 9. 判据核心重构：三处扫描器收敛成一个 + 结构化递归判定（独立复核报告的发现）

独立检验者（对抗性复核）证明：**只修 `captureExpr` 一个扫描器是不够的** ——
`splitTopLevel` 与 `insideStringSink` 各自数引号，正则里的引号污染它们之后表达式会塌成一段，
而旧的 `ESCAPE_CALL.test(段)`（整段里出现 `esc(` 就放行）随之退化成「整行有 esc 就放行」。
**实测 9 类真漏报**：模板插值混裸值、括号内混裸值、无括号三元、逻辑与、调用实参、
`.map` 体内混裸值（PR #13 同一类）、正则字面量导致塌段、正则污染致真 sink 失配。

**改法**：
- **引号/正则/深度感知只实现一次**（`eachCodeChar` / `regexEndAt`），四个扫描器全部调它；
- 新增 `splitOperators`：在顶层按 `+ - * / % && || ?? ? : ,` 切分（原先只切 `+`）；
- `segmentIsSafe` 改为**结构化递归**：纯 esc 调用才算安全；组 / 实参 / 三元两支 / 模板插值 /
  箭头体 / 函数体 / 集合方法链 一律递归，深度上限 6（超出按不安全，保守）。

**顺带真修一处**：历史对话选择器 `+ d +`（日期串）未转义 → `+ esc(d) +`。

**基线 22 → 25**：新增 3 条是「函数体里先算局部变量再拼接」型（局部变量本身含刻意渲染的标记，
不能用处补 esc），按本仓既有做法进基线 + `mustContain` 证据（证据片段**从源码自动派生**，不手写猜）。

**复核样例固化**：新增 `tests/structural-judge-vectors.test.js`（9 类真漏报必须报红 + 2 类对照组必须放行），
改判定逻辑时它是主要回归网。

### 验证
> **本版的验证数字一律不写在这里**（写了必漂：本段在定稿过程中被改过三次，出现过 503/509/512 与「基线 23/22/25」三套口径）。
> 要看现状请跑 `npm test`、`npm run check:innerhtml`、`npm run check`，**以输出为准**。
- `check:style` / `check:integrity` 均 exit 0；语法 57 个文件（含新测试）
- 对抗样例逐条复验：行首续行已捕获 `"esc(a)\n  + rawHtml"`；三种注释形态均只剩 `esc(a)`；正则捕获止于分号

> 📦 **历史条目已归档**：v2.5.5 及更早（2026-08-27 ~ 2026-10-04）在
> [`docs/archive/CHANGELOG-pre-2.6.md`](./docs/archive/CHANGELOG-pre-2.6.md)。
> 根目录只保留 **v2.6.0 起的当前批次**，更早的请去上面那个文件。

## v2.7.13 (2026-10-08) — 🛡️ 棘轮扩成 sink 清单 + 修掉两处既有裸拼（v2.7.2 起就有）

复核方指出「棘轮只认 `.innerHTML` 一种出口」—— 谁加了 `insertAdjacentHTML` / `outerHTML` /
`document.write` / `createContextualFragment` / `srcdoc`，棘轮照样全绿。这与「`render-escape` ③
按变量名写死」是同一类病，只高了一层。落地的过程中，**新判据自己就抓出了两处既有真洞**。

### 1. sink 清单化（6 种，每种同一套结构判据）
`innerHTML`(=/+=)、`outerHTML`(=)、`srcdoc`(=)、`insertAdjacentHTML`、`document.write`、
`createContextualFragment`。另加 `UNLISTED_SINKS`：`insertAdjacentElement` / `document.writeln` /
`dangerouslySetInnerHTML` / `outerHTML +=` / `DOMParser` / `eval(` / `new Function(` ——
谁新开了清单外的注入入口，直接报红（否则棘轮会「全绿但没在看」）。

### 2. `srcdoc` 的缓解写成**断言**
srcdoc 是整份文档、转义无从下手，唯一缓解是 iframe 的 `sandbox`。现在：有 srcdoc ⇒ 必须存在
`sandbox`；且取值里**不许**出现 `allow-same-origin`（否则 iframe 与主页面同源，srcdoc 就真成了注入点）。
配了两个方向的对照测试。

### 3. 顺带发现并修掉旧判据的**三个覆盖洞**（这比 sink 清单更重要）
- **跨行拼接的续行整段漏判**：旧提取器只看拼接的首行（「括号未闭合才往下一行吃」），
  于是 `st.innerHTML = 首行字面量 + …` 的**续行里有什么它一概不知**。
- **单段 RHS 整行跳过**：旧实现 `segs.length < 2 → continue`，把 `el.innerHTML = 动态值`
  （只有一段、恰恰最危险）整行放过。
- **带插值的模板字面量被判成「字面量」**：`` `<div>${x}</div>` `` 是拼接的另一种写法，旧判据把它当安全字面量放行（写回执时又探测出来的一处；当前仓库 0 处使用 ⇒ 收紧后基线零变动）。

这两个洞加起来，实测曾静默放过 **26 行** —— 其中 **2 处是真洞，v2.7.2（重构前）就存在**：
- 「保存成功」提示把 **`presetName`（用户自建预设名）** 与 **`agentPresetName`** 裸拼进 `innerHTML`
- 违禁词编辑弹窗把 **`bannedWords.join(逗号)`（用户自填违禁词）** 裸拼进 `<textarea>`
  （可用 `</textarea>` 破出 —— 与 #14 点名的世界书 textarea 同一 class）

均已加 `esc(...)` 修掉，并补进回归测试。**这两处不是本次重构引入的**（a816afd 与 HEAD 逐字节同在），
是旧判据看不见它们。

### 4. 基线 7 → 23（覆盖扩大的一次性扩张，逐条带分类 + 证据）
分类：`static-template` 1 / `numeric` 6 / `ternary-literals` 1 / `upstream-escaped` 11 /
`param-by-callers` 2 / `sandboxed-doc` 1 / `dom-roundtrip` 1。**每条都必须给出机器可验证的证据**：
要么 `mustContain`（片段必须仍在，复查失败即报红），要么 `builder`（对指定函数体做「不许出现未转义插值」
的结构断言 —— 例如 `panelHTML()`：sink 那层只看到一个词，真正要守的是它 483 行的体内）。
同文本重复行（`box.innerHTML = h;` ×2、`detailPanel.innerHTML = html;` ×2）加了同文本序号 `occ` 进键，
否则棘轮分不清「消掉一条」还是「另一条还在」。

### 5. 新护栏 ⑧⑨⑨⑩⑪（变异验证全过，见 `docs/VERIFICATION.md` §一 18~24）
⑧ 六种 sink 各塞一个样本必须全部被点名；⑨ 清单外入口必须报红；⑩ srcdoc 的 sandbox 双向断言；
⑪ 往静态模板塞未转义插值必须报红（sink 那层看不见它）。

### 6. 文档裁定落地
- golden 的 `EXEMPT` 措辞改为「**S2-C2 的前置条件**」：复核方核实 `apply` 本体在 `a816afd→HEAD`
  只改了 5 行（全是 `S.` 前缀改写），装配体不是这轮动的对象 —— 但 **apply 文本没变 ≠ 行为没变**
  （它调用的 1847 行被搬走了），风险由对账工具（选 A）覆盖。
- `AGENTS.md` §12 补两条：行数护栏只管「现值型文档」（CHANGELOG 的历史记录不该清）；
  「npm 能 spawn / node child_process 不能」是某些受限沙箱的属性，不是仓库属性。

---
## v2.7.12 (2026-10-08) — 🔁 让 CI 可本地复现 + 三条防漂护栏

> 承接 2.7.11：CI 是「唯一的自动化强制点」，所以它自己也需要护栏与本地复现路径。

### 1. `npm run ci:local`：一条命令复现 CI
按工作流**相同的顺序**跑那 5 条（语法检查 → 全量测试 → 样式预算 → 客户端自检 → innerHTML 棘轮）。
推送前先本地跑一遍，比等 CI 红了再回来查快得多。

### 2. 三条防漂护栏（`tests/tooling-integrity.test.js` ④-b / ④-c / ④-d）
- **④-b** CI 里每条 `run:` 必须能映射到 `package.json` 的脚本 —— 改了脚本名却忘改工作流，立刻报红。
  这正是 2.7.11 修的那类漂移（清单与事实脱节），只是换到了 CI 这一层。
- **④-c** 工作流不许出现 `continue-on-error` / `|| true` —— 那是把护栏**静默中和**的手段。
- **④-d** `ci:local` 与工作流必须是**同一组**命令，否则会出现「本地绿、CI 红」。

变异验证：脚本名写错 / 加 `continue-on-error` / `ci:local` 少一步 —— 三条全部报红。

### 3. 实测：CI 等价环境（干净 checkout + 无 DSH）全绿
`git worktree add --detach` 出干净 checkout、去掉 `DSH_ASAR`（CI 上没有 DSH），按 CI 的原样命令跑：

| 步骤 | 结果 |
|---|---|
| `npm run check` | ✅ 语法全通过（54 个文件） |
| `npm test` | ✅ 全绿（数量以输出为准 —— 这里原本手抄了 481，是更早提交上的数，已过期） |
| `npm run check:style` | ✅ exit 0 |
| `npm run check:integrity` | ✅ 三件套全通过 |
| `npm run check:innerhtml` | ✅ 基线 7 条 / 0 新增 |

那 3 个 skipped 是 `cordis-mount.test.js` 找不到 DSH 的 `app.asar` 时自己 skip（4 项里 skip 3、剩 1 项照跑）——
合法跳过，空跑判据不误报。工作流 YAML 本身也用解析器验过（一次性验证，不进依赖）：触发条件、
`runs-on`、7 个 step 全部正确。

### 4. ⚠️ 触发 CI 要推**分支**，别推 main
本地 `main` 与 `origin/main` **内容一致但历史不同**（共同祖先只到 `2cea581`，两边各自重写），
直接推 `main` 会变成 force-push。推分支即可：`git push origin main:ci/<名字>`
（分支推送同样命中 `push` 触发器）。

> 为什么单列这一节：CI 是唯一能在服务端拦下坏提交的地方，但它同时也是最容易「以为它在跑、其实没跑」
> 的地方 —— 工作流文件名错、YAML 缩进错、脚本改名、`runs-on` 写错，任何一种都会让它静默失效。

### 5. ✅ CI 首次实跑绿（闭环）
2026-10-08 推送分支 `ci/tooling-security-refactor` 后，workflow `check` 在
[run 37725561712](https://github.com/chen731215-dev/dsh-tavern-v2/actions/runs/37725561712) 上**全过**（10/10 step，约 70 秒）——
「重构等价」至此不再只有本机证据。

配套新增 `tools/ci-watch.mjs`：一条命令拿到「结论 + 每个 step 的成败」，匿名读 API 不需要 token。
（为什么要固化成工具：CI 最容易的失效方式是**静默不跑** —— 文件名错、YAML 缩进错、`runs-on` 写错，
任何一种都不会给你报错，只会让你以为「没消息就是好消息」。）

### 6. 发布前真机冒烟清单（补上 CI 的**结构性盲区**，见 AGENTS.md §9.1）
`cordis-mount.test.js` 是唯一真的把插件挂进 DSH 的测试，而 CI（及任何没装 DSH 的机器）上它是
**4 项 skip 3 项**。所以「CI 绿」并不等于「插件能在真 DSH 里跑」—— 这一环只能由真机确认：
先在本机跑 `node --test tests/cordis-mount.test.js`（应 `pass 4 / skipped 0`，覆盖「能挂载」），
再做四条 UI 冒烟（面板能打开 / 预设列表正常 / 切预设不串台 / 记忆与关系网正常注入）。
结论要写进发布那次的 CHANGELOG 条目留痕。

### 7. 落主线的方式：两条平行历史已接续
本地与远端此前是两条平行历史（远端那批提交是当年用「API 重建 blob→tree→commit」推的，
同内容不同 SHA，共同祖先只到 `2cea581`）。普通 3-way merge 会在此**假冲突 7 个文件**，
所以用 `-s ours` 把 `origin/main` 接为第二父提交（merge commit，**零内容变化**）——
依据是两个提交的 `^{tree}` 同为 `85390b25…`（同一个 tree 对象，即内容已被完整包含）。
接续后 `git push origin main` 是 fast-forward，不再需要分支，更不需要 force。

### 8. 补上「行为等价」的证据（golden 差分 + 验证记录留痕）
复核方指出（原话）：**「逐行对账 + 全量测试通过」只是必要不充分** —— 文本对得上只说明没抄错，
而重排代码最容易破坏的恰是没有断言的那些行为。补了两件：

- **`tests/golden-prompt.test.js` + `tests/fixtures/golden-prompt.json`**：golden 是在**重构前那一版**
  （`a816afd`）上生成的产物快照，测试在当前版本上重跑同一组固定 fixture 并要求**逐字节一致**。
  覆盖 11 项纯组装阶段（世界书选条与渲染、卡正文提取、宏剔除、`sanitizePromptText`、预算估算、
  注入模式判定、阶段解析、拼接语义）。变异验证：改掉 `buildWorldbookText` 标题两个字 ⇒ 立刻报红
  并打印第一处差异行。**豁免清单**（`apply` 内部组装、stats 数值、UI、时序、LLM 失败路径）
  写在测试的 `EXEMPT` 常量里并有护栏防缩水。
- **`docs/VERIFICATION.md`**：把历次「变异 → 报红」对照表留痕（17 条），并如实列出**不可复核**的
  证据（`_scratch/` 临时脚本不入库 ⇒ 白名单那条对第三方不可见，替代证据是 golden 差分 +
  函数清单核对：旧版 195 个函数声明 → 新版全 `lib/**` 372 个，**旧有新无 = 0**）。

### 9. 复核方抓出的两处我方问题（已修）
- **「485 vs 481」不是环境差异，是我自己的文档数字过期**：481 量于 `f0cb541`（那时
  `tooling-integrity` 只有 11 项），`87f5b6e` 给它加了 4 项 ⇒ 现为 485。已把数字换成不漂的写法，
  并把护栏扩到「`N pass` / `N fail`」这一类（原来只禁「N 个测试文件 / N 项断言」，漏了另一半）。
  顺带排除猜测：node v22.22.2 与 v24.21.0 跑同一套都是 485/3。
- **行数口径**：`wc -l`（数换行符）= 5582，`split(/\r?\n/).length`（多算一个尾部空元素）= 5583。
  两种口径差 1 —— 所以**活的行数一律不写进文档**。

---
## v2.7.11 (2026-10-08) — 🧷 补上唯一的自动化强制点：CI + 清单不再手抄 + 文档不再手抄数字

> 这一版修的是「**护栏本身没有护栏**」：全量测试 / 语法检查 / 样式预算 / 客户端自检 /
> innerHTML 棘轮，此前全都只在「人记得手动跑」的那一刻生效。

### 1. `npm test` 曾经漏跑 3 个测试文件 —— 其中一个是安全回归
`scripts.test` 是一条**手抄的 `&&` 长链**。实测（2026-10-08）：`tests/` 下有 25 个
`*.test.js`，而脚本只引用了 22 个，漏掉的是：

- `tests/render-escape.test.js` ← **issue #14 的安全回归测试**
- `tests/nsfw-slot.test.js`
- `tests/server-state-paths.test.js` ← 2.7.8 的路径镜像护栏

也就是说：**用最顺手的 `npm test` 会得到「全绿」，但安全不变量根本没执行。**
同一批排查还发现 `scripts.check` 只引用了 14 个文件、漏掉 13 个测试文件 —— 同一类漂移。

现在两处都改成**扫目录**，清单由文件系统决定：

| 脚本 | 之前 | 现在 |
|---|---|---|
| `npm test` | 手抄 22 条 `&&` 长链 | `node tools/run-each-test.mjs`（自动扫全部 `tests/*.test.js`） |
| `npm run check` | 手抄 14 条 `node --check` | `node --experimental-vm-modules tools/check-syntax.mjs`（扫 `lib/` `tests/` `tools/`，52 个文件） |

新增测试文件**自动**被纳入，不会再漏；`&&` 长链「第一个红项掩盖后面全部」的问题也一并消失。

### 2. 顺带修掉 runner 自己的空跑漏洞
`tools/run-each-test.mjs` 原来的判据是 `status===0 && fail===0` ——
**`pass=0 且 fail=0` 会被判成通过**，也就是「一条断言都没跑」等于绿灯。
现改为判**失败**（输出标 `❔`），并加了「合计 0 项断言 ⇒ 失败」的总量兜底。
受限环境里 spawn 失败（EBUSY）现在会明确报红，而不是伪装成通过。

### 3. 新增 CI —— 这是唯一能在服务端拦下坏提交的地方
`.github/workflows/check.yml`（`windows-latest` + Node 22，本仓是 Windows-only）：
push / PR 自动跑 `check` / `test` / `check:style` / `check:integrity` / `check:innerhtml`。
`cordis-mount.test.js` 找不到 DSH 的 `app.asar` 时会自己 skip，所以 runner 不需要装 DSH。

`tools/check-syntax.mjs` 刻意**不用 `node --check` 子进程**，而是在当前进程里用
`vm.SourceTextModule` 解析 —— 受限环境里 spawn 型工具会给出「全 0」的假结果，
而假结果长得像绿灯。

### 4. 规范文档里的数字清零
`AGENTS.md` 曾在**同一节里同时存在 22 / 23 / 25 三套口径**（`CLAUDE.md` / `.cursorrules`
还各有一份 434 项的过期拷贝）。现在：**文档里禁止再写「N 个文件 / N 项」**，
一律「以 `npm test` 的输出为准」；结构树里每条手抄行数也全部删掉
（顺带补上缺失的 `tools/check-syntax.mjs`、`.github/`，删掉重复的 `style-budget.json` 行，
修掉 §10 里被 blockquote 切断的表格与重复的 S3 行）。

### 5. 混合换行清零
`.gitignore`（我在 S1 追加时误用了 CRLF，原文件是 LF）与 `lib/utils.js`
（末尾 9 行历史遗留的裸 LF）都已统一。`lib/utils.js` 是**纯行尾修复、git diff 为零**。

### 6. §6 新增三条不变量（安全纵深记账）
- **⑩ 编码只覆盖 HTML 语境**：实测 URL 语境 0 处动态 `href`/`src`、CSS 插值的动态部分
  只有「字面量三元」与数值、无 `<script>` 字面量；另外记下第四类此前未记账的语境 ——
  **`srcdoc`**（`iframe.srcdoc = sb.html`，内容来自 muv-engine）。当前缓解是
  `sandbox="allow-scripts"` 且**刻意没有** `allow-same-origin`，改它时不要顺手加。
- **⑪ 落库边界能一次覆盖两条出口**：关系网字段 → `innerHTML`（XSS 路）、
  `summary`/`memory` 文本 → 系统提示（提示词注入路），**字段不重叠但同源**。
  只加渲染端 `esc()` 永远只护得住前者。同时纠正一个容易传错的说法：
  `buildRelationsHintText()` 往提示词里注入的**只有计数**，关系字段本身不进系统提示。
- **⑫ 没有第二道墙**：主 Web UI 页面无阻断性 CSP（实测 `app.asar` 里的 CSP 命中分别属于
  媒体文件响应头 / SVG 净化 / 附属页），而注入的脚本跑在持有 DSH 本地 API 凭据的源里
  ⇒ **必须假设转义是第一道也是唯一一道防线**。

### 7. 新增 `tests/tooling-integrity.test.js`（11 项）
把上面几条钉死：`npm test` / `npm run check` 不许变回手抄清单、CI 必须存在且跑齐五条、
三份文档不许出现手抄数字、全仓文本文件不许混合换行、runner 不许丢掉空跑判据。
每条判据都写成**纯函数**并配**反证组**（喂坏样本必须报错）；已做变异验证：
把 `scripts.test` 改回手抄清单 / 移走 `.github` / 往 `AGENTS.md` 塞回「23 文件 / 454 项」/
把 `lib/utils.js` 改回混合换行 —— 四条全部被精确点名报红。

---

### 8. 验证期间又抓出两处「本机绿、干净 clone 红」的坑（同日补掉）

这两个都不是新功能的问题，而是**护栏自己的问题** —— 是靠「把每个提交单独 checkout 出来、
在 CRLF 检出态下跑一遍」量出来的；本机工作树因为文件恰好是 LF，一直看不出来。

- **`tests/innerhtml-escape-ratchet.test.js` ⑦ 的字符窗口**：它用 `{0,220}` 定位函数体，
  而本仓在 Windows 检出（`core.autocrlf=true`）下 bundle 是 CRLF，CRLF 会把窗口撑破 ⇒ 干净 clone / CI 假红。
  现在先做行尾归一化再匹配。
- **`tools/run-each-test.mjs` 的摘要解析**：原来只认 `ℹ pass N`，而较新的 node 输出 `# pass N`
  ⇒ 所有文件都解析成 `pass=0`。而「全 0」在「只看 `fail===0`」的旧判据下等于**全绿** ——
  这是最危险的一档。现在两种格式都认；同时把 `skipped>0` 从「空跑」里排除
  （`cordis-mount` 在没有 DSH 的环境会整批 skip，那是合法跳过）。

> 顺带量到一处历史遗留：`lib/index.js` **入库时就是混合换行**（blob 里 7168 CRLF + 6 裸 LF），
> 任何一次干净 checkout 拿到的都是混合文件。已随服务端分层提交统一为 CRLF（内容逐字不变）。

---

## v2.7.10 (2026-10-08) — 🧹 三套转义实现收敛成一套 + 基线不许"不明所以"

> 全量 **468/468 通过**（25 文件，护栏由 5 项扩到 7 项），`check:style` / `check:integrity` /
> `check:innerhtml` 均通过。

### 1. 仓库里其实有**三套**转义实现（不是两套），其中一套已经漂了
| 位置 | 实现 | 状态 |
|---|---|---|
| `esc`（L12） | `& < > " '` 五字符 | 真源 |
| `escapeHtml`（原 L6888） | 字符表式，五字符 | 与 esc 等价，但**是第二份实现** |
| `htmlEscapeStr`（原 L6098） | 链式 replace，**只吃四个字符、漏了单引号** | **已经漂了** |

`htmlEscapeStr` 的角色还不同：它被拿去在「已由 `esc` 生成」的 HTML 里做 `indexOf` 搜索键
（`plotGuideText` 那处）。漏 `'` 的后果是**正文含单引号时搜不到**，选项按钮退化成"追加到末尾"——
是行为 bug，不是安全洞。

三套全部收敛成 `return esc(s)`。这正是 `escAttr` 当年的翻车形态（第二份实现漂成空操作），
所以新增测试 ⑦：**仓库里只许有一套转义实现**（`replace(/&/g,'&amp;')` 只允许出现在 `esc` 里），
已做变异验证（加第二套 ⇒ 立刻报红）。

### 2. 基线不许变成"不明所以的白名单"
上一版留了 9 条基线。其中 4 条是**字符串**字段，光看行不知道凭什么放过 —— 半年后没人能 review。
所以做了两件事：

**a) 能彻底消掉的，就不放进基线**：`g.label` / `grp.label` 两处分组标题
（源头是硬编码字面量 `'🍺 酒馆预设'`）直接在用处加 `esc()` —— 这样即使将来来源改成数据也不会漏。
棘轮自动报「已消除 2 条」，基线 9 → 7。

**b) 剩下 7 条每条都要带「分类 + 理由 + 机器可验证的证据」**：
```json
{ "lineNo": 4165, "kind": "upstream-escaped", "why": "pName 与 pMeta 都在上游转过义",
  "mustContain": ["var pName = esc(p.name || '')", "pMeta += '🎭' + esc("] }
```
- 分类：`numeric`（天然安全，可永久留）/ `upstream-escaped` / `ternary-literals`
- **`mustContain` 是每次都会复查的断言**，不是写给人看的注释 —— 谁把上游的 `esc` 去掉，
  测试 ⑥ 立刻报红（已做变异验证）
- `--update` 时匹配不到分类就记 `unclassified`，**校验直接失败** —— 逼着新条目当场给理由

三层防线：`--update` 拦未分类 → 测试 ⑤ 拦缺理由 → 测试 ⑥ 拦证据失效。

---

## v2.7.9 (2026-10-08) — 🛡️ innerHTML 转义棘轮（补上"新建渲染路径"这个盲区）+ 两处同类残留

> 全量 **466/466 通过**（25 文件，新增 5 项），`check:style` / `check:integrity` / **`check:innerhtml`** 均通过。

### 起因：现有护栏对"新建渲染路径"是盲的
`tests/render-escape.test.js` 的 ③ 是**按变量名写死**的模式（`+ e.source +` / `+ label +` …）。
它能守住已知的两个渲染函数，但换个变量名、换个函数就抓不到 —— 而"把两个渲染函数合并成一个
通用 helper""抽统一 innerHTML 拼装函数"这两种重构动作，恰恰最容易漏掉某一路来源。
**PR #13 就是这么翻车的**：它转义了 `e.source` / `e.target`，漏了 `label`。

### 新增：结构化护栏（不看变量名）
`tools/check-innerhtml-escape.mjs`（`npm run check:innerhtml`）
把 `.innerHTML = / +=` 右边的表达式按**顶层 `+`** 切成段，**逐段**判定：

- 结构安全 = 字面量 / esc 函数族 / 含 esc 的 `.map|.join` 链 / `.length` `.count` 计数 / 两支都是字面量的三元
- 否则整行记为可疑 —— **不做「整行有 esc 就放行」**，那正是漏掉"转义了一个、漏了另一个"的原因
- 表达式跨行（`.map(function (s) { … }).join('')`）会自动往后补全窗口再判
- 棘轮：现状记进 `tools/innerhtml-baseline.json`（9 条，均已人工过目），**只许减不许增**
- 边界用**顶层扫描**而不是正则切三元 —— 否则 `'transform:rotate(90deg);'` 里那个 `:` 会被切错

护栏：`tests/innerhtml-escape-ratchet.test.js`（5 项，含 3 组反证：
① "转义一个漏一个"必须被抓 ② **换个变量名的新渲染路径**必须被抓 ③ 旧的写死模式在同一段上确实抓不到）。
已做变异验证：注入一条新渲染路径 ⇒ 立刻报红并点名 `node.displayName`。

### 顺带修掉两处同类残留（扫描器第一轮就抓出来了）
| 位置 | 问题 | 处理 |
|---|---|---|
| `switchSessionPreset` 的提示行 | `presetName \|\| presetId` 两次**裸拼**进 innerHTML（调用来路是 `p.id, p.name`，即用户自建预设名） | 两处加 `esc()` |
| 禁用词标签渲染 | 用的是**自己手写的一次性转义** `w.replace(/</g,'&lt;')`，而不是项目的 `esc()`（严格说不可利用 —— 实体解码出的 `<` 是文本字符，但这正是"另起一套转义"的形态） | 统一为 `esc(w)` |

### 关于防线 (d)：边界净化目前**没有**
`parseSummaryOutput` 只做 JSON 解析，`rels` 原样返回，落库前没有任何清洗。
纵深防御确实缺这一环 —— 但它是**行为变更**（会改动落库内容），需要单独决策，本轮没动。

---

## v2.7.8 (2026-10-08) — 🧱 S2-B2b + S2-C1：路径镜像落地，搬出首批路径依赖函数

> **纯结构改动，用户可见行为零变化。** 全量 **461/461 通过**（24 文件，新增护栏 5 项），
> `check:style`、`check:integrity` 均通过，CRLF 保留，累计对账**零丢失**。

### S2-B2b：路径 `let` 的单向镜像（`P` + `syncPaths`）
路径那 9 个 `let` 因为被 memory-isolation 按行首切片，**必须留在 `index.js`**。
于是把方向定死为「**index.js 的 `let` 是唯一真源，`state.js` 的 `P` 是它的镜像**」：

- 新增 `export const P = {…}` 与 `export function syncPaths(v)`；
- 全仓**只有两处** `syncPaths()` 调用：模块初始化时 + `bindDshPaths()` 里；
- 新增 `tests/server-state-paths.test.js`（5 项）盯住这个新失效模式：
  ① 调用恰好两处 ② 9 个路径名的赋值只允许出现在 `bindDshPaths` 里
  ③ `lib/server/*` 不许直接改 `P` ④ `P` 的键与 index.js 一一对应 ⑤ 反证（判据不是空跑）
- 已做变异验证：在 `bindDshPaths` 外插一句 `ROOT = '/tmp/evil'` ⇒ ② 立刻报红。

> ⚠️ 插入点有个坑：切片范围是「`let ROOT` 行 → `const DEFAULT_PRESET_DIR` 行」**两端都含**，
> 镜像调用必须插在这**之后**，否则那段独立模块会引用到未定义的 `syncPaths`。

### S2-C1：搬出首批路径依赖函数（`lib/index.js` 5923 → 5583 行）
| 模块 | 内容 |
|---|---|
| `dsh-conn.js`（追加） | `readDshProviders` / `readDshCredentials` / `resolveMemApi` / `readDshDefaultAgentPresetId` / `listDshConnections` |
| `session-read.js`（新） | `readSessionEventsDirect` / `readLastAssistantText` |
| `session-migrate.js`（新） | `migratePersonaCompleteFlag` / `migratePersonaTextField` |
| `state-io.js`（新） | `writeState` / `ensureRoot` |

搬走时函数体里的路径名统一改写成 `P.XXX`（唯一改动，其余逐字保留）。

### 又两个「源码切片钉住」的函数（本轮实测新增）
- **`readState`**：函数体里含默认 state 字面量 `nsfwEnabled: false, nsfwPrompt: ''`，
  被 `nsfw-slot` ⑪ 与 `core` P0-6 按源码字符串钉住 —— 搬走立刻报红。
- **`armLiveAgents`**：`greeting-seed` 测试按 `function armLiveAgents(` 切片它。
两者已补进 `AGENTS.md §5.1` 的留守名单。

### 对账口径升级
路径/状态改名后，"逐行零丢失"不再能直接对——`_scratch/s2c-verify.mjs` 现在会先归一化
`P.`/`S.` 前缀，并把「被 S2-B2a 改成 state.js 属性的那 10 行声明」列白名单，
**同时要求这些白名单项在 state.js 里真的能找到对应属性**（防止用白名单掩盖真丢失）。

---

## v2.7.7 (2026-10-08) — 🧱 S2-B2a：可变运行时状态收进 `lib/server/state.js`

> **纯结构改动，用户可见行为零变化。** 全量 **456/456 通过**（23 文件），
> `check:style`、`check:integrity` 均通过，每个文件过 `node --check`，CRLF 未变。

`lib/index.js` 顶层那 6 个「非路径」可变 `let` 统一挂到 `S` 对象上，
这样 `lib/server/*` 才能在不反向 import `index.js` 的前提下读到它们（AGENTS.md §5 第 5 条）：

| 变量 | 引用改写 |
|---|---|
| `playerName` | 10 处 → `S.playerName` |
| `activePluginCtx` | 3 处 → `S.activePluginCtx` |
| `builtinDirsCache` | 5 处 → `S.builtinDirsCache` |
| `_bindingsCache` / `_bindingsDirty` | 8 / 5 处 |
| `promptStatsFlushedAt` | 2 处 |

### 两个「不能动」的例外（写进 `state.js` 头注释了）
- **路径类那 8 个 `let`**（`ROOT` / `TAVERN_DATA_ROOT` / …）必须留在 `index.js`：
  memory-isolation 测试按行首切片 `let ROOT = path.join(DSH_HOME, '.agent-presets')` 到
  `const DEFAULT_PRESET_DIR` 这一整段。留给 S2-B2b 用 `syncPaths()` 做单向镜像。
- **`lastSessionId`**：测试断言源码里必须有字面量 `const targetSid = lastSessionId`，改名会破坏护栏。

### 踩到的坑（值得记）
改写引用时加了 `(?<![\w$.])` 保护属性访问（`state.playerName` 不能被改），
但**漏了对象字面量的键**——默认 state 对象和 `json(...)` 响应体里都有 `playerName: …`，
一度被改成 `S.playerName: ''` 直接语法错。补上 `(?!:)` 后通过。
教训：**自检判据必须和替换判据完全一致**，否则会把「故意跳过的对象键」误报成遗漏。

---

## v2.7.6 (2026-10-08) — 🔒 堵掉 issue #14 的同类残留：预设名裸拼 innerHTML

> **安全补丁，不动界面结构。** 全量 **456/456 通过**（23 文件，新增 2 项断言），
> `check:style`、`check:integrity` 均通过，`node --check` 通过，bundle 的 LF 行尾未变。

### 背景
issue #14（关系网 HTML 注入，已在 2.6.1 修完）的报告者还提到「数不清的漏洞」。
本次把同一 class 的残留清掉了：**预设名裸拼进 innerHTML**。
数据源是用户/服务端填的预设名（不是模型输出），且「导入预设」这条路径能由外部文件带入名字，
所以一并堵上——虽然严重度低于 #14，但属于同一个错误模式。

### 改了三处
| 位置 | 内容 |
|---|---|
| 导入预设提示 | `pname` → `esc(pname)` |
| 会话面板「当前预设：」 | 见下（这处有个坑） |
| 预设面板「当前编辑：」 | `currentPreset.name` → `esc(...)` |

### 一个差点做成「假修复」的坑
「当前预设」那行原本是**先把名字写进 innerHTML，再用 `innerHTML.replace('当前预设：' + 原始名, …)` 反查替换**。
如果只在写入端加 `esc()`，读回来的 `& " '` 已被浏览器解码，反查串（未解码）对不上
⇒ **预设名里带引号时「修正绿字」会静默失效**（功能悄悄没了，测试还全绿）。
所以改成「先定好最终文本 → 转义 → 写一次」，顺带去掉这个 innerHTML 往返。

### 新增护栏（带变异验证）
`tests/render-escape.test.js` ⑥⑦ 两条：预设名三处必须 `esc(...)`；
禁止再出现 `presetStatus.innerHTML.replace('当前预设：'` 这种反查；
同时断言「以下拉框为准」的修正行为**必须保留**（不能因为加转义把功能弄丢）。
已做变异验证：把三处 `esc(` 撤掉 ⇒ ⑥ 立刻报红，恢复后 9/9。

### 顺带记一条环境事实
`lib/client.manager.bundle.js` 是 **LF**，`lib/index.js` 是 **CRLF** —— 两个文件行尾约定不同，
改哪个都要先确认（Git Bash 的 `grep -c $'\r$'` 在这上面会给假读数，用 node 数才准）。

---

## v2.7.5 (2026-10-08) — 🧱 S2-B1：建起交接文档点名的 `presets` / `bindings` / `skills`

> **纯结构改动，用户可见行为零变化。** 全量 **454/454 通过**（23 文件），`check:style`、`check:integrity` 均通过，
> 每个文件过 `node --check`，CRLF 100% 保留，逐行对账**零丢失**。

### 做了什么
在 S2-A 的基础上再搬 21 个函数/常量，`lib/index.js` **6195 → 5924 行**（累计自 7175 行**净减 1251 行**）：

| 模块 | 变化 | 内容 |
|---|---|---|
| `bindings.js` | 新增 216 行 | 绑定来源分类、活动会话探测、字段归一化 |
| `skills.js` | 新增 36 行 | skill 名合法化、frontmatter 解析 |
| `presets.js` | 新增 21 行 | 组合文本判据（S2-C 抽出装配逻辑后继续扩充） |
| `constants.js` | +24 行 | 追加 `BINDING_SOURCE_*`、`BLANK_PRESET_SKELETON`、`SKILL_NAME_RE`、`liveAgents` |
| `session-log.js` | +13 行 | 追加 `contentTextOnly` |

### 一个重要发现：`lastSessionId` 在源码层面被钉死
`tests/memory-isolation.test.js` 断言源码里必须存在字面量 **`const targetSid = lastSessionId`**
（防止自动总结在异步回调里再读全局、拿错会话）。所以这个变量**不能改名、也不能收进 state.js**。
→ 已连同完整名单写进 `AGENTS.md §5.1`。

### 为什么剩下的都卡在可变状态上
用定点迭代穷举过：不先解决可变状态，可安全搬走的**只剩 148 行**。剩下的函数分别被这些东西挡住：
钉住函数（`readPresetsMeta` / `getPresetDir`）、路径 `let`（`ROOT` / `DSH_SETTINGS_FILE` …）、
切片锚点常量（`DEFAULT_PRESET_ID` / `DEFAULT_PRESET_DIR`）、以及依赖它们的 `readState` / `writeState` / `writeBindings` 等。
**下一棒是 `lib/server/state.js`（S2-B2），它是解开这些的唯一钥匙。**

---

## v2.7.4 (2026-10-07) — 🧱 S2-A 服务端分层：拆出 `lib/server/`（10 个模块 / 净减 980 行）

> **纯结构改动，用户可见行为零变化。** 全量 **454/454 通过**（23 文件 / 与基线逐项一致），
> `check:style` 通过、`check:integrity` 通过、每个新文件过 `node --check`、CRLF 100% 保留。
> 逐行对账结论：**原始 `lib/index.js` 的每一个非空行都被完整保留，零丢失、零改写**。

### 做了什么
`lib/index.js` 里「依赖闭包干净、不碰可变全局状态」的 **53 个函数 / 9 个共享常量**整块搬进新目录：

| 模块 | 行数 | 内容 |
|---|---|---|
| `worldbook.js` | 311 | 世界书 v2 统一格式 + SillyTavern 语义的条目选择 |
| `summary.js` | 168 | 总结提示词构造 / LLM 调用 / 结果解析 / 拒答识别 |
| `preset-decl.js` | 160 | 预设声明块（受管片段）渲染与修补 |
| `util.js` | 115 | 无状态纯工具（`json` `readBody` `clipText` …） |
| `session-log.js` | 100 | 会话历史定位 + zstd 多帧读取 |
| `dsh-conn.js` | 81 | DSH settings / credentials 里的 API 连接解析 |
| `constants.js` | 37 | 跨模块共享常量（两边都用到的那几个） |
| `prompt.js` | 56 | 注入生效范围闸门 + 提示词体积预算 |
| `text.js` | 41 | 文本清洗 / 角色卡正文提取 |
| `zstd.js` | 14 | zstd 垫片（Node < 22.5 降级） |

`lib/index.js`：**7175 → 6195 行**。依赖关系单向（`index.js → lib/server/*`），无循环。

### 为什么有些函数没搬
几个测试**按行切片** `lib/index.js` 源码拼成独立模块求值，被切的函数必须留守
（详见 `AGENTS.md §5.1` 的完整名单）。另外 41 个函数依赖可变全局（`ROOT` / `playerName` / 缓存），
要等 `lib/server/state.js` 落地才能搬 —— 那是 S2-B。

### 过程中修掉的两个坑
1. **不能用「第一个顶格 `}`」判函数结尾**：`normalizeName` 的 `for` 循环闭合括号缩进错了（顶格），
   `parseStagePlans` 的正则字面量里带 `\{` —— 两者都会让朴素扫描拦腰截断函数。
   改为括号配平 + **每个抽出的块单独 `node --check`**（180 个块全部独立解析通过）。
2. **分节说明注释不能跟着函数搬**：`// ── DSH home 解析 ──` 下的注释同时服务于留下来的 `resolveDshHome`。

---

## v2.7.3 (2026-10-07) — 🧹 作废老规矩 + 历史文档归档 + 🛡️ 客户端自检三件套

> **本次没有改动任何运行代码（`lib/` 下一个字节都没动）。** 全量 **454/454 通过**（23 文件），`check:style` 通过。
> 包含两部分：S1 仓库收尾，以及 S4① 安全网（自检三件套）。

### 1. 作废一条挡路的老规矩（本次的重点）

- `AGENTS.md` 里那条「服务端逻辑全部集中在 `lib/index.js`，**不要新建拆分子模块文件**」—— **已删除**。
  那条正是把 index.js 堆到 7174 行的原因。改为 §5「服务端分层」，并给出拆分的硬要求
  （先有安全网再动刀、逐段搬家、不许改用户可见行为、不许反向 import index.js）。
- 顺带校准了陈旧信息：版本号 1.9.2 → 2.7.3、包名 `@local/dsh-tavern` → `dsh-tavern`、
  **`lib/client.manager.bundle.js` 就是源码、没有构建步骤**（旧文档说「要重新打包」是错的，已删）。
- **把规则的三份拷贝收敛成一份真源**：`AGENTS.md` 是唯一规范源，
  `CLAUDE.md` 与 `.cursorrules` 改为指向它的短指针。
  原因：三份内容相同，**其中两份已经漂移**成过期版本号，再抄第四份迟早还会漂。

### 2. CHANGELOG 拆分（133 KB → 14.6 KB）

- v2.5.5 及更早（1741 行）→ `docs/archive/CHANGELOG-pre-2.6.md`；根目录只留 v2.6.0 起的当前批次。
- **做了逐字符对账**：`HEAD` 版本 = 新的根 CHANGELOG + 归档正文，除本次有意的改动外完全一致，**零丢失**。
- 顺手修掉一处**游离的 H1**：`# Changelog` 曾被夹在 v2.6.1 与 v2.6.0 之间，导致文档大纲断裂。
- 归档区保留了两处**已知历史遗留**并在文件里标注，故意不改：文末的 `## v3.0.0` 空标题；
  正文中一处 `# → {"ok":true,...}` 开头的行（代码块里的 shell 注释，不是标题）。

### 3. 根目录清理

- `AUDIT-功能体检.md`、`ROOT_CAUSE_报告.md`、`HANDOFF-会话绑定修复.md`、4 个 `RELEASE_NOTES_*`
  → `docs/archive/`。
- **根目录遗留的 `client.manager.bundle.js` 旧副本（78 KB）** → `docs/archive/_legacy/`。
  它和真正的源码 `lib/client.manager.bundle.js`（486 KB）**同名**，改错地方会静默不生效——这是个容易踩的坑。
- 三个一次性探针 `find_unprotected.{js,cjs}` / `find_unprotected2.cjs` → `docs/archive/_probes/`。
  它们含本机绝对路径 `C:/dsh-tavern/lib` 且用了 `require`，违反 §7 红线与 ESM 规范，**不要照抄**。
- `.gitignore` 增加 `_scratch/`：该目录放本地临时脚本（含从会话日志提取 token 的性质），**绝不能入库**。

### 4. 对发布包的影响（属于预期内的变化）

- `RELEASE_NOTES_*.md` 已全部归档，**发布包不再包含它们**；`package.json.files` 里那条匹配不到东西的
  `RELEASE_NOTES*.md` glob 一并移除（否则留着会误导）。
- README 里指向 release notes 的链接已改到归档路径。
- `docs/` 本来就不在发布包里，归档区不会随包发布。

### 5. 🛡️ S4① 安全网：客户端自检三件套（新增 `tools/check-client-integrity.mjs`）

> 拆 7000 行的服务端之前要先有护栏。这三件事的思路来自 PR #13（@H2CO3w），
> 但那个 PR 只把方法论写在提交信息里、没提交脚本 —— 这里补实现并配了测试。
>
> 用法：`npm run check:integrity`。

1. **悬空 id 扫描**：把 JS 里 `getElementById('x')` / `querySelector('#x')` 引用的 id，
   与 markup 里声明的 id 取差集。当前实测 **243 处引用 / 164 个声明 / 0 悬空**。
   - 刻意**不收** `'#id' + x` 这类拼接写法与注释掉的历史代码 —— 这两种第一版都误报过，已钉进回归测试。
2. **标签配平**：先把 `panelHTML()` 那种「字符串片段数组」按组拼回去，再做栈式配对。
   当前 **610 个标签全部成对**。
   - 早期版本把全文字符串一股脑拼在一起，报了 **233 个假不平衡** —— 改成按片段数组分组后归零。
3. **卡片嵌套深度**：确认每张 `data-tv-tab` 卡片与同级卡片同层（当前 12 张都在 depth=2），
   没有被容器误吞。

三个判据都带**空跑防护**：源码里明明有 `data-tv-tab` / 字面量 id 查询，却一个都没抓到时，
工具判**失败**并提示「判据空跑」。理由是空跑出来的绿灯比红灯更危险 —— 它会让人以为护栏还在。
（这一点是拿突变样本验证出来的：故意把一张卡片多包一层，工具立刻报 8 张卡片层级不一致 + 1 处标签不平衡。）

配套测试 `tests/client-integrity.test.js` **20 项**，每条「应当报错」的判据都配坏样本对照。

### 6. 验证

- `node tools/run-each-test.mjs` → **23 文件 / 454 项 / 0 失败**（原有 434 项全保留，新增 20 项）。
- `node tools/assert-style-budget.mjs` → 退出码 0，两项内联事件硬 0 保持。
- CHANGELOG 拆分做了 `HEAD` 版与新文件的逐字符对账。

---
## v2.7.2 (2026-10-07) — ✏️ 最后一颗纯图标铅笔改成文字

- 「重命名当前预设」按钮此前**内容只有一个 `✏️`**，全靠 `title` 属性兜底 —— 辨认成本高，
  也是 PR #13 指出的"emoji 当控件唯一标识"的最后一例。现改为文字 **「重命名」**（保留 `title`）。
- 说明：仍有 1 处 emoji（「✏️ 手动输入」单选标签），那是**带文字的装饰图标**，
  不影响识别，且状态栏解析依赖的 emoji（`👤` / `⏰` 等）属于预设格式契约，不在清理范围。
- 验证：全量 **434/434**；`check:style` 通过（客户端/服务端内联事件均 0）。


## v2.7.1 (2026-10-07) — 🧹 删除「编辑 AI 回复」整条功能 + 5 处内联事件属性清零

### 1. 删除「编辑 AI 回复」（整条功能下线，含服务端事实修正段与历史改写路由）

**为什么删**：用户反馈「编辑 AI 回复后 AI 并不遵守」。它靠两件本就不可靠的事：
① 直接改写 DSH 会话日志（自己提示"保存后需重启 dsh 生效"）；② 往系统提示里塞一段
「对话历史事实修正」。两者都不是真的在改模型看到的那条历史 —— 于是**删掉**，不留半截。

- 客户端 `lib/client.manager.bundle.js`：
  - `startEdit()`（编辑层 overlay / 保存 / 取消 / 恢复原文）整块删除；
  - 消息右下角的 ✏️ 按钮与其 hover 显隐、`tavernEditIndex` / `tavernEditApplied` 标记删除；
  - 「✏️ 已修正（影响后续生成）」徽章删除；
  - `editedCache` / `loadEditions` / `saveEdition`（含 `/api/tavern/edited-messages` 与
    `/api/tavern/edit-history` 两处 fetch）及其全部调用点删除；
  - `getMessageContentEl()` 随编辑覆盖一起失去了唯一调用者，一并删除；
  - `initMessageEditor()` 更名 `initMessageBeautifier()` —— 它现在只负责美化，不再"编辑"。
- 服务端 `lib/index.js`：
  - `tavern:edits`（order=0）注入段整块删除（`【最高优先级 — 对话历史事实修正】` 构造）；
  - 路由 `/api/tavern/edited-messages`、`/api/tavern/edit-history` 删除；
  - `readEditedMessages` / `writeEditedMessages` / `editHistoryMessage`、
    `EDITED_MESSAGES_FILE`（两处赋值）删除；
  - 卡组装处的 `editsText` 及其拼接项删除（`memoryText`/`styleText`/`netText` 等相邻项未动）；
  - `sectionSizes.edits` 与 prompt-stats 输出的 `edits` 字段删除，`total` 口径同步改；
  - 服务端统计页与客户端体积面板里的「事实修正」字段删除显示。
- **注**：`~/.dsh/.agent-presets/edited-messages.json` 是**用户数据**，未触碰；代码已不再引用它。
- 体积快照落盘**没有丢**：原先由 `tavern:edits` 段负责调用的 `flushPromptStats()`，
  改由 `tavern:nsfw` 段（order 仍为 -1）在每条返回路径上调用，行为不变。

### 2. 修 `tabKeyForTail` 漏「元素自身」（真 bug）

散件规则里 `#tavern-extra` 写的是**那个 textarea 自身**，而旧实现只 `el.querySelector('#id')`
查后代 ⇒ 这个控件永远搬不进「内容」页签，被留在页签外。现在同时认「自身命中」与「后代命中」。
`tests/panel-tabs.test.js` 新增 ⑤b：真跑 `installPanelTabs`，断言 `#tavern-extra`
**必须**落进 `content` 页签、且仍留在面板根下 = 判据失败。

### 3. 页签 hover / 选中不再长得一样 + 焦点环

`.t-tab:hover` 与 `.t-tab.active` 原先同用 `--dsw-alias-bg-layer-2`，肉眼分不出"鼠标划过"和"当前页签"。
hover 改走 `--dsw-alias-interactive-bg-hover`（浅一档），active 保持 layer-2 + 边框 + 加粗；
并补 `.t-tab:focus-visible` 焦点环（`outline: 2px solid var(--dsw-alias-brand-primary)` + `outline-offset: 2px`）。

### 4. 删除坏掉的批量删除路径（死代码 + 点击必抛异常）

`#tavern-preset-batch`（常驻 `display:none`）与 `#tavern-batch-box` 面板的 markup + 处理器整块删除。
它们读的 `#tavern-batch-box` / `#tavern-batch-del` / `sessionPresetSelect.options`
早已不存在 —— 这段代码一旦被触发**必抛 TypeError**。
可用的那套（每行复选框 + `#tavern-preset-batch-del2`）**未动**。
顺带修掉同区域的连带引用：删除预设后"自动绑定到第一个预设"原先读那个已不存在的下拉框，
改用面板自己的 `state.presets` / `getActivePresetId()` 取同一个值。

### 5. 5 处内联事件属性 → 事件委托（`inlineHandlerAttr` 5 → 0）

世界书列表的删除本/删除条目/勾选框 + 关系网详情面板的两处「点击空白处关闭」，
全部改为容器上的**事件委托**（`closest('[data-wb-group-action="delete"]')` 等）。
行为等价：删除按钮的点击**不冒泡**到条目行，因此不会顺带触发展开/折叠。
`inlineHandlerAttr` 从棘轮**升级为硬规则**（恒为 0）。

### 6. 测试与 UI 文案解耦 emoji

`tests/scope-panel-ui.test.js` 原先断言 `>🌍 所有会话生效<` 这类**可见文案 + emoji**（16 处断言/测试名），
文案一改测试就红。改为断言稳定标识：给三个范围按钮加 `data-scope-mode="global|session|cwd"`、
解绑按钮加 `data-action="unbind"`，测试断言 `data-*`（**未改任何可见文案**）。

### ✅ 验证

- 全量 **434/434 通过**（22 个文件；434/434，净 +1 = 新增 panel-tabs ⑤b ——
  删除的是**断言行**而非测试项，所以每个文件的项数不变）；
- `node --check` 全过（4 个 lib 文件 + 22 个 tests + 2 个 tools）；
- 样式预算通过，并**全线下调**：内联事件属性 **5 → 0**、内联样式 312→301、裸 hex 352→331、
  rgba 160→150、`cssText` 赋值 72→59、颜色字面量 58→53 种、padding 变体 46→42、z-index 11→8；
- 残留 grep（`lib/`）：`saveEdition|loadEditions|editedCache|startEdit|tavern:edits|edited-messages|edit-history` 全部 **0**
  （`tavern-preset-batch` 仅剩保留项 `#tavern-preset-batch-del2` 的 2 处命中，属正常）。

### 🔍 顺手发现 → 本版已一并处理

- **服务端自渲染设置页的内联事件全部清零**：设置页原有 8 处 `on<event>="…"`
  （`saveWin()` / `save()` / `plotOptions` 等 3 个开关 / 2 个世界书模式单选），
  全部改为 `addEventListener` 绑定；`writeSessionLines()`（改写整份 zstd 会话日志的死函数）
  已删除，只在注释里留下"**不要再引入这种写法**"的警示。
- **样式预算新增两项硬 0 规则**：`inlineHandlerAttr`（客户端，5→0）与
  `inlineHandlerAttrServer`（服务端 `lib/index.js` 自渲染 HTML，8→0）——
  后者是 v2.7.1 前**完全漏检**的一类（预算脚本只量客户端 bundle）。
  新增 `tests/style-budget.test.js` 的 ③-b 断言把这条钉死，并配"合成样本必须命中"的非空跑对照。

### ✅ 本版验证（2.7.1）

- 全量 **434/434 通过**（22 个文件）；
- `npm run check:style`：`inlineHandlerAttr = 0`、`inlineHandlerAttrServer = 0`、`!important = 0`，exit 0。


## v2.7.0 (2026-10-07) — 🧭 声明式页签归属 + 📏 样式预算棘轮

### 1. 卡片的页签归属改为**声明式**（\data-tv-tab\）

以前面板按**卡片标题的字符串前缀**分页签（\TAB_DEFS[].titles\ + \	.indexOf(prefix) === 0\）。这个做法有个隐蔽的坏处：
**标题一改（哪怕只是加个 emoji），前缀就匹配不上，卡片会静默掉出页签体系** —— 不报错，只在用户发现"卡片不见了"时才暴露。

现在：**一级卡片自己在 markup 上声明** \data-tv-tab="<页签 key>"\，这是唯一权威来源；标题前缀只作**回退**，
并且回退失败会被**运行时自检点名**：

- 12 张一级卡片全部注入声明（映射与改造前逐张一致，行为不变、只换机制）；
- \installPanelTabs()\ 先读声明 → 声明缺失/非法才回退标题前缀 → 把没被收走的卡片写进
  \#tavern-manager[data-tab-unclaimed]\ 并在控制台 \console.warn\ 点名（**未归类 ≠ 消失**：卡片仍留在面板里可见）；
- 测试升级：\	ests/panel-tabs.test.js\ 改为解析 \data-tv-tab\，并新增两条**行为测试** ——
  ④c「声明优先于标题」（把标题改成映射不出来的，卡片仍按声明归位）、
  ④d「自检点名」（声明非法且标题认不出 ⇒ 被点名且保持可见）；
- 另加一条**一致性硬约束**：每张卡片的声明必须与标题前缀指向同一个页签（迁移期最容易被漏掉的漂移）。

### 2. 样式预算棘轮（\	ools/\）

\lib/client.manager.bundle.js\ 是**单文件直发**的界面代码（没有构建步骤，这个 bundle 就是源码）。
在这种约束下，"UI 越来越脏"没法靠一次重构解决，于是引入**可测量的棘轮**：

- \	ools/assert-style-budget.mjs\ + \	ools/style-budget.json\：把当前 12 项指标记账，**以后只许降不许升**；
  想加新颜色/新内联样式？必须在同一次提交里跑 \--update\ 并说明理由 —— 让 review 看见，而不是无声堆积。
- 硬规则：\!important\ 必须恒为 **0**（当前 0 ✅）。
- 记账：内联样式 312、裸 hex 352、rgba 160、\cssText\ 赋值 72、颜色字面量 **58 种**、padding 变体 46、
  内联事件属性 **5**（历史遗留，记为棘轮：只许降不许升）。
- \
pm run check:style\ 一条命令校验；\	ests/style-budget.test.js\（7 项）把它挂进测试套件，
  其中 ① 用合成样本证明**指标不是空跑**（数字逐个对得上）+ 注释行不会被误计。

### 3. \
pm run test:each\

逐个测试文件独立跑并给汇总表。原因：原来的 \	est\ 是一条 \&&\ 长链，**第一个红项就把后面的全跳过**，
"还有几个文件是红的"看不出来。

### ✅ 验证

- 全量 **432/432 通过**（22 个文件；新增 panel-tabs 2 项 + style-budget 7 项）；
- 语法检查全过（含两个新工具脚本）。

### 🔍 顺手发现（未在本版处理，已记账）

- markup 里仍有 **5 处内联事件属性**（世界书条目的 \stopPropagation\/勾选、关系网模态两处）。
  改写它们需要浏览器里交互验证，属于独立任务；已作为**棘轮**禁止增长。
## v2.6.1 (2026-10-07) — 🔴 渲染层 HTML 注入修复（issue #14）

**问题**：关系网渲染（小图 + 大图两个函数）把**模型输出**的数据直接拼进 `innerHTML`；
世界书条目正文直接拼进 `<textarea>`。这条链是闭合的：对话/角色卡 → 自动总结 → 落库 →
前端**自动重画**（不需要用户点任何东西）⇒ 注入的脚本在 DSH Web UI 的源里执行，
而那个页面持有 DSH API 凭据，足以**以用户身份驱动 agent**。

**修了什么**：
- `esc()` 补上单引号（原本只吃 `& < > "`）—— 属性值常以单引号包裹，只挡 `"` 等于没挡；
- `escAttr()` 原实现是**空操作**（`esc` 之后 `replace(")` 永不命中），改为复用 `esc`；
- 关系网两个渲染函数里**所有**模型字段（`e.source` / `e.target` / `n.label` / `n.id` /
  `e.label` / `other` / `truncate(ed.label…)`）一律过 `esc(...)`；
- 世界书：正文过 `esc(...)`，条目名称/关键词统一走 `escAttr(...)`。

**测试**：新增 `tests/render-escape.test.js` —— 转义函数的真假两态、5 组注入载荷、
关系网区段"零裸拼接"扫描、世界书正文断言；每条"必须转义"都配**旧实现反例**，证明判据不是永真。

## v2.6.0 (2026-10-04)

### ✨ 恢复「成人向提示段」：**机制**回来了，正文由你自己填

背景：v2.5.1 曾按指示把 `tavern:nsfw` 破限段连正文一起整段删除。这次恢复的是**机制与入口**，
不是那段固定文案 —— 正文改成**使用者自己填的可编辑字段**。

| 恢复了什么 | 说明 |
|---|---|
| `tavern:nsfw` 注入段 | 按会话注入；过 `isTavernSession` + `decideInjectionScope` 两道判据（不会再出现串台） |
| `state.nsfwEnabled` 开关 | 面板「🎲 玩法 → 🔞 成人向提示段」勾选，实时写入服务端 |
| **正文可编辑**（本次新增的设计） | `state.nsfwPrompt` + 面板文本框；注入的就是这段原文，插件不加工、不截断 |
| 体积统计 | 面板「📏 提示词体积」多一行"成人段"；`prompt-stats.json` 增加 `nsfw` 字段并计入 `total` |
| 顺序 | `order: -1`，排在 `tavern:edits`(order=0) 之前 —— 体积快照仍由 edits 落盘，位置没动 |

**为什么正文做成 state 字段、而不是写回代码**：

- 正文因此**不进代码、不进 npm 包**：不会随发布出门，也**不会被升级覆盖**；
- 插件本身保持"不含该段正文"—— 写什么尺度、要不要用，是使用者自己的事。

**升级影响**：**零行为变化**。默认 `nsfwEnabled: false` + 空正文 ⇒ 一个字节都不注入。
`tavern-state.json` 里若留着旧的 `nsfwEnabled: true`，只要正文为空同样不注入。

### 用法

1. 面板 → **🎲 玩法** → **🔞 成人向提示段**；
2. 把正文粘进文本框 → **💾 保存正文**（失焦也会自动保存）；
3. 勾选「启用」→ 下次组装提示词生效。

正文只存在本机 `<DSH_HOME>/.agent-presets/tavern-state.json` 的 `nsfwPrompt` 字段里。

### ✅ 验证

- 新增 `tests/nsfw-slot.test.js` **14 项**：默认零注入 / 开关与正文真假两态 / 会话隔离 /
  生效范围闸门（allowlist 双空、名单命中、global 下的 cwd 黑名单）/ state 路由往返 /
  类型与长度上限（20000 字符）/ 源码护栏（默认正文为空、槽位必须过闸门、客户端卡片与页签、随包代码不含现成文案）；
- 旧测试按新契约改写：`core.test.js` P0-5b（"段必须消失" → "槽位必须在且默认空"）、
  `panel-tabs.test.js`（卡片数 11 → 12；🔞 文案从"禁止出现"改为"必须可见且落在玩法页签"）、
  `cordis-mount.test.js`（注入段 2 → 3）；
- 全量回归 **416/416 通过**，语法检查全过。

