# 设计要点 给要改这个插件的人看。装机与用法在 [安装说明.md](../安装说明.md),功能概览在 [README.md](../README.md)。 ## 两半,各自独立 - **host 半侧**(`src/index.js`、`src/install.js`、`src/detect.js`、`src/store.js`)订阅 `session/event`,把每轮结果记进环形缓冲;安装引擎独占所有文件写入。 - **浏览器半侧**(`src/client/*.js` → 预构建的 `lib/client.js`)是纯渲染器。它拿不到会话数据 —— `main` 是 root 作用域,只暴露五个 root 席位,**没有携带消息的那几个钩子** —— 所以数据由 host 经 HTTP 提供。 **host 半侧的模块**:`index.js`(路由与每轮审计)· `install.js`(安装引擎,独占所有文件写入)· `provenance.js`(来源记录,4.0 新增)· `detect.js` / `store.js`(无依赖的纯逻辑,与浏览器半侧共享同一份实现)。 两条路由都挂在 `/api` 前缀下(默认 `/api/skill-report/state` 与 `/api/skill-report/skills`),因此免费继承 `dsh-client-connection` 的 `requestRejection`:可信 Host/Origin 栅栏 + 浏览器会话鉴权。这条不是顺手为之:**唯一的写入口没有这层围栏就不该存在** —— 裸 `webServer.register` 路由会让本机任何进程都能往用户的 skills 目录丢文件,`networkExposure` 不是 `loopback` 时局域网也能。 ## 枚举 skill 必须带 scope `skills.snapshot()` 读的是 `[全局层, ...scope 链的层]`。这个部署的**全局层没有任何 provider** —— `dsh-web-app` 关掉了宿主的 `skill-filesystem` 行,本地发现归 agent preset 所有 —— 所以**不带 scope 的调用必然为空**。 这正是早期"已安装 SKILL 0、主机侧没有上报 skill"的原因,而且是**每个新会话开局必现**:当时 scope 取自"最近一个**已完成**回合"的会话,新会话还没有完成的回合,于是退化成不带 scope 的调用。现在 scope 取**活着的会话**(任何 `session/event` 都会记下会话 id,另有 `sessions.list()` 兜底),第一帧就是对的。`test/scope.mjs` 钉住这条,并且**桩件忠实模拟了分层语义**(不带 scope 就返回空),所以它不会再溜回来。 ## 安装引擎的四条规矩 1. **绝不写到 skills 目录之外** —— 名称先过严格 slug,解析后的绝对路径再做一次包含性校验。用 `relative()` 而不是 `startsWith()`,因为 `/skills-evil` 也以 `/skills` 开头。 2. **绝不破坏数据** —— 新内容先落在 `.echocat-stage-*` 暂存目录,完整后才 `rename` 就位;覆盖前把旧的复制进备份目录;**卸载是移动不是删除**。改 `meta.yaml` 时逐行 patch 而**不重新序列化**:别人的文件里有 `summary-cn`、`tag-cn`、`complete-tags-*`、注释和键序,重排会一次全丢。 3. **绝不静默失败** —— 每个失败都是带稳定 `code` 的 `InstallError`,界面按 code 分支并渲染 `message` + 可选 `hint`。 4. **绝不信任输入** —— zip 用手写解析器(只吃 stored/deflate,拒绝 zip64),在写盘**之前**挡掉 zip-slip、绝对路径、符号链接条目与解压炸弹;URL 安装默认拒绝 `localhost` 与 RFC1918。 **4.0 补的一条**:来源记录写在**暂存目录里**、并且只在 `commit()` 的 `rename` 成功之后落盘 —— 一次会被拒绝的安装(`NAME_TAKEN`)必须让文件系统完全保持原样,连一条记录都不该留下。 ## 观察型插件绝不能让宿主起不来 插件树**没有 per-plugin 隔离**:`apply()` 里抛错会失败**整棵插件树**;而 `ctx.inject()` 的回调是**异步激活**的,不在 `apply()` 的 try/catch 覆盖范围内。所以 `apply()` 顶层有兜底,**每一处异步注册点(HTTP 路由、`slots.register`)也各自兜一层**,失败降级为"功能少了",而不是"应用打不开"。 这条来自实测,代价是一次起不来:把 `session/event` 当成 `ctx.sessions.on(...)` 写过一次,`apply()` 抛 `TypeError`,**DSH 直接不启动**。会话事件是 **cordis 上下文事件**(`ctx.on`,经 `ctx.inject(['sessions'], c => c.effect(...))` 订阅),**不是 `sessions` 服务的方法**。 `tools/build-client.mjs` 同理:它**会解析自己的产物**再报成功,因为"文本内联式"打包器发现不了模板字符串被提前闭合。 ## 踩过的坑 都是调出来的,写在这里是因为它们比功能列表更能解释这个插件为什么长这样。 **把宿主的语义令牌当视觉令牌用** —— 一次性造成"浅蓝底色"和"两条黑线"两个抱怨: | 令牌 | 实际含义 | 后果 | | --- | --- | --- | | `--dsw-alias-label-primary` | **正文字色** `#0f1115` | 所有强调色(左侧竖条、主按钮、下划线、焦点环)都成了近黑色 | | `--dsw-alias-border-l1` | `#0000000a`,**4% 黑** | 卡片、分隔线、输入框边框等于没画,面板成了一张白纸 | | `--dsw-alias-fill-l1/l2` | **根本不存在** | `var()` 回退到兜底值,看着像生效了 | | `--dsw-specific-menu` 等 | 一族**偏蓝**的面 | 大面积借用它 → 整块面板泛蓝 | 结论:**大面积、颜色、边框一律用插件自己的令牌**,只有小范围交互态才借宿主的。判断方法也不难 —— 采一下屏幕像素,比对着压缩过的截图推理可靠得多。 **设计令牌必须定义在每一个界面根上**,不只是中栏面板。横栏展开的安装面板是横栏根节点的**兄弟**,不是子节点,所以令牌只挂 `.sr-root` 时,面板里每个 `var(--sr-*)` 都是未定义的 —— 而**未定义的 `var()` 会让整条声明在计算值阶段失效**,边框、内边距、间距、颜色一起消失,渲染成裸 HTML。 **逗号选择器列表不能共享尾随组合符。** `.a,.b ::selection` 的含义是"`.a` **元素本身**、以及 `.b` 里的选中文字"。写成 `.sr-root,.sr-strip-shell,.sr-backdrop,.sr-rail ::selection` 之后,前三个界面根被当成了元素本身:`background:var(--sr-accent-weak)` 把整块面板刷成蓝色,`outline:2px solid` 给它套了个黑框,`box-sizing` 只对最后一个生效。修法是**界面根声明为数组**、每个变体逐个映射生成。 **滚动列里给 flex 子项加 `min-height:0` 会让粘底页脚卡在中间。** 它把内容体压成一屏高,`flex:1` 于是把页脚的*流内位置*放到了**容器底部**(而不是内容末尾),`sticky;bottom:0` 无事可做,溢出的内容继续画在页脚下面。 **`finally` 里的清理失败会把成功变成失败。** `git clone` 留下只读的 pack 文件,Windows 上删不掉(`force:true` 只忽略 ENOENT,不忽略 EPERM)。它写在 `finally` 里,于是一次**已经成功**的安装给用户报的是 `EPERM`。现在清理先清只读位、带重试,并且**永不抛出**。 **CSS 注释里的一个反引号会静默截断整张样式表。** 三张样式表都是模板字符串。注释里写一个反引号,字符串就提前结束,后面剩下的 CSS 被当成 JavaScript 解析 —— 而**它往往仍然能解析通过**:`font-size:12px` 是合法的 label 语句。于是产物语法正确、插件照常加载,只是样式全丢;或者更糟,某个残留片段被读成标识符,加载时抛 `ReferenceError`。这一版里两种都真实发生过,**两次 `new Function` 的解析检查都放行了**。现在 `tools/build-client.mjs` 会检查样式表末尾的 end marker 是否还在(截断时它必然丢失),并且 `theme.js` 里留了一行"本文件禁止反引号"。顺带一条纪律:**不要用 PowerShell 的 `Get-Content`/`Set-Content` 改这些文件** —— 默认按 ANSI 读会把破折号变成 `鈥?`,写回时还会加 BOM。 ## 卡片为什么是三行,而不是"名字在左、按钮在右" 第一版卡片把**名字和六个按钮放在同一个 flex 行**里。六个按钮先按内容占位,名字拿剩下的 —— 于是 `h3-prompt-writing` 渲染成 `h3-prompt-writi…`,长一点的直接折成两行再截断。 **一个 skill 的名字是这张卡片上唯一不能被裁掉的东西**,所以布局改成三行: 1. **名字行**:头像 + 名字块(中文名/代号)+ 标签,标签靠右且最多占 55% 宽; 2. **说明**:描述与来源行; 3. **按钮行**:占满整行、从卡片左边缘开始 —— 这才是"页脚"该有的样子。 配合 `white-space:normal` + `overflow-wrap:anywhere`:sklug 是没有空格的长 token(`paper-collage-explainer-generator`),没有 `anywhere` 就只能溢出而不是换行。原来那条 `.sr-skill-top` 规则连同它的元素一起删掉了 —— **没有元素的规则会在样式表里躺很多年**。 ## 插件自己的版本检查:为什么会有两个源,为什么比较不能是字符串 「检查更新」这个按钮要回答的问题只有一个:**有没有比现在这个更新的版本**。看起来简单,但有三处如果做错,会给用户一个自信的错误答案。 **① 不能用字符串比较版本。** `'4.0.10' < '4.0.9'` 在字符串世界里成立,在版本世界里不成立。所以 `compareVersions` 逐段转数字比较;预发布版本(`4.0.0-rc.1`)**低于**它的正式版,否则面板会劝一个已经装好正式版的人"升级"到候选版。**畸形版本串一律拒绝作答**(返回 0 = "不比"):`Number('nonsense')` 是 `NaN`,而 `NaN` 参与的比较全是 `false`,会被读成"相等" —— 一个乱写的 tag 就会静默变成"已是最新"。这个 bug 在写测试时真实出现过。 **② 两个源都要问,取更高的那个。** npm registry 知道 `npm i` 会装到什么,GitHub API 知道人在仓库页上看到什么、还带发布说明。任一源都可能缺失(没发布、没建 release、没有网),所以两条都问、各自失败各自降级,取较高者。tag 上的 `v` 前缀要剥掉再比较、但显示时保留原样,否则「有新版本 v4.1.0(当前 4.0.0)」一句话里混了两种写法。 **③ 失败必须是值,不是异常。** 路由永远 200,`latest: null` 配一个给人看的原因;**"查不到"和"已是最新"必须是两句不同的话**,否则用户没法判断要不要管它。缓存 10 分钟,并发点击共享一轮请求(面板有三个座位,三次点击不该变成六个请求),`?force=1` 绕过缓存 —— 用户再点一次就是在明确要求重新问。 按钮只在**点击时**联网,从不进 5 秒轮询:一个只是开着的面板不该持续敲 registry。 ## 4.0 的宽度:为什么是百分比,不是像素 需求是「横栏永远以整个 DSH 界面的缩放自适应,比输入框窄一点」。第一版把它写成 `max-width: calc(var(--dsh-composer-card-max-width) - 16px)` —— 在宽窗口是对的,在窄窗口是错的, 而且**两个错误都只有真浏览器才看得出来**: 1. **容器本身成为约束时,两者会一样宽。** 宿主自己算的宽度是 `--dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px)`,而 `--dsh-chat-content-width` 是 `clamp(680px, 列宽 * .64, 920px)` —— **最小 680px**。窗口窄到 680px 时,dock 的内容盒只有 648px,输入框取满 648px,于是我们的 `- 16px` 上限(664px)根本 不起作用,两边**完全相等**。实测:`window 680px → composer 648 · bar 648`。 2. **居中会把差值平分到两侧。** 比输入框窄 16px,再用 `margin-inline:auto` 居中,左边缘就比 输入框右移 8px —— 两个上下叠放的面板左边差 8px,看起来就是个渲染 bug。实测每一个窗口宽度都如此。 所以最终规则是两条约束一起用: | 约束 | 规则 | 什么时候生效 | | --- | --- | --- | | 不超过输入框 | `max-width: var(--sr-max)`(= 宿主宽度 − 16px) | 宽窗口 | | 不超过容器 | `width: calc(100% - 16px)` | 容器比输入框的上限还窄时 | 容器最多等于 100%,我们永远是 `100% - 16px`,所以**"更窄"在任何尺寸下都由构造保证**,不再依赖 某个具体像素值。居中保留(`margin-inline:auto` + `align-self:center`),因为两者共享同一条中轴, 差值 8px 平分到两侧才是对齐的——而"左对齐"反而会让它偏 8px。 `tools/check-width-live.mjs` 用**真 Chrome**在 8 个窗口宽度上量真实像素:57 项断言,每次 8 个宽度 × 7 项检查。它不是 `npm test` 的一部分(需要 Chrome 二进制,不该是门禁的依赖),而是改宽度规则时 必须手动跑的那一道。 > 这一节值得留着,因为它演示了一件事:**算术检查能证明规则,证明不了渲染**。上面两个缺陷在 > `test/ui-polish.mjs`(解析 calc、比对顺序)里全是绿的。 ## 200+ 项界面打磨:为什么要生成而不是手写 4.0 加了 **215 项**界面调整。它们不是散在样式表里的 215 条声明,而是 `src/client/polish.js` 里 215 条**具名记录**,样式表文本由它们生成。理由: 1. **数量是事实,不是声明。** `POLISH.length` 可执行、可断言,提交信息里的"200+ 项"因此可以被检查。 2. **每一条都必须真的生效。** `test/ui-polish.mjs` 逐条断言它的声明出现在生成的 CSS 里——选择器写错、 属性被浏览器丢弃,都会让数量静默缩水,而这正是"手写打磨"无法防住的事。 3. **每一条都带理由。** 记录里有 `why` 字段。半像素是有意的还是笔误?下一个人读理由,不读数值。 生成器还有两条硬规矩,都是踩出来的: - **不许泄漏到宿主。** 记录里若出现没有 `.sr-` 也没有 `{all}` / `{root}` 标记的裸元素选择器(比如 `div`),生成器**直接抛错**,而不是生成一条会重排宿主应用的全局规则。这条 guard 由 `test/ui-polish.mjs` 主动调用验证——**没人能调用的 guard 不算 guard**。 - **不改写 `@media`。** 生成块插在手写规则**之后**、媒体查询**之前**。一开始打算追加到末尾,那样 会静默压掉 `@media (max-width:560px)` 的窄屏布局——用一个更糟的 bug 换一个更小的 bug。 - **`rooted: false` 是唯一的逃逸口,而它直到 4.0.2 才真正实现。** `polish.js` 的 JSDoc 从第一天 就写着这个选项,**两个生成器都没实现它**。于是任何不以 surface root 开头的选择器都会被当成 "surface 的后代"展开——`.sr-portal-host` 就这样变成了 `.sr-root .sr-portal-host`,而 portal host 自己**就是** `.sr-root`(挂在 `document.body` 上),不是它的后代。**这条规则永远匹配不到它要 匹配的元素**,host 于是保留 `.sr-root` 的整套外框(`height:100%` + 边框 + 圆角 + 白底)成为 `#root` 之后的一个空盒子,文档高出整整一个视口——用户往下滑就看见"一个空白带框的页面"。 这次事故里最值得记的一条:**`test/ui-polish.mjs` 当时是 135/135 全绿。** 那条断言写的是 `/\.sr-portal-host\{[^}]*display:contents/` ——它只验证**声明进了样式表**,而 `.sr-root .sr-portal-host{…display:contents}` 完美满足这个正则。**声明存在 ≠ 规则能命中。** 现在的断言验证"这条选择器能不能匹配 host 本身"以及"没有任何规则以 surface 后代的形式去找它", 并且 `tools/check-portal-assertions.mjs`(已进 `npm test`)会把同一批断言喂给**旧的错误样式 表**,确认它们真的会 FAIL——**能被旧代码骗过的断言不算断言**。 还有一层加固:portal host 不再带 `sr-root` 面板外框类(令牌由 `.sr-backdrop` 提供,它本身就是 surface root,而且永远是 portal 的内容)。这样即使将来某条规则再丢,失败模式也只是"对话框没居 中",而不是"整页空白"。 同一次打磨里另外两处**非显然**的取舍: - **`font-weight:500px` 这类静默失效。** 序列化器一开始把所有数字都当长度加 `px`,于是 `font-weight:500px`、`opacity:1px` 被浏览器整条丢弃——**声明消失了,但样式表看起来没问题**。 现在有一张 unitless 名单,测试里还有一条专门抓它的断言(名字里含 `order` 的 `border-top-right-radius` 一度被误报,名单和断言都按这个修正过)。 - **允许"重置为默认值",但要说明理由。** 4 条记录把属性设回初始值(例如把被弱化的不透明度调回 1)。 这是必要的覆盖,不是空操作;测试里有一张 `ALLOWED_RESETS` 表逐条列出并断言其存在,因为"某条记录 什么都没改却计入了 200 项"是这份清单唯一可能不诚实的方式。 ## 停用:为什么是「移出目录」,而不是改名或隐藏 需求是「每个 skill 有个启用/关闭开关」。三种做法里只有一种是真的: | 做法 | 模型还能加载吗 | 结论 | | --- | --- | --- | | 只在面板里隐藏 | **能** | 自欺欺人:面板说停用了,模型照用 | | 目录改名(`.name` 之类) | **能** —— DSH 只跳过 `.system`,chokidar 也照看 | 同上 | | **把目录移出 skills 根目录** | **不能** | ✅ 唯一真的 | DSH 的 `skill-filesystem` 是**按 `//SKILL.md` 监视**来发现 skill 的,所以只要目录还在 根里,它就还是「已安装」。于是停用被实现为**移到 `/skill-report/disabled/`**, 启用就是移回来。 这样顺手满足三件事:**没有任何东西被销毁**(本引擎最老的一条规矩)、停用**一步可逆**、被停用的 skill **仍然在目录里**(`onDisk()` 同时扫两个根,每行带 `disabled` 标记),于是它还能改名、查来源、 检查更新、删除 —— 面板只是把它放进「已停用」分组,而不是让它消失。 两个容易漏的连带约束: - **停用的名字仍然被占用。** 往一个已停用的名字上安装会得到 `NAME_TAKEN`,否则用户会有两份副本, 而且无从知道模型加载的是哪一份。 - **`rename` / `claim` / `uninstall` 都必须能解析到停用目录。** 它们原本都直接拼 `skillsRoot/`,因此对停用的 skill 一律报 `NOT_FOUND` —— 这是写完开关后测试立刻抓到的。 ## 4.0re 设计重做:先看见,再改 「界面很难看」这句话无法变成断言,所以先做了一个能**看见**的工具:[tools/preview.mjs](../tools/preview.mjs) 用与 bundle 测试相同的 CommonJS shim 与 hook 运行时,把**真实的** `SkillReportPanel` / `SkillReportStrip` / `InstallSheet` / `StatusRail` 渲染成静态 HTML,配上**真实的样式表**,在真 Chrome 里截出浅色与深色两张图。它还能 `--measure`,把计算后的字号、色值、圆角打印出来—— 截图只能说「好像有点小」,探针能说「统计数字是 26px、标签是 10.5px」,这是调整一个值和猜一个值的差别。 第一次看到就明白了问题所在 —— 这些都不是 bug,而是**没有设计语言**: - 面板是**白卡片放在白页面上**:没有投影、没有层次,边界只剩一条 1px 淡边; - 所有标签都在 **10–11.5px** 之间,于是标题、正文、元数据**没有层级**; - 主按钮是一个**淡色药丸**,看起来像禁用状态的链接; - 用量条是**默认样子的矩形**; - 深色模式在应用内切换时**根本没生效**(见下)。 ### 建立的语言 **编辑式极简**,现代开发者工具用的那种: | 系统 | 内容 | 为什么 | | --- | --- | --- | | **四个表面台阶** | canvas `#f7f7f8` → card `#fff` → raised `#fbfbfc` → sunken `#f2f2f4` | 面板要能**坐在**页面上,而不是融进去 | | **一个强调色** | 靛蓝 `#4f46e5`,只用于三处:主操作、选中、"有东西可做" | 宿主自己的蓝是链接色;大面积借用会让面板像另一个应用 | | **五级字阶** | 10.5 / 11.5 / 12.5 / 15 / 26,字号越大字距越紧 | 层级来自尺度,不来自随手的字号 | | **三级投影** | raise / hover / overlay,每级都是**两层低透明度**叠加 | 单层深色模糊看起来像污渍,不像深度 | | **发丝线用 `color-mix` 从墨色推导** | 边框在任何表面上都是恰当的重量 | 硬编码的灰在某个台阶上必然太淡或太重 | **265 条具名记录**(`src/client/design.js`),与 215 条 polish 记录同样是数据、同样生成 CSS、同样逐条 被断言。两个块**故意重叠**(token、字号、圆角、投影),所以顺序是承重的:design 块插在 polish 块 **之后**,并列断言了这一点,而不是靠约定。 ### 三个只有量了才知道的收获 1. **深色模式可能压根没生效。** 原来的深色只挂在 `prefers-color-scheme: dark` 上 —— 用户如果是在 **应用内**选的深色(宿主用 class / attribute 标记),插件会一直亮着。现在两条信号都发, 并各有一条断言。(顺带发现:`--force-dark-mode` 是 Chromium 的**自动反色**,不是我们的深色盘 —— 第一张"深色"截图里靛蓝被反成了珊瑚色。预览因此改成真的加 `.dark`。) 2. **对比度是算出来的,不是看出来的。** 建了 WCAG 计算并写进断言:正文 18.2:1、次级 6.4:1、 元数据 5.3:1、强调色 6.3:1(浅色)/ 4.9:1(深色)。元数据色因此从 `#8a8f9a`(3.2:1,不达标) 调到 `#666c78`。 3. **生成器有两个静默 bug。** 计算样式探针发现输入框占位符颜色没变,追下去是: `{all}` 后面跟**类名**会生成 `*.sr-input` —— 那是"元素的后代",规则**静默失效**;而 **逗号列表只有第一项被加了根**,其余项会匹配整个文档,正是守卫本该拦住的那种宿主泄漏。 两处都修了,并各配一条断言。 ## 来源记录:为什么写在 skill 自己的目录里 4.0 让"装了之后能更新"成为可能,靠的是一份随 skill 走的记录(`src/provenance.js`)。 **它解决的问题**:3.0 把用户粘进来的地址用一次就丢,git 克隆还被删掉。于是作者发了修复,用户只能删了重装 —— 而删除是移动到备份,他还无从判断新旧。 **为什么是 skill 目录里的 `.echocat.json`,而不是插件自己的数据库**: 1. **搬走就跟着走**。用户手工拷一个 skill 到另一台机器,来源记录一起过去; 2. **用户能自己看、自己删**。纯 JSON,不依赖本插件解释; 3. **不会和 skill 自己的文件撞名**。skill 目录是 kebab-case,点号开头不可能冲突;发现机制找的是 `SKILL.md`,多一个点文件无影响。 **指纹怎么算**:遍历目录、按相对路径排序(目录顺序在不同文件系统上不稳定,不排序会报出假变更),把路径、大小与每个文件的字节折进一个 64 位 FNV-1a。**排除记录文件自身** —— 否则写入记录就会让它自己记录的那份指纹失效。刻意不用 `node:crypto`:这是变更探测器而不是安全原语,测试要钉住一个确定值,就不该随运行时的默认算法漂移。 **指纹的用途只有一个,但很关键**:`changedSinceInstall` 表示"用户在手装之后改过这个 skill 的文件"——这是更新**唯一**会静默毁掉东西的场景,所以面板必须在确认前说出来。因此插件的自我编辑(改中文显示名要重写 `meta.yaml`)必须在写完后**刷新记录里的指纹**,否则每次改名都会让那张卡片开始喊狼来了,而喊久了的警告等于没有警告。 **检查更新为什么用 `git ls-remote` 而不是比对内容**:一次轻量网络查询就能回答"远端这个 ref 现在指向哪个 commit",而比对内容意味着每次检查都要把整个 skill 重新下载一遍 —— 那种成本会让"检查更新"这个按钮没人愿意按。所以安装时记下 commit,检查时只问远端现在指向哪。**固定版本不误报**:`ref` 本身就是 40 位 sha 时,没有"更新"可言,直接说清楚而不是编一个 verdict。 **`claim`(标记来源)为什么存在,以及它为什么是诚实的**:本机已有的十几个 skill 全是手工装的,没有任何记录,如果只支持"本插件装的才能更新",这个功能对现存用户等于不存在。所以允许用户手填地址 —— 但记录里打上 `claimed: true`,检查时**不比对**,更新时**多要一次确认**。地址是用户说的,我们没验证过;把猜测包装成事实才是真正的问题。 ## 三条非显然的实现约束 **`installArchive` 曾经在参数错位的情况下"正常工作"过。** 一次编辑事故把 `staged(name, overwrite, build, origin)` 的第三、四个实参挤成了 `build, name, origin`,而 `staged` 的签名是 `(name, overwrite, build, origin)` —— 于是它把 `undefined` 当 `overwrite`、把名字当 build 函数。它没崩,因为 `staged` 恰好只用 `name` 与 `origin`。**这是"测试替不了代码审查"的例子**:装出来的 skill 一切正常,只有溯源记录里的地址少了个 `.zip`。所以每个 install 模式都有一条断言直接读回记录文件,而不是只看响应。 **`commit()` 的抛错必须在 `staged()` 的函数体里,不能在返回的对象字面量里。** 写成 `return { ...commit(...), ...built }` 时,抛错发生在 `staged` 内部、不再冒泡给 `run_action` 的 try/catch —— 表现是 `NAME_TAKEN` 变成未捕获的 rejection 而不是一条 409。现在拆成两步:先 `const committed = commit(...)`,再 `return { ...committed, ...built }`。 **更新检查的结果不放组件 state。** 它 await 一次网络往返,而 `main` 面板在用户切回对话时就被卸载 —— 续体里 `setState` 正是"组件没了还去写它"的形状。所以结果(以及 `checking` 标志)都放在 `src/client/source.js` 的模块级 store 里,和 skills、toasts 一样的模式。 ## 测试 ```powershell node test/units.mjs && node test/smoke.mjs && node test/install.mjs && node test/http.mjs && ` node test/install-route.mjs && node test/scope.mjs && node test/client-bundle.mjs && node test/client-css.mjs ``` | 套件 | 覆盖 | | --- | --- | | `test/units.mjs` | `detect.js` / `store.js` 纯逻辑:两条通道、去重、环形缓冲边界、订阅隔离 | | `test/smoke.mjs` | host 半侧跑在**真实 cordis** 的 `Context` + `Service` 上 | | `test/install.mjs` | 安装引擎:真临时目录 + 真 zip 字节,含 zip-slip、绝对路径、符号链接、解压炸弹、体积上限、覆盖备份、卸载确认、SSRF 围栏、地址识别、只读文件清理;**加上 4.0 的溯源纯函数、每种模式写出的记录、claim/check/update 各自的拒绝路径,以及一段用真实本地 git 仓库跑完的「装 → 发现新 commit → 更新」闭环** | | `test/http.mjs` | 真实 cordis + 记录型 connection 服务;直接调用路由 handler,断言载荷、HEAD 无 body、降级行为 | | `test/install-route.mjs` | 安装/改名/溯源端点真挂载:协议形状、状态码映射、`allowInstall:false` 变只读、改名往返回读、**记录经 state 供数抵达面板** | | `test/scope.mjs` | **首个回合完成前也必须能列出已安装 skill**(scope 取自活跃会话) | | `test/install.mjs` | 安装引擎:真临时目录 + 真 zip 字节,含 zip-slip、绝对路径、符号链接、解压炸弹、体积上限、覆盖备份、卸载确认、SSRF 围栏、地址识别、只读文件清理;**加上 4.0 的溯源纯函数、每种模式写出的记录、claim/check/update 各自的拒绝路径、一段用真实本地 git 仓库跑完的「装 → 发现新 commit → 更新」闭环,以及 30 条停用/启用断言** | | `test/http.mjs` | 真实 cordis + 记录型 connection 服务;直接调用路由 handler,断言载荷、HEAD 无 body、降级行为 | | `test/install-route.mjs` | 安装/改名/溯源/停用启用/发布检查端点真挂载:协议形状、状态码映射、`allowInstall:false` 变只读、改名往返回读、**记录经 state 供数抵达面板**、**停用的 skill 出现在 `disabledSkills` 而不再出现在实时目录里**、**发布检查是 GET/HEAD 只读、会缓存、`force` 会绕过缓存、关掉联网后仍发布仓库地址** | | `test/release.mjs` | **版本比较与双源合并**:semver 逐段数值比较(`4.0.9 < 4.0.10`)、预发布低于正式版、畸形版本串拒绝作答、npm 与 GitHub 取更高者、任一源失败都降级成"不知道"而不是抛错、缓存与 `force`、并发只发一轮请求、请求必须可中止 | | `test/scope.mjs` | **首个回合完成前也必须能列出已安装 skill**(scope 取自活跃会话) | | `test/client-bundle.mjs` | 真实 `lib/client.js` 按 `__ModuleLoader__` 协议求值,**真实 React**;自带 hook 运行时,把安装、预览、覆盖重试、两步删除、改中文名、尺寸拒绝、**来源标记与两步更新**、**开关一步停用/启用**、**两个分组的渲染**、**计数器搬进横栏**、**两个版本按钮(检查更新只读、发布页新标签打开且 noopener)**端到端跑一遍 | | `test/client-css.mjs` | 样式表契约:令牌必须覆盖每个界面根、不得引用未定义令牌、类必须有规则、选择器不得共享组合符、粘底页脚不得被压 | | `test/ui-polish.mjs` | **宽度契约 + 215 项打磨 + 270 项设计重做**:解析宿主宽度公式并在 7 个内容宽度上验证"永远更窄";逐条核对两个生成块的声明真的进了渲染后的样式表;拒绝空操作记录;验证泄漏 guard 可被调用;**计算 WCAG 对比度**(正文/次级/元数据/强调色/深色各自达标);断言 design 块在 polish 块之后、且在窄屏媒体查询之前;断言深色盘同时挂在 OS 偏好与显式主题两条信号上;断言开关、分组、认领行、版本按钮、以及"名字不截断"各自的样式真的存在;**并且断言 portal host 的中和规则能匹配 host 本身、而不是以 surface 后代的形式去找它**(4.0.1 那个整页空白 bug 的回归测试) | | `tools/check-portal-assertions.mjs` | **守卫套件**(在 `npm test` 里):把上面那批 portal-host 断言喂给**故意还原成 4.0.1 错误选择器**的样式表,要求它们**必须 FAIL**;同时证明**旧的弱断言在两份样式表上都通过** —— 一个不可能失败的断言只是装饰 | **当前 1364 项断言,十二个门全绿:** ``` units 54 · smoke 25 · install 305 · http 44 · install-route 153 · release 57 · scope 17 client-bundle 451 · client-css 113 · ui-polish 137 (十套共 1336) check-portal-assertions 8 (守卫套件) verify-install 33 · verify-compose 16 (两个重启前门禁,共 49) ``` 外加两个**不进 `npm test`** 的真浏览器工具(都需要 Chrome 二进制,让门禁依赖浏览器不合适): - `node tools/check-width-live.mjs` —— 8 个窗口宽度上 57 项像素级断言; - `node tools/preview.mjs` —— 渲染真实面板并截图(`--measure` 打印计算样式)。 `test/ui-polish.mjs` 从 66 条长到 **105 条**(+39),其中 **27 条专管设计块**:每个 design 记录的声明 是否进了渲染后的样式表、两个生成块的层叠顺序、WCAG 对比度、深色盘的两条信号、字阶单调性、 每级投影是否真是两层。数字取自实际运行,不是估的。 后两个门禁是**重启前**检查:它们读活的 profile 与 `dsh-client-modules` 的真实行为,确认"装到一半"的状态也不会让应用起不来。 > 这两个门禁**不再写死版本号**(4.0 改的):原来是 `'3.0.0'` 字面量,于是每次发版都会红一条与"解析器有没有选中已安装副本"毫无关系的断言。现在版本从**被组成的那份 manifest** 里读出来比对。 ## 配置 写在 profile 的 `cordis.patch.yml` 里,**按 id 覆盖**(是 `id` 覆盖,不是 `insert` —— `insert` 会再塞一个重复条目): ```yaml - id: skill-report config: notifyOnNoSkill: true # false = 只在本轮用到 skill 时才弹通知 translateMissing: true # 关掉可停止一切模型调用 allowInstall: true # false = 面板变只读 skillsRoot: '' # 留空 = 自动解析(推荐) ``` 全部字段与默认值: | 字段 | 默认 | 作用 | | --- | --- | --- | | `enabled` | `true` | 总开关 | | `notifyOnNoSkill` | `true` | 未调用 skill 的回合是否也弹通知 | | `includeSubagents` | `false` | 子代理回合是否计入 | | `logReports` | `false` | 每条报告是否也写进 host 日志 | | `maxRecent` | 环形缓冲容量 | 面板保留多少个完成的回合 | | `httpRoute` | `true` | 是否注册路由(false = 面板与安装都失效) | | `httpPath` | `/api/skill-report/state` | 面板供数路由 | | `translateMissing` | `true` | 给没中文的 skill 自动翻译(消耗你自己的额度,每个一次) | | `allowInstall` | `true` | 是否暴露安装端点 | | `installPath` | `/api/skill-report/skills` | 安装/改名端点 | | `skillsRoot` | `''` | 写入目标;留空则自动解析 | | `backupRoot` | `''` | 备份目录;留空则用 `$DSH_HOME/skill-report/backups` | | `allowPrivateHosts` | `false` | 允许从 `localhost` / RFC1918 地址安装 |