@mingmaple/dsh-handover 0.9.14 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +123 -92
  2. package/docs/migrate.md +52 -41
  3. package/docs/releases/0.9.12.md +2 -3
  4. package/docs/releases/0.9.14.md +1 -5
  5. package/docs/releases/0.9.22.md +34 -0
  6. package/docs/releases/1.0.0.md +33 -0
  7. package/docs/troubleshoot.md +230 -46
  8. package/package.json +74 -71
  9. package/scripts/behavior.mjs +398 -0
  10. package/scripts/blank-dsh.mjs +41 -25
  11. package/scripts/blank-smoke.mjs +278 -109
  12. package/scripts/check-docs.mjs +7 -0
  13. package/scripts/e2e-migration.mjs +2 -2
  14. package/scripts/office.mjs +3 -3
  15. package/scripts/portability.mjs +71 -0
  16. package/scripts/style-gate.mjs +571 -0
  17. package/src/client.js +1028 -955
  18. package/src/index.mjs +313 -142
  19. package/src/lib/absrefs.mjs +1 -1
  20. package/src/lib/api.mjs +18 -10
  21. package/src/lib/archive.mjs +1 -1
  22. package/src/lib/brief.mjs +8 -6
  23. package/src/lib/config.mjs +23 -8
  24. package/src/lib/consent.mjs +54 -1
  25. package/src/lib/docmap.mjs +140 -0
  26. package/src/lib/drift.mjs +1 -1
  27. package/src/lib/events.mjs +1 -1
  28. package/src/lib/facts.mjs +19 -12
  29. package/src/lib/gates.mjs +30 -6
  30. package/src/lib/global-task.mjs +69 -0
  31. package/src/lib/guide.mjs +81 -85
  32. package/src/lib/handover-io.mjs +69 -6
  33. package/src/lib/identity.mjs +330 -0
  34. package/src/lib/legacy.mjs +89 -0
  35. package/src/lib/lint.mjs +17 -13
  36. package/src/lib/manual.mjs +135 -59
  37. package/src/lib/mover.mjs +23 -19
  38. package/src/lib/names.mjs +14 -19
  39. package/src/lib/office.mjs +63 -15
  40. package/src/lib/pack-ops.mjs +11 -11
  41. package/src/lib/pathkey.mjs +24 -0
  42. package/src/lib/peer.mjs +4 -4
  43. package/src/lib/phase.mjs +8 -13
  44. package/src/lib/profile.mjs +1 -1
  45. package/src/lib/reminder.mjs +110 -0
  46. package/src/lib/role.mjs +9 -6
  47. package/src/lib/scaffold.mjs +68 -26
  48. package/src/lib/schedule.mjs +5 -6
  49. package/src/lib/session-create.mjs +3 -3
  50. package/src/lib/session-ops.mjs +80 -51
  51. package/src/lib/sessions.mjs +41 -3
  52. package/src/lib/sevenzip.mjs +10 -1
  53. package/src/lib/status.mjs +337 -176
  54. package/src/lib/succession.mjs +16 -16
  55. package/src/lib/system.mjs +364 -0
  56. package/src/lib/zstd-log.mjs +1 -1
  57. package/src/selftest.mjs +6 -6
  58. package/src/templates/Changelog.skeleton.md +3 -3
  59. package/src/templates/Charter.skeleton.md +13 -9
  60. package/src/templates/Handover.global.skeleton.md +20 -13
  61. package/src/templates/Handover.template.md +89 -0
  62. package/src/templates/Plan.skeleton.md +34 -0
  63. package/src/templates/Prompt_Templates.md +101 -20
  64. package/src/templates/Runtime_Manual.md +263 -122
  65. package/src/templates/System.skeleton.md +31 -0
  66. package/src/templates/Systems.global.skeleton.md +13 -0
  67. package/src/templates/rules/Delegation_Rules.md +45 -0
  68. package/src/templates/rules/Handover_Rules.md +46 -23
  69. package/src/templates/rules/Migration_Rules.md +7 -7
  70. package/src/templates/rules/Misc_Task_Standard.md +17 -10
  71. package/src/templates/rules/Writing_Rules.md +33 -16
  72. package/src/templates/standards/README.md +37 -0
  73. package/src/templates/standards//345/206/231/344/275/234/350/247/204/350/214/203/README.md +120 -0
  74. package/src/templates/standards//345/206/231/344/275/234/350/247/204/350/214/203//344/276/235/346/215/256_/350/256/272/346/226/207.md +389 -0
  75. package/src/tools/ho_backup.mjs +135 -0
  76. package/src/tools/ho_changelog.mjs +4 -4
  77. package/src/tools/ho_charter.mjs +7 -6
  78. package/src/tools/ho_checkpoint.mjs +36 -32
  79. package/src/tools/ho_docmap.mjs +239 -0
  80. package/src/tools/ho_gate.mjs +11 -5
  81. package/src/tools/ho_handover.mjs +61 -35
  82. package/src/tools/ho_office.mjs +46 -5
  83. package/src/tools/ho_profile.mjs +3 -3
  84. package/src/tools/ho_sessions.mjs +46 -26
  85. package/src/tools/ho_status.mjs +251 -0
  86. package/src/tools/ho_succeed.mjs +3 -3
  87. package/src/tools/ho_workspace.mjs +146 -42
  88. package/src/tools/peer.mjs +416 -0
  89. package/src/tools/ho_brief.mjs +0 -57
  90. package/src/tools/ho_diag.mjs +0 -87
  91. package/src/tools/ho_drift.mjs +0 -41
  92. package/src/tools/ho_init.mjs +0 -82
  93. package/src/tools/ho_pack.mjs +0 -38
  94. package/src/tools/ho_phase.mjs +0 -37
  95. package/src/tools/ho_snapshot.mjs +0 -104
  96. package/src/tools/ho_tokens.mjs +0 -57
  97. package/src/tools/peer_list.mjs +0 -37
  98. package/src/tools/peer_new.mjs +0 -75
  99. package/src/tools/peer_peek.mjs +0 -41
  100. package/src/tools/peer_rename.mjs +0 -31
  101. package/src/tools/peer_send.mjs +0 -52
  102. 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
  */
@@ -26,7 +26,23 @@ process.env.HOME = BLANK;
26
26
  const { apply, schedDepsFor } = await import(pathToFileURL(path.join(dist, 'index.mjs')).href);
27
27
  const reg = { tools: [], sections: [], logs: [], listeners: {} };
28
28
  const ctx = {
29
- tools: { register: (t, o) => { reg.tools.push(t); (reg.toolOpts = reg.toolOpts || []).push(o); }, restrict() { }, resolve: () => ({}), guard: (fn) => { reg.guardFn = fn; } },
29
+ tools: {
30
+ register: (t, o) => { reg.tools.push(t); (reg.toolOpts = reg.toolOpts || []).push(o); },
31
+ restrict() { },
32
+ resolve: () => ({}),
33
+ // 宿主允许注册多个守卫:按注册顺序依次判,任意一个拒绝即拒绝。
34
+ guard: (fn) => {
35
+ reg.guardFns = (reg.guardFns || []).concat([fn]);
36
+ reg.guardFn = (exec) => {
37
+ for (const g of reg.guardFns) {
38
+ let r;
39
+ try { r = g(exec); } catch (e) { return String(e && e.message ? e.message : e); }
40
+ if (r) return r;
41
+ }
42
+ return undefined;
43
+ };
44
+ },
45
+ },
30
46
  systemPrompt: { section: (s) => { reg.sections.push(s); return () => { reg.disposed = (reg.disposed || 0) + 1; }; } },
31
47
  sessionController: {
32
48
  create: async () => ({ sessionId: 'session-deploy-test' }),
@@ -42,7 +58,7 @@ const ctx = {
42
58
  };
43
59
  apply(ctx, {});
44
60
 
45
- ok(reg.tools.length === 24, '工具数应为 24', String(reg.tools.length));
61
+ ok(reg.tools.length === 14, '工具数应为 14', String(reg.tools.length));
46
62
  ok(reg.sections.length === 0, '未开启全局前置提示词时不得注册提示段', String(reg.sections.length));
47
63
  ok(reg.tools.filter((t) => !t || !t.name || !t.description || !t.execute).length === 0, '每个工具都要有 name/description/execute');
48
64
  // 上下文成本实测:工具描述是每次请求都要付的固定开销。参考值只用于展示,不作为通过条件。
@@ -52,11 +68,11 @@ const budget = await import(pathToFileURL(path.join(dist, 'lib', 'facts.mjs')).h
52
68
  const widest = rows.slice().sort((a, b) => b.n - a.n)[0];
53
69
  console.log('工具描述 token 实测:最宽 ' + widest.name + ' ' + widest.n + '(参考值 ' + budget.TOKEN_BUDGET.toolDescTokens + ',不判失败)');
54
70
  }
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 个');
71
+ ok(reg.tools.filter((t) => t.name.startsWith('ho_')).length === 13, 'ho_* 应为 13 个', String(reg.tools.filter((t) => t.name.startsWith('ho_')).length));
72
+ ok(reg.tools.filter((t) => t.name === 'peer').length === 1, '平级会话工具应合并为一个 peer');
57
73
  // 每步条件启用:内核 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));
74
+ // 否则它的首轮提示词会带着 14 个用不了的 schema(每步白付约 1.5k token)。
75
+ ok((reg.toolOpts || []).length === 14 && (reg.toolOpts || []).every((o) => o && typeof o.when === 'function'), '14 个工具都应带每步启用条件', String((reg.toolOpts || []).length));
60
76
 
61
77
  /** 取某个工具的每步启用条件(0.2.2 起工具按步解析)。 */
62
78
  const gateFor = (name) => {
@@ -66,7 +82,7 @@ const gateFor = (name) => {
66
82
 
67
83
  {
68
84
  // 每步条件同时承担子会话排除与角色过滤:调用方每步重算工具表,换角色下一轮即生效。
69
- const gate = gateFor('ho_brief');
85
+ const gate = gateFor('ho_status');
70
86
  ok(typeof gate === 'function', '每步启用条件应可调用');
71
87
  if (typeof gate === 'function') {
72
88
  ok(gate({}) === true, '没有 agent 的调用应放行(命令行与演练路径)');
@@ -75,10 +91,10 @@ const gateFor = (name) => {
75
91
  ok(gate({ agent: { session: { header: { id: 'session-plain', cwd: path.join(BLANK, 'nowhere') } } } }) === false, '未登记角色的会话按普通处理(0 个插件工具)');
76
92
  const roleMod = await import(pathToFileURL(path.join(dist, 'lib', 'role.mjs')).href);
77
93
  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');
94
+ ok(gate({ agent: { session: { header: { id: 'session-boss', cwd: path.join(BLANK, 'nowhere') } } } }) === true, '全局角色应可见 ho_status');
95
+ const gatePlain = gateFor('ho_office');
96
+ ok(typeof gatePlain === 'function' && gatePlain({ agent: { session: { header: { id: 'session-boss', cwd: path.join(BLANK, 'nowhere') } } } }) === true, '全局角色应可见 ho_office');
97
+ ok(gatePlain({ agent: { session: { header: { id: 'session-plain', cwd: path.join(BLANK, 'nowhere') } } } }) === false, '普通角色不可见 ho_office');
82
98
  }
83
99
  }
84
100
  // 可选注入多了 connection(/api 载体)。两个都必须是**可选**的,否则旧宿主整插件不激活。
@@ -149,11 +165,11 @@ let payload = null;
149
165
  try { payload = JSON.parse(r1.body); } catch (e) { fails.push('返回不是 JSON:' + e.message); }
150
166
  if (payload) {
151
167
  ok(payload.plugin.version && payload.plugin.version !== '?', '应报出版本号', String(payload.plugin.version));
152
- ok(payload.plugin.tools === 24, 'payload 工具数应为 24', String(payload.plugin.tools));
168
+ ok(payload.plugin.tools === 14, 'payload 工具数应为 14', String(payload.plugin.tools));
153
169
  ok(payload.workspace && payload.workspace.found === false, '空白 HOME 下应判定为没找到工作区', JSON.stringify(payload.workspace));
154
170
  ok(Array.isArray(payload.notes) && payload.notes.length > 0, '没找到工作区时应给中文提示');
155
171
  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)));
172
+ 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
173
  ok(payload.plugin.initErrors && payload.plugin.initErrors.length === 0, '初始化错误应为空', JSON.stringify(payload.plugin.initErrors));
158
174
  }
159
175
  ok((await call('GET', '/dsh-handover/nope.json')).code === 404, '未知路径应 404');
@@ -198,6 +214,23 @@ ok(fs.existsSync(path.join(root, '_Control_Center', 'rules', 'Workspace_Charter.
198
214
  ok(fs.existsSync(path.join(root, '_Control_Center', 'templates', 'Prompt_Templates.md')), '建全局根:提示词模板应在');
199
215
  ok(fs.existsSync(path.join(root, 'Projects')) && fs.existsSync(path.join(root, '_Project_Meta')), '建全局根:项目目录应在');
200
216
 
217
+ // 通用规范库:init 要铺出目录与说明;既有工作区靠 scaffold 补齐,且不得覆盖已有内容。
218
+ if (root) {
219
+ const standardsDir = path.join(root, '_Control_Center', 'standards');
220
+ const standardsReadme = path.join(standardsDir, 'README.md');
221
+ ok(fs.existsSync(standardsReadme), '建全局根:通用规范库的说明应在', standardsReadme);
222
+ const scafMod = await import(pathToFileURL(path.join(dist, 'lib', 'scaffold.mjs')).href);
223
+ const keep = '# 本库自己的说明' + '\n';
224
+ fs.writeFileSync(standardsReadme, keep, 'utf8');
225
+ fs.rmSync(path.join(root, '_Control_Center', 'tools'), { recursive: true, force: true });
226
+ const sc1 = scafMod.ensureScaffold(root);
227
+ ok(sc1.ok && sc1.created.indexOf('_Control_Center/tools') !== -1, 'scaffold 应补齐缺失的目录', JSON.stringify(sc1.created));
228
+ ok(fs.readFileSync(standardsReadme, 'utf8') === keep, 'scaffold 不得覆盖已有文件');
229
+ const sc2 = scafMod.ensureScaffold(root);
230
+ ok(sc2.ok && sc2.created.length === 0 && sc2.written.length === 0, 'scaffold 二次执行应无改动', JSON.stringify([sc2.created.length, sc2.written.length]));
231
+ ok(scafMod.ensureScaffold(path.join(BLANK, 'nowhere-at-all')).ok === false, 'scaffold 对不存在的根应报错');
232
+ }
233
+
201
234
  // cwd 已失效(旧路径被删)时,根解析必须退回登记表,不能在幽灵路径上重建 _Control_Center
202
235
  if (root) {
203
236
  const regDir0 = path.join(BLANK, '.dsh', 'storages');
@@ -252,18 +285,18 @@ ok(e3.code === 200 && e3.json && e3.json.ok && e3.json.chars === 0 && e3.json.en
252
285
  const s4 = await postJson('/dsh-handover/register', { path: root || PARENT });
253
286
  ok(s4.code === 501 && s4.json && s4.json.ok === false && String(s4.json.hint || '').indexOf(root || PARENT) !== -1, '无 workspaceRegistry 时应回退给「手工粘贴路径」的提示', JSON.stringify(s4.json));
254
287
 
255
- const initTool = reg.tools.find((t) => t.name === 'ho_init');
256
- ok(!!initTool, '应注册 ho_init(首次安装工具)');
288
+ const initTool = reg.tools.find((t) => t.name === 'ho_workspace');
289
+ ok(!!initTool, '应注册 ho_workspace(含首次安装 action=init)');
257
290
  if (initTool) {
258
291
  const PARENT2 = path.join(BLANK, 'mother2');
259
292
  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]);
293
+ const ri = await initTool.execute({ action: 'init', parent: PARENT2 }, null);
294
+ ok(/已建立全局文件体系/.test(ri.text), 'ho_workspace(action=init) 应建树并报出路径', ri.text.split('\n')[0]);
295
+ const ri2 = await initTool.execute({ action: 'init', parent: PARENT2 }, null);
296
+ ok(/未建立/.test(ri2.text), 'ho_workspace(action=init) 对非空目标应拒绝且不改动', ri2.text.split('\n')[0]);
264
297
  }
265
298
 
266
- const readonly = ['ho_diag', 'ho_phase', 'ho_brief', 'ho_sessions', 'ho_gate', 'ho_charter', 'ho_drift', 'ho_workspace'];
299
+ const readonly = ['ho_status', 'ho_sessions', 'ho_gate', 'ho_charter', 'ho_workspace'];
267
300
  for (const t of reg.tools) {
268
301
  if (readonly.indexOf(t.name) === -1) continue;
269
302
  try {
@@ -285,7 +318,7 @@ const { resolveConfig } = await import(DIST('lib/config.mjs'));
285
318
  const WS = path.join(BLANK, 'ws');
286
319
  fs.mkdirSync(path.join(WS, '_Control_Center'), { recursive: true });
287
320
  fs.mkdirSync(path.join(WS, 'Projects', 'Demo', 'Demo库'), { recursive: true });
288
- fs.mkdirSync(path.join(WS, '_Misc', 'tasks', '抢名额'), { recursive: true });
321
+ fs.mkdirSync(path.join(WS, '_Misc', 'tasks', '示例任务'), { recursive: true });
289
322
 
290
323
  // ---------- 在任复用:工具层前置校验(任何写入之前返回) ----------
291
324
  {
@@ -305,7 +338,7 @@ fs.mkdirSync(path.join(WS, '_Misc', 'tasks', '抢名额'), { recursive: true });
305
338
  } catch (e) { return String(e && e.message ? e.message : e); }
306
339
  };
307
340
  const lowErr = await newErr({});
308
- ok(/已有在任会话 Demo1/.test(lowErr) && /session-hold/.test(lowErr) && /peer_send/.test(lowErr) && /force=true/.test(lowErr), '项目类别有低水位在任时应拒绝新建并给出复用建议', lowErr);
341
+ ok(/已有在任会话 Demo1/.test(lowErr) && /session-hold/.test(lowErr) && /peer/.test(lowErr) && /force=true/.test(lowErr), '项目类别有低水位在任时应拒绝新建并给出复用建议', lowErr);
309
342
  const forcedErr = await newErr({ force: true });
310
343
  ok(forcedErr.indexOf('已有在任会话') === -1, 'force=true 应越过复用判据', forcedErr);
311
344
 
@@ -337,11 +370,11 @@ const rTitle = resolveRole({ sessionId: 'session-t1', title: '全局9' });
337
370
  ok(rTitle.role === 'global' && rTitle.source === 'title', '角色判定:标题含「全局」→ global', JSON.stringify(rTitle));
338
371
  const rCwd = resolveRole({ sessionId: 'session-t2', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库') });
339
372
  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' });
373
+ const rMisc = resolveRole({ sessionId: 'session-t3', title: '杂项1', cwd: path.join(WS, '_Misc', 'tasks', '示例任务') });
374
+ ok(rMisc.role === 'misc' && rMisc.task === '示例任务', '角色判定:cwd 在 _Misc/tasks/<任务>/ → misc', JSON.stringify(rMisc));
375
+ const rPrompt = resolveRole({ sessionId: 'session-t4', title: '', firstPrompt: '先看 ' + path.join(WS, 'Projects', 'Demo2', 'Demo2库') + ' 里的内容' });
376
+ ok(rPrompt.role === 'project' && rPrompt.project === 'Demo2', '角色判定:首条提示里的 Projects/<名> → project', JSON.stringify(rPrompt));
377
+ const rPlain = resolveRole({ sessionId: 'session-t5', title: '未命名', cwd: '/tmp' });
345
378
  ok(rPlain.role === 'plain', '角色判定:都不中 → plain(普通会话)', JSON.stringify(rPlain));
346
379
 
347
380
  writeRole({}, 'session-t6', { role: 'misc', task: 'X', source: 'user' });
@@ -351,7 +384,7 @@ ok(readRoles({}).sessions['session-t6'] && readRoles({}).sessions['session-t6'].
351
384
 
352
385
  // 回归锁:首条提示里的 `Projects/**` 通配曾被当成项目名 `**、_Project_Meta`,
353
386
  // 于是交接路径、备份目录、归档目录全落在这个 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/** 前先问用户。' });
387
+ const rGlob = resolveRole({ sessionId: 'session-glob-prompt', title: '普通会话', cwd: 'C:/Users/x/.dsh/profiles/desktop', firstPrompt: '- 动 _Control_Center/**、Projects/**、_Project_Meta/** 前先问用户。' });
355
388
  ok(rGlob.role === 'plain', '首条提示里的 Projects/** 通配不得被当成项目名', JSON.stringify(rGlob));
356
389
  const hpBad = handoverPathFor(WS, { role: 'project', project: '**、_Project_Meta' });
357
390
  ok(hpBad.path === null && /不合法/.test(String(hpBad.reason || '')), '非法项目名不得拼出交接路径', JSON.stringify(hpBad));
@@ -366,25 +399,84 @@ const hpG = handoverPathFor(WS, { role: 'global' });
366
399
  ok(/Global_Handover\.md$/.test(hpG.path), '全局交接路径', hpG.path);
367
400
  const hpP = handoverPathFor(WS, { role: 'project', project: 'Demo' });
368
401
  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);
402
+ const hpM = handoverPathFor(WS, { role: 'misc', task: '示例任务' });
403
+ ok(/_Misc/.test(hpM.path) && /示例任务/.test(hpM.path) && /Handover\.md$/.test(hpM.path), '杂项交接路径应落 _Misc/tasks/<任务>/Handover.md', hpM.path);
371
404
  const hpU = handoverPathFor(WS, { role: 'plain' });
372
405
  ok(hpU.path === null && !!hpU.reason, '普通会话不得给出交接路径(不退回全局)', JSON.stringify(hpU));
373
406
 
374
407
  const tsG = toolsetFor('global');
375
- ok(tsG.allow.length === 24 && tsG.deny.length === 0, '全局应可见全部 24 个工具', JSON.stringify([tsG.allow.length, tsG.deny.length]));
408
+ ok(tsG.allow.length === 14 && tsG.deny.length === 0, '全局应可见全部 14 个工具', JSON.stringify([tsG.allow.length, tsG.deny.length]));
376
409
  const tsP = toolsetFor('project');
377
410
  ok(tsP.allow.length === 11, '项目角色应可见 11 个工具', String(tsP.allow.length));
378
411
  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, '项目角色不该看见全局管理工具');
412
+ ok(tsP.allow.indexOf('ho_sessions') === -1 && tsP.allow.indexOf('ho_changelog') === -1, '项目角色不该看见全局管理工具');
380
413
  ok(tsP.deny.indexOf('ho_handover') === -1 && tsP.deny.indexOf('run_code') === -1, 'deny 名单不得含保留名或本角色已允许的工具');
381
414
  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));
415
+ 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
416
  const tsU = toolsetFor('plain');
384
- ok(tsU.allow.length === 0 && tsU.deny.length === 24, '普通会话不载入任何插件工具', JSON.stringify([tsU.allow.length, tsU.deny.length]));
417
+ ok(tsU.allow.length === 0 && tsU.deny.length === 14, '普通会话不载入任何插件工具', JSON.stringify([tsU.allow.length, tsU.deny.length]));
385
418
  const rSub = resolveRole({ sessionId: 'session-sub', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库'), subagent: true });
386
419
  ok(rSub.role === 'plain' && rSub.source === 'subagent', '子会话默认按普通处理,不按 cwd 判项目', JSON.stringify(rSub));
387
420
 
421
+ // ---------- 体系注册与授权提示:写入权限、路径校验、空注册、注入、计划 ----------
422
+ {
423
+ const { pmDocmapTool } = await import(DIST('tools/ho_docmap.mjs'));
424
+ const { registerHolder } = await import(DIST('lib/office.mjs'));
425
+ const { systemBlock, registryRelPath, readRegistry, planRelPath } = await import(DIST('lib/system.mjs'));
426
+ const manDm = await import(DIST('lib/manual.mjs'));
427
+ const { grantConsent, consumedConsent, activeConsentsFor, consentPromptLines } = await import(DIST('lib/consent.mjs'));
428
+
429
+ const dmInfo = { role: 'project', project: 'Demo', task: '' };
430
+ const dmEnv = {
431
+ cfg: resolveConfig({ workspaceRoot: WS }),
432
+ resolveWs: () => WS,
433
+ selfId: (exec) => (exec && exec.agent && exec.agent.session ? exec.agent.session.header.id : null),
434
+ roleOf: () => dmInfo,
435
+ refreshPromptFor: () => true,
436
+ };
437
+ const dmExec = (sid) => ({ agent: { session: { header: { id: sid, cwd: path.join(WS, 'Projects', 'Demo', 'Demo库') } } } });
438
+ fs.writeFileSync(path.join(WS, 'Projects', 'Demo', 'README.md'), 'x', 'utf8');
439
+ registerHolder(WS, { kind: 'project', project: 'Demo', name: 'Demo1', sessionId: 'session-dm', workspace: WS });
440
+ const dm = pmDocmapTool(dmEnv);
441
+ const wroteDm = await dm.execute({ action: 'write', entries: [{ path: 'Projects/Demo/README.md', about: '项目简介', when: '动手前', section: 'files' }] }, dmExec('session-dm'));
442
+ ok(/已写入/.test(wroteDm.text), '体系注册:在任者写自己的注册应成功', wroteDm.text.split('\n')[0]);
443
+ const readBack = readRegistry(WS, dmInfo);
444
+ ok(readBack.exists && readBack.data.files.length === 1 && readBack.data.files[0].path === 'Projects/Demo/README.md', '体系注册:写盘后可解析回一条', JSON.stringify(readBack.data.files));
445
+ const sysBlock = systemBlock(WS, dmInfo);
446
+ ok(/体系注册/.test(sysBlock) && /Projects\/Demo\/README\.md/.test(sysBlock) && /动手前/.test(sysBlock), '体系注册:注入块含路径与时机', sysBlock);
447
+ const injectedDm = manDm.scopedManual(dmInfo, { ws: WS, system: sysBlock });
448
+ ok(injectedDm.indexOf('体系注册') !== -1 && injectedDm.split('体系注册').length === 2, '体系注册:注入段只出现一次', String(injectedDm.split('体系注册').length - 1));
449
+ let missErr = '';
450
+ 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); }
451
+ ok(/未写入/.test(missErr) && /nope\.md/.test(missErr), '体系注册:路径不存在应整条拒绝并点名', missErr);
452
+ ok(readRegistry(WS, dmInfo).data.files.length === 1, '体系注册:拒绝后原注册不得被改');
453
+ const planWrote = await dm.execute({ action: 'plan', mode: 'write', text: '# 行动计划\n\n## 1 目标\n试跑\n' }, dmExec('session-dm'));
454
+ ok(/已写入计划/.test(planWrote.text), '行动计划:可写入任务根', planWrote.text.split('\n')[0]);
455
+ ok(fs.existsSync(path.join(WS, planRelPath(WS, dmInfo))), '行动计划:落在任务根 PLAN.md');
456
+ const planRead = await dm.execute({ action: 'plan', mode: 'read' }, dmExec('session-dm'));
457
+ ok(/行动计划/.test(planRead.text), '行动计划:可读回');
458
+ registerHolder(WS, { kind: 'project', project: 'Demo', name: 'Demo2', sessionId: 'session-dm2', workspace: WS });
459
+ let oldErr = '';
460
+ 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); }
461
+ ok(/只有在任会话可以写/.test(oldErr), '体系注册:已交出的旧会话只读', oldErr);
462
+ const emptyDm = await dm.execute({ action: 'write', entries: [] }, dmExec('session-dm2'));
463
+ ok(/已写入/.test(emptyDm.text) && /目录 0 条、规范 0 条/.test(emptyDm.text), '体系注册:空 entries 写入空注册', emptyDm.text.split('\n')[0]);
464
+ ok(systemBlock(WS, dmInfo).indexOf('体系注册(项目「Demo」)') !== -1, '体系注册:空注册只注入标题一行');
465
+ ok(registryRelPath(dmInfo).replace(/\\/g, '/') === '_Project_Meta/Demo/System.md', '体系注册:文件按任务独立存放', registryRelPath(dmInfo));
466
+
467
+ grantConsent('session-dm2', 'blank-smoke', 'compact');
468
+ const actDm = activeConsentsFor('session-dm2');
469
+ const consentLines = consentPromptLines(actDm);
470
+ ok(actDm.length === 1 && consentLines.length === 1 && /用户已授权压缩/.test(consentLines[0]), '授权:有效期内应生成一行提示', JSON.stringify(consentLines));
471
+ ok(activeConsentsFor('session-other').length === 0, '授权:别的会话看不到', JSON.stringify(activeConsentsFor('session-other')));
472
+ const withConsent = manDm.scopedManual(dmInfo, { ws: WS, system: '', consents: actDm });
473
+ ok(withConsent.indexOf('用户已授权压缩') !== -1, '授权:提示段应带那一行', withConsent.slice(-160));
474
+ consumedConsent('session-dm2', 'compact');
475
+ ok(consentPromptLines(activeConsentsFor('session-dm2')).length === 0, '授权:用掉后提示消失(不占位)');
476
+ const afterUse = manDm.scopedManual(dmInfo, { ws: WS, system: '', consents: activeConsentsFor('session-dm2') });
477
+ ok(afterUse.indexOf('已授权') === -1, '授权:用掉后提示段不再有该行');
478
+ }
479
+
388
480
  // ---------- 骨架档案:预设、默认骨架、注册、校验 ----------
389
481
  const prof = await import(DIST('lib/profile.mjs'));
390
482
  const pjs = prof.presets();
@@ -467,8 +559,8 @@ ok(tg.indexOf('收尾并停止') !== -1, '续跑文案应允许交接无待办
467
559
  const globName = io.archiveFileName('E:/ws/_Control_Center/records/Global_Handover.md', stamp);
468
560
  ok(globName === 'Global_Handover_' + stamp + '.md', '全局归档名应为 Global_Handover_<时间>.md', globName);
469
561
  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);
562
+ const miscDir = ad.archiveDirFor(WS, { role: 'misc', task: '示例任务' }).replace(/\\/g, '/');
563
+ ok(miscDir.endsWith('_Misc/tasks/示例任务/_archive'), '杂项归档目录应在任务目录内(_archive)', miscDir);
472
564
  const handTool = reg.tools.find((t) => t.name === 'ho_handover');
473
565
  ok(!!handTool, '应注册 ho_handover');
474
566
  if (handTool) {
@@ -499,21 +591,29 @@ const projAgent = {
499
591
  };
500
592
  for (const cb of listeners) cb({ agent: projAgent, source: 'fresh' });
501
593
  ok(calls.restrict.length === 0, '按步路径不用 restrict 收窄工具', String(calls.restrict.length));
502
- const projGatePack = gateFor('ho_pack');
594
+ const projGatePack = gateFor('ho_sessions');
503
595
  const projGateHand = gateFor('ho_handover');
504
596
  const projExec = { agent: projAgent };
505
- ok(typeof projGatePack === 'function' && projGatePack(projExec) === false, '项目角色不可见 ho_pack(每步条件)');
597
+ ok(typeof projGatePack === 'function' && projGatePack(projExec) === false, '项目角色不可见 ho_sessions(每步条件)');
506
598
  ok(typeof projGateHand === 'function' && projGateHand(projExec) === true, '项目角色可见 ho_handover(每步条件)');
507
- ok(gateFor('ho_brief')(projExec) === false, '项目角色不可见 ho_brief');
508
- ok(calls.sections.length === 1 && calls.sections[0].name === 'dsh-handover:manual', '应注册同名提示段遮蔽兜底段');
509
- const secNorm = String(calls.sections[0].text).replace(/\\/g, '/');
599
+ ok(gateFor('ho_changelog')(projExec) === false, '项目角色不可见 ho_changelog');
600
+ ok(calls.sections.length >= 1 && calls.sections.some((s) => s.name === 'dsh-handover:manual'), '应注册同名提示段遮蔽兜底段', JSON.stringify(calls.sections.map((s) => s.name)));
601
+ const projSec = calls.sections.find((s) => s.name === 'dsh-handover:manual');
602
+ const projText = String(projSec && projSec.text || '');
603
+ ok(!calls.sections.some((s) => s.name === 'dsh-handover:reminder'), '不再注册动态提醒段(提醒改走工具结果尾注入)');
604
+ const secNorm = projText.replace(/\\/g, '/');
510
605
  ok(secNorm.indexOf('Projects/Demo') !== -1 && secNorm.indexOf('_Project_Meta/Demo/Handover.md') !== -1, '项目提示段应含自己的路径', secNorm.slice(0, 200));
511
606
  ok(secNorm.indexOf('任务完成不报全局') !== -1, '项目提示段应写明任务完成不向全局报告', secNorm.slice(0, 400));
512
607
  ok(secNorm.indexOf('任务达成') === -1, '项目提示段不得再出现任务达成');
513
- ok(secNorm.indexOf('可直接 peer_send') !== -1, '项目提示段应允许会话之间直接对话', secNorm.slice(0, 400));
608
+ ok(secNorm.indexOf('可直接 peer') !== -1, '项目提示段应允许会话之间直接对话', secNorm.slice(0, 400));
514
609
  ok(secNorm.indexOf('resume=wait') !== -1, '项目提示段应说明收尾压缩不唤醒');
515
- console.log('项目提示段 token 实测:' + budget.estimateTokens(calls.sections[0].text) + '(参考值 ' + budget.TOKEN_BUDGET.projectTokens + ',不判失败)');
516
- ok(calls.sections[0].text.indexOf('子智能体') === -1, '项目段不得携带子智能体派发纪律(不属于本插件)');
610
+ ok(secNorm.indexOf('_Control_Center/standards/') !== -1, '项目提示段应给出通用规范的查找范围', secNorm.slice(-240));
611
+ ok(secNorm.indexOf('把某某写成规范') !== -1 && secNorm.indexOf('本会话所在的工作区') !== -1 && secNorm.indexOf('写入通用规范库') !== -1 && secNorm.indexOf('报全局') !== -1, '项目提示段应写明未指明写本库、通用规范库报全局', secNorm.slice(-400));
612
+ console.log('项目提示段 token 实测:' + budget.estimateTokens(projText) + '(参考值 ' + budget.TOKEN_BUDGET.projectTokens + ',不判失败)');
613
+ ok(projText.indexOf('纯窗口是否可能超过 40%') !== -1, '项目段应携带 1.0 子智能体判定线(纯窗口 40%)');
614
+ ok(secNorm.indexOf('写计划就把拆子智能体安排进去') !== -1, '项目段应写明超过 40% 时计划里引入子智能体', secNorm.slice(0, 400));
615
+ ok(secNorm.indexOf('超过时,写计划就把拆子智能体安排进去') < secNorm.indexOf('写行动计划 PLAN.md(任务根)'), '项目段应是先判纯窗口、再写一次计划', secNorm.slice(0, 400));
616
+ ok(secNorm.indexOf('子智能体') !== -1 && secNorm.indexOf('并发 ≤3') !== -1 && secNorm.indexOf('交回原作者续用') !== -1, '项目段应携带子智能体硬线(并发与续用)', secNorm.slice(0, 500));
517
617
  // 真实工作区路径 + 迁移提示是最坏情形,参考值另设一档
518
618
  {
519
619
  const man = await import(pathToFileURL(path.join(dist, 'lib', 'manual.mjs')).href);
@@ -542,11 +642,16 @@ const globAgent = {
542
642
  };
543
643
  for (const cb of listeners) cb({ agent: globAgent, source: 'fresh' });
544
644
  ok(gCalls.restrict === 0, '全局角色不应被裁剪', String(gCalls.restrict));
545
- ok(gCalls.sections.length === 1 && gCalls.sections[0].name === 'dsh-handover:manual', '全局也应遮蔽兜底段');
645
+ ok(gCalls.sections.some((s) => s.name === 'dsh-handover:manual'), '全局也应遮蔽兜底段', JSON.stringify(gCalls.sections.map((s) => s.name)));
646
+ const globSec = gCalls.sections.find((s) => s.name === 'dsh-handover:manual');
647
+ const globText = String(globSec && globSec.text || '');
648
+ ok(!gCalls.sections.some((s) => s.name === 'dsh-handover:reminder'), '全局同样不注册动态提醒段');
546
649
  // 常驻段只放每轮判定,一次性流程下沉手册;参考值按加权 token 计,只打印实测值。
547
- console.log('全局提示段 token 实测:' + budget.estimateTokens(gCalls.sections[0].text) + '(参考值 ' + budget.TOKEN_BUDGET.globalTokens + ',不判失败)');
548
- ok(gCalls.sections[0].text.indexOf('反模式') === -1, '全局段不得携带反模式纪律(不属于本插件)');
549
- ok(gCalls.sections[0].text.indexOf('send_message 续用') === -1, '全局段不得携带子智能体续用纪律(不属于本插件)');
650
+ console.log('全局提示段 token 实测:' + budget.estimateTokens(globText) + '(参考值 ' + budget.TOKEN_BUDGET.globalTokens + ',不判失败)');
651
+ ok(globText.indexOf('纯窗口是否可能超过 40%') !== -1, '全局段应携带 1.0 子智能体判定线(纯窗口 40%)');
652
+ ok(globText.indexOf('写计划就把拆子智能体安排进去') < globText.indexOf('写行动计划 PLAN.md(任务根)'), '全局段应是先判纯窗口、再写一次计划');
653
+ ok(globText.indexOf('并发 ≤3') !== -1 && globText.indexOf('交回原作者续用') !== -1, '全局段应携带子智能体硬线(并发与续用)');
654
+ ok(globText.indexOf('list_agents') === -1, '全局段不携带子智能体检索细节(细则在运行时手册)');
550
655
  // 真机最坏情形:长路径 + 迁移提示行
551
656
  {
552
657
  const man2 = await import(pathToFileURL(path.join(dist, 'lib', 'manual.mjs')).href);
@@ -555,25 +660,27 @@ ok(gCalls.sections[0].text.indexOf('send_message 续用') === -1, '全局段不
555
660
  const gWorst = man2.globalManual({ ws: longWs2, move: { phase: 'switched', dst: otherWs2, src: longWs2 } });
556
661
  console.log('全局最坏情形提示段 token 实测:' + budget.estimateTokens(gWorst) + '(参考值 ' + budget.TOKEN_BUDGET.globalWorstTokens + ',不判失败)');
557
662
  }
558
- ok(gCalls.sections[0].text.indexOf('前置提示词') === -1, '插件角色段不得再携带前置提示词(由用户全局前置提示词注入)');
559
- // 结构只保留会话定位、权限、节奏、交接与按需取用五节
663
+ ok(globText.indexOf('前置提示词') === -1, '插件角色段不得再携带前置提示词(由用户全局前置提示词注入)');
664
+ // 结构:1.0 为六节(身份、权限、任务流程、上下文与压缩、交接与体系、按需取用)
560
665
  {
561
- const g = gCalls.sections[0].text;
666
+ const g = globText;
562
667
  const cnt = (k) => g.split(k).length - 1;
563
- const secs = ['一、身份', '二、权限', '三、节奏', '四、交接', '五、按需取用'];
668
+ const secs = ['一、身份', '二、权限', '三、任务流程', '四、上下文与压缩', '五、交接与体系', '六、按需取用'];
564
669
  const bad = secs.filter((k) => cnt(k) !== 1);
565
- ok(bad.length === 0, '全局段五节骨架应各出现一次', bad.join(','));
566
- const absent = ['子智能体', '反模式', '自检'];
670
+ ok(bad.length === 0, '全局段六节骨架应各出现一次', bad.join(','));
671
+ const absent = ['反模式', '自检'];
567
672
  const still = absent.filter((k) => g.indexOf(k) !== -1);
568
673
  ok(still.length === 0, '插件角色段不得携带非会话约束', still.join(','));
569
- ok(cnt('换任') <= 2, '「换任」在全局段出现不应超过两处', String(cnt('换任')));
674
+ ok(cnt('换任') <= 4, '「换任」在全局段出现不应超过四处', String(cnt('换任')));
570
675
  }
571
676
  ok(gCalls.injected === 0, '全局不再重复注入工作流(提示段里已有)', String(gCalls.injected));
572
677
  // 分层护栏——常驻段只留判定,工作流细节必须留在手册里
573
678
  {
574
- const g0 = String(gCalls.sections[0].text);
575
- ok(g0.indexOf('ho_pack') === -1 && g0.indexOf('move-probe') === -1, '常驻段不得回灌工作流细节(应放手册)');
679
+ const g0 = globText;
680
+ ok(g0.indexOf('ho_backup') === -1 && g0.indexOf('move-probe') === -1, '常驻段不得回灌工作流细节(应放手册)');
576
681
  ok(g0.indexOf('ho_charter(doc="runtime", section=…') !== -1, '常驻段应给出按节取用的指针');
682
+ ok(g0.indexOf('_Control_Center/standards/') !== -1, '全局段应给出通用规范的查找范围', g0.slice(-240));
683
+ ok(g0.indexOf('把某某写成规范') !== -1 && g0.indexOf('写入通用规范库') !== -1 && g0.indexOf('本会话所在的工作区') !== -1, '全局段应写明规范的写入范围', g0.slice(-400));
577
684
  const rm = fs.readFileSync(path.join(dist, 'templates', 'Runtime_Manual.md'), 'utf8');
578
685
  ok(rm.indexOf('## 11. 首次安装') !== -1 && rm.indexOf('## 12. 提示词预算') !== -1, '手册应含首次安装与提示词预算两节');
579
686
  ok(rm.indexOf('续用优先') !== -1, '手册 §5 应含子智能体续用纪律');
@@ -594,24 +701,25 @@ ok(gCalls.injected === 0, '全局不再重复注入工作流(提示段里已
594
701
  const uiFiles = listFiles(path.join(repo, 'src'), ['.mjs', '.js'], { skip: ['templates'] });
595
702
  const docFiles = [path.join(repo, 'README.md')]
596
703
  .concat(listFiles(path.join(repo, 'src', 'templates'), ['.md']))
597
- .filter((f) => f.indexOf('Writing_Rules.md') === -1);
704
+ // 写作规范正文与论文属规范性文本,条款本身会引用被禁词;Writing_Rules 是用户内容。
705
+ .filter((f) => f.indexOf('Writing_Rules.md') === -1 && f.indexOf(path.join('templates', 'standards')) === -1);
598
706
  const uiHits = scanFiles(uiFiles, { ui: true });
599
707
  const docHits = scanFiles(docFiles, { ui: false });
600
708
  const fmt = (hs) => hs.slice(0, 6).map((h) => path.relative(repo, h.file) + ':' + h.line + ' ' + h.rules.join('、')).join(' | ');
601
709
  ok(uiHits.length === 0, '界面文案应一律使用标准书面语(词表与符号扫描)', fmt(uiHits));
602
710
  ok(docHits.length === 0, '文档文案应一律使用标准书面语(词表与符号扫描)', fmt(docHits));
603
711
  }
604
- ok(opsSrc.indexOf('不得自行开展作业') !== -1 && opsSrc.indexOf('待用户指派') !== -1, '启动提示词应先报告再待指派');
712
+ ok(opsSrc.indexOf('不得自行开展作业') !== -1 && opsSrc.indexOf('待当前用户指派') !== -1, '启动提示词应先报告再待指派');
605
713
  }
606
714
 
607
715
  // 按步路径(0.2.2 起):创建即按角色登记提示段;工具可见性由每步条件算,不再调用 restrict。
608
- const unrCalls = { restrict: 0, section: null };
716
+ const unrCalls = { restrict: 0, section: null, sections: [] };
609
717
  const unrAgent = {
610
718
  id: 'session-unr',
611
- session: { header: { id: 'session-unr', title: '随便', cwd: '/tmp/x' } },
719
+ session: { header: { id: 'session-unr', title: '未命名', cwd: '/tmp/x' } },
612
720
  ctx: {
613
721
  tools: { restrict: () => { unrCalls.restrict++; return () => { }; } },
614
- systemPrompt: { section: (o) => { unrCalls.section = o; return () => { }; } },
722
+ systemPrompt: { section: (o) => { unrCalls.section = o; unrCalls.sections.push(o); return () => { }; } },
615
723
  },
616
724
  inject: () => { throw new Error('未设角色的会话不应写历史'); },
617
725
  };
@@ -623,9 +731,9 @@ ok(!unrCalls.section, '普通会话不注册角色提示段');
623
731
  // 开轮前核对:休眠期间改了角色,下一轮开始时补装提示段(工具由每步条件即时生效)。
624
732
  writeRole({}, 'session-unr', { role: 'project', project: 'Demo', source: 'user' });
625
733
  for (const cb of ((reg.listeners && reg.listeners['agent/status']) || [])) cb({ agent: unrAgent, status: 'running' });
626
- ok(!!unrCalls.section && /项目/.test(String(unrCalls.section.text)), '开轮前核对应按新角色补装提示段', JSON.stringify(unrCalls.section && unrCalls.section.name));
734
+ ok(unrCalls.sections.some((s) => s.name === 'dsh-handover:manual' && /项目/.test(String(s.text))), '开轮前核对应按新角色补装提示段', JSON.stringify(unrCalls.sections.map((s) => s.name)));
627
735
 
628
- // 旧内核(没有 tools.resolve)才走 restrict:创建时按普通收窄 24 个,改角色后开轮前换成 13 个。
736
+ // 旧内核(没有 tools.resolve)才走 restrict:创建时按普通收窄 14 个,改角色后开轮前换成 3 个。
629
737
  {
630
738
  const legacyCalls = { restrict: 0, lastDeny: null, disposed: 0 };
631
739
  const legacyListeners = {};
@@ -651,10 +759,10 @@ ok(!!unrCalls.section && /项目/.test(String(unrCalls.section.text)), '开轮
651
759
  ctx: { tools: legacyTools, systemPrompt: legacyCtx.systemPrompt },
652
760
  };
653
761
  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));
762
+ ok(legacyCalls.restrict === 1 && legacyCalls.lastDeny && legacyCalls.lastDeny.length === 14, '旧内核:创建时按普通收窄 14 个', JSON.stringify(legacyCalls.lastDeny && legacyCalls.lastDeny.length));
655
763
  writeRole({}, 'session-legacy', { role: 'project', project: 'Demo', source: 'user' });
656
764
  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));
765
+ ok(legacyCalls.restrict === 2 && legacyCalls.lastDeny && legacyCalls.lastDeny.length === 3, '旧内核:开轮前换成项目角色的 3 个', JSON.stringify(legacyCalls.lastDeny && legacyCalls.lastDeny.length));
658
766
  ok(legacyCalls.disposed === 1, '旧内核:换角色应撤销旧限制', String(legacyCalls.disposed));
659
767
  }
660
768
  // ---------- 角色路由 ----------
@@ -679,7 +787,7 @@ const oBad = await orderCall({ action: 'order', order: 'wave', session_id: 'sess
679
787
  ok(/只能是 handover 或 compact/.test(oBad), 'order 取值应受限', oBad);
680
788
  // 交接/压缩指令必须先问用户
681
789
  const oNoConfirm = await orderCall({ action: 'order', order: 'handover', session_id: 'session-norole' });
682
- ok(/需要用户确认/.test(oNoConfirm), 'order 未确认应拒绝', oNoConfirm);
790
+ ok(/需要当前用户确认/.test(oNoConfirm), 'order 未确认应拒绝', oNoConfirm);
683
791
  const oUnr = await orderCall({ action: 'order', order: 'handover', session_id: 'session-norole', confirm: true });
684
792
  ok(/没有交接路径/.test(oUnr), '未设角色的会话不能下交接指令(避免退回全局交接)', oUnr);
685
793
  const oCold = await orderCall({ action: 'order', order: 'compact', session_id: 'session-r1', confirm: true });
@@ -697,15 +805,15 @@ const guardExec = (name, argsObj, sid, cwd, title) => ({
697
805
  });
698
806
  ok(typeof reg.guardFn === 'function', '应注册工具守卫', typeof reg.guardFn);
699
807
  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'));
808
+ const gGlobal = reg.guardFn(guardExec('peer', { action: 'send', session_id: 'session-proj-x' }, 'session-glob', WS, '全局9'));
809
+ ok(gGlobal === undefined, '全局角色 peer(action=send) 应放行', String(gGlobal));
810
+ const gReport = reg.guardFn(guardExec('peer', { action: 'send', session_id: 'session-glob' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
703
811
  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'));
812
+ const gPeer = reg.guardFn(guardExec('peer', { action: 'send', session_id: 'session-proj' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
705
813
  ok(gPeer === undefined, '会话之间可直接对话,不必经全局转述', String(gPeer));
706
- const gNew = reg.guardFn(guardExec('peer_new', { workspace: 'Demo库' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
814
+ const gNew = reg.guardFn(guardExec('peer', { action: 'new', workspace: 'Demo库' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
707
815
  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'));
816
+ const gMisc = reg.guardFn(guardExec('peer', { action: 'new', workspace: 'Misc' }, 'session-misc', path.join(WS, '_Misc', 'tasks', '示例任务'), '杂项1'));
709
817
  ok(gMisc === undefined, '杂项会话自建 Misc 换任应放行', String(gMisc));
710
818
  }
711
819
 
@@ -732,7 +840,7 @@ const plainRows = [];
732
840
  for (let i = 0; i < 10; i++) {
733
841
  plainRows.push({
734
842
  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',
843
+ project: '', task: '', wsKey: '', wsTitle: '', wsPath: '', pct: i + 1, state: 'idle', active: false, onduty: false, seen: '2026-10-21 20:19 +0800',
736
844
  });
737
845
  }
738
846
 
@@ -745,22 +853,25 @@ const FIXTURE = {
745
853
  guide: payload ? payload.guide : { intro: ['说明'], situations: [{ when: 'w', say: 's', what: 'x' }], groups: [{ title: 'g', tools: [['ho_x', 'y']] }], rules: ['r'] },
746
854
  extraPrompt: '',
747
855
  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 },
856
+ gate: { ok: true, verdict: '全部落位。', total: 1, bodyLines: 24, libraries: [{ name: 'Demo1', ok: true, titleOk: true, waterOk: true, missing: 0, lines: 24 }] },
857
+ handover: { global: { verdict: '通过', reasons: [] }, projects: [{ name: 'Demo1', verdict: '通过', reasons: [] }, { name: 'Idle', verdict: '缺节', reasons: ['缺 ## 1. 正在做的事(2026-10-12 起的强制项,本项目还没补)'] }], misc: [{ name: '示例任务', verdict: '通过', reasons: [] }], fail: 0 },
750
858
  sessions: {
859
+ workspaces: [{ key: 'w1', title: '全局', path: 'C:/ws' }],
751
860
  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' },
861
+ { 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' },
862
+ { 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' },
863
+ { sid: 'session-misc', title: '示例任务', role: 'misc', roleLabel: '杂项', dutyKind: '杂项', dutyKey: '示例任务', pct: 5, state: 'idle', active: false, onduty: true, seen: '2026-10-20 09:00 +0800' },
864
+ { 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
865
  ],
755
866
  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' },
867
+ { 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' },
868
+ { 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' },
869
+ { 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
870
  ].concat(plainRows),
760
- notify: [{ sid: 'session-y', project: 'Mine' }], hot: 2, unknown: 0, cutoff: '2026-11-07 09:25 +0800', silentHours: 12,
761
- verdict: '2 个会话已进预警区(≥32%),应先收齐交接再换会话',
871
+ notify: [{ sid: 'session-y', key: 'Demo1', name: 'Demo1' }], hot: 2, unknown: 0, cutoff: '2026-11-07 09:25 +0800', silentHours: 12,
872
+ verdict: '2 个会话已过 35% 线,应先收齐交接再换会话',
762
873
  },
763
- consent: [{ sessionId: 'session-y', leftMs: 120000 }],
874
+ consent: [{ sessionId: 'session-y', leftMs: 120000, kind: 'compact', onduty: true }],
764
875
  notes: [],
765
876
  };
766
877
  globalThis.fetch = async () => ({ ok: true, json: async () => FIXTURE });
@@ -817,29 +928,50 @@ function textOf(node, out = []) {
817
928
  }
818
929
  const text = textOf(tree).join(' ');
819
930
  const treeJson = JSON.stringify(tree);
820
- for (const want of ['dsh-handover', '刷新', '读数', '压缩授权', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '使用说明']) {
931
+ for (const want of ['dsh-handover', '刷新', '读数', '授权', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '工具明细', '使用说明']) {
821
932
  ok(text.indexOf(want) !== -1, '成体视图应出现「' + want + '」');
822
933
  }
823
934
 
824
935
  // 回到重构前的版式——常用区直接展开,只折叠低频项;授权可直接点。
825
936
  const sections = tree.kids.filter((k) => k && k.type === 'section');
826
937
  const cardTitles = sections.map((sec) => textOf(sec.kids[0]).join(''));
827
- const wantOrder = ['压缩授权', '移动全局根目录', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '插件', '会话间通信守卫', '使用说明'];
828
- ok(JSON.stringify(cardTitles) === JSON.stringify(wantOrder), '分区应自上而下为授权、迁移、角色、提示词、只读数据、插件、守卫、说明', JSON.stringify(cardTitles));
938
+ const wantOrder = ['授权', '移动全局根目录', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '插件', '最近一次会话间通信判定', '工具明细', '使用说明'];
939
+ ok(JSON.stringify(cardTitles) === JSON.stringify(wantOrder), '分区应自上而下为授权、迁移、角色、提示词、只读数据、插件、判定、说明', JSON.stringify(cardTitles));
829
940
 
830
941
  const folds = treeJson.split('"details"').length - 1;
831
- ok(folds === 3, '只折叠低频项(迁移、前置提示词、使用说明),常用区直接展开', String(folds));
942
+ ok(folds === 6, '折叠项应为迁移、前置提示词、工具明细、使用说明与两个工作区组', String(folds));
832
943
 
833
- // 压缩授权:给出可点击的会话按钮,用户不必手填 id。
834
- const allowBtns = [];
944
+ // 会话角色:按 DSH 工作区分组、默认折叠,已归档会话不列出。
945
+ const roleCard = sections.find((sec) => textOf(sec.kids[0]).join('') === '会话角色');
946
+ const roleDetails = [];
835
947
  (function walk(n) {
836
948
  if (!n || typeof n !== 'object') return;
837
949
  if (Array.isArray(n)) { n.forEach(walk); return; }
838
- if (n.type === 'button' && textOf(n).join('').indexOf('允许 ') === 0) allowBtns.push(textOf(n).join(''));
950
+ if (n.type === 'details') roleDetails.push(n);
951
+ (n.kids || []).forEach(walk);
952
+ })(roleCard);
953
+ ok(roleDetails.length === 2, '会话角色应按工作区分组(全局+未分组)', String(roleDetails.length));
954
+ ok(roleDetails.every((d) => d.props.open !== true), '工作区分组默认收起');
955
+ const roleText = textOf(roleCard).join(' ');
956
+ ok(roleText.indexOf('全局(3 个会话)') !== -1 && roleText.indexOf('未分组(10 个会话)') !== -1, '分组标题应带会话数,未登记会话归入未分组', roleText.slice(0, 200));
957
+ ok(roleText.indexOf('已归档会话不列出') === -1 && roleText.indexOf('点击「设置角色」当场生效') === -1, '会话角色卡不再堆说明文字,只留标题与分组');
958
+
959
+ // 授权:只列在任会话,两组各一列按钮,用户不必手填 id。
960
+ const grantBtns = [];
961
+ (function walk(n) {
962
+ if (!n || typeof n !== 'object') return;
963
+ if (Array.isArray(n)) { n.forEach(walk); return; }
964
+ if (typeof n.type === 'function') { walk(n.type(n.props || {})); return; }
965
+ if (n.type === 'button') {
966
+ const label = textOf(n).join('');
967
+ if (label === '授权压缩' || label === '授权换任') grantBtns.push(label);
968
+ }
839
969
  (n.kids || []).forEach(walk);
840
970
  })(tree);
841
- ok(allowBtns.length >= 3, '压缩授权应提供可点击的会话按钮', JSON.stringify(allowBtns.slice(0, 5)));
842
- ok(text.indexOf('允许 y') !== -1, '授权按钮须带会话短号');
971
+ ok(grantBtns.filter((x) => x === '授权压缩').length === 3, '授权压缩:两张在任会话按钮+输入行按钮', JSON.stringify(grantBtns));
972
+ ok(grantBtns.filter((x) => x === '授权换任').length === 3, '授权换任:两张在任会话按钮+输入行按钮', JSON.stringify(grantBtns));
973
+ ok(text.indexOf('有授权') !== -1, '有有效授权时应显示「有授权」');
974
+ ok(text.indexOf('当前无有效授权') === -1, '不再显示无授权占位');
843
975
 
844
976
  // 角色下拉含普通,不再回退到空值。
845
977
  ok(text.indexOf('普通') !== -1, '角色下拉应含普通选项');
@@ -860,23 +992,60 @@ ok(migrateDetail && migrateDetail.type === 'details' && migrateDetail.props.open
860
992
  // 交接体检只写问题,不带括注。
861
993
  ok(text.indexOf('缺 ## 1. 正在做的事') !== -1 && text.indexOf('强制项') === -1, '交接体检的问题列不应带括注说明');
862
994
 
863
- // 会话水位只列项目会话,并给出本轮需通知。
864
- ok(text.indexOf('Mine') !== -1 && text.indexOf('34.6%') !== -1, '会话水位应列出项目会话与水位');
995
+ // 会话水位只列在任会话,全局、项目、杂项都要有,并给出本轮需通知。
996
+ const waterCard = sections.find((sec) => textOf(sec.kids[0]).join('') === '会话水位');
997
+ const waterText = textOf(waterCard).join(' ');
998
+ ok(waterText.indexOf('Demo1') !== -1 && waterText.indexOf('34.6%') !== -1, '会话水位应列出项目在任会话与水位');
999
+ ok(waterText.indexOf('全局1') !== -1 && waterText.indexOf('示例任务') !== -1, '会话水位应含全局与杂项的在任会话', waterText.slice(0, 240));
1000
+ ok(waterText.indexOf('Demo2') === -1, '会话水位不列已交出的会话', waterText.slice(0, 240));
865
1001
  ok(text.indexOf('本轮需通知') !== -1, '水位段应给出本轮需通知');
866
- ok(text.indexOf('Study1') !== -1, '角色表应列出平级会话');
1002
+ ok(text.indexOf('Demo2') !== -1, '角色表应列出平级会话');
1003
+ const hoCard = sections.find((sec) => textOf(sec.kids[0]).join('') === '交接文档体检');
1004
+ const hoText = textOf(hoCard).join(' ');
1005
+ ok(hoText.indexOf('杂项') !== -1 && hoText.indexOf('示例任务') !== -1, '交接体检应覆盖已登记的杂项任务', hoText.slice(0, 240));
867
1006
 
868
1007
  // 版式约束:无进度条、无图形结论符号。
869
1008
  ok(treeJson.indexOf('dshh-bar') === -1, '不得使用进度条');
870
1009
  const clientSrc = fs.readFileSync(path.join(dist, 'client.js'), 'utf8');
871
1010
  ok(!/[\u2705\u274C\u26A0\u2717]/.test(clientSrc), '界面不得使用图形结论符号');
872
- ok(clientSrc.indexOf('delWord.trim() !== "删除"') !== -1, '删除旧副本须输入确认词');
1011
+ ok(clientSrc.indexOf('delWord.trim() !== "删除"') !== -1, '删除旧备份须输入确认词');
1012
+ // 保留的旧备份不算待处理:迁移卡只在妨碍使用的情形下提示。
1013
+ ok(clientSrc.indexOf('(st.phase === "switched" && (st.olds || []).length)') === -1, '保留的旧备份不得计入待处理');
1014
+ ok(clientSrc.indexOf('旧备份保留,不影响使用') !== -1, '迁移完成后应注明旧备份不影响使用');
1015
+ // 角色下拉顺序保持全局、项目、杂项、普通;会话行本身按时间倒序,不再按角色排列。
1016
+ {
1017
+ const g = clientSrc.indexOf('value: "global"');
1018
+ const p = clientSrc.indexOf('value: "project"');
1019
+ const m = clientSrc.indexOf('value: "misc"');
1020
+ const n = clientSrc.indexOf('value: "plain"');
1021
+ ok(g !== -1 && p !== -1 && m !== -1 && n !== -1 && g < p && p < m && m < n, '角色下拉应为全局、项目、杂项、普通', JSON.stringify({ g, p, m, n }));
1022
+ ok(clientSrc.indexOf('ROLE_ORDER') === -1 && clientSrc.indexOf('byTime') !== -1, '会话角色组内应按时间倒序,不再按角色排列');
1023
+ }
1024
+
1025
+ // 组内顺序实测:夹具中全局组按时间应为 全局1、Demo1、Demo2。
1026
+ {
1027
+ const first = roleDetails[0];
1028
+ const rows = [];
1029
+ (function collect(n) {
1030
+ if (!n || typeof n !== 'object') return;
1031
+ if (Array.isArray(n)) { n.forEach(collect); return; }
1032
+ if (n.type === 'tr') {
1033
+ const cells = (n.kids || []).filter((k) => k && k.type === 'td');
1034
+ if (cells.length >= 2) rows.push(textOf(cells[0]).join(''));
1035
+ return;
1036
+ }
1037
+ if (typeof n.type === 'function') { collect(n.type(n.props || {})); return; }
1038
+ (n.kids || []).forEach(collect);
1039
+ })(first);
1040
+ ok(JSON.stringify(rows.slice(0, 3)) === JSON.stringify(['全局1', 'Demo1', 'Demo2']), '角色组内会话应按时间倒序排列', JSON.stringify(rows.slice(0, 3)));
1041
+ }
873
1042
 
874
1043
  // 未接入工作区:一键部署卡片置顶。
875
1044
  const NOT_READY = {
876
1045
  generatedAt: '2026-11-07 21:25 +0800',
877
1046
  plugin: { name: '@mingmaple/dsh-handover', version: 'test', tools: 22, inject: ['tools'], initErrors: [], scheduler: '就绪' },
878
1047
  workspace: { root: '', found: false },
879
- setup: { steps: ['准备一个长期存在的目录', '粘贴路径后点「一键部署」'] },
1048
+ setup: { steps: ['准备一个长期存在的目录', '粘贴路径后点击「一键部署」'] },
880
1049
  guide: payload ? payload.guide : null,
881
1050
  notes: ['未找到 _Control_Center:这个实例还没接入工作区。'],
882
1051
  };
@@ -890,7 +1059,7 @@ ok(titles3[0] === '首次安装', '未接入工作区时首次安装置顶', JSO
890
1059
  const text3 = textOf(tree3).join(' ');
891
1060
  ok(text3.indexOf('一键部署') !== -1, '未接入时应能一键部署(建体系+注册工作区+建全局会话)');
892
1061
  ok(text3.indexOf('仅建立文件体系') !== -1, '未接入时仍可只建立文件体系');
893
- ok(text3.indexOf('待处理') !== -1, '未接入时应显示待处理项');
1062
+ ok(text3.indexOf('未找到 _Control_Center') !== -1, '未接入工作区时应给出中文提示');
894
1063
 
895
1064
  // 已接入但没有全局会话:给出补建入口,且排在常规卡片之前。
896
1065
  const READY_NO_GLOBAL = Object.assign({}, FIXTURE, { globalSessions: 0 });
@@ -1020,7 +1189,7 @@ try {
1020
1189
  ok(/工作区/.test(newErr) && /已注册的工作区/.test(newErr), '新建会话缺工作区时应报错并列出可选工作区', newErr.slice(0, 160));
1021
1190
 
1022
1191
  // 回归锁(0.9.14):① 新建杂项会话的启动提示读本任务交接,不退回全局交接;
1023
- // ② 杂项换任的接任者落回本会话所在的注册工作区;③ 接任提示里的「全局 id」是现任全局,不是退休前任。
1192
+ // ② 杂项换任的接任者落回本会话所在的注册工作区;③ 接任提示里的「全局 id」是现任全局,不是已交出的旧会话。
1024
1193
  {
1025
1194
  const { registerHolder } = await import(DIST('lib/office.mjs'));
1026
1195
  const storages = path.join(BLANK, '.dsh', 'storages');
@@ -1045,26 +1214,26 @@ ok(/工作区/.test(newErr) && /已注册的工作区/.test(newErr), '新建会
1045
1214
  {
1046
1215
  let createdOut = '';
1047
1216
  try {
1048
- const r = await sessT.execute({ action: 'new', confirm: true, title: '杂项2', kind: 'misc', role: 'misc', task: '抢名额', workspace: 'ws-misc' }, makerExec2);
1217
+ const r = await sessT.execute({ action: 'new', confirm: true, title: '杂项2', kind: 'misc', role: 'misc', task: '示例任务', workspace: 'ws-misc' }, makerExec2);
1049
1218
  createdOut = String((r && r.text) || '');
1050
1219
  } catch (e) { createdOut = 'ERR ' + String(e && e.message ? e.message : e); }
1051
1220
  const p = seen.prompts[seen.prompts.length - 1] || '';
1052
1221
  ok(/已建会话/.test(createdOut), '新建杂项会话应成功', createdOut.slice(0, 200));
1053
- ok(/_Misc[\\/]tasks[\\/]抢名额[\\/]Handover\.md/.test(p), '新建杂项会话的启动提示应读本任务交接', p.slice(0, 200));
1222
+ ok(/_Misc[\\/]tasks[\\/]示例任务[\\/]Handover\.md/.test(p), '新建杂项会话的启动提示应读本任务交接', p.slice(0, 200));
1054
1223
  ok(p.indexOf('Global_Handover') === -1, '新建杂项会话的启动提示不得指向全局交接', p.slice(0, 200));
1055
1224
  }
1056
1225
  {
1057
1226
  const { runSuccession } = await import(DIST('lib/succession.mjs'));
1058
1227
  const { grantConsent } = await import(DIST('lib/consent.mjs'));
1059
- const miscHandover = path.join(miscWs, 'tasks', '抢名额', 'Handover.md');
1228
+ const miscHandover = path.join(miscWs, 'tasks', '示例任务', 'Handover.md');
1060
1229
  fs.mkdirSync(path.dirname(miscHandover), { recursive: true });
1061
- fs.writeFileSync(miscHandover, '# 抢名额交接' + '\n\n## 下一步\n- 等用户指派下一步\n', 'utf8');
1230
+ fs.writeFileSync(miscHandover, '# 示例任务交接' + '\n\n## 下一步\n- 等用户指派下一步\n', 'utf8');
1062
1231
  registerHolder(WS, { kind: 'global', name: '全局1', sessionId: 'session-global-cur', workspace: WS });
1063
- registerHolder(WS, { kind: 'misc', task: '抢名额', name: '杂项1', sessionId: 'session-misc-old', workspace: miscWs });
1232
+ registerHolder(WS, { kind: 'misc', task: '示例任务', name: '杂项1', sessionId: 'session-misc-old', workspace: miscWs });
1064
1233
  grantConsent('session-misc-old', 'blank-smoke', 'succeed');
1065
1234
  const succOut = await runSuccession({
1066
1235
  cfg: resolveConfig({ workspaceRoot: WS }),
1067
- roleOf: () => ({ role: 'misc', task: '抢名额', project: '' }),
1236
+ roleOf: () => ({ role: 'misc', task: '示例任务', project: '' }),
1068
1237
  selfId: () => 'session-misc-old',
1069
1238
  selfCwd: () => miscWs,
1070
1239
  handoverFor: () => ({ path: miscHandover, reason: '' }),
@@ -1078,9 +1247,9 @@ ok(/工作区/.test(newErr) && /已注册的工作区/.test(newErr), '新建会
1078
1247
  const madeArgs = seen.creates[seen.creates.length - 1] || {};
1079
1248
  ok(madeArgs.workspaceId === 'ws-misc', '杂项接任者应落回本会话所在的工作区', JSON.stringify(madeArgs));
1080
1249
  const p = seen.prompts[seen.prompts.length - 1] || '';
1081
- ok(new RegExp('.{0,1}抢名额[\\\\/]Handover\\.md').test(p), '接任提示末尾应带本任务交接路径', p.slice(-320));
1250
+ ok(new RegExp('.{0,1}示例任务[\\\\/]Handover\\.md').test(p), '接任提示末尾应带本任务交接路径', p.slice(-320));
1082
1251
  ok(p.indexOf('session-global-cur') !== -1, '接任提示的「全局 id」应是现任全局', p.slice(-320));
1083
- ok(p.indexOf('session-misc-old') === -1, '接任提示不得把退休前任当全局', p.slice(-320));
1252
+ ok(p.indexOf('session-misc-old') === -1, '接任提示不得把旧会话当全局', p.slice(-320));
1084
1253
  }
1085
1254
  }
1086
1255