@mingmaple/dsh-handover 0.9.6 → 0.9.13

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 (50) hide show
  1. package/README.md +53 -17
  2. package/docs/migrate.md +1 -1
  3. package/docs/releases/0.9.12.md +28 -0
  4. package/docs/releases/0.9.13.md +30 -0
  5. package/docs/troubleshoot.md +43 -6
  6. package/package.json +70 -57
  7. package/scripts/blank-dsh.mjs +6 -5
  8. package/scripts/blank-smoke.mjs +268 -58
  9. package/scripts/build.mjs +3 -0
  10. package/scripts/check-docs.mjs +23 -2
  11. package/scripts/office.mjs +81 -0
  12. package/src/client.js +92 -23
  13. package/src/index.mjs +264 -145
  14. package/src/lib/api.mjs +67 -9
  15. package/src/lib/config.mjs +4 -3
  16. package/src/lib/consent.mjs +59 -20
  17. package/src/lib/facts.mjs +5 -4
  18. package/src/lib/guide.mjs +14 -7
  19. package/src/lib/handover-io.mjs +20 -0
  20. package/src/lib/manual.mjs +23 -12
  21. package/src/lib/names.mjs +2 -2
  22. package/src/lib/office.mjs +272 -0
  23. package/src/lib/pack-ops.mjs +3 -0
  24. package/src/lib/phase.mjs +6 -4
  25. package/src/lib/prompt.mjs +18 -0
  26. package/src/lib/role.mjs +52 -18
  27. package/src/lib/schedule.mjs +10 -14
  28. package/src/lib/session-create.mjs +42 -0
  29. package/src/lib/session-ops.mjs +183 -79
  30. package/src/lib/sessions.mjs +9 -8
  31. package/src/lib/status.mjs +8 -2
  32. package/src/lib/succession.mjs +244 -0
  33. package/src/lib/text.mjs +27 -0
  34. package/src/lib/webroute.mjs +1 -1
  35. package/src/lib/workspace.mjs +1 -1
  36. package/src/selftest.mjs +15 -0
  37. package/src/templates/Charter.skeleton.md +1 -1
  38. package/src/templates/Prompt_Templates.md +9 -17
  39. package/src/templates/Runtime_Manual.md +17 -14
  40. package/src/templates/rules/Handover_Rules.md +3 -2
  41. package/src/templates/rules/Misc_Task_Standard.md +3 -3
  42. package/src/tools/ho_checkpoint.mjs +11 -4
  43. package/src/tools/ho_diag.mjs +5 -3
  44. package/src/tools/ho_handover.mjs +7 -6
  45. package/src/tools/ho_office.mjs +59 -0
  46. package/src/tools/ho_profile.mjs +10 -2
  47. package/src/tools/ho_sessions.mjs +3 -2
  48. package/src/tools/ho_snapshot.mjs +2 -1
  49. package/src/tools/ho_succeed.mjs +24 -0
  50. package/src/tools/ho_workspace.mjs +7 -3
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 空白环境冒烟:不依赖任何真实工作区,验「装上就能跑」。
3
- * 1) 宿主半在假 ctx 上 apply:22 个工具、手册段、状态路由(200/404/405/HEAD)、只读工具不抛
3
+ * 1) 宿主半在假 ctx 上 apply:24 个工具、手册段、状态路由(200/404/405/HEAD)、只读工具不抛
4
4
  * 2) 浏览器半在假 window/React 上装载:bundle 外形、settings.section 注册、成体视图能渲染
5
5
  * 用法:node scripts/blank-smoke.mjs [--dist <dist 目录>]
6
6
  */
@@ -26,9 +26,14 @@ process.env.HOME = BLANK;
26
26
  const { apply, schedDepsFor } = await import(pathToFileURL(path.join(dist, 'index.mjs')).href);
27
27
  const reg = { tools: [], sections: [], logs: [], listeners: {} };
28
28
  const ctx = {
29
- tools: { register: (t) => reg.tools.push(t), restrict() { }, guard: (fn) => { reg.guardFn = fn; } },
29
+ tools: { register: (t, o) => { reg.tools.push(t); (reg.toolOpts = reg.toolOpts || []).push(o); }, restrict() { }, resolve: () => ({}), guard: (fn) => { reg.guardFn = fn; } },
30
30
  systemPrompt: { section: (s) => { reg.sections.push(s); return () => { reg.disposed = (reg.disposed || 0) + 1; }; } },
31
- sessionController: {},
31
+ sessionController: {
32
+ create: async () => ({ sessionId: 'session-deploy-test' }),
33
+ rename: async () => { },
34
+ prompt: async () => ({ accepted: true }),
35
+ },
36
+
32
37
  logger: { warn: (m) => reg.logs.push(String(m)), error: (m) => reg.logs.push(String(m)), info() { } },
33
38
  effect: (fn) => fn(),
34
39
  on: (n, cb) => { (reg.listeners[n] = reg.listeners[n] || []).push(cb); },
@@ -37,15 +42,45 @@ const ctx = {
37
42
  };
38
43
  apply(ctx, {});
39
44
 
40
- ok(reg.tools.length === 22, '工具数应为 22', String(reg.tools.length));
45
+ ok(reg.tools.length === 24, '工具数应为 24', String(reg.tools.length));
41
46
  ok(reg.sections.length === 0, '未开启全局前置提示词时不得注册提示段', String(reg.sections.length));
42
47
  ok(reg.tools.filter((t) => !t || !t.name || !t.description || !t.execute).length === 0, '每个工具都要有 name/description/execute');
43
- // 上下文成本守门:工具描述是每次请求都要付的固定开销,单条不得超过预算(加权 token)。
48
+ // 上下文成本实测:工具描述是每次请求都要付的固定开销。参考值只用于展示,不作为通过条件。
44
49
  const budget = await import(pathToFileURL(path.join(dist, 'lib', 'facts.mjs')).href);
45
- const fatDesc = reg.tools.filter((t) => budget.estimateTokens(t.description) > budget.TOKEN_BUDGET.toolDescTokens).map((t) => t.name);
46
- ok(fatDesc.length === 0, '单条工具描述应不超过 ' + budget.TOKEN_BUDGET.toolDescTokens + ' token(上下文成本)', fatDesc.join(','));
47
- ok(reg.tools.filter((t) => t.name.startsWith('ho_')).length === 16, 'ho_* 应为 16 个', String(reg.tools.filter((t) => t.name.startsWith('ho_')).length));
50
+ {
51
+ const rows = reg.tools.map((t) => ({ name: t.name, n: budget.estimateTokens(t.description) }));
52
+ const widest = rows.slice().sort((a, b) => b.n - a.n)[0];
53
+ console.log('工具描述 token 实测:最宽 ' + widest.name + ' ' + widest.n + '(参考值 ' + budget.TOKEN_BUDGET.toolDescTokens + ',不判失败)');
54
+ }
55
+ ok(reg.tools.filter((t) => t.name.startsWith('ho_')).length === 18, 'ho_* 应为 18 个', String(reg.tools.filter((t) => t.name.startsWith('ho_')).length));
48
56
  ok(reg.tools.filter((t) => t.name.startsWith('peer_')).length === 6, 'peer_* 应为 6 个');
57
+ // 每步条件启用:内核 0.2.2 起工具按「步」解析,子会话在解析工具表这一步就要被排除,
58
+ // 否则它的首轮提示词会带着 24 个用不了的 schema(每步白付约 2.5k token)。
59
+ ok((reg.toolOpts || []).length === 24 && (reg.toolOpts || []).every((o) => o && typeof o.when === 'function'), '24 个工具都应带每步启用条件', String((reg.toolOpts || []).length));
60
+
61
+ /** 取某个工具的每步启用条件(0.2.2 起工具按步解析)。 */
62
+ const gateFor = (name) => {
63
+ const i = (reg.tools || []).findIndex((t) => t && t.name === name);
64
+ return i >= 0 && reg.toolOpts && reg.toolOpts[i] ? reg.toolOpts[i].when : null;
65
+ };
66
+
67
+ {
68
+ // 每步条件同时承担子会话排除与角色过滤:调用方每步重算工具表,换角色下一轮即生效。
69
+ const gate = gateFor('ho_brief');
70
+ ok(typeof gate === 'function', '每步启用条件应可调用');
71
+ if (typeof gate === 'function') {
72
+ ok(gate({}) === true, '没有 agent 的调用应放行(命令行与演练路径)');
73
+ ok(gate({ agent: { session: { header: { id: 'session-sub', origin: 'subagent' } } } }) === false, '子会话的工具条件应拒绝');
74
+ ok(gate({ agent: { session: { header: { id: 'session-child', parentSession: 'session-root' } } } }) === false, '带父会话的会话也应拒绝');
75
+ ok(gate({ agent: { session: { header: { id: 'session-plain', cwd: path.join(BLANK, 'nowhere') } } } }) === false, '未登记角色的会话按普通处理(0 个插件工具)');
76
+ const roleMod = await import(pathToFileURL(path.join(dist, 'lib', 'role.mjs')).href);
77
+ roleMod.writeRole({}, 'session-boss', { role: 'global' });
78
+ ok(gate({ agent: { session: { header: { id: 'session-boss', cwd: path.join(BLANK, 'nowhere') } } } }) === true, '全局角色应可见 ho_brief');
79
+ const gatePlain = gateFor('ho_phase');
80
+ ok(typeof gatePlain === 'function' && gatePlain({ agent: { session: { header: { id: 'session-boss', cwd: path.join(BLANK, 'nowhere') } } } }) === true, '全局角色应可见 ho_phase');
81
+ ok(gatePlain({ agent: { session: { header: { id: 'session-plain', cwd: path.join(BLANK, 'nowhere') } } } }) === false, '普通角色不可见 ho_phase');
82
+ }
83
+ }
49
84
  // 可选注入多了 connection(/api 载体)。两个都必须是**可选**的,否则旧宿主整插件不激活。
50
85
  const injectNames = (reg.injects || []).map((i) => i.names.join(','));
51
86
  ok(injectNames.some((n) => n.indexOf('webServer') !== -1), '应把 webServer 作为可选注入', injectNames.join(' | '));
@@ -92,6 +127,17 @@ ok(badEnv && badEnv.result && badEnv.result.ok === false && /信封不合法/.te
92
127
  const rpcGet = await carrier.fetch(new Request('http://127.0.0.1/api/dsh-handover', { method: 'GET' }));
93
128
  ok(rpcGet.status === 405, '载体 GET 应 405', String(rpcGet.status));
94
129
 
130
+ // 一键部署:没有 workspaceRegistry 时仍要建体系并建会话,并把「工作区未注册」如实报出。
131
+ const deployParent = path.join(BLANK, 'deploy');
132
+ const rpcDeploy = await rpcCall('deploy', { parent: deployParent });
133
+ const dv = rpcDeploy && rpcDeploy.result && rpcDeploy.result.ok ? rpcDeploy.result.value : null;
134
+ ok(!!dv, 'deploy 应成功', JSON.stringify(rpcDeploy && rpcDeploy.result && rpcDeploy.result.error));
135
+ if (dv) {
136
+ ok(dv.sessionId === 'session-deploy-test', 'deploy 应建出全局会话', String(dv.sessionId));
137
+ ok(fs.existsSync(path.join(dv.root, '_Control_Center', 'records', 'Global_Handover.md')), 'deploy 应在指定路径铺好文件体系', String(dv.root));
138
+ ok(!dv.workspace && /workspaceRegistry/.test(String(dv.workspaceError || '')), '无 workspaceRegistry 时应如实报告工作区未注册', JSON.stringify({ w: dv.workspace, e: dv.workspaceError }));
139
+ }
140
+
95
141
  async function call(method, url) {
96
142
  const res = { code: 0, headers: null, body: undefined, writeHead(c, h) { this.code = c; this.headers = h; }, end(b) { this.body = b; } };
97
143
  await route.handler({ method, url }, res);
@@ -103,11 +149,11 @@ let payload = null;
103
149
  try { payload = JSON.parse(r1.body); } catch (e) { fails.push('返回不是 JSON:' + e.message); }
104
150
  if (payload) {
105
151
  ok(payload.plugin.version && payload.plugin.version !== '?', '应报出版本号', String(payload.plugin.version));
106
- ok(payload.plugin.tools === 22, 'payload 工具数应为 22', String(payload.plugin.tools));
152
+ ok(payload.plugin.tools === 24, 'payload 工具数应为 24', String(payload.plugin.tools));
107
153
  ok(payload.workspace && payload.workspace.found === false, '空白 HOME 下应判定为没找到工作区', JSON.stringify(payload.workspace));
108
154
  ok(Array.isArray(payload.notes) && payload.notes.length > 0, '没找到工作区时应给中文提示');
109
155
  ok(payload.guide && Array.isArray(payload.guide.situations) && payload.guide.situations.length >= 8, 'payload 应带「怎么用」的时机表');
110
- ok(payload.guide && payload.guide.groups.reduce((a, g) => a + g.tools.length, 0) === 22, '使用说明里应列全 22 个工具', payload.guide && String(payload.guide.groups.reduce((a, g) => a + g.tools.length, 0)));
156
+ ok(payload.guide && payload.guide.groups.reduce((a, g) => a + g.tools.length, 0) === 24, '使用说明里应列全 24 个工具', payload.guide && String(payload.guide.groups.reduce((a, g) => a + g.tools.length, 0)));
111
157
  ok(payload.plugin.initErrors && payload.plugin.initErrors.length === 0, '初始化错误应为空', JSON.stringify(payload.plugin.initErrors));
112
158
  }
113
159
  ok((await call('GET', '/dsh-handover/nope.json')).code === 404, '未知路径应 404');
@@ -241,6 +287,52 @@ fs.mkdirSync(path.join(WS, '_Control_Center'), { recursive: true });
241
287
  fs.mkdirSync(path.join(WS, 'Projects', 'Demo', 'Demo库'), { recursive: true });
242
288
  fs.mkdirSync(path.join(WS, '_Misc', 'tasks', '抢名额'), { recursive: true });
243
289
 
290
+ // ---------- 在任复用:工具层前置校验(任何写入之前返回) ----------
291
+ {
292
+ const { registerHolder } = await import(DIST('lib/office.mjs'));
293
+ const cacheDir = path.join(BLANK, '.dsh', 'storages', 'session_projcache', 'sessions');
294
+ fs.mkdirSync(cacheDir, { recursive: true });
295
+ fs.writeFileSync(path.join(cacheDir, 'session-hold.json'), JSON.stringify({
296
+ record: { rows: { contextPressure: { val: { pressureTokens: 16000, contextWindow: 160000 } } } },
297
+ }), 'utf8');
298
+ registerHolder(WS, { kind: 'project', project: 'Demo', name: 'Demo1', sessionId: 'session-hold', workspace: WS });
299
+ const sessToolR = reg.tools.find((t) => t.name === 'ho_sessions');
300
+ const makerExec = { name: 'ho_sessions', arguments: {}, agent: { session: { header: { id: 'session-maker', cwd: WS } } } };
301
+ const newErr = async (extra) => {
302
+ try {
303
+ await sessToolR.execute(Object.assign({ action: 'new', confirm: true, title: 'Demo2', project: 'Demo' }, extra), makerExec);
304
+ return '';
305
+ } catch (e) { return String(e && e.message ? e.message : e); }
306
+ };
307
+ const lowErr = await newErr({});
308
+ ok(/已有在任会话 Demo1/.test(lowErr) && /session-hold/.test(lowErr) && /peer_send/.test(lowErr) && /force=true/.test(lowErr), '项目类别有低水位在任时应拒绝新建并给出复用建议', lowErr);
309
+ const forcedErr = await newErr({ force: true });
310
+ ok(forcedErr.indexOf('已有在任会话') === -1, 'force=true 应越过复用判据', forcedErr);
311
+
312
+ // 换任:本类别现任就是本会话且水位未到线时拒绝,force=true 越过。
313
+ const handover = path.join(WS, '_Control_Center', 'records', 'Global_Handover.md');
314
+ fs.mkdirSync(path.dirname(handover), { recursive: true });
315
+ fs.writeFileSync(handover, '# 全局交接' + '\n\n## 下一步\n- 无\n', 'utf8');
316
+ registerHolder(WS, { kind: 'global', name: '全局1', sessionId: 'session-hold', workspace: WS });
317
+ const { grantConsent, consumedConsent } = await import(DIST('lib/consent.mjs'));
318
+ grantConsent('session-hold', 'blank-smoke', 'succeed');
319
+ const { runSuccession } = await import(DIST('lib/succession.mjs'));
320
+ const succEnv = {
321
+ cfg: resolveConfig({}),
322
+ roleOf: () => ({ role: 'global', project: '', task: '' }),
323
+ selfId: () => 'session-hold',
324
+ handoverFor: () => ({ path: handover, reason: '' }),
325
+ resolveWs: () => WS,
326
+ selfPct: () => 10,
327
+ registry: () => ({ list: () => [] }),
328
+ };
329
+ const succLow = await runSuccession(succEnv, null, {});
330
+ ok(/水位 10\.0%/.test(succLow) && /通常无需换任/.test(succLow) && /force=true/.test(succLow), '换任:本会话水位未到线时应拒绝并建议 force', succLow);
331
+ const succForce = await runSuccession(succEnv, null, { force: true });
332
+ ok(succForce.indexOf('尚未到线') === -1, '换任:force=true 应越过水位判据', succForce);
333
+ consumedConsent('session-hold', 'succeed');
334
+ }
335
+
244
336
  const rTitle = resolveRole({ sessionId: 'session-t1', title: '全局9' });
245
337
  ok(rTitle.role === 'global' && rTitle.source === 'title', '角色判定:标题含「全局」→ global', JSON.stringify(rTitle));
246
338
  const rCwd = resolveRole({ sessionId: 'session-t2', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库') });
@@ -257,6 +349,19 @@ const rStore = resolveRole({ sessionId: 'session-t6', title: '全局10' });
257
349
  ok(rStore.role === 'misc' && rStore.source === 'store', '角色表优先于标题推断', JSON.stringify(rStore));
258
350
  ok(readRoles({}).sessions['session-t6'] && readRoles({}).sessions['session-t6'].task === 'X', '角色表应可读回');
259
351
 
352
+ // 回归锁:首条提示里的 `Projects/**` 通配曾被当成项目名 `**、_Project_Meta`,
353
+ // 于是交接路径、备份目录、归档目录全落在这个 Windows 非法名字上(实测四类报错)。
354
+ const rGlob = resolveRole({ sessionId: 'session-glob-prompt', title: 'Android AI Agent 3', cwd: 'C:/Users/x/.dsh/profiles/desktop', firstPrompt: '- 动 _Control_Center/**、Projects/**、_Project_Meta/** 前先问用户。' });
355
+ ok(rGlob.role === 'plain', '首条提示里的 Projects/** 通配不得被当成项目名', JSON.stringify(rGlob));
356
+ const hpBad = handoverPathFor(WS, { role: 'project', project: '**、_Project_Meta' });
357
+ ok(hpBad.path === null && /不合法/.test(String(hpBad.reason || '')), '非法项目名不得拼出交接路径', JSON.stringify(hpBad));
358
+ {
359
+ let badWrite = '';
360
+ try { writeRole({}, 'session-bad-name', { role: 'project', project: '**、_Project_Meta' }); } catch (e) { badWrite = e.message; }
361
+ ok(/不合法/.test(badWrite), '角色表不得写入含非法字符的项目名', badWrite);
362
+ ok(readRoles({}).sessions['session-bad-name'] === undefined, '非法项目名不得落盘', JSON.stringify(readRoles({}).sessions['session-bad-name']));
363
+ }
364
+
260
365
  const hpG = handoverPathFor(WS, { role: 'global' });
261
366
  ok(/Global_Handover\.md$/.test(hpG.path), '全局交接路径', hpG.path);
262
367
  const hpP = handoverPathFor(WS, { role: 'project', project: 'Demo' });
@@ -267,16 +372,16 @@ const hpU = handoverPathFor(WS, { role: 'plain' });
267
372
  ok(hpU.path === null && !!hpU.reason, '普通会话不得给出交接路径(不退回全局)', JSON.stringify(hpU));
268
373
 
269
374
  const tsG = toolsetFor('global');
270
- ok(tsG.allow.length === 22 && tsG.deny.length === 0, '全局应可见全部 22 个工具', JSON.stringify([tsG.allow.length, tsG.deny.length]));
375
+ ok(tsG.allow.length === 24 && tsG.deny.length === 0, '全局应可见全部 24 个工具', JSON.stringify([tsG.allow.length, tsG.deny.length]));
271
376
  const tsP = toolsetFor('project');
272
- ok(tsP.allow.length === 9, '项目角色应可见 9 个工具', String(tsP.allow.length));
377
+ ok(tsP.allow.length === 11, '项目角色应可见 11 个工具', String(tsP.allow.length));
273
378
  ok(tsP.allow.indexOf('ho_handover') !== -1 && tsP.allow.indexOf('ho_checkpoint') !== -1, '项目角色必须能写自己的交接与压缩');
274
379
  ok(tsP.allow.indexOf('ho_pack') === -1 && tsP.allow.indexOf('ho_sessions') === -1, '项目角色不该看见全局管理工具');
275
380
  ok(tsP.deny.indexOf('ho_handover') === -1 && tsP.deny.indexOf('run_code') === -1, 'deny 名单不得含保留名或本角色已允许的工具');
276
381
  const tsM = toolsetFor('misc');
277
- ok(tsM.allow.length === 9 && tsM.allow.indexOf('peer_new') !== -1 && tsM.allow.indexOf('ho_gate') === -1, '杂项角色:9 个,含 peer_new、不含 ho_gate', String(tsM.allow.length));
382
+ ok(tsM.allow.length === 11 && tsM.allow.indexOf('peer_new') !== -1 && tsM.allow.indexOf('ho_gate') === -1, '杂项角色:11 个,含 peer_new、不含 ho_gate', String(tsM.allow.length));
278
383
  const tsU = toolsetFor('plain');
279
- ok(tsU.allow.length === 0 && tsU.deny.length === 22, '普通会话不载入任何插件工具', JSON.stringify([tsU.allow.length, tsU.deny.length]));
384
+ ok(tsU.allow.length === 0 && tsU.deny.length === 24, '普通会话不载入任何插件工具', JSON.stringify([tsU.allow.length, tsU.deny.length]));
280
385
  const rSub = resolveRole({ sessionId: 'session-sub', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库'), subagent: true });
281
386
  ok(rSub.role === 'plain' && rSub.source === 'subagent', '子会话默认按普通处理,不按 cwd 判项目', JSON.stringify(rSub));
282
387
 
@@ -309,6 +414,10 @@ if (profTool) {
309
414
  ok(/已注册/.test(rr.text) && /application/.test(rr.text), 'ho_profile read 应读到已注册骨架');
310
415
  const vv = await profTool.execute({ action: 'validate' }, profExec);
311
416
  ok(/结论:通过/.test(vv.text), 'ho_profile validate 应通过', vv.text.slice(0, 120));
417
+ // 项目会话只处理自己的骨架:传别的项目名要报错,不能静默忽略入参(实测踩过)。
418
+ let crossErr = '';
419
+ try { await profTool.execute({ action: 'validate', project: '学习' }, profExec); } catch (e) { crossErr = e.message; }
420
+ ok(/只能读写自己的骨架/.test(crossErr), '项目会话传别的项目名应明确报错', crossErr);
312
421
  }
313
422
 
314
423
  // 回归锁:压缩调度器拿的是**会话 id 字符串**。早期版本这里错配了 exec 版函数,
@@ -333,6 +442,48 @@ const tu = fillResumePrompt(tcfg, { n: 1, handover: '', role: 'plain' });
333
442
  ok(/普通会话,不参与交接/.test(tu), '普通会话的续跑文案应说明不参与交接', tu.slice(0, 90));
334
443
  const tg = fillResumePrompt(tcfg, { n: 3, handover: hpG.path, role: 'global' });
335
444
  ok(tg.indexOf('第 3/3 次压缩') !== -1, '续跑文案应填上次数', tg.slice(0, 40));
445
+ ok(tcfg.directPeer === 'on', '会话之间直接对话应默认允许', String(tcfg.directPeer));
446
+ ok(tg.indexOf('收尾并停止') !== -1, '续跑文案应允许交接无待办时收尾停止', tg.slice(0, 120));
447
+
448
+ // 压缩额度用尽优先于水位判定:3/3 之后再压不是「水位没到」,而是该换任。
449
+ // 实测:某项目会话压满 3 次、水位 25.4% 时,ho_checkpoint 只报「水位未到线」,误导它去找 force=true。
450
+ {
451
+ const { decide } = await import(pathToFileURL(path.join(dist, 'lib', 'phase.mjs')).href);
452
+ const d1 = decide({ ratio: 0.25, compactionsUsed: 3, cfg: tcfg, lockState: 'clean' });
453
+ ok(d1.action === 'new_session', '压满 3/3 时水位低也应建议换任', JSON.stringify(d1));
454
+ const d2 = decide({ ratio: 0.9, compactionsUsed: 3, cfg: tcfg, lockState: 'clean' });
455
+ ok(d2.action === 'new_session', '压满 3/3 时到硬顶也应换任', JSON.stringify(d2));
456
+ const d3 = decide({ ratio: 0.4, compactionsUsed: 2, cfg: tcfg, lockState: 'clean' });
457
+ ok(d3.action === 'checkpoint', '未压满时过交接线仍应压缩', JSON.stringify(d3));
458
+ }
459
+
460
+ // 归档命名按 Handover_Rules §4.2:<原文件名>_<YYYYMMDD_HHMMSS>.md,不加链标识;杂项归档在任务目录内。
461
+ {
462
+ const io = await import(pathToFileURL(path.join(dist, 'lib', 'handover-io.mjs')).href);
463
+ const stamp = io.archiveStamp(new Date(2026, 10, 8, 14, 43, 7));
464
+ ok(stamp === '20261108_144307', '归档时间戳应到秒', stamp);
465
+ const projName = io.archiveFileName('E:/ws/_Project_Meta/Demo/Handover.md', stamp);
466
+ ok(projName === 'Handover_' + stamp + '.md', '项目归档名应为 Handover_<时间>.md,不加项目前缀', projName);
467
+ const globName = io.archiveFileName('E:/ws/_Control_Center/records/Global_Handover.md', stamp);
468
+ ok(globName === 'Global_Handover_' + stamp + '.md', '全局归档名应为 Global_Handover_<时间>.md', globName);
469
+ const ad = await import(pathToFileURL(path.join(dist, 'lib', 'role.mjs')).href);
470
+ const miscDir = ad.archiveDirFor(WS, { role: 'misc', task: '抢名额' }).replace(/\\/g, '/');
471
+ ok(miscDir.endsWith('_Misc/tasks/抢名额/_archive'), '杂项归档目录应在任务目录内(_archive)', miscDir);
472
+ const handTool = reg.tools.find((t) => t.name === 'ho_handover');
473
+ ok(!!handTool, '应注册 ho_handover');
474
+ if (handTool) {
475
+ const archExec = { name: 'ho_handover', arguments: { action: 'archive' }, agent: { session: { header: { id: 'session-arch', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库'), title: 'Demo1' } } } };
476
+ const r = await handTool.execute({ action: 'archive' }, archExec);
477
+ const m = /已归档 (.+?\.md)/.exec(r.text || '');
478
+ ok(!!m, '归档应报出目标路径', String(r.text).slice(0, 140));
479
+ if (m) {
480
+ const p = m[1];
481
+ ok(p.replace(/\\/g, '/').indexOf('_Archives/Demo/Handover/') !== -1, '项目归档目录应为 _Archives/Demo/Handover', p);
482
+ ok(/^Handover_\d{8}_\d{6}\.md$/.test(path.basename(p)), '归档文件名不加项目前缀且时间到秒', path.basename(p));
483
+ ok(fs.existsSync(p), '归档文件应存在', p);
484
+ }
485
+ }
486
+ }
336
487
 
337
488
  const listeners = (reg.listeners && reg.listeners['agent/created']) || [];
338
489
  ok(listeners.length === 1, '应监听 agent/created 且只一处', String(listeners.length));
@@ -342,26 +493,34 @@ const projAgent = {
342
493
  session: { header: { id: 'session-proj', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库') } },
343
494
  ctx: {
344
495
  tools: { restrict: (f) => { calls.restrict.push(f); return () => { calls.lifted++; }; } },
345
- systemPrompt: { section: (o) => { calls.sections.push(o); return () => { }; } },
496
+ systemPrompt: { section: (o) => { calls.sections.push(o); return () => { calls.lifted++; }; } },
346
497
  },
347
498
  inject: (m) => calls.injected.push(m),
348
499
  };
349
500
  for (const cb of listeners) cb({ agent: projAgent, source: 'fresh' });
350
- ok(calls.restrict.length === 1, '项目角色应调用 restrict 一次', String(calls.restrict.length));
351
- ok(calls.restrict[0] && Array.isArray(calls.restrict[0].deny) && calls.restrict[0].deny.length === 13, 'restrict 的 deny 名单应为 13 个', JSON.stringify(calls.restrict[0] && calls.restrict[0].deny && calls.restrict[0].deny.length));
352
- ok(Array.isArray(calls.restrict[0] && calls.restrict[0].deny) && calls.restrict[0].deny.indexOf('ho_pack') !== -1 && calls.restrict[0].deny.indexOf('ho_handover') === -1, 'deny 名单应与角色矩阵一致');
501
+ ok(calls.restrict.length === 0, '按步路径不用 restrict 收窄工具', String(calls.restrict.length));
502
+ const projGatePack = gateFor('ho_pack');
503
+ const projGateHand = gateFor('ho_handover');
504
+ const projExec = { agent: projAgent };
505
+ ok(typeof projGatePack === 'function' && projGatePack(projExec) === false, '项目角色不可见 ho_pack(每步条件)');
506
+ ok(typeof projGateHand === 'function' && projGateHand(projExec) === true, '项目角色可见 ho_handover(每步条件)');
507
+ ok(gateFor('ho_brief')(projExec) === false, '项目角色不可见 ho_brief');
353
508
  ok(calls.sections.length === 1 && calls.sections[0].name === 'dsh-handover:manual', '应注册同名提示段遮蔽兜底段');
354
509
  const secNorm = String(calls.sections[0].text).replace(/\\/g, '/');
355
510
  ok(secNorm.indexOf('Projects/Demo') !== -1 && secNorm.indexOf('_Project_Meta/Demo/Handover.md') !== -1, '项目提示段应含自己的路径', secNorm.slice(0, 200));
356
- ok(budget.estimateTokens(calls.sections[0].text) <= budget.TOKEN_BUDGET.projectTokens, '项目提示段应控制在 ' + budget.TOKEN_BUDGET.projectTokens + ' token 内(成本守门)', String(budget.estimateTokens(calls.sections[0].text)));
511
+ ok(secNorm.indexOf('任务完成不报全局') !== -1, '项目提示段应写明任务完成不向全局报告', secNorm.slice(0, 400));
512
+ ok(secNorm.indexOf('任务达成') === -1, '项目提示段不得再出现任务达成');
513
+ ok(secNorm.indexOf('可直接 peer_send') !== -1, '项目提示段应允许会话之间直接对话', secNorm.slice(0, 400));
514
+ ok(secNorm.indexOf('resume=wait') !== -1, '项目提示段应说明收尾压缩不唤醒');
515
+ console.log('项目提示段 token 实测:' + budget.estimateTokens(calls.sections[0].text) + '(参考值 ' + budget.TOKEN_BUDGET.projectTokens + ',不判失败)');
357
516
  ok(calls.sections[0].text.indexOf('子智能体') === -1, '项目段不得携带子智能体派发纪律(不属于本插件)');
358
- // 真实工作区路径 + 迁移提示是最坏情形,预算另设一档
517
+ // 真实工作区路径 + 迁移提示是最坏情形,参考值另设一档
359
518
  {
360
519
  const man = await import(pathToFileURL(path.join(dist, 'lib', 'manual.mjs')).href);
361
520
  const longWs = path.join(os.tmpdir(), 'Project_Mate', '_Workspace');
362
521
  const otherWs = path.join(os.tmpdir(), 'Project_Mate', '_Workspace_2');
363
522
  const worst = man.scopedManual({ role: 'project', project: 'Demo', task: '' }, { ws: longWs, move: { phase: 'switched', dst: otherWs, src: longWs } });
364
- ok(budget.estimateTokens(worst) <= budget.TOKEN_BUDGET.projectWorstTokens, '项目段在真实长路径与迁移提示下仍应在预算内', String(budget.estimateTokens(worst)));
523
+ console.log('项目最坏情形提示段 token 实测:' + budget.estimateTokens(worst) + '(参考值 ' + budget.TOKEN_BUDGET.projectWorstTokens + ',不判失败)');
365
524
  }
366
525
  ok(secNorm.indexOf('_Temp/Demo/') !== -1 && secNorm.indexOf('_Archives/Demo/') !== -1, '项目提示段应含临时与归档路径', secNorm.slice(0, 300));
367
526
  // 回归锁:早期版本用 agent.inject({source:{kind:'plugin'}}) 投一次性引导,v4 格式准入拒绝该 source,
@@ -384,8 +543,8 @@ const globAgent = {
384
543
  for (const cb of listeners) cb({ agent: globAgent, source: 'fresh' });
385
544
  ok(gCalls.restrict === 0, '全局角色不应被裁剪', String(gCalls.restrict));
386
545
  ok(gCalls.sections.length === 1 && gCalls.sections[0].name === 'dsh-handover:manual', '全局也应遮蔽兜底段');
387
- // 常驻段只放每轮判定,一次性流程下沉手册;上限按加权 token 计。
388
- ok(budget.estimateTokens(gCalls.sections[0].text) <= budget.TOKEN_BUDGET.globalTokens, '全局提示段应控制在 ' + budget.TOKEN_BUDGET.globalTokens + ' token 内(硬上限)', String(budget.estimateTokens(gCalls.sections[0].text)));
546
+ // 常驻段只放每轮判定,一次性流程下沉手册;参考值按加权 token 计,只打印实测值。
547
+ console.log('全局提示段 token 实测:' + budget.estimateTokens(gCalls.sections[0].text) + '(参考值 ' + budget.TOKEN_BUDGET.globalTokens + ',不判失败)');
389
548
  ok(gCalls.sections[0].text.indexOf('反模式') === -1, '全局段不得携带反模式纪律(不属于本插件)');
390
549
  ok(gCalls.sections[0].text.indexOf('send_message 续用') === -1, '全局段不得携带子智能体续用纪律(不属于本插件)');
391
550
  // 真机最坏情形:长路径 + 迁移提示行
@@ -394,7 +553,7 @@ ok(gCalls.sections[0].text.indexOf('send_message 续用') === -1, '全局段不
394
553
  const longWs2 = path.join(os.tmpdir(), 'Project_Mate', '_Workspace');
395
554
  const otherWs2 = path.join(os.tmpdir(), 'Project_Mate', '_Workspace_2');
396
555
  const gWorst = man2.globalManual({ ws: longWs2, move: { phase: 'switched', dst: otherWs2, src: longWs2 } });
397
- ok(budget.estimateTokens(gWorst) <= budget.TOKEN_BUDGET.globalWorstTokens, '全局段在长路径与迁移提示下仍应在预算内', String(budget.estimateTokens(gWorst)));
556
+ console.log('全局最坏情形提示段 token 实测:' + budget.estimateTokens(gWorst) + '(参考值 ' + budget.TOKEN_BUDGET.globalWorstTokens + ',不判失败)');
398
557
  }
399
558
  ok(gCalls.sections[0].text.indexOf('前置提示词') === -1, '插件角色段不得再携带前置提示词(由用户全局前置提示词注入)');
400
559
  // 结构只保留会话定位、权限、节奏、交接与按需取用五节
@@ -425,6 +584,11 @@ ok(gCalls.injected === 0, '全局不再重复注入工作流(提示段里已
425
584
  // 换任启动提示词不得再写「然后接着干」——没任务时它会抠字眼给自己找活
426
585
  const opsSrc = fs.readFileSync(path.join(dist, 'lib', 'session-ops.mjs'), 'utf8');
427
586
  ok(opsSrc.indexOf('接着干') === -1, '启动提示词与交接提示不得再出现「接着干」');
587
+ ok(opsSrc.indexOf('任务达成') === -1 && opsSrc.indexOf('三种时机') !== -1, '启动提示词应改为三种时机且不再含任务达成');
588
+ const ckSrc = fs.readFileSync(path.join(dist, 'tools', 'ho_checkpoint.mjs'), 'utf8');
589
+ ok(ckSrc.indexOf('resume') !== -1, 'ho_checkpoint 应支持 resume 选择压缩后去向');
590
+ const idxSrc = fs.readFileSync(path.join(dist, 'index.mjs'), 'utf8');
591
+ ok(idxSrc.indexOf('directPeer') !== -1, '会话守卫应支持直接对话开关');
428
592
  // 语体扫描——界面与文档一律标准书面语(口语词、感叹号、图形结论、全角斜杠、时间缺时区)。
429
593
  {
430
594
  const uiFiles = listFiles(path.join(repo, 'src'), ['.mjs', '.js'], { skip: ['templates'] });
@@ -440,41 +604,59 @@ ok(gCalls.injected === 0, '全局不再重复注入工作流(提示段里已
440
604
  ok(opsSrc.indexOf('不得自行开展作业') !== -1 && opsSrc.indexOf('待用户指派') !== -1, '启动提示词应先报告再待指派');
441
605
  }
442
606
 
443
- const unrCalls = { restrict: 0 };
607
+ // 按步路径(0.2.2 起):创建即按角色登记提示段;工具可见性由每步条件算,不再调用 restrict。
608
+ const unrCalls = { restrict: 0, section: null };
444
609
  const unrAgent = {
445
610
  id: 'session-unr',
446
611
  session: { header: { id: 'session-unr', title: '随便', cwd: '/tmp/x' } },
447
612
  ctx: {
448
- tools: { restrict: (f) => { unrCalls.restrict++; unrCalls.lastDeny = (f && f.deny) || null; return () => { }; } },
449
- systemPrompt: { section: () => () => { } },
613
+ tools: { restrict: () => { unrCalls.restrict++; return () => { }; } },
614
+ systemPrompt: { section: (o) => { unrCalls.section = o; return () => { }; } },
450
615
  },
451
616
  inject: () => { throw new Error('未设角色的会话不应写历史'); },
452
617
  };
453
618
  for (const cb of listeners) cb({ agent: unrAgent, source: 'fresh' });
454
619
  ok(true, '未设角色的会话装配不应抛错');
455
- // 回归锁:ho_sessions(action="new") 是「先 create(触发 agent/created)→ 再写角色表」,
456
- // 出生时必然未设角色(判为普通)。若当场收窄,新会话会在角色写入前一直只有 0 个工具
457
- // (2026-10-24 全局9 接任实撞)。⇒ 未设角色出生不得当场 restrict,由延迟复核按真角色装配。
458
- ok(unrCalls.restrict === 0, '未设角色出生时不得当场收窄(等角色表到位再装)', String(unrCalls.restrict));
459
- // 延迟复核要真的「补装」,不能只是「什么都不做」:角色表随后写入 ⇒ 1.5 s 后应按真角色改造工具集。
460
- // 用独立的假会话(session-late),别占用后面 order 用例要用的「无角色」会话。
461
- const lateCalls = { restrict: 0, lastDeny: null };
462
- const lateAgent = {
463
- id: 'session-late',
464
- session: { header: { id: 'session-late', title: '随便', cwd: '/tmp/y' } },
465
- ctx: {
466
- tools: { restrict: (f) => { lateCalls.restrict++; lateCalls.lastDeny = (f && f.deny) || null; return () => { }; } },
467
- systemPrompt: { section: () => () => { } },
468
- },
469
- inject: () => { throw new Error('未设角色的会话不应写历史'); },
470
- };
471
- for (const cb of listeners) cb({ agent: lateAgent, source: 'fresh' });
472
- ok(lateCalls.restrict === 0, '未设角色出生时也不得对第二个会话当场收窄', String(lateCalls.restrict));
473
- writeRole({}, 'session-late', { role: 'project', project: 'Demo', source: 'user' });
474
- await new Promise((res) => setTimeout(res, 1700));
475
- ok(lateCalls.restrict === 1, '角色后到时延迟复核应补装真角色', String(lateCalls.restrict));
476
- ok(lateCalls.restrict === 1 && lateCalls.lastDeny && lateCalls.lastDeny.length === 13, '补装后 deny 名单应为项目角色的 13 个', JSON.stringify(lateCalls.lastDeny && lateCalls.lastDeny.length));
620
+ ok(unrCalls.restrict === 0, '按步路径不再用 restrict 收窄工具', String(unrCalls.restrict));
621
+ ok(!unrCalls.section, '普通会话不注册角色提示段');
622
+
623
+ // 开轮前核对:休眠期间改了角色,下一轮开始时补装提示段(工具由每步条件即时生效)。
624
+ writeRole({}, 'session-unr', { role: 'project', project: 'Demo', source: 'user' });
625
+ for (const cb of ((reg.listeners && reg.listeners['agent/status']) || [])) cb({ agent: unrAgent, status: 'running' });
626
+ ok(!!unrCalls.section && /项目/.test(String(unrCalls.section.text)), '开轮前核对应按新角色补装提示段', JSON.stringify(unrCalls.section && unrCalls.section.name));
477
627
 
628
+ // 旧内核(没有 tools.resolve)才走 restrict:创建时按普通收窄 24 个,改角色后开轮前换成 13 个。
629
+ {
630
+ const legacyCalls = { restrict: 0, lastDeny: null, disposed: 0 };
631
+ const legacyListeners = {};
632
+ const legacyTools = {
633
+ register: () => { },
634
+ restrict: (f) => { legacyCalls.restrict++; legacyCalls.lastDeny = (f && f.deny) || null; return () => { legacyCalls.disposed++; }; },
635
+ guard: () => { },
636
+ };
637
+ const legacyCtx = {
638
+ tools: legacyTools,
639
+ systemPrompt: { section: () => () => { } },
640
+ sessionController: {},
641
+ logger: { warn: () => { }, error: () => { }, info: () => { } },
642
+ effect: (fn) => fn(),
643
+ on: (n, cb) => { (legacyListeners[n] = legacyListeners[n] || []).push(cb); },
644
+ get: () => null,
645
+ inject: () => { },
646
+ };
647
+ apply(legacyCtx, {});
648
+ const legacyAgent = {
649
+ id: 'session-legacy',
650
+ session: { header: { id: 'session-legacy', cwd: '/tmp/l' } },
651
+ ctx: { tools: legacyTools, systemPrompt: legacyCtx.systemPrompt },
652
+ };
653
+ for (const cb of (legacyListeners['agent/created'] || [])) cb({ agent: legacyAgent, source: 'fresh' });
654
+ ok(legacyCalls.restrict === 1 && legacyCalls.lastDeny && legacyCalls.lastDeny.length === 24, '旧内核:创建时按普通收窄 24 个', JSON.stringify(legacyCalls.lastDeny && legacyCalls.lastDeny.length));
655
+ writeRole({}, 'session-legacy', { role: 'project', project: 'Demo', source: 'user' });
656
+ for (const cb of (legacyListeners['agent/status'] || [])) cb({ agent: legacyAgent, status: 'running' });
657
+ ok(legacyCalls.restrict === 2 && legacyCalls.lastDeny && legacyCalls.lastDeny.length === 13, '旧内核:开轮前换成项目角色的 13 个', JSON.stringify(legacyCalls.lastDeny && legacyCalls.lastDeny.length));
658
+ ok(legacyCalls.disposed === 1, '旧内核:换角色应撤销旧限制', String(legacyCalls.disposed));
659
+ }
478
660
  // ---------- 角色路由 ----------
479
661
  const noRole = await postJson('/dsh-handover/role', { session_id: 'session-r1', role: 'project' });
480
662
  ok(noRole.code === 400 && /项目名/.test(String(noRole.json && noRole.json.error)), '设项目角色缺项目名应 400', JSON.stringify(noRole.json));
@@ -496,9 +678,9 @@ ok(/order 需要 session_id/.test(oNoSid), 'order 缺 session_id 应报错', oNo
496
678
  const oBad = await orderCall({ action: 'order', order: 'wave', session_id: 'session-r1' });
497
679
  ok(/只能是 handover 或 compact/.test(oBad), 'order 取值应受限', oBad);
498
680
  // 交接/压缩指令必须先问用户
499
- const oNoConfirm = await orderCall({ action: 'order', order: 'handover', session_id: 'session-unr' });
681
+ const oNoConfirm = await orderCall({ action: 'order', order: 'handover', session_id: 'session-norole' });
500
682
  ok(/需要用户确认/.test(oNoConfirm), 'order 未确认应拒绝', oNoConfirm);
501
- const oUnr = await orderCall({ action: 'order', order: 'handover', session_id: 'session-unr', confirm: true });
683
+ const oUnr = await orderCall({ action: 'order', order: 'handover', session_id: 'session-norole', confirm: true });
502
684
  ok(/没有交接路径/.test(oUnr), '未设角色的会话不能下交接指令(避免退回全局交接)', oUnr);
503
685
  const oCold = await orderCall({ action: 'order', order: 'compact', session_id: 'session-r1', confirm: true });
504
686
  ok(/不在内存/.test(oCold), '休眠会话不能排压缩(compactNow 需要活体 agent)', oCold);
@@ -520,9 +702,9 @@ if (typeof reg.guardFn === 'function') {
520
702
  const gReport = reg.guardFn(guardExec('peer_send', { session_id: 'session-glob' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
521
703
  ok(gReport === undefined, '项目会话向全局报告应放行', String(gReport));
522
704
  const gPeer = reg.guardFn(guardExec('peer_send', { session_id: 'session-proj' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
523
- ok(typeof gPeer === 'string' && /唤醒权只在全局/.test(gPeer), '项目会话之间不得互相唤醒', String(gPeer));
705
+ ok(gPeer === undefined, '会话之间可直接对话,不必经全局转述', String(gPeer));
524
706
  const gNew = reg.guardFn(guardExec('peer_new', { workspace: 'Demo库' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
525
- ok(typeof gNew === 'string', '项目会话不得建平级会话', String(gNew));
707
+ ok(typeof gNew === 'string' && /仅属全局/.test(gNew), '项目会话不得建平级会话', String(gNew));
526
708
  const gMisc = reg.guardFn(guardExec('peer_new', { workspace: 'Misc' }, 'session-misc', path.join(WS, '_Misc', 'tasks', '抢名额'), '杂项1'));
527
709
  ok(gMisc === undefined, '杂项会话自建 Misc 换任应放行', String(gMisc));
528
710
  }
@@ -558,6 +740,7 @@ const FIXTURE = {
558
740
  generatedAt: '2026-11-07 21:25 +0800',
559
741
  plugin: { name: '@mingmaple/dsh-handover', version: 'test', tools: 22, inject: ['tools', 'systemPrompt'], initErrors: [], scheduler: '就绪' },
560
742
  workspace: { root: 'C:/ws', found: true },
743
+ globalSessions: 1,
561
744
  guardLast: { allow: true, source: 'title', title: '全局1', sid: 'session-x' },
562
745
  guide: payload ? payload.guide : { intro: ['说明'], situations: [{ when: 'w', say: 's', what: 'x' }], groups: [{ title: 'g', tools: [['ho_x', 'y']] }], rules: ['r'] },
563
746
  extraPrompt: '',
@@ -641,7 +824,7 @@ for (const want of ['dsh-handover', '刷新', '读数', '压缩授权', '会话
641
824
  // 回到重构前的版式——常用区直接展开,只折叠低频项;授权可直接点。
642
825
  const sections = tree.kids.filter((k) => k && k.type === 'section');
643
826
  const cardTitles = sections.map((sec) => textOf(sec.kids[0]).join(''));
644
- const wantOrder = ['压缩授权', '移动全局根目录', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '插件', '唤醒权守卫', '使用说明'];
827
+ const wantOrder = ['压缩授权', '移动全局根目录', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '插件', '会话间通信守卫', '使用说明'];
645
828
  ok(JSON.stringify(cardTitles) === JSON.stringify(wantOrder), '分区应自上而下为授权、迁移、角色、提示词、只读数据、插件、守卫、说明', JSON.stringify(cardTitles));
646
829
 
647
830
  const folds = treeJson.split('"details"').length - 1;
@@ -688,12 +871,12 @@ const clientSrc = fs.readFileSync(path.join(dist, 'client.js'), 'utf8');
688
871
  ok(!/[\u2705\u274C\u26A0\u2717]/.test(clientSrc), '界面不得使用图形结论符号');
689
872
  ok(clientSrc.indexOf('delWord.trim() !== "删除"') !== -1, '删除旧副本须输入确认词');
690
873
 
691
- // 未接入工作区:首次安装置顶。
874
+ // 未接入工作区:一键部署卡片置顶。
692
875
  const NOT_READY = {
693
876
  generatedAt: '2026-11-07 21:25 +0800',
694
877
  plugin: { name: '@mingmaple/dsh-handover', version: 'test', tools: 22, inject: ['tools'], initErrors: [], scheduler: '就绪' },
695
878
  workspace: { root: '', found: false },
696
- setup: { steps: ['准备一个长期存在的目录', '粘贴路径后点击「建立全局文件体系」'] },
879
+ setup: { steps: ['准备一个长期存在的目录', '粘贴路径后点「一键部署」'] },
697
880
  guide: payload ? payload.guide : null,
698
881
  notes: ['未找到 _Control_Center:这个实例还没接入工作区。'],
699
882
  };
@@ -704,8 +887,21 @@ const tree3 = Comp3({});
704
887
  const sections3 = tree3.kids.filter((k) => k && k.type === 'section');
705
888
  const titles3 = sections3.map((sec) => textOf(sec.kids[0]).join(''));
706
889
  ok(titles3[0] === '首次安装', '未接入工作区时首次安装置顶', JSON.stringify(titles3));
707
- ok(textOf(tree3).join(' ').indexOf('建立全局文件体系') !== -1, '未接入时应能建立文件体系');
708
- ok(textOf(tree3).join(' ').indexOf('待处理') !== -1, '未接入时应显示待处理项');
890
+ const text3 = textOf(tree3).join(' ');
891
+ ok(text3.indexOf('一键部署') !== -1, '未接入时应能一键部署(建体系+注册工作区+建全局会话)');
892
+ ok(text3.indexOf('仅建立文件体系') !== -1, '未接入时仍可只建立文件体系');
893
+ ok(text3.indexOf('待处理') !== -1, '未接入时应显示待处理项');
894
+
895
+ // 已接入但没有全局会话:给出补建入口,且排在常规卡片之前。
896
+ const READY_NO_GLOBAL = Object.assign({}, FIXTURE, { globalSessions: 0 });
897
+ const exp4 = loaded.factory((name) => (name === 'react' ? fakeReact(READY_NO_GLOBAL) : (() => { throw new Error('x'); })));
898
+ let Comp4 = null;
899
+ exp4.apply({ slots: { inject: (n, cb) => cb(), register: (o, c) => { Comp4 = c; } } });
900
+ const tree4 = Comp4({});
901
+ const sections4 = tree4.kids.filter((k) => k && k.type === 'section');
902
+ const titles4 = sections4.map((sec) => textOf(sec.kids[0]).join(''));
903
+ ok(titles4[0] === '全局会话', '已接入但无全局会话时应给出补建卡片', JSON.stringify(titles4.slice(0, 3)));
904
+ ok(textOf(tree4).join(' ').indexOf('创建全局会话') !== -1, '补建卡片应提供「创建全局会话」按钮');
709
905
 
710
906
  ok(styles.length === 1, '样式只注入一次(幂等)', String(styles.length));
711
907
 
@@ -809,6 +1005,20 @@ const delDeep = await mv.rmTree(path.join(mvRoot, 'deep'));
809
1005
  ok(delDeep.ok && !fs.existsSync(path.join(mvRoot, 'deep')), 'rmTree 应删掉超长路径树', JSON.stringify(delDeep.fails));
810
1006
  fs.rmSync(mvRoot, { recursive: true, force: true });
811
1007
 
1008
+ // 工作区与会话的显式化:create 支持 ensure;新建会话缺工作区必须报错并列出可选项。
1009
+ ctx.workspaceRegistry = { list: () => [], create: async (p, t) => ({ id: 'ws-test', title: t, path: p }) };
1010
+ const wsToolEnsure = reg.tools.find((t) => t.name === 'ho_workspace');
1011
+ const wsEnsureDir = path.join(BLANK, 'proj', 'Demo');
1012
+ const wsEnsureOut = await wsToolEnsure.execute({ action: 'create', path: wsEnsureDir, title: 'Demo', ensure: true });
1013
+ ok(/已注册工作区/.test(String(wsEnsureOut && wsEnsureOut.text)), 'ho_workspace create 应支持 ensure 建目录', String(wsEnsureOut && wsEnsureOut.text));
1014
+ ok(fs.existsSync(wsEnsureDir), 'ensure=true 应真的建出目录', wsEnsureDir);
1015
+ const sessToolWs = reg.tools.find((t) => t.name === 'ho_sessions');
1016
+ let newErr = '';
1017
+ try {
1018
+ await sessToolWs.execute({ action: 'new', confirm: true, title: '项目1' }, { agent: { session: { header: { id: 'session-x', cwd: BLANK } } } });
1019
+ } catch (e) { newErr = String(e && e.message ? e.message : e); }
1020
+ ok(/工作区/.test(newErr) && /已注册的工作区/.test(newErr), '新建会话缺工作区时应报错并列出可选工作区', newErr.slice(0, 160));
1021
+
812
1022
  fs.rmSync(BLANK, { recursive: true, force: true });
813
1023
  console.log('');
814
1024
  if (fails.length) {
package/scripts/build.mjs CHANGED
@@ -45,9 +45,12 @@ const distPkg = {
45
45
  description: rootPkg.description,
46
46
  license: rootPkg.license,
47
47
  exports: { ".": "./index.mjs", "./client": "./client.js" },
48
+ ...(rootPkg.engines ? { engines: rootPkg.engines } : {}),
48
49
  dsh: {
49
50
  bundle: { patch: "./cordis.patch.yml" },
50
51
  client: rootPkg.dsh && rootPkg.dsh.client ? rootPkg.dsh.client : { platform: "web" },
52
+ // 兼容声明随构建产物走:安装面读的是 dist/package.json,不是对外仓库的根清单。
53
+ ...(rootPkg.dsh && rootPkg.dsh.compatibility ? { compatibility: rootPkg.dsh.compatibility } : {}),
51
54
  },
52
55
  };
53
56
  fs.writeFileSync(path.join(dist, "package.json"), JSON.stringify(distPkg, null, 2) + "\n");
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 文档校验:工具名集合、旧数与旧术语、内部链接、版本、语体。
2
+ * 文档校验:工具名集合、旧数与旧术语、内部链接、版本、语体,以及版本说明的对外口吻。
3
3
  * 只读,不做任何改写。退出码 0 表示全部通过。
4
4
  */
5
5
  import fs from 'node:fs';
@@ -14,6 +14,8 @@ const readme = path.join(root, 'README.md');
14
14
  const files = [readme]
15
15
  .concat(listFiles(path.join(root, 'docs'), ['.md']))
16
16
  .filter((f) => fs.existsSync(f));
17
+ const releasesDir = path.join(root, 'docs', 'releases');
18
+ const releaseFiles = files.filter((f) => path.dirname(f) === releasesDir);
17
19
  const problems = [];
18
20
 
19
21
  // 1. README 长度(对外首页承载全部说明,上限放宽)
@@ -22,11 +24,30 @@ if (readmeLines > 200) problems.push('README 应不超过 200 行(当前 ' + r
22
24
 
23
25
  // 2. 旧数与旧术语
24
26
  const FORBIDDEN = ['21 个工具', '27 个工具', '未分类', '追加系统提示词', '4,028', '4.6 KB', '每库 24 行', 'CHANGELOG'];
27
+ // 审阅语境与内部用语:这些词只应出现在桌面审阅材料里,不能进对外文档与版本说明。
28
+ const REVIEW_WORDS = ['过目', '待验收', '待您', '待你', '请确认', '麻烦您', '麻烦你', '假宿主', '桩服务', '桩实现'];
25
29
  for (const file of files) {
26
30
  const text = fs.readFileSync(file, 'utf8');
27
31
  for (const w of FORBIDDEN) {
28
32
  if (text.indexOf(w) !== -1) problems.push(path.relative(root, file) + ' 仍含旧表述「' + w + '」');
29
33
  }
34
+ for (const w of REVIEW_WORDS) {
35
+ if (text.indexOf(w) !== -1) problems.push(path.relative(root, file) + ' 含审阅语境或内部用语「' + w + '」');
36
+ }
37
+ }
38
+
39
+ // 2.1 版本说明是对外发布正文:不出现二人体,且每个已发布版本都要有一份。
40
+ for (const file of releaseFiles) {
41
+ const text = fs.readFileSync(file, 'utf8');
42
+ const person = text.match(/[您你]/);
43
+ if (person) problems.push(path.relative(root, file) + ' 版本说明不应出现二人体(命中「' + person[0] + '」)');
44
+ if (!/^#\s+\S/.test(text)) problems.push(path.relative(root, file) + ' 版本说明应以一级标题开头');
45
+ }
46
+ {
47
+ const pkgVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
48
+ if (!fs.existsSync(path.join(releasesDir, pkgVersion + '.md'))) {
49
+ problems.push('缺少版本说明 docs/releases/' + pkgVersion + '.md(Release 正文从这里取)');
50
+ }
30
51
  }
31
52
 
32
53
  // 3. 工具名:文档里出现的 ho_* 与 peer_* 必须在 names.mjs 中
@@ -70,5 +91,5 @@ if (problems.length) {
70
91
  for (const p of problems) console.log(' - ' + p);
71
92
  process.exitCode = 1;
72
93
  } else {
73
- console.log('CHECK-DOCS OK(' + files.length + ' 份文档,工具名 ' + ALL_TOOL_NAMES.length + ' 个)');
94
+ console.log('CHECK-DOCS OK(' + files.length + ' 份文档,含版本说明 ' + releaseFiles.length + ' 份,工具名 ' + ALL_TOOL_NAMES.length + ' 个)');
74
95
  }