@mingmaple/dsh-handover 0.9.13 → 0.9.22

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 (65) hide show
  1. package/README.md +46 -19
  2. package/docs/migrate.md +6 -6
  3. package/docs/releases/0.9.14.md +18 -0
  4. package/docs/releases/0.9.22.md +34 -0
  5. package/docs/troubleshoot.md +34 -10
  6. package/package.json +71 -71
  7. package/scripts/blank-dsh.mjs +29 -23
  8. package/scripts/blank-smoke.mjs +261 -78
  9. package/scripts/e2e-migration.mjs +2 -2
  10. package/scripts/office.mjs +3 -3
  11. package/src/client.js +159 -120
  12. package/src/index.mjs +65 -58
  13. package/src/lib/api.mjs +9 -7
  14. package/src/lib/brief.mjs +2 -2
  15. package/src/lib/config.mjs +3 -3
  16. package/src/lib/consent.mjs +54 -1
  17. package/src/lib/docmap.mjs +143 -0
  18. package/src/lib/gates.mjs +25 -1
  19. package/src/lib/guide.mjs +35 -44
  20. package/src/lib/lint.mjs +3 -2
  21. package/src/lib/manual.mjs +48 -9
  22. package/src/lib/mover.mjs +10 -10
  23. package/src/lib/names.mjs +14 -19
  24. package/src/lib/office.mjs +22 -14
  25. package/src/lib/pack-ops.mjs +2 -2
  26. package/src/lib/peer.mjs +4 -4
  27. package/src/lib/role.mjs +8 -5
  28. package/src/lib/scaffold.mjs +42 -20
  29. package/src/lib/schedule.mjs +1 -2
  30. package/src/lib/session-create.mjs +1 -1
  31. package/src/lib/session-ops.mjs +49 -20
  32. package/src/lib/sessions.mjs +1 -1
  33. package/src/lib/status.mjs +205 -45
  34. package/src/lib/succession.mjs +27 -11
  35. package/src/lib/zstd-log.mjs +1 -1
  36. package/src/selftest.mjs +6 -6
  37. package/src/templates/Prompt_Templates.md +13 -13
  38. package/src/templates/Runtime_Manual.md +56 -30
  39. package/src/templates/rules/Handover_Rules.md +2 -1
  40. package/src/templates/rules/Migration_Rules.md +1 -1
  41. package/src/templates/standards/README.md +25 -0
  42. package/src/tools/ho_backup.mjs +135 -0
  43. package/src/tools/ho_checkpoint.mjs +12 -11
  44. package/src/tools/ho_docmap.mjs +153 -0
  45. package/src/tools/ho_gate.mjs +2 -1
  46. package/src/tools/ho_office.mjs +1 -1
  47. package/src/tools/ho_sessions.mjs +3 -4
  48. package/src/tools/ho_status.mjs +246 -0
  49. package/src/tools/ho_succeed.mjs +1 -1
  50. package/src/tools/ho_workspace.mjs +86 -14
  51. package/src/tools/peer.mjs +205 -0
  52. package/src/tools/ho_brief.mjs +0 -57
  53. package/src/tools/ho_diag.mjs +0 -87
  54. package/src/tools/ho_drift.mjs +0 -41
  55. package/src/tools/ho_init.mjs +0 -82
  56. package/src/tools/ho_pack.mjs +0 -38
  57. package/src/tools/ho_phase.mjs +0 -37
  58. package/src/tools/ho_snapshot.mjs +0 -104
  59. package/src/tools/ho_tokens.mjs +0 -57
  60. package/src/tools/peer_list.mjs +0 -37
  61. package/src/tools/peer_new.mjs +0 -75
  62. package/src/tools/peer_peek.mjs +0 -41
  63. package/src/tools/peer_rename.mjs +0 -31
  64. package/src/tools/peer_send.mjs +0 -52
  65. package/src/tools/peer_status.mjs +0 -46
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 空白环境冒烟:不依赖任何真实工作区,验「装上就能跑」。
3
- * 1) 宿主半在假 ctx 上 apply:24 个工具、手册段、状态路由(200/404/405/HEAD)、只读工具不抛
3
+ * 1) 宿主半在假 ctx 上 apply:14 个工具、手册段、状态路由(200/404/405/HEAD)、只读工具不抛
4
4
  * 2) 浏览器半在假 window/React 上装载:bundle 外形、settings.section 注册、成体视图能渲染
5
5
  * 用法:node scripts/blank-smoke.mjs [--dist <dist 目录>]
6
6
  */
@@ -42,7 +42,7 @@ const ctx = {
42
42
  };
43
43
  apply(ctx, {});
44
44
 
45
- ok(reg.tools.length === 24, '工具数应为 24', String(reg.tools.length));
45
+ ok(reg.tools.length === 14, '工具数应为 14', String(reg.tools.length));
46
46
  ok(reg.sections.length === 0, '未开启全局前置提示词时不得注册提示段', String(reg.sections.length));
47
47
  ok(reg.tools.filter((t) => !t || !t.name || !t.description || !t.execute).length === 0, '每个工具都要有 name/description/execute');
48
48
  // 上下文成本实测:工具描述是每次请求都要付的固定开销。参考值只用于展示,不作为通过条件。
@@ -52,11 +52,11 @@ const budget = await import(pathToFileURL(path.join(dist, 'lib', 'facts.mjs')).h
52
52
  const widest = rows.slice().sort((a, b) => b.n - a.n)[0];
53
53
  console.log('工具描述 token 实测:最宽 ' + widest.name + ' ' + widest.n + '(参考值 ' + budget.TOKEN_BUDGET.toolDescTokens + ',不判失败)');
54
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));
56
- ok(reg.tools.filter((t) => t.name.startsWith('peer_')).length === 6, 'peer_* 应为 6 个');
55
+ ok(reg.tools.filter((t) => t.name.startsWith('ho_')).length === 13, 'ho_* 应为 13 个', String(reg.tools.filter((t) => t.name.startsWith('ho_')).length));
56
+ ok(reg.tools.filter((t) => t.name === 'peer').length === 1, '平级会话工具应合并为一个 peer');
57
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));
58
+ // 否则它的首轮提示词会带着 14 个用不了的 schema(每步白付约 1.5k token)。
59
+ ok((reg.toolOpts || []).length === 14 && (reg.toolOpts || []).every((o) => o && typeof o.when === 'function'), '14 个工具都应带每步启用条件', String((reg.toolOpts || []).length));
60
60
 
61
61
  /** 取某个工具的每步启用条件(0.2.2 起工具按步解析)。 */
62
62
  const gateFor = (name) => {
@@ -66,7 +66,7 @@ const gateFor = (name) => {
66
66
 
67
67
  {
68
68
  // 每步条件同时承担子会话排除与角色过滤:调用方每步重算工具表,换角色下一轮即生效。
69
- const gate = gateFor('ho_brief');
69
+ const gate = gateFor('ho_status');
70
70
  ok(typeof gate === 'function', '每步启用条件应可调用');
71
71
  if (typeof gate === 'function') {
72
72
  ok(gate({}) === true, '没有 agent 的调用应放行(命令行与演练路径)');
@@ -75,10 +75,10 @@ const gateFor = (name) => {
75
75
  ok(gate({ agent: { session: { header: { id: 'session-plain', cwd: path.join(BLANK, 'nowhere') } } } }) === false, '未登记角色的会话按普通处理(0 个插件工具)');
76
76
  const roleMod = await import(pathToFileURL(path.join(dist, 'lib', 'role.mjs')).href);
77
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');
78
+ ok(gate({ agent: { session: { header: { id: 'session-boss', cwd: path.join(BLANK, 'nowhere') } } } }) === true, '全局角色应可见 ho_status');
79
+ const gatePlain = gateFor('ho_office');
80
+ ok(typeof gatePlain === 'function' && gatePlain({ agent: { session: { header: { id: 'session-boss', cwd: path.join(BLANK, 'nowhere') } } } }) === true, '全局角色应可见 ho_office');
81
+ ok(gatePlain({ agent: { session: { header: { id: 'session-plain', cwd: path.join(BLANK, 'nowhere') } } } }) === false, '普通角色不可见 ho_office');
82
82
  }
83
83
  }
84
84
  // 可选注入多了 connection(/api 载体)。两个都必须是**可选**的,否则旧宿主整插件不激活。
@@ -149,11 +149,11 @@ let payload = null;
149
149
  try { payload = JSON.parse(r1.body); } catch (e) { fails.push('返回不是 JSON:' + e.message); }
150
150
  if (payload) {
151
151
  ok(payload.plugin.version && payload.plugin.version !== '?', '应报出版本号', String(payload.plugin.version));
152
- ok(payload.plugin.tools === 24, 'payload 工具数应为 24', String(payload.plugin.tools));
152
+ ok(payload.plugin.tools === 14, 'payload 工具数应为 14', String(payload.plugin.tools));
153
153
  ok(payload.workspace && payload.workspace.found === false, '空白 HOME 下应判定为没找到工作区', JSON.stringify(payload.workspace));
154
154
  ok(Array.isArray(payload.notes) && payload.notes.length > 0, '没找到工作区时应给中文提示');
155
155
  ok(payload.guide && Array.isArray(payload.guide.situations) && payload.guide.situations.length >= 8, 'payload 应带「怎么用」的时机表');
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)));
156
+ ok(payload.guide && payload.guide.groups.reduce((a, g) => a + g.tools.length, 0) === 14, '工具明细里应列全 14 个工具', payload.guide && String(payload.guide.groups.reduce((a, g) => a + g.tools.length, 0)));
157
157
  ok(payload.plugin.initErrors && payload.plugin.initErrors.length === 0, '初始化错误应为空', JSON.stringify(payload.plugin.initErrors));
158
158
  }
159
159
  ok((await call('GET', '/dsh-handover/nope.json')).code === 404, '未知路径应 404');
@@ -198,6 +198,23 @@ ok(fs.existsSync(path.join(root, '_Control_Center', 'rules', 'Workspace_Charter.
198
198
  ok(fs.existsSync(path.join(root, '_Control_Center', 'templates', 'Prompt_Templates.md')), '建全局根:提示词模板应在');
199
199
  ok(fs.existsSync(path.join(root, 'Projects')) && fs.existsSync(path.join(root, '_Project_Meta')), '建全局根:项目目录应在');
200
200
 
201
+ // 通用规范库:init 要铺出目录与说明;既有工作区靠 scaffold 补齐,且不得覆盖已有内容。
202
+ if (root) {
203
+ const standardsDir = path.join(root, '_Control_Center', 'standards');
204
+ const standardsReadme = path.join(standardsDir, 'README.md');
205
+ ok(fs.existsSync(standardsReadme), '建全局根:通用规范库的说明应在', standardsReadme);
206
+ const scafMod = await import(pathToFileURL(path.join(dist, 'lib', 'scaffold.mjs')).href);
207
+ const keep = '# 本库自己的说明' + '\n';
208
+ fs.writeFileSync(standardsReadme, keep, 'utf8');
209
+ fs.rmSync(path.join(root, '_Control_Center', 'tools'), { recursive: true, force: true });
210
+ const sc1 = scafMod.ensureScaffold(root);
211
+ ok(sc1.ok && sc1.created.indexOf('_Control_Center/tools') !== -1, 'scaffold 应补齐缺失的目录', JSON.stringify(sc1.created));
212
+ ok(fs.readFileSync(standardsReadme, 'utf8') === keep, 'scaffold 不得覆盖已有文件');
213
+ const sc2 = scafMod.ensureScaffold(root);
214
+ ok(sc2.ok && sc2.created.length === 0 && sc2.written.length === 0, 'scaffold 二次执行应无改动', JSON.stringify([sc2.created.length, sc2.written.length]));
215
+ ok(scafMod.ensureScaffold(path.join(BLANK, 'nowhere-at-all')).ok === false, 'scaffold 对不存在的根应报错');
216
+ }
217
+
201
218
  // cwd 已失效(旧路径被删)时,根解析必须退回登记表,不能在幽灵路径上重建 _Control_Center
202
219
  if (root) {
203
220
  const regDir0 = path.join(BLANK, '.dsh', 'storages');
@@ -252,18 +269,18 @@ ok(e3.code === 200 && e3.json && e3.json.ok && e3.json.chars === 0 && e3.json.en
252
269
  const s4 = await postJson('/dsh-handover/register', { path: root || PARENT });
253
270
  ok(s4.code === 501 && s4.json && s4.json.ok === false && String(s4.json.hint || '').indexOf(root || PARENT) !== -1, '无 workspaceRegistry 时应回退给「手工粘贴路径」的提示', JSON.stringify(s4.json));
254
271
 
255
- const initTool = reg.tools.find((t) => t.name === 'ho_init');
256
- ok(!!initTool, '应注册 ho_init(首次安装工具)');
272
+ const initTool = reg.tools.find((t) => t.name === 'ho_workspace');
273
+ ok(!!initTool, '应注册 ho_workspace(含首次安装 action=init)');
257
274
  if (initTool) {
258
275
  const PARENT2 = path.join(BLANK, 'mother2');
259
276
  fs.mkdirSync(PARENT2, { recursive: true });
260
- const ri = await initTool.execute({ parent: PARENT2 }, null);
261
- ok(/已建立全局文件体系/.test(ri.text), 'ho_init 应建树并报出路径', ri.text.split('\n')[0]);
262
- const ri2 = await initTool.execute({ parent: PARENT2 }, null);
263
- ok(/未建立/.test(ri2.text), 'ho_init 对非空目标应拒绝且不改动', ri2.text.split('\n')[0]);
277
+ const ri = await initTool.execute({ action: 'init', parent: PARENT2 }, null);
278
+ ok(/已建立全局文件体系/.test(ri.text), 'ho_workspace(action=init) 应建树并报出路径', ri.text.split('\n')[0]);
279
+ const ri2 = await initTool.execute({ action: 'init', parent: PARENT2 }, null);
280
+ ok(/未建立/.test(ri2.text), 'ho_workspace(action=init) 对非空目标应拒绝且不改动', ri2.text.split('\n')[0]);
264
281
  }
265
282
 
266
- const readonly = ['ho_diag', 'ho_phase', 'ho_brief', 'ho_sessions', 'ho_gate', 'ho_charter', 'ho_drift', 'ho_workspace'];
283
+ const readonly = ['ho_status', 'ho_sessions', 'ho_gate', 'ho_charter', 'ho_workspace'];
267
284
  for (const t of reg.tools) {
268
285
  if (readonly.indexOf(t.name) === -1) continue;
269
286
  try {
@@ -285,7 +302,7 @@ const { resolveConfig } = await import(DIST('lib/config.mjs'));
285
302
  const WS = path.join(BLANK, 'ws');
286
303
  fs.mkdirSync(path.join(WS, '_Control_Center'), { recursive: true });
287
304
  fs.mkdirSync(path.join(WS, 'Projects', 'Demo', 'Demo库'), { recursive: true });
288
- fs.mkdirSync(path.join(WS, '_Misc', 'tasks', '抢名额'), { recursive: true });
305
+ fs.mkdirSync(path.join(WS, '_Misc', 'tasks', '示例任务'), { recursive: true });
289
306
 
290
307
  // ---------- 在任复用:工具层前置校验(任何写入之前返回) ----------
291
308
  {
@@ -305,7 +322,7 @@ fs.mkdirSync(path.join(WS, '_Misc', 'tasks', '抢名额'), { recursive: true });
305
322
  } catch (e) { return String(e && e.message ? e.message : e); }
306
323
  };
307
324
  const lowErr = await newErr({});
308
- ok(/已有在任会话 Demo1/.test(lowErr) && /session-hold/.test(lowErr) && /peer_send/.test(lowErr) && /force=true/.test(lowErr), '项目类别有低水位在任时应拒绝新建并给出复用建议', lowErr);
325
+ ok(/已有在任会话 Demo1/.test(lowErr) && /session-hold/.test(lowErr) && /peer/.test(lowErr) && /force=true/.test(lowErr), '项目类别有低水位在任时应拒绝新建并给出复用建议', lowErr);
309
326
  const forcedErr = await newErr({ force: true });
310
327
  ok(forcedErr.indexOf('已有在任会话') === -1, 'force=true 应越过复用判据', forcedErr);
311
328
 
@@ -337,11 +354,11 @@ const rTitle = resolveRole({ sessionId: 'session-t1', title: '全局9' });
337
354
  ok(rTitle.role === 'global' && rTitle.source === 'title', '角色判定:标题含「全局」→ global', JSON.stringify(rTitle));
338
355
  const rCwd = resolveRole({ sessionId: 'session-t2', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库') });
339
356
  ok(rCwd.role === 'project' && rCwd.project === 'Demo', '角色判定:cwd 在 Projects/<名>/ → project', JSON.stringify(rCwd));
340
- const rMisc = resolveRole({ sessionId: 'session-t3', title: '杂项1', cwd: path.join(WS, '_Misc', 'tasks', '抢名额') });
341
- ok(rMisc.role === 'misc' && rMisc.task === '抢名额', '角色判定:cwd 在 _Misc/tasks/<任务>/ → misc', JSON.stringify(rMisc));
342
- const rPrompt = resolveRole({ sessionId: 'session-t4', title: '', firstPrompt: '先看 ' + path.join(WS, 'Projects', 'Study', 'Study库') + ' 里的内容' });
343
- ok(rPrompt.role === 'project' && rPrompt.project === 'Study', '角色判定:首条提示里的 Projects/<名> → project', JSON.stringify(rPrompt));
344
- const rPlain = resolveRole({ sessionId: 'session-t5', title: '随便', cwd: '/tmp' });
357
+ const rMisc = resolveRole({ sessionId: 'session-t3', title: '杂项1', cwd: path.join(WS, '_Misc', 'tasks', '示例任务') });
358
+ ok(rMisc.role === 'misc' && rMisc.task === '示例任务', '角色判定:cwd 在 _Misc/tasks/<任务>/ → misc', JSON.stringify(rMisc));
359
+ const rPrompt = resolveRole({ sessionId: 'session-t4', title: '', firstPrompt: '先看 ' + path.join(WS, 'Projects', 'Demo2', 'Demo2库') + ' 里的内容' });
360
+ ok(rPrompt.role === 'project' && rPrompt.project === 'Demo2', '角色判定:首条提示里的 Projects/<名> → project', JSON.stringify(rPrompt));
361
+ const rPlain = resolveRole({ sessionId: 'session-t5', title: '未命名', cwd: '/tmp' });
345
362
  ok(rPlain.role === 'plain', '角色判定:都不中 → plain(普通会话)', JSON.stringify(rPlain));
346
363
 
347
364
  writeRole({}, 'session-t6', { role: 'misc', task: 'X', source: 'user' });
@@ -351,7 +368,7 @@ ok(readRoles({}).sessions['session-t6'] && readRoles({}).sessions['session-t6'].
351
368
 
352
369
  // 回归锁:首条提示里的 `Projects/**` 通配曾被当成项目名 `**、_Project_Meta`,
353
370
  // 于是交接路径、备份目录、归档目录全落在这个 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/** 前先问用户。' });
371
+ const rGlob = resolveRole({ sessionId: 'session-glob-prompt', title: '普通会话', cwd: 'C:/Users/x/.dsh/profiles/desktop', firstPrompt: '- 动 _Control_Center/**、Projects/**、_Project_Meta/** 前先问用户。' });
355
372
  ok(rGlob.role === 'plain', '首条提示里的 Projects/** 通配不得被当成项目名', JSON.stringify(rGlob));
356
373
  const hpBad = handoverPathFor(WS, { role: 'project', project: '**、_Project_Meta' });
357
374
  ok(hpBad.path === null && /不合法/.test(String(hpBad.reason || '')), '非法项目名不得拼出交接路径', JSON.stringify(hpBad));
@@ -366,25 +383,79 @@ const hpG = handoverPathFor(WS, { role: 'global' });
366
383
  ok(/Global_Handover\.md$/.test(hpG.path), '全局交接路径', hpG.path);
367
384
  const hpP = handoverPathFor(WS, { role: 'project', project: 'Demo' });
368
385
  ok(/_Project_Meta/.test(hpP.path) && /Demo/.test(hpP.path) && /Handover\.md$/.test(hpP.path), '项目交接路径应落 _Project_Meta/<项目>/Handover.md', hpP.path);
369
- const hpM = handoverPathFor(WS, { role: 'misc', task: '抢名额' });
370
- ok(/_Misc/.test(hpM.path) && /抢名额/.test(hpM.path) && /Handover\.md$/.test(hpM.path), '杂项交接路径应落 _Misc/tasks/<任务>/Handover.md', hpM.path);
386
+ const hpM = handoverPathFor(WS, { role: 'misc', task: '示例任务' });
387
+ ok(/_Misc/.test(hpM.path) && /示例任务/.test(hpM.path) && /Handover\.md$/.test(hpM.path), '杂项交接路径应落 _Misc/tasks/<任务>/Handover.md', hpM.path);
371
388
  const hpU = handoverPathFor(WS, { role: 'plain' });
372
389
  ok(hpU.path === null && !!hpU.reason, '普通会话不得给出交接路径(不退回全局)', JSON.stringify(hpU));
373
390
 
374
391
  const tsG = toolsetFor('global');
375
- ok(tsG.allow.length === 24 && tsG.deny.length === 0, '全局应可见全部 24 个工具', JSON.stringify([tsG.allow.length, tsG.deny.length]));
392
+ ok(tsG.allow.length === 14 && tsG.deny.length === 0, '全局应可见全部 14 个工具', JSON.stringify([tsG.allow.length, tsG.deny.length]));
376
393
  const tsP = toolsetFor('project');
377
394
  ok(tsP.allow.length === 11, '项目角色应可见 11 个工具', String(tsP.allow.length));
378
395
  ok(tsP.allow.indexOf('ho_handover') !== -1 && tsP.allow.indexOf('ho_checkpoint') !== -1, '项目角色必须能写自己的交接与压缩');
379
- ok(tsP.allow.indexOf('ho_pack') === -1 && tsP.allow.indexOf('ho_sessions') === -1, '项目角色不该看见全局管理工具');
396
+ ok(tsP.allow.indexOf('ho_sessions') === -1 && tsP.allow.indexOf('ho_changelog') === -1, '项目角色不该看见全局管理工具');
380
397
  ok(tsP.deny.indexOf('ho_handover') === -1 && tsP.deny.indexOf('run_code') === -1, 'deny 名单不得含保留名或本角色已允许的工具');
381
398
  const tsM = toolsetFor('misc');
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));
399
+ ok(tsM.allow.length === 10 && tsM.allow.indexOf('peer') !== -1 && tsM.allow.indexOf('ho_gate') === -1, '杂项角色:10 个,含 peer、不含 ho_gate', String(tsM.allow.length));
383
400
  const tsU = toolsetFor('plain');
384
- ok(tsU.allow.length === 0 && tsU.deny.length === 24, '普通会话不载入任何插件工具', JSON.stringify([tsU.allow.length, tsU.deny.length]));
401
+ ok(tsU.allow.length === 0 && tsU.deny.length === 14, '普通会话不载入任何插件工具', JSON.stringify([tsU.allow.length, tsU.deny.length]));
385
402
  const rSub = resolveRole({ sessionId: 'session-sub', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库'), subagent: true });
386
403
  ok(rSub.role === 'plain' && rSub.source === 'subagent', '子会话默认按普通处理,不按 cwd 判项目', JSON.stringify(rSub));
387
404
 
405
+ // ---------- 文档定位(docmap)与授权提示:写入权限、路径校验、空 map、注入 ----------
406
+ {
407
+ const { pmDocmapTool } = await import(DIST('tools/ho_docmap.mjs'));
408
+ const { registerHolder } = await import(DIST('lib/office.mjs'));
409
+ const { docmapBlock, docmapRelPath, readDocmap } = await import(DIST('lib/docmap.mjs'));
410
+ const manDm = await import(DIST('lib/manual.mjs'));
411
+ const { grantConsent, consumedConsent, activeConsentsFor, consentPromptLines } = await import(DIST('lib/consent.mjs'));
412
+
413
+ const dmInfo = { role: 'project', project: 'Demo', task: '' };
414
+ const dmEnv = {
415
+ cfg: resolveConfig({ workspaceRoot: WS }),
416
+ resolveWs: () => WS,
417
+ selfId: (exec) => (exec && exec.agent && exec.agent.session ? exec.agent.session.header.id : null),
418
+ roleOf: () => dmInfo,
419
+ refreshPromptFor: () => true,
420
+ };
421
+ const dmExec = (sid) => ({ agent: { session: { header: { id: sid, cwd: path.join(WS, 'Projects', 'Demo', 'Demo库') } } } });
422
+ fs.writeFileSync(path.join(WS, 'Projects', 'Demo', 'README.md'), 'x', 'utf8');
423
+ registerHolder(WS, { kind: 'project', project: 'Demo', name: 'Demo1', sessionId: 'session-dm', workspace: WS });
424
+ const dm = pmDocmapTool(dmEnv);
425
+ const wroteDm = await dm.execute({ action: 'write', entries: [{ path: 'Projects/Demo/README.md', about: '项目简介', when: '动手前' }] }, dmExec('session-dm'));
426
+ ok(/已写入/.test(wroteDm.text), 'docmap:在任者写自己的 map 应成功', wroteDm.text.split('\n')[0]);
427
+ const readBack = readDocmap(WS, dmInfo);
428
+ ok(readBack.exists && readBack.entries.length === 1 && readBack.entries[0].path === 'Projects/Demo/README.md', 'docmap:写盘后可解析回一条', JSON.stringify(readBack.entries));
429
+ const dmBlock = docmapBlock(WS, dmInfo);
430
+ ok(/文档定位/.test(dmBlock) && /Projects\/Demo\/README\.md/.test(dmBlock) && /动手前/.test(dmBlock), 'docmap:注入块含路径与时机', dmBlock);
431
+ const injectedDm = manDm.scopedManual(dmInfo, { ws: WS, docmap: dmBlock });
432
+ ok(injectedDm.indexOf('文档定位') !== -1 && injectedDm.split('文档定位').length === 2, 'docmap:注入段只出现一次', String(injectedDm.split('文档定位').length - 1));
433
+ let missErr = '';
434
+ try { await dm.execute({ action: 'write', entries: [{ path: 'Projects/Demo/nope.md', about: 'x' }] }, dmExec('session-dm')); } catch (e) { missErr = String(e && e.message ? e.message : e); }
435
+ ok(/未写入/.test(missErr) && /nope\.md/.test(missErr), 'docmap:路径不存在应整条拒绝并点名', missErr);
436
+ ok(readDocmap(WS, dmInfo).entries.length === 1, 'docmap:拒绝后原 map 不得被改');
437
+ registerHolder(WS, { kind: 'project', project: 'Demo', name: 'Demo2', sessionId: 'session-dm2', workspace: WS });
438
+ let oldErr = '';
439
+ try { await dm.execute({ action: 'write', entries: [{ path: 'Projects/Demo/README.md', about: 'x' }] }, dmExec('session-dm')); } catch (e) { oldErr = String(e && e.message ? e.message : e); }
440
+ ok(/只有在任会话可以写/.test(oldErr), 'docmap:已交出的旧会话只读', oldErr);
441
+ const emptyDm = await dm.execute({ action: 'write', entries: [] }, dmExec('session-dm2'));
442
+ ok(/空 map/.test(emptyDm.text), 'docmap:空 entries 应写明不注入', emptyDm.text);
443
+ ok(docmapBlock(WS, dmInfo) === '', 'docmap:空 map 注入块为空串');
444
+ ok(docmapRelPath(dmInfo).replace(/\\/g, '/') === '_Control_Center/records/docmap/project-Demo.md', 'docmap:文件路径按会话命名', docmapRelPath(dmInfo));
445
+
446
+ grantConsent('session-dm2', 'blank-smoke', 'compact');
447
+ const actDm = activeConsentsFor('session-dm2');
448
+ const consentLines = consentPromptLines(actDm);
449
+ ok(actDm.length === 1 && consentLines.length === 1 && /用户已授权压缩/.test(consentLines[0]), '授权:有效期内应生成一行提示', JSON.stringify(consentLines));
450
+ ok(activeConsentsFor('session-other').length === 0, '授权:别的会话看不到', JSON.stringify(activeConsentsFor('session-other')));
451
+ const withConsent = manDm.scopedManual(dmInfo, { ws: WS, docmap: '', consents: actDm });
452
+ ok(withConsent.indexOf('用户已授权压缩') !== -1, '授权:提示段应带那一行', withConsent.slice(-160));
453
+ consumedConsent('session-dm2', 'compact');
454
+ ok(consentPromptLines(activeConsentsFor('session-dm2')).length === 0, '授权:用掉后提示消失(不占位)');
455
+ const afterUse = manDm.scopedManual(dmInfo, { ws: WS, docmap: '', consents: activeConsentsFor('session-dm2') });
456
+ ok(afterUse.indexOf('已授权') === -1, '授权:用掉后提示段不再有该行');
457
+ }
458
+
388
459
  // ---------- 骨架档案:预设、默认骨架、注册、校验 ----------
389
460
  const prof = await import(DIST('lib/profile.mjs'));
390
461
  const pjs = prof.presets();
@@ -467,8 +538,8 @@ ok(tg.indexOf('收尾并停止') !== -1, '续跑文案应允许交接无待办
467
538
  const globName = io.archiveFileName('E:/ws/_Control_Center/records/Global_Handover.md', stamp);
468
539
  ok(globName === 'Global_Handover_' + stamp + '.md', '全局归档名应为 Global_Handover_<时间>.md', globName);
469
540
  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);
541
+ const miscDir = ad.archiveDirFor(WS, { role: 'misc', task: '示例任务' }).replace(/\\/g, '/');
542
+ ok(miscDir.endsWith('_Misc/tasks/示例任务/_archive'), '杂项归档目录应在任务目录内(_archive)', miscDir);
472
543
  const handTool = reg.tools.find((t) => t.name === 'ho_handover');
473
544
  ok(!!handTool, '应注册 ho_handover');
474
545
  if (handTool) {
@@ -499,19 +570,21 @@ const projAgent = {
499
570
  };
500
571
  for (const cb of listeners) cb({ agent: projAgent, source: 'fresh' });
501
572
  ok(calls.restrict.length === 0, '按步路径不用 restrict 收窄工具', String(calls.restrict.length));
502
- const projGatePack = gateFor('ho_pack');
573
+ const projGatePack = gateFor('ho_sessions');
503
574
  const projGateHand = gateFor('ho_handover');
504
575
  const projExec = { agent: projAgent };
505
- ok(typeof projGatePack === 'function' && projGatePack(projExec) === false, '项目角色不可见 ho_pack(每步条件)');
576
+ ok(typeof projGatePack === 'function' && projGatePack(projExec) === false, '项目角色不可见 ho_sessions(每步条件)');
506
577
  ok(typeof projGateHand === 'function' && projGateHand(projExec) === true, '项目角色可见 ho_handover(每步条件)');
507
- ok(gateFor('ho_brief')(projExec) === false, '项目角色不可见 ho_brief');
578
+ ok(gateFor('ho_changelog')(projExec) === false, '项目角色不可见 ho_changelog');
508
579
  ok(calls.sections.length === 1 && calls.sections[0].name === 'dsh-handover:manual', '应注册同名提示段遮蔽兜底段');
509
580
  const secNorm = String(calls.sections[0].text).replace(/\\/g, '/');
510
581
  ok(secNorm.indexOf('Projects/Demo') !== -1 && secNorm.indexOf('_Project_Meta/Demo/Handover.md') !== -1, '项目提示段应含自己的路径', secNorm.slice(0, 200));
511
582
  ok(secNorm.indexOf('任务完成不报全局') !== -1, '项目提示段应写明任务完成不向全局报告', secNorm.slice(0, 400));
512
583
  ok(secNorm.indexOf('任务达成') === -1, '项目提示段不得再出现任务达成');
513
- ok(secNorm.indexOf('可直接 peer_send') !== -1, '项目提示段应允许会话之间直接对话', secNorm.slice(0, 400));
584
+ ok(secNorm.indexOf('可直接 peer') !== -1, '项目提示段应允许会话之间直接对话', secNorm.slice(0, 400));
514
585
  ok(secNorm.indexOf('resume=wait') !== -1, '项目提示段应说明收尾压缩不唤醒');
586
+ ok(secNorm.indexOf('_Control_Center/standards/') !== -1, '项目提示段应给出通用规范的查找范围', secNorm.slice(-240));
587
+ ok(secNorm.indexOf('把某某写成规范') !== -1 && secNorm.indexOf('本会话所在的工作区') !== -1 && secNorm.indexOf('写入通用规范库') !== -1 && secNorm.indexOf('报全局') !== -1, '项目提示段应写明未指明写本库、通用规范库报全局', secNorm.slice(-400));
515
588
  console.log('项目提示段 token 实测:' + budget.estimateTokens(calls.sections[0].text) + '(参考值 ' + budget.TOKEN_BUDGET.projectTokens + ',不判失败)');
516
589
  ok(calls.sections[0].text.indexOf('子智能体') === -1, '项目段不得携带子智能体派发纪律(不属于本插件)');
517
590
  // 真实工作区路径 + 迁移提示是最坏情形,参考值另设一档
@@ -572,8 +645,10 @@ ok(gCalls.injected === 0, '全局不再重复注入工作流(提示段里已
572
645
  // 分层护栏——常驻段只留判定,工作流细节必须留在手册里
573
646
  {
574
647
  const g0 = String(gCalls.sections[0].text);
575
- ok(g0.indexOf('ho_pack') === -1 && g0.indexOf('move-probe') === -1, '常驻段不得回灌工作流细节(应放手册)');
648
+ ok(g0.indexOf('ho_backup') === -1 && g0.indexOf('move-probe') === -1, '常驻段不得回灌工作流细节(应放手册)');
576
649
  ok(g0.indexOf('ho_charter(doc="runtime", section=…') !== -1, '常驻段应给出按节取用的指针');
650
+ ok(g0.indexOf('_Control_Center/standards/') !== -1, '全局段应给出通用规范的查找范围', g0.slice(-240));
651
+ ok(g0.indexOf('把某某写成规范') !== -1 && g0.indexOf('写入通用规范库') !== -1 && g0.indexOf('本会话所在的工作区') !== -1, '全局段应写明规范的写入范围', g0.slice(-400));
577
652
  const rm = fs.readFileSync(path.join(dist, 'templates', 'Runtime_Manual.md'), 'utf8');
578
653
  ok(rm.indexOf('## 11. 首次安装') !== -1 && rm.indexOf('## 12. 提示词预算') !== -1, '手册应含首次安装与提示词预算两节');
579
654
  ok(rm.indexOf('续用优先') !== -1, '手册 §5 应含子智能体续用纪律');
@@ -601,14 +676,14 @@ ok(gCalls.injected === 0, '全局不再重复注入工作流(提示段里已
601
676
  ok(uiHits.length === 0, '界面文案应一律使用标准书面语(词表与符号扫描)', fmt(uiHits));
602
677
  ok(docHits.length === 0, '文档文案应一律使用标准书面语(词表与符号扫描)', fmt(docHits));
603
678
  }
604
- ok(opsSrc.indexOf('不得自行开展作业') !== -1 && opsSrc.indexOf('待用户指派') !== -1, '启动提示词应先报告再待指派');
679
+ ok(opsSrc.indexOf('不得自行开展作业') !== -1 && opsSrc.indexOf('待当前用户指派') !== -1, '启动提示词应先报告再待指派');
605
680
  }
606
681
 
607
682
  // 按步路径(0.2.2 起):创建即按角色登记提示段;工具可见性由每步条件算,不再调用 restrict。
608
683
  const unrCalls = { restrict: 0, section: null };
609
684
  const unrAgent = {
610
685
  id: 'session-unr',
611
- session: { header: { id: 'session-unr', title: '随便', cwd: '/tmp/x' } },
686
+ session: { header: { id: 'session-unr', title: '未命名', cwd: '/tmp/x' } },
612
687
  ctx: {
613
688
  tools: { restrict: () => { unrCalls.restrict++; return () => { }; } },
614
689
  systemPrompt: { section: (o) => { unrCalls.section = o; return () => { }; } },
@@ -625,7 +700,7 @@ writeRole({}, 'session-unr', { role: 'project', project: 'Demo', source: 'user'
625
700
  for (const cb of ((reg.listeners && reg.listeners['agent/status']) || [])) cb({ agent: unrAgent, status: 'running' });
626
701
  ok(!!unrCalls.section && /项目/.test(String(unrCalls.section.text)), '开轮前核对应按新角色补装提示段', JSON.stringify(unrCalls.section && unrCalls.section.name));
627
702
 
628
- // 旧内核(没有 tools.resolve)才走 restrict:创建时按普通收窄 24 个,改角色后开轮前换成 13 个。
703
+ // 旧内核(没有 tools.resolve)才走 restrict:创建时按普通收窄 14 个,改角色后开轮前换成 3 个。
629
704
  {
630
705
  const legacyCalls = { restrict: 0, lastDeny: null, disposed: 0 };
631
706
  const legacyListeners = {};
@@ -651,10 +726,10 @@ ok(!!unrCalls.section && /项目/.test(String(unrCalls.section.text)), '开轮
651
726
  ctx: { tools: legacyTools, systemPrompt: legacyCtx.systemPrompt },
652
727
  };
653
728
  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));
729
+ ok(legacyCalls.restrict === 1 && legacyCalls.lastDeny && legacyCalls.lastDeny.length === 14, '旧内核:创建时按普通收窄 14 个', JSON.stringify(legacyCalls.lastDeny && legacyCalls.lastDeny.length));
655
730
  writeRole({}, 'session-legacy', { role: 'project', project: 'Demo', source: 'user' });
656
731
  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));
732
+ ok(legacyCalls.restrict === 2 && legacyCalls.lastDeny && legacyCalls.lastDeny.length === 3, '旧内核:开轮前换成项目角色的 3 个', JSON.stringify(legacyCalls.lastDeny && legacyCalls.lastDeny.length));
658
733
  ok(legacyCalls.disposed === 1, '旧内核:换角色应撤销旧限制', String(legacyCalls.disposed));
659
734
  }
660
735
  // ---------- 角色路由 ----------
@@ -679,7 +754,7 @@ const oBad = await orderCall({ action: 'order', order: 'wave', session_id: 'sess
679
754
  ok(/只能是 handover 或 compact/.test(oBad), 'order 取值应受限', oBad);
680
755
  // 交接/压缩指令必须先问用户
681
756
  const oNoConfirm = await orderCall({ action: 'order', order: 'handover', session_id: 'session-norole' });
682
- ok(/需要用户确认/.test(oNoConfirm), 'order 未确认应拒绝', oNoConfirm);
757
+ ok(/需要当前用户确认/.test(oNoConfirm), 'order 未确认应拒绝', oNoConfirm);
683
758
  const oUnr = await orderCall({ action: 'order', order: 'handover', session_id: 'session-norole', confirm: true });
684
759
  ok(/没有交接路径/.test(oUnr), '未设角色的会话不能下交接指令(避免退回全局交接)', oUnr);
685
760
  const oCold = await orderCall({ action: 'order', order: 'compact', session_id: 'session-r1', confirm: true });
@@ -697,15 +772,15 @@ const guardExec = (name, argsObj, sid, cwd, title) => ({
697
772
  });
698
773
  ok(typeof reg.guardFn === 'function', '应注册工具守卫', typeof reg.guardFn);
699
774
  if (typeof reg.guardFn === 'function') {
700
- const gGlobal = reg.guardFn(guardExec('peer_send', { session_id: 'session-proj-x' }, 'session-glob', WS, '全局9'));
701
- ok(gGlobal === undefined, '全局角色 peer_send 应放行', String(gGlobal));
702
- const gReport = reg.guardFn(guardExec('peer_send', { session_id: 'session-glob' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
775
+ const gGlobal = reg.guardFn(guardExec('peer', { action: 'send', session_id: 'session-proj-x' }, 'session-glob', WS, '全局9'));
776
+ ok(gGlobal === undefined, '全局角色 peer(action=send) 应放行', String(gGlobal));
777
+ const gReport = reg.guardFn(guardExec('peer', { action: 'send', session_id: 'session-glob' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
703
778
  ok(gReport === undefined, '项目会话向全局报告应放行', String(gReport));
704
- const gPeer = reg.guardFn(guardExec('peer_send', { session_id: 'session-proj' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
779
+ const gPeer = reg.guardFn(guardExec('peer', { action: 'send', session_id: 'session-proj' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
705
780
  ok(gPeer === undefined, '会话之间可直接对话,不必经全局转述', String(gPeer));
706
- const gNew = reg.guardFn(guardExec('peer_new', { workspace: 'Demo库' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
781
+ const gNew = reg.guardFn(guardExec('peer', { action: 'new', workspace: 'Demo库' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
707
782
  ok(typeof gNew === 'string' && /仅属全局/.test(gNew), '项目会话不得建平级会话', String(gNew));
708
- const gMisc = reg.guardFn(guardExec('peer_new', { workspace: 'Misc' }, 'session-misc', path.join(WS, '_Misc', 'tasks', '抢名额'), '杂项1'));
783
+ const gMisc = reg.guardFn(guardExec('peer', { action: 'new', workspace: 'Misc' }, 'session-misc', path.join(WS, '_Misc', 'tasks', '示例任务'), '杂项1'));
709
784
  ok(gMisc === undefined, '杂项会话自建 Misc 换任应放行', String(gMisc));
710
785
  }
711
786
 
@@ -732,7 +807,7 @@ const plainRows = [];
732
807
  for (let i = 0; i < 10; i++) {
733
808
  plainRows.push({
734
809
  sid: 'session-p' + i, title: '旧会话' + i, role: 'plain', roleLabel: '普通', source: 'none',
735
- project: '', task: '', pct: i + 1, state: 'idle', active: false, seen: '2026-10-21 20:19 +0800',
810
+ project: '', task: '', wsKey: '', wsTitle: '', wsPath: '', pct: i + 1, state: 'idle', active: false, onduty: false, seen: '2026-10-21 20:19 +0800',
736
811
  });
737
812
  }
738
813
 
@@ -745,22 +820,25 @@ const FIXTURE = {
745
820
  guide: payload ? payload.guide : { intro: ['说明'], situations: [{ when: 'w', say: 's', what: 'x' }], groups: [{ title: 'g', tools: [['ho_x', 'y']] }], rules: ['r'] },
746
821
  extraPrompt: '',
747
822
  extraPromptEnabled: false,
748
- gate: { ok: true, verdict: '全部落位。', total: 1, bodyLines: 24, libraries: [{ name: 'Mine', ok: true, titleOk: true, waterOk: true, missing: 0, lines: 24 }] },
749
- handover: { global: { verdict: '通过', reasons: [] }, projects: [{ name: 'Mine', verdict: '通过', reasons: [] }, { name: 'Idle', verdict: '缺节', reasons: ['缺 ## 1. 正在做的事(2026-10-12 起的强制项,本项目还没补)'] }], fail: 0 },
823
+ gate: { ok: true, verdict: '全部落位。', total: 1, bodyLines: 24, libraries: [{ name: 'Demo1', ok: true, titleOk: true, waterOk: true, missing: 0, lines: 24 }] },
824
+ handover: { global: { verdict: '通过', reasons: [] }, projects: [{ name: 'Demo1', verdict: '通过', reasons: [] }, { name: 'Idle', verdict: '缺节', reasons: ['缺 ## 1. 正在做的事(2026-10-12 起的强制项,本项目还没补)'] }], misc: [{ name: '示例任务', verdict: '通过', reasons: [] }], fail: 0 },
750
825
  sessions: {
826
+ workspaces: [{ key: 'w1', title: '全局', path: 'C:/ws' }],
751
827
  rows: [
752
- { sid: 'session-y', project: 'Mine', pct: 34.6, state: 'idle', active: true, seen: '2026-10-23 23:19 +0800' },
753
- { sid: 'session-l', project: 'Study', pct: 50, state: 'idle', active: false, seen: '2026-10-22 20:00 +0800' },
828
+ { sid: 'session-y', title: 'Demo1', project: 'Demo1', role: 'project', roleLabel: '项目', dutyKind: '项目', dutyKey: 'Demo1', pct: 34.6, state: 'idle', active: true, onduty: true, seen: '2026-10-23 23:19 +0800' },
829
+ { sid: 'session-g1', title: '全局1', role: 'global', roleLabel: '全局', dutyKind: '全局', dutyKey: '全局1', pct: 40, state: 'idle', active: true, onduty: true, seen: '2026-11-07 21:00 +0800' },
830
+ { sid: 'session-misc', title: '示例任务', role: 'misc', roleLabel: '杂项', dutyKind: '杂项', dutyKey: '示例任务', pct: 5, state: 'idle', active: false, onduty: true, seen: '2026-10-20 09:00 +0800' },
831
+ { sid: 'session-l', project: 'Demo2', title: 'Demo2', role: 'project', roleLabel: '项目', dutyKind: '项目', dutyKey: 'Demo2', pct: 50, state: 'idle', active: false, onduty: false, seen: '2026-10-22 20:00 +0800' },
754
832
  ],
755
833
  all: [
756
- { 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' },
757
- { 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' },
758
- { 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' },
834
+ { sid: 'session-g1', title: '全局1', role: 'global', roleLabel: '全局', source: 'title', project: '', task: '', wsKey: 'w1', wsTitle: '全局', wsPath: 'C:/ws', pct: 40, state: 'idle', active: true, onduty: true, seen: '2026-11-07 21:00 +0800' },
835
+ { sid: 'session-y', title: 'Demo1', role: 'project', roleLabel: '项目', source: 'store', project: 'Demo1', task: '', wsKey: 'w1', wsTitle: '全局', wsPath: 'C:/ws', pct: 34.6, state: 'idle', active: true, onduty: true, seen: '2026-10-23 23:19 +0800' },
836
+ { sid: 'session-l', title: 'Demo2', role: 'project', roleLabel: '项目', source: 'cwd', project: 'Demo2', task: '', wsKey: 'w1', wsTitle: '全局', wsPath: 'C:/ws', pct: 50, state: 'idle', active: false, onduty: false, seen: '2026-10-22 20:00 +0800' },
759
837
  ].concat(plainRows),
760
- notify: [{ sid: 'session-y', project: 'Mine' }], hot: 2, unknown: 0, cutoff: '2026-11-07 09:25 +0800', silentHours: 12,
838
+ notify: [{ sid: 'session-y', key: 'Demo1', name: 'Demo1' }], hot: 2, unknown: 0, cutoff: '2026-11-07 09:25 +0800', silentHours: 12,
761
839
  verdict: '2 个会话已进预警区(≥32%),应先收齐交接再换会话',
762
840
  },
763
- consent: [{ sessionId: 'session-y', leftMs: 120000 }],
841
+ consent: [{ sessionId: 'session-y', leftMs: 120000, kind: 'compact', onduty: true }],
764
842
  notes: [],
765
843
  };
766
844
  globalThis.fetch = async () => ({ ok: true, json: async () => FIXTURE });
@@ -817,29 +895,50 @@ function textOf(node, out = []) {
817
895
  }
818
896
  const text = textOf(tree).join(' ');
819
897
  const treeJson = JSON.stringify(tree);
820
- for (const want of ['dsh-handover', '刷新', '读数', '压缩授权', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '使用说明']) {
898
+ for (const want of ['dsh-handover', '刷新', '读数', '授权', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '工具明细', '使用说明']) {
821
899
  ok(text.indexOf(want) !== -1, '成体视图应出现「' + want + '」');
822
900
  }
823
901
 
824
902
  // 回到重构前的版式——常用区直接展开,只折叠低频项;授权可直接点。
825
903
  const sections = tree.kids.filter((k) => k && k.type === 'section');
826
904
  const cardTitles = sections.map((sec) => textOf(sec.kids[0]).join(''));
827
- const wantOrder = ['压缩授权', '移动全局根目录', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '插件', '会话间通信守卫', '使用说明'];
828
- ok(JSON.stringify(cardTitles) === JSON.stringify(wantOrder), '分区应自上而下为授权、迁移、角色、提示词、只读数据、插件、守卫、说明', JSON.stringify(cardTitles));
905
+ const wantOrder = ['授权', '移动全局根目录', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '插件', '最近一次会话间通信判定', '工具明细', '使用说明'];
906
+ ok(JSON.stringify(cardTitles) === JSON.stringify(wantOrder), '分区应自上而下为授权、迁移、角色、提示词、只读数据、插件、判定、说明', JSON.stringify(cardTitles));
829
907
 
830
908
  const folds = treeJson.split('"details"').length - 1;
831
- ok(folds === 3, '只折叠低频项(迁移、前置提示词、使用说明),常用区直接展开', String(folds));
909
+ ok(folds === 6, '折叠项应为迁移、前置提示词、工具明细、使用说明与两个工作区组', String(folds));
832
910
 
833
- // 压缩授权:给出可点击的会话按钮,用户不必手填 id。
834
- const allowBtns = [];
911
+ // 会话角色:按 DSH 工作区分组、默认折叠,已归档会话不列出。
912
+ const roleCard = sections.find((sec) => textOf(sec.kids[0]).join('') === '会话角色');
913
+ const roleDetails = [];
914
+ (function walk(n) {
915
+ if (!n || typeof n !== 'object') return;
916
+ if (Array.isArray(n)) { n.forEach(walk); return; }
917
+ if (n.type === 'details') roleDetails.push(n);
918
+ (n.kids || []).forEach(walk);
919
+ })(roleCard);
920
+ ok(roleDetails.length === 2, '会话角色应按工作区分组(全局+未归属)', String(roleDetails.length));
921
+ ok(roleDetails.every((d) => d.props.open !== true), '工作区分组默认收起');
922
+ const roleText = textOf(roleCard).join(' ');
923
+ ok(roleText.indexOf('全局(3 个会话)') !== -1 && roleText.indexOf('未归属工作区') !== -1, '分组标题应带会话数', roleText.slice(0, 200));
924
+ ok(roleText.indexOf('已归档会话不列出') === -1 && roleText.indexOf('点击「设置角色」当场生效') === -1, '会话角色卡不再堆说明文字,只留标题与分组');
925
+
926
+ // 授权:只列在任会话,两组各一列按钮,用户不必手填 id。
927
+ const grantBtns = [];
835
928
  (function walk(n) {
836
929
  if (!n || typeof n !== 'object') return;
837
930
  if (Array.isArray(n)) { n.forEach(walk); return; }
838
- if (n.type === 'button' && textOf(n).join('').indexOf('允许 ') === 0) allowBtns.push(textOf(n).join(''));
931
+ if (typeof n.type === 'function') { walk(n.type(n.props || {})); return; }
932
+ if (n.type === 'button') {
933
+ const label = textOf(n).join('');
934
+ if (label === '授权压缩' || label === '授权换任') grantBtns.push(label);
935
+ }
839
936
  (n.kids || []).forEach(walk);
840
937
  })(tree);
841
- ok(allowBtns.length >= 3, '压缩授权应提供可点击的会话按钮', JSON.stringify(allowBtns.slice(0, 5)));
842
- ok(text.indexOf('允许 y') !== -1, '授权按钮须带会话短号');
938
+ ok(grantBtns.filter((x) => x === '授权压缩').length === 3, '授权压缩:两张在任会话按钮+输入行按钮', JSON.stringify(grantBtns));
939
+ ok(grantBtns.filter((x) => x === '授权换任').length === 3, '授权换任:两张在任会话按钮+输入行按钮', JSON.stringify(grantBtns));
940
+ ok(text.indexOf('有授权') !== -1, '有有效授权时应显示「有授权」');
941
+ ok(text.indexOf('当前无有效授权') === -1, '不再显示无授权占位');
843
942
 
844
943
  // 角色下拉含普通,不再回退到空值。
845
944
  ok(text.indexOf('普通') !== -1, '角色下拉应含普通选项');
@@ -860,23 +959,42 @@ ok(migrateDetail && migrateDetail.type === 'details' && migrateDetail.props.open
860
959
  // 交接体检只写问题,不带括注。
861
960
  ok(text.indexOf('缺 ## 1. 正在做的事') !== -1 && text.indexOf('强制项') === -1, '交接体检的问题列不应带括注说明');
862
961
 
863
- // 会话水位只列项目会话,并给出本轮需通知。
864
- ok(text.indexOf('Mine') !== -1 && text.indexOf('34.6%') !== -1, '会话水位应列出项目会话与水位');
962
+ // 会话水位只列在任会话,全局、项目、杂项都要有,并给出本轮需通知。
963
+ const waterCard = sections.find((sec) => textOf(sec.kids[0]).join('') === '会话水位');
964
+ const waterText = textOf(waterCard).join(' ');
965
+ ok(waterText.indexOf('Demo1') !== -1 && waterText.indexOf('34.6%') !== -1, '会话水位应列出项目在任会话与水位');
966
+ ok(waterText.indexOf('全局1') !== -1 && waterText.indexOf('示例任务') !== -1, '会话水位应含全局与杂项的在任会话', waterText.slice(0, 240));
967
+ ok(waterText.indexOf('Demo2') === -1, '会话水位不列已交出的会话', waterText.slice(0, 240));
865
968
  ok(text.indexOf('本轮需通知') !== -1, '水位段应给出本轮需通知');
866
- ok(text.indexOf('Study1') !== -1, '角色表应列出平级会话');
969
+ ok(text.indexOf('Demo2') !== -1, '角色表应列出平级会话');
970
+ const hoCard = sections.find((sec) => textOf(sec.kids[0]).join('') === '交接文档体检');
971
+ const hoText = textOf(hoCard).join(' ');
972
+ ok(hoText.indexOf('杂项') !== -1 && hoText.indexOf('示例任务') !== -1, '交接体检应覆盖已登记的杂项任务', hoText.slice(0, 240));
867
973
 
868
974
  // 版式约束:无进度条、无图形结论符号。
869
975
  ok(treeJson.indexOf('dshh-bar') === -1, '不得使用进度条');
870
976
  const clientSrc = fs.readFileSync(path.join(dist, 'client.js'), 'utf8');
871
977
  ok(!/[\u2705\u274C\u26A0\u2717]/.test(clientSrc), '界面不得使用图形结论符号');
872
- ok(clientSrc.indexOf('delWord.trim() !== "删除"') !== -1, '删除旧副本须输入确认词');
978
+ ok(clientSrc.indexOf('delWord.trim() !== "删除"') !== -1, '删除旧备份须输入确认词');
979
+ // 保留的旧备份不算待处理:迁移卡只在妨碍使用的情形下提示。
980
+ ok(clientSrc.indexOf('(st.phase === "switched" && (st.olds || []).length)') === -1, '保留的旧备份不得计入待处理');
981
+ ok(clientSrc.indexOf('旧备份保留,不影响使用') !== -1, '迁移完成后应注明旧备份不影响使用');
982
+ // 角色顺序:全局、项目、杂项、普通(表格与下拉同序)。
983
+ {
984
+ const g = clientSrc.indexOf('value: "global"');
985
+ const p = clientSrc.indexOf('value: "project"');
986
+ const m = clientSrc.indexOf('value: "misc"');
987
+ const n = clientSrc.indexOf('value: "plain"');
988
+ ok(g !== -1 && p !== -1 && m !== -1 && n !== -1 && g < p && p < m && m < n, '角色下拉应为全局、项目、杂项、普通', JSON.stringify({ g, p, m, n }));
989
+ ok(clientSrc.indexOf('ROLE_ORDER') !== -1, '会话角色应按角色顺序排列');
990
+ }
873
991
 
874
992
  // 未接入工作区:一键部署卡片置顶。
875
993
  const NOT_READY = {
876
994
  generatedAt: '2026-11-07 21:25 +0800',
877
995
  plugin: { name: '@mingmaple/dsh-handover', version: 'test', tools: 22, inject: ['tools'], initErrors: [], scheduler: '就绪' },
878
996
  workspace: { root: '', found: false },
879
- setup: { steps: ['准备一个长期存在的目录', '粘贴路径后点「一键部署」'] },
997
+ setup: { steps: ['准备一个长期存在的目录', '粘贴路径后点击「一键部署」'] },
880
998
  guide: payload ? payload.guide : null,
881
999
  notes: ['未找到 _Control_Center:这个实例还没接入工作区。'],
882
1000
  };
@@ -890,7 +1008,7 @@ ok(titles3[0] === '首次安装', '未接入工作区时首次安装置顶', JSO
890
1008
  const text3 = textOf(tree3).join(' ');
891
1009
  ok(text3.indexOf('一键部署') !== -1, '未接入时应能一键部署(建体系+注册工作区+建全局会话)');
892
1010
  ok(text3.indexOf('仅建立文件体系') !== -1, '未接入时仍可只建立文件体系');
893
- ok(text3.indexOf('待处理') !== -1, '未接入时应显示待处理项');
1011
+ ok(text3.indexOf('未找到 _Control_Center') !== -1, '未接入工作区时应给出中文提示');
894
1012
 
895
1013
  // 已接入但没有全局会话:给出补建入口,且排在常规卡片之前。
896
1014
  const READY_NO_GLOBAL = Object.assign({}, FIXTURE, { globalSessions: 0 });
@@ -1019,6 +1137,71 @@ try {
1019
1137
  } catch (e) { newErr = String(e && e.message ? e.message : e); }
1020
1138
  ok(/工作区/.test(newErr) && /已注册的工作区/.test(newErr), '新建会话缺工作区时应报错并列出可选工作区', newErr.slice(0, 160));
1021
1139
 
1140
+ // 回归锁(0.9.14):① 新建杂项会话的启动提示读本任务交接,不退回全局交接;
1141
+ // ② 杂项换任的接任者落回本会话所在的注册工作区;③ 接任提示里的「全局 id」是现任全局,不是已交出的旧会话。
1142
+ {
1143
+ const { registerHolder } = await import(DIST('lib/office.mjs'));
1144
+ const storages = path.join(BLANK, '.dsh', 'storages');
1145
+ fs.mkdirSync(storages, { recursive: true });
1146
+ const miscWs = path.join(WS, '_Misc');
1147
+ fs.writeFileSync(path.join(storages, 'workspace.json'), JSON.stringify({
1148
+ tables: { workspaces: {
1149
+ 'ws-glob': { id: 'ws-glob', title: '全局', path: WS },
1150
+ 'ws-misc': { id: 'ws-misc', title: '_Misc', path: miscWs },
1151
+ } },
1152
+ }), 'utf8');
1153
+ const seen = { creates: [], prompts: [] };
1154
+ ctx.sessionController.create = async (a) => { seen.creates.push(a || {}); return { sessionId: 'session-made-' + seen.creates.length }; };
1155
+ ctx.sessionController.rename = async () => { };
1156
+ ctx.sessionController.prompt = async (a) => {
1157
+ const part = a && Array.isArray(a.content) ? a.content[0] : null;
1158
+ seen.prompts.push(String(part && part.text ? part.text : ''));
1159
+ return { accepted: true };
1160
+ };
1161
+ const makerExec2 = { name: 'ho_sessions', arguments: {}, agent: { session: { header: { id: 'session-maker2', cwd: WS } } } };
1162
+ const sessT = reg.tools.find((t) => t.name === 'ho_sessions');
1163
+ {
1164
+ let createdOut = '';
1165
+ try {
1166
+ const r = await sessT.execute({ action: 'new', confirm: true, title: '杂项2', kind: 'misc', role: 'misc', task: '示例任务', workspace: 'ws-misc' }, makerExec2);
1167
+ createdOut = String((r && r.text) || '');
1168
+ } catch (e) { createdOut = 'ERR ' + String(e && e.message ? e.message : e); }
1169
+ const p = seen.prompts[seen.prompts.length - 1] || '';
1170
+ ok(/已建会话/.test(createdOut), '新建杂项会话应成功', createdOut.slice(0, 200));
1171
+ ok(/_Misc[\\/]tasks[\\/]示例任务[\\/]Handover\.md/.test(p), '新建杂项会话的启动提示应读本任务交接', p.slice(0, 200));
1172
+ ok(p.indexOf('Global_Handover') === -1, '新建杂项会话的启动提示不得指向全局交接', p.slice(0, 200));
1173
+ }
1174
+ {
1175
+ const { runSuccession } = await import(DIST('lib/succession.mjs'));
1176
+ const { grantConsent } = await import(DIST('lib/consent.mjs'));
1177
+ const miscHandover = path.join(miscWs, 'tasks', '示例任务', 'Handover.md');
1178
+ fs.mkdirSync(path.dirname(miscHandover), { recursive: true });
1179
+ fs.writeFileSync(miscHandover, '# 示例任务交接' + '\n\n## 下一步\n- 等用户指派下一步\n', 'utf8');
1180
+ registerHolder(WS, { kind: 'global', name: '全局1', sessionId: 'session-global-cur', workspace: WS });
1181
+ registerHolder(WS, { kind: 'misc', task: '示例任务', name: '杂项1', sessionId: 'session-misc-old', workspace: miscWs });
1182
+ grantConsent('session-misc-old', 'blank-smoke', 'succeed');
1183
+ const succOut = await runSuccession({
1184
+ cfg: resolveConfig({ workspaceRoot: WS }),
1185
+ roleOf: () => ({ role: 'misc', task: '示例任务', project: '' }),
1186
+ selfId: () => 'session-misc-old',
1187
+ selfCwd: () => miscWs,
1188
+ handoverFor: () => ({ path: miscHandover, reason: '' }),
1189
+ resolveWs: () => WS,
1190
+ selfPct: () => 40,
1191
+ registry: () => ({ list: () => [] }),
1192
+ controller: () => ctx.sessionController,
1193
+ writeRole: () => { },
1194
+ }, null, {});
1195
+ ok(/换任完成/.test(succOut), '杂项换任应能走通(授权、体检、建会话、登记、投递)', succOut.split('\n').join(' ').slice(0, 240));
1196
+ const madeArgs = seen.creates[seen.creates.length - 1] || {};
1197
+ ok(madeArgs.workspaceId === 'ws-misc', '杂项接任者应落回本会话所在的工作区', JSON.stringify(madeArgs));
1198
+ const p = seen.prompts[seen.prompts.length - 1] || '';
1199
+ ok(new RegExp('.{0,1}示例任务[\\\\/]Handover\\.md').test(p), '接任提示末尾应带本任务交接路径', p.slice(-320));
1200
+ ok(p.indexOf('session-global-cur') !== -1, '接任提示的「全局 id」应是现任全局', p.slice(-320));
1201
+ ok(p.indexOf('session-misc-old') === -1, '接任提示不得把旧会话当全局', p.slice(-320));
1202
+ }
1203
+ }
1204
+
1022
1205
  fs.rmSync(BLANK, { recursive: true, force: true });
1023
1206
  console.log('');
1024
1207
  if (fails.length) {