@gavinc-cn/touchstone-dsh 0.2.0 → 0.4.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 (116) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +23 -15
  3. package/README.zh.md +17 -12
  4. package/auth.py +0 -0
  5. package/backend/lib/access.js +44 -0
  6. package/backend/lib/agents.js +341 -0
  7. package/backend/lib/args.js +94 -0
  8. package/backend/lib/auth.js +195 -0
  9. package/backend/lib/board.js +6227 -0
  10. package/backend/lib/builtin-assets.js +1177 -0
  11. package/backend/lib/casescan.js +456 -0
  12. package/backend/lib/chat.js +2090 -0
  13. package/backend/lib/cookie.js +76 -0
  14. package/backend/lib/db.js +2260 -0
  15. package/backend/lib/dshdriver.js +1148 -0
  16. package/backend/lib/dshevents.js +851 -0
  17. package/backend/lib/export-cases.js +261 -0
  18. package/backend/lib/feishu.js +1747 -0
  19. package/backend/lib/feishu_cfg.js +262 -0
  20. package/backend/lib/feishu_conv.js +1484 -0
  21. package/backend/lib/feishu_device.js +428 -0
  22. package/backend/lib/feishu_inbound.js +2077 -0
  23. package/backend/lib/feishu_provision.js +886 -0
  24. package/backend/lib/feishu_rest.js +398 -0
  25. package/backend/lib/feishu_slash.js +568 -0
  26. package/backend/lib/feishu_ws.js +950 -0
  27. package/backend/lib/http.js +312 -0
  28. package/backend/lib/lib.js +973 -0
  29. package/backend/lib/loadcase.js +749 -0
  30. package/backend/lib/loadgen.js +1407 -0
  31. package/backend/lib/localbus.js +164 -0
  32. package/backend/lib/lock.js +426 -0
  33. package/backend/lib/proc.js +361 -0
  34. package/backend/lib/prompts.js +1021 -0
  35. package/backend/lib/pybase64.js +114 -0
  36. package/backend/lib/pyjson.js +214 -0
  37. package/backend/lib/query.js +125 -0
  38. package/backend/lib/rag.js +1310 -0
  39. package/backend/lib/runner.js +4546 -0
  40. package/backend/lib/seed_hint.js +40 -0
  41. package/backend/lib/servercaps.js +182 -0
  42. package/backend/lib/serverstate.js +227 -0
  43. package/backend/lib/sessparse.js +1176 -0
  44. package/backend/lib/sse.js +207 -0
  45. package/backend/lib/static.js +84 -0
  46. package/backend/lib/taskjson.js +302 -0
  47. package/backend/lib/waitq.js +2114 -0
  48. package/backend/lib/worktree.js +588 -0
  49. package/backend/lib/zstd-frames.js +218 -0
  50. package/backend/routes/admin.js +553 -0
  51. package/backend/routes/agents.js +149 -0
  52. package/backend/routes/assets.js +145 -0
  53. package/backend/routes/board.js +1228 -0
  54. package/backend/routes/feishu.js +269 -0
  55. package/backend/routes/feishu_app.js +249 -0
  56. package/backend/routes/feishu_hook.js +358 -0
  57. package/backend/routes/index.js +298 -0
  58. package/backend/routes/projects.js +1138 -0
  59. package/backend/routes/sessions.js +1003 -0
  60. package/backend/routes/tasks.js +1530 -0
  61. package/backend/schema/fixtures/ancient.sql +189 -0
  62. package/backend/schema/fixtures/pre_alter.sql +177 -0
  63. package/backend/schema/migrations.json +154 -0
  64. package/backend/schema/schema.json +1451 -0
  65. package/backend/schema/schema.sql +216 -0
  66. package/backend/server.js +505 -0
  67. package/board.py +96 -13
  68. package/builtin_assets.py +0 -0
  69. package/builtin_prompts/free_style/flow.md +0 -0
  70. package/builtin_prompts/free_style/load_test.md +0 -0
  71. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  72. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  73. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  74. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  75. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  76. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  77. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  78. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  79. package/chat.py +33 -0
  80. package/db.py +294 -129
  81. package/dsh-plugin/cordis.patch.yml +0 -0
  82. package/dsh-plugin/install.sh +22 -8
  83. package/dsh-plugin/lib/client.bundle.js +111 -2
  84. package/dsh-plugin/lib/index.js +340 -29
  85. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  86. package/dsh-plugin/scripts/build.mjs +0 -0
  87. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  88. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  89. package/dsh-plugin/src/client.js +111 -2
  90. package/dshevents.py +0 -0
  91. package/export_cases.py +0 -0
  92. package/extensions/dsh-autocommit/asset.json +0 -0
  93. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  94. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  95. package/feishu.py +417 -3
  96. package/feishu_conv.py +0 -0
  97. package/lib.py +0 -0
  98. package/lifecycle.py +116 -13
  99. package/loadcase.py +0 -0
  100. package/loadgen.py +0 -0
  101. package/localbus.py +0 -0
  102. package/migrate_p7b.py +0 -0
  103. package/package.json +6 -1
  104. package/platcompat.py +0 -0
  105. package/prompts.py +0 -0
  106. package/rag.py +0 -0
  107. package/requirements.txt +0 -0
  108. package/server.py +155 -11
  109. package/touchstone.py +0 -0
  110. package/waitq.py +0 -0
  111. package/webui/dist-plugin/assets/index-3QsYQi7y.css +1 -0
  112. package/webui/dist-plugin/assets/index-B8AMtW7y.js +486 -0
  113. package/webui/dist-plugin/index.html +2 -2
  114. package/worktree.py +0 -0
  115. package/webui/dist-plugin/assets/index-JLwyZZxC.js +0 -484
  116. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
@@ -0,0 +1,291 @@
1
+ /**
2
+ * 发包闭包计算 —— 「npm 包里必须有哪些文件」的**唯一一份**算法(零依赖,只用 `node:`)。
3
+ *
4
+ * 为什么要有它:`package.json` 的 `files` 是**白名单**,漏一条就是「发出去的包起不来」,
5
+ * 而漏的形态是静默的 —— 2026-10-15 实测 `@gavinc-cn/touchstone-dsh@0.1.1` 的 105 个文件里
6
+ * **没有 `backend/routes/` 的 11 个文件**(白名单只写了 `backend/server.js` / `backend/lib` /
7
+ * `backend/schema`),解包后 `node backend/server.js` 当场
8
+ * `ERR_MODULE_NOT_FOUND: .../backend/routes/index.js`(`backend/server.js:61` 就 import 它)
9
+ * 并 exit 1。手写清单(`dsh-plugin/scripts/prepack-check.mjs` 的 `REQUIRED`)挡不住这种漏,
10
+ * 因为它只查「我记得的那些」;这里改成**从入口正推**:包内必需 = 两个入口的**静态 import
11
+ * 闭包** + 运行时时按路径读的数据根。新增一个 `backend/routes/xxx.js` 而忘了改白名单 ⇒
12
+ * 闭包自动长大 ⇒ 判据变红。
13
+ *
14
+ * 两个入口(`package.json` 的 `main` + Node 后端入口,见 `dsh-plugin/lib/index.js:117-118`):
15
+ * - `dsh-plugin/lib/index.js` —— dsh 插件壳,宿主 `require`/`import` 的就是它;
16
+ * - `backend/server.js` —— 薄壳 spawn 的 Node 后端进程。
17
+ * 只解析**相对说明符**(`./` / `../`):`node:*` 内建与裸包名不是包内文件,一律记为
18
+ * `unresolved` 供人看(不参与判据 —— 把裸包名当必需会把判据搞成假红)。
19
+ *
20
+ * 数据根(`RUNTIME_RESOURCES`)是**手写的**,条目都带「谁在哪一行读它」的证据;递归 import
21
+ * 扫描器看不到这些「按路径拼出来的读」,所以只能人工登记 —— 但每条都写清理由,不写「大概
22
+ * 需要」。注意 `backend/schema/fixtures/*` **不在**闭包里:它只被 `backend/test/db.test.mjs`、
23
+ * `tools/parity/db-run.mjs`、`tools/export_schema.py` 读(测试/量具),运行时没人读
24
+ * (实测 `grep -rn fixtures backend/lib backend/routes` 无命中)—— 别凭印象往里塞。
25
+ *
26
+ * 白名单展开(`expandFilesEntries`)按 npm 的 `files` 语义实现最小规则,行为经实测校准
27
+ * (`npm pack --dry-run --json`,见下),不是照文档猜的:
28
+ * - 条目相对包根;`*` 不跨 `/`(实测 `files:["*.py","*.md"]` 只收根级的 a.py/top.md,
29
+ * 子目录 `sub/b.py` 不收 ⇒ **没有** matchBase 语义),`**` 跨目录;
30
+ * - 条目指到目录 ⇒ 递归含其内容(`backend/lib`、`webui/dist-plugin` 都是这个形态);
31
+ * - `package.json` / `README*` / `LICENSE*`(含 `LICENCE*`)恒含。
32
+ * 有意**不**建模 `.gitignore`:本仓 `.gitignore` 里有 `webui/dist-plugin/`,但它照进包
33
+ * (实测 3 个文件都在)—— 说明显式 `files` 条目压过忽略规则。副作用写在文件末尾「已知缺口」。
34
+ */
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+
39
+ /** 包根(本文件在 `<包根>/dsh-plugin/lib/` 下, 上溯三级) */
40
+ export const PKG_ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
41
+
42
+ /** 闭包的两个入口(相对包根,posix 分隔) */
43
+ export const ENTRIES = Object.freeze([
44
+ 'dsh-plugin/lib/index.js',
45
+ 'backend/server.js',
46
+ ]);
47
+
48
+ /**
49
+ * 运行时时**按路径读**的数据根(静态扫描看不到;每条都要有证据行号)。
50
+ * `files` 列出「精确文件」(测试夹具不算,别往里塞);`dirs` 列出「整个目录都要」——
51
+ * 目录内容在判据里**当场递归枚举**,所以往里新增数据文件也自动进入闭包。
52
+ */
53
+ export const RUNTIME_RESOURCES = Object.freeze({
54
+ files: Object.freeze([
55
+ { rel: 'backend/schema/schema.sql', why: 'backend/lib/db.js:59 initDb 启动即读(目标 DDL)' },
56
+ { rel: 'backend/schema/schema.json', why: 'backend/lib/db.js:60 收敛器按它补列/建索引' },
57
+ { rel: 'backend/schema/migrations.json', why: 'backend/lib/db.js:61 声明式迁移清单,启动即执行' },
58
+ ]),
59
+ dirs: Object.freeze([
60
+ { rel: 'builtin_prompts/free_style', why: 'backend/lib/prompts.js:237 _ASSET_DIR,运行时读流程文档与文件模板' },
61
+ { rel: 'extensions', why: 'backend/lib/builtin-assets.js:262 DEFAULT_EXT_DIR,资产清单扫描根(routes/assets.js 的端点)' },
62
+ { rel: 'webui/dist-plugin', why: 'server 静态服务目录(薄壳启动参数 --web-dir 传的就是它,dsh-plugin/lib/index.js)' },
63
+ ]),
64
+ });
65
+
66
+ /** 目录递归枚举时跳过的东西(不进包,也不需要进闭包) */
67
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.run', '__pycache__']);
68
+
69
+ /** 相对说明符扫描(见文件头:锚在行首,避开注释里的散文;多行 import 靠 `[^'"]` 跨行) */
70
+ const IMPORT_FROM_RE = /^[ \t]*import\s+[^'"]*?\bfrom\s*['"]([^'"]+)['"]/gm;
71
+ const IMPORT_SIDE_EFFECT_RE = /^[ \t]*import\s*['"]([^'"]+)['"]/gm;
72
+ const EXPORT_FROM_RE = /^[ \t]*export\s+(?:\*|\{[^}]*\})\s*from\s*['"]([^'"]+)['"]/gm;
73
+ const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
74
+
75
+ /** posix 化(判据字符串一律用 `/`,Windows 上跑出来的也必须一样) */
76
+ export function toPosix(rel) {
77
+ return rel.split(path.sep).join('/');
78
+ }
79
+
80
+ /** 目录清单缓存:同一进程内按 root 复用(判据是只读的静态检查,进程内不假设文件树会变) */
81
+ const LIST_CACHE = new Map();
82
+
83
+ /** 递归列出 root 下所有**文件**(相对包根,posix 分隔,排序);结果按 root 缓存 */
84
+ export function listFiles(root = PKG_ROOT) {
85
+ const cached = LIST_CACHE.get(root);
86
+ if (cached) return cached;
87
+ const out = [];
88
+ const walk = (dir) => {
89
+ let entries;
90
+ try {
91
+ entries = fs.readdirSync(dir, { withFileTypes: true });
92
+ } catch {
93
+ return; // 读不动就当不存在(判据自己会报缺)
94
+ }
95
+ for (const entry of entries) {
96
+ const abs = path.join(dir, entry.name);
97
+ if (entry.isDirectory()) {
98
+ if (SKIP_DIRS.has(entry.name)) continue;
99
+ walk(abs);
100
+ } else if (entry.isFile()) {
101
+ out.push(toPosix(path.relative(root, abs)));
102
+ }
103
+ }
104
+ };
105
+ walk(root);
106
+ out.sort();
107
+ LIST_CACHE.set(root, out);
108
+ return out;
109
+ }
110
+
111
+ /** 抽一个源文件里的相对说明符(去重、保持出现顺序) */
112
+ export function scanRelativeSpecifiers(source) {
113
+ const found = [];
114
+ const add = (spec) => {
115
+ if (!spec) return;
116
+ if (!spec.startsWith('./') && !spec.startsWith('../')) return;
117
+ if (!found.includes(spec)) found.push(spec);
118
+ };
119
+ for (const re of [IMPORT_FROM_RE, IMPORT_SIDE_EFFECT_RE, EXPORT_FROM_RE, DYNAMIC_IMPORT_RE]) {
120
+ re.lastIndex = 0;
121
+ for (const m of source.matchAll(re)) add(m[1]);
122
+ }
123
+ return found;
124
+ }
125
+
126
+ /**
127
+ * 解析相对说明符 → 包内文件(posix 相对路径);解析不到返回 null。
128
+ * 支持无扩展名(补 `.js`、`/index.js`),与 Node 的 ESM 解析口径近似(本仓无扩展名导入)。
129
+ */
130
+ export function resolveSpecifier(fromRel, spec, root = PKG_ROOT) {
131
+ const baseDir = path.posix.dirname(fromRel);
132
+ const joined = path.posix.normalize(path.posix.join(baseDir, spec));
133
+ const candidates = [joined, `${joined}.js`, `${joined}.mjs`, path.posix.join(joined, 'index.js')];
134
+ for (const rel of candidates) {
135
+ if (rel.startsWith('..')) continue; // 跑出包根:不是包内文件
136
+ const abs = path.join(root, rel);
137
+ try {
138
+ if (fs.statSync(abs).isFile()) return rel;
139
+ } catch { /* 不存在的候选,继续试下一个 */ }
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * 计算运行时闭包:两个入口的静态 import 闭包 + `RUNTIME_RESOURCES`。
146
+ * @returns {{files: string[], unresolved: Array<{from: string, spec: string}>,
147
+ * entries: string[], resources: string[]}}
148
+ * `files` 排序去重;`unresolved` 是相对说明符解析不到(或裸包名/`node:`)的清单,
149
+ * 只报不用(避免把裸包名当必需导致假红)。
150
+ */
151
+ export function computeClosure(root = PKG_ROOT) {
152
+ const files = new Set();
153
+ const unresolved = [];
154
+ const queue = [...ENTRIES];
155
+ const seen = new Set();
156
+ while (queue.length > 0) {
157
+ const rel = queue.shift();
158
+ if (seen.has(rel)) continue;
159
+ seen.add(rel);
160
+ const abs = path.join(root, rel);
161
+ let source;
162
+ try {
163
+ source = fs.readFileSync(abs, 'utf8');
164
+ } catch {
165
+ unresolved.push({ from: rel, spec: '(入口本身读不到)' });
166
+ continue;
167
+ }
168
+ files.add(rel);
169
+ for (const spec of scanRelativeSpecifiers(source)) {
170
+ const resolved = resolveSpecifier(rel, spec, root);
171
+ if (resolved === null) unresolved.push({ from: rel, spec });
172
+ else queue.push(resolved);
173
+ }
174
+ }
175
+ const resources = [];
176
+ for (const item of RUNTIME_RESOURCES.files) {
177
+ files.add(item.rel);
178
+ resources.push(item.rel);
179
+ }
180
+ for (const item of RUNTIME_RESOURCES.dirs) {
181
+ const abs = path.join(root, item.rel);
182
+ if (!fs.existsSync(abs)) { unresolved.push({ from: '(资源根)', spec: item.rel }); continue; }
183
+ for (const rel of listFiles(abs)) {
184
+ const full = `${item.rel}/${rel}`;
185
+ files.add(full);
186
+ resources.push(full);
187
+ }
188
+ }
189
+ return {
190
+ files: [...files].sort(),
191
+ unresolved,
192
+ entries: [...ENTRIES],
193
+ resources: resources.sort(),
194
+ };
195
+ }
196
+
197
+ /** glob → 正则(npm `files` 最小语义:`**` 跨 `/`,`*`/`?` 不跨 `/`) */
198
+ function globToRegExp(pattern) {
199
+ let re = '^';
200
+ for (let i = 0; i < pattern.length; i++) {
201
+ const ch = pattern[i];
202
+ if (ch === '*') {
203
+ if (pattern[i + 1] === '*') {
204
+ i++;
205
+ if (pattern[i + 1] === '/') { i++; re += '(?:.*/)?'; } // `**/` 也匹配零层
206
+ else re += '.*';
207
+ } else {
208
+ re += '[^/]*';
209
+ }
210
+ } else if (ch === '?') {
211
+ re += '[^/]';
212
+ } else if ('\\^$.|+()[]{}'.includes(ch)) {
213
+ re += `\\${ch}`;
214
+ } else {
215
+ re += ch;
216
+ }
217
+ }
218
+ return new RegExp(`${re}$`);
219
+ }
220
+
221
+ /**
222
+ * npm 恒含的文件(`files` 白名单管不着)—— **只在包根**,实测校准过:
223
+ * `files:["a.txt"]` 时 `sub/README.md` / `sub/deep/LICENSE` 都不进包,
224
+ * 而根级 `package.json` 恒在。别把这条写成「任意层级的 README/LICENSE」(会假绿:
225
+ * 例如 `tests/README.md` 会被误判成包内 —— 实测它不在 105 个文件里)。
226
+ */
227
+ function isAlwaysIncluded(rel) {
228
+ if (rel === 'package.json') return true;
229
+ if (rel.includes('/')) return false;
230
+ return /^(README|LICENSE|LICENCE)(\..*)?$/i.test(rel);
231
+ }
232
+
233
+ /**
234
+ * 展开 `files` 白名单 → 包内文件集合(最小 npm 语义,见文件头)。
235
+ * @param {string[]} entries package.json 的 files 数组
236
+ * @param {{root?: string, allFiles?: string[]}} [opts] 便于测试注入(负对照就在内存里换 entries)
237
+ * @returns {Set<string>} posix 相对路径集合
238
+ */
239
+ export function expandFilesEntries(entries, opts = {}) {
240
+ const root = opts.root || PKG_ROOT;
241
+ const all = opts.allFiles || listFiles(root);
242
+ const packed = new Set();
243
+ for (const rel of all) if (isAlwaysIncluded(rel)) packed.add(rel);
244
+ for (const raw of entries) {
245
+ const entry = path.posix.normalize(String(raw).replace(/^\.\//, '').replace(/\/+$/, ''));
246
+ if (!entry || entry.startsWith('..')) continue;
247
+ const abs = path.join(root, entry);
248
+ let stat = null;
249
+ try { stat = fs.statSync(abs); } catch { /* 不存在:当 glob 处理 */ }
250
+ if (stat && stat.isDirectory()) {
251
+ for (const rel of listFiles(abs)) packed.add(`${entry}/${rel}`);
252
+ continue;
253
+ }
254
+ if (stat && stat.isFile()) { packed.add(entry); continue; }
255
+ const re = globToRegExp(entry);
256
+ for (const rel of all) if (re.test(rel)) packed.add(rel);
257
+ }
258
+ return packed;
259
+ }
260
+
261
+ /**
262
+ * 判据本体:闭包里但包内没有的文件(空数组 = 过)。
263
+ * @param {string[]} closureFiles `computeClosure().files`
264
+ * @param {Iterable<string>} packedFiles `expandFilesEntries(...)`
265
+ */
266
+ export function findUnpacked(closureFiles, packedFiles) {
267
+ const packed = packedFiles instanceof Set ? packedFiles : new Set(packedFiles);
268
+ return closureFiles.filter((rel) => !packed.has(rel));
269
+ }
270
+
271
+ /**
272
+ * 读仓库 `package.json` 的 `files` 并跑完整判据(`prepack-check.mjs` 与单测共用)。
273
+ * @returns {{ok: boolean, missing: string[], closure: object, packedCount: number, files: string[]}}
274
+ */
275
+ export function checkPackClosure(root = PKG_ROOT) {
276
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
277
+ const entries = Array.isArray(pkg.files) ? pkg.files : [];
278
+ const closure = computeClosure(root);
279
+ const packed = expandFilesEntries(entries, { root });
280
+ const missing = findUnpacked(closure.files, packed);
281
+ return { ok: missing.length === 0, missing, closure, packedCount: packed.size, files: entries };
282
+ }
283
+
284
+ /**
285
+ * 已知缺口(写在这里,不装作没有):
286
+ * ① `.gitignore` / `.npmignore` 未建模 —— 若将来某个运行时文件恰好落在被忽略的目录里、
287
+ * 而白名单**没有**显式列出它,本判据会假绿(今天不会:闭包里的路径全部由显式条目覆盖)。
288
+ * ② npm 的 `files` 对**符号链接**、`.npmrc`、`bundledDependencies` 有额外规则,本判据不管。
289
+ * ③ 运行时**动态拼**出来的路径(例如按用户输入拼文件名)静态看不到,只能靠人工登记进
290
+ * `RUNTIME_RESOURCES`;登记漏了就是假绿。
291
+ */
File without changes
@@ -1,13 +1,20 @@
1
1
  /**
2
2
  * 发包前自检 —— package.json 的 `prepack` 钩子(`npm pack` / `npm publish` 前自动执行)。
3
3
  *
4
- * 两道检查:
4
+ * 三道检查:
5
5
  * ① **必需文件在不在**:本包是自包含的(插件壳 + Python 平台 + 预构建 webui/dist-plugin +
6
6
  * 内置提示词/内置资产),少任何一块装到 profile 里都跑不起来;
7
7
  * ② **三处「包名」逐字一致**:包根 package.json 的 `name`、`dsh-plugin/cordis.patch.yml`
8
8
  * 里 insert 行的 `name`、`dsh-plugin/lib/client.bundle.js` 里 `__ModuleLoader__.load({id})`
9
9
  * 的 id —— dsh 宿主按注册行的包名给浏览器半预留模块槽,三者不一致时页面报
10
10
  * "loaded without registering"(2026-10-07 改名 @gavinc-cn/touchstone-dsh 时踩过)。
11
+ * ③ **闭包 ⊆ 白名单**(2026-10-15 新增):从两个入口(`backend/server.js`、
12
+ * `dsh-plugin/lib/index.js`)正推运行时闭包(静态 import 图 + 运行时按路径读的数据根),
13
+ * 与 `files` 展开结果比对。①的手写清单挡不住「新加了一个文件、忘了改白名单」——
14
+ * 实测 0.1.1 发出去的包里 `backend/routes/` 11 个文件全缺,解包后
15
+ * `node backend/server.js` 直接 ERR_MODULE_NOT_FOUND(`backend/server.js:61` import 它)。
16
+ * 算法在 `dsh-plugin/lib/pack-closure.mjs`(与 `tools/parity/test/pack-closure.test.mjs`
17
+ * **同一份**,不是一个抄一个);这里只读文件、**绝不递归调用 `npm pack`**(会被自己再触发)。
11
18
  *
12
19
  * 任一项不过就非 0 退出,让 `npm pack` / `npm publish` 直接中断:宁可发不出去,不可发出坏包。
13
20
  * 用法: node dsh-plugin/scripts/prepack-check.mjs
@@ -16,6 +23,8 @@ import { readFileSync, existsSync } from 'node:fs';
16
23
  import { dirname, join } from 'node:path';
17
24
  import { fileURLToPath } from 'node:url';
18
25
 
26
+ import { checkPackClosure } from '../lib/pack-closure.mjs';
27
+
19
28
  /** 包根(本脚本在 <包根>/dsh-plugin/scripts/ 下, 上溯三级) */
20
29
  const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
21
30
 
@@ -24,6 +33,15 @@ const REQUIRED = [
24
33
  'package.json',
25
34
  'server.py',
26
35
  'requirements.txt',
36
+ // Node 后端骨架(2026-10-11 M0):profile 里配 `backend: node` 时薄壳拉起的就是它,
37
+ // 缺了等于「开关一打开就起不来」——必须在发包自检里挡住。
38
+ 'backend/server.js',
39
+ 'backend/lib',
40
+ // 数据层共享契约(2026-10-11 M1):`backend/lib/db.js` 启动即读 schema.sql /
41
+ // schema.json / migrations.json,缺了就是「Node 后端起不来」——同样必须在发包自检里挡住。
42
+ 'backend/schema/schema.sql',
43
+ 'backend/schema/schema.json',
44
+ 'backend/schema/migrations.json',
27
45
  'builtin_prompts',
28
46
  'extensions/dsh-autocommit/asset.json',
29
47
  'webui/dist-plugin/index.html',
@@ -53,9 +71,19 @@ if (!bundle.includes(`id: ${JSON.stringify(pkg.name)}`)) {
53
71
  }
54
72
  if (pkg.private === true) failures.push('package.json 里 private: true, 发不出去(发布前删掉)');
55
73
 
74
+ // 闭包 ⊆ 白名单: 从入口正推运行时必需文件, 逐条要求在 files 展开结果里
75
+ // (算法见 dsh-plugin/lib/pack-closure.mjs;只读文件, 不跑 npm pack ⇒ 可以安全地待在 prepack 里)
76
+ const closure = checkPackClosure(PKG_ROOT);
77
+ if (!closure.ok) {
78
+ failures.push(`files 白名单漏了 ${closure.missing.length} 个运行时必需文件`
79
+ + `(新增运行时文件/目录后忘了改 package.json 的 files 就会这样):`);
80
+ for (const rel of closure.missing) failures.push(`files 缺: ${rel}`);
81
+ }
82
+
56
83
  if (failures.length > 0) {
57
84
  console.error(`prepack 自检未通过(${failures.length} 项):`);
58
85
  for (const line of failures) console.error(` - ${line}`);
59
86
  process.exit(1);
60
87
  }
61
- console.log(`prepack 自检通过: ${pkg.name}@${pkg.version}, ${REQUIRED.length} 个必需路径齐全, 包名三处一致`);
88
+ console.log(`prepack 自检通过: ${pkg.name}@${pkg.version}, ${REQUIRED.length} 个必需路径齐全, 包名三处一致, `
89
+ + `运行时闭包 ${closure.closure.files.length} 个文件全在白名单内`);
File without changes
@@ -272,10 +272,117 @@ function panelEntry() {
272
272
  return '/touchstone/app';
273
273
  }
274
274
 
275
- // 全屏面板: 无顶栏(iframe 吃满全屏) + 关闭按钮悬浮在左下角(位置与 dsh 侧栏入口一致)
275
+ // ── 窗口装饰区让位(2026-10-17, 卡 987 方案 A) ─────────────────────────────────
276
+ // 血案: 面板是全窗口浮层(position:fixed; inset:0), 而桌面版窗口的顶带(Windows 的最小化/
277
+ // 最大化/关闭、macOS 的红绿灯)由桌面壳画在网页之上 —— 浮层顶部那行按钮正好落在它底下,
278
+ // 在 dsh desktop 里整块被盖住、点不到(现象与取证见
279
+ // doc_ai/debug_runtime/202610/20261017_0600_零项目首屏无可点建项目入口… .md)。
280
+ // 修法: 让整个浮层从窗口顶边**下移**「壳实际绘制的窗口 chrome 高度」—— 顶带完整露给壳
281
+ // (标题文字/拖拽区/窗口按钮都还在原位), 面板内容从它下面开始。等价于 dsh 自己主面板与
282
+ // 弹窗的处理口径(layout 发布 --dsh-frame-top-clearance / --dsh-frame-chrome-top),
283
+ // 而且**不需要 SPA 侧改任何代码**: iframe 拿到的视口自己就变矮了。
284
+ // 高度来源(优先级从高到低; 全部读不到 ⇒ 0 ⇒ 独立浏览器形态, 行为与今天完全一致):
285
+ // ① dsh 桌面壳发布在 <html> 上的约定量(见 @deepseek-ai/dsh-client-ui-layout 的
286
+ // README「窗口 chrome 顶带」; 普通浏览器文档不发布这些值):
287
+ // --dsh-frame-chrome-top 壳当前**实际绘制**的顶带高(全屏时归 0)
288
+ // --dsh-frame-top-clearance 顶带之下的固定下沉量(Windows = 标题栏高, macOS = 48px)
289
+ // **取两者较大**: 全屏时 chrome-top 归零, 但 dsh 的 Frame 仍按 titlebar 高度让位,
290
+ // 取 max 才能和主面板同口径、不会在全屏时又把窗口按钮盖回去。
291
+ // ② WCO 原生接口 navigator.windowControlsOverlay(Chromium/Electron; 旧桌面壳只开了
292
+ // overlay 但没发布 ① 的量时兜底): getTitlebarAreaRect() 的 y + height = 顶带高。
293
+ // ③ CSS env(titlebar-area-height) 探针(同一能力的老式访问面, 只支持 env 的壳兜底)。
294
+ // 仍读不到(普通浏览器/不支持窗口浮层的壳) ⇒ 0 ⇒ 现状(独立形态本来就没有窗口装饰)。
295
+ const CHROME_MAX_PX = 200; // 合法上限: 再大必然是脏值(标题栏不可能 200px), 一口拒绝
296
+
297
+ /** 读 <html> 上某个 CSS 自定义属性的像素值。空串/无法解析/越界 ⇒ null(0px 是合法值)。 */
298
+ function readChromePx(name) {
299
+ try {
300
+ if (typeof document === 'undefined' || !document.documentElement) return null;
301
+ const cs = window.getComputedStyle ? window.getComputedStyle(document.documentElement) : null;
302
+ if (!cs) return null;
303
+ const raw = cs.getPropertyValue(name);
304
+ if (!raw) return null;
305
+ const n = parseFloat(raw);
306
+ if (!Number.isFinite(n) || n < 0 || n > CHROME_MAX_PX) return null;
307
+ return n;
308
+ } catch { return null; } // 读样式异常绝不能拖垮面板本体
309
+ }
310
+
311
+ /** WCO 原生接口量顶带高(标题栏区域 rect 的 y + height); 不可见/不可用 ⇒ null。 */
312
+ function wcoChromePx() {
313
+ try {
314
+ const nav = window.navigator;
315
+ const wco = nav && nav.windowControlsOverlay;
316
+ if (!wco || !wco.visible) return null;
317
+ const rect = wco.getTitlebarAreaRect ? wco.getTitlebarAreaRect() : null;
318
+ if (!rect) return null;
319
+ const n = (rect.y || 0) + rect.height;
320
+ if (!Number.isFinite(n) || n <= 0 || n > CHROME_MAX_PX) return null;
321
+ return n;
322
+ } catch { return null; }
323
+ }
324
+
325
+ /** env(titlebar-area-height) 探针: 挂一个 0 宽、高度取 env 的隐形探针量出来; 不支持 ⇒ null。
326
+ * 探针始终会被摘掉(成功/失败都摘), 不往宿主 DOM 里留垃圾。 */
327
+ function envChromePx() {
328
+ try {
329
+ if (typeof document === 'undefined' || !document.body
330
+ || typeof document.createElement !== 'function'
331
+ || typeof document.body.appendChild !== 'function') return null;
332
+ const probe = document.createElement('div');
333
+ probe.style.cssText = 'position:fixed;left:0;top:0;width:0;'
334
+ + 'height:env(titlebar-area-height,0px);visibility:hidden;pointer-events:none';
335
+ document.body.appendChild(probe);
336
+ let h = 0;
337
+ try {
338
+ h = probe.getBoundingClientRect ? probe.getBoundingClientRect().height : 0;
339
+ } finally {
340
+ if (typeof probe.remove === 'function') probe.remove();
341
+ else if (typeof document.body.removeChild === 'function') document.body.removeChild(probe);
342
+ }
343
+ if (!Number.isFinite(h) || h <= 0 || h > CHROME_MAX_PX) return null;
344
+ return h;
345
+ } catch { return null; }
346
+ }
347
+
348
+ /** 窗口 chrome 顶带高(px): 面板浮层从窗口顶边下移这么多。0 = 没有桌面窗口装饰。 */
349
+ function windowChromeTop() {
350
+ // ① dsh 桌面壳的约定量(两者取大, 口径见本节头注释)
351
+ const chrome = readChromePx('--dsh-frame-chrome-top');
352
+ const clearance = readChromePx('--dsh-frame-top-clearance');
353
+ if (chrome !== null || clearance !== null) {
354
+ return Math.max(chrome || 0, clearance || 0);
355
+ }
356
+ // ②③ 旧壳/其它壳兜底: WCO 原生接口 → env() 探针
357
+ return wcoChromePx() || envChromePx() || 0;
358
+ }
359
+
360
+ /** chrome 顶带高的响应式读取: 渲染期现读(首帧就带真值, 不闪一帧顶格),
361
+ * 壳换档(resize/进出全屏/窗口几何变化)时自增重渲染。 */
362
+ function useWindowChromeTop() {
363
+ const [, setRevision] = React.useState(0);
364
+ React.useEffect(() => {
365
+ const bump = () => setRevision((n) => n + 1);
366
+ window.addEventListener('resize', bump);
367
+ document.addEventListener('fullscreenchange', bump);
368
+ // WCO 几何变化不是 DOM 事件(壳侧全屏/标题栏变高/贴边), 只能由原生接口通知
369
+ const wco = window.navigator && window.navigator.windowControlsOverlay;
370
+ if (wco && typeof wco.addEventListener === 'function') wco.addEventListener('geometrychange', bump);
371
+ return () => {
372
+ window.removeEventListener('resize', bump);
373
+ document.removeEventListener('fullscreenchange', bump);
374
+ if (wco && typeof wco.removeEventListener === 'function') wco.removeEventListener('geometrychange', bump);
375
+ };
376
+ }, []);
377
+ return windowChromeTop();
378
+ }
379
+
380
+ // 全屏面板: 浮层从窗口 chrome 顶带之下开始(没有桌面壳时 = 0 = 吃满全屏) +
381
+ // 关闭按钮悬浮在左下角(位置与 dsh 侧栏入口一致)
276
382
  // iframe 同源, /touchstone/app 经薄壳反代到 server.py 的插件版 SPA
277
383
  function Panel() {
278
384
  const isOpen = useOpen();
385
+ const chromeTop = useWindowChromeTop(); // 窗口装饰顶带高: Windows 标题栏/macOS 红绿灯
279
386
  // 保活(2026-10-07): 打开过就在 DOM 里常驻, 关闭只把容器 display:none —— iframe 一旦
280
387
  // 从 DOM 摘掉, 内嵌 SPA 整篇文档就没了, 再打开是全新加载(路由/弹窗/滚动/未提交输入全丢)。
281
388
  // mounted 由「打开过」与「当前打开」合成: 首次打开的那一次渲染就带上 iframe(不等 effect
@@ -382,7 +489,9 @@ function Panel() {
382
489
  const visible = !zoneOk || shown; // 自动隐藏不可用时(zoneOk=false)保持常显
383
490
  return h('div', {
384
491
  style: {
385
- position: 'fixed', inset: 0, zIndex: 9999,
492
+ // 顶边让出窗口装饰带(见 useWindowChromeTop): 桌面壳里 = 标题栏/红绿灯的高度,
493
+ // 普通浏览器里 = 0(与旧版 inset:0 完全一致)。其余三边照旧贴边。
494
+ position: 'fixed', top: chromeTop, left: 0, right: 0, bottom: 0, zIndex: 9999,
386
495
  background: 'var(--dsw-alias-bg-base, #161617)',
387
496
  display: isOpen ? 'block' : 'none', // 关闭 = 只隐藏: iframe 与内嵌 SPA 常驻(保活)
388
497
  },
package/dshevents.py CHANGED
File without changes
package/export_cases.py CHANGED
File without changes
File without changes
File without changes
File without changes