@dsh-workbench/module-tools 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  ```bash
9
9
  dsh-wb-module init <unit> [--dir <目录>] [--template minimal|diy] [--description "一句话"]
10
10
  dsh-wb-module build # 出 dist/host.js 与套了 loader 壳的 client.js
11
- dsh-wb-module check [--only <判据>] # 十条判据;--only 可以单跑
11
+ dsh-wb-module check [--only <判据>] # 十一条判据;--only 可以单跑
12
12
  dsh-wb-module design <init|check|render|accept>
13
13
  dsh-wb-module publish # 发货前的清单:门禁 + 三张表 + npm pack 清单(只 dry run)
14
14
  ```
@@ -16,11 +16,12 @@ dsh-wb-module publish # 发货前的清单:门禁 + 三张表 +
16
16
  `--cwd <目录>` 指定包目录(默认当前目录)。模块目录里更省事的是骨架自带的脚本:
17
17
  `pnpm build` / `pnpm typecheck` / `pnpm test` / `pnpm selfcheck` / `pnpm design`。
18
18
 
19
- ## 十条判据(`check`)
19
+ ## 十一条判据(`check`)
20
20
 
21
21
  | 判据 | 盯什么 |
22
22
  |---|---|
23
- | `contracts` | 模块契约:单元 id 唯一、两个面、四个脚本、`files`、peer 下限 |
23
+ | `contracts` | 模块契约:单元 id 唯一、两个面、四个脚本、`files`、peer 下限;以及**清单 ↔ 代码**(真正注册的工具)与**底稿 ↔ 清单**(`design.json` 的能力名)两处对账(见下) |
24
+ | `protocol` | 模块声明协议:`dsh.workbench` 的 `apiVersion` 要在、静态半形状要对、不许有协议不认识的键(`scope` 现在**就是**不认识的键之一 —— 见下) |
24
25
  | `bundles` | 产物边界:宿主面里不许出现浏览器侧的东西 |
25
26
  | `source` | 源码边界:不直接引 dsh 的包、不引不是种子词的 `@deepseek-ai/*` |
26
27
  | `primitives` | dsh 原语只从模块表(`kit`)拿 |
@@ -38,12 +39,65 @@ dsh-wb-module publish # 发货前的清单:门禁 + 三张表 +
38
39
  三张表(面板 / 能力 / 联动)+ 状态矩阵 + 数据源记录 + 验收清单住在模块根目录的 `design.json`,
39
40
  README 里那一段由它生成(夹在 `<!-- dsh-wb:design:start/end -->` 之间)。
40
41
 
42
+ `design.json` 是**设计底稿**:只装人话(`oneLine` / `shape` / `approvalText` / 面板那几张表的文字 /
43
+ `datasource`),**不装机器字段**。**"这条能力在哪些工作区出现"不在底稿、也不在清单**:
44
+ 那是**用户**的事(他的面板板),模块作者声明不了(用户口径「模块开发者不能定义 scope,
45
+ scope 永远是用户来决定的」,见 [ADR-0038](../../docs/adr/0038-module-declaration-protocol.md)
46
+ §2.4 / §6.16)。
47
+ `contracts` 会把底稿的能力名与清单的工具名做**键集合对账**(不重不漏,只对名字):
48
+ 底稿多一条、清单多一把都点名判红;没有 `design.json` 的模块不查。
49
+
41
50
  ```bash
42
51
  dsh-wb-module design check # 空着的格一格格点名叫出来 + README 有没有落后
43
52
  dsh-wb-module design render # 用 design.json 重新生成 README 那一段
44
53
  dsh-wb-module design accept # 用户逐条点头之后,把验收清单打勾
45
54
  ```
46
55
 
56
+ ## 协议:`dsh` 字段里的五段
57
+
58
+ 一个模块包在 `package.json` 里声明五件事,缺哪一段在哪一层炸都不一样:
59
+
60
+ | 段 | 是什么 | 谁读 |
61
+ |---|---|---|
62
+ | `dsh.bundle.patch` | 把自己插进 profile 组合图的 cordis 补丁 | dsh 的组合器(**装载**) |
63
+ | `dsh.client{platform,inject,external}` | 浏览器侧接线 | dsh 的客户端组合器(**dsh 自己不校验**,错的声明要等浏览器 `require` 才炸 —— 所以 `contracts` 替你查) |
64
+ | `dsh.workbench` | **模块声明协议**(下详) | 门禁 · 市场 · 外壳 |
65
+ | `dsh.workbenchFaces{host,client}` | **工具链元数据**(**不在协议里**):两个 half 的**源码**入口 | 门禁:`source` 拿它算可达性、`contracts` 查它在位;运行期与装机预检都不读 |
66
+ | `peerDependencies['@dsh-workbench/core']` | 服务面:外壳 provide 的服务在不在 | dsh 的 `evaluatePluginCompatibility` |
67
+
68
+ ### `dsh.workbench`(协议 v1)
69
+
70
+ ```jsonc
71
+ "workbench": {
72
+ "apiVersion": 1, // 必填。形状契约,缺了外壳不认这个模块
73
+ "unit": "hello", // 单元 id(小写连字符),进持久化配置
74
+ "card": { "titleKey": "hello.cardTitle", "order": 90 }, // 没有卡片写 null
75
+ "tools": [{ "name": "hello_read", "writes": false }], // 没工具写 []
76
+ "host": { "inject": [] }, // 这个单元要外壳的哪几个服务(判"能不能挂")
77
+ "env": { "command": "opencli", "plugins": [], "libs": [] } // 可选:靠什么才跑得起来
78
+ },
79
+ "workbenchFaces": { "host": "src/host.ts", "client": "src/client.tsx" } // 工具链那一格(不在协议里):两半的源码入口
80
+ ```
81
+
82
+ 五条口径:
83
+
84
+ 1. **声明了 `apiVersion` = 宣布静态半写全了** —— `unit` / `card` / `tools` / `host`
85
+ 四个键都必须有(`card` 没有卡片写 `null`),`env` 没有外部供给时省略。
86
+ 2. **不许有协议不认识的键**:生字段会被外壳**静默忽略**(拼错一个键就等于那句话没写),
87
+ 所以 `protocol` 判据先拦。**`scope` 不在协议里**:它曾经是(`card.scope` / `tools[].scope`),
88
+ 现已删除 —— 一个能力在哪些工作区出现**只由用户的板决定**,作者写它 = 写了一个生字段、当场红
89
+ (`protocol` 会点名 `"scope"`)。见 [ADR-0038](../../docs/adr/0038-module-declaration-protocol.md) §2.4 / §6.16。
90
+ 3. **清单声明的工具 = 代码真正注册的工具**(U1b 起):清单是工具面的**唯一**来源,
91
+ `contracts` 拿 `mount()` 里 `registerTools()` 真正注册的名字对账,两边漂了就红。
92
+ 4. **底稿 ↔ 清单只对账键集合**(U1c 起):`design.json` 的 `capabilities[].name` 与清单的
93
+ `tools[].name` 不重不漏,**只对名字**(内容各写一处,没有可比的东西);没有 `design.json`
94
+ 的模块不查 —— 底稿是可选的设计材料。见 [ADR-0038](../../docs/adr/0038-module-declaration-protocol.md) §2.5 / §6.3。
95
+ 5. **`dsh.workbenchFaces` 不是协议的一部分,但门禁必填**(A-2 起):它是 `dsh.workbench` 的
96
+ **兄弟键**,写两半的**源码**入口;`source` 判据靠它算"这段源码属于哪一半"。
97
+ 写进 `dsh.workbench` 里会被判**生字段**(不留兼容),它也不能指向出货产物
98
+ —— 从 `dist/host.js` 反推不出是哪份源码把 dsh 的包拖进来的。见
99
+ [ADR-0038](../../docs/adr/0038-module-declaration-protocol.md) §7 第 7 条。
100
+
47
101
  ## 两个模板
48
102
 
49
103
  | 模板 | 是什么 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-workbench/module-tools",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "模块包的构建与门禁工具:build(单文件 ESM 宿主侧 + 套 loader 壳的浏览器侧)、check(契约/产物边界/源码边界/卡片渲染)。模块包不再各带一份构建脚本。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,7 +20,8 @@
20
20
  "access": "public"
21
21
  },
22
22
  "dependencies": {
23
- "esbuild": "^0.28.2"
23
+ "esbuild": "^0.28.2",
24
+ "@dsh-workbench/shared": "0.1.17"
24
25
  },
25
26
  "scripts": {
26
27
  "selfcheck": "node scripts/selfcheck.mjs"
package/src/check.mjs CHANGED
@@ -1,5 +1,4 @@
1
- // 十条判据的**聚合层**:跑、收集三态、打印、决定退出码。
2
- //
1
+ // 判据的**聚合层**:跑、收集三态、打印、决定退出码。有几条以 `CHECKS` 表为准。
3
2
  // ── 为什么判据改成"导出函数",打印与退出码都搬到这里 ──────────────────────────
4
3
  //
5
4
  // 在这之前,每条 `checks/*.mjs` 都是"import 即执行 + 自己 console.log + 自己 process.exit"
@@ -21,9 +20,10 @@
21
20
  // 失败 ✖ <title> <N> 条不过: 然后逐条 ` · <problem>`
22
21
  // 最后一行是汇总;有任何 fail 就 exit 1。
23
22
 
24
- // 十个名字与 title 一字不变 —— selfcheck 会把它与各判据模块导出的 `title` 逐个对一遍。
23
+ // 名字与 title 一字不变 —— selfcheck 会把它与各判据模块导出的 `title` 逐个对一遍。
25
24
  export const CHECKS = {
26
25
  contracts: { title: '模块契约', load: () => import('./checks/contracts.mjs') },
26
+ protocol: { title: '模块声明协议', load: () => import('./checks/protocol.mjs') },
27
27
  bundles: { title: '模块产物边界', load: () => import('./checks/bundles.mjs') },
28
28
  source: { title: '模块源码边界', load: () => import('./checks/source.mjs') },
29
29
  primitives: { title: 'dsh 原语只从模块表拿', load: () => import('./checks/primitives.mjs') },
@@ -4,7 +4,7 @@
4
4
  // 解析得到 —— 装到别人那里就是构建期找不到文件(`check:standalone` 会把它变成红灯,
5
5
  // 这条判据则直接指到源码那一行)。
6
6
  //
7
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
7
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
8
8
  // 打印与退出码归 `../check.mjs` 那一层。
9
9
 
10
10
  import { readdirSync, readFileSync, statSync } from 'node:fs';
@@ -14,7 +14,7 @@
14
14
  // 覆盖范围跟着**自述清单**走:凡 `package.json` 里声明了 `dsh.workbench` 的包都被检查,
15
15
  // 新加一个模块不用改这个脚本。
16
16
  //
17
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
17
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
18
18
  // 打印与退出码归 `../check.mjs` 那一层。
19
19
 
20
20
  import { existsSync, readFileSync } from 'node:fs';
@@ -12,7 +12,7 @@
12
12
  // `window.__ModuleLoader__.load({id, factory})`,用假的 loader / require 把它拉起来。
13
13
  // 覆盖范围跟着 `dsh.workbench` 自述清单走 —— 新模块不用改这里。
14
14
  //
15
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
15
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
16
16
  // 打印与退出码归 `../check.mjs` 那一层。
17
17
 
18
18
  import { existsSync, readFileSync } from 'node:fs';
@@ -10,17 +10,20 @@
10
10
  // · `dsh.client` 的 inject / external 每一条都能落到图里的一行(seed 词 / 平台包 / workspace 包);
11
11
  // · `dsh.workbench`(**这是个模块**):
12
12
  // - `unit` 形状合法、全局唯一(撞了就是装上去当场抛);
13
- // - `faces.host` / `faces.client` 指的入口文件真的在;
13
+ // - `dsh.workbenchFaces.host` / `.client` 指的源码入口文件真的在 ——
14
+ // 它是**工具链字段**,不在 `dsh.workbench` 协议里(ADR-0038 §7 第 7 条):
15
+ // `source` 判据靠这两个入口做可达性分析,所以它必须是源码路径、必须声明;
14
16
  // - 对壳有 peer 依赖(它跑在外壳交出来的服务上);
15
17
  // - 有自己的接线自检脚本(模块侧也必须机器判据,不能只靠人眼)。
16
18
  //
17
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
19
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
18
20
  // 打印与退出码归 `../check.mjs` 那一层。
19
21
 
20
22
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
21
23
  import { createRequire } from 'node:module';
22
24
  import { join, relative } from 'node:path';
23
25
  import { fileURLToPath, pathToFileURL } from 'node:url';
26
+ import { readDeclaration } from '@dsh-workbench/shared/host';
24
27
  import { discoverPackages, workspaceRootOf } from '../discover.mjs';
25
28
  import { SEED_SHORT } from '../seed.mjs';
26
29
 
@@ -241,15 +244,59 @@ function fakeStore() {
241
244
  return { for: () => one(), machine: () => one(), scan: () => [one()] };
242
245
  }
243
246
 
244
- function mountOf(face, created) {
247
+ // mount 也一定拿得到 shell 与 workspaceRegistry(外壳给的是 dsh 交出来的服务)。
248
+ // 少给这两样,模块的**降级写法**就会生效 —— 而"拿不到 shell 就只交 routes + registerTools"
249
+ // 是合法写法(实测 pduiv2 的 coding-issues / coding-analysis 就这么写),降级后的面本来就不带
250
+ // 审批钩子,判据却会把这读成"没认领审批":红的是判据自己的环境,不是模块。
251
+ // (与上面 `store` 那笔账同一条纪律:假 deps 少给一样,模块就在这条判据上被冤枉。)
252
+ function fakeShell() {
253
+ return {
254
+ resolve: () => undefined,
255
+ execute: async () => ({
256
+ result: async () => ({
257
+ exitCode: null,
258
+ timedOut: false,
259
+ aborted: false,
260
+ stdout: { text: '', truncated: false },
261
+ stderr: { text: '', truncated: false },
262
+ }),
263
+ }),
264
+ };
265
+ }
266
+
267
+ function fakeWorkspaceRegistry() {
268
+ return {
269
+ get: () => undefined,
270
+ resolveByPath: async () => undefined,
271
+ create: async () => undefined,
272
+ };
273
+ }
274
+
275
+ function mountOf(face, created, manifest, tools) {
245
276
  if (face === undefined || typeof face.mount !== 'function') return undefined;
277
+ // 假服务**一次 mount 内是同一个对象**(真外壳给的就是同一个)—— 有的模块会拿两次再比。
278
+ const shell = fakeShell();
279
+ const workspaceRegistry = fakeWorkspaceRegistry();
280
+ const toolRegistry = {
281
+ register(tool) {
282
+ tools.push(tool);
283
+ return () => {};
284
+ },
285
+ };
246
286
  try {
247
287
  return face.mount({
288
+ manifest,
248
289
  ctx: {
249
- get: () => undefined,
290
+ get: (key) => {
291
+ if (key === 'tools') return toolRegistry;
292
+ if (key === 'shell') return shell;
293
+ if (key === 'workspaceRegistry') return workspaceRegistry;
294
+ return undefined;
295
+ },
250
296
  effect: (fn) => {
251
297
  fn();
252
298
  },
299
+ provide: () => {},
253
300
  logger: { warn: () => {}, info: () => {} },
254
301
  },
255
302
  manifests: () => [],
@@ -266,6 +313,39 @@ const LONG_TASK_METHODS = ['start', 'snapshot', 'active', 'current', 'onChange']
266
313
 
267
314
  const LONG_TASK_RENDERERS = ['useLongTask', 'LongTaskRow', 'longTaskDot'];
268
315
 
316
+ // 审批探针要用的**合成调用**。
317
+ //
318
+ // 为什么不是 `{ arguments: {} }`:外壳调 needsApproval 时拿到的是 agent 真传的参数,
319
+ // 而"这次要不要点头"本来就允许依赖参数 —— "带 confirm: true 才真的写"是最常见的合法口径。
320
+ // 空参数探会把这类模块判成"没认领审批"(实测 pduiv2 那 4 条审批报错就是这么来的,
321
+ // ADR-0038 §6.15)。所以能拿到工具的 `parameters`(JSON Schema)时,按**必填项**造一个
322
+ // 形状合法的调用:枚举取第一个值,string / number / boolean / array / object 各给一个占位值。
323
+ //
324
+ // 拿不到(registerTools() 没注册出这把工具、或注册时没带 parameters)就退回空参数,
325
+ // 并在判红时把这件事写进那句话 —— 那种情况下判据只能验到"钩子在不在"这一层。
326
+ function placeholderOf(schema) {
327
+ if (typeof schema !== 'object' || schema === null) return null;
328
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0];
329
+ if (schema.type === 'string') return 'probe';
330
+ if (schema.type === 'number' || schema.type === 'integer') return 1;
331
+ if (schema.type === 'boolean') return true;
332
+ if (schema.type === 'array') return [];
333
+ if (schema.type === 'object') return {};
334
+ return null;
335
+ }
336
+
337
+ function probeCallOf(tool) {
338
+ const schema = tool?.parameters;
339
+ if (typeof schema !== 'object' || schema === null) {
340
+ return { arguments: {}, shaped: false };
341
+ }
342
+ const properties = typeof schema.properties === 'object' && schema.properties !== null ? schema.properties : {};
343
+ const required = Array.isArray(schema.required) ? schema.required : [];
344
+ const args = {};
345
+ for (const key of required) args[key] = placeholderOf(properties[key]);
346
+ return { arguments: args, shaped: true };
347
+ }
348
+
269
349
  function installed(specifier, from) {
270
350
  try {
271
351
  createRequire(join(from, 'package.json')).resolve(`${packageOf(specifier)}/package.json`);
@@ -289,12 +369,28 @@ function packageOf(specifier) {
289
369
  return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
290
370
  }
291
371
 
372
+ /** 深比较两份 JSON:对象**键序无关**,数组**顺序有关**(工具那一串的顺序是声明的一部分)。 */
373
+ function reconcileTools(name, manifest, toolNames) {
374
+ const declared = manifest.tools.map((tool) => tool.name).sort();
375
+ const actual = [...new Set(toolNames.filter((one) => one !== ''))].sort();
376
+ if (declared.join('\n') === actual.join('\n')) return [];
377
+ return [
378
+ `${name}: dsh.workbench 声明的能力与代码真正注册的对不上 —— ` +
379
+ `清单 [${declared.join(', ')}]、代码 [${actual.join(', ')}]。` +
380
+ `清单是工具面的**唯一**来源(外壳按它算收窄、市场按它画花名册):` +
381
+ `漏声明 = 那个工具不会被收起也不会进花名册;多声明 = 花名册里有个永远不存在的工具`,
382
+ ];
383
+ }
384
+
292
385
  export async function run() {
293
386
  const packages = discoverPackages({ root: process.cwd() });
294
387
  const known = new Set(packages.map((pkg) => pkg.manifest.name));
295
388
  const problems = [];
296
389
  const unresolvedIn = new Set();
297
390
  const units = new Map();
391
+ // 判据"看到了但下不了结论"的那些,写进 notes(红绿两边都印)——
392
+ // 与其给一个撑不住的 verdict,不如把作者该自己确认的那一件事说清楚(ADR-0038 §6.15)。
393
+ const probeNotes = [];
298
394
  let declared = 0;
299
395
  let modules = 0;
300
396
 
@@ -360,11 +456,14 @@ export async function run() {
360
456
  }
361
457
 
362
458
  for (const face of ['host', 'client']) {
363
- const rel = dsh.workbench.faces?.[face];
459
+ const rel = dsh.workbenchFaces?.[face];
364
460
  if (typeof rel !== 'string') {
365
- problems.push(`${name}: dsh.workbench.faces.${face} 没声明`);
461
+ problems.push(
462
+ `${name}: dsh.workbenchFaces.${face} 没声明 —— 这两个源码入口是工具链字段` +
463
+ `(不在 dsh.workbench 协议里):source 判据靠它们判"哪一半",缺了它判不了`,
464
+ );
366
465
  } else if (!existsSync(join(pkg.dir, rel))) {
367
- problems.push(`${name}: dsh.workbench.faces.${face} 指的文件不存在 —— ${rel}`);
466
+ problems.push(`${name}: dsh.workbenchFaces.${face} 指的文件不存在 —— ${rel}`);
368
467
  }
369
468
  }
370
469
 
@@ -396,10 +495,37 @@ export async function run() {
396
495
  // 外部供给:清单里声明了 env,就要把"靠什么跑起来"和"没装好时怎么办"都说清。
397
496
  // 外壳只按 env 去装(app/host.ts 把 plugins/libs 交给隔离运行时跑 probe/install),
398
497
  // 它不知道这个能力叫什么 —— 所以"照做"那句安装命令必须由模块自己给。
498
+ const read = readDeclaration(name, dsh.workbench);
499
+ const manifest = read.manifest;
399
500
  const created = [];
501
+ // 注册出来的**工具对象**(不只是名字):审批探针要读它的 `parameters` 造形状合法的调用。
502
+ const tools = [];
400
503
  const face = await unitFaceOf(pkg.dir);
401
- const mounted = face === undefined ? undefined : mountOf(face, created);
402
- const env = face?.manifest?.env;
504
+ const mounted = face === undefined ? undefined : mountOf(face, created, manifest, tools);
505
+ const env = manifest?.env;
506
+
507
+ if (manifest !== undefined) {
508
+ if (face === undefined) {
509
+ problems.push(
510
+ `${name}: 声明了 ${manifest.tools.length} 个能力,但宿主面挂不起来(dist/host.js 缺、或 apply 抛了)` +
511
+ `—— 声明的能力对不了账,先把面修好`,
512
+ );
513
+ } else {
514
+ try {
515
+ mounted?.registerTools();
516
+ } catch (error) {
517
+ problems.push(`${name}: registerTools() 抛了 —— ${String(error)}`);
518
+ }
519
+ problems.push(
520
+ ...reconcileTools(
521
+ name,
522
+ manifest,
523
+ tools.map((tool) => (typeof tool?.name === 'string' ? tool.name : '')),
524
+ ),
525
+ );
526
+ }
527
+ }
528
+
403
529
  if (env !== undefined) {
404
530
  problems.push(...envProblemsOf(name, env));
405
531
  const products = productsTextOf(pkg.dir);
@@ -438,7 +564,8 @@ export async function run() {
438
564
  // 设计表里写了哪一种就得真的接上哪一种 —— 否则用户改完刷新回到默认,而表上写着"能配"。
439
565
  // (**与 env 无关**:没有 env 的模块一样要查,所以放在 env 块外面。)
440
566
  const configRows = designRowsOf(pkg.dir, 'config');
441
- const wiredPrefs = /\bprefs\s*:/.test(hostProductOf(pkg.dir));
567
+ const hostProduct = hostProductOf(pkg.dir);
568
+ const wiredPrefs = /\bprefs\s*:/.test(hostProduct);
442
569
  const declaredPrefs = (configRows ?? []).some((one) => text(one?.['persistence']) === 'prefs');
443
570
  if (declaredPrefs && !wiredPrefs) {
444
571
  problems.push(
@@ -453,18 +580,27 @@ export async function run() {
453
580
  );
454
581
  }
455
582
 
456
- // 「这把能力在哪些项目里出现」:表里写了 workspace(按项目收窄),产物里就得真的实现 gate.deniedTools ——
457
- // 否则每个项目里它都出现在工具清单里,agent 调了才发现不行(两次调用白花,而且它还会再试)。
458
- // 反向**不查**:`deniedTools` 也用来"按自己的开关收窄"(比如能力没配好先藏起来),那不是项目作用域 ——
459
- // 查反向会把这种合法用法判红。
583
+ // 底稿与清单只对账**键集合**:`design.json` 的 `capabilities[].name` 与清单的 `tools[].name`
584
+ // 不重不漏(ADR-0038 §2.5)。**只对名字**:内容各写一处(人话在底稿、机器字段在清单),没有可比的东西。
585
+ // 没有 `design.json` 的模块不查(底稿是可选的,契约不要求它);清单形状坏了的也不查(protocol 已经说话了)。
460
586
  const capabilityRows = designRowsOf(pkg.dir, 'capabilities');
461
- const declaredWorkspaceScope = (capabilityRows ?? []).some((one) => text(one?.['scope']) === 'workspace');
462
- if (declaredWorkspaceScope && !/\bdeniedTools\b/.test(hostProductOf(pkg.dir))) {
463
- problems.push(
464
- `${name}: 设计表里有一条能力写着"只在某些项目里出现"(capabilities[].scope = 'workspace'),` +
465
- '产物里却没有实现 gate.deniedTools —— 每个项目的工具清单里都有它,agent 调了才知道不行。' +
466
- '接法:mount 返回 `gate: { deniedTools(workspace) { … }, subscribe }`(见 TOOLS.md §12)',
467
- );
587
+ if (capabilityRows !== undefined && manifest !== undefined) {
588
+ const inDesign = capabilityRows.map((one) => text(one?.name)).filter((one) => one !== '');
589
+ const inDeclaration = manifest.tools.map((tool) => tool.name);
590
+ for (const name0 of inDesign.filter((one) => !inDeclaration.includes(one))) {
591
+ problems.push(
592
+ `${name}: design.json 的能力行里有「${name0}」,清单的 tools[] 里却没有它 —— ` +
593
+ '能力只有清单那一处能出货(装机预检 / 市场 / 外壳都读它),底稿写了也是一句空话:' +
594
+ `要么把它补进 \`dsh.workbench.tools\`,要么从底稿里删掉`,
595
+ );
596
+ }
597
+ for (const name0 of inDeclaration.filter((one) => !inDesign.includes(one))) {
598
+ problems.push(
599
+ `${name}: 清单的 tools[] 里声明了「${name0}」,design.json 的能力行里却没有它 —— ` +
600
+ '底稿是给人读的那半(它干嘛、要不要点头,`design render` 拿它出 README):' +
601
+ `要么在 design.json 的 capabilities[] 里为「${name0}」补一行,要么把它从清单里删掉`,
602
+ );
603
+ }
468
604
  }
469
605
 
470
606
  // 长任务:跑一会儿的事,注册了就得让用户看得见那四盏灯。
@@ -580,10 +716,18 @@ export async function run() {
580
716
  //
581
717
  // 外壳每次调用前都会问套件:writes:true 的工具,needsApproval 不给 verdict.kind==='ask'
582
718
  // 就**当场 deny**(原文:"在套件的 needsApproval 里补一条审批(含"这次会发生什么"),别让写静默过去")——
583
- // 那条错误只有真跑一次才看得见,用户看到的是一次莫名其妙的拒绝。所以门禁在出包前先看一遍:
584
- // · needsApproval 要给完整的 ask 口径(理由 + 问句 + 兜底文案,zh/en 都要);
585
- // · approvalDetail 要说清"这次会发生什么"(审批屏上就靠它,缺了只剩一句通用问句)。
586
- const writeTools = (face?.manifest?.tools ?? []).filter((one) => one?.writes === true);
719
+ // 那条错误只有真跑一次才看得见,用户看到的是一次莫名其妙的拒绝。所以门禁在出包前先看一遍。
720
+ //
721
+ // **这条判据只能验到哪一层**(原样写给以后改它的人):
722
+ // · 硬事实:一个 writes:true 的工具,mount() 有没有把 needsApproval 交出来;
723
+ // · 判据能造出合成调用时:对那次调用给的 verdict 形状对不对;verdict 是 ask 时
724
+ // approvalDetail 说没说清"这次会发生什么";
725
+ // · **验不了的**:真实写入调用会不会真的 ask。答案依赖参数的模块("带 confirm: true 才真的写")
726
+ // 在合成调用上依法回 `{ kind: 'read' }` —— 外壳那条路(shared/host/tool-approval.ts)在 read
727
+ // 时**根本不会问 approvalDetail**,所以这里也不再逼它给。这类模块列进 notes 让作者自己确认。
728
+ // · 空参数探会误报:合成调用按声明里的 `parameters` 必填项造(见 probeCallOf),
729
+ // 造不出来时退回空参数并把这件事写进判红那句。
730
+ const writeTools = (manifest?.tools ?? []).filter((one) => one?.writes === true);
587
731
  if (writeTools.length > 0) {
588
732
  if (mounted === undefined) {
589
733
  problems.push(
@@ -592,13 +736,32 @@ export async function run() {
592
736
  );
593
737
  continue;
594
738
  }
739
+ const registered = new Map(tools.map((tool) => [text(tool?.name), tool]));
740
+ const readOnProbe = [];
595
741
  for (const tool of writeTools) {
596
- const call = { name: tool.name, arguments: {}, workspace: null };
742
+ const probe = probeCallOf(registered.get(tool.name));
743
+ const call = { name: tool.name, arguments: probe.arguments, workspace: null };
744
+ const cannotShape = probe.shaped
745
+ ? ''
746
+ : `(registerTools() 没注册出 ${tool.name} 的 parameters,判据只能用空参数探 —— ` +
747
+ `你的判断依赖参数的话,注册时把 parameters 那个 JSON Schema 一起交出来)`;
748
+ if (typeof mounted.needsApproval !== 'function') {
749
+ problems.push(
750
+ `${name}: 工具 ${tool.name} 的清单里写着 writes:true,但 mount() 的返回值里没有 needsApproval —— ` +
751
+ `外壳每次调用前都问它"这次要不要点头",没有它就只有一句"没认领",**每一次调用都当场被拒**。` +
752
+ `做法:mount() 返回的对象里加 needsApproval(call),认出这把工具再给 verdict:` +
753
+ `{ kind: 'read' }(这次不用问)或 { kind: 'ask', reason, question: {zh,en}, fallback: {zh,en} }(要问,且问得清)`,
754
+ );
755
+ continue;
756
+ }
597
757
  let verdict;
598
758
  try {
599
- verdict = mounted?.needsApproval?.(call);
600
- } catch {
601
- verdict = undefined;
759
+ verdict = mounted.needsApproval(call);
760
+ } catch (error) {
761
+ problems.push(
762
+ `${name}: 工具 ${tool.name} 的 needsApproval 在合成调用 ${JSON.stringify(call.arguments)} 上抛了 —— ${String(error)}`,
763
+ );
764
+ continue;
602
765
  }
603
766
  // 合法口径有两种:`{ kind: 'read' }`(这件事不用问 —— 例:没什么可做的)与
604
767
  // `{ kind: 'ask', reason, question{zh,en}, fallback{zh,en} }`(要问,且问得清)。
@@ -613,15 +776,23 @@ export async function run() {
613
776
  text(verdict.fallback?.en) !== '';
614
777
  if (!read && !ask) {
615
778
  problems.push(
616
- `${name}: 工具 ${tool.name} 的清单里写着 writes:true,但 needsApproval 没给出合法口径 —— ` +
779
+ `${name}: 工具 ${tool.name} 的清单里写着 writes:true,但 needsApproval 对合成调用 ` +
780
+ `${JSON.stringify(call.arguments)} 没给出合法口径 —— ` +
617
781
  `要么 { kind: 'read' }(这件事不用问),要么 { kind: 'ask', reason, question: {zh,en}, ` +
618
- `fallback: {zh,en} }(要问,且问得清)。别的返回值外壳会当场 deny`,
782
+ `fallback: {zh,en} }(要问,且问得清)。别的返回值外壳会当场 deny${cannotShape}`,
619
783
  );
620
784
  continue;
621
785
  }
786
+ // read:这次调用不用点头。**不查 approvalDetail** —— 外壳在 read 时也不会去问它
787
+ // (`approvalFor` 只在 verdict 是 ask 时才调 detailOf)。合成调用本来就不是一次真实写入,
788
+ // 拿它逼模块给"这次会发生什么"就是让判据替外壳问一个外壳不会问的问题。
789
+ if (read) {
790
+ readOnProbe.push(tool.name);
791
+ continue;
792
+ }
622
793
  let facts;
623
794
  try {
624
- facts = await mounted?.approvalDetail?.(call);
795
+ facts = await mounted.approvalDetail?.(call);
625
796
  } catch {
626
797
  facts = undefined;
627
798
  }
@@ -632,11 +803,20 @@ export async function run() {
632
803
  : text(facts.zh) !== '' && text(facts.en) !== '');
633
804
  if (!described) {
634
805
  problems.push(
635
- `${name}: 写工具 ${tool.name} 没给 approvalDetail("这次会发生什么",zh/en 或 {blocked})—— ` +
636
- `审批屏上用户只能看到一句通用问句,看不到会动什么`,
806
+ `${name}: 写工具 ${tool.name} 的 needsApproval 对合成调用回了 ask(要点头),` +
807
+ `但 approvalDetail 没说清"这次会发生什么"(zh/en 或 {blocked})—— ` +
808
+ `审批屏上用户只能看到一句通用问句,看不到会动什么${cannotShape}`,
637
809
  );
638
810
  }
639
811
  }
812
+ if (readOnProbe.length > 0) {
813
+ probeNotes.push(
814
+ `写工具 ${readOnProbe.join(' / ')} 按合成调用回了"这次不用问"(kind: 'read')—— ` +
815
+ `判据只能验到这一步:它是不是"参数里带 confirm 才真的写"那种**合法**口径,` +
816
+ `合成调用看不出来(必填项给的是占位值,不是真实写入参数)。作者自己确认一次:` +
817
+ `真的调用会不会 ask、ask 时 approvalDetail 说没说清`,
818
+ );
819
+ }
640
820
  }
641
821
  }
642
822
 
@@ -669,7 +849,7 @@ export async function run() {
669
849
  }
670
850
 
671
851
  if (problems.length > 0) {
672
- const notes = [`${declared} 个带 dsh 声明的包,其中 ${modules} 个模块`];
852
+ const notes = [`${declared} 个带 dsh 声明的包,其中 ${modules} 个模块`, ...probeNotes];
673
853
  // 落不到图里最常见的成因不是"声明写错",而是"这个包不在任何 workspace 里"(裸目录里 init
674
854
  // 出来的骨架)。不点出来的话,屏幕上的话会把责任推给那两条声明,而真正该做的是换到工坊里跑。
675
855
  const bare = [...unresolvedIn].filter(barePackage);
@@ -690,6 +870,7 @@ export async function run() {
690
870
  `inject/external 都能落到图里 · 单元 id 唯一 · 面文件与 peer 都在 · 各有接线自检`,
691
871
  '(test 不在这儿跑:状态级的东西在模块自己的 vitest 里 —— 四个脚本自己跑一遍)',
692
872
  `单元:${unitList}`,
873
+ ...probeNotes,
693
874
  ],
694
875
  problems: [],
695
876
  };
@@ -17,7 +17,7 @@
17
17
  //
18
18
  // 判据跟着 `dsh.workbench` 自述清单走;没有 client.js 的包这里没事可查(由 build/契约判据管)。
19
19
  //
20
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
20
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
21
21
  // 打印与退出码归 `../check.mjs` 那一层。
22
22
 
23
23
  import { existsSync, readFileSync } from 'node:fs';
@@ -4,7 +4,7 @@
4
4
  // 不能照搬"本 workspace 里所有 access=public 的包" —— 在外壳仓里等价,但把这个工具拿到
5
5
  // 别人的 monorepo(例如 pdv2)里跑时,那条会把人家二十几个自家 SDK 包一起判了。
6
6
  //
7
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
7
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
8
8
  // 打印与退出码归 `../check.mjs` 那一层。
9
9
 
10
10
  import { readdirSync, readFileSync, statSync } from 'node:fs';
@@ -12,7 +12,7 @@
12
12
  //
13
13
  // 判据跟着 `dsh.workbench` 自述清单走:没用 dsh 原语的模块直接跳过(0 命中 = 通过)。
14
14
  //
15
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
15
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
16
16
  // 打印与退出码归 `../check.mjs` 那一层。
17
17
 
18
18
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
@@ -0,0 +1,57 @@
1
+ // 模块声明协议:`dsh.workbench` 的**版本**与**静态半形状**。
2
+ //
3
+ // 这一条是**薄壳**:真正的形状判定在 `@dsh-workbench/shared` 的 `declarationProblemsOf()`。
4
+ // 三个调用方共用那一份实现,所以协议只有一处定义:
5
+ // · 门禁(本判据)
6
+ // · 外壳(装配期把声明读成运行期清单,ADR-0038 §2.2)
7
+ // · 装机预检(拿 registry 元数据里的 package.json 就能调,ADR-0038 §2.6)
8
+ // 在 U1b 之前本文件自己带了一套形状校验 —— 那意味着外壳与门禁会各判各的,
9
+ // 正是"两份同类真相"。现在它只剩"把当前目录的包扫一遍、把问题倒出来"。
10
+ //
11
+ // 分工:contracts 管"这个包长得对不对"(unit 唯一、`dsh.workbenchFaces` 在、peer 在、审批认领了没),
12
+ // 本判据管"这份声明读不读得懂"(版本认不认、静态半形状对不对、有没有生字段)。
13
+ //
14
+ // `faces` 自 A-2 起**不在协议里**(ADR-0038 §7 第 7 条):它指的是**源码**入口
15
+ // (`src/host.ts` / `src/client.tsx`),而协议是"装的人手上有什么"那份清单 —— 装机预检与外壳
16
+ // 都不用它。它搬到 `dsh.workbench` 的**兄弟键** `dsh.workbenchFaces`,由 contracts / source 读;
17
+ // 于是它既不是协议里的合法键(写回 `dsh.workbench` 里会被判生字段),也不会被协议"顺手校验"。
18
+
19
+ import { declarationProblemsOf } from '@dsh-workbench/shared/host';
20
+
21
+ export const title = '模块声明协议';
22
+
23
+ export async function run() {
24
+ const { discoverPackages } = await import('../discover.mjs');
25
+ const packages = discoverPackages({ root: process.cwd() });
26
+ const problems = [];
27
+ const versions = new Set();
28
+ let declared = 0;
29
+
30
+ for (const pkg of packages) {
31
+ const raw = pkg.manifest.dsh?.workbench;
32
+ if (raw === undefined) continue;
33
+ problems.push(...declarationProblemsOf(pkg.manifest.name, raw));
34
+ if (typeof raw === 'object' && raw !== null && Number.isInteger(raw.apiVersion)) {
35
+ declared += 1;
36
+ versions.add(raw.apiVersion);
37
+ }
38
+ }
39
+
40
+ if (problems.length > 0) {
41
+ return {
42
+ status: 'fail',
43
+ notes: [`${packages.length} 个包里带 dsh.workbench 声明的已逐个读过`],
44
+ problems,
45
+ };
46
+ }
47
+
48
+ return {
49
+ status: 'pass',
50
+ notes: [
51
+ declared === 0
52
+ ? '这个工作区里还没有模块(没有带 dsh.workbench 的包)'
53
+ : `${declared} 个模块都声明了协议 v${[...versions].sort().join(' / v')},静态半形状齐、没有生字段`,
54
+ ],
55
+ problems: [],
56
+ };
57
+ }
@@ -5,11 +5,15 @@
5
5
  // 模块包自己要守的是另外几条,写在下面。判据全部来自源码里的 import 说明符,覆盖范围跟着
6
6
  // `dsh.workbench` 自述清单走(新加模块不用改这里)。
7
7
  //
8
- // 关于 dsh 的包:「哪一半」**不靠目录名猜**,而是从两个入口(`faces.host` / `faces.client`)
9
- // 做一次本包内可达性分析。种子词(模块表里天生就有的那几个)在**只被客户端入口可达**的文件里
10
- // 可以用(ADR-0028);宿主侧一个都不许碰 —— 那些包只在浏览器侧存在。
8
+ // 关于 dsh 的包:「哪一半」**不靠目录名猜**,而是从两个入口(`dsh.workbenchFaces.host` /
9
+ // `dsh.workbenchFaces.client`)做一次本包内可达性分析。种子词(模块表里天生就有的那几个)在
10
+ // **只被客户端入口可达**的文件里可以用(ADR-0028);宿主侧一个都不许碰 —— 那些包只在浏览器侧存在。
11
11
  //
12
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
12
+ // 这两个入口是 dsh.workbenchFaces 的**唯一**用途(工具链字段,不是协议的一部分,见 ADR-0038 §7 第 7 条):
13
+ // 装机预检与外壳都不读它。所以这里必须拿**源码**入口 —— 从产物 dist/host.js 反推不出是哪一份源码
14
+ // 把 dsh 的包拖进来的。
15
+ //
16
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
13
17
  // 打印与退出码归 `../check.mjs` 那一层。
14
18
 
15
19
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
@@ -102,7 +106,7 @@ export async function run() {
102
106
  for (const pkg of modulePackages) {
103
107
  const name = pkg.manifest.name;
104
108
  const unit = pkg.manifest.dsh.workbench.unit;
105
- const faces = pkg.manifest.dsh.workbench.faces ?? {};
109
+ const workbenchFaces = pkg.manifest.dsh.workbenchFaces ?? {};
106
110
  const srcDir = join(pkg.dir, 'src');
107
111
  const viaPlatform = hasPlatformPackage(pkg.manifest);
108
112
 
@@ -112,8 +116,10 @@ export async function run() {
112
116
  );
113
117
  }
114
118
 
115
- const hostReach = faces.host === undefined ? new Set() : reachableFrom(pkg.dir, unit, faces.host);
116
- const clientReach = faces.client === undefined ? new Set() : reachableFrom(pkg.dir, unit, faces.client);
119
+ const hostReach =
120
+ workbenchFaces.host === undefined ? new Set() : reachableFrom(pkg.dir, unit, workbenchFaces.host);
121
+ const clientReach =
122
+ workbenchFaces.client === undefined ? new Set() : reachableFrom(pkg.dir, unit, workbenchFaces.client);
117
123
  const sideOf = (file) => {
118
124
  const host = hostReach.has(file);
119
125
  const client = clientReach.has(file);
@@ -147,7 +153,7 @@ export async function run() {
147
153
  : '这个文件在宿主侧可达';
148
154
  problems.push(
149
155
  `${name}: ${rel} 里 import 了 ${specifier}(dsh 的浏览器包,是平台种子词)—— ${why},` +
150
- `而它只在浏览器侧存在。把它挪到只被 faces.client 可达的文件里`,
156
+ `而它只在浏览器侧存在。把它挪到只被 dsh.workbenchFaces.client 可达的文件里`,
151
157
  );
152
158
  continue;
153
159
  }
@@ -4,7 +4,7 @@
4
4
  // 最后无论成败都还原。藏起来还过不了 = 这条模块在用"只有本仓才有"的相对路径 / 别名 ——
5
5
  // 发布出去会散架。
6
6
  //
7
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
7
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
8
8
  // 打印与退出码归 `../check.mjs` 那一层。所以这里**不许**自己退出:
9
9
  // 仓外工作区(没有外壳源码)以前就是"提前成功退出"(退出码 0)—— 那会把排在后面的判据静默吞掉。
10
10
 
@@ -5,7 +5,7 @@
5
5
  // CSS Modules 的哈希类名靠"同一份文件、同一个导出"配对,调用点拿到 undefined 时真机渲染成
6
6
  // `class="undefined"` —— 元素整片没样式,而且控制台一声不响。
7
7
  //
8
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
8
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
9
9
  // 打印与退出码归 `../check.mjs` 那一层。
10
10
 
11
11
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
package/src/design.mjs CHANGED
@@ -41,8 +41,7 @@ const ROWS = {
41
41
  mayBeEmpty: true,
42
42
  },
43
43
  capabilities: {
44
- fields: ['name', 'oneLine', 'shape', 'approval', 'scope'],
45
- choices: { scope: ['always', 'workspace'] },
44
+ fields: ['name', 'oneLine', 'shape', 'approval'],
46
45
  optional: ['approvalText'],
47
46
  mayBeEmpty: true,
48
47
  },
@@ -68,15 +67,6 @@ function persistenceLabel(value) {
68
67
  return cell(raw);
69
68
  }
70
69
 
71
- /** 一条能力在哪些项目里出现:`always` = 每个项目都该看见;`workspace` = 只在某些项目里有意义,
72
- * 代码里必须真的用 `gate.deniedTools(workspace)` 把它收窄,否则表上写着窄、实际到处都在。 */
73
- function scopeLabel(value) {
74
- const raw = text(value);
75
- if (raw === 'always') return '哪个项目都能用';
76
- if (raw === 'workspace') return '只在某些项目里出现(按项目收窄)';
77
- return cell(raw);
78
- }
79
-
80
70
  function datasourceRowsOf(read) {
81
71
  return [
82
72
  '| 项 | 值(照抄,别转述) |',
@@ -264,16 +254,15 @@ export function renderDesign(design) {
264
254
  '### 表 2 agent:他能替用户做什么、哪几件要点头',
265
255
  '',
266
256
  tableOf(
267
- '能力 | 一句话 | 传什么 / 拿回什么(贴真实调用与返回) | 要点头吗 | 点头时他看到的"会发生什么" | 在哪些项目里出现',
257
+ '能力 | 一句话 | 传什么 / 拿回什么(贴真实调用与返回) | 要点头吗 | 点头时他看到的"会发生什么"',
268
258
  capabilities.length === 0
269
- ? [['(这份模块故意没有能力)', '—', '—', '—', '—', '—']]
259
+ ? [['(这份模块故意没有能力)', '—', '—', '—', '—']]
270
260
  : capabilities.map((one) => [
271
261
  cell(one?.name),
272
262
  cell(one?.oneLine),
273
263
  cell(one?.shape),
274
264
  cell(one?.approval),
275
265
  soft(one?.approvalText),
276
- scopeLabel(one?.scope),
277
266
  ]),
278
267
  ),
279
268
  '',
package/src/publish.mjs CHANGED
@@ -69,7 +69,7 @@ export async function publish({ root }) {
69
69
  if (existsSync(join(root, DESIGN_FILE))) runCli(root, ['design', 'check']);
70
70
  else console.log('○ 没有 design.json(三张表还没落成机器形状)—— 建议先 dsh-wb-module design init');
71
71
  } else {
72
- console.log('○ 这不是一个 wb 模块包(没有 dsh.workbench.unit)—— 跳过十条判据,只查打包清单');
72
+ console.log('○ 这不是一个 wb 模块包(没有 dsh.workbench.unit)—— 跳过判据,只查打包清单');
73
73
  }
74
74
 
75
75
  const packed = packList(root);
@@ -24,9 +24,13 @@
24
24
  "external": ["@dsh-workbench/core/client"]
25
25
  },
26
26
  "workbench": {
27
+ "apiVersion": 1,
27
28
  "unit": "__UNIT__",
28
- "faces": { "host": "src/host.ts", "client": "src/client.tsx" }
29
- }
29
+ "card": { "titleKey": "__UNIT__.cardTitle", "order": 90 },
30
+ "tools": [],
31
+ "host": { "inject": [] }
32
+ },
33
+ "workbenchFaces": { "host": "src/host.ts", "client": "src/client.tsx" }
30
34
  },
31
35
  "peerDependencies": { "@dsh-workbench/core": "^0.1.0", "react": "*" },
32
36
  "peerDependenciesMeta": { "react": { "optional": true } },
@@ -108,14 +108,12 @@ const ctx = {
108
108
 
109
109
  pluginExports.apply(ctx);
110
110
  check('卡片真的注册进了外壳', faces.length === 1, `注册了 ${faces.length} 个`);
111
- check(`注册的是 ${UNIT_ID} 单元`, faces[0]?.manifest.id === UNIT_ID, faces[0]?.manifest.id);
111
+ check(`注册的是 ${UNIT_ID} 单元`, faces[0]?.unit === UNIT_ID, faces[0]?.unit);
112
112
  check('卡片是个能画的组件', typeof faces[0]?.card === 'function');
113
113
  check(
114
- '卡片自己带标题与顺序(外壳的看板据此排)',
115
- typeof faces[0]?.manifest.card?.titleKey === 'string' &&
116
- faces[0].manifest.card.titleKey !== '' &&
117
- typeof faces[0]?.manifest.card?.order === 'number',
118
- JSON.stringify(faces[0]?.manifest.card),
114
+ '面只交 unit(标题与顺序住在 package.json 的 dsh.workbench 里,由外壳按 unit 交给浏览器)',
115
+ faces[0]?.manifest === undefined,
116
+ JSON.stringify(faces[0]?.manifest),
119
117
  );
120
118
  check(
121
119
  '卡片在空数据下也画得出来(不许抛)',
@@ -4,23 +4,11 @@ import { messages } from './messages';
4
4
 
5
5
  const UNIT_ID = '__UNIT__';
6
6
 
7
- interface UnitManifest {
8
- readonly id: string;
9
- readonly kind: string;
10
- readonly card: {
11
- readonly titleKey: string;
12
- readonly order: number;
13
- readonly enabledByDefault: boolean;
14
- } | null;
15
- readonly tools: readonly unknown[];
16
- readonly host: { readonly inject: readonly string[] };
17
- }
18
-
19
7
  type Rendered = ReturnType<typeof h>;
20
8
  type Translate = (key: string, params?: Record<string, unknown>) => string;
21
9
 
22
10
  interface UnitClientFace {
23
- readonly manifest: UnitManifest;
11
+ readonly unit: string;
24
12
  readonly messages: { readonly zh: Readonly<Record<string, string>>; readonly en: Readonly<Record<string, string>> };
25
13
  readonly card: (props: { readonly t: Translate; readonly onRefresh: () => void }) => Rendered;
26
14
  }
@@ -34,16 +22,8 @@ interface ClientContext {
34
22
  effect(effect: () => void | (() => void), label?: string): () => void;
35
23
  }
36
24
 
37
- const manifest: UnitManifest = {
38
- id: UNIT_ID,
39
- kind: 'board',
40
- card: { titleKey: `${UNIT_ID}.cardTitle`, order: 90, enabledByDefault: true },
41
- tools: [],
42
- host: { inject: [] },
43
- };
44
-
45
25
  const face: UnitClientFace = {
46
- manifest,
26
+ unit: UNIT_ID,
47
27
  messages,
48
28
  card: (props) => {
49
29
  const { Card } = kit.components;
@@ -4,25 +4,13 @@ export const name = '__NAME__';
4
4
 
5
5
  export const inject = ['workbenchUnits'];
6
6
 
7
- interface UnitManifest {
8
- readonly id: string;
9
- readonly kind: string;
10
- readonly card: {
11
- readonly titleKey: string;
12
- readonly order: number;
13
- readonly enabledByDefault: boolean;
14
- } | null;
15
- readonly tools: readonly unknown[];
16
- readonly host: { readonly inject: readonly string[] };
17
- }
18
-
19
- interface UnitHostFace {
20
- readonly manifest: UnitManifest;
7
+ interface UnitModuleFace {
8
+ readonly unit: string;
21
9
  mount(): { routes: Record<string, never>; registerTools(): Array<() => void> };
22
10
  }
23
11
 
24
12
  interface UnitRegistry {
25
- register(face: UnitHostFace): () => void;
13
+ register(face: UnitModuleFace): () => void;
26
14
  }
27
15
 
28
16
  interface HostContext {
@@ -31,16 +19,8 @@ interface HostContext {
31
19
  logger?: { warn?(message: string): void } | undefined;
32
20
  }
33
21
 
34
- const manifest: UnitManifest = {
35
- id: UNIT_ID,
36
- kind: 'board',
37
- card: { titleKey: `${UNIT_ID}.cardTitle`, order: 90, enabledByDefault: true },
38
- tools: [],
39
- host: { inject: [] },
40
- };
41
-
42
- const face: UnitHostFace = {
43
- manifest,
22
+ const face: UnitModuleFace = {
23
+ unit: UNIT_ID,
44
24
  mount: () => ({ routes: {}, registerTools: () => [] }),
45
25
  };
46
26
 
@@ -39,7 +39,7 @@ describe('__NAME__ 的面', () => {
39
39
  });
40
40
  apply(ctx as never);
41
41
  expect(faces).toHaveLength(1);
42
- expect((faces[0] as { manifest: { id: string } }).manifest.id).toBe('__UNIT__');
42
+ expect((faces[0] as { unit: string }).unit).toBe('__UNIT__');
43
43
  });
44
44
 
45
45
  it('浏览器半侧把自己的卡片注册进外壳', () => {
@@ -51,5 +51,6 @@ describe('__NAME__ 的面', () => {
51
51
  });
52
52
  applyClient(ctx as never);
53
53
  expect(faces).toHaveLength(1);
54
+ expect((faces[0] as { unit: string }).unit).toBe('__UNIT__');
54
55
  });
55
56
  });
@@ -58,7 +58,7 @@ pnpm selfcheck # 构建 + 把 client.js 当浏览器加载器跑一遍 + 调
58
58
 
59
59
  | 能力 | 一句话 | 传什么 / 拿回什么(贴真实调用与返回) | 要点头吗 | 点头时他看到的"会发生什么" |
60
60
  |---|---|---|---|---|
61
- | (这份骨架故意没有能力:`manifest.tools` 是空的。要加就照这张表填,只读的不点头、动东西的要点头) | | | | |
61
+ | (这份骨架故意没有能力:`dsh.workbench.tools` 是空的。要加就照这张表填,只读的不点头、动东西的要点头) | | | | |
62
62
 
63
63
  ### 表 3 联动:agent 动完,这个模块哪一格变
64
64
 
@@ -25,9 +25,13 @@
25
25
  "external": ["@dsh-workbench/core/client"]
26
26
  },
27
27
  "workbench": {
28
+ "apiVersion": 1,
28
29
  "unit": "__UNIT__",
29
- "faces": { "host": "src/host.ts", "client": "src/client.tsx" }
30
- }
30
+ "card": { "titleKey": "__UNIT__.cardTitle", "order": 90 },
31
+ "tools": [],
32
+ "host": { "inject": [] }
33
+ },
34
+ "workbenchFaces": { "host": "src/host.ts", "client": "src/client.tsx" }
31
35
  },
32
36
  "peerDependencies": { "@dsh-workbench/core": "^0.1.0", "react": "*" },
33
37
  "peerDependenciesMeta": { "react": { "optional": true } },
@@ -115,14 +115,12 @@ const ctx = {
115
115
 
116
116
  pluginExports.apply(ctx);
117
117
  check('卡片真的注册进了外壳', faces.length === 1, `注册了 ${faces.length} 个`);
118
- check(`注册的是 ${UNIT_ID} 单元`, faces[0]?.manifest.id === UNIT_ID, faces[0]?.manifest.id);
118
+ check(`注册的是 ${UNIT_ID} 单元`, faces[0]?.unit === UNIT_ID, faces[0]?.unit);
119
119
  check('卡片是个能画的组件', typeof faces[0]?.card === 'function');
120
120
  check(
121
- '卡片自己带标题与顺序(外壳的看板据此排)',
122
- typeof faces[0]?.manifest.card?.titleKey === 'string' &&
123
- faces[0].manifest.card.titleKey !== '' &&
124
- typeof faces[0]?.manifest.card?.order === 'number',
125
- JSON.stringify(faces[0]?.manifest.card),
121
+ '面只交 unit(标题与顺序住在 package.json 的 dsh.workbench 里,由外壳按 unit 交给浏览器)',
122
+ faces[0]?.manifest === undefined,
123
+ JSON.stringify(faces[0]?.manifest),
126
124
  );
127
125
  check(
128
126
  '卡片在空数据下也画得出来(不许抛)',
@@ -10,18 +10,6 @@ const KB = 1024;
10
10
  const { Card, MockStrip, CenteredState, EmptyState, StateBlock, IconPanelScreen } = kit.components;
11
11
  const { useApiResource, useFixturePick } = kit.hooks;
12
12
 
13
- interface UnitManifest {
14
- readonly id: string;
15
- readonly kind: string;
16
- readonly card: {
17
- readonly titleKey: string;
18
- readonly order: number;
19
- readonly enabledByDefault: boolean;
20
- } | null;
21
- readonly tools: readonly unknown[];
22
- readonly host: { readonly inject: readonly string[] };
23
- }
24
-
25
13
  type Rendered = ReturnType<typeof h>;
26
14
  type Translate = (key: string, params?: Record<string, unknown>) => string;
27
15
 
@@ -37,7 +25,7 @@ interface BoardProps {
37
25
  }
38
26
 
39
27
  interface UnitClientFace {
40
- readonly manifest: UnitManifest;
28
+ readonly unit: string;
41
29
  readonly messages: { readonly zh: Readonly<Record<string, string>>; readonly en: Readonly<Record<string, string>> };
42
30
  readonly card: (props: BoardProps) => Rendered;
43
31
  }
@@ -146,14 +134,6 @@ export function overviewStateOf(input: OverviewInput): OverviewState {
146
134
  return { kind: 'loading' };
147
135
  }
148
136
 
149
- const manifest: UnitManifest = {
150
- id: UNIT_ID,
151
- kind: 'board',
152
- card: { titleKey: `${UNIT_ID}.cardTitle`, order: 90, enabledByDefault: true },
153
- tools: [],
154
- host: { inject: [] },
155
- };
156
-
157
137
  function bodyOf(state: OverviewState, t: Translate): Rendered {
158
138
  if (state.kind === 'idle') {
159
139
  return h(CenteredState, {
@@ -207,7 +187,7 @@ function bodyOf(state: OverviewState, t: Translate): Rendered {
207
187
  }
208
188
 
209
189
  const face: UnitClientFace = {
210
- manifest,
190
+ unit: UNIT_ID,
211
191
  messages,
212
192
  card: (props) => {
213
193
  const workspace = props.world.current.workspace;
@@ -56,25 +56,13 @@ function limitOf(search: URLSearchParams): number {
56
56
  return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(parsed)));
57
57
  }
58
58
 
59
- interface UnitManifest {
60
- readonly id: string;
61
- readonly kind: string;
62
- readonly card: {
63
- readonly titleKey: string;
64
- readonly order: number;
65
- readonly enabledByDefault: boolean;
66
- } | null;
67
- readonly tools: readonly unknown[];
68
- readonly host: { readonly inject: readonly string[] };
69
- }
70
-
71
- interface UnitHostFace {
72
- readonly manifest: UnitManifest;
59
+ interface UnitModuleFace {
60
+ readonly unit: string;
73
61
  mount(): { routes: Record<string, RouteHandler>; registerTools(): Array<() => void> };
74
62
  }
75
63
 
76
64
  interface UnitRegistry {
77
- register(face: UnitHostFace): () => void;
65
+ register(face: UnitModuleFace): () => void;
78
66
  }
79
67
 
80
68
  interface HostContext {
@@ -83,16 +71,8 @@ interface HostContext {
83
71
  logger?: { warn?(message: string): void } | undefined;
84
72
  }
85
73
 
86
- const manifest: UnitManifest = {
87
- id: UNIT_ID,
88
- kind: 'board',
89
- card: { titleKey: `${UNIT_ID}.cardTitle`, order: 90, enabledByDefault: true },
90
- tools: [],
91
- host: { inject: [] },
92
- };
93
-
94
- const face: UnitHostFace = {
95
- manifest,
74
+ const face: UnitModuleFace = {
75
+ unit: UNIT_ID,
96
76
  mount: () => ({
97
77
  routes: {
98
78
  [OVERVIEW_ROUTE]: async (search: URLSearchParams) => {
@@ -51,12 +51,12 @@ import {
51
51
  } from './client';
52
52
 
53
53
  interface HostFace {
54
- readonly manifest: { readonly id: string };
54
+ readonly unit: string;
55
55
  mount(): { readonly routes: Record<string, (search: URLSearchParams) => Promise<unknown>> };
56
56
  }
57
57
 
58
58
  interface ClientFace {
59
- readonly manifest: { readonly id: string };
59
+ readonly unit: string;
60
60
  readonly card: (props: {
61
61
  readonly world: { readonly current: { readonly workspace?: { readonly path: string } | null } };
62
62
  readonly refreshToken: number;
@@ -163,8 +163,8 @@ describe('__NAME__ 的面', () => {
163
163
  });
164
164
 
165
165
  it('宿主半侧与浏览器半侧都把自己的面注册进外壳', () => {
166
- expect(hostFace().manifest.id).toBe('__UNIT__');
167
- expect(clientFace().manifest.id).toBe('__UNIT__');
166
+ expect(hostFace().unit).toBe('__UNIT__');
167
+ expect(clientFace().unit).toBe('__UNIT__');
168
168
  });
169
169
  });
170
170