# 成长工作台 · 架构文档 > `dsh-growth-workbench@0.1.0` DSH Bundle(宿主半边 + 浏览器半边 + 一个 profile 补丁层) > 与同类的 DSH 侧栏插件同构(宿主半边 + 浏览器半边 + 一个 profile 补丁层),只是领域规则更多。 --- ## 一、全景 ``` ┌──────────────────────────── DSH 进程(Node) ────────────────────────────┐ │ │ │ profile: web │ │ bundles: [ @deepseek-ai/dsh-base, @deepseek-ai/dsh-web-app, │ │ …, dsh-growth-workbench ] │ │ │ │ │ cordis.patch.yml 的 insert 行 │ │ ▼ │ │ ┌──── 宿主半边(index.mjs) ────────────────────────────────────────┐ │ │ │ apply(ctx) │ │ │ │ ├─ ctx.webServer.register({ prefix:'/gw/api', handler }) ────┼───┼──► 浏览器 fetch │ │ └─ ctx.tools.register( ×4 growth_* ) ─────────────────────────┼───┼──► Agent │ │ │ │ │ │ api.mjs ──┐ tools.mjs ──┐ │ │ │ │ ▼ ▼ │ │ │ │ store.mjs ◄──── validate.mjs ◄──── model.mjs │ │ │ │ │ │ │ │ └────────────┼─────────────────────────────────────────────────────┘ │ │ ▼ │ │ $DSH_HOME/growth-workbench/{profile,plan,progress,assessments}.json │ │ ▲ ▲ │ └──────────────────────────────┼───┼───────────────────────────────────────┘ │ │ 同一份文件、同一套校验 ┌──────────────────────────────┼───┼──────── 浏览器 ───────────────────────┐ │ ┌──── 浏览器半边(client.js) ──────────────────────────────────────┐ │ │ │ __ModuleLoader__.load({ id, factory }) │ │ │ │ 左侧菜单:sidebar.panellist 行 + main 页(四页签 Panel) │ │ │ │ 右侧 Tab:sidebarRightTabs 类型 + sidebar.right.pane.tab 体 │ │ │ │ 两份视图共享一个刷新通道(reloaders) │ │ │ └──────────────────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────────────┘ ``` **核心架构决定:数据只有一份,在磁盘上;页面与 Agent 是它的两扇门,不是两份拷贝。** 这份数据不在浏览器里,而在宿主进程旁边 —— 页面和 Agent 天生读的是同一份。**不存在"把数据搬给 Agent"这件事**:不需要导出、不需要找文件、不需要为搬运定义格式、也不需要修补编码。 --- ## 二、为什么是 Bundle,为什么双端 ### 2.1 Bundle 判定标准 一个包是不是"插件",看它有没有 `dsh.bundle.patch`。`dsh plugin --profile web add` 是个 **pnpm 转发器**:跑完 pnpm 后**按安装结果**(不是按依赖 diff)reconcile——凡解析到的包里声明了 `dsh.bundle.patch`,就追加进 `dsh.profile.bundles` 层栈;没有的只当普通依赖,并打一条 warning。 所以本包的三处身份必须**逐字一致**,否则表现为静默不挂载或启动期报错: | 位置 | 值 | |---|---| | `package.json` 的 `name` | `dsh-growth-workbench` | | `cordis.patch.yml` 插入行的 `id` 与 `name` | `dsh-growth-workbench` | | `index.mjs` 的 `export const name` | `dsh-growth-workbench` | | `client.js` 的模块 id | `dsh-growth-workbench` | `selftest.mjs` 断言这四处。 ### 2.2 为什么必须有宿主半边 三个理由,缺一个都构不成双端: 1. **数据要落在磁盘上**(页面访问不到 `$DSH_HOME`) 2. **Agent 要能读写同一份数据**(`ctx.tools` 只在宿主侧) 3. **写入要过校验**(校验必须发生在**所有**写入路径上,包括 Agent 那条) ### 2.3 宿主半边不 import 任何 Harness 包(硬约束) `dsh plugin add ` 装的是 **link**,模块真实路径留在 profile 的解析根之外,裸 `@deepseek-ai/...` import **解析不到**。官方包能这么写是因为它们被打包进 profile 的 `node_modules`;外部 link 包不行。 所以: - 工具定义为**普通对象**,`parameters` 与 `output.schema` 直接用裸 JSON Schema(就是 `defineTool` 编译出来的形状) - `@deepseek-ai/dsh-tools` 在本仓库里任何地方都不 import:工具定义直接手写裸 JSON Schema,就是 `defineTool` 编译出来的形状。开发期曾用一个外部脚本加载真 `defineTool` 逐成员比对(结论是深相等);该脚本依赖本机真装的 Harness,不随本仓库分发 - 代价:`defineTool` 会生成 `validate(args)` 前置断言,我们得自己写;对一个无参工具就是一行 **这条约束反过来也是个好处**:宿主半边与领域层可以在普通 `node` 里直接跑 —— 离线自检的绝大部分就是这么验的,不需要 Harness、不需要 DSH 在运行、不需要浏览器。 --- ## 三、分层与模块职责 | 文件 | 层 | 职责 | 依赖 | |---|---|---|---| | `model.mjs` | 领域(纯) | 能力模型、权重公式、加权缺口、完成率/连续打卡/当前阶段、rubric 与定级、曲线规则 | 无 | | `store.mjs` | 持久化 | 四份文档的读写、原子写、打卡累积、历史只追加 | `node:fs` | | `validate.mjs` | 领域门禁 | 计划与考核的写入校验、任务标识分配 | `model` | | `api.mjs` | 传输 | 页面用的 HTTP 路由,组装 `/state` | `store` `model` | | `tools.mjs` | 传输 | 六个工具、给 Agent 的简报渲染 | `store` `model` `validate` | | `index.mjs` | 装配 | 注册路由与工具,全部走 `ctx.effect` | `api` `tools` | | `client.js` | 表现 | 左侧整页(四页签)+ 右侧 Tab | 浏览器 `fetch` | **分层的硬规则**:`model.mjs` 不碰 I/O,`store.mjs` 不懂领域。这两条让整个领域逻辑可以在纯 `node` 里测,不需要 Harness、不需要浏览器。 ### 3.1 为什么页面与 Agent 走两条传输路径 | | 页面 | Agent | |---|---|---| | 通道 | `POST /gw/api/*` | 工具调用 | | 校验 | 同一套(`api.mjs` 调 `validate.mjs`) | 同一套(`tools.mjs` 调 `validate.mjs`) | | 存储 | 同一份文档 | 同一份文档 | 两条路径**共用 `store.mjs` 与 `validate.mjs`**,所以不可能出现"页面能写进去、Agent 写不进去"或反过来。这是"只有一份数据"能成立的技术前提。 --- ## 四、与 DSH 的接缝(**最容易踩坑的部分**) ### 4.1 左侧菜单行 ```js ctx.effect(() => ctx.slots.inject('sidebar.panellist', () => ctx.slots.register( { name: 'sidebar.panellist', id: PANEL_ID, order: 50, label: LABEL }, PanelIcon, // React 组件,接收 { size, active } )), '…: left menu row') ``` - `sidebar.panellist` 是 **list** 型槽位:`id` 同时是「主面板 key」,sidebar 拿它去 `ctx.layout.selectPanel(id)` - `label` 是行的标题;`order` 决定排在工作台(40)之后 - 图标**继承 `currentColor`**,sidebar 拥有按钮本身 ### 4.2 左侧整页 ```js ctx.effect(() => ctx.slots.inject('main', () => ctx.slots.register( { name: 'main', key: PANEL_ID }, // key 必须与 sidebar 行的 id 一致 Panel, )), '…: left menu page') ``` **关键事实**:`main` 是 **root 作用域**,`sidebar.right.pane.tab` 是 **session 作用域**。一个 handle 不能跨作用域,所以两边的视图是两个实例——这就是为什么需要刷新通道(§6.4)。 **并且:左侧页面不在启动时自动选中。** 选中主面板 = **顶掉对话**,工作台不该开机抢屏。用户点那行才进。 ### 4.3 右侧 Tab 两段式,和原生类型走的是同一条路(`dsh-client-ui-sidebar-documentpreview` 是官方活证): ```js // 第一段:类型(静态定义,不含组件) ctx.sidebarRightTabs.register({ id: TAB_ID, kind: TAB_KIND, priority: 'extension', title: () => '今日', guide: [{ order: 30, title: () => '今日任务', description: () => '…' }], }) // 第二段:Tab 体进 keyed 槽位,key = 上面的 id ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( { name: 'sidebar.right.pane.tab', key: TAB_ID }, TodayTab, )) ``` **注意**:`sidebarRightTabs.register(...)` **不接受 React 组件**。契约是 `{ id, kind, title(address), patterns?, priority?, canOpen?, guide? }`——组件走第二段。 **注册类型 ≠ 打开 Tab。** 这是个 **page type**(无 `patterns`),必须 `ctx.sidebarRight.openTab(kind)`。 **而 `openTab` 在列还没挂载时会抛**(启动最初几百毫秒),所以: ```js function revealTabWhenReady(ctx) { let attempts = 0; let timer; const tick = () => { timer = undefined; if (attempts >= 40) return; // ~20 秒后放弃 attempts += 1; try { ctx.sidebarRight.openTab(TAB_KIND); } catch { timer = setTimeout(tick, 500); } // 给不出座位 → 等下一次 }; tick(); return () => { if (timer !== undefined) clearTimeout(timer); }; } ``` 放弃后仍能从右栏 guide 入口进去。右侧 Tab **自动展开**是安全的(不动对话),左侧页面**绝不**自动选中。 ### 4.4 工具 ```js ctx.tools.register({ name, description, parameters: { type: 'object', properties: {…} }, // 裸 JSON Schema output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: String(v) }] }, async execute(args) {…}, presentCall: () => ({ card: 'generic', title, kind: 'other', rawInput: {} }), }) ``` - `execute` **必须是 async**(或返回 Promise):错误必须是 rejected promise,不能是同步 throw - `parameters` 用平台约定:空参数是**开放对象根** `{ type:'object', properties:{} }`(`defineTool({parameters:{}})` 就这么编译),所以多余参数由 `execute` 自己拒绝 - 输出声明为 `string` 时,`output.schema` 就是 `{ type: 'string' }` ### 4.5 浏览器半边的加载契约 ```js window.__ModuleLoader__.load({ id: 'dsh-growth-workbench', factory: (require) => { … return { apply, inject } }, }) ``` - **factory-form CJS**,不是 ESM 打包产物:宿主端把 `exports['./client']` 的路径直接喂给浏览器 - `require` 只在**平台种子**里解析,种子(实测自 `dsh-web-frontend` 的 `by()`)为: ``` react, react/jsx-runtime, react-dom, react-dom/client, @deepseek-ai/cordis, @deepseek-ai/dsh-client-store, @deepseek-ai/dsh-client-ui-slots, @deepseek-ai/dsh-client-ui-primitives, @deepseek-ai/dsh-client-ui-dockkit ``` 种子外的一切要靠模块图(`dsh.client.inject`)或自包含。本插件只用 `react`,所以没有额外依赖。 - `package.json` 需要 `dsh.client.platform = 'web'` 与 `exports['./client']`;客户端模块系统据此发现它。`immediately: true` 让它在 phase-one 就注册(Tab 类型要在列挂载前就位) --- ## 五、数据模型 四份文档,各自原子写(同目录临时文件 + `rename`): ``` $DSH_HOME/growth-workbench/ ├── profile.json 画像(唯一长期资产) ├── plan.json 计划 ├── progress.json 打卡(一天写很多次) └── assessments.json 考核与自评历史(只追加) ``` **为什么分四个文件**:页面一天要写 `progress.json` 很多次,Agent 偶尔写 `plan.json`。合成一个文件,每次打卡都变成与"改计划"的潜在冲突。 ### 5.1 `profile.json` ```jsonc { "version": 1, "currentRole": "", // 当前身份 "targetRole": "数据运营", "targetRoleSlug": "data-ops", "targetRoleStatus": "preset", // preset | generated | building(自己填的方向;旧数据里可能是 beta) "positioning": "", // 方向一句话定位 "route": "稳妥路线", "timePerDay": "约 1 小时", "deadline": "2026-12-25", "constraints": [], // Q1 + Q3 推出来的约束 "verifiedFacts": [], // 用户确认过的底盘 —— 只追加 "pending": [], // 模型推测,待确认 —— 只追加 "intake": { "q1":"C", "q2":"B", "q3":"A", "q4":"B" }, "selfAssessment": { "date":"…", "scores": { "A1":3 }, "gap":0.14, "weight":1300 }, "updated": "…" } ``` `verifiedFacts` 与 `pending` 由 `updateProfile` 去重并**只追加**:未经确认的推测绝不能覆盖已确认的事实,确认过的事实也不能抹掉"它曾是推测"这条记录。这是三层事实隔离在代码里的落点。 ### 5.2 `plan.json` ```jsonc { "version": 1, "planStart": "2026-09-25", // 第 1 天 —— 必填 "role": "数据运营", "route": "稳妥路线", "goal": "90 天能独立产出一份…", "phases": [{ "name": "基础", "days": [1, 30], // 闭区间,必须不重不漏铺满 1..N "goal": "…", "project": "…", "criteria": "…", "tasks": [{ "id": "T1", // 稳定标识 —— 存储身份 "ref": "1.1", // 派生标识 —— 只给人读 "day": 1, "phase": "基础", "action": "…", "capability": "A2", "reason": "…", "minutes": 30, "minimumVersion": "…", "doneCriteria": "…", "acceptableEvidence": "…", "dependsOn": "无" }], "weeks": [{ "week": 5, "theme": "…", "acceptance": "…" }] }], "portfolio": [], "selfCheck": [{ "id":"Q1", "phase":"基础", "question":"…", "capability":"B1" }], "resources": [], "nextTaskNumber": 4 // 下一个新任务取这个号 } ``` **`ref` 与 `id` 的分工是这套模型的支点**: | | `ref`(`2.3`) | `id`(`T7`) | |---|---|---| | 来源 | 位置派生 | 生成时分配,跨阶段全局自增 | | 可变 | **会移位**(一插任务就变) | **永不变** | | 用途 | 显示、`前置依赖`、给人读 | **存储 key**、跨会话关联 | | 禁止 | 任何持久化 key | —— | 用 `ref` 做 key = 把用户的勾选挂在"第 2 阶段第 3 个位置"上;计划一细化,记录整体错位——**显示错位升级成数据错位**。 ### 5.3 `progress.json` ```jsonc { "version": 1, "tasks": { "T1": { "done": true, "evidence": "标注截图已存到工作区", "tier": "过程", // 自述 | 过程 | 成果 | null "checkInDates": ["2026-09-25","2026-09-26"], // 累积、升序、去重 "lastDate": "2026-09-26" // 给人读的最近一次 } } } ``` **`checkInDates` 累积不覆盖**:连续打卡天数是从**全部任务打卡日的并集**数的。覆盖式写入会让"同一个任务连打三天"读成 1 天——而"每天只推进同一个任务"正是减量规则要求的状态,**最容易掉队、最需要正反馈的那批人会看到最错的数字**。 `evidence` 为空时 `tier` 强制回落 `null`:没交证据 ≠ 自述。 ### 5.4 `assessments.json` ```jsonc { "version": 1, "history": [ // 按 date 升序,只追加 { "date":"…", "day":30, "kind":"self", "coverage":"全量", "scores": null, "gap":0.14, "gapWeight":1300, "answered":20, "skipped":0, "confidence":"medium", "sources":["页面"], "unsubmitted":[], "curvePoints":[…] }, { "date":"…", "day":30, "kind":"review", "scores": { "完成率":8, "证据质量":18, "作品达标度":15, "知识考核":20 }, "total":61, "grade":"及格", "gradeAction":"针对薄弱项补强,不换方向", "confidence":"medium", "sources":["页面"], "coverage":"全量", "unsubmitted":[], "attribution":"计划问题", "adjustments":[{ "任务标识":"T1", "一句话改动":"…", "改了什么":"…" }], "report":"…", "curvePoints":[…] } ] } ``` **`curvePoints` 每点形如** `{ 能力项, 分, 置信度, 证据档位, 来源 }`——`日期` 与 `第几天` 由所在条目提供,不重复存。不带 `置信度` 与 `来源` 的点画出来是骗人的,所以这两个字段是强制的。 --- ## 六、控制流 ### 6.1 页面读:一次调用 ``` 浏览器 GET /gw/api/state → buildState() readAll() 读四份文档 model.planTasks(plan) 展开任务并补 ref / phase / day model.dayNumber / currentPhase 今天第几天、在哪个阶段 model.completionRate 已完成/全部两个数 model.streakDays 打卡日并集 → 连续天数 model.weekRate 本周口径(可能 null) model.evidenceDistribution 四档分布 model.gapAnalysis 加权缺口 + 补强优先级 + 未确认组 model.curvePoints 只有全量轮次进曲线 ← { ok, today, profile, plan, progress, history, curve, metrics, focus, catalog } ``` **一次调用而不是十几次**:页面的读数全部派生自同一批文档,拆开会让面板渲染出"两个不同时刻的两半"。 `catalog` 把能力模型、路线、四题、rubric、八行表、铁律一并送回——**页面上能看到的规则文案,与代码里执行的是同一份数据**。 ### 6.2 页面写 ``` POST /gw/api/ → serve(res, () => mutate(route, body)) ├─ 校验入参(越界、缺字段、非法枚举 → 400 + 可读原因) ├─ 调 store.* 落盘 └─ 返回受影响的部分 ``` 页面首次加载后只走增量:`/state` 重取一次 + 通知另一个视图(§6.4)。 ### 6.3 Agent 写:唯一经过门禁的路径 ``` growth_save_plan → 画像里有方向吗?(没有 → 明确拒绝:"先在页面上选定方向与路线") → validate.canonicalPlan(input, existing, roleSlug) ├─ planStart 必填且合法 ├─ goal 非空;阶段数 1-8 ├─ 天区间必须从 1 起、首尾相接、不重叠不留缝 ├─ 每任务:8 字段齐全 / minutes 15-60 / capability 在模型里 ├─ dependsOn 是 `无` 或真实存在的任务引用 ├─ 任务标识:沿用(合法、本次未占用、且磁盘上还有这个任务) │ 否则现分配 max+1;落在已退休号段 → 拒绝 ├─ selfCheck 每题挂能力项、且不得含答案 └─ 有任一问题 → 一次性列出全部,抛错(不写入) → store.updatePlan(canonical) → 返回摘要:起始日→第 N 天 / 阶段区间 / 标识范围 / 下一个可用号 ``` **门禁放在写入路径上,不是放在文档里。** 这几条规则的失败模式都是"不抛异常,只是让数字变错",所以必须在入口拦住。 ### 6.4 两份视图的同步 ```js const reloaders = new Set() function refreshOthers(writer) { for (const reload of reloaders) if (reload !== writer) reload() } ``` `main`(root 作用域)与 `sidebar.right.pane.tab`(session 作用域)是**两个实例**,各自 `useWorkbench()` 时把自己的 `load` 注册进来。任一边写入成功后调 `refreshOthers(load)`。 不这么做的话,用户在左侧勾完、切到右侧发现没变,会以为坏了。 ### 6.5 自评轮次:证据进入曲线的唯一通路 ``` POST /gw/api/self-assessment { scores: { A1: 3, … } } → model.gapAnalysis 加权缺口(null 不进分母) → model.reviewWindow 本轮窗口 = [上一轮评估日 + 1 天, 今天] → 逐项 model.windowTier 窗口内是否有该能力项、达到某档位的打卡 → model.selfPointConfidence 默认 low;有过程 → medium;有成果 → high → appendAssessment({ kind:'self', coverage:'全量', curvePoints }) → updateProfile({ selfAssessment }) ``` **任务证据不直接产生分数**——它唯一的作用是给自评点抬置信度。窗口以"上一次评估"为界而不是固定 30 天:考核间隔本来就不固定,固定窗口会让一段证据被算进两个窗口、另一段哪个都不算。 --- ## 七、不变量与两道门禁 ### 7.1 不变量 | # | 不变量 | 违反的后果 | 谁保证 | |---|---|---|---| | 1 | `任务标识` 全局唯一、永不变、删除不复用 | 旧打卡记录被继承到新任务 | `canonicalPlan` | | 2 | 存储 key 只用 `任务标识`,绝不用 `任务引用` | 计划一变动,记录整体错位 | `store.checkIn` 校验 `T` 形式 | | 3 | 阶段 `days` 不重不漏铺满 `1..N` | 第 31 天起当前阶段永久错 | `canonicalPlan` | | 4 | `planStart` 必填 | 「第几天」/周次/阶段整体错位 | `canonicalPlan` + 页面显式告警 | | 5 | `未确认` 记 `null`,不进分母、不补 0 | 造出虚假的巨大差距 | `gapAnalysis` | | 6 | `evidence` 为空 → `tier` 为 `null` | 把"没交证据"读成"自述" | `store.checkIn` | | 7 | `checkInDates` 累积不覆盖 | 连续打卡天数失真 | `store.checkIn` | | 8 | 考核历史只追加、按日期升序 | 丢趋势 | `store.appendAssessment` | | 9 | `verifiedFacts` / `pending` 只追加 | 推测升格为用户事实 | `store.updateProfile` | | 10 | `null ≠ 0`(本周完成率、完成率无任务时) | 报告里写出用户没发生过的"停滞" | `model` + 页面渲染 | | 11 | 只有 `覆盖项集 = 全量` 的轮次进曲线 | 用子集冒充全域 | `model.curvePoints` | | 12 | 三处身份一致(name / patch id / client id) | 静默不挂载 | `selftest.mjs` | ### 7.2 两道门禁 **`validate.canonicalPlan`** —— 计划写入前。检查项见 §6.3。返回规范化的计划(含分配的标识与 `nextTaskNumber`)。 **`validate.canonicalReview`** —— 考核写入前。四维必须齐全、各 0-25;`day` 必须 ≥1;`coverage` 只接受 `全量`/`定向`;`unsubmitted` 只接受四维名。 两道门禁都是**纯函数**:无 I/O、无状态,因此可以穷举测试。 --- ## 八、扩展点 ### 8.1 加一个方向(能力模型) 在 `model.mjs` 的 `ROLES` 里加一份(`groups` 权重合计必须 100%、每组 > 0%),并把 `ROLE_CHOICES` 里对应项的状态从 `building` 改成 `preset`。 ```js 'product-ops': { slug: 'product-ops', name: '产品运营', status: 'draft', positioning: '提升产品核心指标。', groups: [ { key:'A', name:'…', weight: 30 }, … ], // 合计 100,每组 > 0 items: [ { id:'A1', group:'A', name:'…', level:'高', anchors: ['1 分锚点','3 分锚点','5 分锚点'] }, … ], } ``` **每组必须 > 0%**:`0 / 50 / 50` 也能凑满 100%,但一个 0% 的组等于**整组能力不参与加权缺口**——它照样出现在报告里、照样有锚点,却在 `W` 里权重为零,比"合计不对"更隐蔽。 锚点**不得写"待补""…"这类占位**;写不出 5 分锚点就是这份模型还没落地,把状态留在 `building`。 ### 8.2 调整权重系数 `LEVEL_COEF = { 高:3, 中:2, 低:1 }` 与 `DEFAULT_TARGET_SCORE = 3` 是**实现期定的可调标准**,不是从数据里推出来的。改动前先认清后果:`W` 会变,历史轮次的 `gap` 与新轮次不同分母——`gapWeight` 已存进每条记录,就是为了让这种比较能被识别出来。 ### 8.3 加一条反馈机制 `FEEDBACK_ROWS` 是数据不是代码:`{ key, says, mechanism, dimension }`。`growth_save_assessment` 会按最低的那一维去查对应的行。加一行即可,但 `dimension` 必须是四维之一。 ### 8.4 接考核提醒 宿主端已经是一个有生命周期的 Cordis 插件(`ctx.effect` 管着路由与六个工具)。加调度器就是再挂一个 `ctx.effect`,并在 `stop` 里清掉定时器。**计划里已经写了下一次考核日期**(调整项里),定时器读它即可。 ### 8.5 加一个 HTTP 路由 `api.mjs` 的 `mutate()` 是一个 switch。新增分支时:**必须走 `store.*`,不要直接写文件**——原子写与各种"只追加"的语义都在那一层。 --- ## 九、验证策略 三层,越往下越贵,所以上面的层要尽可能盖住: ### 9.1 离线自检(116 项,`node selftest.mjs`,不需要 Harness) 覆盖: - 7 个文件各自按加载形式解析(`node --check`) - 四处身份一致;`files[]` 含 `cordis.patch.yml`;无运行时依赖 - **每个宿主文件都不 import Harness 包**(link 安装的硬约束) - `apply()` 只注册 1 条路由前缀 + 6 个工具,且都可 dispose - 能力模型与源表一致(20 项 / 四组权重合计 100 / 11 项高权重 / 每项 3 个锚点) - 权重算例:全答 `W = 1300`;文档里的 `gap = 180/1300` 复现 - 未确认项不进分母;补强优先级按单项缺口降序;高权重项超 10 项按组权重截断 - 完成率 / 连续打卡(并集)/ 第几天 / 当前阶段 / 本周完成率为 `null` - 曲线:只有全量轮次进;不得只用自评点宣称提升 - **门禁逐条反例**:缺 `planStart`、天区间有缝、缺字段、分钟越界、能力项不存在、自查题带答案、复用退休编号、依赖指向不存在 —— 全部必须被拒 - 六个工具端到端跑在一个临时 `DSH_HOME` 上 - 12 条 HTTP 路由 ### 9.2 真浏览器验收(无头 Chrome + DevTools Protocol)—— 脚本不随本仓库分发 > **本节所述脚本不随本仓库分发。** 它依赖本机排障环境,因此没有随开源仓库发布。下面记录的是它覆盖的内容,用来在将来重建这套验收、或改由人工执行时当清单用。 用的是**一次性实例**(独立 `DSH_HOME` + 独立端口),绝不碰用户真实数据。流程: 1. 用**真工具**(`growth_save_profile` / `growth_save_plan` / `growth_save_assessment`)灌数据 2. 打开页面 → 断言左栏有行、可点 3. 点进左侧整页 → 四个页签都在 4. **今日页勾选一个任务 → 回查 `/state`,确认落进了 `progress.json`**(这一条是页面与磁盘的接线证明) 5. 计划页:阶段天区间、8 字段表、只排到周提示、考核自查题 6. 画像页:四题、方向、底盘、**逐项 1/3/5 锚点原文**、20 项四组、「拿不准」通道 7. 考核页:Agent 写的历史、四维、定级动作、调整版任务 8. **页面上点分完成一次自评 → 提交 → 查 gap 只含已确认组、未确认组被标出、轮次进历史** 9. 右栏「今日」Tab 有内容 10. 全程 `Runtime.exceptionThrown` 与 `console.error` 计数为 0 **为什么这层必须有**:离线自检证明不了"页面画出来了"。而这次的实测确实抓到了两个只有真浏览器才暴露的问题: - 计划页的「考核自查」与「作品集」整段消失且**不报任何错**(深层 `createElement` 丢了一个 `)`,把后半棵树挪进了错误的父节点) - 自评点选用闭包里的旧 `scores`,连点几下时**最后一次覆盖前面几次**(14 次点击只存下 1 项) 两个问题离线自检都是绿的。 ### 9.3 契约等价性 —— 脚本不随本仓库分发 > **脚本同样不随本仓库分发**(它需要本机真装的 Harness 才能加载真 defineTool)。下面保留它当时的结论与抓到的真问题,作为手写 schema 时的注意事项。 本插件不 import `dsh-tools`(§2.3),所以自己维护编译后的 schema。这个脚本从 profile 的 `node_modules` 里加载**真** `dsh-tools`,用真 `defineTool` 生成参考定义,逐成员比对: ``` PASS parameters match defineTool({}) exactly PASS the output schema matches defineTool exactly PASS every definition member defineTool produces is present PASS the parameter schema passes the registry assertion PASS the output schema passes the registry assertion PASS the declared output accepts the value the tool returns ``` **它抓到过一个真问题**:`defineTool({ parameters: {} })` 编译出的是**开放**对象根(`{type:'object',properties:{}}`),我一开始写成了 `additionalProperties:false`。已改成与平台一致,多余参数由 `execute` 拒绝。 ### 9.4 已知的验证盲区 - **真模型未跑过。** 六个工具是拿固定输入验的;"AI 收到 `growth_context` 的简报后能不能按 rubric 打出合理分数"没有端到端验过。这需要真实对话,本轮没做。 - **Windows 上的 DSH 启动有既有坑**(与本插件无关):PowerShell 5.1 的 `Set-Content -Encoding UTF8` 会写 BOM,而 Harness 的 JSON storage 拒绝带 BOM 的文件,整个 boot 会挂。验收脚本因此用 `node` 写 `workspace.json`。 --- ## 十、技术债与限制 | # | 项 | 影响 | 触发条件 | |---|---|---|---| | 1 | **`client.js` 手写、无构建** | 7.6 万字符的 `createElement` 树靠人工维护;一次丢括号的失败模式是"静默挪错父节点" | 改到第六个页签时该考虑 JSX + 构建 | | 2 | **`client.js` 曾重写三遍** | 前两遍都是括号问题。第三遍改成**所有 children 一律传数组**,这类错误从结构上不再可能 | 已缓解,见 `verify` 注释 | | 3 | **路由无独立鉴权** | DSH web server 默认绑 `127.0.0.1`,handler 拥有响应,信任边界与整个 app 一致 | **若哪天绑到 `0.0.0.0`,必须给 handler 加 `BrowserAuth.isAuthenticated` cookie 校验** | | 4 | **只有一份能力模型** | 其余方向不能逐项自评(页面如实说明) | 见 §8.1 | | 5 | **数据是全局的,不是 per-session** | 右栏 Tab 是 session 作用域,但读的是同一份数据;两个会话看到同一个列表 | 有意为之:画像和计划本来就该跟着人走 | | 6 | **`growth_context scope=all` 可能很长** | 任务多时简报会很大 | 加 compaction 策略,或按阶段过滤 | | 7 | **无并发写保护** | 页面与 Agent 同时写不同文档是安全的(分文件);同时写**同一份**文档是最后写入者胜 | Agent 不写 `progress.json`,所以实际冲突面很小 | | 8 | **`adjustments` 是自由形状** | 工具只校验它是数组,不校验每项的字段 | 若要按调整项生成任务,得先给它定形状 | | 9 | **深自评没有页面入口** | 它是 30-45 分钟那一档,排在计划第 7-14 天,靠对话进行 | 有意为之 | --- ## 十一、速查 ```bash # 离线自检(不需要 Harness、不需要 DSH 在跑) npm test # 语法自检(七个文件各自的加载形式) npm run check # 装到 profile / 看层栈 / 卸载 dsh plugin --profile web add ./dsh-growth-workbench dsh --profile web --dump-config | grep growth dsh plugin --profile web remove dsh-growth-workbench # 页面接口 curl -s localhost:PORT/gw/api/state # 页面读的全部 curl -s localhost:PORT/gw/api/export # 四份文档合一份,自己留存 curl -s -X POST localhost:PORT/gw/api/checkin \ -H 'content-type: application/json' \ -d '{"taskId":"T1","done":true,"evidence":"截图","tier":"成果"}' ``` ``` 数据目录 $DSH_HOME/growth-workbench/ 插件来源 本仓库(link 安装时删了源码目录插件就没了;npm / tarball / git 安装不受影响) 规则真相源 docs/产品文档.md 第六节 ```