# dsh-approval-chime 挂载与验收(任务 t5 · verifier) > **一句话现状**:profile **尚未改动**。本执行环境的文件沙箱禁止写工作区外路径(`EPERM`),按规则发起的一次性提权(`danger-full-access`)被本会话自动拒绝(审批提示已禁用)。因此「三处 profile 改动」是唯一未完成项,**需要用户在本机终端跑一条命令**(§4),随后重启 `dsh web`。其余全部就绪:备份、挂载脚本、预检脚本、逐字节目标清单、以及「挂载后状态」在影子 profile 上的 56/56 预检证明。 > > 本任务**没有重启** `dsh web`(会终止当前会话),重启与听感确认属于用户,之后由 captain 复核托管状态。 --- ## 1. 为什么没有直接写 profile(原始证据) 第一次真实挂载尝试(`node dsh-approval-chime/deploy/mount.mjs`):**备份成功**(工作区内),随后写 profile 被拒: ``` [backup] \dsh-approval-chime\deploy\backup-20260915-191825 $DSH_HOME\profiles\web\package.json → package.json (866 B, sha256 42f203f51a91cb95…) $DSH_HOME\profiles\web\cordis.patch.yml → cordis.patch.yml (1559 B, sha256 ffcdc71bd98da961…) $DSH_HOME\profiles\web\pnpm-lock.yaml → pnpm-lock.yaml (7901 B, sha256 7cb0ded492e54173…) Error: EPERM: operation not permitted, open '$DSH_HOME\profiles\web\package.json' at writeFileSync (node:fs:2482:20) at applyManifest (.../deploy/mount.mjs:139:27) ``` 按任务书规则,同一条命令用 `sandbox_permissions=danger-full-access` 重试一次,结果: ``` Error: the user rejected escalating this command to "danger-full-access" ``` 独立佐证(说明这不是本脚本的问题):DSH 自己的 CLI 诊断命令也在同一处被拒 —— ``` PS> dsh --profile web --dump-config Error: EPERM: operation not permitted, open '$DSH_HOME\profiles\web\cordis.yml' ``` 即:**该环境拒绝任何对 `$DSH_HOME` 的写入**。按规则,提权被拒后不再尝试绕行;profile 保持原样(§2.1 的三份哈希与线上文件逐字节一致可证)。 > 附带发现(供后续决策):`dsh --profile web --dump-config` 不是只读命令 —— 它会先 `prepareProfile` 写 `profiles/web/cordis.yml` 再打印合成结果。因此本次没有用它作为验收手段(改用 §5 的进程内 composer,同一套代码路径、零写入)。 --- ## 2. 已完成的准备(全部在工作区内,可复核) ### 2.1 备份(acceptance #1) ``` dsh-approval-chime/deploy/backup-20260915-191825/ package.json 866 B sha256 42F203F51A91CB95C90788EA5968F776697829A718DD26A76D61BF4C6650175E cordis.patch.yml 1559 B sha256 FFCDC71BD98DA961B829697CE34E4F36D5C85F7B07136A6C30D28EAD6EB8E574 pnpm-lock.yaml 7901 B manifest.json 备份时刻的哈希、node_modules 采样、以及备份时的 profile 清单全文 ``` 备份与线上文件的一致性已核验(备份可用于回滚): ``` backup sha: 42F203F51A91CB95C90788EA5968F776697829A718DD26A76D61BF4C6650175E live sha: 42F203F51A91CB95C90788EA5968F776697829A718DD26A76D61BF4C6650175E patch backup sha: FFCDC71BD98DA961B829697CE34E4F36D5C85F7B07136A6C30D28EAD6EB8E574 patch live sha: FFCDC71BD98DA961B829697CE34E4F36D5C85F7B07136A6C30D28EAD6EB8E574 ``` ### 2.2 工作区内的工具与产物 | 路径 | 作用 | | --- | --- | | `deploy/mount.mjs` | 挂载执行器:**先备份** → 幂等写三处改动 → 后置校验(重新解析并核对两个键)。`--dry-run` 只打印;`--emit-expected ` 只生成目标清单 | | `deploy/precheck.mjs` | **只读**预检(可重复运行):A 插件包契约 / B schemastery junction / C profile 清单 / D node_modules 链接 / E 用 DSH 自己的 composer 合成行并与备份做无回归对照;输出结论 `VERDICT: READY` 或 `NOT READY` | | `deploy/expected-package.json` | 挂载后 `profiles/web/package.json` 的**逐字节目标**(由 mount.mjs 同一条代码路径生成) | | `deploy/_shadow/profile/` | 用目标清单 + 8 个 junction 模拟「已挂载」的 profile 副本,用于在**不碰真 profile** 的前提下证明预检会全绿(§5.2) | | `deploy/_raw/*.txt` | 全部原始输出(挂载尝试、预检线上/影子/当前、dry-run) | | `verify-independent/probe-6-autoplay-replay.mjs` | 新增独立探针:自动播放策略下「拦下的那一次其实是排期而非丢弃」(18/18,支撑 §9-F4) | --- ## 3. 精确改动清单(profile 侧仅三处) ### 3.1 `$DSH_HOME\profiles\web\package.json` —— 两个键 | # | 位置 | 操作 | 值 | | --- | --- | --- | --- | | 1 | `dependencies` | 新增一键(插在 pnpm 的字母序位置:`deepseek-harness-background` 之后、`dsh-quorum` 之前) | `"dsh-approval-chime": "link:/dsh-approval-chime"` | | 2 | `dsh.profile.bundles` | 追加**一个**元素(作为最后一层 patch) | `"dsh-approval-chime"` | 其余键(`name`/`private`/`patchReload`/其它 7 个依赖与 9 个 bundle)**一字不动**,顺序不变。挂载后的完整目标文件见 `deploy/expected-package.json`(也就是下面这段): ```json { "name": "dsh-profile-web", "private": true, "dependencies": { "@nanmicoder/dsh-agent-teams": "^0.1.18", "deepseek-harness-background": "^0.5.4", "dsh-approval-chime": "link:/dsh-approval-chime", "dsh-quorum": "link:/dsh-quorum", "dsh-quorum-panel": "link:/dsh-quorum-panel", "dsh-team-probe": "link:/dsh-team-probe", "dsh-whale-widget": "^0.3.0", "dshmarket": "^1.46.1" }, "dsh": { "profile": { "bundles": [ "@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "deepseek-harness-background", "dsh-whale-widget", "dshmarket", "@nanmicoder/dsh-agent-teams", "dsh-team-probe", "dsh-quorum", "dsh-quorum-panel", "dsh-approval-chime" ], "patchReload": "live" } } } ``` ### 3.2 `profiles\web\node_modules\dsh-approval-chime` —— 一个 junction ```powershell New-Item -ItemType Junction ` -Path '$DSH_HOME\profiles\web\node_modules\dsh-approval-chime' ` -Target '\dsh-approval-chime' ``` 形态与现存 workspace 插件一致(实测 `dsh-quorum`:`Mode=d----l, LinkType=Junction, Target=D:\...\dsh-quorum`)。 ### 3.3 `profiles\web\cordis.patch.yml` —— **不改**(0 处) 插件自带 `cordis.patch.yml` 已 `insert` 一行 `{id: dsh-approval-chime, name: dsh-approval-chime}`;bundle 层的 patch 会随 `dsh.profile.bundles` 自动生效。**再在 profile 的 patch 里插同一 id 会抛 `duplicate loader entry id` 直接炸启动** —— 所有层的 patch 会先被合并成一个数组、再一次性交给根 group(`@deepseek-ai/cordis-plugin-include/src/index.ts` 的 `applyPatches(...) → root.update(data)`),而 `@deepseek-ai/cordis-plugin-loader/src/config/group.ts:64` 对同一数组内的重复 id 直接 `throw new TypeError`(t1 已取证,本仓 probe-5 E.2/E.2b 复现命中)。因此这一处**刻意保持原样**,预检脚本会显式断言「profile patch 里没有该 id 的 insert」。 --- ## 4. 需要执行的操作(用户/有写权限者) ### 4.1 唯一需要手动做的事(一行) ``` 在真机终端执行:node dsh-approval-chime/deploy/mount.mjs 然后重启 dsh web(浏览器刷新一次页面) ``` 展开成可复制的两步(第 2 步才是「重启」这一手动动作): ```powershell cd '' node dsh-approval-chime/deploy/mount.mjs # 备份 → 写 profile(幂等,可重复跑) node dsh-approval-chime/deploy/precheck.mjs # 期望:VERDICT: READY(56/56) # 然后:停掉 dsh web,重新 dsh web,浏览器刷新一次 ``` `mount.mjs` 的输出(dry-run,实测原文): ``` [changes] • dependencies["dsh-approval-chime"] = "link:/dsh-approval-chime" • dsh.profile.bundles[] += "dsh-approval-chime" (appended as the last layer) • New-Item -ItemType Junction -Path "...node_modules\dsh-approval-chime" -Target "...\dsh-approval-chime" ``` 安全性质:幂等(已正确时只打印 note,不写文件);只改上面两个键,其余原样;写完立刻重新解析并核对;不跑 pnpm、不装包、不重启、不碰 `cordis.patch.yml`。 --- ## 5. 预检脚本与实测输出(acceptance #5) `node dsh-approval-chime/deploy/precheck.mjs` —— 只读、可重复、无副作用。检查项: * **A 插件包契约**:`package.json` 可解析、name/type、`dsh.bundle.patch`、`exports['.']`/`exports['./client']` 指向的文件存在、`lib/index.js` 与 `lib/client.js` 通过 `node --check`、插件 patch 只 insert 一行且 id/name 正确。 * **B schemastery junction**(capain 指定的前置项):junction 存在、`lstat.isSymbolicLink()`、`realpath(junction) === realpath($DSH_HOME/profiles/node_modules/@deepseek-ai/schemastery)`、且**从真实 `lib/index.js` 路径 `createRequire` 能解析并拿到可调用工厂**。 * **C profile 清单**:合法 JSON、`link:` 依赖(目标目录 realpath 相符)、`bundles` 恰好一次且在末位、`patchReload` 合法、与备份逐条对账(无回归)。 * **D profile node_modules**:junction 存在/形态/目标指向插件目录、解析后能看到 `package.json`/`lib/index.js`/`lib/client.js`/`cordis.patch.yml`;另检查 `dsh-quorum`/`dsh-quorum-panel`/`dsh-team-probe` 仍可解析(不破坏他人)。 * **E patch 层与合成行(DSH 自己的 composer)**:用 `@deepseek-ai/dsh-app-boot` 的 `loadProfileDirectory()` + `composeEntries()`(与启动时同一条代码路径)合成整棵行树,断言 **无重复 row id**、恰好一行 `{"id":"dsh-approval-chime","name":"dsh-approval-chime"}` 且未 disabled;并断言 profile patch 没有重复插入该 id、没有重复 id 目标、没有把它 disabled。 ### 5.1 线上现状(尚未挂载)—— 预期 NOT READY ``` === precheck: 34/42 checks passed === FAILED [C] profile declares the plugin dependency — undefined FAILED [C] the dependency spec is a link: to the workspace plugin dir FAILED [C] dsh.profile.bundles contains the plugin exactly once — expected 1, got 0 FAILED [C] the plugin is the last bundle layer (appended) FAILED [C] no extra bundle layer beyond the plugin — expected 1, got 0 FAILED [D] profile node_modules/dsh-approval-chime exists FAILED [E] the plugin is one of the resolved bundle layers — not found FAILED [E] the composed tree contains exactly one plugin row — expected 1, got 0 VERDICT: NOT READY (see failures above) ``` 即:**除了「还没挂载」这 8 条,其余 34 条(含 junction、JSON/YAML 解析、无重复 id、其它插件无回归)现在就已经全绿**。 ### 5.2 影子 profile(= 挂载后的预期状态)—— 56/56 READY 把 §3.1 的目标清单放进工作区影子目录并建 8 个 junction,用同一个脚本对其预检(真实 profile 零改动): ``` PS> node dsh-approval-chime/deploy/precheck.mjs --profile-dir dsh-approval-chime/deploy/_shadow/profile [PASS] the resolved bundle directory is the workspace plugin dir — …\dsh-approval-chime · composed top-level rows = 160 [PASS] the composed tree contains exactly one plugin row — expected 1, got 1 · plugin row = {"id":"dsh-approval-chime","name":"dsh-approval-chime"} === precheck: 56/56 checks passed === VERDICT: READY — the profile will mount dsh-approval-chime at the next `dsh web` start. ``` **这是本轮能得到的最强离线证据**:DSH 自己的解析器 + composer 在「挂载后」的清单上合成了 160 行顶层行、无重复 id、恰好一行本插件。影子目录只是为了证明,可随时删除。 --- ## 6. 回滚(两步,可随时执行) ```powershell # 1) 原样还原两个 JSON 键 —— 用备份文件覆盖(含 cordis.patch.yml/pnpm-lock.yaml,一并还原最保险) Copy-Item '\dsh-approval-chime\deploy\backup-20260915-191825\package.json' '$DSH_HOME\profiles\web\package.json' -Force Copy-Item '\dsh-approval-chime\deploy\backup-20260915-191825\cordis.patch.yml' '$DSH_HOME\profiles\web\cordis.patch.yml' -Force # 2) 删掉 junction(不影响工作区插件本体) Remove-Item '$DSH_HOME\profiles\web\node_modules\dsh-approval-chime' -Force # 3) 重启 dsh web ``` 可选清理:`$DSH_HOME\settings.yaml` 里的 `approval-chime:` 段(留着也无害:插件不挂载时没人读它)。 **临时停用**(不删依赖,只停插件行):在 profile `cordis.patch.yml` 末尾追加 ```yaml - id: dsh-approval-chime disabled: true ``` —— 注意是 **id 定向覆盖**,不是 `insert`(后者会因重复 id 炸启动)。 还原后自查:`node dsh-approval-chime/deploy/precheck.mjs` 应回到 `NOT READY`(8 条失败),且其它插件条目与备份逐条一致。 --- ## 7. 重启后验收清单(用户执行;听感与点击确认由用户完成) > 前提:已跑 `mount.mjs` 且 `precheck.mjs` 输出 `VERDICT: READY`;已重启 `dsh web`;浏览器打开 **`http://127.0.0.1:3080`**(必须是 loopback,否则卡片不渲染,见 §9-F1)并刷新一次页面。 | # | 步骤 | 通过判据 | 失败时先看 | | --- | --- | --- | --- | | 1 | 控制台执行 `window.__DSH_BOOT__.entries.filter(e => e.id === 'dsh-approval-chime')` | 返回 1 条(客户端行已进入模块图) | DSH 日志有没有 `failed to import loader entry dsh-approval-chime` | | 2 | 控制台执行 `window.__DSH_APPROVAL_CHIME__` | 对象存在,`.revision === 'rev-24 · muting draws the slash instead of moving the bell'`(**整串描述**,仍在控制台面)、`.revisionId === 'rev-24'`(**rev-21 新增,= 分区页那个构建戳的文本**,由 `.revision` 推导)、`.namespace === 'approval-chime'`、`.slot === 'settings.section'`(rev-10 另加 `.sessionSlot === 'conversation.session.header.actions'`;rev-11 另加 `.sessionWrites()`;rev-12 另加 `.sessionToneRows === 4`、`.pickerMetrics` 与 `.sessionIcon`;**rev-18 另加顶层 `.reduceMotion()`(rev-20 起只报告环境、不改变行为)**,见 B6) | 若为 `undefined`:bundle 未装载(看 §9 已知限制与 DSH 日志);`settings service unavailable` 说明 profile 缺 `dsh-settings` | | 3 | 设置 → **通知提醒**(独立的 settings.section 分区,在「插件」之后、「Agent 预设」之前) | 出现分区页:标题「通知提醒」+ 一行说明 + **「启用提示音」是苹果式拨动开关(38×22,开启=与音量条同色的蓝底白钮、关闭=灰底细边)** + **音量进度条**显示 70%,右上角构建戳**只印版本 id** `rev-24`(rev-21 起;描述整串 `rev-24 · muting draws the slash instead of moving the bell` 只在控制台 `.revision` 可读,= `.revisionId` 是戳的文本);同时**设置 → 插件 → 插件配置里不再出现本插件**(rev-7 起不注册那张卡片) | `settings.yaml` 是否出现 `approval-chime:` 段(判断 DSH 侧是否注册成功);再搜 DSH 日志 `dsh-approval-chime` | | 4 | 点分区页「试听」 | **能听到提示音**;「音频状态」变 `已就绪 · 已解锁` | 无声但状态 `未解锁` → 先在页面任意处点一下再试(自动播放策略) | | 5 | 点页面任意处(解锁音频)后,让 DSH 真实发起一次权限申请 | **响一次**;「已触发」+1、「上次触发」显示时间、「最近音色/音量」显示 `chime / 70% (gain 0.42)` | 面板出现但不响 → 看「已见审批」是否 +1:+1 而「已触发」不变=被开关/音量闸门挡住;都不变=U2 未成立(上报) | | 6 | 无听力条件下的等价判据(控制台) | `__DSH_APPROVAL_CHIME__.stats().triggers` 递增;`stats().lastGain === 0.42`;`audio().state === 'running'`;`preview()` 返回 `true` | `stats().suppressedPolicy > 0` 说明被自动播放策略拦下(见 §9-F4) | | 7 | 音量拖到 0 → 触发一次审批 | 不响;分区页出现「因音量为 0 而静音 ×1」 | — | | 8 | 关掉「启用提示音」→ 试听按钮变灰;再打开 | 打开时立即响一次(即时反馈) | — | | 9 | 改音量到 35 并刷新页面 | 分区页仍显示 35%;`$DSH_HOME\settings.yaml` 出现 `approval-chime:` 且含 `volume: 35` | 非 loopback 页面写入只进内存 | | 10 | **关键回归**:连续触发 3 次审批,每次用面板按钮正常「允许/拒绝」 | 面板按钮立即生效、无需二次点击;console 无本插件异常 | 若审批卡顿/需重复点击 → 立即按 §6 回滚(本插件不注册审批瀑布,理论上不可能) | ### 7.1 rev-10 新增手工步骤(按会话独立;rev-11 加了写入收敛;用户执行) > **前提(与 §7 不同)**:rev-10 改了 `lib/index.js`(新增 `/api/approval-chime/sessions` 路由与覆盖文件), > **必须重启 `dsh web`**,再刷新页面。只刷新页面会出现「铃铛在、但点它报错」——新的客户端半对上了旧的 DSH 侧, > 契约证据见 `docs/契约调研.md` §L.1/§L.3。冒烟自证(无需听力):控制台 > `await fetch('/api/approval-chime/sessions').then(r => r.json())` 应返回 `{ok:true, revision:…, sessions:{…}}`; > 返回 404 即 DSH 侧未重启。 > **rev-11 只改 `lib/client.js`**(DSH 侧零改动),所以:DSH 已经在跑 rev-10 及以后时,**刷新页面**即可拿到写入收敛。 | # | 步骤 | 通过判据 | 失败时先看 | | --- | --- | --- | --- | | L1 | 打开任意会话,看标题行(在「Agent 预设」胶囊与作业/日程动作的右侧、右侧工具区之前) | 出现一个**小铃铛**(**28×28** 无边框按钮、铃铛图标 22px —— rev-13 起比原来大 1.57 倍)+ 一个更小的 caret(**16×28**、chevron 11×16);铃铛是实心的(全局「启用提示音」为开) | 看不到铃铛 → 控制台 `__DSH_APPROVAL_CHIME__.slot/sessionSlot`;再确认 `sessionSlot === 'conversation.session.header.actions'`,且 DSH 侧已重启(L0 的 fetch 探针) | | L2 | 把鼠标停在铃铛上 | 悬浮提示**同时含中英两种文案**:`本会话审批提示音:开 · Approval chime for this session: on`(`aria-label` 与 `title` 同文) | — | | L3 | **铃铛两态**:点一下铃铛 | 图标变成**带斜杠的铃铛**、颜色变淡,提示文案变成「关 / off」;控制台 `stats().suppressedSession` 在该会话下一次审批时 +1 | `__DSH_APPROVAL_CHIME__.sessionSettings('').overridden.enabled === true` 可判「写进去了」;若为 `false`,看 popover 里的错误行 | | L4 | **静音一个会话**:把会话 A 的铃铛点成「关」,再让 A 发起一次权限申请 | **不响**;设置 → 通知提醒 的抑制行出现「因本会话提示音关闭而静音 ×1」 | 「已见审批」+1 而「已触发」不变 = 该会话确实被静音(这是预期,不是故障) | | L5 | **两个会话同时各响各的**:开两个会话(A 已静音、B 正常),让两者**先后进入待审批**(快照同一批) | B 响一次;若两个会话都未静音,则**各响一次、间隔约 180 ms**(两声而不是一声更响的) | `stats().lastBatchSize` / `stats().lastBatchPlayed` 可判批次规模与响了几次;`triggers` 逐次 +1 | | L6 | **每会话音色覆盖**:在会话 B 点 caret → 音色选「铃铛 bell」、音量勾掉「跟随全局音量」并拖到 30 | B 的下一次审批用 bell、音量 30(`stats().lastTone === 'bell'`、`stats().lastVolume === 30`、`lastGain === 0.18`);**会话 A 的设置页全局值不变** | popover 关闭后重开,选择应保持(数据来自 DSH 文件,不是页面状态) | | L7 | **存储位置**:`Get-Content "$env:DSH_HOME\approval-chime\sessions.json"`(未设 `DSH_HOME` 时为 `~\.dsh\approval-chime\sessions.json`) | 文件存在,形如 `{"version":1,"sessions":{"":{"enabled":false,"volume":30,"tone":"bell","updatedAt":…}}}`;**`settings.yaml` 里没有 per-session 内容**;浏览器 Application 面板里没有本插件的任何存储项 | 文件不存在 → DSH 侧未重启(L0 探针 404)或路由注册失败(DSH 日志搜 `per-session route`) | | L8 | **恢复跟随全局**:popover 里点「恢复跟随全局」 | 铃铛回到随全局的状态;`sessions.json` 里该会话的记录**消失**(空覆盖不落盘) | — | | L9 | **零回退**:重跑 §7 第 3/7/8/9 步 | 设置页(开关/音量/音色/导入/试听/恢复默认/3 行弹出列表)与本轮之前**逐项一致** | — | | L10 | **写入收敛(rev-11)**:对同一个会话的铃铛**快速连点两次**(第一次静音、第二次开回来),然后在控制台执行 `__DSH_APPROVAL_CHIME__.sessionWrites()` 与 `__DSH_APPROVAL_CHIME__.sessions()` | `outstanding === 0`;`sessions().sessions` 里该会话的条目与 `sessions.json` **一致**(连点两次后应为空 = 跟随全局);铃铛的显示与之一致 | 若两者相反:先确认拿到的是 rev-11(L2 的 `.revision`);`outstanding` 长期 >0 说明写入未落定(看 DSH 日志/网络面板) | > **上限与淘汰(无需真机即可验证)**:`sessions.json` 最多 200 条,超出时按 `updatedAt` 淘汰最旧; > 自测 `verify/host-half.test.mjs` 已用 205 条的种子文件断言「读回 200 条、最旧 5 条被淘汰」。 > > **写入收敛(rev-11,无需真机即可验证)**:`verify/client-half.test.mjs` §5i 用一个"应答可被扣住、由测试决定投递顺序"的 > 存储桩断言:同一会话连点两次、**先放第二个应答再放第一个**之后,本地表与桩里的存储逐字段一致、只重读一次、无在飞写入。 > 同一条自测在 rev-10 的字节上会报红(rev-11 的改法就是针对它)。 ### 7.2 rev-12 新增手工步骤(两处音色列表长一样;用户执行) > **前提**:rev-12 **只改 `lib/client.js`**(DSH 侧与 rev-11 逐字节相同),所以**只需刷新页面**; > 只有 DSH 还停在 rev-9 或更早时才需要按 §7.1 重启。这一节要看的是**像素**,沙箱里量不到(自测只能证明两处是同一份声明)。 | # | 步骤 | 通过判据 | 失败时先看 | | --- | --- | --- | --- | | P1 | 打开一个会话 → 点标题行铃铛旁的 **caret** → 展开**音色**下拉 | 列表与「设置 → 通知提醒」里那张**长得一样**:圆角外框、行是圆角高亮、选中行带 ✓、字与卡片那张一样大 | 若仍是方角灰底、无 ✓ → 页面还是旧 bundle:核对 §7 第 2 步的 `.revision`(应为 `rev-24 …`)并硬刷新 | | P2 | 同一张列表**默认**(未导入任何自定义音频)应显示 4 行:`跟随全局 / 风铃 chime / 铃铛 bell / 蜂鸣 beep` | 4 行**全部完整可见**;理想情况下**不出现滚动条**(若出现一条几像素的滚动条而 4 行都完整,属 H19 的可接受余量,请回报) | 末行被裁 → 回报并改 `TONE_PICKER_SLACK_PX`(`lib/client.js:252` 的常量,自测数字随之自动跟随) | | P3 | 对照设置页那张列表(同一次会话里两个页面切换看) | 两处的**行高、字号、高亮形状、配色**一致;设置页那张仍是**恰好 3 行**后滚动(rev-5 的验收点没变) | 设置页那张若变形 → 立刻回报(它不是本轮的目标,属于回归) | | P4 | 控制台 `__DSH_APPROVAL_CHIME__.pickerMetrics` | `{rowPx:28,textPx:13,slackPx:8,cardMaxPx:84,sessionMaxPx:120}` | 值不同 → 页面是旧 bundle | | P5 | 深色主题下再看一眼 P1(本机主题切换) | 列表底色/文字/高亮与卡片那张一致,不出现"白底黑字"的 UA 默认 | 若出现 → 记录主题名与截图(`@supports (appearance:base-select)` 不被支持时才会落到这条退路) | ### 7.3 rev-13 新增手工步骤(图标放大到你觉得合适为止;用户执行) > **前提**:rev-13 **只改 `lib/client.js`** 的常量与两个 SVG 属性(DSH 侧与 rev-12 逐字节相同),**只需刷新页面**。 > 这一节要判的是"够不够大"—— 只有你的眼睛能判,沙箱渲染不了(README §9 H20)。 | # | 步骤 | 通过判据 | 失败时先看 | | --- | --- | --- | --- | | B1 | 看任意会话标题行的铃铛 + caret(与截图里的旧尺寸对比) | 明显比之前大:按钮 28×28、铃铛图标 22px(绘制约 15px,旧的是 ≈9.5px)、caret 16×28;与同一行出厂 chip 的高度(28px)齐平 | 没变大 → 页面还是旧 bundle:核对 §7 第 2 步的 `.revision`(应为 `rev-24 …`)并硬刷新 | | B2 | 控制台 `__DSH_APPROVAL_CHIME__.sessionIcon` | `{bellBoxPx:28,caretBoxPx:16,bellGlyphPx:22,caretGlyph:'11x16',bellGapPx:6,caretOpenRotateDeg:90,caretRotateMs:160,onBackground:'var(--dsw-alias-state-business-primary,#2563eb)',onForeground:'#fff',onBackgroundHover:'color-mix(…)'}` | 值不同 → 旧 bundle(`caretOpenRotateDeg` / `caretRotateMs` 是 rev-17 新增的两个字段;**rev-20 把 `caretRotateMs` 定在 160** —— rev-19 是 400、rev-18 是 300、rev-17 是 160;缺这两个字段说明页面还是 rev-16 及更早的 bundle,`caretRotateMs === 400` 说明是 rev-19 的 bundle、`=== 300` 说明是 rev-18 的 bundle) | | B3 | 还嫌大/嫌小 | 回复一句"再大一点/小一点"即可:实现只需改 `BELL_BOX_PX` 与 `BELL_GLYPH_PX`(`lib/client.js` 顶部常量),自测数字从 `sessionIcon` 读、自动跟随 | 自测里"≥20px / >1.4×"两条是**绝对下限**,缩回旧尺寸会报红 —— 这是故意的 | | B4 | 顺带确认没挤坏这一行:把鼠标移到铃铛上、点开 caret | 悬停有 8px 圆角胶囊底色;popover 仍贴在铃铛下方(不会被更大的按钮顶偏或裁切);右侧工具区没被推挤 | popover 位置若偏 → 记录截图(`popoverPosition` 用的是铃铛外框的 `getBoundingClientRect`,本轮没有改它) | | B5 | 点开会话标题行的 **caret**,盯住那个箭头(rev-17 的转动;**rev-20 起任何环境下都是同一段 160ms**;rev-18/rev-19 曾放慢到 300/400ms) | 箭头**以过渡动画下转 90°**(指向下)、关掉弹层时**转回原位**;这一段的时长就是 `CARET_ROTATE_MS = 160ms`,**产品里没有 reduced-motion 例外**(`@media (prefers-reduced-motion` 在产品里出现 **0** 次、`transition:none` **0** 次),所以本页即使处于 reduce 环境,看到的也应当是**同一段**转动;动的是字形本身,按钮那 16×28 的格子**不位移、也不参与旋转**(`getComputedStyle(按钮).transform === 'none'` 在任何状态都成立) | **完全不转 / 瞬间跳变** → 先核对 §7 第 2 步的 `.revision`(应为 `rev-24 …`)并**硬刷新**(旧 bundle 可能停在 rev-19 的 400ms;若是 rev-18/rev-19 的 bundle,则那两版在这条环境下会把过渡整条去掉、只剩 90° 终态 —— **已退役的旧行为**);刷新后仍不动,就记下 `.revision` 与 `sessionIcon.caretRotateMs` 回报。**"看不看得见 / 快不快 / 顺不顺 / 会不会太抢眼"只有真机能定** —— 沙箱没有浏览器引擎,过渡插值、合成层与两种主题下的观感一律未证 | | B6 | 控制台 `__DSH_APPROVAL_CHIME__.reduceMotion()`(**rev-18 新增的顶层诊断;rev-20 起只报告环境、不改变行为**) | 返回值只回答"本页是否命中 `(prefers-reduced-motion: reduce)`":`true` 或 `false` **都不改变转动** —— rev-20 起任何环境下播放的都是**同一段 160ms**(产品里 `@media (prefers-reduced-motion` 出现 **0** 次,自测另有一条守卫钉住"任何环境下都不给 caret 加覆盖")。所以"看不到转动"**不再有"环境要少动效"这个解释**:先确认 `.revision === 'rev-24 · muting draws the slash instead of moving the bell'` 并**硬刷新**;刷新后仍不动再回报(`sessionIcon.caretRotateMs` 应为 **160**) | 报 `TypeError: ... is not a function` → 页面还是 rev-17 及更早的 bundle(这条诊断 rev-18 才有),硬刷新。它**只在控制台可读**,不是几何:`sessionIcon` 里**没有**这个键(有了一条就说明它被错误地塞进几何快照了)。**披露**:旧叙述(rev-18/rev-19)曾写"`true` = 环境要求少动效,瞬跳是**设计**" —— 那是**当时**的策略、**已退役**,不要按它排查 | ### 7.4 rev-14 新增手工步骤(铃铛的蓝色;用户执行) > **前提**:rev-14 **只改 `lib/client.js`** 的 3 个颜色常量与 2 条 CSS 规则(DSH 侧与 rev-13 逐字节相同),**只需刷新页面**。 | # | 步骤 | 通过判据 | 失败时先看 | | --- | --- | --- | --- | | C1 | 看一个**会响**的会话标题行 | 铃铛按钮是**实心蓝底 + 白色铃铛**,蓝与「设置 → 通知提醒」里那个**开启状态的开关**、以及**音量条**是同一种蓝(同一个令牌) | 还是灰的 → 旧 bundle:核对 §7 第 2 步的 `.revision`(应为 `rev-24 …`)并硬刷新 | | C2 | 点一下铃铛(静音这个会话) | 蓝色**消失**,回到透明底 + 灰色带斜杠铃铛(蓝色只代表"这个会话会响");再点一下恢复蓝色 | 静音后仍是蓝的 → 这是缺陷,回报(自测里有一条"静音态在整张样式表里没有任何 background"就是防它) | | C3 | 鼠标悬停在蓝铃铛上 | 蓝色**略微变深**(`color-mix(… 86%, #000)`),不是变成灰色胶囊 | 悬停变灰 → 回报(会响态有一条独立的 `:hover` 规则防这个) | | C4 | 深浅两个主题各看一次;想换蓝的浓淡 | 蓝底白铃铛在两种主题下都清楚;若要换色只改 `BELL_ON_BG`(`lib/client.js` 顶部常量),自测会拿它去比对开关与音量条两条规则,不会让三处漂移 | 白铃铛在蓝底看不清 → 记录截图(`#fff` on `#2563eb` 约 4.8:1) | ### 7.5 rev-22 → rev-24 新增手工步骤(铃铛现在**不动**了:静音时画一道斜杠;用户执行) > **前提**:rev-22 / rev-23 / rev-24 **都只改 `lib/client.js`**(DSH 侧与 rev-21 逐字节相同),所以**只需刷新页面**。 > 这一节要看的是**动效本身** —— 沙箱没有浏览器引擎,本仓里没有任何探针观察过真正被画出来的那一帧 > (`probe-13` 是本轮唯一非零的探针,它就是那个"没有引擎"的读数)。能证的只是"哪条规则上膛、 > 关键帧文本是什么、`data-draw` 什么时候为真、`key` 变了、铃铛本体没有位移、笔画画在铃铛后面"。 | # | 步骤 | 通过判据 | 失败时先看 | | --- | --- | --- | --- | | R1 | 控制台 `__DSH_APPROVAL_CHIME__.sessionIcon` | 除原有的几何字段外多了两个:`bellMuteMs === 240`、`bellSlashLen === 15.27`(rev-24 的当值;rev-22 那两个字段名是 `bellRing*`、rev-23 是 `bellTilt*`,**都已退役**) | 缺这两个键 → 页面还是 rev-23 及更早的 bundle:先核对 §7 第 2 步的 `.revision`(应为 `rev-24 …`)并**硬刷新** | | R2 | **首帧必须静止**:刷新页面后**什么都别点**,盯着会话标题行那个铃铛 | 铃铛**一动不动**(载入不播)。这是构造上的:`data-draw` 在第一次点击之前是 `"false"`,四条动画规则全部挂在 `[data-draw="true"]` 上 | 一刷新就动 → **这是缺陷,请回报**(自测里有断言钉住"首帧属性必须是 `false`") | | R3 | **静音**:点一下铃铛(关掉本会话提示音) | **铃铛本体一点都不动**(不斜、不缩放,按钮盒也不位移:`getComputedStyle(按钮).transform === 'none'`);动的是一道**斜杠从左上端画到右下端**(`stroke-dashoffset` 由 15.27 走到 0,**240ms**,曲线 `cubic-bezier(0.22,1,0.36,1)`);同时蓝底**逐渐变暗到消失**(同一个 240ms) | 铃铛在晃 → **这是缺陷**(rev-24 已把 rev-22 的抖动与 rev-23 的倾斜整段删除);斜杠是"整条突然出现" → 说明动画没上膛 | | R4 | **取消静音**:再点一下 | 斜杠**由左上往右下扫走**(`stroke-dashoffset` 由 0 走到 −15.27,**同样 240ms**,曲线 `cubic-bezier(0.42,0,0.58,1)`),蓝底**逐渐变暗再回到蓝**;两个方向的时长**必须一样** | 回来时"啪一下就没" → 回报(用户明确要求两个方向等长) | | R5 | **图层**:静音状态下盯住斜杠与铃铛轮廓的交界 | 斜杠**从铃铛后面穿过**(SVG 没有 z-index,绘制顺序就是那个 paths 数组:斜杠先画、铃铛后画)。**斜杠压在铃铛上面就是缺陷**(用户报过) | 斜杠盖在铃铛上 → 回报(自测按源码顺序 + 渲染树顺序双重钉住) | | R6 | **静止态不许有墨**:取消静音后盯着铃铛看 | **一处墨都没有**:右下角**没有点**、左上角**没有短线**。这两条路都走过:`dashoffset` 会把圆头端点停在路径末端(一个点),再推一个长度又会把下一段重复拉进另一端(一条短线);现在静止态是用 `opacity:0` 隐藏的 | 看到点或短线 → **这是缺陷,请回报** | | R7 | **hover 不动**:把鼠标在铃铛上来回移几次(不要点) | 只有 hover 胶囊底色变化,**没有任何动画**(切换时才上膛,注意时不上膛) | hover 就动 → 回报 | | R8 | 想换个时长 | 只改一个常量:`BELL_MUTE_MS`(240),在 `lib/client.js` 顶部;**四条规则**(画 / 扫走 / 变暗 / 回蓝)都由它拼出,改一处即全部 | 只有其中一个方向变了 → 说明有人把毫秒抄成了字面量(自测里有"只允许一处声明"的守卫) | | R9 | **减少动效环境下的同一件事**(可选) | 本插件**没有**为斜杠加 `prefers-reduced-motion` 降级:产品里 `@media (prefers-reduced-motion` 出现 **0** 次、`transition:none` **0** 次;`reduceMotion()` 只报告环境、**不改变任何行为** | 在 reduce 环境下"完全不画" → 页面不是 rev-24 的 bundle,先核对 `.revision` | > **本节的边界(不许当已证)**:240ms **读起来快不快**、蓝底变暗的**观感**、斜杠在 22px 图标上 > **看不看得清**、笔画穿过铃铛时**像不像"从后面过"** —— 这些只有真机能定(这段时长用户已经给过四轮反馈, > 历史原样记在常量旁边)。自测能证的只是"四条规则由同一个常量拼出、静止态零墨、笔画在铃铛后面、 > 首帧不上膛、点击才动、旧命名一处不剩"。 > **沿革(历史,原样保留)**:rev-22 是"响一下"(`BELL_RING_MS = 420` / `BELL_RING_DEG = 14`, > 关键帧 `[[0,0],[15,1],[35,-0.79],[55,0.5],[75,-0.29],[100,0]]`);用户说「再换一个要有高级感」, > rev-23 换成**一次倾斜并落定**(`BELL_TILT_MS = 560` / `BELL_TILT_DEG = 9`,带 scale 轨, > `@keyframes dacBellTilt`);用户接着说**「不要晃动,静音时把斜杠重左上拉到右下的动画」**, > 于是 rev-24 **把上面两版的动作整段删掉**,改成"画斜杠 + 蓝底变暗"。 > 那两版的常量名**在活产品里一处不剩**(自测里有一条守卫专门 grep 它们)。 --- ## 8. `dsh plugin …` CLI 语义实测(README §5 更正依据) 任务要求先独立确认该子命令是否真实存在及其语义。结论:**存在,但它不是「安装插件」的完整步骤**。 实测源码 `\node_modules\@deepseek-ai\dsh\lib\plugin-Ddi42qoW.js`: ```js // 文档注释原文: // `dsh plugin --profile ` — profile plugin management as a // thin pnpm forwarder: initialize the profile on first use, run // `pnpm ` in the profile directory, then reconcile the // `dsh.profile.bundles` layer list against the installed state … const result = spawnSync("pnpm", args.map(...), { cwd: dir, stdio: "inherit", shell: process.platform === "win32" }); … if (exitCode === 0) reconcilePlugins(before, dir); ``` * `dsh plugin --profile web ` = **把参数原样转发给 profile 目录里的 `pnpm`**;成功后按「已安装依赖」重算 `dsh.profile.bundles`(`exportsPatch()` 用 `resolveBundleDir` 判断某依赖是否声明 `dsh.bundle.patch`,是且不在 bundles 里就追加)。 * `dsh --help` 的示例也是 `dsh plugin --profile tui add `。 * 因此 **`dsh plugin --profile web install` 只是 `pnpm install`**:它本身**不会新增依赖**;只有当 `package.json` 已声明我们的 `link:` 依赖时,才会顺手建 junction(并把缺失的 bundles 项补上)。README §5 原文把它放在「步骤 1 已手改 package.json」之后,语义上勉强成立,但**作为「挂载步骤」的表述不准确**,已在 README 里更正并加了警示块。 * 能一条命令完成三件事的正确形式是:`dsh plugin --profile web add "link:D:/…/dsh-approval-chime"`。**本任务没有采用**:它会跑 pnpm、改写 `pnpm-lock.yaml` 并重排 `node_modules`(对正在运行的 DSH 来说副作用大于收益),而等价的手工/脚本三处改动风险更小、可逐字节复核。 --- ## 9. 已知行为与限制(用户须知;均不构成验收失败) | # | 事项 | 说明与应对 | | --- | --- | --- | | F1 | **非 loopback 页面分区页渲染为空(导航行仍在)** | 平台在非 loopback 下 `persistence='memory'`,命名空间拿不到 `status==='ready'` → 分区组件返回 `null`(不会有"内存保留"提示行)。rev-7 起该槽是插件自己的 `settings.section`,所以导航行照常出现、只是页面为空。**验收必须用 `http://127.0.0.1:3080`。**(t4 评审探针 B 实测;本仓 probe-3 §c 同样证明 `status!=='ready'` ⇒ 渲染 `null`) | | F2 | **一次鼠标拖拽可能写两次同一个值** | 释放时 `pointerup` 与 `mouseup` 都会提交,而草稿要等写成功才清空,故同一 `set('volume', same)` 可能出现两次。平台写队列串行化并携带 `pendingRevision`(`client-ui-settings` 的 `mutate()`),被 revision 栅栏拒绝也只是 `recover()` 后正常 resolve,故语义等价于一次、不会弹错。(probe-3 实测记录 `[{volume,60},{volume,60}]`;probe-5 C.4d–f 为平台侧证据) | | F3 | **`uiSession` 是硬 inject** | 浏览器侧 `inject=['slots','locale','settingsScope','uiSession']`:任一服务缺失,cordis 不会 apply,**提示音与分区页会一起不挂载**。当前目标 profile 不会触发:`dsh-web-app` 已挂 4 个 provider(`ui-session`/`ui-renderer`/`dsh-client-locale`/`ui-settings` 各自提供),且内置 `ui-approval` 同样 `inject` `uiSession`(probe-5 H7/H7f/H7g 有 `file:line`)。适用边界:仅在 DSH 改名/移除某服务时才会出现"整体不挂载"。 | | F4 | **未获得用户手势时,被自动播放策略拦下的那一次会"迟到"响** | probe-6 实测:锁定状态下提示音**不是被丢弃,而是已建图并排期**(振荡器 `start()` 在冻结的 `currentTime`),解锁路径只 `resume()`、不重建;因此首次成功 resume 时那次声音会被听到。计数语义:该次同时 `triggers +1` 与 `suppressedPolicy +1`(分区页同时显示"已触发"和"被浏览器自动播放策略拦下 ×1")。**应对:重启后如果第一次没听见,先点一下页面或点「试听」解锁音频。** | | R1 | 去重表上限 256(t3 登记) | 被淘汰的 key 若仍在某次快照里,理论上会再响一次;UI 会话里不可达。 | | R2 | 同一次快照里的多个新审批只响一次(t3 登记) | 实现明示的批处理;验收只要求「同一次审批只响一次」。 | | R3 | H3 遮蔽属 DSH 限制(t3 登记) | 每个 session 只暴露一个 pending(按 precedence 取大者),同会话并发提问+审批时审批可能被遮蔽而漏响。 | | R4 | `dsh plugin --profile web install` 不是挂载步骤 | 见 §8;README 已更正。 | --- ## 10. 本任务实际做/未做的事(声明) **做了**:profile 现状备份(工作区内,含 package.json / cordis.patch.yml / pnpm-lock.yaml + 清单);挂载脚本(dry-run 已验证,真实运行到写 profile 那一步被沙箱拒绝);只读预检脚本(线上 34/42 = 未挂载的预期状态;影子 56/56 = 挂载后的预期状态);期望清单逐字节目标;CLI 语义实测;README 三处更正(F1/F2/§5 CLI + §3 H12 行);新增 probe-6(自动播放补响机制,18/18);本文件。 **没做**(且明确不做):**未重启 `dsh web`**(会终止当前会话;重启是用户的手动动作);**未写 profile**(沙箱 EPERM + 提权被拒,profile 三份文件哈希与备份逐字节一致可证);未跑 pnpm / 未装任何包;未改 `lib/**` 与 `verify/**`。 (⚠️ **历史时点标注(rev-10 审查 OBS-2)**:上面这条声明属于**本任务(rev-4 时点)**的事实——当时的 `lib/**` 哈希是 `E5E2008A…`/`1F3E5B60…`。 rev-7 之后这两个文件当然已经变了(rev-8…rev-13 每一轮都改过 `lib/client.js`);**当前字节以 `CHANGELOG.md` 的 rev-24 条目为准**。) --- ## 11. 复现命令速查 ```powershell cd '' # 预检(只读,随时可跑):现在应 NOT READY;挂载后应 READY node dsh-approval-chime/deploy/precheck.mjs # 影子(挂载后状态的离线证明) node dsh-approval-chime/deploy/precheck.mjs --profile-dir dsh-approval-chime/deploy/_shadow/profile # 挂载(需要写 profile 的权限;幂等) node dsh-approval-chime/deploy/mount.mjs --dry-run # 只看改动 node dsh-approval-chime/deploy/mount.mjs # 备份 + 应用 # 自动播放补响机制(独立探针,18 项) node dsh-approval-chime/verify-independent/probe-6-autoplay-replay.mjs ```