Files
dsh_ai1net_server/交付物/功能打包到基础插件-分步实施方案-20260926.md
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

703 lines
51 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 功能打包到基础插件 · 分步实施方案(含逐棒验证与避坑)
- 日期:2026-09-26
- 上游判定:`交付物/全功能承载判定-底层插件能否承-20260926.md` §3.12(五块能进一个包;**六件只剩三件**)+ `交付物/AI1NET-三类节点结构调整可行性-20260926.md`(**承载两分**)
- 客户端侧依据:工作区 `E:/ProgramData/AIProject/dsh-ai1net-desktop`(**只读引用**,⛔ 不改其文件)
- 编排方式:技能 `dsh-auto-handoff-chain`(规划棒 ↔ 执行棒交替 · 一棒一件事 · 收尾四件套)
- 状态:**规划件** —— 本文不出手改码、不部署;执行由后续棒次照单落地
---
## §0 定位与四条硬边界
**这件事是什么**:把现在散在两处的功能(`src/supervisor` / `web/routes` / `db` / `net/relay` / `im` + 实例侧)**收敛成一个可分发的基础插件包**,让它成为「用户装官方 DSH + 一个包 = 全套能力」的那个**包**。
**四条硬边界(⛔ 本方案全程遵守)**:
| # | 边界 | 为什么 |
|---|---|---|
| **1** | ⛔ **不重造分发与更新机制** —— 平台侧共享包库 + 版本维度 + 三只只读口**已上机**;客户端侧拉取器已有对接单(`docs/对接单_桌面端接入版本管理与包更新机制_20260926.md`) | 重造 = 两套指纹两套语义,必定打架(见 §4-C1) |
| **2** | ⛔ **不改官方 dsh 主程序与缓存**(R2)—— 扩展只走官方插件/注入点 | 官方明写 developer preview「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」⇒ 任何 fork 都会在下次升级时全量返工 |
| **3** | ⛔ **不碰「包外面那三件」**(起实例 / 取身份 / 把包装进去) | 判定件 §3.12.5:那三件归**部署方案**与**引导层**,不在本方案的范围内 |
| **4** | 🔴 **执行前必须先拿到用户确认**(2026-09-26 13:19 用户明令:「现在只做方案 落地详细步骤和方案做好了 确认后在执行」) | 方案阶段**只出方案件与交接单**;⛔ 任何棒次**落码 / 部署**前必须先交用户确认,⛔ 不许"自动接续"把执行棒带跑(见 §6.0) |
---
## §1 开工前必须先纠正的三条前提
> ⚠️ 这三条我上一轮(只读了平台仓库时)判断有误或不全,**先纠正,否则方案会建在错前提上**。
### 1.1 ✅ 官方桌面壳**存在**,且插件机制是同一套
| 我上一轮的说法 | 实际 |
|---|---|
| 「本仓库无 `apps/`、无桌面壳」 | ⚠️ **只在平台仓库里成立** —— 官方壳在**上游**:`deepseek-ai/deepseek-harness` → **`apps/desktop`(0.1.6-alpha.1)+ `apps/desktop-host`**,`private: true`、未发 npm ⇒ **按官方源码直接构建,不 fork、零 patch** |
**实测结论**(客户端线 `docs/S6_官方二次开发能力与桌面端扩展点实测_20260921.md`):
> **官方明确支持二次开发,且是"一等公民"级设计**;载体 = **插件(组合包 bundle)+ patch + profile**;**桌面端不是另一套,用的是同一套机制**。
> 官方文档原文:「**独立插件窗口**获得结构化的**列出、安装、移除、更新和更新检查**操作」;「GUI 插件修改…使用相同的 staging、健康检查、激活与 rollback 路径」。
⛔ 我上一轮「桌面/服务器两条壳要分别实现」的说法**要修正为**:**壳是两条,插件机制是一条**。
### 1.2 ⚠️ 桌面端**能跑**平台,但**没有多租户隔离** —— 不是"跑不了"
| 我上一轮的说法 | 实际(客户端线 `CODEBUDDY.md §9` 原文) |
|---|---|
| 「`setpriv`/`systemd-run` 是 Linux 专有 ⇒ Windows 上这些代码路径不执行」 | ⚠️ **那条链是 `account` 隔离档的依赖;客户端线走的是 `soft` 档** ⇒ `setpriv`/`systemd-run` 根本不在这条路上 |
| 「桌面机不能当承载」 | ✅ **结论对,但理由要换** ⇒ 见下 |
🔴 **实测原文两条**:
1. 「**平台启动实例的方式在 Windows 上不成立**:裸名 `spawn('npm')` → `ENOENT`(全局包是 `.cmd` 垫片);显式 `.cmd` → `EINVAL`;**必须走 `cmd.exe /c` 或 `shell: true`**(`orchestrator.ts:663-665` 现为不带 shell)」
⇒ 🔴 **这是全案唯一必须改平台代码的地方**,也是客户端线的 **M0 验收点**。
2. 「**多人形态开关是零代码的(只配域名)**,但**隔离档仍是 `soft` ⇒ 用户之间无隔离**」
⇒ 🔴 **准确说法**:**Windows 上能跑、能起实例;但 `soft` 档下用户之间没有隔离** ⇒ 所以**桌面机只能当"主权承载"(自己的租户),不能当"公共承载"(别人的租户)** —— 与 `AI1NET-三类节点结构调整可行性` 的判定**同向,但现在有了实证理由**。
### 1.3 ⭐ 桌面端**没有 `webServer`** —— 这是"一个包两端跑"的真正门槛
> 🔴 **2026-09-28 加注(M6 归并的硬前提)**:本节不只是"一个包两端跑"的门槛 —— 它同样是 **M6「设备接入」归并的硬前提**。
> 依据(实测):垫片现状是 `inject = ['connection', 'webServer']`(靠 `webServer.port` 拿本机口,`dsh-client/packages/device-shim/lib/index.js:61`);而 ai1net 包要求 `inject = ['connection']`(`dsh-plugin-ai1net/lib/index.js:102`),且**包内门禁 ①c 逐字断言这一行**、并**逐行**断言代码里零 `webServer` 引用(`dsh-plugin-ai1net/scripts/build.mjs:123` 附近)。
> ⇒ 归并**必须先去掉 `webServer` 依赖**:端口改**包内配置常量** + **动态发现**(与 §5.2 的上游改造是同一处改动,正好合并)。⛔ 不改 ⇒ 门禁红 + 桌面端逐字复现本节的 `pending` 现象。
**实测**(`S6 §3`,出处 `apps/desktop-host/src/index.ts:326-343` + `dsh-client-connection/lib/index.js:547-601`):
| 扩展点 | Web 组合 | **桌面组合(实测)** |
|---|---|---|
| `webServer` 具名路由 / fallback | ✅ 有(激活即 listen) | ❌ **服务根本不存在**(壳内 host 走 fd 管道、不 listen) |
| `/api/*` 服务端逻辑 | 经 webServer | ✅ **`ctx.connection.fetch.register({path, methods, fetch})`** |
| ⚠️ 路径匹配 | 支持 `exact` / `prefix` | 🔴 **只支持精确路径,⛔ 无前缀匹配** |
| 页面级路由(新增一张页) | ✅ `register({kind:'exact'\|'prefix'})` | ❌ **无页面路由**(`/api` → connection,其余全归 SPA dist) |
| 浏览器端插件(client bundle) | ✅ `dsh.client` + `/plugins/*` | ✅ **同一套** |
| index 注入 | `webserver/index-inject` | ✅ 同一事件(`desktop-host/src/index.ts:191`) |
⇒ 🔴 **所以"一个包"不是"写一遍就行",而是"必须只用两端都存在的扩展点子集"**:
```
可用交集 = { connection.fetch.register(精确路径) · dsh.client(浏览器端) · index 注入 }
⛔ 禁用 = { webServer.register · 前缀路由 · 页面级路由 }
```
---
## §2 目标形态
```
┌──────────────── 一个发行包(组合包 bundle)────────────────┐
│ 模块① 多租户界面与请求 模块② 模型管理 │
│ 模块③ 技能插件管理 模块④ 覆盖网络界面 │
│ 模块⑤ IM 插件侧 (+ client bundle:五个卡片) │
└───────────────────────────┬───────────────────────────────┘
同一份代码,两处加载(同一个 pluginId、同一个库)
┌───────────────────────────┴───────────────────────────────┐
▼ ▼
【实例内 / 桌面壳内】 【平台进程】
· 全部业务逻辑 · 只接受"请求"
· 依赖 = 扩展点交集 · 要写平台库的动作在这里
· 桌面端 = 官方壳 + 本包;服务器端 = 官方 dsh + 本包 · ⚠️ 平台不是"实例"⇒ 装不了插件
(判定件 §3.12.3)
```
**一个包、一个 pluginId、一个库**(`dshs_pl_<pluginId>`),库内表按模块**分段命名**。
---
## §3 分步实施方案
> 每步格式:**目标 / 前置 / 步骤 / 🔴 验证(可复现)/ ⚠️ 坑 / 回滚 / 完成判据**。
> 🔴 **验证一律要"能变红"** —— 不通过"负控"(故意改坏一处必须报红)的判据,不算判据。
### S0 · 立验证地基(**先立尺,再动手**)
**目标**:让 S1 之后的每一步都有**一键可复现**的判据。⛔ 这一步不做,后面所有"验证"都是自说自话。
**前置**:无。**不需要拍板、不需要改任何代码。**
**步骤**:
1. **取基线**(两端各一条命令,结果落文件):
- 平台侧:共享层包数与指纹(现读数为 **4 个包**,`dsh-plugin-mcn-suite` = `0.5.1` / `27f6c6d7…` / 659 文件 / 2,018,751 B)
- 客户端侧:本机 profile 的 bundle 清单(现为 **4 个 bundle、无 `@dsh-local/*`**)
2. **建「插件四判据」冒烟脚本**(用**已有插件**当靶子,⛔ 不用新包):装配 ✓ / 运行 ✓ / 取数 ✓ / 回调 ✓
3. **建「浏览器级判据」脚本**(客户端 UI 必须有这一关)
4. **做负控**:故意把靶子改坏一处 ⇒ **脚本必须报红**;改回 ⇒ 必须报绿
**🔴 验证**:
- 同一脚本**连跑两次**,结果一致(排除偶发)
- **负控通过**:改坏 ⇒ 红|改回 ⇒ 绿(这一条是 S0 的**唯一**完成判据)
**⚠️ 坑**:
- ⚠️ **`dump-config` 验不了运行时挂载**(官方 "without booting" ⇒ 不跑 `apply()`)⇒ 证据**必须取活体 boot**(`$B/home-adapt/logs/startup-*.log` 有 fiber 级 outcome)
- ⚠️ **启动日志只记失败条目**(插件 `logger.info` 不在其中)⇒ 运行时挂载证据只能取 **RPC** 或 boot 失败面
- 🔴 **`node --check` +「client bundle 可组合」+「boot 本包零报错」三条全绿也证明不了客户端能加载** ⇒ **UI 交付必须有浏览器级判据**
**回滚**:删脚本(无副作用)。
**完成判据**:负控红/绿各一次,且连跑两次一致。
---
### S1 · 定「包规格书」(**不动一行代码**)
**目标**:产出一份冷读者能照做的**包规格书**。这是后面所有棒次的共同输入。
**前置**:S0 完成。
**步骤**:
1. **列模块**:五个模块的名称、职责、边界(哪一块的哪一半在平台侧)
2. **逐条列扩展点**,每条必须标注**两端是否都有**:
- ⛔ 出现任何 `webServer` / 前缀路由 / 页面级路由 ⇒ **当场改成 `connection.fetch.register` + 精确路径**
3. **列全部 API 精确路径**(⛔ 不能靠前缀)—— 桌面端**只认精确路径**
4. **定包名与库名**:包名 ⇒ 库名 `dshs_pl_<pluginId>`,**长度必须 ≤ 48**、`[a-z][a-z0-9_]{0,40}`
5. **定表名分段方案**(库内表加 `p_<pluginId>_` 前缀,模块靠后半段区分)
6. **定迁移入口**:只留**一个**入口、按模块分步执行(治"一个包一个库 ⇒ 一次迁移牵动全部")
**🔴 验证**:
- **冷读者测试**:把规格书给一个不参与本方案的会话读,问它「能不能照做」—— 不能 ⇒ 补
- **逐条扩展点对表**:与 §1.3 的表逐条比对,**⛔ 零条落在"桌面不存在"那一列**
- **库名长度硬校验**:用正则跑一遍(⛔ 别目测)
**⚠️ 坑**:见 §4-B1 / §4-D2。**回滚**:无(纯文档)。
**完成判据**:规格书里**没有一条扩展点只在 Web 端存在**。
---
### S2 · 空包两端落地(**最小可运行骨架**)
**目标**:一个**最小包**(一个 client 卡片 + 一条 host 路由),**两端都能装、都能出界面**。
**前置**:S1 规格书完成。
**步骤**:
1. 按官方三步做包:`apply(ctx)` 模块 → `package.json` 写 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` → `dsh plugin add`
2. host 半:`ctx.connection.fetch.register({path:'/api/<name>/ping', methods:['GET'], fetch})`,**`inject = ['connection']` 必写**
3. client 半:`dsh.client` + **lazy-CJS 封包**(⛔ 零顶层 `import`/`export`/`require`)
4. 两端各起一次,看界面
**🔴 验证(四层,缺一不算过)**:
| 层 | 怎么验 | 过了说明什么 |
|---|---|---|
| 语法 | `node --check` | ⚠️ **只说明语法** |
| 装进 profile | 本机 profile 里出现该包 | 装配通 |
| **活体 boot** | `startup-*.log` 有 fiber 级 outcome | 挂载通 |
| **浏览器级** | 真壳里出现卡片、**JS 错误 0**、无 `Failed to load plugins` | ✅ **唯一能证明 UI 真的能用** |
**⚠️ 坑**:§4-A1(lazy-CJS)· §4-A2(`inject` 必须声明)· §4-A3(`maxDepth` 不可省)· §4-A4(静态注入是坏的)· §4-E2(改插件后必重启壳)。
**回滚**:从 profile 移除该包(⛔ 目录用 `mv`,不删)。
**完成判据**:**两端**都出现卡片;浏览器控制台 JS 错误 = 0。
---
### S3 · 数据面(一个包一个库)
**目标**:包能建自己的库、跑迁移、被对账。
**前置**:S2 通过。
**步骤**:
1. 声明数据面 ⇒ **平台侧建库** `dshs_pl_<pluginId>`(⛔ 顺序:**先建库再投放**)
2. 迁移:模块分段、单入口分步执行
3. 对账:`pg_database` 的 `dshs_pl_*` × 控制面台账
4. **改声明后回读 yaml 实际值**(⛔ 不回读 = 会漏落)
**🔴 验证**:
- 建库 → 迁移 → 对账**三轮全绿**;库名匹配 `^dshs_pl_[a-z][a-z0-9_]{0,40}$`
- **负控**:改一处声明**不回读** ⇒ 必须能查出 yaml 与声明不一致(证明"回读"这一步真的在起作用)
**⚠️ 坑**:§4-D1 · §4-D2 · §4-D3 · §4-D4。
**回滚**:库用 `mv` 式子改名冻结,⛔ 不 `DROP`。
**完成判据**:三个模块的表都在同一个库里且**能单独回滚**。
---
### S4 · 接进既有的分发与更新机制(⛔ **一行都别重造**)
**目标**:包能被平台**对账 + 增量拉取 + 原子换上**,两端指纹逐字一致。
**前置**:S3 通过。⚠️ **本步依赖一个待拍板项**(§6-P1:平台侧只读三口增认「设备身份」)—— 未拍板时**只做能做的**(手工主机密钥干跑 happy path)。
**步骤**:
1. **照抄权威实现**:`src/worker/agent.ts` 的 `syncSharedLayerOnce()` + `pullOnePack()`(纯 Node、无框架依赖)
2. 实现四步:对账 → 取包 → 落地(原子换六动作)→ 回报
3. 接进 profile 装配:依赖写 **`"<包名>": "link:<目录绝对路径>"`**
4. **补 `@deepseek-ai/*` 软链**(共享层里**零个**这些包,它们是宿主提供的 optional peer)
5. 镜像目录绝对路径**与平台侧逐字同形**
**🔴 验证**:
- **指纹逐字相同**(⛔ 不是"大概一致");对账 `diff` 三个集合全空
- **三条反例判据(⛔ 不验不算完成)**:
1. **清单拿不到**(断网/黑洞)⇒ 本地文件**零改动**(前后指纹一致)
2. **单个包失败** ⇒ 其余包**照常更新**(不是整轮放弃)
3. **`extra`**(本地多出来的包)⇒ 平台只告知,**本地一个文件没被动过**
- **装配后**:本机 profile 出现该包 ⇒ 实例页「能力管理(插件/技能)」栏**开始出现**
**⚠️ 坑**:§4-C1 – §4-C5(**指纹算法不一致 ⇒ 永远 stale、每轮全量重拉**;对账与下载共用超时;`link:` 目标两机同路径;软链缺失表现为"包在但加载不了")。
**回滚**:**停掉拉取器即回滚**(拉取失败本就不动本地文件 ⇒ 无数据风险)。
**完成判据**:三条反例判据全过 + 指纹逐字相同。
---
### S5 · 第一块迁移:**IM 插件侧**(选它是因为它有现成模板)
**目标**:把 IM 的插件侧并入基础包。
**前置**:S4 通过。
**为什么第一个做它**:判定件 §3.12.3 / §3.12.6 —— **IM 是今天唯一「装配 / 运行 / 取数 / 回调」四面齐全的** ⇒ 它**就是模板**,其余四块照它做。
**步骤**:
1. 照 IM 的接入方式把插件侧搬进包(⛔ **是重写,不是 `git mv`**)
2. 补上残留两条:① 校验"插件是否真在该实例启用" ② 一条实例只该有**一条**常驻长轮询
**🔴 验证**:
- IM 四条面(装配/运行/取数/回调)在**新包**里逐条复现
- **回归**:迁移前后,同一房间的消息收发行为一致(⛔ 用同一组输入比对)
- **负控**:故意让长轮询起两条 ⇒ 必须能检出
**⚠️ 坑**:§4-B2 · §4-C1。
**回滚**:切回旧实现(两套并存一段时间,⛔ 不删旧的)。
**完成判据**:四条面齐全 + 行为回归一致 + 残留两条已补。
---
### S6 · 第二块:**技能插件管理**(含"更新-告知 / 自动")
**目标**:上传 → 检测 → 建库 → 启用 → 更新,全链进包。
**前置**:S5 通过。
**步骤**:
1. 管理面(上传/检测/建库/启用)进包
2. 「更新」四拆照判定件 §2.1:① 看见新版本 ✅ 包 ② 点更新按钮 ✅ 包(**只到"请求"**)③ 把新包搬进来 → **走 S4 的拉取器**(⛔ 不在包里自己搬)④ 「设置自动处理」→ **执行者必须在包外**
3. package.json 指向 yaml;自增 id ⛔ 不能照搬;UI ≤900px 必须独占
**🔴 验证**:
- 全链 dry-run 一遍
- **"看见新版本"的判据**:包只报"有差异",⛔ **不自己下结论**(对齐既有纪律:**「不知道」不是「已知」**)
- 「自动处理」开关在包外时,**关掉执行者 ⇒ 行为必须停**(证明执行者确实在包外)
**⚠️ 坑**:§4-D1 · §4-E1。
**完成判据**:更新链的**每一步**都能指出"谁在做",且执行者都在包外。
---
### S7 · 第三块:**模型管理**
**目标**:配置 / 凭据 / 共享授权 / 界面进包。
**前置**:S6 通过。
**🔴 验证**:
- 共享模型走**管理员逐用户授权**(`users.shared_model_granted`,v11 默认 0)—— 负控:未授权用户必须看不见
- ⚠️ **用户卷在 worker 上时,写 home 必须走 `UserFs`**;负控:直写 `fs` ⇒ 必须能检出"静默空操作"
**⚠️ 坑**:§4-D4(`homedir()` 必错)· §4-F3。
**完成判据**:模型配置在**实例内**可见、**平台级凭据⛔不下发到客户端**(R5 客户端专属红线)。
---
### S8 · 第四块:**覆盖网络界面**(⚠️ 身份与隧道留在包外)
**目标**:看哪些区 / 选加入 / 跨区可见性 / 界面进包。**入网凭据与隧道 ⛔ 不进**。
**前置**:S7 通过。⚠️ **并且 F1.5(G1 序号 + G6 撤根)必须先有结论** —— 定稿原文「**F1.5 是一切的前置**」。
**🔴 验证**:
- 界面能显示"有哪些区可加入" —— ⚠️ **⛔ 不可做成公开列表**(拓扑信息即扩大暴露面),改法=**凭邀请码**;负控:无邀请码时必须**看不到任何区**
- 跨区可见性跟随「用户 × 区」成员关系(已拍板);负控:无成员关系 ⇒ 不可见
**⚠️ 坑**:§4-F4(`egressCidrs` 语义)· §4-G1。
**完成判据**:包内**零**身份/隧道逻辑。
---
### S9 · 第五块:**多租户**(包里只做界面 + 请求)
**目标**:租户列表 / 配额 / 成员 / 账单界面进包;**"创建隔离实例"这个动作落平台进程**。
**前置**:S8 通过。⚠️ **依赖 §6-P2(平台侧那一半怎么落)拍板**。
**🔴 验证**:
- **界面 + 请求**在包内;**动作**在平台 —— 停掉平台侧 ⇒ 界面必须**明确报"做不到"**,⛔ 不许假装成功
- 配额相关:⚠️ 配额 384 MiB / V8 堆**按宿主算** —— 负控:改宿主规格,租户侧看到的数必须跟着变
**⚠️ 坑**:§4-B3(平台不是"实例"⇒ 装不了插件)。
**完成判据**:包里**没有**任何直接写平台库的代码路径。
---
## §3.10 落地详细步骤(**S0–S9 逐步照做清单**)
> §3 说的是「每步要达成**什么**」,本节说的是「**手怎么动**」。
> 🔴 本节仍是**方案**,⛔ 不是执行授权 —— 全部动作**须用户确认后才可开工**(见 §6.0)。
### 3.10.1 先把「尺子」定死(S0 的产出规格)
后面九步的判据**一律不许临时目测**,只许调 S0 建出来的四把尺子:
| 尺子 | 名字 | 用法 | 绿 / 红怎么算 |
|---|---|---|---|
| 基线 | `v-baseline.sh` | 取两端基线(共享层包数+指纹 / 本机 profile bundle 清单),落文件 | rc=0,且**连跑两次逐字一致** |
| 插件四判据 | `v-plugin.sh <装配\|运行\|取数\|回调>` | 拿**已有插件**当靶子,逐面判 | rc=0 绿 / rc≠0 红 |
| 浏览器级 | `v-ui.sh` | 真壳起一次:出卡片 + JS 错误 0 + 无 `Failed to load plugins` | rc=0 绿 / rc≠0 红 |
| 负控驱动 | `v-neg.sh <case>` | 改坏靶子一处 ⇒ 断言 `v-plugin.sh` **必须红** ⇒ 改回 ⇒ 断言**必须绿** | 两个断言都成立才 rc=0 |
🔴 **三条约定先立,否则后面全是扯皮**:
1. **退出码即结论**:绿 = `rc 0`、红 = `rc≠0`。⛔ 不许拿"脚本打印了一行 OK"当绿。
2. **只许测活体**:「不 boot 就出结果」的路子(`dump-config` 类)**测不出运行时挂载** ⇒ 证据必须来自活体 boot 日志的 fiber outcome 或一次真实请求。
3. **落点** = `交付物/验证脚本/`(与方案件同处,已在入库范围);⛔ **不放进文档库 `07-scripts/`**(那里绑宿主 hook,动它影响全平台)。
### 3.10.2 S0 · 立验证地基
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S0-1 | 取平台侧基线(包数 + 每包指纹) | 平台侧(**只读**) | `交付物/验证脚本/baseline-平台-<日期>.txt` | 现值应为 **4 包** |
| S0-2 | 取客户端侧基线(profile bundle 清单) | 本机 | `baseline-客户端-<日期>.txt` | 现值应为 **4 bundle**、**无 `@dsh-local/*`** |
| S0-3 | 写 `v-plugin.sh`(四面判据) | 本机 | 脚本 | 拿**已有插件**跑 ⇒ 四面全绿 |
| S0-4 | 写 `v-ui.sh`(浏览器级) | 本机 | 脚本 | 拿已有插件跑 ⇒ 卡片出现、JS 错误 0 |
| S0-5 | 写 `v-neg.sh` 并跑负控 | 本机 | 脚本 + 负控记录 | **改坏 ⇒ 红、改回 ⇒ 绿**(各一次) |
| S0-6 | 连跑两次对比 | 本机 | 对比记录 | 两次输出**逐字一致** |
**失败怎么办**:若 S0-3/S0-4 拿**已有插件**都跑不绿 ⇒ 🔴 **立刻停**,说明基线本身有问题,先修尺子再谈打包(⛔ 不许"先往下走、回头再修")。
### 3.10.3 S1 · 定包规格书
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S1-1 | 列五个模块的名称/职责/边界(标明哪一块的哪一半在平台侧) | 文档 | 规格书 §1 | 冷读者能说出"这块归谁" |
| S1-2 | 逐条列扩展点,**每条标"两端是否都有"** | 文档 | 规格书 §2 | 与 §1.3 表**逐条**比对 |
| S1-3 | 列全部 API **精确路径** | 文档 | 规格书 §3 | ⛔ 零条前缀 |
| S1-4 | 定包名 → 库名,**跑正则** | 文档 | 规格书 §4 | 匹配 `^dshs_pl_[a-z][a-z0-9_]{0,40}$` 且**总长 ≤48** |
| S1-5 | 定表名分段(`p_<pluginId>_` + 模块后半段) | 文档 | 规格书 §5 | 同库内任意两表不重名 |
| S1-6 | 定**唯一**迁移入口(按模块分步) | 文档 | 规格书 §6 | 能指出"只回滚某模块"走哪一步 |
**失败怎么办**:一旦出现 `webServer` / 前缀路由 / 页面级路由 ⇒ **当场改掉**,⛔ 不许"先留着后面再说"。
### 3.10.4 S2 · 空包两端落地
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S2-1 | 按官方三步建最小包(`apply(ctx)` → `package.json` 写 bundle patch → `dsh plugin add`) | 本机仓库 | 包骨架 | `v-plugin.sh 装配` 绿 |
| S2-2 | host 半:注册**一条**精确路径路由,**`inject` 必写** | 本机仓库 | 路由代码 | `v-plugin.sh 取数` 绿 |
| S2-3 | client 半:`dsh.client` + **lazy-CJS 封包**(⛔ 零顶层 `import`/`export`/`require`) | 本机仓库 | 卡片代码 | 封包契约门禁过 |
| S2-4 | **两端各起一次** | 平台侧 + 本机壳 | 截图 / 日志 | `v-ui.sh` 绿(**两端各一次**) |
**失败怎么办**:卡片不出现 ↦ §4-A1|路由静默 404 ↦ §4-A2(`inject` 没声明)|启动报错 ↦ §4-A3/A4。
### 3.10.5 S3 · 数据面
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S3-1 | 声明数据面 | 本机仓库 | 声明文件 | **回读 yaml 实际值**并与声明一致 |
| S3-2 | **先建库** `dshs_pl_<pluginId>`(⛔ 不许先投放) | 平台侧 | 库 | 库名匹配正则、总长 ≤48 |
| S3-3 | 迁移:**单入口**、按模块分步 | 平台侧 | 表 | 三模块的表都在同一库 |
| S3-4 | 对账:`pg_database` 的 `dshs_pl_*` × 控制面台账 | 平台侧 | 对账输出 | 两集合一致 |
| S3-5 | **负控**:故意改一处声明且**不回读** | 本机 | 负控记录 | 必须能查出"yaml 与声明不一致" |
**失败怎么办**:库没建上 ↦ §4-D5(顺序反了)|声明改了没落 ↦ §4-D3(没回读)。
### 3.10.6 S4 · 接进既有分发机制
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S4-1 | **照抄**权威实现(`syncSharedLayerOnce()` / `pullOnePack()`,纯 Node) | 本机仓库 | 拉取器 | 与权威实现**逐段对得上** |
| S4-2 | 实现四步:对账 → 取包 → 落地(**原子换六动作**)→ 回报 | 本机仓库 | 拉取器 | happy path 跑通 |
| S4-3 | 依赖写 `link:<目录绝对路径>` | profile | profile 依赖 | 两端路径**逐字同形** |
| S4-4 | 补 `@deepseek-ai/*` 软链 | 客户端侧 | 软链 | 缺软链的现象="包在但加载不了" |
| S4-5 | **三条反例判据**:清单拿不到 / 单包失败 / `extra` | 本机 | 三条记录 | ⛔ **不验不算完成** |
| S4-6 | 装进 profile 后看实例页能力栏 | 两端 | 截图 | 能力栏**开始出现** |
**失败怎么办**:每轮全量重拉 ↦ §4-C1(指纹算法不一致)|超时怪 ↦ §4-C2(对账 20 s 与下载 300 s **必须分开算**)|"不知道"被当成"一致" ↦ §4-C5。
⚠️ **本步依赖待拍板项 P1** —— 未拍板时只做能做的(用主机密钥干跑 happy path)。
### 3.10.7 S5 · 第一块迁移(IM 插件侧 · **模板块**)
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S5-1 | 照 IM 接入方式把插件侧**重写**进包(⛔ 不是 `git mv`) | 本机仓库 | 模块⑤ | 四条面(装配/运行/取数/回调)逐条复现 |
| S5-2 | 补残留①:校验"插件是否**真在该实例启用**" | 本机仓库 | 代码 | 未启用 ⇒ 必须拒 |
| S5-3 | 补残留②:一个实例只允许**一条**常驻长轮询 | 本机仓库 | 代码 | **负控**:故意起两条 ⇒ 必须检出 |
| S5-4 | **回归**:迁移前后,同一组输入的消息收发行为一致 | 两端 | 比对记录 | 同输入 ⇒ 同行为 |
**失败怎么办**:旧实现**不删**,两套并存一段时间(回滚 = 切回旧的)。
### 3.10.8 S6 · 第二块(技能插件管理)
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S6-1 | 上传/检测/建库/启用 进包 | 本机仓库 | 模块③ | `v-plugin.sh` 四面绿 |
| S6-2 | 「更新」四拆:①②进包(**只到"请求"**);③ 搬包**走 S4 拉取器**;④ 执行者**留包外** | 本机仓库 | 代码 | 能**逐条指出**"谁在做",且执行者在包外 |
| S6-3 | `package.json` 指向 yaml;自增 id ⛔ 不照搬;UI ≤900px 独占 | 本机仓库 | 代码 | 窄屏截图 |
| S6-4 | **负控**:关掉包外执行者 ⇒ 自动更新行为**必须停** | 本机 | 负控记录 | 行为确实停 |
**失败怎么办**:改完没生效 ↦ §4-E1(测试跑的是构建产物,**先 build**)。
### 3.10.9 S7 · 第三块(模型管理)
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S7-1 | 模型配置/凭据/共享授权/界面 进包 | 本机仓库 | 模块② | `v-plugin.sh` 绿 |
| S7-2 | 共享模型走**管理员逐用户授权**(默认未授权) | 本机仓库 | 代码 | **负控**:未授权用户**看不见** |
| S7-3 | 写 home 一律走 `UserFs` | 本机仓库 | 代码 | **负控**:直写 `fs` ⇒ 能检出"静默空操作" |
| S7-4 | 验"平台级凭据 ⛔ 不下发客户端"(R5 红线) | 两端 | 检查记录 | 客户端侧**零**平台凭据 |
**失败怎么办**:`homedir()` 写错 ↦ §4-D4。
### 3.10.10 S8 · 第四块(覆盖网络界面)
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S8-1 | 界面:看哪些区 / 选加入 / 跨区可见性 | 本机仓库 | 模块④ | `v-ui.sh` 绿 |
| S8-2 | ⚠️ **⛔ 不做公开区列表**(拓扑即暴露面)⇒ 改**凭邀请码** | 本机仓库 | 代码 | **负控**:无邀请码 ⇒ **看不到任何区** |
| S8-3 | 跨区可见性跟随「用户 × 区」成员关系 | 本机仓库 | 代码 | **负控**:无成员关系 ⇒ 不可见 |
| S8-4 | 确认包内**零**身份/隧道逻辑 | 本机仓库 | 检查记录 | 搜不到身份/隧道代码路径 |
⛔ **本步的硬门**:**F1.5(G1 序号 + G6 撤根)必须先有结论** —— 定稿原文「**F1.5 是一切的前置**」。
**失败怎么办**:连不上 ↦ §4-F2(改走 unix socket)|`egressCidrs` 语义 ↦ §4-F3。
### 3.10.11 S9 · 第五块(多租户)
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S9-1 | 租户列表/配额/成员/账单 界面进包 | 本机仓库 | 模块① | `v-ui.sh` 绿 |
| S9-2 | **"创建隔离实例"这个动作留平台进程** | 平台侧 | 代码 | 包里搜不到写平台库的路径 |
| S9-3 | 验"平台侧停掉 ⇒ 界面必须**明确报做不到**" | 两端 | 负控记录 | ⛔ 不许假装成功 |
| S9-4 | 配额按**宿主**算的负控:改宿主规格 ⇒ 租户侧看到的数**跟着变** | 平台侧 | 负控记录 | 确实跟着变 |
**失败怎么办**:想"在包里直接写平台库" ↦ §4-B3(平台不是"实例",装不了插件)。
### 3.10.12 每一步都要"停得住"(治"做一半烂在那里")
| 步 | 停下来时的状态 | 会不会留半成品 |
|---|---|---|
| S0 | 只有几个脚本 | **不留**(零副作用) |
| S1 | 只有一份文档 | **不留** |
| S2 | 空包可卸 | 卸掉即回原状 |
| S3 | 库空着 | 库用**改名冻结**,⛔ 不 `DROP` |
| S4 | 停拉取器即停 | 拉取失败**本就不动本地文件** |
| S5–S9 | 新旧并存 | 切回旧实现 |
⇒ 🔴 **所以本方案允许"随时叫停"**:任何一步停下来,系统都还是能用的状态。这也是"确认后再执行"能成立的前提。
---
## §4 避坑总表(**这是本方案的第二主产出**)
> 每条:**现象 → 根因 → 后果 → 规避 → 证伪手段**。全部来自两端实测,⛔ 没有一条是推测。
### A 类 · 客户端 / UI(**最容易"看着是绿的其实没生效"**)
| # | 现象 | 根因 | 后果 | 规避 | 证伪手段 |
|---|---|---|---|---|---|
| **A1** | 卡片根本不出现、页面报 `Failed to load plugins` | 🔴 **官方 client bundle 契约 = lazy-CJS 封包**:形如 `window.__ModuleLoader__.load({id, factory})`,`factory(require)` 内 `require('react')`…,末尾 `exports.apply`/`exports.inject`;**零顶层 `import`/`export`/`require`**。手写 ESM 直交 ⇒ 浏览器按**经典脚本**加载 ⇒ `SyntaxError: Cannot use import statement outside a module` | 🔴 **`node --check` +「可组合」+「boot 零报错」三条全绿也证明不了能加载** | 按 lazy-CJS 封包写;**`scripts/build.mjs` 的封包契约门禁**必须过 | **浏览器级**:真壳里出现卡片 + **JS 错误 0** + 无 `Failed to load plugins` |
| **A2** | HTTP 路由**静默 404** | 🔴🔴 **cordis 的服务只能靠 `inject` 拿** —— `ctx.connection` 未在 `inject` 声明 ⇒ `cannot get property "connection" without inject`;`ctx.reflect.get('connection', false)`(官方注释 "without the inject requirement")**返回 undefined** | 只写防御式 `try/catch` ⇒ **路由不挂、静默 404**,看着像"没人调" | `inject = ['connection']` **必写**(官方同类包 `file-upload`/`ui-deliverables` 全如此) | 真发一次请求;⛔ 别只看"没报错" |
| **A3** | 挂载抛错 | `maxDepth:'provider-managed'` **不可省** | 整包挂不上 | 照官方样板写 | 活体 boot 有该条 fiber outcome |
| **A4** | `ERR_MODULE_NOT_FOUND` | **静态注入是坏的** ⇒ 以**动态**为准 | 包在但加载不了 | 用动态注入 | 活体 boot |
| **A5** | 改了却没生效 | 模块级常量**在加载时读**,**不解热更新** | 白改一轮 | **改插件后必须重启壳** | 重启前后各取一次快照对比 |
| **A6** | 一个可选插件把壳搞得起不来 | 可选插件在服务缺失时**抛错** ⇒ `plugin tree failed to load: 1 entry did not activate` | 整棵插件树不加载 | 服务缺失时**降级告警,⛔ 不抛错** | 故意注掉一个服务 ⇒ 其余插件仍活 |
| **A7** | 页面看着**完全正常**,但一调用全局就炸 | 把 JS/CSS 内联进 `<script>`/`<style>` **未转义 `</script`/`</style`/`<!--`** | HTML 解析器**提前闭合** ⇒ 全局从未建立 | 内联前一律转义;IIFE + 捕获阶段拦默认提交 | 浏览器控制台 |
| **A8** | 「登录接口 200,下一步却 401」 | `dsh-app:` 是**自定义协议** ⇒ Chromium **不落 `Set-Cookie`**(cookie 存储只认 http/https/ftp/ws/wss) | **看着像登录失败,其实是 cookie 被丢** | 壳内用**显式凭据往返** | 抓响应头/请求头,⛔ 不看 cookie 存储 |
| **A9** | 登录成功后**回到初始页** | 同 URL 整页导航会**冲掉壳内现场** | 观感"登录完又回到起点" | 改写成 `<入口>#<标记>` 走**同文档片段** | 观察是否整页重载 |
| **A10** | 同一份脚本塞进同一文档**跑第二次必崩** | 平台门户脚本体**不幂等**(经典脚本顶层写 `const pwInput`,而经典脚本**共享全局词法环境**)🔴 **最危险的不是报错,是静默降级**:`form.addEventListener('submit')` 没挂上 ⇒ 表单走默认提交 ⇒ **凭据进 URL** | 全新壳里第一次跑是好的 ⇒ **极易判成"偶发"** | body 内联脚本一律包 **IIFE** + 捕获阶段拦默认提交 | 挂载两次,看 URL 是否带 `?username=` |
| **A11** | host 半判"不安全"并**整体跳过注入** | 注入脚本正文里出现**脚本闭合标签字面量** | 现象="插件 mounted=9 但壳里什么都没有" | 注入脚本正文⛔ 不写闭合标签字面量 | host 半日志 |
### B 类 · 扩展点(**一个包两端跑的门槛**)
| # | 现象 | 根因 | 规避 / 证伪 |
|---|---|---|---|
| **B1** | 桌面端插件树加载失败:`pending (waiting for service: webServer)` | 🔴 **桌面端没有 `webServer`**(壳内 host 走 fd 管道、不 listen) | 桌面端**必须**用 `connection`;规格书逐条标注"两端是否都有"|🔴 **这也是 M6 归并的硬前提**(2026-09-28 加注):M6 的 host 半若带 `webServer` 依赖,桌面端会**逐字复现本行现象**;归并时端口一律取**包内配置常量**(⛔ 不读宿主服务),且包内门禁 ①c 会逐行抓 `webServer` |
| **B2** | API 明明注册了却 404 | 🔴 桌面端 **只支持精确路径,⛔ 无前缀匹配** | 每条路由**逐条列精确路径**;证伪=逐条真发 |
| **B3** | 「放进一个包」落了地一半 | 🔴 **平台不是"实例" ⇒ 装不了插件** ⇒ 要写平台库的动作**只能加进平台进程本身** | 包里⛔ 不写直连平台库的路径;证伪=停掉平台侧,界面必须**明确报做不到** |
| **B4** | 新增页面无处可挂 | 桌面端**无页面路由** | 走**同文档渲染** |
### C 类 · 装配与分发
| # | 现象 | 根因 | 规避 / 证伪 |
|---|---|---|---|
| **C1** | 永远判 stale、**每轮全量重拉** | 🔴 **指纹算法两侧不一致**(协议里的"指纹"是**内容归一化后**的哈希;⛔ 别用 `md5 of tar` / `sha256 of file list` 之类的近似物) | **逐字同规则**;抄 `src/worker/agent.ts` 的 `syncSharedLayerOnce()`/`pullOnePack()` |
| **C2** | 2 MB 包跨云报 `aborted due to timeout` | **对账与下载共用一个超时**(对账 20 s / 下载 300 s、上限 900 s、最低速率 64 KiB/s —— **必须分开算**) | 两套超时分开;全部**可用环境变量覆盖,⛔ 不写死** |
| **C3** | 装配失配 | `link:` 目标路径不同 | **镜像目录绝对路径与平台侧逐字同形** |
| **C4** | `ERR_MODULE_NOT_FOUND`("包在但加载不了"的**假故障**) | 共享层里**零个 `@deepseek-ai/*`**(宿主提供的 optional peer) | **必须补软链** |
| **C5** | 「不知道」被折算成「已知」 | 指纹拿不到时报/存了"没问题" | 🔴 纪律:**算不出指纹就报"算不出"**;传/存 `null` ⇒ 平台按 **stale** 处理,⛔ **绝不折算成"一致"** |
| **C6** | 失败一轮全放弃 / 删了不该删的 | 三条语义纪律 | ① **清单取不到 ⇒ 本轮什么都不做** ② **单包失败 ⇒ 只记 `failed`**,其余照常 ③ **`extra` ⇒ 只告知,绝不删除** |
### D 类 · 数据
| # | 现象 | 根因 | 规避 / 证伪 |
|---|---|---|---|
| **D1** | 迁移一失败**五块全挂** | 一个包 = 一个 pluginId = **一个库** | 表名分段 + **单入口按模块分步** + 每模块可单独回滚 |
| **D2** | 库名非法 / 建不出来 | 库名形状 `dshs_pl_` + `[a-z][a-z0-9_]{0,40}`、**总长 ≤ 48** | 用**正则硬校验**,⛔ 别目测 |
| **D3** | 列限声明**改了却没生效** | 列级 `maxBytes` **平台无强制点** ⇒ 唯一把关人 = **插件侧 `COLUMN_MAX_BYTES`** | **改声明后必回读 yaml 实际值** |
| **D4** | 写 home **静默空操作** | `homedir()` 必错(实例 `HOME=ws`);用户卷在 worker 上时直写 `fs` = 静默空操作 | 写 home **必须走 `UserFs`**;⛔ 不得 `cp`/`mv`/直写 `fs` 动 home |
| **D5** | 顺序错导致白干 | **先建库再投放** | 写进步骤顺序 |
### E 类 · 构建 / 运行
| # | 现象 | 根因 | 规避 / 证伪 |
|---|---|---|---|
| **E1** | 测试全过、运行时还是旧行为 | **测试跑的是构建产物** ⇒ 改源码不构建 = **新旧混跑**,症状像"改动无效" | **改完必 build**;证伪=比对 `src` 与 `lib` 时间戳 |
| **E2** | "改了却没生效"(UI 侧) | 见 A5 | **改插件后必须重启壳** |
| **E3** | 脚本 top-level await 直接报错 | `.ts` **被当 CJS** | 自研 tsx 脚本一律用 **`.mts`** |
| **E4** | `EBUSY` | **同步派生被拦**(`execFileSync`/`spawnSync`) | 解包/校验**用异步 `spawn`** |
| **E5** | `npm` 起不来(Windows) | 平台 `orchestrator.ts:663-665` 现为**不带 shell** ⇒ 裸名 `ENOENT`(`.cmd` 垫片)、显式 `.cmd` `EINVAL` | 走 `cmd.exe /c` 或 `shell: true`;⚠️ **这是全案唯一必须改平台代码的地方** |
| **E6** | client bundle 加载失败 | `exports.default` **禁用**(lazy-CJS 封包契约) | 只导出 `apply` + `inject`(R3) |
### F 类 · 系统 / 沙箱 / 网络
| # | 现象 | 根因 | 规避 |
|---|---|---|---|
| **F1** | 🔴 **所有实例全挂** | 改 bwrap 参数前**没看目标机 bubblewrap 版本**(47=0.4.0/106=0.11.0);`--perms` 属 0.5+ ⇒ 47 上 `Unknown option` | 要权限**用 `--tmpfs`**;⚠️ `smoke-isolation` 不能作 account 模式验收 |
| **F2** | 插件连不上自己 spawn 的 gateway | 插件被 nft 拒连 `127.0.0.0/8` | 改走 **unix socket** |
| **F3** | 私网里的目标连不上 | `egressCidrs` 语义:empty = `0.0.0.0/0`(**except private ranges**) | 收敛 egress 前先确认目标不在私网 |
| **F4** | 实例「起不来」被误判成 OOM | 🔴 **先查属主/EACCES** —— root 跑过 `dsh --profile` ⇒ 属主变 root ⇒ 崩溃循环/404 | `find <home> -user root -exec chown <uid>:<uid> {} +` → 重启 |
| **F5** | 106 上实例必崩 | `users/` 权限**必须 711**(700 ⇒ 实例必崩) | 装机时校验 |
### G 类 · 流程 / 协作
| # | 规则 | 出处 |
|---|---|---|
| **G1** | 🔴 **不抢锁 / 抢不到就停手**;⛔ 绝不"人工删锁 / 接管"(R9) | 平台 §6 / 客户端 §7 |
| **G2** | ⛔ **绝不 `taskkill /IM electron.exe`**(WorkBuddy 自己就是 Electron)⇒ 杀壳**只能按端口定位 PID** | 客户端 |
| **G3** | ⛔ **`launcher \| head` 会让壳静默退出** ⇒ 启动器**不接管道** | 客户端 |
| **G4** | 清目录一律 **`mv` 改名,⛔ 不删**(safe-delete 护栏整会话累计、Node 侧无确认通道) | 客户端 |
| **G5** | ⛔ **不新发凭据体系**、⛔ **不下发平台共享密钥**、⛔ **不新增端口**(走 443) | 对接单 §8 |
| **G6** | ⚠️ **别用 `grep https?://` 判"有没有网络依赖"** —— updater 的 feed 地址来自 `app-update.yml`,源码里**一个 URL 都没有**(该 grep **必然漏**,实测漏过一次) | 客户端 §9 |
| **G7** | ⚠️ **"离线可用"要防的是"挂起",不是"秒拒"** —— 拿"没人监听的端口"(秒 `ECONNREFUSED`)去验离线是**假验证**;每一处对外调用都必须带**上限等待** | 客户端 §9 |
| **G8** | ⛔ **改 / 重启必须写在回执里** —— 别用"改了源码"冒充"改了运行态" | 对接单 §7-⑩ |
---
## §5 验证基建:判据分五层(**每层都要能变红**)
| 层 | 怎么验 | 能证明什么 | ⛔ 不能证明什么 |
|---|---|---|---|
| **1 语法** | `node --check` | 语法合法 | 一切运行时行为 |
| **2 契约** | `scripts/build.mjs` 封包契约门禁 | 封包格式对 | 浏览器能否加载 |
| **3 装配** | profile 里出现该包 | 装配通 | 运行态挂载 |
| **4 运行态** | **活体 boot**(`startup-*.log` 有 fiber outcome) | 挂载通 | UI 能否渲染 |
| **5 浏览器级** | 真壳出现卡片 + **JS 错误 0** + 无 `Failed to load plugins` | ✅ **UI 真的能用** | — |
🔴 **三层铁律**:
1. **UI 类交付必须有第 5 层**(`node --check` + 可组合 + boot 零报错**三条全绿也证明不了**)
2. **每条判据都要做负控**(改坏 ⇒ 必须红)—— ⛔ 不通过负控的判据不是判据
3. **反例与正例同等重要** —— 光验 happy path 不算验过(对接单 §5 已明写三条反例判据)
---
## §6 接续棒编排(**规划棒 ↔ 执行棒交替**)
### 6.0 🔴 第 0 号停点:**用户确认门**(2026-09-26 13:19 立)
**用户原话**:「现在只做方案 落地详细步骤和方案做好了 确认后在执行」。
⇒ 🔴 **本线的棒分两类,开跑条件不同**:
| 棒的类型 | 能不能自动开跑 | 依据 |
|---|---|---|
| **规划棒**(只出方案件 / 交接单;不改码、不部署、不 ssh) | ✅ **可以** | 属于"做方案"本身 |
| **执行棒**(任何落码 / 部署 / 改配置 / 动服务器) | 🔴 **一律先交用户确认**,⛔ **不许自动登记** | 用户明令"确认后再执行" |
**这条门禁怎么落地**(三条,⛔ 缺一不可):
1. 规划棒收官时 **⛔ 不登记执行棒**;只把成果交用户确认,并把"下一棒"写进入口「⏭️ 本线下一项」+ 逐字标注「**⏸ 等用户确认后才可立棒**」。
2. 用户确认后,才由**用户或新会话**立执行棒(⛔ 不预登记队列)。
3. 执行棒自己的 prompt 里也要写:**范围以用户确认过的口径为准**,⛔ 不自行扩大。
⚠️ **为什么这条要写进方案、而不是只写在入口**:本 §6.1 原本写着"①→⑧ 自动交替接力",照那样跑**会自动把执行棒带起来** ⇒ 与用户明令冲突。**已在第 38 棒的 automation prompt 里同步掐掉"登记下一棒"这一步。**
### 6.1 棒次表
| 棒 | 类型 | 目标 | 产出 / 判据 |
|---|---|---|---|
| **①** | 规划棒 | 冻结**包规格书**(S1)+ 把 S0 的验证基建写成执行单 | `05-交接单/交接单_包规格书_20260926.md`(8 段);判据=**零条扩展点只在 Web 端存在** |
| **②** | 执行棒 | S0 立验证地基 | 四判据脚本 + 浏览器级脚本;判据=**负控红/绿各一次** |
| **③** | 规划棒 | 按规格书出 S2+S3 执行单 | 交接单;判据=规格书与执行单**逐条对得上** |
| **④** | 执行棒 | S2 空包两端落地 | 两端出现卡片、JS 错误 0 |
| **⑤** | 执行棒 | S3 数据面 | 建库→迁移→对账三轮绿 + 负控 |
| **⑥** | 执行棒 | S4 接进既有分发机制 | 指纹逐字相同 + **三条反例判据** |
| **⑦** | 规划棒 | S5–S9 一块一单(或合并成两单) | 交接单 |
| **⑧** | 执行棒 ×N | S5 → S9 逐块迁移(**一块一棒**) | 每块各自的验证 + 回归 |
🔴 **上表的开跑门(按 §6.0)**:**① 规划棒可自动开跑**;**②–⑧ 全是执行棒 ⇒ 每一棒都要先拿到你的确认**,⛔ 不许上一棒自动带起来。⚠️ 也就是说:**这张棒次表是"路线图",不是"排期"。**
⚠️ **块间并行的判据**:S5–S9 **不要并行** —— 它们**共用一个库、一套迁移入口**(§4-D1)⇒ 域不重叠这条**不成立**。
### 6.2 每棒的固定纪律(照抄进 prompt)
```
第 0 步:跑 state.py 看状态(只读、免抢锁)。
第 1 步:preflight-lock.sh "<会话名>" <目标文件...> —— 【D】未归类 或【E】机制层非空 ⇒ rc=1 ⇒ 拒开工。
第 1b 步:handoff-guard.sh --claim-exec "<会话名>" --domains <域...>;抢不到 = 只报告并停(⛔ 不删锁,R9)。
第 2 步:读唯一执行依据 = 入口 §2「🎯 本轮动作」块点名的那份交接单。
收尾四件套(缺一即算未完成):释放锁(必带会话名)· 🔴 接续(规划棒:可登记"下一棒";**执行棒:⛔ 一律不登记**,见 §6.0)· 推进入口 §2 · 写当日日志。
约束:⛔ 不 commit / push;⛔ 不改官方 dsh 主程序(R2);⛔ 不重造分发机制;⛔ 不扩大单子范围。
成本纪律:批量活先写脚本;取证 ≤3 条命令;⛔ 不 Glob/Grep 全库摸底。
```
### 6.3 停点清单(⛔ 顺序不可颠倒)
| 序 | 停点 | 触发时怎么办 |
|---|---|---|
| **0** | 🔴 **用户确认门**(§6.0) | **任何执行棒开跑前**都要过;⛔ 规划棒也不许替用户跳过 |
| **1** | **P1**(桌面客户端要不要有只读读包权 · §7) | 撞上 ⇒ 停下问,⛔ 不许自动跨过 |
| **2** | **P2**(平台侧那一半怎么落) | 同上 |
| **3** | **F1.5**(G1 序号 + G6 撤根) | S8 的**硬门** —— 定稿原文「F1.5 是一切的前置」 |
| **4** | **S1 待拍板**(106 现存实例怎么处置) | 带数据不可逆 ⇒ 必须先拍板 |
⇒ 🔴 **一句话**:**规划棒可以自动跑;执行棒要过两道门 —— 用户确认门(0)+ 它撞上的具体停点。**
---
## §7 停点与待拍板
> ⛔ 顺序不可颠倒;**本轮要你确认两件:C0(路线)+ P1(权限)**(P2 与前一轮的 S1 同批,等 P1 定了再问)。
### 🔴 C0 —— 这份方案的整体路线,认不认?
**要你定的是**:后面**要不要按这份方案往下走**(S0 → S9,先立验证地基再动手,一块一棒)。
**为什么需要你定**:这是"**做不做、按什么顺序做**",属**业务优先级**,⛔ 不是技术取舍 ⇒ 我不自决。
**A 案 · 认这份路线**
(优点:S0 先立尺,后面每一步都有**可复现、能变红**的判据,不会出现"看着做完了其实没生效";一块一棒、**每一步都停得住**。
缺点:S0+S1 是**纯准备**,前期看不到画面,要等 S2 才第一次出界面。)
**B 案 · 先只做 S0+S1,看效果再定**
(优点:成本最低、**零副作用零部署**、不留半成品;看完规格书与判据再决定要不要继续,判断依据最足。
缺点:要分两次做决定。)
**C 案 · 调整顺序或范围**
(比如先做某一块、或砍掉某一块 —— 直接说改哪一处即可。)
**我的倾向**:**B 起手,认可后再走 A**(可推翻)。理由:S0/S1 不改一行代码、不碰服务器,做完你手上会多一份**可执行的包规格书** + 一套**能变红的判据**;这时候再决定后面走不走,依据最足。
### 🔴 P1 —— 要不要让桌面客户端有权读取平台的插件包?
**要你定的是**:桌面上的 dsh 实例,要不要被允许**从平台把插件包拉下来**(只读,不写)。
**为什么需要你定**:这是**权限面的扩大** —— 设备在此之前只能"入网",此后能**读到平台的插件包内容**。影响的是**所有装了客户端的设备**,不是一台机器;而权限一旦放开,收回要靠撤设备授权。
**A 案 · 放开(只读)**
(优点:桌面本机实例**终于能拿到平台业务插件**,否则那个能力栏永远不出现;复用**已有设备公钥**校验,⛔ 不新发凭据、⛔ 不下发平台共享密钥、⛔ 不新增端口;三口**全是只读**,读不到别的用户的数据、写不了任何东西;**可撤回** —— 撤回设备授权即失效。
缺点:可见面**确实扩大了一小格**(设备从"只能入网"变成"能读包库");需要平台侧改一处校验臂并重启控制面。)
**B 案 · 不放开**
(优点:可见面**一格不扩**,维持现状最小权限;⛔ 平台侧零改动。
缺点:桌面本机实例**永远拿不到平台业务插件** —— 那个缺口长期存在,「一个包两端一致」这件事在客户端侧**落不了地**,等于**一半的方案做不成**。)
**我的倾向**:**A**(可推翻)。理由:这是**小幅、可撤回、只读**的扩展,而它换来的是"两端装同一个包"这个**核心目标**;⛔ 不应为了一格只读可见面放弃整个目标。
---
## §8 出处
| 内容 | 出处 |
|---|---|
| 五块能否进一个包 · 三件留外面 · 平台进程装不了插件 | `交付物/全功能承载判定-底层插件能否承-20260926.md` §3.12 |
| 承载两分(公共 / 主权) | `交付物/AI1NET-三类节点结构调整可行性-20260926.md` §3 |
| 插件一等公民 · 桌面扩展点对照 · `webServer` 不存在 · 精确路径 | 客户端 `docs/S6_官方二次开发能力与桌面端扩展点实测_20260921.md` §1–§5 |
| Windows 起实例的唯一改动点 · 隔离档 soft · 离线防挂起 · updater 三重可容忍 | 客户端 `CODEBUDDY.md §9` |
| 客户端红线(凭据不下发 / 环境变量白名单) | 客户端 `CODEBUDDY.md §4 R2·R5` |
| lazy-CJS 封包契约 · 必须有浏览器级判据 | 客户端 `.workbuddy/接续入口_desktop_20260925.md` §2 ⑥ ⑪ |
| 分发四步协议 · 三条语义纪律 · 坑清单 10 条 · 边界 5 条 | 客户端 `docs/对接单_桌面端接入版本管理与包更新机制_20260926.md` |
| 平台权威实现(可照抄) | `src/worker/agent.ts` 的 `syncSharedLayerOnce()` / `pullOnePack()` |
| 一插件一库 · 库名规则 · `link:` 装配 · 列级 maxBytes | `src/db/plugin-data/schema.ts` · `diff.ts` · `registry.ts` |
| bwrap 版本 · nft loopback · `egressCidrs` · 属主/EACCES | 平台 `.workbuddy/memory/MEMORY.md` 本机铁律 + `src/supervisor/*` |
| 编排方法 | 技能 `dsh-auto-handoff-chain` |