# 贡献指南(CONTRIBUTING) 面向维护者与二次开发。用户可见的功能、安装与配置见 [README.md](./README.md), 架构与关键机制见 [doc/design.md](./doc/design.md)。 本文档是**开发流程、版本要求与验证方法**的出处:README 只陈述用户需要知道的事实, 原因、判定依据与操作步骤都在这里。 ## 目录 - [1. 目录结构](#1-目录结构) - [2. 本地开发与检查](#2-本地开发与检查) - [3. 版本要求与兼容性](#3-版本要求与兼容性) - [4. 兼容性校验怎么做](#4-兼容性校验怎么做) - [5. 打包与发版](#5-打包与发版) - [6. 开发坑](#6-开发坑) ## 1. 目录结构 ``` dsh-approval-mode/ ├── README.md # 用户可见:功能、风险提示、安装、配置 ├── README.en.md # 同上(英文) ├── CONTRIBUTING.md # 本文档:开发流程、版本要求、验证方法 ├── CHANGELOG.md # 每个版本的变更;发布正文的来源 ├── doc/design.md # 设计文档:架构与关键机制 ├── package.json # bundle manifest(dsh.bundle + dsh.client + engines.dsh + peer 版本要求 + icon) ├── cordis.patch.yml # 组合层:插入插件行 ├── icon.svg # 插件页/插件市场卡片图标(manifest 的 icon) ├── locale/en.json # 插件显示名与描述(en) ├── locale/zh.json # 插件显示名与描述(zh) ├── index.js # Host half(审批应答器 + Config/既有 settings 兼容 + 控制路由 + 代理通知) ├── lib/client.js # Client half(工具栏控件 + <=0.1.6 的插件页配置卡片) └── scripts/ ├── dshClient.js # 轻量 DSH 回环 API 客户端(HTTP + WebSocket,自包含) ├── listen-only.mjs # 审批帧监听验证脚本(不应答) ├── check-client.mjs # 离线 client bundle 契约检查(零依赖) ├── check-host.mjs # 离线 Host 半契约检查(零依赖,模块钩子打桩) ├── release-notes.mjs# 由 CHANGELOG 组装 Release 正文(零依赖) └── pack.mjs # 跨平台打包(npm pack → dist/) ``` 分两半:Host(`index.js`,默认模式声明为插件 `Config.defaultMode`、在旧宿主上兼容 `approval-mode` settings 命名空间、`approval/request` 应答器、会话模式文件、 `GET/POST /approval-mode` 控制路由)与 Client(`lib/client.js`,`window.__ModuleLoader__` bundle)。设计细节见 doc/design.md §2–§3。 ## 2. 本地开发与检查 仓库**没有 `node_modules`,也不安装依赖**:检查脚本只用 Node 内置模块,clone 下来直接跑。 ```sh npm run check ``` 等价于: ```sh node --check index.js node --check lib/client.js node --check scripts/dshClient.js node --check scripts/listen-only.mjs node --check scripts/check-client.mjs node --check scripts/check-host.mjs node --check scripts/release-notes.mjs node scripts/check-client.mjs node scripts/check-host.mjs ``` 两个契约检查脚本需要 Node ≥ 22.15(`check-host.mjs` 用 `module.registerHooks` 打桩 `@deepseek-ai/*`)。CI 与本机开发都用 Node 24。 ### 2.1 `scripts/check-client.mjs` 为什么存在 `lib/client.js` 是 `window.__ModuleLoader__` bundle:它不能被 `import`,没有类型检查, 出错只会出现在浏览器 console 里——而「注册错槽位」「key 写错」这类错误在界面上表现为 **卡片/按钮静默消失**,没有任何报错。该脚本用 stub 的 `window.__ModuleLoader__`、 `document`、`react`、`fetch` 加载真实 bundle,把能离线断言的部分全部断言掉: 1. 工具栏控件注册进 `conversation.input.left`(`locale` 绑定 `approval-mode` 词典); 2. 插件页卡片注册进 `settings.plugin.item`,且 `key` **等于** settings 命名空间 —— 宿主的「插件」分区按这个交集派发卡片,写错就永远不渲染; 3. 宿主没有客户端 `settingsScope` 服务时,**只丢卡片、不丢工具栏按钮**(降级路径); 4. 卡片渲染出全部三个选项、当前值选中,且写入**默认地址**(`POST /approval-mode`); 5. 工具栏渲染出全部三个选项、显示的是**该会话**的值而不是默认值,且写入 **会话地址**(`POST /approval-mode?session=…`)——两个界面互不冒充; 6. 卡片与工具栏用到的词典 key 在 zh/en 中**都存在**。 ### 2.2 `scripts/check-host.mjs` 为什么存在 `index.js` 依赖 `@deepseek-ai/schemastery`,而本仓库刻意不装依赖,因此它平时只在真实宿主里 运行——恰恰是这套代码里失败最安静的部分:模式判定错了只会表现为「审批没弹」或「提权被自动 放行」,没有任何报错。该脚本用 `module.registerHooks` 为该说明符提供进程内打桩,**并对任何 `@deepseek-ai/dsh-*` 说明符直接报错**(插件不得依赖带版本号的宿主包;重新引入这类 import 必须 在这里失败,而不是在用户的 profile 里表现为「插件不兼容」),加载**真实的 `index.js`**, 并用假的 settings / webServer / agents 上下文断言: 1. 模式词汇就是 client 侧提供的三个值,且 `Config` 把默认模式声明成实时(volatile)字段; 2. 提权按 `dsh-sandbox` 真正发出的 reason 前缀识别,且不误判普通审批; 3. 每个模式的应答结果:`ask` 全部委派、两种绕过都自动放行、**只有** `bypass-except-escalation` 把提权交回用户; 4. 模式按会话解析、默认值兜底,且会话映射**跨重启存活**(写入 `$DSH_HOME/approval-mode/ sessions.json`); 5. **两代 settings 服务**:<=0.1.6 的 `register/get/update` 命名空间与 0.1.7+ 的 `describe/update` 配置表单都覆盖到——后者**没有 `register`**,正是本次回归(旧代码在此 抛 `TypeError` 导致宿主半不挂载);另外覆盖「完全没有 settings 服务」的组合; 6. 0.1.2 及更早写的全局 `mode`、以及旧文档里的 `sessions`,升级后仍被读取并迁移; 7. 默认写入按**插件自己的 loader entry id** 走 settings 服务; 8. 控制路由的地址语义(无 `session` = 默认值,`?session=` = 该会话), 以及非回环 403、非法 mode 400、坏 JSON 400、非法 session 地址 400、错误方法 405; 9. 坏会话文件退化为空、写入即修复;宿主侧 live 配置变更只通知一次; 10. **注入的通知消息带 producer 自有的 source kind**(不是退役的 `{kind:"plugin"}`)—— 这是会话格式 v4 的写入准入规则,违反它会让该会话之后每一轮都失败(§4.1 的 0.1.5 记录); 11. 消息在插件内构造:新标识、深冻结、不改调用方对象,且 `deepFreeze` 对环安全; 12. Agent 的会话地址按**两种形状**解析(0.2.0 的 `agent.id` 与更早的 `agent.session.id`), 两者都拿不到的请求落到默认值而不是抛异常。 打桩替换的是库,不是被测代码:`index.js` 是磁盘上那一份,只有它的 import 被改写。 ### 2.3 证明它能失败 一个永远绿的检查等于没有检查。逐条反向验证: - 把卡片注册的 `key: NS` 临时改成 `key: NS + "-probe"`,`check-client.mjs` 应报 `FAIL plugin card registered into settings.plugin.item keyed by the settings namespace`; - 把工具栏的写地址从 `routeUrl(sessionId)` 改回 `ROUTE_PATH`,应报 `FAIL the picker writes the SESSION address`; - 把 `bypass-except-escalation` 的提权分支改回 `return "allowed-once"`,`check-host.mjs` 应报 2 条 `FAIL`(提权豁免与按会话豁免); - 把 host 的 `inject` 改回 `["settings", "webServer"]` 并在 `apply` 开头直接调 `ctx.settings.register`(0.1.4 之前的写法),`check-host.mjs` 应报 4 条 `FAIL`,其中 「a settings service WITHOUT register (0.1.7+) mounts the plugin instead of throwing」 就是 DSH 0.1.7 上的真实回归; - 把注入消息的 `source` 改回 `{ kind: "plugin", plugin: "approval-mode" }`,`check-host.mjs` 应报 2 条 `FAIL`(v4 source 准入与 producer kind 一致),这正是 0.1.4 的回归; - 在 `index.js` 里重新 `import` 任意 `@deepseek-ai/dsh-*`(例如 `@deepseek-ai/dsh-llm`), `check-host.mjs` 应在加载期直接抛 `index.js resolved @deepseek-ai/dsh-llm — this plugin must not depend on a versioned @deepseek-ai/dsh-* package`; - 把 `sessionIdOf` 改回只读 `agent.session.id`,`check-host.mjs` 应报 8 条 `FAIL` (0.2.0 的 Agent 形状、按会话模式、通知范围都失效)。 八条均已实测(2026-09-22 / 2026-09-25 / 2026-09-29),确认后改回。 ### 2.4 端到端验证(可选) UI 行为的最终确认只能靠真实宿主,需要**重启 DSH**才能装载新版本: ```sh npm run pack dsh plugin --profile add ./dist/dsh-approval-mode-.tgz ``` 两个注意点: - **不要对 `link:` 安装执行 `dsh plugin remove`**(见 §6)。 - 在 GUI 之外直接请求控制路由(`curl http://127.0.0.1:/approval-mode`)会得到 **403 `forbidden`**:那是 DSH Desktop 的浏览器访问围栏 (`dsh-plugin-desktop/lib/webserver.js` → `decideDesktopBrowserAccess`, 非 Electron renderer 且未开启普通浏览器访问时一律拒绝),**不是插件路由故障**。 页面内的同源 fetch 正常。 ## 3. 版本要求与兼容性 ### 3.1 声明在哪里 宿主版本要求声明在 **`package.json` 的 `engines.dsh`**,各共享包的可用范围声明在 **`peerDependencies`**。dsh-market 的浏览期兼容性判定 (`discovery-compatibility.js` 的 `manifestFacts` / `deriveHostCompatibility`) 读的正是这两处,并给出 `belowMin`(低于声明下界 = 环境太旧)之类的结论; README 只复述结论,判定依据是本表。 | 声明 | 范围 | 依据 | | --- | --- | --- | | `engines.dsh` | `>=0.1.1-rc.2` | 宿主版本下界;dsh-market 的专用宿主版本声明通道(顶层 `engines.dsh` 优先于 `dsh.engines.dsh`) | | `@deepseek-ai/cordis` | `^4.0.1` | `ctx.on(…, true)` prepend、`ctx.inject`、`ctx.effect` | | `@deepseek-ai/schemastery` | `^3.18.1` | `Config` schema(`object` / `union` / `dict` / `.volatile()`),遗留一代(DSH <= 0.1.6)的 namespace schema 也用它 | | `react` | `^18.2.0` | client bundle(宿主 seed 模块提供) | **不声明任何 `@deepseek-ai/dsh-*` peer**(0.2.0 起):插件的 Host 半只 import `@deepseek-ai/schemastery`,注入的通知消息用 `node:crypto` + 本地 `deepFreeze` 自行构造 (与宿主 `@deepseek-ai/dsh-llm` 的 `createUserMessage` 同形:新 UUID 标识 + 深冻结的 user 消息)。 宿主侧 `evaluatePluginCompatibility` 只检查 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` 这两个名字, 声明一个与自己不再有代码关系的包,等于把插件的可用范围绑死在某条 DSH 版本线上——0.2.0 的 `@deepseek-ai/dsh-llm` 一起步就是 `0.2.0-rc.1`,旧声明因此被判为不兼容(§4.1)。 `@deepseek-ai/dsh-settings` **同样不声明**(0.1.4 起):插件不 import 该包,只用宿主注入的 settings 服务,且两代契约都兼容——<=0.1.6 的 `register/get/update` 与 0.1.7+(含 0.2.0)的 `describe/update`(配置表单)。 ### 3.2 为什么上界不设(以及 0.1.7 / 0.2.0 的教训) caret 只界到 `0.2.0`,不写显式上界:插件只使用**公开服务**——`settings`、`webServer`、 `approval/request` 水瀑布、slot 座位——不依赖内部符号。 但 2026-09 的 DSH 0.1.7 说明「公开」不等于「契约不变」:settings 服务被整体换成 「插件 `Config` + profile patch 配置表单」,`register`/`get` 消失,旧代码在 `apply` 第一行 就抛 `TypeError`(§4.1)。插件因此不再把 settings 当**必选**服务(`inject` 只要求 `webServer`),改用 `ctx.inject` 按能力接入两代实现;真正没变的两个接口——控制路由与 审批水瀑布——保持原样。结论:宿主换代时按 §4 逐 API 对照,不要因为服务名没变就假设契约没变。 DSH 0.2.0 又给出另一条:**声明本身也会过期**。共享包跟着核心一起跳到 `0.2.0-rc.1`, `peerDependencies` 里写着 `^0.1.1-rc.2` 的 `@deepseek-ai/dsh-llm` 立刻被 `evaluatePluginCompatibility` 判为 `belowMin`:插件行被组合期预检置为 `disabled` (`Config.listConfigs` 里是 `inactive`),插件页卡片报「与 DSH 0.2.0-rc.1 不兼容」。 同一版本还有一处只是**取值方式**变了的契约:`Agent` 收窄成 `{ id }`(会话 id), 不再有 `agent.session`,按会话取模式的代码会静默退回默认值。于是 0.2.0 干脆把这条 peer 去掉:只用 `node:crypto` + 本地 `deepFreeze` 构造同形消息,会话地址改由 `sessionIdOf` 兼容两种 Agent 形状,`engines.dsh` 一并改为无上界的 `>=0.1.1-rc.2`。可复用的判据是—— **peer 里只保留真的被 import 的包**,而 `@deepseek-ai/dsh-*` 里每一个都绑定某条 DSH 版本线, 能不 import 就不 import。 ### 3.3 为什么共享宿主包只放 peerDependencies,且标成 optional 放进 `dependencies` 会在插件自己的 `node_modules` 里装独立拷贝、遮蔽宿主版本, dsh-market 也会就此告警。本插件 `dependencies` 为空,宿主共享包全部由 `$DSH_HOME/profiles/node_modules` 解析。(`devDependencies` 里的 `@deepseek-ai/schemastery` 只服务本地开发。) profile 的 `pnpm-workspace.yaml` 设了 `autoInstallPeers: false`,而这些包由上一层的 共享层提供、pnpm 在 profile 工作区内看不到,`pnpm peers check` 因此把它们全报成 `missing peer`——这些缺失是设计使然,**不要**为了让告警消失而把它们装进 profile (近处多一份 cordis / dsh-settings 会分裂服务单例)。`peerDependenciesMeta` 里逐项 `optional: true` 才是正确做法,它只让 pnpm 不再报这些必然的缺失,**不动 `peerDependencies` 里的版本范围**,因此浏览期兼容性判定不受影响。 唯一被削弱的信号是 dsh-market 的**安装前预检**(`assessCompatibility` → `classifyPeer`):它对 optional peer 只给 `warning(reason:"optional")`,不再判 `belowMin` 风险。宿主版本下界因此改由 `engines.dsh` 明确声明,浏览期的 「低于声明下界」结论仍然成立;预检里那一条降级为 warning 是有意的取舍。 实测(2026-09-22,pnpm 11.8、`autoInstallPeers: false`):换成未加 `peerDependenciesMeta` 的旧清单,`pnpm peers check` 复现 issue #10 的五条 `missing peer`;加上之后输出 `No peer dependency issues found`。 ### 3.4 版本号与发布的关系 插件版本**独立于** DSH 核心版本线演进(DSH 兼容范围由 §3.1 的 peer 范围表达,不要用插件 版本号去对齐核心版本——历史上 `0.1.1-rc.2` 曾对齐过一次核心,那是特例): - 版本号默认只动第三位:`0.1.0` → `0.1.1` → `0.1.2`。插件处于 `0.x`,新增功能与仅修复都进第三位, 区别写在 CHANGELOG 的「新增」/「变更」小节里,不靠版本号的位置表达。 - **第二位留给插件自身代际的变化**:宿主声明面(`engines.dsh`、`@deepseek-ai/dsh-*` peer)发生 代际改变时才动第二位。`0.1.5` → `0.2.0` 是第一次:插件开始支持 DSH `0.2.0-rc.1` 线,并去掉了 唯一一条随 DSH 版本线漂移的 peer(§3.1)。它仍然**不是**与 DSH 版本对齐——对齐与否由 `engines.dsh` 声明,版本号只表达插件自己的代际。 - **正式版之后不要再追加同一版本的 `-rc.N`**:预发布号只属于尚未发布的版本。 `v0.1.1-rc.4` 就是这么被否掉的——`0.1.1` 已经是正式版,再发它的 rc 等于往回走。 发布时 `package.json` 的 `version`、tag(`v`)与 `CHANGELOG.md` 的小节标题三者 必须一致:工作流会校验前两者,`scripts/release-notes.mjs` 用第三者组装 Release 正文 (缺失时退化为提交列表)。流程见 §5。 ### 3.5 设置页那张卡片的依赖,以及它为什么不进 README <=0.1.6 的插件页配置卡片需要宿主的客户端 `settingsScope` 服务(由 `@deepseek-ai/dsh-client-ui-settings` 提供)。该下界**低于**本插件声明的 DSH 下界 `0.1.1-rc.2`,因此在受支持的 0.1.1–0.1.6 宿主上卡片都可用。 0.1.7+ 不再有 `settingsScope`,也不再有 `settings.plugin.item` 槽位:那里的默认模式由宿主 **按插件 `Config` 自动渲染**(§3.1 的 `Config.defaultMode`),客户端不需要卡片代码。 `lib/client.js` 仍保留旧卡片注册(嵌套 `ctx.inject(["settingsScope"])`),在 0.1.7+ 上 只是不注册——这条降级路径由 `scripts/check-client.mjs` 断言:宿主没有该服务时,工具栏按钮 与绕过审批完全不受影响。0.2.0 同样没有这两个名字(客户端服务目录里只有 `layout`、`locale`、 `sessions`、`slots`、`theme`、`timer`、`uiWorkspace`、`workspaces`;槽位树里也没有 `settings.plugin.item`),因此 0.2.0 上默认模式同样由宿主渲染,卡片代码依旧不参与。 ## 4. 兼容性校验怎么做 每次 DSH 大版本更新后,按下面的顺序重新校验,并把结论写回 §3.1 的表和 README 的事实段。 1. **逐 API 对照宿主实现**(读代码,不靠记忆): | 插件用法 | 宿主侧位置 | | --- | --- | | `export const Config` + `.volatile()` 字段 → `apply(ctx, config)` 拿到 `{get()}` 引用 | `@deepseek-ai/schemastery` + `@deepseek-ai/cordis`(`Fiber._commitVolatile`) | | `ctx.on("loader/volatile-update", fn)`(本 fiber 作用域) | `@deepseek-ai/cordis-plugin-loader`(`_commitVolatile` 的 `fiber.ctx.emit`) | | `settings.update(entryId, patch)`、`describe()`(0.1.7+ 配置表单) | `@deepseek-ai/dsh-settings`(`SettingsForms`) | | `configEditor.configuration()` → 按 `fiber.uid` 认领自己的 entry id | `@deepseek-ai/dsh-config-editor` | | `ctx.settings.register(ns, schema, {applies})` / `settings.get/update`(<=0.1.6 兼容分支) | `@deepseek-ai/dsh-settings`(旧命名空间实现) | | `ctx.webServer.register({kind:"exact",path,handler})` | `@deepseek-ai/dsh-host-webserver` | | `ctx.on("approval/request", fn, true)`(prepend) | `@deepseek-ai/dsh-user-approval`(`ctx.waterfall`) | | `reason` 前缀 `escalate sandbox to : ` | `@deepseek-ai/dsh-sandbox`(`approveEscalation`) | | Agent 的会话地址:0.2.0 是 `agent.id`,更早是 `agent.session.id` | `@deepseek-ai/dsh-user-approval`(`ApprovalRequestEvent.agent`)、`@deepseek-ai/dsh-agent`(`Agent` 收窄为 `{ id }`)、`@deepseek-ai/dsh-agent-loop`(`inject(input)`) | | 通知消息在插件内构造(`node:crypto` 的 `randomUUID` + 本地 `deepFreeze`),与宿主 `createUserMessage` 同形 | `@deepseek-ai/dsh-llm` 的 `createMessage`/`deepFreeze`(`@deepseek-ai/dsh-util-values`)——只作对照,**不 import** | | 注入消息的 `source.kind` 必须是 producer 自有值,不能是 `"plugin"` | `@deepseek-ai/dsh-session-format-v3-to-v4`(`assertV4RowAdmission`,会话格式 v4 写入准入) | | `ctx.get("dshHomePath")(…)`(插件自有状态目录) | `@deepseek-ai/dsh-home-paths`(app-boot 提供) | | `slots.register({…, locale})` → 组件 `props.t` | `@deepseek-ai/dsh-client-ui-renderer`(locale seat) | | `props.useProjection("permissions")` | standard props | | `props.sessionId`(session 作用域座位) | `@deepseek-ai/dsh-client-ui-session`(`BUILTIN_SOURCE`) | | `settings.plugin.item`(keyed slot)+ `settingsScope`(仅 <=0.1.6 的卡片) | `@deepseek-ai/dsh-client-ui-settings-plugins` / `dsh-client-ui-settings` | | `icon` + `locale/*.json` 的 `meta.title/description` → 插件页卡片 | `@deepseek-ai/dsh-app-boot` 的 `readPluginMeta`(`iconOf` 的扩展名/尺寸/目录越界校验;`dictionariesOf` 读 `meta.title`/`meta.description`) | 2. **确认卡片仍会被派发**:宿主「插件」分区渲染的是 `describe().namespaces`(被服务的 settings 命名空间)与 `settings.plugin.item` 中 `key` 相同的卡片的**交集**。命名空间在 0.1.1-rc.2 之后由 `describe()` 全量返回 (白名单已移除),卡片侧由 §2.1 的检查脚本守住。 3. **确认提权判据仍然成立**:`dsh-sandbox` 的 `approveEscalation()` 必须仍然发出 `escalate sandbox to : `。改拼写不会报错,只会让 `bypass-except-escalation` 退化成普通 `bypass`(§3.1.1)。 4. **留运行时证据**:宿主日志出现 `[dsh-approval-mode] loaded: default mode = …` (挂载成功)与 `hot-mounted`,且**没有** `ctx.settings.register is not a function` 这类 `apply` 期异常;`logs/host/*.error.log` 无本插件条目。0.1.7+ 的写入证据是 profile patch (`$DSH_HOME/profiles//cordis.patch.yml` 里本插件 entry 的 `config.defaultMode`) 与会话文件 `$DSH_HOME/approval-mode/sessions.json`;<=0.1.6 仍是 `settings.yaml` 中的 `approval-mode:` 小节。 5. **注意误判**:§2.4 的 403 是 DSH Desktop 的浏览器访问围栏,与插件无关。 6. **用宿主自己的判定函数复核 manifest**(不靠 semver 心算):插件行的启用与否由 `@deepseek-ai/dsh-app-boot` 的 `evaluatePluginCompatibility(manifest, exemptions, 运行版本)` 决定——返回非 `undefined` 就是插件页上那条「不兼容」。把 `manifest` 换成插件目录里的 `package.json`、`runtime` 取 `getDshRuntimeVersion()` 即可离线复现;同时用 `dshmarket` 的 `manifestFacts` + `deriveHostCompatibility` 复核 dsh-market 的浏览期结论 (它读顶层 `engines.dsh` 与宿主 peer),并用 `readPluginMeta(name, )` 确认 `icon` 与 `locale/*.json` 真能解析出 data URL 与双语标题。§4.1 的 0.2.0 记录就是这么做的。 ### 4.1 校验记录 - **0.2.0-rc.1(DSH Desktop 2.0.16,node v24.18.1,共享层 `@deepseek-ai/*` 0.2.0-rc.1、 cordis 4.0.4、schemastery 3.18.4)— 修复后通过。** 失败现象与根因:插件页卡片报 「dsh-approval-mode@0.1.5 与 DSH 0.2.0-rc.1 不兼容(要求 `@deepseek-ai/dsh-llm ^0.1.1-rc.2`)」, 宿主侧的 `Config.listConfigs` 把插件行报成 `inactive`。根因是 manifest 里的 `@deepseek-ai/dsh-llm` peer(0.2.0 起该包与核心同线,`evaluatePluginCompatibility` 判 `belowMin`), 叠加两处真实契约变化:`ApprovalRequestEvent.agent` 收窄为 `{ id }`(不再有 `session`), 以及通知消息工厂不再是插件的稳定依赖。修复见 §3.1/§3.2;离线契约检查全绿(§2,含新增的 `Agent` 双形状与消息构造断言),反向验证见 §2.3。接口依据:`dsh-app-boot` 的 `evaluatePluginCompatibility`/`readPluginMeta`/`prepareProfileEntries`、`dsh-plugin-manager` 的 `listBundles`、`dsh-user-approval` 的 `ApprovalService`、`dsh-agent-loop` 的 `Agent.inject`、 `dsh-settings` 的 `SettingsForms`(0.2.0 仍是 `describe/update`,`ns = entry.options.id`)、 `dsh-sandbox` 的 `approveEscalation`、客户端服务目录与 `conversation.input.left` 的 slot 契约。 第 6 步的离线复核:新 manifest 的 `evaluatePluginCompatibility` 为 `undefined`、dsh-market 结论 `compatible`、`readPluginMeta` 解析出 `icon`(`data:image/svg+xml;base64,…`)与中英标题; 同一脚本对 profile 里已装的 0.1.5 复现出插件页那条不兼容。 **未做的一步**:DSH Desktop 2.0.16 的界面上没有热装载本版(替换已安装包需要重启 Desktop), 因此「宿主渲染的默认模式表单」与按钮外观只做了接口层核对,未截图确认。 - **0.1.5-rc.2(DSH Desktop 2.0.11,node v24.18.1,共享层 `@deepseek-ai/*` 0.1.5-rc.2、 cordis 4.0.2、schemastery 3.18.2)— 通过。** 上面第 1 步列出的 API 全部一致; 宿主日志 `hot-mounted dsh-approval-mode` 且无插件错误;`settings.yaml` 中 `approval-mode` 写入成功;离线契约检查全绿(§2)。 - **0.1.7-rc.1(DSH Desktop 2.0.14,node v24,共享层 `@deepseek-ai/*` 0.1.7-rc.1、 cordis 4.0.4、schemastery 3.18.4)— 修复后通过。** 失败现象与根因:旧版 `index.js` 在 `apply` 抛 `TypeError: ctx.settings.register is not a function`(宿主日志 `logs/host/dsh-*.log`),宿主半不挂载 ⇒ `/approval-mode` 路由不存在 ⇒ 界面显示 「审批模式未知」。修复采用 §3.2 描述的两代兼容结构;离线契约检查全绿(§2), 反向验证 4 条 FAIL(§2.3)。接口依据:`dsh-settings` 的 `SettingsForms`、 `cordis-plugin-loader` 的 `_commitVolatile`/`loader/volatile-update`、 `dsh-config-editor` 的 `configuration()`、`dsh-web-app/cordis.patch.yml` 里 `locale` entry id。 - **0.1.7-rc.1 热修(0.1.5,撤回 0.1.4)— 通过。** 0.1.4 修好挂载后暴露下一个契约变化: 注入消息的 v3 source 包装 `{ kind: "plugin", plugin: "approval-mode" }` 被会话格式 v4 拒绝 (`SessionFormatError: format v4 message requires a producer-owned source kind`), 症状是该会话之后每一轮 `agent turn failed`(宿主日志 `[dsh-agent-error]`、 `[jsonl-session-persistence] background write … failed`)。修复为 `{ kind: "dsh-approval-mode" }`; 用真实 `assertV4RowAdmission` 复核旧包装被拒、新常量通过;坏事件未落盘,重启即恢复。 0.1.4 的 Release 与 tag 已撤回。 - 更早版本(0.1.0-rc.x / 0.1.1-rc.x)的验证记录见 doc/design.md §5.1–§5.2。 ## 5. 打包与发版 ```sh npm run pack # → dist/dsh-approval-mode-.tgz ``` - **CI**(`.github/workflows/ci.yml`):push/PR 到 main 时跑 `npm run check` 与 `npm run pack`。 - **Release**(`.github/workflows/release.yml`):推送 `v*` tag 时触发,**先校验 `package.json` 的 version 与 tag 一致**(不一致直接失败),然后 check、pack、 用 `scripts/release-notes.mjs` 组装正文、创建 GitHub Release 并附上 `dist/*.tgz`。 **Release 正文来自 `CHANGELOG.md`**:`scripts/release-notes.mjs` 取该 tag 对应的 `## []` 小节,接上安装说明。所以**发版前必须先在 CHANGELOG.md 里写这一节**, 否则正文只会退化成提交列表(脚本会在 stderr 与正文里都标注这一点,不会静默)。 脚本零依赖,可以本地预览: ```sh node scripts/release-notes.mjs --version 0.1.2 --changelog CHANGELOG.md --out - ``` 发版清单: 1. 在 `CHANGELOG.md` 里写 `## [] - ` 小节(`[Unreleased]` 留空); 2. 改 `package.json` 的 `version`(规则见 §3.4); 3. 同步两份 README 里 tarball 示例的文件名(`dsh-approval-mode-.tgz`)—— 这里最容易腐坏; 4. 跑 `npm run check`,并用上面的命令预览一遍 Release 正文; 5. 提交并推送,然后打 `v` tag。 发布内容由 `package.json` 的 `files` 决定:`index.js`、`lib/client.js`、`icon.svg`、 `locale/*.json`、`cordis.patch.yml`、两份 README、`CONTRIBUTING.md`、`CHANGELOG.md`、 `doc/`、LICENSE——README 指向本文档的**相对链接在安装后的包里同样可用**(缺了它,tarball 用户点开就是死链)。`scripts/` 是开发工具,不进 tarball。`icon.svg` 与 `locale/*.json` 必须同时在 `files` 与 `exports`(`"./locale/*.json"`)里:宿主读插件显示元数据走的是 Node 解析器,`exports` 漏了它,图标与标题就静默退回包名。 ## 6. 开发坑 1. **`dsh plugin remove` 对 `link:` 安装的依赖会删除 link 目标目录内容** (开发中曾清空源目录)。开发验证请用 tarball 安装(`add ./x.tgz`), 不要对 `link:` 安装执行 remove。 2. **`link:` 目录安装时 Node 从包真实路径解析 import**,找不到 DSH 共享层 (`@deepseek-ai/*`);tarball / npm / git 安装由 pnpm store 管理依赖,无此问题。 3. **pnpm ≥10 拒绝依赖的构建脚本**:如安装报 `ERR_PNPM_IGNORED_BUILDS`, 按官方指南在 profile 的 `pnpm-workspace.yaml` 添加 `allowBuilds: { : true }`。 本插件无构建脚本,不受影响。