@mingmaple/dsh-handover 0.0.0-stage → 0.9.12

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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/docs/migrate.md +61 -0
  4. package/docs/troubleshoot.md +77 -0
  5. package/package.json +70 -5
  6. package/scripts/blank-dsh.mjs +203 -0
  7. package/scripts/blank-smoke.mjs +981 -0
  8. package/scripts/build.mjs +61 -0
  9. package/scripts/check-docs.mjs +74 -0
  10. package/scripts/e2e-migration.mjs +127 -0
  11. package/scripts/lib/register.mjs +103 -0
  12. package/src/client.js +945 -0
  13. package/src/cordis.patch.yml +16 -0
  14. package/src/index.mjs +726 -0
  15. package/src/lib/absrefs.mjs +112 -0
  16. package/src/lib/api.mjs +291 -0
  17. package/src/lib/archive.mjs +198 -0
  18. package/src/lib/brief.mjs +74 -0
  19. package/src/lib/charter.mjs +28 -0
  20. package/src/lib/config.mjs +37 -0
  21. package/src/lib/consent.mjs +84 -0
  22. package/src/lib/drift.mjs +65 -0
  23. package/src/lib/events.mjs +89 -0
  24. package/src/lib/extra-prompt.mjs +41 -0
  25. package/src/lib/facts.mjs +70 -0
  26. package/src/lib/gates.mjs +148 -0
  27. package/src/lib/guide.mjs +103 -0
  28. package/src/lib/handover-io.mjs +69 -0
  29. package/src/lib/lint.mjs +152 -0
  30. package/src/lib/manual.mjs +124 -0
  31. package/src/lib/mover.mjs +663 -0
  32. package/src/lib/names.mjs +33 -0
  33. package/src/lib/output.mjs +11 -0
  34. package/src/lib/pack-ops.mjs +332 -0
  35. package/src/lib/peer.mjs +260 -0
  36. package/src/lib/phase.mjs +36 -0
  37. package/src/lib/profile.mjs +204 -0
  38. package/src/lib/prompt.mjs +18 -0
  39. package/src/lib/registry.mjs +53 -0
  40. package/src/lib/retarget-helper.tmpl +46 -0
  41. package/src/lib/role.mjs +251 -0
  42. package/src/lib/rpc-carrier.mjs +65 -0
  43. package/src/lib/scaffold.mjs +118 -0
  44. package/src/lib/schedule.mjs +223 -0
  45. package/src/lib/session-create.mjs +42 -0
  46. package/src/lib/session-ops.mjs +408 -0
  47. package/src/lib/sessions.mjs +143 -0
  48. package/src/lib/sevenzip.mjs +48 -0
  49. package/src/lib/status.mjs +177 -0
  50. package/src/lib/text.mjs +101 -0
  51. package/src/lib/webroute.mjs +130 -0
  52. package/src/lib/workspace.mjs +23 -0
  53. package/src/lib/zstd-log.mjs +65 -0
  54. package/src/selftest.mjs +84 -0
  55. package/src/templates/Changelog.skeleton.md +10 -0
  56. package/src/templates/Charter.skeleton.md +53 -0
  57. package/src/templates/Handover.global.skeleton.md +56 -0
  58. package/src/templates/Prompt_Templates.md +96 -0
  59. package/src/templates/Runtime_Manual.md +166 -0
  60. package/src/templates/rules/Handover_Rules.md +44 -0
  61. package/src/templates/rules/Migration_Rules.md +32 -0
  62. package/src/templates/rules/Misc_Task_Standard.md +23 -0
  63. package/src/templates/rules/Writing_Rules.md +24 -0
  64. package/src/tools/ho_brief.mjs +57 -0
  65. package/src/tools/ho_changelog.mjs +59 -0
  66. package/src/tools/ho_charter.mjs +89 -0
  67. package/src/tools/ho_checkpoint.mjs +132 -0
  68. package/src/tools/ho_diag.mjs +87 -0
  69. package/src/tools/ho_drift.mjs +41 -0
  70. package/src/tools/ho_gate.mjs +69 -0
  71. package/src/tools/ho_handover.mjs +84 -0
  72. package/src/tools/ho_init.mjs +82 -0
  73. package/src/tools/ho_pack.mjs +38 -0
  74. package/src/tools/ho_phase.mjs +37 -0
  75. package/src/tools/ho_profile.mjs +105 -0
  76. package/src/tools/ho_sessions.mjs +115 -0
  77. package/src/tools/ho_snapshot.mjs +104 -0
  78. package/src/tools/ho_tokens.mjs +57 -0
  79. package/src/tools/ho_workspace.mjs +217 -0
  80. package/src/tools/peer_list.mjs +37 -0
  81. package/src/tools/peer_new.mjs +75 -0
  82. package/src/tools/peer_peek.mjs +41 -0
  83. package/src/tools/peer_rename.mjs +31 -0
  84. package/src/tools/peer_send.mjs +52 -0
  85. package/src/tools/peer_status.mjs +46 -0
@@ -0,0 +1,981 @@
1
+ /**
2
+ * 空白环境冒烟:不依赖任何真实工作区,验「装上就能跑」。
3
+ * 1) 宿主半在假 ctx 上 apply:22 个工具、手册段、状态路由(200/404/405/HEAD)、只读工具不抛
4
+ * 2) 浏览器半在假 window/React 上装载:bundle 外形、settings.section 注册、成体视图能渲染
5
+ * 用法:node scripts/blank-smoke.mjs [--dist <dist 目录>]
6
+ */
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
+ import { listFiles, scanFiles } from './lib/register.mjs';
12
+
13
+ const here = path.dirname(fileURLToPath(import.meta.url));
14
+ const repo = path.resolve(here, '..');
15
+ const argIdx = process.argv.indexOf('--dist');
16
+ const dist = argIdx !== -1 ? path.resolve(process.argv[argIdx + 1]) : path.join(repo, 'dist');
17
+
18
+ const fails = [];
19
+ const ok = (cond, msg, extra) => { if (!cond) fails.push(msg + (extra ? ' → ' + extra : '')); };
20
+
21
+ // 空白 HOME:没有 workspace.json、没有 _Control_Center
22
+ const BLANK = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-handover-blank-'));
23
+ process.env.USERPROFILE = BLANK;
24
+ process.env.HOME = BLANK;
25
+
26
+ const { apply, schedDepsFor } = await import(pathToFileURL(path.join(dist, 'index.mjs')).href);
27
+ const reg = { tools: [], sections: [], logs: [], listeners: {} };
28
+ const ctx = {
29
+ tools: { register: (t, o) => { reg.tools.push(t); (reg.toolOpts = reg.toolOpts || []).push(o); }, restrict() { }, resolve: () => ({}), guard: (fn) => { reg.guardFn = fn; } },
30
+ systemPrompt: { section: (s) => { reg.sections.push(s); return () => { reg.disposed = (reg.disposed || 0) + 1; }; } },
31
+ sessionController: {
32
+ create: async () => ({ sessionId: 'session-deploy-test' }),
33
+ rename: async () => { },
34
+ prompt: async () => ({ accepted: true }),
35
+ },
36
+
37
+ logger: { warn: (m) => reg.logs.push(String(m)), error: (m) => reg.logs.push(String(m)), info() { } },
38
+ effect: (fn) => fn(),
39
+ on: (n, cb) => { (reg.listeners[n] = reg.listeners[n] || []).push(cb); },
40
+ get: () => null,
41
+ inject: (names, cb) => { (reg.injects = reg.injects || []).push({ names, cb }); reg.injectNames = names; reg.injectCb = cb; },
42
+ };
43
+ apply(ctx, {});
44
+
45
+ ok(reg.tools.length === 22, '工具数应为 22', String(reg.tools.length));
46
+ ok(reg.sections.length === 0, '未开启全局前置提示词时不得注册提示段', String(reg.sections.length));
47
+ ok(reg.tools.filter((t) => !t || !t.name || !t.description || !t.execute).length === 0, '每个工具都要有 name/description/execute');
48
+ // 上下文成本守门:工具描述是每次请求都要付的固定开销,单条不得超过预算(加权 token)。
49
+ const budget = await import(pathToFileURL(path.join(dist, 'lib', 'facts.mjs')).href);
50
+ const fatDesc = reg.tools.filter((t) => budget.estimateTokens(t.description) > budget.TOKEN_BUDGET.toolDescTokens).map((t) => t.name);
51
+ ok(fatDesc.length === 0, '单条工具描述应不超过 ' + budget.TOKEN_BUDGET.toolDescTokens + ' token(上下文成本)', fatDesc.join(','));
52
+ ok(reg.tools.filter((t) => t.name.startsWith('ho_')).length === 16, 'ho_* 应为 16 个', String(reg.tools.filter((t) => t.name.startsWith('ho_')).length));
53
+ ok(reg.tools.filter((t) => t.name.startsWith('peer_')).length === 6, 'peer_* 应为 6 个');
54
+ // 每步条件启用:内核 0.2.2 起工具按「步」解析,子会话在解析工具表这一步就要被排除,
55
+ // 否则它的首轮提示词会带着 22 个用不了的 schema(每步白付约 2.5k token)。
56
+ ok((reg.toolOpts || []).length === 22 && (reg.toolOpts || []).every((o) => o && typeof o.when === 'function'), '22 个工具都应带每步启用条件', String((reg.toolOpts || []).length));
57
+
58
+ /** 取某个工具的每步启用条件(0.2.2 起工具按步解析)。 */
59
+ const gateFor = (name) => {
60
+ const i = (reg.tools || []).findIndex((t) => t && t.name === name);
61
+ return i >= 0 && reg.toolOpts && reg.toolOpts[i] ? reg.toolOpts[i].when : null;
62
+ };
63
+
64
+ {
65
+ // 每步条件同时承担子会话排除与角色过滤:调用方每步重算工具表,换角色下一轮即生效。
66
+ const gate = gateFor('ho_brief');
67
+ ok(typeof gate === 'function', '每步启用条件应可调用');
68
+ if (typeof gate === 'function') {
69
+ ok(gate({}) === true, '没有 agent 的调用应放行(命令行与演练路径)');
70
+ ok(gate({ agent: { session: { header: { id: 'session-sub', origin: 'subagent' } } } }) === false, '子会话的工具条件应拒绝');
71
+ ok(gate({ agent: { session: { header: { id: 'session-child', parentSession: 'session-root' } } } }) === false, '带父会话的会话也应拒绝');
72
+ ok(gate({ agent: { session: { header: { id: 'session-plain', cwd: path.join(BLANK, 'nowhere') } } } }) === false, '未登记角色的会话按普通处理(0 个插件工具)');
73
+ const roleMod = await import(pathToFileURL(path.join(dist, 'lib', 'role.mjs')).href);
74
+ roleMod.writeRole({}, 'session-boss', { role: 'global' });
75
+ ok(gate({ agent: { session: { header: { id: 'session-boss', cwd: path.join(BLANK, 'nowhere') } } } }) === true, '全局角色应可见 ho_brief');
76
+ const gatePlain = gateFor('ho_phase');
77
+ ok(typeof gatePlain === 'function' && gatePlain({ agent: { session: { header: { id: 'session-boss', cwd: path.join(BLANK, 'nowhere') } } } }) === true, '全局角色应可见 ho_phase');
78
+ ok(gatePlain({ agent: { session: { header: { id: 'session-plain', cwd: path.join(BLANK, 'nowhere') } } } }) === false, '普通角色不可见 ho_phase');
79
+ }
80
+ }
81
+ // 可选注入多了 connection(/api 载体)。两个都必须是**可选**的,否则旧宿主整插件不激活。
82
+ const injectNames = (reg.injects || []).map((i) => i.names.join(','));
83
+ ok(injectNames.some((n) => n.indexOf('webServer') !== -1), '应把 webServer 作为可选注入', injectNames.join(' | '));
84
+ ok(injectNames.some((n) => n.indexOf('connection') !== -1), '应把 connection 作为可选注入', injectNames.join(' | '));
85
+ // 多了 commands(/allow-compact 聊天页命令),同样必须是可选的。
86
+ ok((reg.injects || []).every((i) => i.names.every((n) => ['webServer', 'connection', 'commands'].indexOf(n) !== -1)), '可选注入只允许 webServer/connection/commands', injectNames.join(' | '));
87
+ ok(injectNames.some((n) => n.indexOf('commands') !== -1), '应把 commands 作为可选注入(/allow-compact)', injectNames.join(' | '));
88
+
89
+ let route = null;
90
+ const wsInject = (reg.injects || []).find((i) => i.names.indexOf('webServer') !== -1);
91
+ ok(!!wsInject, '应有 webServer 的可选注入');
92
+ wsInject.cb({ webServer: { register: (r) => { route = r; return () => { }; } }, effect: (fn) => fn() });
93
+ ok(route && route.kind === 'prefix' && route.path === '/dsh-handover', '状态路由挂在 /dsh-handover');
94
+
95
+ // ---------- /api 载体(主通道:GUI 只信这个,裸 fetch 拿不到数据)
96
+ let carrier = null;
97
+ const connInject = (reg.injects || []).find((i) => i.names.indexOf('connection') !== -1);
98
+ ok(!!connInject, '应有 connection 的可选注入');
99
+ connInject.cb({
100
+ connection: { fetch: { register: (spec) => { carrier = spec; return () => { }; } }, rpc: { call: async () => ({}) } },
101
+ effect: (fn) => fn(),
102
+ });
103
+ ok(carrier && carrier.path === '/api/dsh-handover', '载体路径应为 /api/dsh-handover', carrier && carrier.path);
104
+ ok(carrier && Array.isArray(carrier.methods) && carrier.methods.indexOf('POST') !== -1, '载体应只收 POST');
105
+ ok(carrier && carrier.requestBody === 'buffered', '载体应声明 requestBody=buffered', carrier && carrier.requestBody);
106
+
107
+ async function rpcCall(method, payload) {
108
+ const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'dsh-handover', payload: { method, payload: payload || {} } });
109
+ const req = new Request('http://127.0.0.1/api/dsh-handover', { method: 'POST', headers: { 'content-type': 'application/json' }, body });
110
+ const res = await carrier.fetch(req);
111
+ return res.json();
112
+ }
113
+ const rpcStatus = await rpcCall('status', {});
114
+ ok(rpcStatus && rpcStatus.type === 'server-response' && rpcStatus.rpcId === 'r1', '载体应回 server-response 信封', JSON.stringify(rpcStatus && rpcStatus.type));
115
+ ok(rpcStatus && rpcStatus.result && rpcStatus.result.ok === true, '载体 status 应成功', JSON.stringify(rpcStatus && rpcStatus.result && rpcStatus.result.error));
116
+ ok(rpcStatus && rpcStatus.result && rpcStatus.result.value && rpcStatus.result.value.plugin, '载体 status 应带 plugin 段');
117
+ const rpcMove = await rpcCall('move.status', {});
118
+ ok(rpcMove && rpcMove.result && rpcMove.result.ok === true && rpcMove.result.value && typeof rpcMove.result.value.phase === 'string', '载体 move.status 应成功', JSON.stringify(rpcMove && rpcMove.result && rpcMove.result.error));
119
+ const rpcBad = await rpcCall('nope.nope', {});
120
+ ok(rpcBad && rpcBad.result && rpcBad.result.ok === false && /未知方法/.test(String(rpcBad.result.error.message)), '载体未知方法应回 ok:false', JSON.stringify(rpcBad && rpcBad.result));
121
+ const rpcBadEnv = await carrier.fetch(new Request('http://127.0.0.1/api/dsh-handover', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"type":"x"}' }));
122
+ const badEnv = await rpcBadEnv.json();
123
+ ok(badEnv && badEnv.result && badEnv.result.ok === false && /信封不合法/.test(String(badEnv.result.error.message)), '载体应拒非法信封', JSON.stringify(badEnv && badEnv.result));
124
+ const rpcGet = await carrier.fetch(new Request('http://127.0.0.1/api/dsh-handover', { method: 'GET' }));
125
+ ok(rpcGet.status === 405, '载体 GET 应 405', String(rpcGet.status));
126
+
127
+ // 一键部署:没有 workspaceRegistry 时仍要建体系并建会话,并把「工作区未注册」如实报出。
128
+ const deployParent = path.join(BLANK, 'deploy');
129
+ const rpcDeploy = await rpcCall('deploy', { parent: deployParent });
130
+ const dv = rpcDeploy && rpcDeploy.result && rpcDeploy.result.ok ? rpcDeploy.result.value : null;
131
+ ok(!!dv, 'deploy 应成功', JSON.stringify(rpcDeploy && rpcDeploy.result && rpcDeploy.result.error));
132
+ if (dv) {
133
+ ok(dv.sessionId === 'session-deploy-test', 'deploy 应建出全局会话', String(dv.sessionId));
134
+ ok(fs.existsSync(path.join(dv.root, '_Control_Center', 'records', 'Global_Handover.md')), 'deploy 应在指定路径铺好文件体系', String(dv.root));
135
+ ok(!dv.workspace && /workspaceRegistry/.test(String(dv.workspaceError || '')), '无 workspaceRegistry 时应如实报告工作区未注册', JSON.stringify({ w: dv.workspace, e: dv.workspaceError }));
136
+ }
137
+
138
+ async function call(method, url) {
139
+ const res = { code: 0, headers: null, body: undefined, writeHead(c, h) { this.code = c; this.headers = h; }, end(b) { this.body = b; } };
140
+ await route.handler({ method, url }, res);
141
+ return res;
142
+ }
143
+ const r1 = await call('GET', '/dsh-handover/status.json');
144
+ ok(r1.code === 200, 'GET status.json 应 200', String(r1.code));
145
+ let payload = null;
146
+ try { payload = JSON.parse(r1.body); } catch (e) { fails.push('返回不是 JSON:' + e.message); }
147
+ if (payload) {
148
+ ok(payload.plugin.version && payload.plugin.version !== '?', '应报出版本号', String(payload.plugin.version));
149
+ ok(payload.plugin.tools === 22, 'payload 工具数应为 22', String(payload.plugin.tools));
150
+ ok(payload.workspace && payload.workspace.found === false, '空白 HOME 下应判定为没找到工作区', JSON.stringify(payload.workspace));
151
+ ok(Array.isArray(payload.notes) && payload.notes.length > 0, '没找到工作区时应给中文提示');
152
+ ok(payload.guide && Array.isArray(payload.guide.situations) && payload.guide.situations.length >= 8, 'payload 应带「怎么用」的时机表');
153
+ 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)));
154
+ ok(payload.plugin.initErrors && payload.plugin.initErrors.length === 0, '初始化错误应为空', JSON.stringify(payload.plugin.initErrors));
155
+ }
156
+ ok((await call('GET', '/dsh-handover/nope.json')).code === 404, '未知路径应 404');
157
+ ok((await call('POST', '/dsh-handover/status.json')).code === 405, 'POST 应 405');
158
+ const head = await call('HEAD', '/dsh-handover/status.json');
159
+ ok(head.code === 200 && head.body === undefined, 'HEAD 应 200 且无 body');
160
+
161
+ // ---------- 首次安装向导 ----------
162
+ const { syncCheck } = await import(pathToFileURL(path.join(dist, 'lib', 'gates.mjs')).href);
163
+ const { lintGlobal } = await import(pathToFileURL(path.join(dist, 'lib', 'lint.mjs')).href);
164
+
165
+ /** 模拟带 JSON body 的 POST。 */
166
+ async function postJson(url, payload, method = 'POST') {
167
+ const listeners = {};
168
+ const req = {
169
+ method,
170
+ url,
171
+ on: (ev, cb) => { (listeners[ev] = listeners[ev] || []).push(cb); },
172
+ destroy: () => { },
173
+ };
174
+ const res = { code: 0, body: undefined, writeHead(c) { this.code = c; }, end(b) { this.body = b; } };
175
+ const pending = route.handler(req, res);
176
+ setTimeout(() => {
177
+ const buf = Buffer.from(JSON.stringify(payload || {}), 'utf8');
178
+ (listeners.data || []).forEach((f) => f(buf));
179
+ (listeners.end || []).forEach((f) => f());
180
+ }, 0);
181
+ await pending;
182
+ let json = null;
183
+ try { json = JSON.parse(res.body); } catch (e) { json = null; }
184
+ return { code: res.code, json };
185
+ }
186
+
187
+ const PARENT = path.join(BLANK, 'mother');
188
+ fs.mkdirSync(PARENT, { recursive: true });
189
+ const s1 = await postJson('/dsh-handover/setup', { parent: PARENT });
190
+ ok(s1.code === 200 && s1.json && s1.json.ok, '建全局根:应成功', JSON.stringify(s1.json));
191
+ const root = s1.json && s1.json.root;
192
+ ok(!!root && fs.existsSync(root), '建全局根:目录应存在', String(root));
193
+ ok(fs.existsSync(path.join(root, '_Control_Center', 'records', 'Global_Handover.md')), '建全局根:交接骨架应在');
194
+ ok(fs.existsSync(path.join(root, '_Control_Center', 'rules', 'Workspace_Charter.md')), '建全局根:Charter 骨架应在');
195
+ ok(fs.existsSync(path.join(root, '_Control_Center', 'templates', 'Prompt_Templates.md')), '建全局根:提示词模板应在');
196
+ ok(fs.existsSync(path.join(root, 'Projects')) && fs.existsSync(path.join(root, '_Project_Meta')), '建全局根:项目目录应在');
197
+
198
+ // cwd 已失效(旧路径被删)时,根解析必须退回登记表,不能在幽灵路径上重建 _Control_Center
199
+ if (root) {
200
+ const regDir0 = path.join(BLANK, '.dsh', 'storages');
201
+ fs.mkdirSync(regDir0, { recursive: true });
202
+ fs.writeFileSync(path.join(regDir0, 'workspace.json'), JSON.stringify({ tables: { workspaces: { w9: { id: 'w9', title: '全局', path: root } } } }, null, 2));
203
+ const wsMod = await import(pathToFileURL(path.join(dist, 'lib', 'workspace.mjs')).href);
204
+ const picked = wsMod.routeWorkspace({ cfg: {} });
205
+ ok(picked === root, 'cwd 失效时根解析应退回登记表里的工作区', String(picked));
206
+ }
207
+
208
+ if (root) {
209
+ const g = syncCheck(root);
210
+ ok(g.ok === true && g.bodyLines === 24, '骨架上的闸门应通过且正文为 24 行', JSON.stringify({ ok: g.ok, bodyLines: g.bodyLines, total: g.total }));
211
+ const lh = lintGlobal(path.join(root, '_Control_Center', 'records', 'Global_Handover.md'));
212
+ ok(lh.verdict === '通过', '骨架交接应通过全局体检', JSON.stringify(lh).slice(0, 160));
213
+ }
214
+
215
+ const s2 = await postJson('/dsh-handover/setup', { parent: PARENT });
216
+ ok(s2.code === 400 && s2.json && s2.json.ok === false, '目标已存在时应拒绝且不改动', JSON.stringify(s2.json && s2.json.error));
217
+ // 目标父目录不存在就自建(用户自选路径是合法需求)
218
+ const AUTO_PARENT = path.join(BLANK, 'auto-parent', 'nested');
219
+ const s3 = await postJson('/dsh-handover/setup', { parent: AUTO_PARENT });
220
+ ok(s3.code === 200 && s3.json && s3.json.ok === true, '父目录不存在时应自建并成功', JSON.stringify(s3.json && s3.json.error));
221
+ ok(fs.existsSync(path.join(AUTO_PARENT, '_Workspace', '_Control_Center', 'records')), '应在 <父目录>/_Workspace 下铺好文件体系');
222
+ const notADir = path.join(BLANK, 'a-file.txt');
223
+ fs.writeFileSync(notADir, 'x');
224
+ const sFile = await postJson('/dsh-handover/setup', { parent: notADir });
225
+ ok(sFile.code === 400 && sFile.json && !sFile.json.ok, '父路径是文件时应报错', JSON.stringify(sFile.json && sFile.json.error));
226
+ // ---------- 全局前置提示词 ----------
227
+ const EXTRA_NAME = 'dsh-handover:extra-prompt';
228
+ const e1 = await postJson('/dsh-handover/extra-prompt', { text: ' 测试用全局前置提示词 ', enabled: true });
229
+ ok(e1.code === 200 && e1.json && e1.json.ok && e1.json.chars > 0 && e1.json.enabled === true, '全局前置提示词:开启并保存应成功', JSON.stringify(e1.json));
230
+ const section = reg.sections.filter((x) => x.name === EXTRA_NAME).pop();
231
+ ok(!!section, '全局前置提示词:应注册为系统提示段');
232
+ ok(section && section.order === 950, '全局前置提示词:段序应 950(工具段 1000 之前)', section && String(section.order));
233
+ ok(section && section.interpolate === false, '全局前置提示词:应原样保留(interpolate:false)');
234
+ ok(section && section.text === '测试用全局前置提示词', '全局前置提示词:应去掉首尾空白', section && JSON.stringify(section.text));
235
+ const st2 = await call('GET', '/dsh-handover/status.json');
236
+ let payload2 = null;
237
+ try { payload2 = JSON.parse(st2.body); } catch (e) { /* ignore */ }
238
+ ok(payload2 && payload2.extraPrompt === '测试用全局前置提示词' && payload2.extraPromptEnabled === true, '全局前置提示词:状态页应读回内容与开关', payload2 && JSON.stringify({ t: payload2.extraPrompt, e: payload2.extraPromptEnabled }));
239
+ const e2 = await postJson('/dsh-handover/extra-prompt', { text: '测试用全局前置提示词', enabled: false });
240
+ ok(e2.code === 200 && e2.json && e2.json.ok && e2.json.enabled === false && e2.json.registered === false, '全局前置提示词:关闭后应释放提示段', JSON.stringify(e2.json));
241
+ ok(reg.disposed >= 1, '全局前置提示词:关闭时应释放上一段', String(reg.disposed));
242
+ const st3 = await call('GET', '/dsh-handover/status.json');
243
+ let payload3 = null;
244
+ try { payload3 = JSON.parse(st3.body); } catch (e) { /* ignore */ }
245
+ ok(payload3 && payload3.extraPromptEnabled === false, '全局前置提示词:关闭后状态页应显示未开启');
246
+ const e3 = await postJson('/dsh-handover/extra-prompt', { text: ' ', enabled: true });
247
+ ok(e3.code === 200 && e3.json && e3.json.ok && e3.json.chars === 0 && e3.json.enabled === false, '全局前置提示词:空内容不得开启', JSON.stringify(e3.json));
248
+
249
+ const s4 = await postJson('/dsh-handover/register', { path: root || PARENT });
250
+ ok(s4.code === 501 && s4.json && s4.json.ok === false && String(s4.json.hint || '').indexOf(root || PARENT) !== -1, '无 workspaceRegistry 时应回退给「手工粘贴路径」的提示', JSON.stringify(s4.json));
251
+
252
+ const initTool = reg.tools.find((t) => t.name === 'ho_init');
253
+ ok(!!initTool, '应注册 ho_init(首次安装工具)');
254
+ if (initTool) {
255
+ const PARENT2 = path.join(BLANK, 'mother2');
256
+ fs.mkdirSync(PARENT2, { recursive: true });
257
+ const ri = await initTool.execute({ parent: PARENT2 }, null);
258
+ ok(/已建立全局文件体系/.test(ri.text), 'ho_init 应建树并报出路径', ri.text.split('\n')[0]);
259
+ const ri2 = await initTool.execute({ parent: PARENT2 }, null);
260
+ ok(/未建立/.test(ri2.text), 'ho_init 对非空目标应拒绝且不改动', ri2.text.split('\n')[0]);
261
+ }
262
+
263
+ const readonly = ['ho_diag', 'ho_phase', 'ho_brief', 'ho_sessions', 'ho_gate', 'ho_charter', 'ho_drift', 'ho_workspace'];
264
+ for (const t of reg.tools) {
265
+ if (readonly.indexOf(t.name) === -1) continue;
266
+ try {
267
+ const args = t.name === 'ho_gate' ? { scope: 'all' } : t.name === 'ho_workspace' ? { action: 'list' } : t.name === 'ho_charter' ? { doc: 'runtime' } : {};
268
+ const out = await t.execute(args, null);
269
+ ok(out && typeof out.text === 'string' && out.text.length > 0, t.name + ' 在空白环境应返回文本');
270
+ } catch (e) {
271
+ fails.push(t.name + ' 在空白环境抛错:' + (e && e.message ? e.message : e));
272
+ }
273
+ }
274
+
275
+ // ---------- 角色:判定、路径、工具集、续跑文案、按角色装配 ----------
276
+ const DIST = (rel) => pathToFileURL(path.join(dist, rel)).href;
277
+ const { resolveRole, handoverPathFor, toolsetFor, writeRole, readRoles } = await import(DIST('lib/role.mjs'));
278
+ const { fillResumePrompt } = await import(DIST('lib/schedule.mjs'));
279
+ const { resolveConfig } = await import(DIST('lib/config.mjs'));
280
+
281
+ // 建一个像样的工作区,好让 cwd 推导走到 _Control_Center
282
+ const WS = path.join(BLANK, 'ws');
283
+ fs.mkdirSync(path.join(WS, '_Control_Center'), { recursive: true });
284
+ fs.mkdirSync(path.join(WS, 'Projects', 'Demo', 'Demo库'), { recursive: true });
285
+ fs.mkdirSync(path.join(WS, '_Misc', 'tasks', '抢名额'), { recursive: true });
286
+
287
+ const rTitle = resolveRole({ sessionId: 'session-t1', title: '全局9' });
288
+ ok(rTitle.role === 'global' && rTitle.source === 'title', '角色判定:标题含「全局」→ global', JSON.stringify(rTitle));
289
+ const rCwd = resolveRole({ sessionId: 'session-t2', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库') });
290
+ ok(rCwd.role === 'project' && rCwd.project === 'Demo', '角色判定:cwd 在 Projects/<名>/ → project', JSON.stringify(rCwd));
291
+ const rMisc = resolveRole({ sessionId: 'session-t3', title: '杂项1', cwd: path.join(WS, '_Misc', 'tasks', '抢名额') });
292
+ ok(rMisc.role === 'misc' && rMisc.task === '抢名额', '角色判定:cwd 在 _Misc/tasks/<任务>/ → misc', JSON.stringify(rMisc));
293
+ const rPrompt = resolveRole({ sessionId: 'session-t4', title: '', firstPrompt: '先看 ' + path.join(WS, 'Projects', 'Study', 'Study库') + ' 里的内容' });
294
+ ok(rPrompt.role === 'project' && rPrompt.project === 'Study', '角色判定:首条提示里的 Projects/<名> → project', JSON.stringify(rPrompt));
295
+ const rPlain = resolveRole({ sessionId: 'session-t5', title: '随便', cwd: '/tmp' });
296
+ ok(rPlain.role === 'plain', '角色判定:都不中 → plain(普通会话)', JSON.stringify(rPlain));
297
+
298
+ writeRole({}, 'session-t6', { role: 'misc', task: 'X', source: 'user' });
299
+ const rStore = resolveRole({ sessionId: 'session-t6', title: '全局10' });
300
+ ok(rStore.role === 'misc' && rStore.source === 'store', '角色表优先于标题推断', JSON.stringify(rStore));
301
+ ok(readRoles({}).sessions['session-t6'] && readRoles({}).sessions['session-t6'].task === 'X', '角色表应可读回');
302
+
303
+ // 回归锁:首条提示里的 `Projects/**` 通配曾被当成项目名 `**、_Project_Meta`,
304
+ // 于是交接路径、备份目录、归档目录全落在这个 Windows 非法名字上(实测四类报错)。
305
+ 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/** 前先问用户。' });
306
+ ok(rGlob.role === 'plain', '首条提示里的 Projects/** 通配不得被当成项目名', JSON.stringify(rGlob));
307
+ const hpBad = handoverPathFor(WS, { role: 'project', project: '**、_Project_Meta' });
308
+ ok(hpBad.path === null && /不合法/.test(String(hpBad.reason || '')), '非法项目名不得拼出交接路径', JSON.stringify(hpBad));
309
+ {
310
+ let badWrite = '';
311
+ try { writeRole({}, 'session-bad-name', { role: 'project', project: '**、_Project_Meta' }); } catch (e) { badWrite = e.message; }
312
+ ok(/不合法/.test(badWrite), '角色表不得写入含非法字符的项目名', badWrite);
313
+ ok(readRoles({}).sessions['session-bad-name'] === undefined, '非法项目名不得落盘', JSON.stringify(readRoles({}).sessions['session-bad-name']));
314
+ }
315
+
316
+ const hpG = handoverPathFor(WS, { role: 'global' });
317
+ ok(/Global_Handover\.md$/.test(hpG.path), '全局交接路径', hpG.path);
318
+ const hpP = handoverPathFor(WS, { role: 'project', project: 'Demo' });
319
+ ok(/_Project_Meta/.test(hpP.path) && /Demo/.test(hpP.path) && /Handover\.md$/.test(hpP.path), '项目交接路径应落 _Project_Meta/<项目>/Handover.md', hpP.path);
320
+ const hpM = handoverPathFor(WS, { role: 'misc', task: '抢名额' });
321
+ ok(/_Misc/.test(hpM.path) && /抢名额/.test(hpM.path) && /Handover\.md$/.test(hpM.path), '杂项交接路径应落 _Misc/tasks/<任务>/Handover.md', hpM.path);
322
+ const hpU = handoverPathFor(WS, { role: 'plain' });
323
+ ok(hpU.path === null && !!hpU.reason, '普通会话不得给出交接路径(不退回全局)', JSON.stringify(hpU));
324
+
325
+ const tsG = toolsetFor('global');
326
+ ok(tsG.allow.length === 22 && tsG.deny.length === 0, '全局应可见全部 22 个工具', JSON.stringify([tsG.allow.length, tsG.deny.length]));
327
+ const tsP = toolsetFor('project');
328
+ ok(tsP.allow.length === 9, '项目角色应可见 9 个工具', String(tsP.allow.length));
329
+ ok(tsP.allow.indexOf('ho_handover') !== -1 && tsP.allow.indexOf('ho_checkpoint') !== -1, '项目角色必须能写自己的交接与压缩');
330
+ ok(tsP.allow.indexOf('ho_pack') === -1 && tsP.allow.indexOf('ho_sessions') === -1, '项目角色不该看见全局管理工具');
331
+ ok(tsP.deny.indexOf('ho_handover') === -1 && tsP.deny.indexOf('run_code') === -1, 'deny 名单不得含保留名或本角色已允许的工具');
332
+ const tsM = toolsetFor('misc');
333
+ 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));
334
+ const tsU = toolsetFor('plain');
335
+ ok(tsU.allow.length === 0 && tsU.deny.length === 22, '普通会话不载入任何插件工具', JSON.stringify([tsU.allow.length, tsU.deny.length]));
336
+ const rSub = resolveRole({ sessionId: 'session-sub', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库'), subagent: true });
337
+ ok(rSub.role === 'plain' && rSub.source === 'subagent', '子会话默认按普通处理,不按 cwd 判项目', JSON.stringify(rSub));
338
+
339
+ // ---------- 骨架档案:预设、默认骨架、注册、校验 ----------
340
+ const prof = await import(DIST('lib/profile.mjs'));
341
+ const pjs = prof.presets();
342
+ ok(Object.keys(pjs).length === 4 && pjs.application && pjs.animation && pjs.library && pjs.general, '应提供 application、animation、library、general 四套预设');
343
+ const dflt = prof.readProfile(WS, { role: 'project', project: 'Demo' });
344
+ ok(dflt.exists === false && dflt.profile.root === 'Projects/Demo', '未注册项目应回默认骨架', JSON.stringify(dflt.profile));
345
+ const metaDir = path.join(WS, '_Project_Meta', 'Demo');
346
+ fs.mkdirSync(metaDir, { recursive: true });
347
+ fs.writeFileSync(path.join(metaDir, 'Project_Overview.md'), 'x');
348
+ fs.writeFileSync(path.join(metaDir, 'Handover.md'), '# x');
349
+ fs.writeFileSync(path.join(WS, 'Projects', 'Demo', 'README.md'), 'x');
350
+ const pf = { schema: 1, kind: 'application', root: 'Projects/Demo', required: ['README.md'], optional: ['SKILL.md'], docs: [], meta: ['Project_Overview.md', 'Handover.md'], gate: { libraryBlock: false }, handover: { extraSections: [] } };
351
+ fs.writeFileSync(prof.profilePathFor(WS, { role: 'project', project: 'Demo' }), JSON.stringify(pf, null, 2));
352
+ const rd = prof.readProfile(WS, { role: 'project', project: 'Demo' });
353
+ ok(rd.exists && rd.profile.kind === 'application', '应读回已写骨架', JSON.stringify(rd.profile));
354
+ const pv = prof.validateProfile(WS, { role: 'project', project: 'Demo' }, rd.profile);
355
+ ok(pv.ok, '骨架校验应通过', JSON.stringify(pv));
356
+ const badPf = prof.normalizeProfile({ ...pf, required: ['../x'] }, { role: 'project', project: 'Demo' });
357
+ ok(badPf.errors.length > 0, '骨架不应接受 .. 路径', JSON.stringify(badPf.errors));
358
+ const profTool = reg.tools.find((t) => t.name === 'ho_profile');
359
+ ok(!!profTool, '应注册 ho_profile');
360
+ if (profTool) {
361
+ const profExec = { agent: { session: { header: { id: 'session-prof', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库'), title: 'Demo1' } } } };
362
+ const pres = await profTool.execute({ action: 'presets' }, profExec);
363
+ ok(/application/.test(pres.text) && /animation/.test(pres.text), 'ho_profile 应返回预设');
364
+ const rr = await profTool.execute({ action: 'read' }, profExec);
365
+ ok(/已注册/.test(rr.text) && /application/.test(rr.text), 'ho_profile read 应读到已注册骨架');
366
+ const vv = await profTool.execute({ action: 'validate' }, profExec);
367
+ ok(/结论:通过/.test(vv.text), 'ho_profile validate 应通过', vv.text.slice(0, 120));
368
+ // 项目会话只处理自己的骨架:传别的项目名要报错,不能静默忽略入参(实测踩过)。
369
+ let crossErr = '';
370
+ try { await profTool.execute({ action: 'validate', project: '学习' }, profExec); } catch (e) { crossErr = e.message; }
371
+ ok(/只能读写自己的骨架/.test(crossErr), '项目会话传别的项目名应明确报错', crossErr);
372
+ }
373
+
374
+ // 回归锁:压缩调度器拿的是**会话 id 字符串**。早期版本这里错配了 exec 版函数,
375
+ // 结果续跑消息永远走 resumePromptUnknown(连全局会话都被写成「本会话还没设定角色」)。
376
+ {
377
+ const seen = [];
378
+ const deps = schedDepsFor({
379
+ infoOfSid: (sid) => { seen.push(sid); return { role: 'global', project: '', task: '', sessionId: sid }; },
380
+ handoverForSid: (sid) => ({ path: 'W/_Control_Center/records/Global_Handover.md', sessionId: sid }),
381
+ });
382
+ const info = deps.roleOf('session-abc');
383
+ const hp = deps.handoverFor('session-abc');
384
+ ok(info && info.role === 'global', '调度器按会话 id 解析角色(不是 exec)', JSON.stringify(info));
385
+ ok(seen.indexOf('session-abc') !== -1, '传给 roleOf 的就是会话 id 原文', JSON.stringify(seen));
386
+ ok(hp && /Global_Handover\.md$/.test(hp.path), '调度器能拿到本会话的交接绝对路径', JSON.stringify(hp));
387
+ }
388
+ const tcfg = resolveConfig({});
389
+ const tp = fillResumePrompt(tcfg, { n: 2, handover: hpP.path, role: 'project' });
390
+ ok(tp.indexOf(hpP.path) !== -1, '续跑文案应带本会话的交接绝对路径', tp.slice(0, 90));
391
+ ok(tp.indexOf('Global_Handover.md') === -1, '项目会话的续跑文案不得出现全局交接路径');
392
+ const tu = fillResumePrompt(tcfg, { n: 1, handover: '', role: 'plain' });
393
+ ok(/普通会话,不参与交接/.test(tu), '普通会话的续跑文案应说明不参与交接', tu.slice(0, 90));
394
+ const tg = fillResumePrompt(tcfg, { n: 3, handover: hpG.path, role: 'global' });
395
+ ok(tg.indexOf('第 3/3 次压缩') !== -1, '续跑文案应填上次数', tg.slice(0, 40));
396
+ ok(tcfg.directPeer === 'on', '会话之间直接对话应默认允许', String(tcfg.directPeer));
397
+ ok(tg.indexOf('收尾并停止') !== -1, '续跑文案应允许交接无待办时收尾停止', tg.slice(0, 120));
398
+
399
+ // 压缩额度用尽优先于水位判定:3/3 之后再压不是「水位没到」,而是该换任。
400
+ // 实测:某项目会话压满 3 次、水位 25.4% 时,ho_checkpoint 只报「水位未到线」,误导它去找 force=true。
401
+ {
402
+ const { decide } = await import(pathToFileURL(path.join(dist, 'lib', 'phase.mjs')).href);
403
+ const d1 = decide({ ratio: 0.25, compactionsUsed: 3, cfg: tcfg, lockState: 'clean' });
404
+ ok(d1.action === 'new_session', '压满 3/3 时水位低也应建议换任', JSON.stringify(d1));
405
+ const d2 = decide({ ratio: 0.9, compactionsUsed: 3, cfg: tcfg, lockState: 'clean' });
406
+ ok(d2.action === 'new_session', '压满 3/3 时到硬顶也应换任', JSON.stringify(d2));
407
+ const d3 = decide({ ratio: 0.4, compactionsUsed: 2, cfg: tcfg, lockState: 'clean' });
408
+ ok(d3.action === 'checkpoint', '未压满时过交接线仍应压缩', JSON.stringify(d3));
409
+ }
410
+
411
+ // 归档命名按 Handover_Rules §4.2:<原文件名>_<YYYYMMDD_HHMMSS>.md,不加链标识;杂项归档在任务目录内。
412
+ {
413
+ const io = await import(pathToFileURL(path.join(dist, 'lib', 'handover-io.mjs')).href);
414
+ const stamp = io.archiveStamp(new Date(2026, 10, 8, 14, 43, 7));
415
+ ok(stamp === '20261108_144307', '归档时间戳应到秒', stamp);
416
+ const projName = io.archiveFileName('E:/ws/_Project_Meta/Demo/Handover.md', stamp);
417
+ ok(projName === 'Handover_' + stamp + '.md', '项目归档名应为 Handover_<时间>.md,不加项目前缀', projName);
418
+ const globName = io.archiveFileName('E:/ws/_Control_Center/records/Global_Handover.md', stamp);
419
+ ok(globName === 'Global_Handover_' + stamp + '.md', '全局归档名应为 Global_Handover_<时间>.md', globName);
420
+ const ad = await import(pathToFileURL(path.join(dist, 'lib', 'role.mjs')).href);
421
+ const miscDir = ad.archiveDirFor(WS, { role: 'misc', task: '抢名额' }).replace(/\\/g, '/');
422
+ ok(miscDir.endsWith('_Misc/tasks/抢名额/_archive'), '杂项归档目录应在任务目录内(_archive)', miscDir);
423
+ const handTool = reg.tools.find((t) => t.name === 'ho_handover');
424
+ ok(!!handTool, '应注册 ho_handover');
425
+ if (handTool) {
426
+ const archExec = { name: 'ho_handover', arguments: { action: 'archive' }, agent: { session: { header: { id: 'session-arch', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库'), title: 'Demo1' } } } };
427
+ const r = await handTool.execute({ action: 'archive' }, archExec);
428
+ const m = /已归档 (.+?\.md)/.exec(r.text || '');
429
+ ok(!!m, '归档应报出目标路径', String(r.text).slice(0, 140));
430
+ if (m) {
431
+ const p = m[1];
432
+ ok(p.replace(/\\/g, '/').indexOf('_Archives/Demo/Handover/') !== -1, '项目归档目录应为 _Archives/Demo/Handover', p);
433
+ ok(/^Handover_\d{8}_\d{6}\.md$/.test(path.basename(p)), '归档文件名不加项目前缀且时间到秒', path.basename(p));
434
+ ok(fs.existsSync(p), '归档文件应存在', p);
435
+ }
436
+ }
437
+ }
438
+
439
+ const listeners = (reg.listeners && reg.listeners['agent/created']) || [];
440
+ ok(listeners.length === 1, '应监听 agent/created 且只一处', String(listeners.length));
441
+ const calls = { restrict: [], sections: [], injected: [], lifted: 0 };
442
+ const projAgent = {
443
+ id: 'session-proj',
444
+ session: { header: { id: 'session-proj', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库') } },
445
+ ctx: {
446
+ tools: { restrict: (f) => { calls.restrict.push(f); return () => { calls.lifted++; }; } },
447
+ systemPrompt: { section: (o) => { calls.sections.push(o); return () => { calls.lifted++; }; } },
448
+ },
449
+ inject: (m) => calls.injected.push(m),
450
+ };
451
+ for (const cb of listeners) cb({ agent: projAgent, source: 'fresh' });
452
+ ok(calls.restrict.length === 0, '按步路径不用 restrict 收窄工具', String(calls.restrict.length));
453
+ const projGatePack = gateFor('ho_pack');
454
+ const projGateHand = gateFor('ho_handover');
455
+ const projExec = { agent: projAgent };
456
+ ok(typeof projGatePack === 'function' && projGatePack(projExec) === false, '项目角色不可见 ho_pack(每步条件)');
457
+ ok(typeof projGateHand === 'function' && projGateHand(projExec) === true, '项目角色可见 ho_handover(每步条件)');
458
+ ok(gateFor('ho_brief')(projExec) === false, '项目角色不可见 ho_brief');
459
+ ok(calls.sections.length === 1 && calls.sections[0].name === 'dsh-handover:manual', '应注册同名提示段遮蔽兜底段');
460
+ const secNorm = String(calls.sections[0].text).replace(/\\/g, '/');
461
+ ok(secNorm.indexOf('Projects/Demo') !== -1 && secNorm.indexOf('_Project_Meta/Demo/Handover.md') !== -1, '项目提示段应含自己的路径', secNorm.slice(0, 200));
462
+ ok(secNorm.indexOf('任务完成不报全局') !== -1, '项目提示段应写明任务完成不向全局报告', secNorm.slice(0, 400));
463
+ ok(secNorm.indexOf('任务达成') === -1, '项目提示段不得再出现任务达成');
464
+ ok(secNorm.indexOf('可直接 peer_send') !== -1, '项目提示段应允许会话之间直接对话', secNorm.slice(0, 400));
465
+ ok(secNorm.indexOf('resume=wait') !== -1, '项目提示段应说明收尾压缩不唤醒');
466
+ ok(budget.estimateTokens(calls.sections[0].text) <= budget.TOKEN_BUDGET.projectTokens, '项目提示段应控制在 ' + budget.TOKEN_BUDGET.projectTokens + ' token 内(成本守门)', String(budget.estimateTokens(calls.sections[0].text)));
467
+ ok(calls.sections[0].text.indexOf('子智能体') === -1, '项目段不得携带子智能体派发纪律(不属于本插件)');
468
+ // 真实工作区路径 + 迁移提示是最坏情形,预算另设一档
469
+ {
470
+ const man = await import(pathToFileURL(path.join(dist, 'lib', 'manual.mjs')).href);
471
+ const longWs = path.join(os.tmpdir(), 'Project_Mate', '_Workspace');
472
+ const otherWs = path.join(os.tmpdir(), 'Project_Mate', '_Workspace_2');
473
+ const worst = man.scopedManual({ role: 'project', project: 'Demo', task: '' }, { ws: longWs, move: { phase: 'switched', dst: otherWs, src: longWs } });
474
+ ok(budget.estimateTokens(worst) <= budget.TOKEN_BUDGET.projectWorstTokens, '项目段在真实长路径与迁移提示下仍应在预算内', String(budget.estimateTokens(worst)));
475
+ }
476
+ ok(secNorm.indexOf('_Temp/Demo/') !== -1 && secNorm.indexOf('_Archives/Demo/') !== -1, '项目提示段应含临时与归档路径', secNorm.slice(0, 300));
477
+ // 回归锁:早期版本用 agent.inject({source:{kind:'plugin'}}) 投一次性引导,v4 格式准入拒绝该 source,
478
+ // 抛在写盘编码阶段 → 坏事件留在缓冲 → 该会话之后每次开轮都 agent.failed。这条断言防止再写回历史。
479
+ ok(calls.injected.length === 0, '不得向会话历史写插件消息(历史一律由提示段承载)', JSON.stringify(calls.injected.map((m) => m && m.source)));
480
+ for (const cb of listeners) cb({ agent: projAgent, source: 'resume' });
481
+ ok(calls.injected.length === 0, '重复装配也不得写历史', String(calls.injected.length));
482
+ ok(calls.lifted >= 1, '重复装配前应先撤销旧限制', String(calls.lifted));
483
+
484
+ const gCalls = { restrict: 0, sections: [], injected: 0 };
485
+ const globAgent = {
486
+ id: 'session-glob',
487
+ session: { header: { id: 'session-glob', title: '全局9', cwd: WS } },
488
+ ctx: {
489
+ tools: { restrict: () => { gCalls.restrict++; return () => { }; } },
490
+ systemPrompt: { section: (o) => { gCalls.sections.push(o); return () => { }; } },
491
+ },
492
+ inject: () => { gCalls.injected++; },
493
+ };
494
+ for (const cb of listeners) cb({ agent: globAgent, source: 'fresh' });
495
+ ok(gCalls.restrict === 0, '全局角色不应被裁剪', String(gCalls.restrict));
496
+ ok(gCalls.sections.length === 1 && gCalls.sections[0].name === 'dsh-handover:manual', '全局也应遮蔽兜底段');
497
+ // 常驻段只放每轮判定,一次性流程下沉手册;上限按加权 token 计。
498
+ ok(budget.estimateTokens(gCalls.sections[0].text) <= budget.TOKEN_BUDGET.globalTokens, '全局提示段应控制在 ' + budget.TOKEN_BUDGET.globalTokens + ' token 内(硬上限)', String(budget.estimateTokens(gCalls.sections[0].text)));
499
+ ok(gCalls.sections[0].text.indexOf('反模式') === -1, '全局段不得携带反模式纪律(不属于本插件)');
500
+ ok(gCalls.sections[0].text.indexOf('send_message 续用') === -1, '全局段不得携带子智能体续用纪律(不属于本插件)');
501
+ // 真机最坏情形:长路径 + 迁移提示行
502
+ {
503
+ const man2 = await import(pathToFileURL(path.join(dist, 'lib', 'manual.mjs')).href);
504
+ const longWs2 = path.join(os.tmpdir(), 'Project_Mate', '_Workspace');
505
+ const otherWs2 = path.join(os.tmpdir(), 'Project_Mate', '_Workspace_2');
506
+ const gWorst = man2.globalManual({ ws: longWs2, move: { phase: 'switched', dst: otherWs2, src: longWs2 } });
507
+ ok(budget.estimateTokens(gWorst) <= budget.TOKEN_BUDGET.globalWorstTokens, '全局段在长路径与迁移提示下仍应在预算内', String(budget.estimateTokens(gWorst)));
508
+ }
509
+ ok(gCalls.sections[0].text.indexOf('前置提示词') === -1, '插件角色段不得再携带前置提示词(由用户全局前置提示词注入)');
510
+ // 结构只保留会话定位、权限、节奏、交接与按需取用五节
511
+ {
512
+ const g = gCalls.sections[0].text;
513
+ const cnt = (k) => g.split(k).length - 1;
514
+ const secs = ['一、身份', '二、权限', '三、节奏', '四、交接', '五、按需取用'];
515
+ const bad = secs.filter((k) => cnt(k) !== 1);
516
+ ok(bad.length === 0, '全局段五节骨架应各出现一次', bad.join(','));
517
+ const absent = ['子智能体', '反模式', '自检'];
518
+ const still = absent.filter((k) => g.indexOf(k) !== -1);
519
+ ok(still.length === 0, '插件角色段不得携带非会话约束', still.join(','));
520
+ ok(cnt('换任') <= 2, '「换任」在全局段出现不应超过两处', String(cnt('换任')));
521
+ }
522
+ ok(gCalls.injected === 0, '全局不再重复注入工作流(提示段里已有)', String(gCalls.injected));
523
+ // 分层护栏——常驻段只留判定,工作流细节必须留在手册里
524
+ {
525
+ const g0 = String(gCalls.sections[0].text);
526
+ ok(g0.indexOf('ho_pack') === -1 && g0.indexOf('move-probe') === -1, '常驻段不得回灌工作流细节(应放手册)');
527
+ ok(g0.indexOf('ho_charter(doc="runtime", section=…') !== -1, '常驻段应给出按节取用的指针');
528
+ const rm = fs.readFileSync(path.join(dist, 'templates', 'Runtime_Manual.md'), 'utf8');
529
+ ok(rm.indexOf('## 11. 首次安装') !== -1 && rm.indexOf('## 12. 提示词预算') !== -1, '手册应含首次安装与提示词预算两节');
530
+ ok(rm.indexOf('续用优先') !== -1, '手册 §5 应含子智能体续用纪律');
531
+ // 项目名先按 cwd 认——换任时插件自己写的启动提示词解析不出项目名,旧逻辑会把新会话整行过滤掉
532
+ const sess = await import(pathToFileURL(path.join(dist, 'lib', 'sessions.mjs')).href);
533
+ const fake = { record: { identity: { cwd: path.join(WS, 'Projects', 'Demo', 'Demo库') }, rows: {} } };
534
+ ok(sess.projectOf(fake).real === true && sess.projectOf(fake).project === 'Demo', '应按 cwd 识别项目会话', JSON.stringify(sess.projectOf(fake)));
535
+ // 换任启动提示词不得再写「然后接着干」——没任务时它会抠字眼给自己找活
536
+ const opsSrc = fs.readFileSync(path.join(dist, 'lib', 'session-ops.mjs'), 'utf8');
537
+ ok(opsSrc.indexOf('接着干') === -1, '启动提示词与交接提示不得再出现「接着干」');
538
+ ok(opsSrc.indexOf('任务达成') === -1 && opsSrc.indexOf('三种时机') !== -1, '启动提示词应改为三种时机且不再含任务达成');
539
+ const ckSrc = fs.readFileSync(path.join(dist, 'tools', 'ho_checkpoint.mjs'), 'utf8');
540
+ ok(ckSrc.indexOf('resume') !== -1, 'ho_checkpoint 应支持 resume 选择压缩后去向');
541
+ const idxSrc = fs.readFileSync(path.join(dist, 'index.mjs'), 'utf8');
542
+ ok(idxSrc.indexOf('directPeer') !== -1, '会话守卫应支持直接对话开关');
543
+ // 语体扫描——界面与文档一律标准书面语(口语词、感叹号、图形结论、全角斜杠、时间缺时区)。
544
+ {
545
+ const uiFiles = listFiles(path.join(repo, 'src'), ['.mjs', '.js'], { skip: ['templates'] });
546
+ const docFiles = [path.join(repo, 'README.md')]
547
+ .concat(listFiles(path.join(repo, 'src', 'templates'), ['.md']))
548
+ .filter((f) => f.indexOf('Writing_Rules.md') === -1);
549
+ const uiHits = scanFiles(uiFiles, { ui: true });
550
+ const docHits = scanFiles(docFiles, { ui: false });
551
+ const fmt = (hs) => hs.slice(0, 6).map((h) => path.relative(repo, h.file) + ':' + h.line + ' ' + h.rules.join('、')).join(' | ');
552
+ ok(uiHits.length === 0, '界面文案应一律使用标准书面语(词表与符号扫描)', fmt(uiHits));
553
+ ok(docHits.length === 0, '文档文案应一律使用标准书面语(词表与符号扫描)', fmt(docHits));
554
+ }
555
+ ok(opsSrc.indexOf('不得自行开展作业') !== -1 && opsSrc.indexOf('待用户指派') !== -1, '启动提示词应先报告再待指派');
556
+ }
557
+
558
+ // 按步路径(0.2.2 起):创建即按角色登记提示段;工具可见性由每步条件算,不再调用 restrict。
559
+ const unrCalls = { restrict: 0, section: null };
560
+ const unrAgent = {
561
+ id: 'session-unr',
562
+ session: { header: { id: 'session-unr', title: '随便', cwd: '/tmp/x' } },
563
+ ctx: {
564
+ tools: { restrict: () => { unrCalls.restrict++; return () => { }; } },
565
+ systemPrompt: { section: (o) => { unrCalls.section = o; return () => { }; } },
566
+ },
567
+ inject: () => { throw new Error('未设角色的会话不应写历史'); },
568
+ };
569
+ for (const cb of listeners) cb({ agent: unrAgent, source: 'fresh' });
570
+ ok(true, '未设角色的会话装配不应抛错');
571
+ ok(unrCalls.restrict === 0, '按步路径不再用 restrict 收窄工具', String(unrCalls.restrict));
572
+ ok(!unrCalls.section, '普通会话不注册角色提示段');
573
+
574
+ // 开轮前核对:休眠期间改了角色,下一轮开始时补装提示段(工具由每步条件即时生效)。
575
+ writeRole({}, 'session-unr', { role: 'project', project: 'Demo', source: 'user' });
576
+ for (const cb of ((reg.listeners && reg.listeners['agent/status']) || [])) cb({ agent: unrAgent, status: 'running' });
577
+ ok(!!unrCalls.section && /项目/.test(String(unrCalls.section.text)), '开轮前核对应按新角色补装提示段', JSON.stringify(unrCalls.section && unrCalls.section.name));
578
+
579
+ // 旧内核(没有 tools.resolve)才走 restrict:创建时按普通收窄 22 个,改角色后开轮前换成 13 个。
580
+ {
581
+ const legacyCalls = { restrict: 0, lastDeny: null, disposed: 0 };
582
+ const legacyListeners = {};
583
+ const legacyTools = {
584
+ register: () => { },
585
+ restrict: (f) => { legacyCalls.restrict++; legacyCalls.lastDeny = (f && f.deny) || null; return () => { legacyCalls.disposed++; }; },
586
+ guard: () => { },
587
+ };
588
+ const legacyCtx = {
589
+ tools: legacyTools,
590
+ systemPrompt: { section: () => () => { } },
591
+ sessionController: {},
592
+ logger: { warn: () => { }, error: () => { }, info: () => { } },
593
+ effect: (fn) => fn(),
594
+ on: (n, cb) => { (legacyListeners[n] = legacyListeners[n] || []).push(cb); },
595
+ get: () => null,
596
+ inject: () => { },
597
+ };
598
+ apply(legacyCtx, {});
599
+ const legacyAgent = {
600
+ id: 'session-legacy',
601
+ session: { header: { id: 'session-legacy', cwd: '/tmp/l' } },
602
+ ctx: { tools: legacyTools, systemPrompt: legacyCtx.systemPrompt },
603
+ };
604
+ for (const cb of (legacyListeners['agent/created'] || [])) cb({ agent: legacyAgent, source: 'fresh' });
605
+ ok(legacyCalls.restrict === 1 && legacyCalls.lastDeny && legacyCalls.lastDeny.length === 22, '旧内核:创建时按普通收窄 22 个', JSON.stringify(legacyCalls.lastDeny && legacyCalls.lastDeny.length));
606
+ writeRole({}, 'session-legacy', { role: 'project', project: 'Demo', source: 'user' });
607
+ for (const cb of (legacyListeners['agent/status'] || [])) cb({ agent: legacyAgent, status: 'running' });
608
+ ok(legacyCalls.restrict === 2 && legacyCalls.lastDeny && legacyCalls.lastDeny.length === 13, '旧内核:开轮前换成项目角色的 13 个', JSON.stringify(legacyCalls.lastDeny && legacyCalls.lastDeny.length));
609
+ ok(legacyCalls.disposed === 1, '旧内核:换角色应撤销旧限制', String(legacyCalls.disposed));
610
+ }
611
+ // ---------- 角色路由 ----------
612
+ const noRole = await postJson('/dsh-handover/role', { session_id: 'session-r1', role: 'project' });
613
+ ok(noRole.code === 400 && /项目名/.test(String(noRole.json && noRole.json.error)), '设项目角色缺项目名应 400', JSON.stringify(noRole.json));
614
+ const badRole = await postJson('/dsh-handover/role', { session_id: 'session-r1', role: 'boss' });
615
+ ok(badRole.code === 400, '未知角色应 400', JSON.stringify(badRole.json));
616
+ const okRole = await postJson('/dsh-handover/role', { session_id: 'session-r1', role: 'project', project: 'Demo', wake: false });
617
+ ok(okRole.code === 200 && okRole.json && okRole.json.ok === true && okRole.json.role === 'project', '设角色应成功', JSON.stringify(okRole.json));
618
+ ok(readRoles({}).sessions['session-r1'] && readRoles({}).sessions['session-r1'].project === 'Demo', '角色应落盘');
619
+ // ---------- ho_sessions(action=order):全局远程下指令 ----------
620
+ const sessTool = reg.tools ? reg.tools.find((t) => t.name === 'ho_sessions') : null;
621
+ ok(!!sessTool, '应注册 ho_sessions');
622
+ const globExec = (args) => ({ name: 'ho_sessions', arguments: args, agent: { session: { header: { id: 'session-glob', cwd: WS, title: '全局9' } } } });
623
+ const orderCall = async (args) => {
624
+ try { const out = await sessTool.execute(args, globExec(args)); return String(out && out.text ? out.text : out); }
625
+ catch (e) { return 'ERR:' + e.message; }
626
+ };
627
+ const oNoSid = await orderCall({ action: 'order', order: 'handover' });
628
+ ok(/order 需要 session_id/.test(oNoSid), 'order 缺 session_id 应报错', oNoSid);
629
+ const oBad = await orderCall({ action: 'order', order: 'wave', session_id: 'session-r1' });
630
+ ok(/只能是 handover 或 compact/.test(oBad), 'order 取值应受限', oBad);
631
+ // 交接/压缩指令必须先问用户
632
+ const oNoConfirm = await orderCall({ action: 'order', order: 'handover', session_id: 'session-norole' });
633
+ ok(/需要用户确认/.test(oNoConfirm), 'order 未确认应拒绝', oNoConfirm);
634
+ const oUnr = await orderCall({ action: 'order', order: 'handover', session_id: 'session-norole', confirm: true });
635
+ ok(/没有交接路径/.test(oUnr), '未设角色的会话不能下交接指令(避免退回全局交接)', oUnr);
636
+ const oCold = await orderCall({ action: 'order', order: 'compact', session_id: 'session-r1', confirm: true });
637
+ ok(/不在内存/.test(oCold), '休眠会话不能排压缩(compactNow 需要活体 agent)', oCold);
638
+ const pNoSid = await orderCall({ action: 'place' });
639
+ ok(/place 需要 session_id/.test(pNoSid), 'place 缺 session_id 应报错', pNoSid);
640
+ const pNoWs = await orderCall({ action: 'place', session_id: 'session-r1' });
641
+ ok(/place 需要 workspace/.test(pNoWs), 'place 缺 workspace 应报错', pNoWs);
642
+ // ---------- 唤醒权守卫(按角色) ----------
643
+ writeRole({}, 'session-glob', { role: 'global' });
644
+ const guardExec = (name, argsObj, sid, cwd, title) => ({
645
+ name,
646
+ arguments: argsObj,
647
+ agent: { session: { header: { id: sid, cwd: cwd, title: title } } },
648
+ });
649
+ ok(typeof reg.guardFn === 'function', '应注册工具守卫', typeof reg.guardFn);
650
+ if (typeof reg.guardFn === 'function') {
651
+ const gGlobal = reg.guardFn(guardExec('peer_send', { session_id: 'session-proj-x' }, 'session-glob', WS, '全局9'));
652
+ ok(gGlobal === undefined, '全局角色 peer_send 应放行', String(gGlobal));
653
+ const gReport = reg.guardFn(guardExec('peer_send', { session_id: 'session-glob' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
654
+ ok(gReport === undefined, '项目会话向全局报告应放行', String(gReport));
655
+ const gPeer = reg.guardFn(guardExec('peer_send', { session_id: 'session-proj' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
656
+ ok(gPeer === undefined, '会话之间可直接对话,不必经全局转述', String(gPeer));
657
+ const gNew = reg.guardFn(guardExec('peer_new', { workspace: 'Demo库' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
658
+ ok(typeof gNew === 'string' && /仅属全局/.test(gNew), '项目会话不得建平级会话', String(gNew));
659
+ const gMisc = reg.guardFn(guardExec('peer_new', { workspace: 'Misc' }, 'session-misc', path.join(WS, '_Misc', 'tasks', '抢名额'), '杂项1'));
660
+ ok(gMisc === undefined, '杂项会话自建 Misc 换任应放行', String(gMisc));
661
+ }
662
+
663
+ const st4 = await call('GET', '/dsh-handover/status.json');
664
+ let payload4 = null;
665
+ try { payload4 = JSON.parse(st4.body); } catch (e) { /* ignore */ }
666
+ ok(payload4 && payload4.roles && payload4.roles['session-r1'], '状态页应带角色表', JSON.stringify(payload4 && payload4.roles));
667
+
668
+ // ---------- 浏览器半 ----------
669
+ let loaded = null;
670
+ globalThis.window = { __ModuleLoader__: { load: (r) => { loaded = r; } } };
671
+ const styles = [];
672
+ globalThis.document = {
673
+ querySelector: (sel) => {
674
+ const m = /data-plugin-css="([^"]+)"/.exec(String(sel));
675
+ if (!m) return null;
676
+ return styles.find((t) => t.dataset && t.dataset.pluginCss === m[1]) || null;
677
+ },
678
+ createElement: () => ({ dataset: {}, textContent: '' }),
679
+ head: { appendChild: (t) => styles.push(t) },
680
+ };
681
+
682
+ const plainRows = [];
683
+ for (let i = 0; i < 10; i++) {
684
+ plainRows.push({
685
+ sid: 'session-p' + i, title: '旧会话' + i, role: 'plain', roleLabel: '普通', source: 'none',
686
+ project: '', task: '', pct: i + 1, state: 'idle', active: false, seen: '2026-10-21 20:19 +0800',
687
+ });
688
+ }
689
+
690
+ const FIXTURE = {
691
+ generatedAt: '2026-11-07 21:25 +0800',
692
+ plugin: { name: '@mingmaple/dsh-handover', version: 'test', tools: 22, inject: ['tools', 'systemPrompt'], initErrors: [], scheduler: '就绪' },
693
+ workspace: { root: 'C:/ws', found: true },
694
+ globalSessions: 1,
695
+ guardLast: { allow: true, source: 'title', title: '全局1', sid: 'session-x' },
696
+ guide: payload ? payload.guide : { intro: ['说明'], situations: [{ when: 'w', say: 's', what: 'x' }], groups: [{ title: 'g', tools: [['ho_x', 'y']] }], rules: ['r'] },
697
+ extraPrompt: '',
698
+ extraPromptEnabled: false,
699
+ gate: { ok: true, verdict: '全部落位。', total: 1, bodyLines: 24, libraries: [{ name: 'Mine', ok: true, titleOk: true, waterOk: true, missing: 0, lines: 24 }] },
700
+ handover: { global: { verdict: '通过', reasons: [] }, projects: [{ name: 'Mine', verdict: '通过', reasons: [] }, { name: 'Idle', verdict: '缺节', reasons: ['缺 ## 1. 正在做的事(2026-10-12 起的强制项,本项目还没补)'] }], fail: 0 },
701
+ sessions: {
702
+ rows: [
703
+ { sid: 'session-y', project: 'Mine', pct: 34.6, state: 'idle', active: true, seen: '2026-10-23 23:19 +0800' },
704
+ { sid: 'session-l', project: 'Study', pct: 50, state: 'idle', active: false, seen: '2026-10-22 20:00 +0800' },
705
+ ],
706
+ all: [
707
+ { sid: 'session-g1', title: '全局1', role: 'global', roleLabel: '全局', source: 'title', project: '', task: '', pct: 40, state: 'idle', active: true, seen: '2026-11-07 21:00 +0800' },
708
+ { sid: 'session-y', title: 'Mine1', role: 'project', roleLabel: '项目', source: 'store', project: 'Mine', task: '', pct: 34.6, state: 'idle', active: true, seen: '2026-10-23 23:19 +0800' },
709
+ { sid: 'session-l', title: 'Study1', role: 'project', roleLabel: '项目', source: 'cwd', project: 'Study', task: '', pct: 50, state: 'idle', active: false, seen: '2026-10-22 20:00 +0800' },
710
+ ].concat(plainRows),
711
+ notify: [{ sid: 'session-y', project: 'Mine' }], hot: 2, unknown: 0, cutoff: '2026-11-07 09:25 +0800', silentHours: 12,
712
+ verdict: '2 个会话已进预警区(≥32%),应先收齐交接再换会话',
713
+ },
714
+ consent: [{ sessionId: 'session-y', leftMs: 120000 }],
715
+ notes: [],
716
+ };
717
+ globalThis.fetch = async () => ({ ok: true, json: async () => FIXTURE });
718
+
719
+ await import(pathToFileURL(path.join(dist, 'client.js')).href);
720
+ ok(loaded && loaded.id === '@mingmaple/dsh-handover', 'client.js 应注册正确 bundle id', loaded && loaded.id);
721
+
722
+ function fakeReact(preloaded) {
723
+ const createElement = (type, props, ...kids) => ({ type, props: props || {}, kids: kids.flat().filter((k) => k !== null && k !== undefined && k !== false) });
724
+ // 只把首个 useState 预置为夹具(该组件首个状态正是读数),其余状态按各自初值,避免误填。
725
+ let first = true;
726
+ return {
727
+ createElement,
728
+ useState: (init) => {
729
+ const v = first && preloaded !== null && preloaded !== undefined ? preloaded : init;
730
+ first = false;
731
+ return [v, () => { }];
732
+ },
733
+ useEffect: (fn) => { try { fn(); } catch (e) { /* 假 React 里跑一次 */ } },
734
+ useCallback: (fn) => fn,
735
+ useId: () => 'id',
736
+ useRef: (v) => ({ current: v }),
737
+ Fragment: 'Fragment',
738
+ };
739
+ }
740
+
741
+ const exp = loaded.factory((name) => {
742
+ if (name === 'react') return fakeReact(null);
743
+ throw new Error('不应 require:' + name);
744
+ });
745
+ ok(typeof exp.apply === 'function', 'exports.apply 应为函数');
746
+ ok(Array.isArray(exp.inject) && exp.inject.indexOf('slots') !== -1, 'exports.inject 应含 slots');
747
+
748
+ let slotOpts = null;
749
+ let Comp = null;
750
+ exp.apply({ slots: { inject: (n, cb) => { ok(n === 'settings.section', '应注册进 settings.section', n); cb(); }, register: (o, c) => { slotOpts = o; Comp = c; } } });
751
+ ok(slotOpts && slotOpts.id === 'handover', '槽位选项不对', JSON.stringify(slotOpts));
752
+ ok(slotOpts && slotOpts.label === 'dsh-handover', '导航标签就是产品名 dsh-handover(不加中文前缀)', JSON.stringify(slotOpts && slotOpts.label));
753
+ ok(typeof Comp === 'function', '应给出组件');
754
+ ok(!!Comp({}), '空数据渲染应返回元素');
755
+
756
+ const exp2 = loaded.factory((name) => (name === 'react' ? fakeReact(FIXTURE) : (() => { throw new Error('x'); })));
757
+ let Comp2 = null;
758
+ exp2.apply({ slots: { inject: (n, cb) => cb(), register: (o, c) => { Comp2 = c; } } });
759
+ const tree = Comp2({});
760
+ function textOf(node, out = []) {
761
+ if (node === null || node === undefined || node === false) return out;
762
+ if (typeof node === 'string' || typeof node === 'number') { out.push(String(node)); return out; }
763
+ if (Array.isArray(node)) { node.forEach((n) => textOf(n, out)); return out; }
764
+ // 组件节点(如 Folded)也要展开,否则表格内容不会被断言看到。
765
+ if (typeof node.type === 'function') { textOf(node.type(node.props || {}), out); return out; }
766
+ if (node.kids) node.kids.forEach((n) => textOf(n, out));
767
+ return out;
768
+ }
769
+ const text = textOf(tree).join(' ');
770
+ const treeJson = JSON.stringify(tree);
771
+ for (const want of ['dsh-handover', '刷新', '读数', '压缩授权', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '使用说明']) {
772
+ ok(text.indexOf(want) !== -1, '成体视图应出现「' + want + '」');
773
+ }
774
+
775
+ // 回到重构前的版式——常用区直接展开,只折叠低频项;授权可直接点。
776
+ const sections = tree.kids.filter((k) => k && k.type === 'section');
777
+ const cardTitles = sections.map((sec) => textOf(sec.kids[0]).join(''));
778
+ const wantOrder = ['压缩授权', '移动全局根目录', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '插件', '会话间通信守卫', '使用说明'];
779
+ ok(JSON.stringify(cardTitles) === JSON.stringify(wantOrder), '分区应自上而下为授权、迁移、角色、提示词、只读数据、插件、守卫、说明', JSON.stringify(cardTitles));
780
+
781
+ const folds = treeJson.split('"details"').length - 1;
782
+ ok(folds === 3, '只折叠低频项(迁移、前置提示词、使用说明),常用区直接展开', String(folds));
783
+
784
+ // 压缩授权:给出可点击的会话按钮,用户不必手填 id。
785
+ const allowBtns = [];
786
+ (function walk(n) {
787
+ if (!n || typeof n !== 'object') return;
788
+ if (Array.isArray(n)) { n.forEach(walk); return; }
789
+ if (n.type === 'button' && textOf(n).join('').indexOf('允许 ') === 0) allowBtns.push(textOf(n).join(''));
790
+ (n.kids || []).forEach(walk);
791
+ })(tree);
792
+ ok(allowBtns.length >= 3, '压缩授权应提供可点击的会话按钮', JSON.stringify(allowBtns.slice(0, 5)));
793
+ ok(text.indexOf('允许 y') !== -1, '授权按钮须带会话短号');
794
+
795
+ // 角色下拉含普通,不再回退到空值。
796
+ ok(text.indexOf('普通') !== -1, '角色下拉应含普通选项');
797
+ ok(fs.readFileSync(path.join(dist, 'client.js'), 'utf8').indexOf('value: "plain"') !== -1, '角色下拉应含普通选项(源码核对)');
798
+
799
+ // 角色列与选择框合并:不再单列「当前角色」,操作只有一个按钮。
800
+ ok(text.indexOf('当前角色') === -1, '角色不应占两列(当前角色与选择框合并)');
801
+ ok(text.indexOf('设置并通知') === -1, '操作列只保留一个按钮');
802
+ const setBtns = text.split('设置角色').length - 1;
803
+ ok(setBtns >= 5, '每行一个「设置角色」按钮', String(setBtns));
804
+ ok(fs.readFileSync(path.join(dist, 'client.js'), 'utf8').indexOf('通知该会话') !== -1, '设置成功后按钮应变为通知动作');
805
+
806
+ // 移动全局根目录:默认折叠。
807
+ const migrateCard = sections.find((sec) => textOf(sec.kids[0]).join('') === '移动全局根目录');
808
+ const migrateDetail = migrateCard.kids[1].kids[0];
809
+ ok(migrateDetail && migrateDetail.type === 'details' && migrateDetail.props.open !== true, '移动全局根目录应默认折叠');
810
+
811
+ // 交接体检只写问题,不带括注。
812
+ ok(text.indexOf('缺 ## 1. 正在做的事') !== -1 && text.indexOf('强制项') === -1, '交接体检的问题列不应带括注说明');
813
+
814
+ // 会话水位只列项目会话,并给出本轮需通知。
815
+ ok(text.indexOf('Mine') !== -1 && text.indexOf('34.6%') !== -1, '会话水位应列出项目会话与水位');
816
+ ok(text.indexOf('本轮需通知') !== -1, '水位段应给出本轮需通知');
817
+ ok(text.indexOf('Study1') !== -1, '角色表应列出平级会话');
818
+
819
+ // 版式约束:无进度条、无图形结论符号。
820
+ ok(treeJson.indexOf('dshh-bar') === -1, '不得使用进度条');
821
+ const clientSrc = fs.readFileSync(path.join(dist, 'client.js'), 'utf8');
822
+ ok(!/[\u2705\u274C\u26A0\u2717]/.test(clientSrc), '界面不得使用图形结论符号');
823
+ ok(clientSrc.indexOf('delWord.trim() !== "删除"') !== -1, '删除旧副本须输入确认词');
824
+
825
+ // 未接入工作区:一键部署卡片置顶。
826
+ const NOT_READY = {
827
+ generatedAt: '2026-11-07 21:25 +0800',
828
+ plugin: { name: '@mingmaple/dsh-handover', version: 'test', tools: 22, inject: ['tools'], initErrors: [], scheduler: '就绪' },
829
+ workspace: { root: '', found: false },
830
+ setup: { steps: ['准备一个长期存在的目录', '粘贴路径后点「一键部署」'] },
831
+ guide: payload ? payload.guide : null,
832
+ notes: ['未找到 _Control_Center:这个实例还没接入工作区。'],
833
+ };
834
+ const exp3 = loaded.factory((name) => (name === 'react' ? fakeReact(NOT_READY) : (() => { throw new Error('x'); })));
835
+ let Comp3 = null;
836
+ exp3.apply({ slots: { inject: (n, cb) => cb(), register: (o, c) => { Comp3 = c; } } });
837
+ const tree3 = Comp3({});
838
+ const sections3 = tree3.kids.filter((k) => k && k.type === 'section');
839
+ const titles3 = sections3.map((sec) => textOf(sec.kids[0]).join(''));
840
+ ok(titles3[0] === '首次安装', '未接入工作区时首次安装置顶', JSON.stringify(titles3));
841
+ const text3 = textOf(tree3).join(' ');
842
+ ok(text3.indexOf('一键部署') !== -1, '未接入时应能一键部署(建体系+注册工作区+建全局会话)');
843
+ ok(text3.indexOf('仅建立文件体系') !== -1, '未接入时仍可只建立文件体系');
844
+ ok(text3.indexOf('待处理') !== -1, '未接入时应显示待处理项');
845
+
846
+ // 已接入但没有全局会话:给出补建入口,且排在常规卡片之前。
847
+ const READY_NO_GLOBAL = Object.assign({}, FIXTURE, { globalSessions: 0 });
848
+ const exp4 = loaded.factory((name) => (name === 'react' ? fakeReact(READY_NO_GLOBAL) : (() => { throw new Error('x'); })));
849
+ let Comp4 = null;
850
+ exp4.apply({ slots: { inject: (n, cb) => cb(), register: (o, c) => { Comp4 = c; } } });
851
+ const tree4 = Comp4({});
852
+ const sections4 = tree4.kids.filter((k) => k && k.type === 'section');
853
+ const titles4 = sections4.map((sec) => textOf(sec.kids[0]).join(''));
854
+ ok(titles4[0] === '全局会话', '已接入但无全局会话时应给出补建卡片', JSON.stringify(titles4.slice(0, 3)));
855
+ ok(textOf(tree4).join(' ').indexOf('创建全局会话') !== -1, '补建卡片应提供「创建全局会话」按钮');
856
+
857
+ ok(styles.length === 1, '样式只注入一次(幂等)', String(styles.length));
858
+
859
+ // ---------- 搬迁(mover):两棵假树全流程(空白 HOME 下,标记与脚本都落在 BLANK 里)
860
+ const mv = await import(pathToFileURL(path.join(dist, 'lib/mover.mjs')).href);
861
+ const mvRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-handover-move-'));
862
+ const srcDir = path.join(mvRoot, 'src');
863
+ fs.mkdirSync(path.join(srcDir, '_Control_Center'), { recursive: true });
864
+ fs.mkdirSync(path.join(srcDir, 'sub'), { recursive: true });
865
+ fs.writeFileSync(path.join(srcDir, 'a.txt'), 'AAA');
866
+ fs.writeFileSync(path.join(srcDir, 'sub', 'b.txt'), 'BBBB');
867
+ const dstDir = path.join(mvRoot, 'dst');
868
+
869
+ const probe = await mv.probeMove({ src: srcDir, to: dstDir });
870
+ ok(probe.ok, '预检应通过', JSON.stringify(probe.problems));
871
+ ok(probe.files === 2 && probe.bytes === 7, '预检应数出 2 文件 / 7 字节', JSON.stringify({ f: probe.files, b: probe.bytes }));
872
+ const probeInner = await mv.probeMove({ src: srcDir, to: path.join(srcDir, 'inside') });
873
+ ok(!probeInner.ok && probeInner.problems.join(' ').indexOf('内部') !== -1, '目标在源内部应拒绝', JSON.stringify(probeInner.problems));
874
+ const probeSame = await mv.probeMove({ src: srcDir, to: srcDir });
875
+ ok(!probeSame.ok && probeSame.problems.join(' ').indexOf('相同') !== -1, '目标等于源应拒绝', JSON.stringify(probeSame.problems));
876
+
877
+ const started = await mv.startMove({ src: srcDir, to: dstDir });
878
+ ok(started.ok && started.report.files === 2, '复制应通过全量校验', JSON.stringify(started.report));
879
+ // to 是**父目录**,真正的根是 <父>/<当前根名>
880
+ ok(probe.dst === path.join(dstDir, 'src'), '目标根应为「父目录/当前根名」', probe.dst);
881
+ ok(fs.existsSync(path.join(started.marker.dst, 'sub', 'b.txt')), '目标应真的有文件');
882
+ const sp = mv.scriptPaths();
883
+ ok(fs.existsSync(sp.helper) && fs.existsSync(sp.cmd) && fs.existsSync(sp.sh), '切换助手与两个平台启动器应已生成');
884
+ const helperText = fs.readFileSync(sp.helper, 'utf8');
885
+ ok(helperText.indexOf('renameSync') !== -1 && helperText.indexOf('symlinkSync') !== -1, '助手应含改名与建链接');
886
+ ok(helperText.indexOf('process.kill') !== -1, '助手应以「父进程是否存活」判断 DSH 是否退出');
887
+ ok(helperText.indexOf('powershell') === -1 && helperText.indexOf('bash') === -1, '助手不得依赖 PowerShell/bash(跨平台)');
888
+
889
+ const stVerified = mv.moveStatus({ src: srcDir });
890
+ ok(stVerified.phase === 'verified', '校验完状态应为 verified', stVerified.phase);
891
+ // 模拟切换:改名 + 建链接(助手在 DSH 退出后做的事,这里用 Node 等价物)
892
+ const oldDir = srcDir + '.old-test';
893
+ fs.renameSync(srcDir, oldDir);
894
+ fs.symlinkSync(started.marker.dst, srcDir, process.platform === 'win32' ? 'junction' : 'dir');
895
+ const stSwitched = mv.moveStatus({ src: srcDir });
896
+ ok(stSwitched.phase === 'switched', '建链接后状态应为 switched', stSwitched.phase + ' / ' + stSwitched.linkTarget);
897
+ ok(stSwitched.olds.length === 1, '应找到 1 个旧副本', JSON.stringify(stSwitched.olds.map((o) => o.path)));
898
+ const refuse = await mv.cleanupOld({ src: srcDir, confirm: false }).then(() => 'no-error', (e) => 'err:' + e.message);
899
+ ok(String(refuse).indexOf('明确确认') !== -1, '未确认不得删旧副本', String(refuse));
900
+ const cleaned = await mv.cleanupOld({ src: srcDir, confirm: true });
901
+ ok(cleaned.ok && cleaned.removed.length === 1 && !fs.existsSync(oldDir), '清旧副本应删掉 .old 目录', JSON.stringify(cleaned.removed));
902
+ const stCleaned = mv.moveStatus({ src: srcDir });
903
+ ok(stCleaned.phase === 'switched+cleaned', '清理后状态应记 switched+cleaned', stCleaned.phase);
904
+
905
+ // 原路径被手工删掉(资源管理器误删)后应能看见并一键重建;只建链接,不动文件
906
+ try { fs.unlinkSync(srcDir); } catch (e) { fs.rmSync(srcDir, { recursive: true, force: true }); }
907
+ const stMissing = mv.moveStatus({ src: srcDir });
908
+ ok(stMissing.srcExists === false, '原路径被删后 srcExists 应为 false', String(stMissing.srcExists));
909
+ ok(stMissing.canRepair === true, '目标还在时应报「可重建」', JSON.stringify({ dst: stMissing.dstExists, repair: stMissing.canRepair }));
910
+ const repaired = mv.repairLink();
911
+ ok(repaired.ok && repaired.repaired === true, '重建链接应成功', JSON.stringify(repaired));
912
+ const stRepaired = mv.moveStatus({ src: srcDir });
913
+ ok(stRepaired.srcExists === true && stRepaired.canRepair === false, '重建后原路径应恢复为链接', JSON.stringify({ s: stRepaired.srcExists, t: stRepaired.linkTarget }));
914
+ ok(stRepaired.phase === 'switched+cleaned', '重建后状态应保持 switched+cleaned', stRepaired.phase);
915
+
916
+ // 压缩授权——AI 不能自己批,只有用户点的那一下才算
917
+ const cs0 = await import(pathToFileURL(path.join(dist, 'lib/consent.mjs')).href);
918
+ const SID = 'session-test-0001';
919
+ const before = cs0.checkConsent(SID);
920
+ ok(before.ok === false, '没有用户授权时应判为未授权', JSON.stringify(before));
921
+ const g1 = cs0.grantConsent(SID, '冒烟');
922
+ ok(g1.minutes === 5, '授权有效期应为 5 分钟', String(g1.minutes));
923
+ const after = cs0.checkConsent(SID);
924
+ ok(after.ok === true, '用户授权后应判为已授权', JSON.stringify(after));
925
+ ok(after.leftMs > 0 && after.leftMs <= 5 * 60 * 1000, '剩余时间应在 5 分钟内', String(after.leftMs));
926
+ ok(Array.isArray(cs0.listConsent()) && cs0.listConsent().length === 1, '授权列表应列出 1 条');
927
+ cs0.consumedConsent(SID);
928
+ ok(cs0.checkConsent(SID).ok === false, '用掉一次后授权应失效');
929
+ ok(cs0.CONSENT_TTL_MS === 5 * 60 * 1000, '授权时限常量应为 5 分钟');
930
+
931
+ // 登记改址——只替换路径字符串、先备份、其余内容不动
932
+ const regDir = path.join(BLANK, '.dsh', 'storages');
933
+ fs.mkdirSync(regDir, { recursive: true });
934
+ const regFile = path.join(regDir, 'workspace.json');
935
+ const fakeWs = { tables: { workspaces: { w1: { id: 'w1', title: '全局', path: srcDir, sessionIds: ['s1'] } } }, other: 'keep-me' };
936
+ fs.writeFileSync(regFile, JSON.stringify(fakeWs, null, 2), 'utf8');
937
+ const planRt = mv.retargetPlan({ src: srcDir, dst: started.marker.dst });
938
+ ok(planRt.needed === true, '登记里仍有旧路径时应报「需要改址」', JSON.stringify(planRt.files));
939
+ ok(planRt.total >= 1, '应数出至少一处旧路径', String(planRt.total));
940
+ const doneRt = mv.applyRetarget({ src: srcDir, dst: started.marker.dst });
941
+ ok(doneRt.written.length === 1, '应改写 1 个登记文件', JSON.stringify(doneRt.written));
942
+ const afterRt = JSON.parse(fs.readFileSync(regFile, 'utf8'));
943
+ ok(afterRt.tables.workspaces.w1.path === started.marker.dst, '工作区路径应改成新位置', afterRt.tables.workspaces.w1.path);
944
+ ok(afterRt.other === 'keep-me', '登记里的其他字段应原样保留');
945
+ ok(fs.existsSync(doneRt.written[0].backup), '改写前应留下备份');
946
+ ok(mv.retargetPlan({ src: srcDir, dst: started.marker.dst }).needed === false, '改完之后应不再需要改址');
947
+
948
+ // 超长路径删除(Windows 上 fs.rm 遇 >260 字符会整体报 EPERM,rmTree 逐项删应成功)
949
+ let deepPath = path.join(mvRoot, 'deep');
950
+ for (let i = 0; i < 8; i++) deepPath = path.join(deepPath, 'dir-' + i + '-' + 'x'.repeat(40));
951
+ fs.mkdirSync(deepPath, { recursive: true });
952
+ const deepFile = path.join(deepPath, 'y'.repeat(90) + '.txt');
953
+ fs.writeFileSync(deepFile, 'deep');
954
+ if (process.platform === 'win32') ok(deepFile.length > 260, '构造的路径应超过 260 字符', String(deepFile.length));
955
+ const delDeep = await mv.rmTree(path.join(mvRoot, 'deep'));
956
+ ok(delDeep.ok && !fs.existsSync(path.join(mvRoot, 'deep')), 'rmTree 应删掉超长路径树', JSON.stringify(delDeep.fails));
957
+ fs.rmSync(mvRoot, { recursive: true, force: true });
958
+
959
+ // 工作区与会话的显式化:create 支持 ensure;新建会话缺工作区必须报错并列出可选项。
960
+ ctx.workspaceRegistry = { list: () => [], create: async (p, t) => ({ id: 'ws-test', title: t, path: p }) };
961
+ const wsToolEnsure = reg.tools.find((t) => t.name === 'ho_workspace');
962
+ const wsEnsureDir = path.join(BLANK, 'proj', 'Demo');
963
+ const wsEnsureOut = await wsToolEnsure.execute({ action: 'create', path: wsEnsureDir, title: 'Demo', ensure: true });
964
+ ok(/已注册工作区/.test(String(wsEnsureOut && wsEnsureOut.text)), 'ho_workspace create 应支持 ensure 建目录', String(wsEnsureOut && wsEnsureOut.text));
965
+ ok(fs.existsSync(wsEnsureDir), 'ensure=true 应真的建出目录', wsEnsureDir);
966
+ const sessToolWs = reg.tools.find((t) => t.name === 'ho_sessions');
967
+ let newErr = '';
968
+ try {
969
+ await sessToolWs.execute({ action: 'new', confirm: true, title: '项目1' }, { agent: { session: { header: { id: 'session-x', cwd: BLANK } } } });
970
+ } catch (e) { newErr = String(e && e.message ? e.message : e); }
971
+ ok(/工作区/.test(newErr) && /已注册的工作区/.test(newErr), '新建会话缺工作区时应报错并列出可选工作区', newErr.slice(0, 160));
972
+
973
+ fs.rmSync(BLANK, { recursive: true, force: true });
974
+ console.log('');
975
+ if (fails.length) {
976
+ console.log('SMOKE FAIL ' + fails.length + ' 项:');
977
+ for (const f of fails) console.log(' - ' + f);
978
+ process.exit(1);
979
+ }
980
+ console.log('SMOKE OK(空白环境冒烟全过)');
981
+