# dsh-sym 项目约定 > 本文件是**项目级**指令,叠加在用户级 `~/.dsh/AGENTS.md` 之后,**项目内的要求优先**。 > 上游依据:`docs/01-architecture/project-architecture-and-requirements.md`(需求与架构基线) > 与 `docs/04-delivery/project-plan-task-charter.md`(任务书)。 > 阈值与公开契约只在 `.project-architect.json` 维护,**不要在本文件另立一套**。 ## 这是什么项目 `dsh-sym`(symbiote,共生体)是给 DeepSeek Harness 接的界面读数层:把 DSH 界面**已经存在 但未呈现**的数字变成可用读数,对**反复手动做的事**给一键。名字刻意不限定功能范围。 **不要**用"插件叫 sym 所以只能做计费"这类理由限制范围;也**不要**因为加了新能力就改名。 ## 动手前必须知道的边界(已证实,勿重复投入) 以下结论都经过实测,**不要重新验证**;要推翻必须先给出新证据: 1. **不能新增客户端 → 宿主的 Remote 通道。** 客户端能调的命名空间由 `dsh-api-remotes` 在**构建期**选定,插件加不了新的。`ctx.typert.register()` 在宿主侧能注册成功,但客户端 那一半挂载不了,且客户端自挂载要求**生成的严格 codec**。 → 任何"界面点一下 → 宿主做一件本没有的事"的想法,**先确认有没有现成 Remote**。 → 会话移动(`lib/move-session.js`)就死在这里;右键菜单能活是因为 `session.openWorkspacePath` 本来就存在。 2. **`dynamicCordisRunner` 不是通道。** 它的沙箱刻意禁用 fs / 网络 / 进程 / 定时器 (引导到 `ctx.fs` 等 Cordis 服务),而且语义是"定义并运行一个插件",不是"执行一次动作"。 3. **宿主代码不热重载。** 改 `lib/host-v*.js` 后必须走: **停用插件 → 换新文件名 → 同步引用 → 启用**。直接改内容或只改文件名都不会生效(模块缓存)。 换名与同步引用已经脚本化:`node scripts/reload-host.mjs --dry-run` 先看要改哪些行, 加 `--apply` 才真正改名;脚本**不**碰停用/启用(那两步由人做)。 每次这样做都会在宿主内存里留下旧模块注册,**重启 App 可清**。 4. **`single` 型插槽是替换,`list` 型才是加法。** 接管 `single` 会丢掉官方行为 (例如账户菜单里的"退出登录")。能加法就不要替换;确实要改视觉时优先 CSS (品牌行的峰谷标记就是 `::after`,没有接管任何组件)。 5. **视觉常量必须先查官方源码。** 面板配色曾因猜测 `--dsw-alias-bg-elevated` 而与官方不一致, 正确值是 `--dsw-specific-menu`。官方弹层的标准配方: `--dsw-specific-menu` + `--dsw-elevation-prominent` + `--dsw-radius-lg` + `--dsw-menu-backdrop-filter`。 - **头部控件的"形制"也有现成样板**:`dsh-client-ui-open-in-app` 的分体按钮是 `border:.5px solid var(--dsw-alias-border-l4)` + `border-radius:var(--dsw-radius-sm)` + `height:24px`。 Git 胶囊(`.dshGit_root`)就是抄这一套,所以它看起来是那排原生控件的一员。 - **图标(`QUICK_ICONS`)的尺寸必须是"整数倍 + 落在像素栅格上"**(2026-10-09 修): 这 105 枚是按 **16 格 viewBox + 1.3 描边**手绘的,而**坐标多是 0.8 的倍数**(0.8 = 16/20, 即设计格其实是 20 分割)。所以对这个集合:`0.8 × 缩放 × DPR = 设备像素` —— 在 2x 屏上 缩放 **1.25(= 显示 20px)** 才让坐标落在**整数设备像素**上。 竖条原来写的是 **19.8px**(1.2375 倍)=非整数缩放 → 坐标落在不规则子相位 → 看着"糊"; 现已改成 20px,设置页的图标格也从 24px(1.5 倍)改成 20px。 **规则**:改图标尺寸只走"设计格的整数倍"(20px 一档最稳),别再写 19.8 / 24 这类值; 竖条的间距/内距也回到 4 的刻度(原来是 4.4 / 5,既分数又不在刻度上)。 ⚠ 本条只解决**对齐**;若仍觉笔画发毛,下一个杠杆是**描边重量**(1.3 → 1.2/1.5), 那属于品味判断,别擅自定。 - **图标有"大小校正表"`ICON_FIT`(2026-10-09 加)**:这 105 枚是分批手绘的,墨迹范围从 7.4 到 15.8 格不等(极差 8.4)、还有 15 枚墨迹中心偏格心 >0.6 格 → 排在一起大小不一、 看着不齐。校正规则:按**墨迹包围盒**(含半个描边,曲线采样展平、圆弧转中心参数化) 把较大边归一到 **12 格 × ZOOM**、并把墨迹中心回正到 (8,8),缩放系数夹在 **[0.75, 1.30] × ZOOM**。归一后极差 **8.40 → 2.39**。 - **`ZOOM`(整体缩放的唯一旋钮)**:1.0 原始 → **1.2**(用户:"整体再大 20%")→ **1.08**(同日用户:"所有图标缩小 10%",即 1.2 × 0.9)。它同时乘到归一目标上, 所以每次调整都是**全集合均匀**缩放(实测 105/105 枚比例一致,偏差 ≤0.0006 的三位小数取整)。 **渲染框仍是 20px**,不是把 SVG 放到 24px —— 所以像素栅格对齐不会被破坏; 笔宽也照旧不变(渲染侧除以 k 抵消)。**改这个值只需重跑生成器**, 测试里的断言已写成"与 ZOOM 无关"(只查收敛性与边距),不用跟着改。 - **第二次校正(同日,用户:"这时再去校正一下大小差距")**:放大后差距重新可见,来源有两处, 都已收掉 —— ① 原夹取上限 `[0.75,1.3]×ZOOM` 把 `dot`(11.54) / `square`(14.04) 挡在目标外, 现放宽到 **[0.7, 2.0]**(不再对任何一枚生效,只当保险);② 补上**光学补偿**: 圆形为主的字形按规范外扩 **+2%**(`ROUND_BUMP`,判定规则=存在直径 ≥ 85% 墨迹较大边的 `circle`,实测 8 枚:compassRose / treeRings / dot / help / clock / ring / info / globe)。 结果:校正后墨迹 **14.39 ~ 14.69,极差 0.298**(原 8.40 → 上一步 2.86 → 0.30); 随后 ZOOM 由 1.2 调到 1.08,整簇等比缩到 **12.95 ~ 13.22**(最大/最小 1.021,关系不变)。 - 表由 `scripts/fit-icons.mjs` 生成(`--write` 写回 `lib/client.js` 的 `#region ICON_FIT`), **改了 `QUICK_ICONS` 的图形数据就要重跑**,否则校正值会与新形状不匹配。 - **笔宽不受影响**:渲染时按 k 缩放,同时把描边写成 `QUICK_ICON_STROKE / k`, 组内 transform 再乘回 k → 屏幕上仍是原笔宽。守卫在 `test/client.test.mjs` (覆盖全 105 枚 + 系数区间 + 笔宽守恒 + 抽查 square 放大 / infinity 收小 / ring 不动)。 - ⚠ 只做"尺寸归一",**没做光学家族补偿**(规范 `V3.3`:圆形应外扩 2–4%、三角形 6–10%)。 要更讲究可以再加,但那属于品味判断。 - ⚠ **`--dsh-content-font-delta` / `--dsh-content-font-delta-secondary` 是否真有值,没有被证实 (2026-10-09 查)**:这两个名字只在外壳 CSS 里被 `var()` **引用**,在 `@deepseek-ai` 插件包、外壳的两个 CSS 资源、`index.html`、外壳的两个 JS 资源里都**找不到定义**; 但 `--dsw-alias-*` 同样找不到定义 —— 说明主题令牌是**运行时注入**的,所以"找不到定义"不等于不存在。 → 结论与约定:**别拿它当"自适应"的依据**(`.dshSymTurnCost` 里那句 `height:calc(28px + var(--dsh-content-font-delta,0px))` 很可能恒等于兜底 0); 要跟字号自适应就用**不依赖令牌**的写法:`min-height` + 内容撑高(Git 胶囊现在就是这么做的)。 ## 接口契约(**改动前先读这里**) **这一节是审计 `AUD-TEST-001`(S1)的整改产物。** 在那次审计中,同一个 AI 在同一会话内 对下面这些接口**连续误判 6 次**——因为契约此前只存在于实现里。**签名与返回契约以实现为准, 本节是它的可读副本;发现不一致时先改本节。** ### `lib/host-v19.js` ```js isPeakTime(ms, holidays = new Set(DEFAULT_HOLIDAYS)) -> boolean // 峰段:北京时间周一至周五 09:00–12:00、14:00–18:00(含起点、不含终点) // 周末与 holidays 中的日期全天为 false。holidays 是 'YYYY-MM-DD' 字符串集合。 expandQuoteMarks(messages) -> 新批次 | null // ⚠ 无标记、或入参不是数组时返回 null(调用方保留原 decision 对象)。 // ⚠ message.content 必须是【块数组】:[{ type:'text', text:'…' }]。 // 传字符串会被整条跳过,表现为"没改动"。 // ⚠ 只有**成功替换过**才返回新批次;标记的 id 解析不到时返回 null // (AUD-LOGIC-001 的整改,2026-09-30 落地)。 proseOfMessage(message) -> string | null // ⚠ 入参是【message 对象】,不是 id。 // 从 content 中按顺序取 type==='text' 的块,用 '\n\n' 连接; // 忽略 reasoning 与 tool-call 块。无文本块或入参为 null 时返回 null。 rememberProse(id, prose) -> void // 按 id 存入引用索引(重复 id 会先删后插,保持"最新")。 // 上限 QUOTE_INDEX_LIMIT = 400,超出淘汰最旧的。 readProcessMemory() -> { rss, heapUsed } | null // 无 process 时返回 null。 PROJECTION_KEY / QUOTE_MARK_PREFIX / QUOTE_MARK_ID_LENGTH / QUICK_ACTIONS_KEY / GIT_STATUS_ROUTE // 跨端契约常量,两端各有一份拷贝(不能共享模块),由 // test/contracts.test.mjs 断言两边相等。见下面「公开契约」。 parseGitStatus(stdout) -> { branch, detached, head, upstream, ahead, behind, files, untracked } // 入参是 `git status --porcelain=v2 --branch` 的 stdout。 // ⚠ 只看行首:`1 `/`2 `/`u ` 计入 files,`? ` 计入 untracked,`! ` 忽略。 // ⚠ `(detached)` 时 branch=null、head=短 sha;没有 upstream 时 ahead/behind 是 null(不是 0)。 parseNumstatTotals(stdout) -> { files, added, deleted } // 入参是 `git diff --numstat` 的 stdout;二进制侧是 `-`,不计入行数。 parseBranches(stdout) -> [{ name, current }] // 入参是 `git for-each-ref --sort=-committerdate --format=%(refname:short)%09%(HEAD) refs/heads`。 // **当前分支排最前**,其余保持 git 的近因序。 createGitStatusReader(ctx) -> { read(cwd, signal), switchBranch(cwd, branch, signal) } // 需要 ctx.subprocess // read 解析 `{root,branches,branch,detached,head,upstream,ahead,behind,files,added,deleted,untracked}`; // 不是 git 仓库 / 没有可用 git 时返回 null。 // 同一 cwd 0.7s 内复用,并发合流;status 与 diff 并行,toplevel 只读一次、分支清单 4s 内复用; // 单次稳态读数 ≈2 条 git 命令 ~40ms。git 命令带超时、输出上限与清洗过的环境。 // ⚠ switchBranch 是**本插件唯一的写操作**(CR-0002):先与本地分支清单白名单比对, // 再用 argv 执行 `git switch -- `;不用 -f / --discard-changes / stash / reset, // git 拒绝时把 stderr 首行原样回传(`{ok:false, code:'refused', message}`), // 成功后失效该 cwd 的读缓存并回一份新读数。 ``` ### `lib/client.js` ```js formatCny(value) -> string // 金额文本(货币符号由语言包的 amount 负责) pickTurn(turns, activeTurn) -> turn | null describeScope(scope, t, title) -> string // 悬停账单的多行文本 readActiveTurn() -> number | null // 读右侧刻度当前选中项 quoteMark(messageId) -> string // 客户端写出的引用标记(端到端由契约测试守卫) describeGitStatus(data, t) -> { branch, branches, files, hasTotals, added, deleted, untracked, title } // 宿主载荷 → 胶囊要画的字段;纯函数,单测覆盖(分支清单过滤、detached 回退、千位分隔)。 // 组件 GitStatusCell 只用它;`data-git-branches` 只有分支数 > 1 时才出现在按钮上。 registerSlotCell(ctx, name, id, order, component, extra?) -> disposer | null // ⚠ 同一实例内重复 id 会被**跳过并返回 null**(AUD-OPS-001 的整改)。 // ⚠ 注册表拒绝时吞掉异常、记录一条降级日志,返回 null——不连累其他插槽。 // ⚠ `extra` 合并进注册选项;传 `{ locale: null }` 等于**删掉** locale 字段。 // 它是"要不要给条目注入 `t`"的开关,**不控制渲染** —— 见下面「槽的 locale 声明」。 installSessionMenu(ctx) -> void // 侧栏会话「⋯」菜单的两项。槽是 list 型,**纯加法**(官方四项 100/200/300/400 不动): // `sym-copy-session-id` (order 900,带 `separatorBefore` → 自带官方发丝分隔线) // `sym-export-session` (order 901) // ⚠ 导出项写在 `ctx.inject(["sessionLogDownload"], …)` 里:**服务拿不到就根本不注册**, // 不是"注册一个渲染 null 的空条目"(那会在菜单里留一条看不见的空行)。 // ⚠ 两项的槽组件都以 `slotProps.sessionId` 为准;文案走 `tr(props.t, …)`。 copyPlainText(primitives, text) -> Promise // 官方 `writeClipboard` 优先(它把失败折成 `false`),否则回落裸 `navigator.clipboard.writeText` // (它用 reject 表示失败,这里也折成 `false`)。两条路径统一成一个布尔值。 CopySessionIdMenuItem / ExportSessionMenuItem(props) -> element | null // props:`{ sessionId, useMenuOpenState, t }`,导出项另加 `{ useSessionLogDownload, request }`。 // ⚠ **所有 hook 必须在任何 return 之前** —— 槽组件的老坑(见下面「槽组件里所有 hook…」)。 // ⚠ 两者都在 primitives(`MenuItemButton`)缺失时返回 null。 // ⚠ 复制项:**成功才关菜单**;失败时保持菜单打开并把文案换成 `copySessionIdFailed` —— // 关掉菜单会让"点了但剪贴板是空的"变成静默欺骗。 ``` ### 降级日志(`noteDegrade`) 两端各有一个 `noteDegrade(where, detail)`:把"安静退场"的原因写到控制台, **每个来源只记一次**(上限 40 条),所以不会随渲染刷屏。 看到 `[dsh-sym] 降级:…` 就是某个可选能力主动缺席了,不是崩溃。 宿主侧唯一不记的情况是 `prices.json` 不存在(ENOENT)——那是正常态。 ### 两个容易搞错的语义 1. **`null` 不是 `undefined`**:本项目一律用 `null` 表示"没有/无变化/不可用"。 写断言时用 `assert.equal(x, null)`,不要写 `undefined`。 2. **"无变化返回 null" 是有意设计**,不是偷懒:让调用方保留原对象、避免无谓拷贝。 见到 `null` 时**不要**当成错误。 ## 汇报产物的格式(用户明确要求过) 列出任何产物时,每份都要**同时**具备两样东西,缺一不可: 1. **中文说明**:这一份是什么。审计产物固定叫「**审计报告**」与「**优化计划书**」 (`project-architect` 那一侧的第二份叫「**优化任务书**」)。 2. **可点击链接**:`[审计报告:<主题>](/绝对/路径)`。 **不要**把产物路径放进代码块或写成裸路径——那样既点不开,也分不清哪份是哪份。 用户为此专门提过要求,**请严格执行**。 审计产物成对出现,读法固定: | 想看什么 | 打开哪份 | |---|---| | 哪里有问题、证据是什么 | **审计报告** | | 打算怎么修、谁负责 | **优化计划书 / 优化任务书** | ## 公开契约(改动前必须先立 CR) | 契约 | 位置 | 规则 | |---|---|---| | 投影 `sessionCost`(stateVersion 3) | 宿主 → 客户端的数据契约之一 | 只存 token 数与计数,**不存金额、单价、汇率** | | 投影 `quickActions`(stateVersion 1) | 宿主 → 客户端 | 只带按钮清单(配置的只读投影) | | 投影 `contextLoss`(stateVersion 1,CR-0004) | 宿主 → 客户端 | 只存计数与 token 数(`compactions` / `prunes` / `lostTokens` / `lastTurn` / `lastFailed`),**不存摘要文本**;只有 `compaction/summary` 与 `compaction/prune` 计数(start/end 不算,否则失败或取消的压缩会被算成成功) | | 认证路由 `GET\|POST /api/sym.git`(CR-0001 + CR-0002) | 宿主 ↔ 客户端的 Git 读数与**分支切换** | 常量 `GIT_STATUS_ROUTE` 两端各一份(契约测试守卫);字段清单见 `.project-architect.json` 的 `contracts.gitStatusRoute`;GET 读数、POST 只做 `git switch`(白名单 + argv + `--`,不 `-f`/不 stash/不 reset);不调托管平台 API、不碰凭据,非仓库一律 404 | | 引用标记 `@引用#<12位id>` | 客户端写入、宿主展开 | **两端必须同步改** | | `lib/prices.json` 字段 | 对用户可见 | `usdToCny` / `holidays` / `models`,破坏性变更需 CR | | `data-sym-*` DOM 属性 | 供验证脚本定位 | **非稳定 API**,可改但需同步测试 | ### 跨端契约的单一事实源(AUD-ARCH-001 的整改,2026-09-30) 两端**不能共享模块**(客户端是浏览器 module factory,宿主是 Node 模块),所以 `PROJECTION_KEY`、`QUOTE_MARK_PREFIX`、`QUOTE_MARK_ID_LENGTH`、`QUICK_ACTIONS_KEY`、 `GIT_STATUS_ROUTE` 在两端各有一份拷贝: | 常量 | 宿主 | 客户端 | |---|---|---| | `PROJECTION_KEY` | `lib/host-v19.js` 顶部 | `lib/client.js` 的 contract 区 | | `QUOTE_MARK_PREFIX` / `QUOTE_MARK_ID_LENGTH` | 同上 | 同上 | | `QUICK_ACTIONS_KEY` | 同上 | 同上 | | `GIT_STATUS_ROUTE` | 同上 | 同上 | **守卫在 `test/contracts.test.mjs`**:它断言两边相等,并用"客户端生成标记 → 宿主展开" 证明两端真的对得上。**改任何一端都要跑 `npm test`**——失配不会报错,只会表现为 数据不显示或引用不展开。 ## 代码约束 - **零依赖、无构建步骤。** 不要引入 dependencies、打包器或转译。平台模块(`react`、 `react-dom`)用 `require` 取,且**必须包在 try/catch**——取不到时降级而不是崩溃。 - **单卡至多 5 个生产文件、净新增至多 300 行**;任一模块超过 `.project-architect.json` 的 `loc.hardCeiling` 时**先拆分再继续**。 - **数据不可得时安静退场**:返回 `null`,**不要**显示 `0` 或占位符。 - **`view()` 里不要返回每次都是新引用的对象**——会击穿 `viewCache`,导致客户端无限重渲染。 内存读数就因此只能随会话活动更新,这是刻意的。 - 加法接入优先;插件缺席或数据缺失时,**官方布局一个像素都不该变**。 ## 验证要求 - **不落盘、未验证的产物不得宣称完成。** 说"做好了"必须附实测证据。 - 改动客户端 → 需要可复现验证;改动宿主 → **必须在真实运行环境验证**(只跑单测不算)。 - 报告要区分**已验证 / 推断 / 未验证**;没验证的明说没验证。 - **仓库内回归已建立**(2026-09-30,任务卡 T-03/T-04/T-05 与 ADV-P1-01/02): 入口是 `npm test`(即 `node --test`),三个文件 `test/host.test.mjs`、`test/client.test.mjs`、`test/contracts.test.mjs`, 共 31 个断言,零依赖。**注意 `node --test test/` 在 Node 24 下会被当成模块路径而失败, 用不带参数的 `node --test`。** - 但**回归只覆盖纯函数**:渲染、插槽注册、热更新仍要人工实测,改动宿主仍必须重启验证。 ## 版本号规则(用户 2026-10-16 定,勿按 semver 自行判断) **三段、每段最多两位数字;每次更新只在第三段 +1。** - 正常递增是 `+0.0.1`:`1.8.0` → `1.8.1` → `1.8.2`……**不因为"这是新功能"就动第二段**。 - 满 99 才进位:`1.8.99` → `1.9.0`;`1.99.99` → `2.0.0`;到 `99.99.99` 为止。 - 所以**不要**用 `npm version minor` / `npm version major`,也不要按语义化版本推下一个版本号。 - 完整口径的唯一事实源是 `CHANGELOG.md` 顶部的「发布规则」,本节只是摘要,**别在这里另立一套**。 - 用户级 `~/.dsh/AGENTS.md` 里有一条同义的全局规则;项目级(本文件)优先。 ## 治理产物 - 需求与架构基线:`docs/01-architecture/project-architecture-and-requirements.md` - 任务书(12 张原子任务卡):`docs/04-delivery/project-plan-task-charter.md` - 事实源(LOC 阈值 / 模块路径 / 公开契约 / 忽略项):`.project-architect.json` - 用户级指令存档:`docs/dsh-user-agents.md` —— 本机 `~/.dsh/AGENTS.md` 的**逐字节副本**, 由 `npm run check` 第 5 条不变量守卫。改一边就要同步另一边(`cp` 一条命令)。 - 审计产物(未来):`docs/05-audits/YYYY-MM-DD-HHMMSS-*.md`,同一时间戳,**永不覆盖历史** **审计默认只读**:除非明确授权修复,不修改业务代码、配置、依赖或远程状态,不把发现顺手重构。 ## 环境事实(省得重新查) - **DSH 自带终端,且 dsh-sym 不做终端入口(2026-10-09 用户决定,勿再提议)**: 终端由 `@deepseek-ai/dsh-client-ui-sidebar-terminal` 提供,注册为**右侧栏**的标签类型 `kind: "terminal"`(多开、选 shell、只读页可"接管输入"),入口只有两个 —— 快捷键 `terminal.new` = **Control + `**(各平台默认一致,别名 `shell` / `new terminal`),以及右侧栏 标签行的「+」→「新建终端」;**左侧栏的图标列表(`sidebar.panellist`)里没有它**,所以容易 以为"DSH 没有终端"。用户的诉求是"你能自动跑命令就行",因此**不加入口**;要做的话得走官方内部 服务 `sidebarRightTabs` / `sidebarRight`(会是本插件第三个内部依赖),且 terminal 的标签地址 方案**未验证**。 - 模型侧另有一套 `terminal_open / read / write / signal / close / list` 工具(持久终端), 但**当前会话的工具集里没有投给 Agent**:Agent 跑命令走的是 `bash`(非交互、一次性、带超时 与输出上限),交互式 TTY、需要人输密码/确认的场景做不了。 - `DSH_HOME=/Users/long/.dsh`;本项目位于 `~/GitHub/DSH-Sym`(2026-09-30 搬出 profile 的 `plugins/` 目录)。它靠 `~/.dsh/profiles/desktop/cordis.patch.yml` 里的 `file://` 条目挂载 —— **换路径后必须重启 App**,宿主手里的还是旧绝对路径 - ⚠ **上面这条在 2026-10-08 之后已经不成立(2026-10-09 实测更正)**:`~/.dsh/profiles/desktop` 改成了**包安装** —— `package.json` 的依赖是 `dsh-sym: github:seeseeczl/dsh-sym`(pnpm 装在 `node_modules/dsh-sym`,锁定 commit `eed6b3f` = 1.5.0),profile 的 `cordis.patch.yml` 里那条 `file://` 已被删掉。**后果:改工作副本(`~/GitHub/DSH-Sym`)不会进正在运行的 App**, 界面读的是 `node_modules/dsh-sym` 里那一份。 - ⚠ **再更正(2026-10-16 实测)**:profile 现在是 **tarball 形态**,且 `cordis.patch.yml` 里 **一条 sym 条目都没有**(`sym-cost` 与 `sym-dev` 都不存在)—— `dsh-sym: file:/Users/long/.dsh-sym-packages/dsh-sym-<版本>.tgz`,随 `dsh.profile.bundles` 里那条 `dsh-sym` 装配。**该 tarball 由工作副本 `npm pack` 产出,不是 git 依赖**, 所以「更新」= 重新 pack → 装 → **Cmd+Q 重启**;`pnpm update dsh-sym` 对 tarball 无效。 之所以走 tarball:本机到 codeload 的下行只有 ~36 KB/s,而 pnpm 对 git tarball 有 **60 秒硬超时** (`fetch-timeout` 配置**改不动**它,2026-10-16 实测;2026-10-06 曾误判为"改 .npmrc 后成功了", 实际那次只是重试撞上了网速较好的窗口)。 - 两条实测结论:① profile patch 里 `- id: sym-cost` + `name: file://…` 这种**id 定向覆盖改不动 `name`**(补丁层只覆盖 `config`);② entry 的 specifier 还是 `dsh-sym` 时,**宿主模块被模块 缓存钉住**,把包内文件换成新内容、甚至改 `package.json` 的 `main` 到新文件名都不生效 (与下面「宿主代码不热重载」同源,只是换文件名的招数在包形态下失效)。 - 想在**不重启 App**的前提下用工作副本,现在可行的做法:给 profile patch 追加一条**新 id 的 insert** 指向工作副本(如 `id: sym-dev` + `name: file:///…/lib/host-v19.js`), `set_plugin` 打开它、并把 `include:sym-cost` 关掉。**必须关掉包安装那条**:两条都提供 `dsh-sym` 时,客户端模块图按包名去重,只会发出包安装那份 `client.js`(宿主新、客户端旧)。 - 长期方案:把 profile 依赖改成 `link:`/`file:` 指向工作副本,或重建 `file://` 挂载 —— 两者都 需要一次 App 重启。 - **`file://` 挂载 ≠ 不是插件**(2026-10-01 查 asar 证实):loader 里有 `nearestPackage()` (asar 偏移 18777873),拿到 `file://` 入口后会**向上找最近的 `package.json`** 当包根。 所以 `file:///…/lib/host-v19.js` 最终仍被认成包 `dsh-sym`,客户端半边照样走 `exports["./client"]` + `dsh.client` 解析 —— 与包安装的**唯一**差别是入口由绝对路径给出, 而不是由 profile 的 `node_modules` 解析。 选它是为了开发期的迭代速度:改 `lib/client.js` 刷新即生效、改 `prices.json` 即时生效、 改宿主只要 `reload-host.mjs` 换名 + 停用启用;走 tarball 则每次都要 pack → 安装 → 重启。 包形态早就备好(`dsh.bundle.patch` + 包内 `cordis.patch.yml` 用裸包名 insert)—— 真要切成包安装时**必须先删掉 profile 里那条 `file://`**:两条都用 `id: sym-cost`,并存会撞 id。 - DSH 的 skill 扫描根:`<项目根>/.dsh/skills`(100) → `<项目根>/.agents/skills`(200) → `custom`(300) → `$DSH_HOME/skills`(400) → `~/.agents/skills`(500) - **技能目录是会话开始时的快照**,新建/复制进去的技能**当前会话看不到**,需开新会话 - ⚠ **更正(2026-10-14 实测)**:此前这里写着"`project-architect` 等技能的附件 (`references/` `scripts/` `assets/`)**不存在**,照方法论人工执行、不要假装跑了校验脚本" —— **那条记载已经过时**。四件套的附件**齐全**,`project-architect` 下有 `scripts/`(15 个 `.py`)、`references/`(7 个 `.md`)、`assets/`(audit + blueprint 模板)、`tests/`。 **审计必须真的跑校验脚本**(它们能抓出人工看不出的问题:ID 撞号、字段缺失、计数不符): ```bash # 采集证据(--supplement 用于人工校准 FM/FE —— 采集器认不出插件形态,会返回 0 条) python3 ~/.dsh/skills/project-architect/scripts/audit_project.py collect . --mode standard \ --supplement /tmp/<校准文件>.json # 产物配对与内容契约校验(报告 + 任务书必须同时间戳) python3 ~/.dsh/skills/project-architect/scripts/audit_project.py validate \ docs/05-audits/<报告>.md docs/05-audits/<任务书>.md --evidence <证据.json> # FM/FE 覆盖校验、与基线增量比较 python3 ~/.dsh/skills/project-architect/scripts/audit_project.py coverage <证据.json> <报告> python3 ~/.dsh/skills/project-architect/scripts/audit_project.py compare <基线> <当前> ``` ⚠ 两条踩过的坑:① **FM/FE 的 ID 必须是 `^(FE|FM)-[A-Z0-9]{6,32}$`**(哈希风格), 自定义短 ID(如 `FE-BAL`)会被拒;② 报告里**状态/结论/严重度不要加 `**` 加粗**, 校验器按纯文本读表格。仍成立的部分:**技能目录是会话开始时的快照**, 新建/复制进去的技能当前会话看不到。 - ⚠ `assets/audit/` 里有**过期的技能模板副本**(2026-10-08 拷入,比技能目录里的旧)。 写审计产物请以 `~/.dsh/skills/project-architect/assets/audit/` 为准,别用项目里这份。 ### 宿主改动到底有没有生效(2026-09-30 踩过两次) - **不要看 `fiberPhase: active`** —— 跑着旧模块的实例也是 active,`moduleName` 显示新文件名 也可能是错觉。**唯一可靠判据**是 `~/.dsh/storages/session_projcache/sessions/.json` 的 `rows` 里 **有没有你新加的投影 key**(或老 key 的 `val` 有没有按你的新代码变化)。 ⚠ 该文件的层级是 `{ version, record: { identity, rows } }` —— **`rows` 在 `record` 下面**, 不是顶层(2026-10-13 实测更正;按旧文直接读 `d.rows` 会拿到空)。 ⚠⚠ **这个文件只能证明"宿主算了什么",绝不能当"客户端能看到什么"的依据** (见下一条,2026-10-13 为此连错三版)。 - ⚠ **同一个坑在"更新包"时表现为硬失败(2026-10-09 实测)**:加载器记住的是**boot 那一刻解析出的入口路径**。 `pnpm update dsh-sym` 把包从 1.5.0 换成 1.6.0 后,`lib/host-v13.js`(boot 时记录的入口)已不存在, 于是 `set_plugin include:sym-cost true` 直接报 `sym-cost (dsh-sym): failed to import`。 **不改代码、不重启的解法**:在安装好的包里把旧入口路径补成一个一行再导出 (`lib/host-v13.js` → `export * from './host-v19.js'`),再"停用 → 启用"即可; 本机 profile 的 `node_modules/dsh-sym` 里现在就有这么一份 shim(**它不属于发布产物**, 下次 `pnpm install` 会被清掉,清掉后要重启 App 才能重新装配这个 entry)。 → 结论:**换包 + 不重启 App** 是有条件的;干净做法仍然是重启。 - 流程必须是三步:**换文件名 → 停用插件 → 启用插件**。只换文件名不够(模块缓存), 而且 HMR 只会重新组合配置、不会重载插件代码。停用/启用可以在会话里用插件管理器直接做 (`set_plugin`,entryId 是 `include:sym-cost`),**不必重启 App**: ``` node scripts/reload-host.mjs --apply # 换名 + 同步引用(已经脚本化) # 然后 plugin_manager: set_plugin false → true ``` ### 侧栏会话「⋯」菜单可加,且官方导出早就有第二个入口可复用(2026-10-13 取证,CR-0003) 两条先查过的结论,**别再重新投入**: 1. **官方日志导出早就有**,入口在**会话头部**的「更多操作 → 下载 Session 日志」 (`@deepseek-ai/dsh-session-log-export`),**不在**侧栏会话列表的「⋯」菜单里 —— 所以用户会觉得"没有导出菜单"。想加第二入口**不要自己调 `/api/session.export`**: 那个包的浏览器半边把自己 `ctx.provide("sessionLogDownload", controller)` 成了客户端服务, 直接 `controller.download(sessionId)` 即可,端点 / HEAD 预检 / 进度弹窗 / 每会话并发折叠 全都是它的。 2. **侧栏会话「⋯」菜单是 `list` 型槽**:`sidebar.workspaces.session.menu.item`, 官方标注 `replaceRisk: none`;渲染传 `{ sessionId, displayTitle }`,注入 `{ hooks: { menuOpenState, shortcuts } }`。官方四项是 pin(100) / rename(200) / fork(300) / archive(400),本项目加 900/901。 - 菜单行**必须复用官方 `MenuItemButton`**,它自带 `separatorBefore`(官方发丝分隔线), 自画 `