@mingmaple/dsh-handover 0.9.22 → 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 (81) hide show
  1. package/README.md +117 -113
  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 +1 -1
  6. package/docs/releases/1.0.0.md +33 -0
  7. package/docs/troubleshoot.md +221 -61
  8. package/package.json +5 -2
  9. package/scripts/behavior.mjs +398 -0
  10. package/scripts/blank-dsh.mjs +12 -2
  11. package/scripts/blank-smoke.mjs +95 -44
  12. package/scripts/check-docs.mjs +7 -0
  13. package/scripts/portability.mjs +71 -0
  14. package/scripts/style-gate.mjs +571 -0
  15. package/src/client.js +1028 -994
  16. package/src/index.mjs +273 -109
  17. package/src/lib/absrefs.mjs +1 -1
  18. package/src/lib/api.mjs +9 -3
  19. package/src/lib/archive.mjs +1 -1
  20. package/src/lib/brief.mjs +8 -6
  21. package/src/lib/config.mjs +21 -6
  22. package/src/lib/docmap.mjs +5 -8
  23. package/src/lib/drift.mjs +1 -1
  24. package/src/lib/events.mjs +1 -1
  25. package/src/lib/facts.mjs +19 -12
  26. package/src/lib/gates.mjs +12 -12
  27. package/src/lib/global-task.mjs +69 -0
  28. package/src/lib/guide.mjs +80 -75
  29. package/src/lib/handover-io.mjs +69 -6
  30. package/src/lib/identity.mjs +330 -0
  31. package/src/lib/legacy.mjs +89 -0
  32. package/src/lib/lint.mjs +14 -11
  33. package/src/lib/manual.mjs +117 -80
  34. package/src/lib/mover.mjs +18 -14
  35. package/src/lib/office.mjs +42 -2
  36. package/src/lib/pack-ops.mjs +10 -10
  37. package/src/lib/pathkey.mjs +24 -0
  38. package/src/lib/phase.mjs +8 -13
  39. package/src/lib/profile.mjs +1 -1
  40. package/src/lib/reminder.mjs +110 -0
  41. package/src/lib/role.mjs +1 -1
  42. package/src/lib/scaffold.mjs +30 -10
  43. package/src/lib/schedule.mjs +5 -5
  44. package/src/lib/session-create.mjs +2 -2
  45. package/src/lib/session-ops.mjs +76 -47
  46. package/src/lib/sessions.mjs +40 -2
  47. package/src/lib/sevenzip.mjs +10 -1
  48. package/src/lib/status.mjs +337 -336
  49. package/src/lib/succession.mjs +14 -14
  50. package/src/lib/system.mjs +364 -0
  51. package/src/templates/Changelog.skeleton.md +3 -3
  52. package/src/templates/Charter.skeleton.md +13 -9
  53. package/src/templates/Handover.global.skeleton.md +20 -13
  54. package/src/templates/Handover.template.md +89 -0
  55. package/src/templates/Plan.skeleton.md +34 -0
  56. package/src/templates/Prompt_Templates.md +91 -10
  57. package/src/templates/Runtime_Manual.md +254 -139
  58. package/src/templates/System.skeleton.md +31 -0
  59. package/src/templates/Systems.global.skeleton.md +13 -0
  60. package/src/templates/rules/Delegation_Rules.md +45 -0
  61. package/src/templates/rules/Handover_Rules.md +46 -24
  62. package/src/templates/rules/Migration_Rules.md +7 -7
  63. package/src/templates/rules/Misc_Task_Standard.md +17 -10
  64. package/src/templates/rules/Writing_Rules.md +33 -16
  65. package/src/templates/standards/README.md +19 -7
  66. package/src/templates/standards//345/206/231/344/275/234/350/247/204/350/214/203/README.md +120 -0
  67. 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
  68. package/src/tools/ho_backup.mjs +8 -8
  69. package/src/tools/ho_changelog.mjs +4 -4
  70. package/src/tools/ho_charter.mjs +7 -6
  71. package/src/tools/ho_checkpoint.mjs +34 -31
  72. package/src/tools/ho_docmap.mjs +135 -49
  73. package/src/tools/ho_gate.mjs +9 -4
  74. package/src/tools/ho_handover.mjs +61 -35
  75. package/src/tools/ho_office.mjs +45 -4
  76. package/src/tools/ho_profile.mjs +3 -3
  77. package/src/tools/ho_sessions.mjs +43 -22
  78. package/src/tools/ho_status.mjs +14 -9
  79. package/src/tools/ho_succeed.mjs +3 -3
  80. package/src/tools/ho_workspace.mjs +83 -51
  81. package/src/tools/peer.mjs +219 -8
@@ -1,15 +1,18 @@
1
- /** ho_workspace —— 工作区生命周期:list / create / delete / init(首次建体系)+ 全局根目录迁移。 */
1
+ /** ho_workspace —— 工作区生命周期:list / create / delete / init / scaffold / migrate(旧体系迁移)+ 全局根目录迁移。 */
2
2
  import fs from 'node:fs';
3
3
  import { nameOf } from '../lib/names.mjs';
4
4
  import path from 'node:path';
5
+ import { pathKey } from '../lib/pathkey.mjs';
5
6
  import { textOutput } from '../lib/output.mjs';
6
7
  import { NL } from '../lib/text.mjs';
7
8
  import { workspaceRows } from '../lib/registry.mjs';
8
9
  import { createGlobalWorkspace, ensureScaffold } from '../lib/scaffold.mjs';
10
+ import { legacyInfo, migrateLegacyDocmaps } from '../lib/legacy.mjs';
11
+ import { syncGlobalRefs } from '../lib/system.mjs';
9
12
  import { probeMove, startMove, moveStatus, cleanupOld, stageSwitch, repairLink, stageRetarget } from '../lib/mover.mjs';
10
13
  import { currentRoot } from '../lib/api.mjs';
11
14
 
12
- const BAD = '不能删除「全局」工作区(那是管理会话的家)。';
15
+ const BAD = '不能删除「全局」工作区:该工作区为管理会话所在工作区。';
13
16
 
14
17
  function gb(n) {
15
18
  const v = Number(n) || 0;
@@ -29,7 +32,7 @@ function renderProbe(p) {
29
32
  if (p.freeBytes !== null && p.freeBytes !== undefined) out.push(' 目标盘可用 ' + gb(p.freeBytes));
30
33
  for (const w of p.warnings || []) out.push(' 注意:' + w);
31
34
  for (const x of p.problems || []) out.push(' 未通过:' + x);
32
- if (p.ok) out.push('提示:复制完会生成切换脚本;切换那一步需要**完全退出 DSH** 再跑(源目录被占用)。');
35
+ if (p.ok) out.push('提示:复制完成后生成切换脚本;切换步骤须完全退出 DSH 后再执行(源目录被占用)。');
33
36
  return out.join(NL);
34
37
  }
35
38
 
@@ -37,7 +40,7 @@ export function pmWorkspaceTool(env) {
37
40
  return {
38
41
  name: nameOf('workspace'),
39
42
  description:
40
- '工作区:init 首次建全局体系、scaffold 补齐缺失目录与说明、list、create、delete、move-* 搬整个全局根目录。删除只删注册。',
43
+ '工作区:init 首次建立全局体系、scaffold 补齐缺失目录与说明、migrate 旧体系迁移、list、create、delete、move-* 迁移整个全局根目录。删除只删注册。',
41
44
  parameters: {
42
45
  type: 'object',
43
46
  additionalProperties: false,
@@ -45,18 +48,18 @@ export function pmWorkspaceTool(env) {
45
48
  properties: {
46
49
  action: {
47
50
  type: 'string',
48
- enum: ['init', 'scaffold', 'list', 'create', 'delete', 'move-probe', 'move', 'move-switch', 'move-status', 'move-retarget', 'move-repair', 'move-cleanup'],
49
- description: 'init / scaffold / list / create / delete / move-probe / move / move-switch / move-status / move-retarget / move-repair / move-cleanup',
51
+ enum: ['init', 'scaffold', 'migrate', 'list', 'create', 'delete', 'move-probe', 'move', 'move-switch', 'move-status', 'move-retarget', 'move-repair', 'move-cleanup'],
52
+ description: 'init / scaffold / migrate / list / create / delete / move-probe / move / move-switch / move-status / move-retarget / move-repair / move-cleanup',
50
53
  },
51
- parent: { type: 'string', description: 'init 用:目标父目录绝对路径(不存在会创建);全局根建在它里面' },
52
- name: { type: 'string', description: 'init 用:全局根目录名,默认 _Workspace' },
53
- register: { type: 'boolean', description: 'init 用:建好后顺带注册为工作区(默认 false)' },
54
- path: { type: 'string', description: 'create 用:目录绝对路径;默认要求已存在,ensure=true 时自动创建' },
55
- title: { type: 'string', description: 'create、init 用:工作区显示标题(init 默认「全局」)' },
56
- ensure: { type: 'boolean', description: 'create 用:目录不存在时自动创建(默认 false,不替你建目录)' },
57
- workspace: { type: 'string', description: 'delete 用:工作区 id、标题、路径' },
58
- to: { type: 'string', description: 'move 用:新根绝对路径(父目录可自动建)' },
59
- confirm: { type: 'boolean', description: 'move-cleanup 用:true 才真的删旧备份' },
54
+ parent: { type: 'string', description: 'init 用:目标父目录的绝对路径(不存在时创建);全局根建于其中' },
55
+ name: { type: 'string', description: 'init 用:全局根目录名,缺省 _Workspace' },
56
+ register: { type: 'boolean', description: 'init 用:建立后一并注册为工作区(缺省 false)' },
57
+ path: { type: 'string', description: 'create 用:目录绝对路径;缺省要求已存在,ensure=true 时自动创建' },
58
+ title: { type: 'string', description: 'create、init 用:工作区显示标题(init 缺省「全局」)' },
59
+ ensure: { type: 'boolean', description: 'create 用:目录不存在时自动创建(缺省 false,不代建目录)' },
60
+ workspace: { type: 'string', description: 'delete 用:工作区 id、标题或路径' },
61
+ to: { type: 'string', description: 'move 用:新根绝对路径(父目录可自动创建)' },
62
+ confirm: { type: 'boolean', description: 'move-cleanup 用:为 true 才实际删除旧备份' },
60
63
  },
61
64
  },
62
65
  output: textOutput(),
@@ -73,20 +76,20 @@ export function pmWorkspaceTool(env) {
73
76
  out.push('未建立:' + r.error);
74
77
  if (r.root) out.push('目标:' + r.root);
75
78
  out.push('');
76
- out.push('用法:ho_workspace(action="init", parent="<已存在的目录绝对路径>")。插件只新建、不覆盖。');
79
+ out.push('用法:ho_workspace(action="init", parent="<已存在的目录绝对路径>")。插件仅新建,不覆盖。');
77
80
  return { text: out.join(NL) };
78
81
  }
79
82
  out.push('已建立全局文件体系:' + r.root);
80
83
  out.push(' 目录 ' + r.created.length + ' 个:' + r.created.slice(0, 5).join('、') + (r.created.length > 5 ? ' 等' : ''));
81
- out.push(' 文件 ' + r.written.length + ' 个:' + (r.written.length ? r.written.join('、') : '(都已存在,未覆盖)'));
82
- if (r.existed) out.push(' 目标目录原为空目录,直接使用。');
83
- if (r.createdParent) out.push(' 父目录原本不存在,已创建。');
84
+ out.push(' 文件 ' + r.written.length + ' 个:' + (r.written.length ? r.written.join('、') : '(均已存在,未覆盖)'));
85
+ if (r.existed) out.push(' 目标原为空目录,直接使用。');
86
+ if (r.createdParent) out.push(' 父目录原不存在,已创建。');
84
87
  if (r.skipped.length) out.push(' 跳过(已存在、未覆盖):' + r.skipped.join('、'));
85
88
  let registered = null;
86
89
  if (args.register === true) {
87
90
  if (!reg || typeof reg.create !== 'function') {
88
91
  out.push('');
89
- out.push('注册未完成:workspaceRegistry 不可用。请把下面这个路径粘进 DSH 的新建工作区窗口:');
92
+ out.push('注册未完成:workspaceRegistry 不可用。请将下列路径粘贴到 DSH 的新建工作区窗口:');
90
93
  out.push(' ' + r.root);
91
94
  return { text: out.join(NL) };
92
95
  }
@@ -99,7 +102,7 @@ export function pmWorkspaceTool(env) {
99
102
  } catch (e) {
100
103
  out.push('');
101
104
  out.push('注册失败:' + String(e && e.message ? e.message : e));
102
- out.push('可把下面这个路径粘进 DSH 的新建工作区窗口:');
105
+ out.push('可将下列路径粘贴到 DSH 的新建工作区窗口:');
103
106
  out.push(' ' + r.root);
104
107
  return { text: out.join(NL) };
105
108
  }
@@ -107,12 +110,12 @@ export function pmWorkspaceTool(env) {
107
110
  out.push('');
108
111
  out.push('下一步:');
109
112
  if (!registered) {
110
- out.push(' 1. 注册为工作区:ho_workspace(action="create", path="' + r.root + '", title="全局"),或把该路径粘进 DSH 的新建工作区窗口;');
111
- out.push(' 2. 新建一个标题含「全局」的会话,闸门、交接体检、会话水位即接入。');
113
+ out.push(' 1. 注册为工作区:ho_workspace(action="create", path="' + r.root + '", title="全局"),或将该路径粘贴到 DSH 的新建工作区窗口;');
114
+ out.push(' 2. 新建一个标题含「全局」的会话,闸门、交接体检与会话水位即接入。');
112
115
  } else {
113
- out.push(' 新建一个标题含「全局」的会话,闸门、交接体检、会话水位即接入。');
116
+ out.push(' 新建一个标题含「全局」的会话,闸门、交接体检与会话水位即接入。');
114
117
  }
115
- out.push('(也可以在设置页的 dsh-handover 中完成「首次安装」,效果相同。)');
118
+ out.push('(也可在设置页的 dsh-handover 中完成「首次安装」,效果相同。)');
116
119
  return { text: out.join(NL) };
117
120
  }
118
121
 
@@ -129,7 +132,36 @@ export function pmWorkspaceTool(env) {
129
132
  out.push(' 新建目录 ' + r.created.length + ' 个:' + (r.created.length ? r.created.join('、') : '(无缺失)'));
130
133
  out.push(' 新建文件 ' + r.written.length + ' 个:' + (r.written.length ? r.written.join('、') : '(无缺失)'));
131
134
  out.push(' 已存在未覆盖 ' + r.skipped.length + ' 个' + (r.skipped.length ? ':' + r.skipped.join('、') : ''));
132
- out.push('只新建、不覆盖、不删:已有文件的内容一字未改。');
135
+ if (r.missingTemplates && r.missingTemplates.length) {
136
+ out.push(' 内置模板缺失 ' + r.missingTemplates.length + ' 个(未写空文件):' + r.missingTemplates.join('、'));
137
+ }
138
+ out.push('只新建,不覆盖、不删除:已有文件的内容未作任何改动。');
139
+ const legacy = legacyInfo(root);
140
+ if (legacy.exists) out.push('提示:检测到旧文档定位(' + legacy.dir + '),执行 ho_workspace(action="migrate") 并入体系注册。');
141
+ return { text: out.join(NL) };
142
+ }
143
+
144
+ if (action === 'migrate') {
145
+ const root = currentRoot(env);
146
+ if (!root) throw new Error('未找到全局根(工作区未接入)');
147
+ const pre = legacyInfo(root);
148
+ if (!pre.exists) {
149
+ out.push('没有旧文档定位:' + pre.dir);
150
+ out.push('判定:无需迁移。');
151
+ return { text: out.join(NL) };
152
+ }
153
+ const r = migrateLegacyDocmaps(root);
154
+ out.push('旧文档定位迁移:' + (r.ok ? '完成' : '部分完成'));
155
+ out.push(' 已并入体系注册 ' + r.converted.length + ' 份' + (r.converted.length ? ':' + r.converted.join('、') : ''));
156
+ out.push(' 全局旧内容保留为工作区文档清单 ' + r.kept.length + ' 份' + (r.kept.length ? ':' + r.kept.join('、') : ''));
157
+ if (r.archived) out.push(' 旧目录已归档(未删除):' + r.archived);
158
+ for (const p of r.problems || []) out.push(' 未迁移:' + p);
159
+ try {
160
+ const g = syncGlobalRefs(root);
161
+ out.push(' 全局总注册已重建:' + g.rows.length + ' 个任务');
162
+ } catch (e) {
163
+ out.push(' 全局总注册重建失败:' + String(e && e.message ? e.message : e));
164
+ }
133
165
  return { text: out.join(NL) };
134
166
  }
135
167
 
@@ -147,7 +179,7 @@ export function pmWorkspaceTool(env) {
147
179
  }
148
180
  }
149
181
  const rows = workspaceRows();
150
- out.push('工作区(退化:读 workspace.json,' + rows.length + ' 个)');
182
+ out.push('工作区(退化读取 workspace.json,' + rows.length + ' 个)');
151
183
  for (const r of rows) out.push(' ' + r.id + ' ' + r.title + ' ' + r.path + ' 会话 ' + r.sessions);
152
184
  return { text: out.join(NL) };
153
185
  }
@@ -161,13 +193,13 @@ export function pmWorkspaceTool(env) {
161
193
  if ((!st || !st.isDirectory()) && args.ensure === true) {
162
194
  try { fs.mkdirSync(p, { recursive: true }); st = fs.statSync(p); } catch (e) { throw new Error('建目录失败:' + p + '(' + String(e && e.message ? e.message : e) + ')'); }
163
195
  }
164
- if (!st || !st.isDirectory()) throw new Error('目录不存在或不是目录:' + p + '(默认不替你建目录;要建请传 ensure=true,或先手工建好)');
196
+ if (!st || !st.isDirectory()) throw new Error('目录不存在或不是目录:' + p + '(缺省不代建目录;需要创建时传 ensure=true,或先行手工建好)');
165
197
  const title = String(args.title || '').trim() || p.split(/[\\/]/).filter(Boolean).pop() || p;
166
198
  let w = null;
167
199
  try { w = await reg.create(p, title); } catch (e) { throw new Error('建工作区失败:' + String(e && e.message ? e.message : e)); }
168
200
  out.push('已注册工作区 ' + String(w && w.id ? w.id : '?') + ' ' + String(w && w.title ? w.title : title));
169
201
  out.push('路径 ' + String(w && w.path ? w.path : p));
170
- out.push('同一路径重复调用返回原记录、不改标题;新登记的工作区排在列表最前。');
202
+ out.push('同一路径重复调用返回原记录,不改标题;新登记的工作区排在列表最前。');
171
203
  return { text: out.join(NL) };
172
204
  }
173
205
 
@@ -177,13 +209,13 @@ export function pmWorkspaceTool(env) {
177
209
  if (!key) throw new Error('delete 需要 workspace(id、标题、路径)');
178
210
  let list = [];
179
211
  try { list = reg.list() || []; } catch { list = []; }
180
- const norm = (s) => String(s || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
212
+ const norm = (s) => pathKey(String(s || ''));
181
213
  const hit = list.find((w) => String(w.id) === key || w.title === key || norm(w.path) === norm(key));
182
214
  if (!hit) throw new Error('找不到工作区:' + key);
183
215
  if (String(hit.title || '').indexOf('全局') !== -1) throw new Error(BAD);
184
216
  const ok = await reg.delete(hit.id);
185
217
  out.push((ok ? '已删除工作区注册 ' : '该工作区不存在(无操作)') + String(hit.id) + ' ' + String(hit.title || '-'));
186
- out.push('注意:只删注册;目录与全部会话日志都保留在原处。');
218
+ out.push('注意:只删注册;目录与全部会话日志均保留在原处。');
187
219
  return { text: out.join(NL) };
188
220
  }
189
221
 
@@ -200,11 +232,11 @@ export function pmWorkspaceTool(env) {
200
232
  if (!p.ok) return { text: renderProbe(p) + NL + '未开始复制。' };
201
233
  const r = await startMove({ src, to });
202
234
  if (!r.ok) {
203
- out.push('复制完成但**校验有差异**,未生成切换脚本。');
235
+ out.push('复制完成,但校验存在差异,未生成切换脚本。');
204
236
  out.push(' 文件 源 ' + r.report.files + ' / 目标 ' + r.report.copies);
205
237
  out.push(' 缺失 ' + r.report.missingCount + ' 条' + (r.report.missing.length ? ':' + r.report.missing.join('、') : ''));
206
238
  out.push(' 多出 ' + r.report.extraCount + ' 条' + (r.report.extra.length ? ':' + r.report.extra.join('、') : ''));
207
- out.push('处置:清空目标目录后重跑,或先停掉在写文件的会话。');
239
+ out.push('处置:清空目标目录后重跑,或先停止正在写入文件的会话。');
208
240
  return { text: out.join(NL) };
209
241
  }
210
242
  out.push('复制并校验通过:' + r.report.files + ' 文件 · ' + gb(r.report.bytes));
@@ -213,20 +245,20 @@ export function pmWorkspaceTool(env) {
213
245
  out.push(' 切换助手 ' + r.marker.script.helper);
214
246
  out.push(' 手工启动器(Windows、POSIX) ' + r.marker.script.cmd + ' 、 ' + r.marker.script.sh);
215
247
  out.push('');
216
- out.push('下一步(三条,顺序不能换):');
248
+ out.push('下一步(3 步,顺序不可调换):');
217
249
  out.push(' ① 完全退出 DSH(源目录被占用,改名会失败)');
218
- out.push(' ② 执行 ho_workspace(action="move-switch") 派后台助手(推荐);助手会等待 DSH 退出后自动完成');
250
+ out.push(' ② 执行 ho_workspace(action="move-switch") 派出后台助手(推荐用法);该助手会在 DSH 退出后自动完成');
219
251
  out.push(' ③ 重新启动 DSH;确认无误后可在设置页删除旧备份');
220
252
  return { text: out.join(NL) };
221
253
  }
222
254
 
223
255
  if (action === 'move-switch') {
224
256
  const r = stageSwitch({ relaunch: args.relaunch === true });
225
- if (r.already) { out.push('已经是切换态(旧路径就是 junction),无需再切。'); return { text: out.join(NL) }; }
257
+ if (r.already) { out.push('已处于切换态(原路径即链接),无需再次切换。'); return { text: out.join(NL) }; }
226
258
  out.push('已派出后台切换助手(pid ' + r.pid + ')。');
227
- out.push(' 它会在你**完全退出 DSH** 后自动完成:旧目录改名、原位建 junction、校验指向' + (r.relaunch ? ',然后重新启动 DSH' : '') + '。');
259
+ out.push(' 该助手会在完全退出 DSH 后自动完成:旧目录改名、原位建立链接、校验指向' + (r.relaunch ? ',然后重新启动 DSH' : '') + '。');
228
260
  out.push(' 日志:' + r.log);
229
- out.push(' 提醒:从现在起不要改这个工作区里的文件(改动只会落在还活着的旧目录上)。');
261
+ out.push(' 提醒:此后不得修改该工作区内的文件(改动只会落在仍在使用的旧目录上)。');
230
262
  return { text: out.join(NL) };
231
263
  }
232
264
 
@@ -234,14 +266,14 @@ export function pmWorkspaceTool(env) {
234
266
  const st = moveStatus(); // 以标记里的源路径为准:修改登记后当前根已是新位置,拿它比对会误报
235
267
  const label = { none: '没有迁移记录', verified: '已复制并校验通过,待切换', switched: '已切换(原路径为链接)', partial: '复制有差异,未生成脚本' };
236
268
  out.push('迁移状态:' + (label[st.phase] || st.phase));
237
- out.push(' 源 ' + st.src + (st.linkTarget ? '(→ ' + st.linkTarget + ')' : ''));
269
+ out.push(' 源 ' + st.src + (st.linkTarget ? '(当前为链接,指向 ' + st.linkTarget + ')' : ''));
238
270
  if (st.dst) out.push(' 目标 ' + st.dst);
239
271
  if (st.marker && st.marker.files) out.push(' 清单 ' + st.marker.files + ' 文件 · ' + (st.marker.digest || '').slice(0, 16) + '…');
240
272
  if (st.retarget) {
241
273
  if (st.retarget.needed) {
242
- out.push(' DSH 登记仍指着旧路径:' + st.retarget.total + ' 处');
274
+ out.push(' DSH 登记仍指向旧路径:' + st.retarget.total + ' 处');
243
275
  for (const f of st.retarget.files) out.push(' ' + f.path + ' ×' + f.count);
244
- out.push(' 修改登记:ho_workspace(action="move-retarget")(退出 DSH 后由助手改写,先备份)');
276
+ out.push(' 修改登记:ho_workspace(action="move-retarget")(退出 DSH 后由助手改写,改写前先备份)');
245
277
  } else {
246
278
  out.push(' DSH 登记已在新位置(无需修改登记)');
247
279
  }
@@ -250,7 +282,7 @@ export function pmWorkspaceTool(env) {
250
282
  for (const o of st.olds) out.push(' 旧备份 ' + o.path + ' ' + gb(o.bytes));
251
283
  out.push(' 删除:ho_workspace(action="move-cleanup", confirm=true)');
252
284
  } else if (st.phase === 'switched') {
253
- out.push(' 没有旧备份');
285
+ out.push(' 无旧备份');
254
286
  }
255
287
  return { text: out.join(NL) };
256
288
  }
@@ -258,32 +290,32 @@ export function pmWorkspaceTool(env) {
258
290
  if (action === 'move-retarget') {
259
291
  const r = stageRetarget();
260
292
  if (r.already) { out.push('DSH 登记已在新位置,无需修改登记。'); return { text: out.join(NL) }; }
261
- if (!r.ok) { out.push('派助手失败:' + r.error); out.push(' 手工执行:' + (r.script && r.script.helper)); return { text: out.join(NL) }; }
262
- out.push('已派出登记修改登记助手(pid ' + r.pid + ')。');
263
- out.push(' 它会在你完全退出 DSH 后改写这些登记(每份先备份):');
293
+ if (!r.ok) { out.push('派出助手失败:' + r.error); out.push(' 手工执行:' + (r.script && r.script.helper)); return { text: out.join(NL) }; }
294
+ out.push('已派出修改登记的助手(pid ' + r.pid + ')。');
295
+ out.push(' 该助手会在完全退出 DSH 后改写下列登记(每份先备份):');
264
296
  for (const f of (r.plan.files || [])) if (f.count) out.push(' ' + f.path + ' ×' + f.count);
265
- out.push(' 只改登记文本,不动任何数据文件;日志:' + (r.script && r.script.log));
297
+ out.push(' 只改登记文本,不改动任何数据文件;日志:' + (r.script && r.script.log));
266
298
  return { text: out.join(NL) };
267
299
  }
268
300
 
269
301
  if (action === 'move-repair') {
270
302
  const r = repairLink();
271
303
  if (r.already) { out.push('原路径已是指向新根的链接,无需重建。'); return { text: out.join(NL) }; }
272
- out.push('已重建链接:' + r.src + ' → ' + r.dst + '(' + r.kind + ')');
273
- out.push(' 说明:只建链接,未改动任何文件。');
304
+ out.push('已重建链接:' + r.src + ' 指向 ' + r.dst + '(' + r.kind + ')');
305
+ out.push(' 说明:仅建立链接,未改动任何文件。');
274
306
  return { text: out.join(NL) };
275
307
  }
276
308
 
277
309
  if (action === 'move-cleanup') {
278
- if (args.confirm !== true) throw new Error('move-cleanup 需要 confirm=true(会真删旧备份目录)');
310
+ if (args.confirm !== true) throw new Error('move-cleanup 需要 confirm=true(将实际删除旧备份目录)');
279
311
  const r = await cleanupOld({ confirm: true });
280
312
  out.push(r.note);
281
- for (const p of r.removed) out.push(' 已删 ' + p);
313
+ for (const p of r.removed) out.push(' 已删除 ' + p);
282
314
  if (r.bytes) out.push(' 释放 ' + gb(r.bytes));
283
315
  return { text: out.join(NL) };
284
316
  }
285
317
 
286
- throw new Error('action 只能是 init / scaffold / list / create / delete / move-probe / move / move-switch / move-status / move-retarget / move-repair / move-cleanup');
318
+ throw new Error('action 只能是 init / scaffold / migrate / list / create / delete / move-probe / move / move-switch / move-status / move-retarget / move-repair / move-cleanup');
287
319
  },
288
320
  };
289
321
  }
@@ -10,6 +10,9 @@
10
10
  * 动作级权限在这里再判一次:角色矩阵只决定「这个工具可不可见」,不细分动作。
11
11
  */
12
12
  import { randomUUID } from 'node:crypto';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
13
16
  import { textOutput } from '../lib/output.mjs';
14
17
  import { PEER_NAME } from '../lib/names.mjs';
15
18
  import { NL } from '../lib/text.mjs';
@@ -18,9 +21,197 @@ import {
18
21
  projectName, pick, peerMarker, resolveWorkspaceId, findSessionLog, decodeSessionLog, conversation,
19
22
  } from '../lib/peer.mjs';
20
23
 
21
- export const ACTIONS = ['list', 'status', 'send', 'peek', 'rename', 'new'];
24
+ export const ACTIONS = ['list', 'status', 'send', 'wait', 'collect', 'collab', 'peek', 'rename', 'new'];
22
25
  const GLOBAL_ONLY = ['list', 'peek', 'rename'];
23
26
 
27
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
28
+ const COLLECT_POLL_MS = 30000;
29
+ const WAIT_MIN_MS = 120000;
30
+
31
+ /** 协作游标:交办者 → 目标 → { since }。只存时间戳,不存消息正文。 */
32
+ function collectFile() {
33
+ const home = process.env.USERPROFILE || process.env.HOME || os.homedir();
34
+ return path.join(home, '.dsh', 'storages', 'dsh-handover.collect.json');
35
+ }
36
+ function loadCollect() {
37
+ try {
38
+ const data = JSON.parse(fs.readFileSync(collectFile(), 'utf8'));
39
+ if (data && typeof data === 'object' && data.cursors) {
40
+ if (!Array.isArray(data.groups)) data.groups = [];
41
+ return data;
42
+ }
43
+ } catch (error) { void error; }
44
+ return { version: 1, cursors: {}, groups: [] };
45
+ }
46
+ function saveCollect(data) {
47
+ const file = collectFile();
48
+ fs.mkdirSync(path.dirname(file), { recursive: true });
49
+ const tmp = file + '.tmp-' + process.pid;
50
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
51
+ fs.renameSync(tmp, file);
52
+ }
53
+ function callerOf(exec) {
54
+ return String(exec?.agent?.session?.header?.id ?? 'unknown');
55
+ }
56
+ function sinceOf(data, caller, target) {
57
+ const row = data.cursors[caller] && data.cursors[caller][target];
58
+ return row && Number.isFinite(row.since) ? row.since : null;
59
+ }
60
+ function setSince(data, caller, target, since) {
61
+ if (!data.cursors[caller]) data.cursors[caller] = {};
62
+ data.cursors[caller][target] = { since };
63
+ }
64
+
65
+ /** 协作注册:被交办方在协作期间不得向交办者或同组成员发明文消息;收集完自动解除。 */
66
+ function groupsOf(state) {
67
+ return Array.isArray(state.groups) ? state.groups : [];
68
+ }
69
+ function groupOf(state, sid) {
70
+ return groupsOf(state).find((g) => String(sid) === String(g.coordinator) || (Array.isArray(g.members) && g.members.indexOf(String(sid)) !== -1)) || null;
71
+ }
72
+ function assertNotReplyingInCollab(state, sender, target) {
73
+ const g = groupOf(state, sender);
74
+ if (!g) return;
75
+ if (String(sender) === String(g.coordinator)) return;
76
+ const inGroup = String(target) === String(g.coordinator)
77
+ || (Array.isArray(g.members) && g.members.indexOf(String(target)) !== -1);
78
+ if (!inGroup) return;
79
+ throw new Error(
80
+ '协作注册期间,被交办方不得向交办者或同组成员发送明文消息(target=' + target + '):'
81
+ + '完成即结束回合,最终回复即为结果,协调者用 peer(action="collect") 取回。'
82
+ + '如需请示,将问题写入最终回复并结束回合;普通对话不受此限制。',
83
+ );
84
+ }
85
+
86
+ /**
87
+ * 供宿主工具守卫使用:某会话是协作成员(非协调者)时返回拒绝理由,否则空串。
88
+ * 用于拦住 DSH 自带的 send_message 等明文投递通道。
89
+ */
90
+ export function collabMemberReason(sid) {
91
+ try {
92
+ const state = loadCollect();
93
+ const g = groupOf(state, sid);
94
+ if (!g) return '';
95
+ if (String(sid) === String(g.coordinator)) return '';
96
+ return '协作注册期间,被交办方不得发送明文消息:完成即结束回合,最终回复由协调者用 peer(action="collect") 取回。';
97
+ } catch (error) { void error; }
98
+ return '';
99
+ }
100
+
101
+ /** 协作注册:开始或结束一组协作;结束即解除明文交流限制。 */
102
+ function collabText(args, exec) {
103
+ const caller = callerOf(exec);
104
+ const state = loadCollect();
105
+ // 结束模式取 collabMode(schema 参数名);兼容早期把 mode 当结束开关的调用。
106
+ const ending = args.collabMode === 'end' || args.mode === 'end';
107
+ if (ending) {
108
+ const before = groupsOf(state).length;
109
+ state.groups = groupsOf(state).filter((g) => String(g.coordinator) !== String(caller));
110
+ saveCollect(state);
111
+ return '已结束协作注册(' + String(before - state.groups.length) + ' 组),明文交流限制已解除。';
112
+ }
113
+ const members = (Array.isArray(args.sessions) ? args.sessions : [])
114
+ .map((x) => String(x || '').trim()).filter(Boolean).filter((s) => s !== caller);
115
+ if (!members.length) throw new Error('peer(action="collab"):sessions 至少列一个协作会话');
116
+ state.groups = groupsOf(state).filter((g) => String(g.coordinator) !== String(caller));
117
+ state.groups.push({ coordinator: caller, members, at: Date.now() });
118
+ saveCollect(state);
119
+ return '已注册协作:协调者 ' + caller + ',成员 ' + members.join('、')
120
+ + '。协作期间成员不得向协调者或同组成员发送明文消息;collect 取回全部结果后自动解除。';
121
+ }
122
+
123
+ /** 助手文本:读目标日志里 since 之后的助手回复,不定长截断。 */
124
+ function assistantReplies(file, sinceMs) {
125
+ const events = decodeSessionLog(file);
126
+ if (events === null) return null;
127
+ const items = conversation(events, 500).filter((it) => it.who === 'assistant' && Number(it.t) > sinceMs);
128
+ return items.map((it) => it.text);
129
+ }
130
+
131
+ /** 单个目标的完成判定:这一轮已结束,且 since 之后有助手文本。 */
132
+ function inspectTarget(sid, since) {
133
+ const file = findSessionLog(sid);
134
+ if (file === null) return { sid, state: 'missing', text: '' };
135
+ let replies = null;
136
+ try { replies = assistantReplies(file, since); } catch (error) { void error; }
137
+ if (replies === null) return { sid, state: 'nozstd', text: '' };
138
+ const cache = readProjCache(sid);
139
+ const busy = cache === null ? null : isBusy(cache);
140
+ if (busy === false && replies.length) {
141
+ return { sid, state: 'done', text: replies[replies.length - 1], at: Date.now() };
142
+ }
143
+ return { sid, state: busy === null ? 'unknown' : busy ? 'running' : 'ended-no-text', text: '' };
144
+ }
145
+
146
+ /** 一对一:等单个目标的回复。 */
147
+ async function waitText(args) {
148
+ const sid = String(args.session_id || '').trim();
149
+ if (!sid) throw new Error('peer(action="wait"):session_id 不能为空');
150
+ const timeout = Math.max(WAIT_MIN_MS, Math.min(3600000, Number(args.timeout_ms) || WAIT_MIN_MS));
151
+ const since = Number(args.since_ms) || Date.now();
152
+ const deadline = Date.now() + timeout;
153
+ let lastError = '';
154
+ for (;;) {
155
+ const one = inspectTarget(sid, since);
156
+ if (one.state === 'done') return '回复 ' + sid + ':' + NL + one.text;
157
+ if (one.state === 'missing') throw new Error('peer(action="wait"):找不到目标会话日志:' + sid);
158
+ if (one.state === 'nozstd') throw new Error('peer(action="wait"):本 Node 构建没有 zstd 支持,读不了会话日志');
159
+ lastError = one.state;
160
+ if (Date.now() >= deadline) break;
161
+ await sleep(2000);
162
+ }
163
+ return '等待超时(' + String(Math.round(timeout / 1000)) + ' 秒):' + sid + ' 状态 ' + lastError
164
+ + '。可先用 peer(action="status") 查看状态,稍后重试 wait。';
165
+ }
166
+
167
+ /**
168
+ * 多会话收集:列一串会话,谁先完成先返回;同时完成多个就一起返回。
169
+ * 一次调用最多等 timeout_ms(最低 120 秒,由助手按任务预估设置);到点一个都没完成则返回仍在运行的名单。
170
+ */
171
+ async function collectText(args, exec) {
172
+ const caller = callerOf(exec);
173
+ const state = loadCollect();
174
+ const list = Array.isArray(args.sessions)
175
+ ? args.sessions.map((x) => String(x || '').trim()).filter(Boolean)
176
+ : [];
177
+ const targets = list.length ? list : Object.keys(state.cursors[caller] || {});
178
+ if (!targets.length) throw new Error('peer(action="collect"):sessions 为空,且没有已交办的目标');
179
+ const timeout = Math.max(WAIT_MIN_MS, Math.min(3600000, Number(args.timeout_ms) || WAIT_MIN_MS));
180
+ const deadline = Date.now() + timeout;
181
+ const explicitSince = Number(args.since_ms) || null;
182
+ const since = {};
183
+ for (const sid of targets) since[sid] = sinceOf(state, caller, sid) ?? explicitSince ?? Date.now();
184
+ const done = [];
185
+ const running = new Map(targets.map((sid) => [sid, 'running']));
186
+ for (;;) {
187
+ for (const sid of targets) {
188
+ if (done.some((d) => d.sid === sid)) continue;
189
+ const one = inspectTarget(sid, since[sid]);
190
+ running.set(sid, one.state);
191
+ if (one.state === 'done') done.push(one);
192
+ }
193
+ if (done.length) {
194
+ for (const one of done) setSince(state, caller, one.sid, one.at);
195
+ const groups = groupsOf(state);
196
+ const g = groups.find((x) => String(x.coordinator) === caller);
197
+ if (g) {
198
+ g.members = (Array.isArray(g.members) ? g.members : []).filter((m) => !done.some((d) => d.sid === m));
199
+ if (!g.members.length) state.groups = groups.filter((x) => x !== g);
200
+ }
201
+ saveCollect(state);
202
+ const out = ['已完成 ' + String(done.length) + ' 个:'];
203
+ for (const one of done) out.push('[' + one.sid + ']' + NL + one.text);
204
+ return out.join(NL);
205
+ }
206
+ if (Date.now() >= deadline) break;
207
+ await sleep(COLLECT_POLL_MS);
208
+ }
209
+ const lines = ['本轮没有完成的会话(已等待 ' + String(Math.round(timeout / 1000)) + ' 秒):'];
210
+ for (const sid of targets) lines.push(' ' + sid + ' ' + (running.get(sid) || 'running'));
211
+ lines.push('可稍后重试 collect;已完成的会立即返回。');
212
+ return lines.join(NL);
213
+ }
214
+
24
215
  function roleOf(env, exec) {
25
216
  try { return env.roleOf(exec) || { role: 'plain' }; } catch { return { role: 'plain' }; }
26
217
  }
@@ -85,11 +276,23 @@ async function sendText(env, args, exec, info) {
85
276
  const sender = exec?.agent?.session?.header?.id ?? 'unknown';
86
277
  const marker = peerMarker(sender);
87
278
  const body = message.startsWith(marker) ? message : marker + NL + message;
279
+ const before = loadCollect();
280
+ assertNotReplyingInCollab(before, sender, target);
281
+ const since = Date.now();
88
282
  const receipt = await controller.prompt(
89
283
  { requestId: randomUUID(), sessionId: target, mode, content: [{ type: 'text', text: body }] },
90
284
  new AbortController().signal,
91
285
  );
92
- return 'accepted=' + String(receipt.accepted) + ' mode=' + mode + ' target=' + target + ' from=' + sender;
286
+ try {
287
+ const state = loadCollect();
288
+ setSince(state, sender, target, since);
289
+ saveCollect(state);
290
+ } catch (error) { void error; }
291
+ const head = 'accepted=' + String(receipt.accepted) + ' mode=' + mode + ' target=' + target + ' from=' + sender;
292
+ if (args.wait === true) {
293
+ return head + NL + await waitText({ session_id: target, since_ms: since, timeout_ms: args.timeout_ms });
294
+ }
295
+ return head;
93
296
  }
94
297
 
95
298
  async function peekText(args) {
@@ -132,7 +335,7 @@ async function newText(env, args, info) {
132
335
  // 会话创建仅属全局;杂项可以把会话建在 Misc 下(落到本任务体系内)。
133
336
  const wantsWorkspace = typeof args.workspace === 'string' && args.workspace.trim() !== '';
134
337
  if (wantsWorkspace && args.cwd !== undefined) {
135
- throw new Error('peer(action="new"):传 workspace 时不要再传 cwd —— session.create 只接受其中之一(cwd 会自动取该工作区的 path)');
338
+ throw new Error('peer(action="new"):传 workspace 时不得再传 cwd,session.create 只接受其中之一(cwd 会自动取该工作区的 path)');
136
339
  }
137
340
  const targetText = String(args.workspace || args.cwd || '').toLowerCase();
138
341
  const allowed = info.role === 'global' || (info.role === 'misc' && targetText.indexOf('misc') !== -1);
@@ -171,17 +374,22 @@ export function pmPeerTool(env) {
171
374
  return {
172
375
  name: PEER_NAME,
173
376
  description:
174
- '平级会话:action=list 一览、status 详情、send 投消息(耗对方 token)、peek 读近期会话历史(耗本会话 token)、rename 改标题、new 新建。list、peek、rename 与 new 仅全局会话可用。',
377
+ '平级会话:action=list 会话一览、status 单会话详情、send 投递消息(wait=true 时同轮等待回复)、wait 等待单个回复、collect 收集多个会话中先完成的最终回复、collab 注册或结束协作、peek 读取历史、rename 修改标题、new 新建会话。list、peek、rename 与 new 仅全局会话可用。',
175
378
  parameters: {
176
379
  type: 'object',
177
380
  additionalProperties: false,
178
381
  required: ['action'],
179
382
  properties: {
180
- action: { type: 'string', enum: ACTIONS, description: '要做的动作' },
181
- session_id: { type: 'string', description: 'status、send、peek、rename:目标会话 id' },
383
+ action: { type: 'string', enum: ACTIONS, description: '要执行的动作' },
384
+ session_id: { type: 'string', description: 'status、send、wait、peek、rename:目标会话 id' },
385
+ sessions: { type: 'array', items: { type: 'string' }, description: 'collect:要等待的会话 id 列表;留空则取已交办的目标' },
182
386
  message: { type: 'string', description: 'send:要投递的正文' },
183
- mode: { type: 'string', enum: ['queue', 'steer'], description: 'send:queue(默认)或 steer' },
184
- limit: { type: 'number', description: 'list:最多多少行(默认 40);peek:最近多少条(默认 8,上限 40)' },
387
+ mode: { type: 'string', enum: ['queue', 'steer'], description: 'send:queue(缺省)或 steer' },
388
+ wait: { type: 'boolean', description: 'send:投递后在同一轮等待回复(一对一对话用)' },
389
+ collabMode: { type: 'string', enum: ['start', 'end'], description: 'collab:start 注册协作(缺省)、end 结束并解除限制' },
390
+ timeout_ms: { type: 'number', description: 'wait、collect:单次等待上限(毫秒,最低 120000,按任务预估设置)' },
391
+ since_ms: { type: 'number', description: 'wait、collect:从哪一时刻之后查找回复(缺省取交办时刻)' },
392
+ limit: { type: 'number', description: 'list:最多多少行(缺省 40);peek:最近多少条(缺省 8,上限 40)' },
185
393
  include_subagents: { type: 'boolean', description: 'list:同时列出子会话' },
186
394
  title: { type: 'string', description: 'new、rename:显示标题' },
187
395
  workspace: { type: 'string', description: 'new:工作区 id、标题或路径(与 cwd 互斥)' },
@@ -197,6 +405,9 @@ export function pmPeerTool(env) {
197
405
  if (action === 'list') return { text: await listText(env, args) };
198
406
  if (action === 'status') return { text: await statusText(env, args) };
199
407
  if (action === 'send') return { text: await sendText(env, args, exec, info) };
408
+ if (action === 'wait') return { text: await waitText(args) };
409
+ if (action === 'collect') return { text: await collectText(args, exec) };
410
+ if (action === 'collab') return { text: collabText(args, exec) };
200
411
  if (action === 'peek') return { text: await peekText(args) };
201
412
  if (action === 'rename') return { text: await renameText(env, args) };
202
413
  return { text: await newText(env, args, info) };