# 架构与移植说明 记录「为什么这样移植」,供后续维护者在 DSH 版本变化时判断该改哪里。 --- ## 1. 迁移动因 原技能是 WorkBuddy 平台产物,L3 能力(每轮注入 + 安全底线拦截)走的是平台的钩子配置 `~/.workbuddy/settings.json` 的 `hooks` 字段。DSH 没有这个东西: - **DSH 没有面向插件的「hook 协议」。** `@deepseek-ai/dsh-hook-protocol` 是 `dsh-hooks-claude-code` / `dsh-hooks-codex` 两个**入向适配器**共用的引擎—— 它让 DSH 去**消费**别人已有的 Claude Code / Codex `hooks.json`,方向是「外部配置 → DSH 行为」,不存在把 DSH 事件导出给外部工具的通道。 - **两个桥接插件都不在任何出厂组合里。** `dsh-base/cordis.patch.yml` 里一条 hook 行都没有, 要用得自己加行并给出必填的 `configPath`(无自动发现、无热加载、无 settings 键)。 - **原本的钩子依赖 Git Bash。** Windows 上平台强制用 Git Bash 执行钩子命令, 缺了就**静默失效**——这正是「说装了却没生效」那类最难查的故障。 所以移植的目标不是「把钩子配置改个路径」,而是:**用 DSH 原生的扩展点重做这三个能力**, 同时保留一条兼容路径给已经习惯配置文件的人。 --- ## 2. 三个能力在 DSH 上的落点 | 原能力 | WorkBuddy 落点 | DSH 落点 | 为什么 | |---|---|---|---| | 每轮注入档位块 | `UserPromptSubmit` 钩子 → `hookSpecificOutput.additionalContext` | `agent/pre-step` 瀑布,把消息追加进 `decision.messages` | `agent/pre-step` 是等价拦截点,且在**第一次模型请求之前**执行,turn 1 也覆盖得到 | | 安全底线转人工确认 | `PreToolUse` 钩子 → `permissionDecision: "ask"` | `tools/pre-execute` 返回 `{kind:'ask', reason}` | DSH 把 `ask` 交给 `tools` 注册表,再由 `approval` 服务真正问用户;`rejected` / 服务缺席一律**失败关闭**成拒绝 | | 内置技能 | 技能目录 | `ctx.skills.registerProvider()` | 与 `@deepseek-ai/dsh-skill-badge` 同形态;模型在 `` 目录里看到它 | 对应的实现文件: - `lib/index.js` —— 装配三个能力 + 埋点 + 只读路由。 - `lib/persona-core.js` —— 与 `persona.py` 对应的纯逻辑(状态分层、档位块渲染、 守卫规则表、埋点字段),**只依赖 Node 内置模块**,因此能脱离 DSH 直接跑测试。 - `lib/skill-meta.json` —— 技能名 / 目录描述 / whenToUse 的唯一真相源。 ### 几个必须守住的细节 - **改写 `agent/pre-step` 的决定时必须 spread**(`{ ...downstream, messages: [...] }`), 否则会丢掉 `startsRequestSeries` 这类字段。 - **不拥有该决定时一律 `return next()`**:本插件不 reject 提示词,只追加消息。 - **同回合只注入一次**:`agent/pre-step` 每步都会触发,用 `WeakMap` 去重, 否则一个回合里工具多跑几步就注入几次,纯粹烧 token。 - **只在本步确实 `enter` 时才注入**:`downstream.kind !== 'enter'` 时直接返回,不打扰别人的否决。 - **可选服务走 `ctx.inject([...])`**:`skills` 与 `webServer` 缺席时(例如 headless), 插件其余部分照常工作,而不是整个 fiber 失败。 --- ## 3. 状态与 Python CLI 完全共用 插件的档位不另存一份,直接读 Python 侧写的那批文件: | 层 | 路径 | 优先级 | |---|---|---| | 会话 | `$DSH_HOME/persona/sessions/state-<会话标识>.json` | 最高 | | 项目 | `<项目根>/.dsh/persona/state.json` | 中 | | 全局 | `$DSH_HOME/persona/state.json` | 最低 | `$DSH_HOME` 缺省 `~/.dsh`(与 `@deepseek-ai/dsh-home-paths` 一致); `<项目根>` 是最近的含 `.git` 的祖先(与 DSH 自己的 `findProjectRoot` 一致)。 会话标识取 `agent.session.header.id`(裸 CLI 场景回退到 `DSH_SESSION_ID` 环境变量)。 这样做的收益是:`persona.py set` 落盘的档位,插件**下一轮立刻读得到**;反过来, 插件写进 `metrics/*.jsonl` 的埋点,`persona.py report` 与人格看板也照旧能读 (字段名逐一对齐,见 `lib/persona-core.js` 的 `toolMetric`)。 **代价**:两条路径同时开会重复写同一批埋点。使用说明里因此写明「A / B / C 三选一」。 --- ## 4. 一处真实的能力回退:`PreCompact` WorkBuddy 版注册 5 个钩子事件。DSH 的 Claude Code 桥接只支持 4 个 (`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`);`PreCompact` 在其不支持清单里,配置会被**静默忽略**。 处置:从钩子生成器、事件分组、文档里全部删掉,并在 `CHANGELOG.md`、README 与 `references/limits.md` 里如实标注影响面——**走桥接路径时,上下文压缩之后档位块不再被 重新注入;走原生插件路径不受影响**。保留 v1.5.14 那次探针结论(`PreCompact` 当时送达 但平台未解包),但明确标注它属于上游平台的形态。 --- ## 5. 为什么底线的文案要跨语言逐字比对 三条底线(安全底线 / 验证下限 / 一次到位)现在**同时存在于两处**: Python 侧(`persona.py` 的常量,钩子路径注入)与 JS 侧(`persona-core.js` 的常量, 原生插件注入)。 原项目反复踩过的失效模式就是「同一段文案存了两份副本、改一边漏改另一边」 (上游记录里的 r12 P2-4)。所以新增 `tests/floor-text-sync.mjs`: 起一个 Python 子进程把常量 dump 成 JSON,与 JS 侧**逐字节**比对。 任何一边改了而另一边没跟上,`npm test` 立刻红。 同理,`PASS_FIRST_LINE` 是**拼出来**的(「一次到位」正文 + `CALL_ECONOMY_LINE`), 不是抄第二份;用例还断言 `CALL_ECONOMY_LINE` 在成型文案里只出现一次。 --- ## 6. 打包:插件怎么被 DSH 认出来 三条判定,缺一不可: 1. `package.json` 声明 `dsh.bundle.patch` → `plugin_manager` 才会把本包当作 **可启停的 profile 层**,而不是「普通依赖」。 2. 该补丁文件里有一行指向本包——剖面把它加进 `dsh.profile.bundles` 后, 装配时插入这一行。 3. `package.json` 声明 `dsh.client`(`platform: "web"`)+ `./client` 导出 → Web 半边才会被加载和注入。DSH 的 `dsh-client-modules` 只接受 `platform: "web"`; 桌面版复用同一份 UI,所以一份 `client.js` 两端都覆盖。 ### 关键陷阱:`peerDependencies` 不是可选项 DSH 在启动时会给 Node 的 ESM / CJS 解析器装一层**运行时解析拦截** (见 `@deepseek-ai/dsh-app-boot` 的 `profile-resolution-bootstrap.js`), 把 `@deepseek-ai/*` 的裸模块请求路由到安装体自带的包。路由规则分两种: - **装在 profile 内**(`profiles//node_modules/...`):目标包的 scope 是 `installation` 就直接拦截,**不需要声明 peer**。 - **用 `link:` 挂载**(开发态,例如 `link:<本仓库目录>`;`link:` 后面必须是绝对路径): 走 `routeLinked`,只有当导入方所在目录的 `package.json` 的 **`peerDependencies` 里列了这个名字**时才会拦截。 所以本包把 `@deepseek-ai/dsh-llm`、`@deepseek-ai/schemastery` 等全部声明为 **optional peer**:全部标 optional 是因为 profile 的 pnpm 配置是 `autoInstallPeers: false`——这些包应当由安装体提供,不该由本包安装; 但名字必须在 `peerDependencies` 里,否则开发态 `link:` 挂载时解析不到, 症状是「插件静默不加载」。 --- ## 7. 网页面板为什么是只读的 `lib/client.js` 只显示当前生效的人格与档位串,**故意不提供切档按钮**。 理由是本技能自己写死的规则:**写全局档位会把所有没单独设过档位的对话一起改掉, 且对方毫无察觉**。一个 UI 按钮很容易让人以为是「只改我这儿」,实际影响面 可能是全部对话。所以切档一律回到对话里用人话发起(「用 ENTJ」), 由模型翻译成正确的 `--scope`,并在回复里说清作用范围。 面板的实现遵守 `references/practices.md`:不 import 任何 DSH Client 包, 样式只用 `--dsw-alias-*` 主题 token **并带 CSS 变量兜底**,任何异常都渲染 `null` (绝不让 slot 条目崩掉),`apply` 里注册并返回 disposer。 --- ## 8. 验证策略 `npm test` = 6 个套件(247 条断言)+ 1 份自检。其中 `plugin-wiring` 在缺 DSH `node_modules` 时跳过,汇总会单独列出「跳过(未验证)」而不算通过: | 文件 | 钉住什么 | |---|---| | `tests/skill-contract.mjs` | 技能名文法、描述归一化 ≤500、`skill-meta.json` 与 SKILL.md frontmatter 同一份事实、三种会让 DSH 丢弃技能的旧拼写不出现 | | `tests/render-block.mjs` | l3 / l2 / off 各自注入什么;思考步骤「实时读 + 快照兜底」;数据源缺失不崩 | | `tests/guard-rules.mjs` | 8 类 52 条正则的命中与不命中、`all`/`exec` 范围差异、规则表条数、参数摊平 | | `tests/state-layers.mjs` | 会话 > 项目 > 全局的优先级、缺层降级、路径推导、会话标识回退链 | | `tests/floor-text-sync.mjs` | 三条底线的 JS ⇄ Python **逐字节**一致 | | `tests/plugin-wiring.mjs` | **装配接线**:用真实 DSH 包 + 假 Cordis 上下文真的调 `apply()`,驱动 `skills.registerProvider` / `tools/pre-execute` / `agent/pre-step` / 只读路由 | | `tests/doctor.mjs` | 包 / 补丁 / 技能负载 / 是否装入 profile / 状态目录可写 | `plugin-wiring.mjs` 需要能解析 `@deepseek-ai/dsh-llm`。**不要在仓库根建 `node_modules`**—— DSH 的运行时解析器对 `link:` 挂载的插件按「本地候选包优先」路由,仓库根一旦真有一个 `node_modules/@deepseek-ai`,DSH 就会从那里加载 `dsh-llm`,于是已安装的插件与宿主 加载到两份不同的模块实例。 正确做法是只在测试子进程里装一个解析钩子: ```powershell $env:PERSONA_DSH_NODE_MODULES = "D:/path/to/dsh/node_modules" # DSH 源码检出,或解包后的 app.asar npm run test:wiring ``` `plugin-wiring.mjs` 会自动做两件事:先按 `PERSONA_DSH_NODE_MODULES`(其次本仓库自己的 `node_modules`)探测一份可用的 DSH `node_modules`,然后用 `node --import tests/_dsh-resolver.mjs` 把自己重启一次,由 `tests/_dsh-hook.mjs` 把 `@deepseek-ai/*` 映射到那份目录。生产路径完全不经过这个钩子。 解析不到时该用例**跳过并明确说明**(不算通过也不算失败)——所以 CI 上没有它。 它是唯一能证明「接线正确」的用例,改动 `lib/index.js` 后请在本地跑一次。 `.github/workflows/ci.yml` 在 Ubuntu + Windows × Node 20/22 × Python 3.10/3.12 上跑, 另外单独断言「钩子配置里不含 `PreCompact`」与「空埋点也必须产出 HTML 看板」。 ### 端到端实测(本机,2026-10) 除单元与装配用例之外,做了一次**真机端到端**验证:把本包装进一个临时 profile (`--from-default-profile headless`),用 `dsh "<一句话任务>"` 真的启动一次。 模型调用因为没有 API key 而失败(headless 环境拿不到桌面版凭据),但**插件链路全程走通**, 证据取自该次会话的持久化日志(`session.v4.jsonl.zstd`,多帧 zstd,逐帧解压后 20 条记录): | 观测 | 证据 | |---|---| | 补丁被组合 | `dsh --dump-config` 输出里出现 `- id: persona-driven` 且无警告 | | `apply()` 正常执行 | 插件侧零警告;注入与路由都注册成功 | | **技能提供者注册成功** | 会话日志里出现 `` 目录,其中一行是 `` - `persona-driven`: 让 AI 按你的风格和节奏干活… `` | | **每轮注入真的发生** | 会话日志里出现一条 `user/message`,其 `source` 为 `{"kind":"persona-driven"}`,正文即档位块 | | 埋点字段正确 | `metrics/injections-<日期>.jsonl` 写入 `{"layer":"global","event":"agent/pre-step","persona":"ENTJ","level":"l3","chars":1259}` | 验证用的临时 profile、全局档位状态与那次会话日志都已清理,未留在系统上。 ### 已知的验证缺口 - **安全底线的「弹确认」还差一次真实工具调用**:`tools/pre-execute` 返回 `{kind:'ask'}` 已由 `plugin-wiring.mjs` 直接验证(且断言命中后不再调用 `next()`), DSH 把 `ask` 转成 Approval 由 `dsh-tools` 的注册表保证(桥接插件走的是同一条路)。 但「在真实会话里真的弹出确认框」需要一次带工具调用的模型回合—— 本机 headless 环境没有 API key,未做。**装好后请按 [安装与验收](安装与验收.md) 第 4 节手工验一次**。 - **网页面板默认关闭(1.6.1 更正)**:`conversation.composer.dock` 渲染时传的是**空 props** (宿主源码里就是 `renderSlot("conversation.composer.dock", {})`),会话标识不在页面 URL 里, 客户端也没有可注入的 session 服务——所以药丸只能读**全局**层,而档位默认写在**会话层**, **默认情况下它永远不显示**。已把 `webPanel` 默认改为 `false` 并在 README 写清原因; 只有显式设了全局档位、并打开 `webPanel: true` 时才有意义。 - **`PreCompact` 的缺失无法在 DSH 上补测**:这是桥接能力边界,只能如实标注。 - **效果宣称仍以 `references/limits.md` 为准**:核心假设按判定线**不成立**, README 与 CHANGELOG 都照抄了这个结论,不得单独摘出 37.5% 那个数字做宣传。 --- ## 9. 跨语言词表契约(统计口径) 埋点写入端有两个:`lib/persona-core.js`(插件)与 `scripts/hook_persona.py`(桥接钩子)。 读取端(`persona_dashboard.py`、`persona.py report`)按**类别名**判定,所以 `TOOL_ALIASES` 与 `TOOL_PREFIX_RULES` 是**跨语言契约**——改一侧必须同步另一侧。 **1.6.1 的真实事故**:移植时 JS 侧被单方面改名(`shell`→`bash`、`grep`+`glob` 合并成 `search`、`task`→`subagent`),又因为 DSH 的工具名是**小写**而两套词表都只认 Claude Code 的 `Read`/`Edit`/`Grep`/`Glob`/`Bash`,于是读/改/搜索三类整批落成 `other` —— 看板上「探查画像」「终端调用」「验证回收」**全部恒为 0,而数据一直在写**。 最快的核对方式: ```bash # JS 侧 期望输出:read shell other node --input-type=module -e "import('./lib/persona-core.js').then(m=>console.log(m.normalizeTool('read'),m.normalizeTool('pwsh'),m.normalizeTool('todo_write')))" # Python 侧 期望输出:read shell other python -c "import sys;sys.path.insert(0,'skills/persona-driven/scripts');import hook_persona as h;print(h.normalize_tool('read'),h.normalize_tool('pwsh'),h.normalize_tool('todo_write'))" ``` **只比对归类行为不够。** 前缀兜底会把漏掉的精确成员接住——实测把 `'read'` 从 JS 成员集合里 删掉之后,`normalize('read')` 两侧**仍然都返回 `read`**,行为比对看不出来。 所以必须**同时比对词表本身**:类别名集合、每个类别的成员集合、前缀规则及其顺序。 完整的逐项比对与变异检验记录见 CHANGELOG 的 1.6.1 一节。 **固化程度(如实说明)**:原计划的 `tests/tool-vocab-sync.mjs` **尚未落地**—— 这条契约目前**已知、有命令可核,但没有自动化拦截**。要真正防复发,应把它补成用例, 并做「改坏后必须变红」的变异检验。