@dsh-workbench/module-tools 1.1.12 → 1.1.14-beta.0

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.
Files changed (64) hide show
  1. package/README.md +36 -23
  2. package/bin/dsh-wb-module.mjs +18 -4
  3. package/package.json +4 -4
  4. package/src/arch.mjs +36 -25
  5. package/src/check.mjs +50 -3
  6. package/src/checks/arch.mjs +2 -2
  7. package/src/checks/contracts.mjs +88 -41
  8. package/src/checks/kit.mjs +3 -2
  9. package/src/checks/lint.mjs +2 -2
  10. package/src/checks/primitives.mjs +2 -2
  11. package/src/discover.mjs +186 -16
  12. package/src/init.mjs +7 -8
  13. package/src/lint.mjs +4 -16
  14. package/src/market-index.mjs +1 -1
  15. package/src/migrate.mjs +69 -0
  16. package/src/release-journal.mjs +3 -1
  17. package/src/release-target.mjs +1 -1
  18. package/src/release-util.mjs +7 -1
  19. package/template-diy/AGENTS.md.tpl +14 -22
  20. package/template-diy/README.md.tpl +61 -65
  21. package/template-diy/design.json.tpl +19 -21
  22. package/template-diy/package.json.tpl +6 -2
  23. package/template-diy/scripts/smoke.assert.mjs.tpl +28 -0
  24. package/template-diy/scripts/smoke.mjs.tpl +150 -30
  25. package/template-diy/src/__tests__/plugin.test.ts.tpl +453 -0
  26. package/template-diy/src/components/NotesSection.tsx.tpl +74 -0
  27. package/template-diy/src/components/OverviewBody.module.css.tpl +5 -0
  28. package/template-diy/src/components/OverviewBody.tsx.tpl +64 -0
  29. package/template-diy/src/components/OverviewCard.module.css.tpl +9 -1
  30. package/template-diy/src/components/OverviewCard.tsx.tpl +108 -67
  31. package/template-diy/src/components/PrefsSection.tsx.tpl +36 -0
  32. package/template-diy/src/components/RunSection.tsx.tpl +69 -0
  33. package/template-diy/src/components/{OverviewCard.test.ts.tpl → __tests__/OverviewCard.test.ts.tpl} +51 -1
  34. package/template-diy/src/declared-tools.ts.tpl +6 -0
  35. package/template-diy/src/host/notes.ts.tpl +92 -0
  36. package/template-diy/src/host/overview.ts.tpl +57 -0
  37. package/template-diy/src/host/sample.ts.tpl +151 -0
  38. package/template-diy/src/host.ts.tpl +88 -66
  39. package/template-diy/src/messages.ts.tpl +28 -0
  40. package/template-diy/src/model/__tests__/flow.test.ts.tpl +36 -0
  41. package/template-diy/src/model/__tests__/notes.test.ts.tpl +49 -0
  42. package/template-diy/src/model/{overview.test.ts.tpl → __tests__/overview.test.ts.tpl} +1 -1
  43. package/template-diy/src/model/__tests__/prefs.test.ts.tpl +59 -0
  44. package/template-diy/src/model/flow.ts.tpl +47 -0
  45. package/template-diy/src/model/notes.ts.tpl +52 -0
  46. package/template-diy/src/model/overview.ts.tpl +3 -1
  47. package/template-diy/src/model/prefs.ts.tpl +61 -0
  48. package/template/AGENTS.md.tpl +0 -33
  49. package/template/CHANGELOG.md.tpl +0 -7
  50. package/template/README.md.tpl +0 -30
  51. package/template/arch.config.mjs +0 -2
  52. package/template/cordis.patch.yml.tpl +0 -3
  53. package/template/eslint.config.mjs +0 -2
  54. package/template/package.json.tpl +0 -50
  55. package/template/scripts/smoke.mjs.tpl +0 -192
  56. package/template/src/client.tsx.tpl +0 -22
  57. package/template/src/components/HelloCard.tsx.tpl +0 -28
  58. package/template/src/host.ts.tpl +0 -21
  59. package/template/src/messages.ts.tpl +0 -12
  60. package/template/src/plugin.test.ts.tpl +0 -56
  61. package/template/tsconfig.json +0 -20
  62. package/template/types/globals.d.ts +0 -14
  63. package/template/vitest.config.ts +0 -14
  64. package/template-diy/src/plugin.test.ts.tpl +0 -159
@@ -2,11 +2,12 @@
2
2
 
3
3
  `__UNIT__` 模块:一个独立的 dsh 插件包,只认外壳的服务名,不认识外壳源码。
4
4
 
5
- 这份是 **DIY 模板**(`--template diy`)生成的骨架:一张**读当前项目目录的只读卡**,
6
- 数据从宿主侧**真读**一个目录算出来(`node:fs`),不是一个写死的样板。
5
+ 这份是 **唯一模板**(`--template diy`,也是默认)生成的骨架:一张**读当前项目目录的只读卡**,
6
+ 数据从宿主侧**真读**一个目录算出来(`node:fs`),另带一条**能跑**的流程 / 长任务 / 真工具 /
7
+ 动作通道样板;不是一个写死的样板。
7
8
 
8
9
  ```bash
9
- dsh-wb-module init <unit> --dir <目录> --template diy # 生成这份骨架(默认模板是没有数据源的空壳)
10
+ dsh-wb-module init <unit> --dir <目录> # 生成这份骨架(只有这一个模板)
10
11
  dsh-wb-module build # 出 dist/host.js 与套了 loader 壳的 client.js
11
12
  dsh-wb-module check # 十三条判据:契约 / 声明协议 / 产物边界 / 源码边界 / 架构 / dsh 原语 / kit 面 / 卡片渲染 / 独立构建 / 内部标识 / 样式归属 / 不指外壳 / 代码卫生(eslint)
12
13
  dsh-wb-module check --only cards # 十三条可以单跑
@@ -17,8 +18,8 @@ dsh-wb-module check --only cards # 十三条可以单跑
17
18
  ```bash
18
19
  pnpm build # 构建产物
19
20
  pnpm typecheck # tsc --noEmit
20
- pnpm test # vitest:路由真读数 + 卡片五态
21
- pnpm selfcheck # 构建 + 把 client.js 当浏览器加载器跑一遍 + 调一次 <unit>/overview 看它真读盘
21
+ pnpm test # vitest:路由真读数 + 卡片五态 + 长任务/工具契约
22
+ pnpm selfcheck # 构建 + 把 client.js 当浏览器加载器跑一遍 + 真跑一趟流程 + 走 scripts/smoke.assert.mjs
22
23
  pnpm check # dsh-wb-module check
23
24
  pnpm lint # dsh-wb-module lint(`--fix` 就地改文件;`check` 永不动手)
24
25
  ```
@@ -31,89 +32,84 @@ pnpm lint # dsh-wb-module lint(`--fix` 就地改文件;`check` 永
31
32
  —— 要改三张表就改 `design.json`,再 `dsh-wb-module design render`;手改那一段会被 next 一次 check 判红。
32
33
  所以下次他说"改一下"不用从头问。
33
34
 
35
+ **非生成段(这一节与下文)不会被 render 覆盖**:需求变了要自己改这里,`design render` 只管标记之间那一段。
36
+
34
37
  怎么问、怎么填:指引 `SKILL.md` 的七步 · `DESIGN.md`(判域 / 两条分工 / 三张表)·
35
38
  `STEPS.md`(每步问什么、什么时候算做完)· `PANEL.md` · `TOOLS.md` · `LINKAGE.md`。
36
39
 
37
40
  <!-- dsh-wb:design:start -->
38
- ### 表 1 面板:他看什么、他能自己配什么
39
-
40
- | 这一格 | 一眼能得出什么结论 | 点一下可以去哪 |
41
- |---|---|---|
42
- | 这个目录占了多少空间 | 该不该清、谁在占 | (本骨架没有外链;要加就把目标写这儿) |
43
-
44
- | 能自己配的 | 管什么 | 默认是什么 | 可选值从哪来 | 选错了会怎样 |
45
- |---|---|---|---|---|
46
- | 列几个最大的文件 | 明细长度 | `5` | `1..50` | 夹回范围内,不报错 |
47
-
48
- 状态矩阵(每种状态:他看到什么 / 他能做什么 / agent 能接着做什么)——
49
- 图号对着下面「卡片画哪五态」那一节:
50
-
51
- | # | 状态 | 他看到什么 | 他能做什么 | agent 能接着做什么 |
52
- |---|---|---|---|---|
53
- | A | 待机(没选项目) | 「先打开一个项目」,不发请求 | 去左栏开一个项目 | 说清"要先有项目" |
54
- | B | 加载 | 「正在读目录」 | 等 | —— |
55
- | C | 就绪 | 路径 + N 文件 / M 目录 / 总字节 + 最大的几个 | 刷新 | 读一次这一格(联动见表 3) |
56
- | D | 就绪但空 | 「这个目录是空的」 | 换一个目录 | 同上 |
57
- | E | 读不到 | 读不到这个目录 + 宿主的原话 | 重试 / 改路径 | 同上 |
58
-
59
- ### 表 2 agent:他能替用户做什么、哪几件要点头
60
-
61
- | 能力 | 一句话 | 传什么 / 拿回什么(贴真实调用与返回) | 要点头吗 | 点头时他看到的"会发生什么" |
62
- |---|---|---|---|---|
63
- | (这份骨架故意没有能力:`dsh.workbench.tools` 是空的。要加就照这张表填,只读的不点头、动东西的要点头) | | | | |
64
-
65
- ### 表 3 联动:agent 动完,这个模块哪一格变
66
-
67
- | 谁在动 | 面板什么时候变 | 哪一格变(投影 + 最窄那个参数) | 用户看到什么 |
68
- |---|---|---|---|
69
- | agent 读一次这一格 | 读完立刻 | 本单元那条只读投影(`__UNIT__/overview`),只带这个目录 | 那一格变成 agent 看到的那份数 |
70
- | agent 只预览 / 只说了句话 | 不动 | —— | 面板没变 |
71
- | 用户自己按刷新 | 立刻 | 同上 | 转一下,然后是新值 |
72
-
73
- ### 数据源探明记录
74
-
75
- `opencli` 只是**侦察兵**:探明之后固化成**一条**读,日常只走这一条
76
- (这条就是 `src/host.ts` 里那个数据源)。改数据源时先改这张表,再改代码。
77
-
78
- | 项 | 值(照抄,别转述) |
79
- |---|---|
80
- | 哪条读(命令 / URL) | 本机目录本身:`readdirSync(path)` + `statSync`(不走网络、不要凭据) |
81
- | 参数(缺省 / 范围 / 上限) | `path` 必填(卡片给当前项目);`limit` 默认 `5`,夹在 `1..50` |
82
- | 凭据从哪来 | 不需要 |
83
- | 没登录 / 凭据过期时是什么样 | 不适用(本机读盘,没有登录这回事) |
84
- | 超时 / 连不上时是什么样 | 路径不存在 / 没权限 / 不是目录 → HTTP 200 + `ok:false`,码 `overview-unreadable` |
85
- | 当初探明用的那次调用(原文) | `node -e "require('node:fs').readdirSync(process.cwd())"` |
86
-
41
+ (这一段由 `design.json` 生成:改需求改 `design.json`,再 `dsh-wb-module design render`;手改会被下一次 check 判红。)
87
42
  <!-- dsh-wb:design:end -->
88
43
 
89
44
  ## 生成出来的形状
90
45
 
91
46
  **分层多文件**,不是单文件:数字越小越底层,上层只许 import 下层的(`arch.config.mjs` 那条
92
47
  `arch` 判据盯着,`dsh-wb-module check` 会跑)。目录**按需要出现**:这份有状态逻辑,所以有 `model/`。
48
+ 宿主侧的辅助实现放 `src/host/`(`moduleArch()` 默认就认这一层;
49
+ `src/host.ts` 仍是对外的宿主面,两个面只认 `dsh.workbenchFaces` 里那两个文件)。
93
50
 
94
51
  | 文件 | 是什么 | 层 |
95
52
  |---|---|---|
96
- | `package.json` | 自述清单:`dsh.workbench.unit` = `__UNIT__`、两个面(`src/host.ts` / `src/client.tsx`)、六个脚本、peer 下限 | — |
97
- | `arch.config.mjs` | 本模块的架构方言,**只有两行**(`import { moduleArch }` + `export default moduleArch()`)—— 层序表 / 两个运行时 / 阈值只有 `@dsh-workbench/module-tools/arch` 那一份,随工具链同代 | — |
53
+ | `package.json` | 自述清单:`dsh.workbench.unit` = `__UNIT__`、两个面(`src/host.ts` / `src/client.tsx`)、八个脚本、peer 下限 | — |
54
+ | `arch.config.mjs` | 本模块的架构方言:`moduleArch()`(`src/host/**` 默认在层序里)—— 层序表 / 两个运行时 / 阈值只有 `@dsh-workbench/module-tools/arch` 那一份,随工具链同代 | — |
98
55
  | `eslint.config.mjs` | 本模块的**语法卫生方言**,同样两行:`moduleLint()` —— 规则表只有 `@dsh-workbench/module-tools/lint` 那一份(`console` / `as any` / hooks 那几条 / 手搓颜色与 `!important`);要开口子就 `moduleLint({ rules })` | — |
99
56
  | `cordis.patch.yml` | 插进 dsh 组合的补丁 | — |
100
57
  | `design.json` | 三张表 + 状态矩阵 + 数据源记录 + 验收清单(**唯一真相**,上面那段由它生成;`dsh-wb-module design check/render/accept` 读写它) | — |
101
58
  | `src/model/overview.ts` | 纯逻辑:类型 + 五态状态机 + `formatBytes`。两个运行时都读得到,不碰 react / kit / dsh / `node:*` | 1 |
102
- | `src/model/overview.test.ts` | 模型层的配对测试(有 `src/model/**` 就必须有 —— `arch` 的 M08) | 99 |
59
+ | `src/model/__tests__/overview.test.ts` | 模型层的配对测试(有 `src/model/**` 就必须有 —— `arch` 的 M08) | 99 |
60
+ | `src/model/flow.ts` | 样板流程的纯逻辑:`FlowDefinition` + 五态标题 / 是否还在跑;不碰宿主 | 1 |
61
+ | `src/model/__tests__/flow.test.ts` | 流程模型的配对测试 | 99 |
62
+ | `src/model/notes.ts` | 笔记的纯逻辑:校验 / 读回 / 最多 20 条 | 1 |
63
+ | `src/model/__tests__/notes.test.ts` | 笔记模型的配对测试 | 99 |
64
+ | `src/model/prefs.ts` | 配置的纯逻辑:按工作区存 `limit`(1..50),坏值回默认 | 1 |
65
+ | `src/model/__tests__/prefs.test.ts` | 配置模型的配对测试 | 99 |
103
66
  | `src/messages.ts` | 卡片那几句文案(zh / en),键带 `<unit>.` 前缀 | 1 |
104
- | `src/components/OverviewCard.tsx` | 卡片:`Card` 壳 + 五态(待机 / 加载 / 就绪 / 出错,其中"就绪但空"单列一态)+ 最大的几个文件 | 2 |
67
+ | `src/declared-tools.ts` | 清单里工具名的生成物(`dsh-wb-module sync` 写;代码只引 `DECLARED_TOOLS.run.name` / `DECLARED_TOOLS.addNote.name`,不写字面量) | 1 |
68
+ | `src/components/OverviewCard.tsx` | 卡片:`Card` 壳 + 五态 + 最大的几个文件 + 长任务行 + 笔记(写)/「显示几个」配置 | 2 |
69
+ | `src/components/OverviewBody.tsx` | 五态各自那一屏(待机 / 加载 / 就绪 / 空 / 出错) | 2 |
70
+ | `src/components/OverviewBody.module.css` | 就绪态「最大的几个文件」列表的布局 | 2 |
71
+ | `src/components/RunSection.tsx` | 长任务那一块:跑流程的钮 + `LongTaskRow`(五态 / 停止) | 2 |
72
+ | `src/components/NotesSection.tsx` | 笔记那一块:输入 + 存一条 + 列表 | 2 |
73
+ | `src/components/PrefsSection.tsx` | 「最大的几个文件」配置(`SelectField`,按工作区存) | 2 |
105
74
  | `src/components/OverviewCard.module.css` | 这个组件自己的样式(CSS Modules,构建时类名按路径哈希)—— **样式跟组件同目录** | 2 |
106
- | `src/components/OverviewCard.test.ts` | 这张卡五态各画得出来(测试跟着被测的东西住) | 99 |
107
- | `src/host.ts` | 宿主面:`readOverview()`(真读目录)+ 一条只读路由 `<unit>/overview`;类型从 `./model/overview` 引 | 10 |
75
+ | `src/components/__tests__/OverviewCard.test.ts` | 这张卡五态各画得出来 + 写失败留在卡上(测试住同目录的 `__tests__/`);流程 / 笔记 / 配置的接线由 `plugin.test.ts` 与 smoke 覆盖 | 99 |
76
+ | `src/host.ts` | 宿主面:接线 + 三条只读路由(`overview` / `notes` / `prefs`)+ 挂工具 / 长任务 / 动作通道 / `prefs` / 审批口径 | 10 |
77
+ | `src/host/overview.ts` | 宿主侧内部实现:`readOverview()`(真读目录)+ 路由回答 | 10 |
78
+ | `src/host/sample.ts` | 宿主侧内部实现:样板流程的动作、长任务、只读工具 | 10 |
79
+ | `src/host/notes.ts` | 宿主侧内部实现:笔记读写(`deps.store`)+ 写工具 + 动作通道 | 10 |
108
80
  | `src/client.tsx` | 浏览器面:**只剩面** —— 注册 + 把 props 交给 `components/OverviewCard` | 10 |
109
- | `src/plugin.test.ts` | 面注册 + 路由真读的断言 | 99 |
110
- | `scripts/smoke.mjs` | 接线自检:卡片进没进外壳、路由真读没真读 | — |
81
+ | `src/__tests__/plugin.test.ts` | 面注册 + 路由真读 + 工具注册 + 流程真跑 + 笔记/配置/审批 | 99 |
82
+ | `scripts/smoke.mjs` | 接线自检:卡片进没进外壳、工具注册、流程真跑、笔记真写、配置存得住;真读断言在 `smoke.assert.mjs` | — |
83
+ | `scripts/smoke.assert.mjs` | **这个模块自己的**真读断言:换数据源时改这份,别删 smoke 里的调用 | — |
84
+ | `CHANGELOG.md` | 版本记录(`dsh-wb-module release` 追加;`package.json.files` 里带它) | — |
111
85
  | `tsconfig.json` / `vitest.config.ts` / `types/globals.d.ts` / `types/styles.d.ts` | 经典 JSX 运行时(`jsxFactory: h`)、vitest 的别名、浏览器半侧的运行时垫片、`*.module.css` 的类名表声明 | — |
112
86
  | `README.md` | 就是本文 | — |
113
87
 
88
+ ## 长任务、工具、写通道与配置(样板)
89
+
90
+ 这份骨架故意带了一组**可跑**的能力,示范五件事:
91
+
92
+ - **宿主面接线**:`name` / `inject` / `apply` 用 `@dsh-workbench/shared/host` 的 `defineUnitHostPlugin`
93
+ 一次给齐;写工具的审批口径用 `writeApprovalOf`;`recordOf` / `textOf` 也从那里取,别每个文件抄一份。
94
+ - **只读工具**:`registerTools()` 是**调用时才注册**、返回的是清理函数;工具名必须和 `package.json` 的
95
+ `dsh.workbench.tools` 一字不差。`__UNIT___run` 起一趟流程,传 `runId` 则只看同一趟。
96
+ - **长任务**:`deps.longTasks.create()` 交回一张注册表,`run` 里调 `deps.flow.run()` 并把
97
+ `onStep` 交给 `longTaskStepOf`(shared/host 的默认映射);卡片用 `useLongTask` 读、`LongTaskRow` 画。
98
+ - **写通道 + 审批**:`__UNIT___add_note` 在清单里写 `writes: true`,`mount()` 返回 `needsApproval`(这次要点头)
99
+ 与 `approvalDetail`(审批屏上那句"会写什么");agent 调用前壳会拦一道。卡片那颗钮走动作通道
100
+ `<unit>-add-note`(点击即确认,不审批),与写工具共用同一份 `deps.store` 数据;网络 / 校验失败
101
+ 留在按钮下面那一行,不静默。
102
+ - **配置(prefs)**:`mount()` 返回 `prefs: { resource, handle }`,按工作区把"显示几个文件"存进
103
+ `deps.store`;卡片用 `useHostWrite({ channel: 'prefs' })` 写、用 `<unit>/prefs` 读。
104
+ - **动作通道**:用户直接点的单步动作走 `<unit>-…` 资源;与工具共用实现,不另写一份。
105
+
106
+ 换成你自己的能力时,五处一起改:`src/model/*` 的纯逻辑、`src/host/*` 的实现、
107
+ `src/host.ts` 的接线(路由 / 工具 / 动作 / prefs / 审批)、`src/components/OverviewCard.tsx` 的展示、
108
+ `design.json` 的三张表与能力行。
109
+
114
110
  ## 数据是在哪算出来的
115
111
 
116
- 宿主侧,`src/host.ts` 的 `readOverview(path, limit)`:
112
+ 宿主侧,`src/host/overview.ts` 的 `readOverview(path, limit)`:
117
113
 
118
114
  * `readdirSync(path, { withFileTypes: true })` 数出**目录数**;
119
115
  * 每个普通文件 `statSync` 一次,累加**总字节**、记下 `name / bytes / mtime`(mtime 是 ISO 串);
@@ -164,8 +160,8 @@ GET /api/__UNIT__/overview?path=<绝对路径>&limit=<1..50>
164
160
  ## 卡片画哪五态
165
161
 
166
162
  `src/model/overview.ts` 的 `overviewStateOf({ phase, value, error })` 是一条纯函数,
167
- 每个分支各对应一屏(`src/model/overview.test.ts` 直接断言这条函数,
168
- `src/components/OverviewCard.test.ts` 再把每一屏画一遍):
163
+ 每个分支各对应一屏(`src/model/__tests__/overview.test.ts` 直接断言这条函数,
164
+ `src/components/__tests__/OverviewCard.test.ts` 再把每一屏画一遍):
169
165
 
170
166
  | 态 | 什么时候 | 画什么 |
171
167
  |---|---|---|
@@ -3,37 +3,35 @@
3
3
  "name": "__NAME__",
4
4
  "oneLine": "__DESC__",
5
5
  "panel": [
6
- { "cell": "这个目录占了多少空间", "conclusion": "该不该清、谁在占", "link": "" }
6
+ { "cell": "(待填)", "conclusion": "(待填)", "link": "" }
7
7
  ],
8
8
  "states": [
9
- { "key": "idle", "sees": "「先打开一个项目」,不发请求", "canDo": "去左栏开一个项目", "agentCan": "说清「要先有项目」" },
10
- { "key": "loading", "sees": "「正在读目录」", "canDo": "等", "agentCan": "——" },
11
- { "key": "ready", "sees": "路径 + N 文件 / M 目录 / 总字节 + 最大的几个", "canDo": "刷新", "agentCan": "读一次这一格(联动见表 3)" },
12
- { "key": "empty", "sees": "「这个目录是空的」", "canDo": "换一个目录", "agentCan": "同上" },
13
- { "key": "unreadable", "sees": "「读不到这个目录」+ 宿主的原话", "canDo": "重试 / 改路径", "agentCan": "同上" }
9
+ { "key": "idle", "sees": "(待填)", "canDo": "(待填)", "agentCan": "(待填)" },
10
+ { "key": "loading", "sees": "(待填)", "canDo": "(待填)", "agentCan": "(待填)" },
11
+ { "key": "ready", "sees": "(待填)", "canDo": "(待填)", "agentCan": "(待填)" },
12
+ { "key": "empty", "sees": "(待填)", "canDo": "(待填)", "agentCan": "(待填)" },
13
+ { "key": "unreadable", "sees": "(待填)", "canDo": "(待填)", "agentCan": "(待填)" }
14
14
  ],
15
15
  "config": [
16
- { "persistence": "view", "key": "列几个最大的文件", "watches": "明细长度", "default": "5", "valuesFrom": "1..50", "wrongChoice": "夹回范围内,不报错" }
16
+ { "persistence": "prefs", "key": "(待填)", "watches": "(待填)", "default": "(待填)", "valuesFrom": "(待填)", "wrongChoice": "(待填)" }
17
+ ],
18
+ "capabilities": [
19
+ { "name": "__UNIT___run", "oneLine": "(待填)", "shape": "(待填)", "approval": "不点头(只读)" },
20
+ { "name": "__UNIT___add_note", "oneLine": "(待填)", "shape": "(待填)", "approval": "点头(写本模块笔记)" }
17
21
  ],
18
- "capabilities": [],
19
22
  "linkage": [
20
- { "who": "agent 读一次这一格", "when": "读完立刻", "whatChanges": "本单元那条只读投影(__UNIT__/overview),只带这个目录", "userSees": "那一格变成 agent 看到的那份数" },
21
- { "who": "agent 只预览 / 只说了句话", "when": "不动", "whatChanges": "——", "userSees": "面板没变" },
22
- { "who": "用户自己按刷新", "when": "立刻", "whatChanges": "同上", "userSees": "转一下,然后是新值" }
23
+ { "who": "(待填)", "when": "(待填)", "whatChanges": "(待填)", "userSees": "(待填)" }
23
24
  ],
24
25
  "datasource": {
25
- "read": "本机目录本身:readdirSync(path) + statSync(不走网络、不要凭据)",
26
- "params": "path 必填(卡片给当前项目);limit 默认 5,夹在 1..50",
27
- "credentials": "不需要",
28
- "notLoggedIn": "不适用(本机读盘,没有登录这回事)",
29
- "timeout": "路径不存在 / 没权限 / 不是目录 → HTTP 200 + ok:false,码 overview-unreadable",
30
- "probeCommand": "node -e \"require('node:fs').readdirSync(process.cwd())\""
26
+ "read": "(待填)",
27
+ "params": "(待填)",
28
+ "credentials": "(待填)",
29
+ "notLoggedIn": "(待填)",
30
+ "timeout": "(待填)",
31
+ "probeCommand": "(待填)"
31
32
  },
32
33
  "writes": [],
33
34
  "acceptance": [
34
- { "step": "打开面板 → 看到这张卡 → 路径 + N 文件 / M 目录 / 总字节", "checked": false },
35
- { "step": "换一个空目录 → 看到「这个目录是空的」(不是空白、也不是 0)", "checked": false },
36
- { "step": "把 limit 调成 50 → 明细变长;调成 0 → 夹回 1,不报错", "checked": false },
37
- { "step": "让 agent 读一次这一格 → 面板那一格变成同一份数", "checked": false }
35
+ { "step": "(待填)", "checked": false }
38
36
  ]
39
37
  }
@@ -16,7 +16,7 @@
16
16
  "selfcheck": "dsh-wb-module build && dsh-wb-module design check && node scripts/smoke.mjs",
17
17
  "check": "dsh-wb-module check",
18
18
  "lint": "dsh-wb-module lint",
19
- "design": "dsh-wb-module design check",
19
+ "design": "dsh-wb-module design",
20
20
  "prepack": "dsh-wb-module build"
21
21
  },
22
22
  "dsh": {
@@ -31,11 +31,15 @@
31
31
  "unit": "__UNIT__",
32
32
  "title": { "zh": "__UNIT__ 模块", "en": "__UNIT__ module" },
33
33
  "card": { "order": 90 },
34
- "tools": [],
34
+ "tools": [
35
+ { "name": "__UNIT___run", "writes": false, "key": "run" },
36
+ { "name": "__UNIT___add_note", "writes": true, "key": "addNote" }
37
+ ],
35
38
  "host": { "inject": [] }
36
39
  },
37
40
  "workbenchFaces": { "host": "src/host.ts", "client": "src/client.tsx" }
38
41
  },
42
+ "dependencies": { "@dsh-workbench/shared": "__SHELL_RANGE__" },
39
43
  "peerDependencies": { "@dsh-workbench/core": "__SHELL_RANGE__", "react": "*" },
40
44
  "peerDependenciesMeta": { "react": { "optional": true } },
41
45
  "devDependencies": {
@@ -0,0 +1,28 @@
1
+ // 这份模块自己的真读断言:换数据源时改这一份,不要删 smoke.mjs 里那一整段。
2
+ //
3
+ // `assertModule()` 只在这里拿到真东西(路由表与 check),想验什么就写什么:
4
+ // 卡片画的数从哪来,这里真的调一次 —— `check` 的十三条判据一条都不碰这条。
5
+
6
+ import { readdirSync } from 'node:fs';
7
+ import { dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const moduleDir = dirname(dirname(fileURLToPath(import.meta.url)));
11
+
12
+ export async function assertModule({ check, routes, UNIT_ID }) {
13
+ const route = `${UNIT_ID}/overview`;
14
+ if (typeof routes[route] !== 'function') {
15
+ check(`真读路由 ${route} 还在`, false, '换数据源时把 smoke.assert.mjs 的断言一起改掉,别静默跳过');
16
+ return;
17
+ }
18
+
19
+ const answer = await routes[route](new URLSearchParams({ path: moduleDir, limit: '3' }));
20
+ const entries = readdirSync(moduleDir, { withFileTypes: true });
21
+ const files = entries.filter((entry) => entry.isFile());
22
+ const names = new Set(files.map((entry) => entry.name));
23
+
24
+ check('路由真读了本目录:ok,回的是这个 path', answer?.ok === true && answer?.path === moduleDir, JSON.stringify(answer?.error ?? answer?.path));
25
+ check('读到的文件数与真实目录一致(不是写死的)', answer?.files === files.length, `answer=${answer?.files} 实际=${files.length}`);
26
+ check('最大的几个文件不超过 limit,而且都是真文件', (answer?.largest?.length ?? 0) >= 1 && answer.largest.length <= 3 && answer.largest.every((one) => names.has(one.name)), JSON.stringify(answer?.largest));
27
+ check('读不到时回 ok:false 的 error,而不是抛', (await routes[route](new URLSearchParams({ path: `${moduleDir}/不存在的目录` })))?.ok === false);
28
+ }
@@ -5,19 +5,22 @@
5
5
  // 于是"模块注册的卡片到底进没进外壳"变成一条机器判据,而不再是人的眼睛。
6
6
  //
7
7
  // ★ 这份脚本验的是**形状**:它认不出成员名,所以换数据源、换卡片内容都不用改它。
8
- // 下面那一段"那条只读路由真读盘"是**这份模板自己的故事**(`<unit>/overview`):你把数据源换成
9
- // 别的之后,它会自动跳过并提醒你,在同一个位置补一条**你自己的**真读断言 —— 卡片画的数从哪来,
10
- // `check` 的十条判据一条都不碰,只有这里会真的去调一次。
8
+ // 模板自己的"真读"与"真跑"在 `scripts/smoke.assert.mjs` 里:换数据源时改那一份,
9
+ // 不要删这里的调用 —— 卡片画的数从哪来,`check` 的十三条判据一条都不碰,只有这里会真调一次。
11
10
  //
12
11
  // 负例在最后一段:服务不在的时候必须**报出来**,不许静默通过 —— 静默的模块是最难查的那种坏。
13
12
 
14
13
  import { readFileSync } from 'node:fs';
15
14
  import { dirname } from 'node:path';
16
15
  import { fileURLToPath } from 'node:url';
16
+ import { createFlowHost, createLongTaskHost } from '@dsh-workbench/shared/host';
17
17
 
18
18
  const PLUGIN_ID = '__NAME__';
19
19
  const UNIT_ID = '__UNIT__';
20
- const OVERVIEW_ROUTE = `${UNIT_ID}/overview`;
20
+ const RUN_TOOL = `${UNIT_ID}_run`;
21
+ const ADD_TOOL = `${UNIT_ID}_add_note`;
22
+ const RUN_RESOURCE = `${UNIT_ID}-run`;
23
+ const moduleDir = dirname(dirname(fileURLToPath(import.meta.url)));
21
24
  const failures = [];
22
25
 
23
26
  function check(name, ok, detail) {
@@ -62,12 +65,23 @@ function fakeKit() {
62
65
  };
63
66
  }
64
67
 
68
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
69
+ const externals = new Set([
70
+ '@dsh-workbench/core/client',
71
+ ...(manifest.dsh?.client?.inject ?? []),
72
+ ...(manifest.dsh?.client?.external ?? []),
73
+ ]);
74
+
65
75
  function moduleRequire(name) {
66
76
  if (name === 'react') {
67
77
  // 必须是**真的元素对象**:返回 null 的话,"卡片画得出来"这类断言会永远假红。
68
- return { createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }) };
78
+ return {
79
+ createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
80
+ useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
81
+ };
69
82
  }
70
83
  if (name === '@dsh-workbench/core/client') return { kit: fakeKit() };
84
+ if (externals.has(name)) return anyStub(`require(${name})`);
71
85
  throw new Error(`模块包不该 require ${JSON.stringify(name)}:模块表里只有外壳的 seed 词与外部声明`);
72
86
  }
73
87
 
@@ -178,11 +192,79 @@ hostExports.apply({
178
192
  });
179
193
 
180
194
  check('宿主面注册进了外壳', hostFaces.length === 1, `注册了 ${hostFaces.length} 个`);
181
- // 给一份最小 deps:宿主面的 `mount(deps)` 会去 `deps.ctx.get('shell')` 拿服务,
182
- // 不给 deps 的话**任何真的用了 `mount(deps)` 的模块**都会在这里崩 —— 而那不是模块的错。
195
+ // 给一份最小 deps:宿主面的 `mount(deps)` 会去 `deps.ctx.get('tools')` / `deps.flow` /
196
+ // `deps.longTasks` 拿东西,少一个都可能在这里崩 —— 而那不是模块的错。
197
+ // 这几项与外壳真正交出去的 `UnitMountDeps`(HostKit)对齐;模块要用更多成员,就在这里补。
198
+ const registeredTools = [];
199
+ const moduleFiles = new Map();
200
+ function moduleStore(workspace) {
201
+ const at = moduleFiles.get(workspace) ?? new Map();
202
+ moduleFiles.set(workspace, at);
203
+ return {
204
+ read: (name) => at.get(name) ?? null,
205
+ write: (name, value) => {
206
+ at.set(name, value);
207
+ },
208
+ remove: (name) => {
209
+ at.delete(name);
210
+ },
211
+ list: () => [...at.keys()],
212
+ };
213
+ }
214
+ const flowHost = createFlowHost();
215
+ const longTaskHost = createLongTaskHost({ scope: `smoke-${UNIT_ID}`, unit: UNIT_ID, bump: () => {} });
216
+ const hostDeps = {
217
+ ctx: {
218
+ get: (name) =>
219
+ name === 'tools'
220
+ ? {
221
+ register(tool) {
222
+ registeredTools.push(tool);
223
+ return () => {};
224
+ },
225
+ }
226
+ : undefined,
227
+ effect: (fn) => {
228
+ fn();
229
+ },
230
+ provide: () => {},
231
+ logger: { warn: () => {} },
232
+ },
233
+ logger: { warn: () => {} },
234
+ manifest: { id: UNIT_ID },
235
+ manifests: () => [],
236
+ ownedUnits: () => [],
237
+ data: (() => {
238
+ const missing = async () => ({ ok: false, error: { code: 'invalid-request', message: 'smoke: 没有 DataReader' } });
239
+ return { read: missing, request: missing, requestMany: async () => ({}), submit: missing };
240
+ })(),
241
+ flow: flowHost,
242
+ sessionIdOf: () => null,
243
+ sessionWorkspace: () => null,
244
+ libInstallCommand: () => '',
245
+ surface: anyStub('surface'),
246
+ env: {},
247
+ store: {
248
+ for: (_unit, workspace) => moduleStore(workspace ?? ''),
249
+ machine: () => moduleStore(''),
250
+ scan: () => [],
251
+ },
252
+ longTasks: longTaskHost.host,
253
+ longTaskRuns: () => new Map(),
254
+ git: {
255
+ origin: async () => ({ kind: 'no-origin' }),
256
+ branch: async () => ({ kind: 'no-branch' }),
257
+ head: async () => ({ kind: 'no-head' }),
258
+ },
259
+ libs: {
260
+ load: async () => {
261
+ throw new Error('smoke: libs 未装');
262
+ },
263
+ },
264
+ };
183
265
  let mounted = {};
184
266
  try {
185
- mounted = hostFaces[0]?.mount({ ctx: { get: () => undefined }, logger: { warn: () => {} } }) ?? {};
267
+ mounted = hostFaces[0]?.mount(hostDeps) ?? {};
186
268
  } catch (error) {
187
269
  failures.push(`宿主面的 mount(deps) 抛了:${error.message}`);
188
270
  }
@@ -190,32 +272,70 @@ try {
190
272
  const routes = mounted.routes ?? {};
191
273
  const strays = Object.keys(routes).filter((key) => key !== UNIT_ID && !key.startsWith(`${UNIT_ID}/`));
192
274
  check(`每条路由都住在自己那一格(${UNIT_ID} 或 ${UNIT_ID}/…)`, strays.length === 0, strays.join(', '));
275
+ const disposers = mounted.registerTools?.();
276
+ check('registerTools() 交得出一份清理函数清单', Array.isArray(disposers) && disposers.length === 2);
193
277
  check(
194
- 'registerTools() 交得出一份清单(这份模块没有工具时是空数组)',
195
- Array.isArray(mounted.registerTools?.()),
278
+ `工具清单里有 ${RUN_TOOL} 与 ${ADD_TOOL}`,
279
+ registeredTools.some((tool) => tool?.name === RUN_TOOL) && registeredTools.some((tool) => tool?.name === ADD_TOOL),
280
+ JSON.stringify(registeredTools.map((tool) => tool?.name)),
196
281
  );
197
282
 
198
- // ── 这份模板自己的故事:那条只读路由**真读了本目录** ──────────────────────
199
- //
200
- // 换了数据源的模块在这里会落到 else 那一支:**跳过**而不是假绿 —— 并且在同一条路径上提醒你
201
- // 补一条自己的真读断言(不然"卡片画的数从哪来"就没人验了)。
202
- if (typeof routes[OVERVIEW_ROUTE] === 'function') {
203
- const here = dirname(dirname(fileURLToPath(import.meta.url)));
204
- const answer = await routes[OVERVIEW_ROUTE](new URLSearchParams({ path: here, limit: '3' }));
205
- check('路由真读了本目录:ok 且数得出文件', answer?.ok === true, JSON.stringify(answer?.error ?? answer?.path));
206
- check(
207
- '读到的文件数与最大的几个文件都是真的(不是写死的)',
208
- answer?.files >= 1 && answer?.largest?.length >= 1,
209
- `files=${answer?.files} largest=${answer?.largest?.length}`,
210
- );
211
- check(
212
- '读不到时回 ok:false 的 error,而不是抛',
213
- (await routes[OVERVIEW_ROUTE](new URLSearchParams({ path: `${here}/不存在的目录` })))?.ok === false,
214
- );
215
- } else {
216
- console.log(`○ 跳过「真读本目录」那一组:这份模块已经没有 ${OVERVIEW_ROUTE}(换过数据源了)。`);
217
- console.log(' 在同一个位置补上**你自己那条读**的断言 —— 卡片画的数从哪来,只有这里能真调一次。');
283
+ // ── 模板自己的故事:流程真跑一遍,长任务最后落到 ok ──────────────────────
284
+ const startAction = (mounted.actions ?? []).find((one) => one.resource === RUN_RESOURCE);
285
+ check('动作通道里有起流程的入口(卡片那颗钮走的就是它)', typeof startAction?.handle === 'function');
286
+ const started = startAction?.handle({ path: moduleDir });
287
+ check('起一趟流程拿得到 runId', started?.ok === true && typeof started?.runId === 'string', JSON.stringify(started));
288
+ let snapshot = started?.runId === undefined ? null : (mounted.longTasks?.snapshot(started.runId) ?? null);
289
+ const deadline = Date.now() + 5000;
290
+ while (snapshot !== null && snapshot.status === 'running' && Date.now() < deadline) {
291
+ await new Promise((resolve) => {
292
+ setTimeout(resolve, 20);
293
+ });
294
+ snapshot = mounted.longTasks?.snapshot(started.runId);
218
295
  }
296
+ check('流程整条跑到 ok', snapshot?.status === 'ok', String(snapshot?.status));
297
+ check(
298
+ '流水里有「读目录」',
299
+ (snapshot?.steps ?? []).some((step) => step.includes('读目录')),
300
+ (snapshot?.steps ?? []).join(' | '),
301
+ );
302
+
303
+ // ── 写通道:笔记(动作通道 + 写工具)+ 审批;配置按工作区存 ──────────────
304
+ const NOTES_ROUTE = `${UNIT_ID}/notes`;
305
+ const PREFS_ROUTE = `${UNIT_ID}/prefs`;
306
+ const NOTE_RESOURCE = `${UNIT_ID}-add-note`;
307
+ const noteTool = registeredTools.find((tool) => tool?.name === `${UNIT_ID}_add_note`);
308
+ const noteDecl = (manifest.dsh?.workbench?.tools ?? []).find((tool) => tool?.name === `${UNIT_ID}_add_note`);
309
+ check('写工具注册上了,清单里也声明成 writes: true(壳才会走审批)', noteTool !== undefined && noteDecl?.writes === true);
310
+ check(
311
+ '写工具要点头,审批屏说得出会写什么',
312
+ mounted.needsApproval?.({ name: `${UNIT_ID}_add_note`, arguments: { text: 'x' } })?.kind === 'ask' &&
313
+ typeof mounted.approvalDetail?.({ name: `${UNIT_ID}_add_note`, arguments: { text: 'x' } })?.zh === 'string',
314
+ );
315
+ const noteAction = mounted.actions?.find((one) => one.resource === NOTE_RESOURCE);
316
+ const noteAdded = noteAction?.handle({ path: moduleDir, text: 'smoke 记的一条' });
317
+ check('动作通道能写一条笔记(点击即确认)', noteAdded?.ok === true, JSON.stringify(noteAdded?.error));
318
+ const notesAnswer = await routes[NOTES_ROUTE](new URLSearchParams({ path: moduleDir }));
319
+ check(
320
+ '笔记读得回来,且就是刚写的那条',
321
+ notesAnswer?.ok === true && notesAnswer?.notes?.some((one) => one.text === 'smoke 记的一条'),
322
+ JSON.stringify(notesAnswer?.notes),
323
+ );
324
+ const prefsBefore = await routes[PREFS_ROUTE](new URLSearchParams({ path: moduleDir }));
325
+ check('配置默认是 5', prefsBefore?.prefs?.limit === 5, JSON.stringify(prefsBefore?.prefs));
326
+ const prefsWrote = mounted.prefs?.handle({ workspace: moduleDir, limit: 10 });
327
+ const prefsAfter = await routes[PREFS_ROUTE](new URLSearchParams({ path: moduleDir }));
328
+ check(
329
+ '配置按工作区存得住(写 10 后读回 10)',
330
+ prefsWrote?.prefs?.limit === 10 && prefsAfter?.prefs?.limit === 10,
331
+ JSON.stringify(prefsAfter?.prefs),
332
+ );
333
+ const otherPrefs = await routes[PREFS_ROUTE](new URLSearchParams({ path: `${moduleDir}/src` }));
334
+ check('另一个工作区没被带上(还是默认 5)', otherPrefs?.prefs?.limit === 5, JSON.stringify(otherPrefs?.prefs));
335
+
336
+ // ── 这个模块自己的真读断言:换数据源时改 scripts/smoke.assert.mjs ──────────
337
+ const { assertModule } = await import('./smoke.assert.mjs');
338
+ await assertModule({ check, routes, mounted, UNIT_ID });
219
339
 
220
340
  // ── 需求底稿在不在:这份骨架的"设计记录"住 README,别只留在对话里 ──────────
221
341
  //