@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
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
+ import fs from 'node:fs';
7
8
  import { NL, readUtf8, isDir, isFile } from './text.mjs';
8
9
  import { handoverPathFor, assertProjectName, assertTaskName } from './role.mjs';
9
10
  import { readHandover } from './handover-io.mjs';
@@ -13,11 +14,19 @@ import { watermark } from './facts.mjs';
13
14
  import { sendPrompt } from './prompt.mjs';
14
15
  import { newSessionIn } from './session-create.mjs';
15
16
  import { lineageKeyOf, loadOffice, registerHolder } from './office.mjs';
17
+ import { migrateIdentity } from './identity.mjs';
18
+ import { pathKey } from './pathkey.mjs';
16
19
 
17
20
  const FENCE = String.fromCharCode(96, 96, 96);
18
21
 
19
22
  function normPath(p) {
20
- return String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
23
+ return pathKey(String(p || ''));
24
+ }
25
+
26
+ /** 路径比较:先比写法;再取 realpath 比,兼容旧的 junction 路径与真实路径。 */
27
+ function sameLoosePath(a, b) {
28
+ if (normPath(a) === normPath(b)) return true;
29
+ try { return normPath(fs.realpathSync(String(a))) === normPath(fs.realpathSync(String(b))); } catch { return false; }
21
30
  }
22
31
 
23
32
  /** 在 ~/.dsh/storages/workspace.json 里按 id、标题、路径找 workspaceId。 */
@@ -25,12 +34,11 @@ function findWorkspaceId(key) {
25
34
  const file = path.join(os.homedir(), '.dsh', 'storages', 'workspace.json');
26
35
  const data = JSON.parse(readUtf8(file));
27
36
  const tables = (data && data.tables && data.tables.workspaces) || {};
28
- const want = normPath(key);
29
37
  for (const id of Object.keys(tables)) {
30
38
  const w = tables[id] || {};
31
39
  if (id === key) return id;
32
40
  if (w.title === key) return id;
33
- if (normPath(w.path) === want) return id;
41
+ if (sameLoosePath(w.path, key)) return id;
34
42
  }
35
43
  return null;
36
44
  }
@@ -56,7 +64,7 @@ export function resolveWorkspaceFor(env, exec, kind, project, task, opts) {
56
64
  const pickWorkspace = (key) => {
57
65
  const wid = findWorkspaceId(key);
58
66
  if (!wid) {
59
- throw new Error('找不到工作区:' + key + NL + '已注册的工作区:' + NL + choices + NL + '传 id、标题或路径都行;要先建工作区,用 ho_workspace(action="create", path="<目录>")。');
67
+ throw new Error('找不到工作区:' + key + NL + '已注册的工作区:' + NL + choices + NL + '可传 id、标题或路径;如需新建工作区,用 ho_workspace(action="create", path="<目录>")。');
60
68
  }
61
69
  return wid;
62
70
  };
@@ -68,8 +76,8 @@ export function resolveWorkspaceFor(env, exec, kind, project, task, opts) {
68
76
  let wid = null;
69
77
  try { wid = findWorkspaceId(o.cwd); } catch { wid = null; }
70
78
  if (!wid) {
71
- throw new Error('cwd 不在任何已注册工作区里:' + o.cwd + NL + '已注册的工作区:' + NL + choices
72
- + NL + '要么把 cwd 换成某个工作区的根,要么先注册它:ho_workspace(action="create", path="' + o.cwd + '")。');
79
+ throw new Error('cwd 不在任何已注册工作区中:' + o.cwd + NL + '已注册的工作区:' + NL + choices
80
+ + NL + '处置方式:把 cwd 改为某个工作区的根,或先注册该目录:ho_workspace(action="create", path="' + o.cwd + '")。');
73
81
  }
74
82
  return wid;
75
83
  }
@@ -82,16 +90,16 @@ export function resolveWorkspaceFor(env, exec, kind, project, task, opts) {
82
90
  return p.indexOf('/projects/' + want) !== -1 || p.indexOf('/_project_meta/' + want) !== -1;
83
91
  });
84
92
  if (!hit) {
85
- throw new Error('项目「' + project + '」还没有对应的工作区。' + NL + '已注册的工作区:' + NL + choices
86
- + NL + '做法一:ho_workspace(action="create", path="<该项目目录>", title="' + project + '") 后再建会话;'
87
- + NL + '做法二:把 workspace 传成上面某个工作区。');
93
+ throw new Error('项目「' + project + '」尚无对应工作区。' + NL + '已注册的工作区:' + NL + choices
94
+ + NL + '方案一:先 ho_workspace(action="create", path="<该项目目录>", title="' + project + '"),再建会话;'
95
+ + NL + '方案二:把 workspace 传成上列任一工作区。');
88
96
  }
89
97
  return String(hit.id);
90
98
  }
91
99
  if (wantGlobalWs) return pickWorkspace(ws);
92
- throw new Error('新建会话必须指明工作区:传 workspace(id、标题或路径),或 cwd(某个已注册工作区的根)。' + NL
100
+ throw new Error('新建会话须指明工作区:传 workspace(id、标题或路径),或 cwd(某个已注册工作区的根)。' + NL
93
101
  + '已注册的工作区:' + NL + choices + NL
94
- + '全局会话落在标题含「全局」的工作区;项目会话同时传 project 与该项目的工作区;杂项会话传该项目外的独立工作区。');
102
+ + '全局会话落在标题含「全局」的工作区;项目会话同时传 project 与该项目的工作区;杂项会话传该项目之外的独立工作区。');
95
103
  }
96
104
 
97
105
  /**
@@ -102,8 +110,8 @@ export function workspaceIdOfPath(env, p) {
102
110
  const want = normPath(p);
103
111
  if (!want || want === '-') return '';
104
112
  const hit = listWorkspaces(env).find((r) => {
105
- const rp = normPath(String(r.path || ''));
106
- return rp && rp !== '-' && rp === want;
113
+ const rp = String(r.path || '');
114
+ return rp && rp !== '-' && sameLoosePath(rp, p);
107
115
  });
108
116
  return hit ? String(hit.id || '') : '';
109
117
  }
@@ -173,17 +181,17 @@ function reuseBlocker(env, ws, kind, key) {
173
181
  if (!cur || !cur.sessionId) return '';
174
182
  let pct = null;
175
183
  try { pct = typeof env.selfPct === 'function' ? env.selfPct(cur.sessionId) : null; } catch { pct = null; }
176
- if (pct === null || pct === undefined || !(Number(pct) < watermark(env.cfg).warnValue)) return '';
184
+ if (pct === null || pct === undefined || !(Number(pct) < watermark(env.cfg).reuseValue)) return '';
177
185
  const label = kind === 'global' ? '全局类别' : kind === 'project' ? '项目「' + key + '」' : '杂项任务「' + key + '」';
178
- return '拒绝新建:' + label + '已有在任会话 ' + (cur.name || '(未命名)') + '(' + cur.sessionId + ')水位 '
179
- + Number(pct).toFixed(1) + '%,低于 ' + watermark(env.cfg).warn + ';建议用 peer_send 复用,确需另建请传 force=true。';
186
+ return '拒绝新建:' + label + '已有在任会话 ' + (cur.name || '(未命名)') + '(' + cur.sessionId + '),水位 '
187
+ + Number(pct).toFixed(1) + '%,低于 ' + watermark(env.cfg).handover + '。建议用 peer(action="send")复用;确需另建,请传 force=true。';
180
188
  }
181
189
 
182
190
  export async function sessionNew(env, args, exec) {
183
191
  const ws = env.resolveWs(exec);
184
192
  const controller = env.controller();
185
193
  // 换任(新建接班会话)先问用户。
186
- if (args.confirm !== true) throw new Error('新建会话(换任)需要用户确认:先向用户说明要建接班会话并得到同意,再传 confirm=true');
194
+ if (args.confirm !== true) throw new Error('新建会话(换任)须经当前用户确认:先向当前用户说明将建立接任会话并取得同意,再传 confirm=true');
187
195
  if (!controller) throw new Error('ctx.sessionController 不可用');
188
196
  // 名字要落成目录:先校验,别等会话建完、写角色表时才失败(那样会留下一个没有角色的会话)。
189
197
  if (args.project) assertProjectName(String(args.project));
@@ -205,6 +213,24 @@ export async function sessionNew(env, args, exec) {
205
213
  if (block) throw new Error(block);
206
214
  }
207
215
 
216
+ // 全局任务整体独立:先把任务库移入项目或杂项位置,沿用现有体系,不重建、不重新注册。
217
+ let migrated = null;
218
+ if (args.from_task) {
219
+ let caller = null;
220
+ try { caller = env.roleOf(exec); } catch { caller = null; }
221
+ if (!caller || caller.role !== 'global') throw new Error('from_task 只对全局会话开放');
222
+ const oldTask = String(args.from_task).trim();
223
+ assertTaskName(oldTask);
224
+ if (finalRole !== 'project' && finalRole !== 'misc') throw new Error('from_task 须同时给 role=project 或 role=misc');
225
+ migrated = migrateIdentity({
226
+ ws,
227
+ prev: { role: 'global-task', task: oldTask },
228
+ next: { role: finalRole, project, task },
229
+ mode: 'auto',
230
+ });
231
+ out.push('全局任务已独立:' + migrated.summary);
232
+ }
233
+
208
234
  // ---- 工作区解析:新建会话必须落到明确的工作区,不允许静默落进调用者所在的工作区。
209
235
  const workspaceId = resolveWorkspaceFor(env, exec, finalRole, project, task, {
210
236
  workspace: args.workspace,
@@ -238,10 +264,13 @@ export async function sessionNew(env, args, exec) {
238
264
  || (finalRole === 'misc' ? path.join(ws, '_Control_Center', 'rules', 'Misc_Task_Standard.md') : '');
239
265
  const text = args.message || [
240
266
  '先读交接:' + handover + '。',
241
- '读毕先向用户报告,不得自行开展作业:一、当前停在哪一步;二、交接列有哪些待办;三、建议先做哪一条。待用户指派后再动手。交接无待办时如实说明,不得编造任务。',
267
+ '读毕先向当前用户报告,不得自行开展作业。报告 3 项:一、当前停在哪一步;二、交接列有哪些待办;三、建议先做哪一条。',
268
+ '待当前用户指派后再开始作业。交接无待办时如实说明,不得编造任务。',
242
269
  '本会话 id:' + sessionId,
243
270
  (finalRole === 'project' ? '全局会话 id:' : '创建者会话 id:') + meId,
244
- '默认沉默:仅在三种时机各发一条 peer_send 给全局:水位不低于 ' + watermark(env.cfg).warn + ' 且已写交接、需用户决定、全局口径有误。任务完成不报全局,任务属本会话内部事务。会话间可直接 peer_send,不必经全局转述;不得每轮回执,不得发送「收到」「已完成」一类确认,全局以文件末态为准。',
271
+ '默认沉默:仅在下列三种时机向全局发送一条 peer(action="send"):水位不低于 ' + watermark(env.cfg).remind + ' 且已写交接;需当前用户决定;全局口径有误。',
272
+ '任务完成不报全局,任务属本会话内部事务。会话之间可直接 peer(action="send"),不必经全局转述。',
273
+ '不得每轮回执,不得发送「收到」「已完成」一类确认;全局以文件末态为准。',
245
274
  ].join(NL);
246
275
  const r = await sendPrompt(env.controller(), sessionId, text, 'queue');
247
276
  out.push('已投启动提示词 accepted=' + String(r && r.accepted) + '(' + text.length + ' 字)');
@@ -292,7 +321,7 @@ export function promptFor(env, opts) {
292
321
  const roleText = kind === 'global'
293
322
  ? '第 ' + ordinal + ' 任全局(管理会话)'
294
323
  : kind === 'project'
295
- ? '「' + project + '」项目的执行对话'
324
+ ? '「' + project + '」项目的执行会话'
296
325
  : kind === 'misc' ? '杂项任务会话' : '新机器接手会话';
297
326
  const scopeText = kind === 'project'
298
327
  ? '库根 Projects/' + project + ' 与 _Project_Meta/' + project
@@ -316,11 +345,11 @@ export function promptFor(env, opts) {
316
345
  const extraLines = [];
317
346
  if (kind === 'project' && project) {
318
347
  extraLines.push('库根:Projects/' + project + '/;元数据:_Project_Meta/' + project + '/;快照:_Temp/' + project + '/Snapshots/');
319
- extraLines.push('作业前读库根 SKILL.md;修改文档时另读 _Control_Center/rules/ 对应规范。');
320
- extraLines.push('进入预警区后写交接;请用户在聊天框发送 /allow-succeed 授权,再由本会话调用 ho_succeed 完成换任,不向全局请求。');
348
+ extraLines.push('作业前读库根 SKILL.md;修改文档时另读 _Control_Center/rules/ 下的对应规范。');
349
+ extraLines.push('进入预警区后写交接。换任须由当前用户在聊天框发送 /allow-succeed 授权,再由本会话调用 ho_succeed 完成,不向全局请求。');
321
350
  }
322
- if (kind === 'misc') extraLines.push('标准见 rules/Misc_Task_Standard.md;换任须用户授权,再由本会话调用 ho_succeed。');
323
- if (kind === 'migration') extraLines.push('迁移口径见 rules/Migration_Rules.md;通过顶层闸门后再开始作业。');
351
+ if (kind === 'misc') extraLines.push('标准见 rules/Misc_Task_Standard.md。换任须经当前用户授权,再由本会话调用 ho_succeed。');
352
+ if (kind === 'migration') extraLines.push('迁移口径见 rules/Migration_Rules.md。通过顶层闸门后再开始作业。');
324
353
  if (o.extra) extraLines.push(String(o.extra));
325
354
  return [body].concat(extraLines.length ? ['', ...extraLines] : []).join(NL);
326
355
  }
@@ -344,7 +373,7 @@ export function sessionPrompt(env, args, exec) {
344
373
  export async function sessionArchive(env, args, exec) {
345
374
  const reg = env.registry();
346
375
  if (!reg || typeof reg.archiveSession !== 'function') {
347
- throw new Error('workspaceRegistry.archiveSession 不可用(先跑 ho_diag 看服务可见性)');
376
+ throw new Error('workspaceRegistry.archiveSession 不可用(先跑 ho_status(view=diag) 看服务可见性)');
348
377
  }
349
378
  const sid = String(args.session_id || '').trim();
350
379
  if (!sid) throw new Error('必须给 session_id');
@@ -363,21 +392,21 @@ export async function sessionArchive(env, args, exec) {
363
392
  const archived = reg.archivedSessionIds;
364
393
  if (archived && typeof archived.length === 'number') out.push('归档集合现有 ' + archived.length + ' 个');
365
394
  } catch { /* ignore */ }
366
- out.push('提醒:归档会话会被 archived-session-gate 拦住、无法唤醒;要用它请先 action="unarchive"。');
395
+ out.push('提醒:归档会话会被 archived-session-gate 拦截,无法唤醒;如需使用,请先执行 action="unarchive"。');
367
396
  return out.join(NL);
368
397
  }
369
398
 
370
399
  export async function sessionUnarchive(env, args) {
371
400
  const reg = env.registry();
372
401
  if (!reg || typeof reg.unarchiveSession !== 'function') {
373
- throw new Error('workspaceRegistry.unarchiveSession 不可用(先跑 ho_diag 看服务可见性)');
402
+ throw new Error('workspaceRegistry.unarchiveSession 不可用(先跑 ho_status(view=diag) 看服务可见性)');
374
403
  }
375
404
  const sid = String(args.session_id || '').trim();
376
405
  if (!sid) throw new Error('必须给 session_id');
377
406
  try { await reg.unarchiveSession(sid); } catch (e) {
378
407
  throw new Error('解归档失败:' + String(e && e.message ? e.message : e));
379
408
  }
380
- return '已解归档 ' + sid + NL + '它回到原工作区位置,可以再次唤醒了。';
409
+ return '已解归档 ' + sid + NL + '该会话已回到原工作区位置,可再次唤醒。';
381
410
  }
382
411
 
383
412
  // ---------------------------------------------------------------- action: order
@@ -387,14 +416,14 @@ function orderHandoverText(env, sid, extra) {
387
416
  const hp = env.handoverForSid(sid);
388
417
  const info = env.infoOfSid(sid);
389
418
  const lines = [];
390
- lines.push("【全局指令:执行交接流程】将当前工作写成交接,写毕即停止操作,等待换任。本轮不再进行项目作业。");
419
+ lines.push("【全局指令:执行交接流程】将当前工作写成交接。写毕立即停止操作,等待换任;本轮不再进行项目作业。");
391
420
  lines.push("");
392
421
  lines.push("1) 目标文件:" + (hp && hp.path ? hp.path : "(角色未定,须先由全局设定角色)"));
393
- lines.push(" 直接调用 ho_handover(action=\"write\", text=...),不要传 path:路径已按你的角色(" + (info && info.role ? info.role : "unknown") + ")解析。");
394
- lines.push("2) 必写:① 当前进度(版本、产物、是否通过)② 环境与路径(版本号、安装位置、下载源)③ 下一步清单,按顺序列出,每条可直接执行 ④ 已完成待验收、要求未实现的部分 ⑤ 已知问题与已踩的错。");
422
+ lines.push(" 按固定流程重写:① ho_handover(action=\"read\") 读旧交接;② ho_handover(action=\"archive\") 归档;③ 读模板 _Control_Center/templates/Handover.md;④ ho_handover(action=\"new\", text=...) 新建空白写入。不传 path:路径已按本会话角色(" + (info && info.role ? info.role : "unknown") + ")解析。");
423
+ lines.push("2) 必写 5 项:① 当前进度(版本、产物、是否通过);② 环境与路径(版本号、安装位置、下载源);③ 下一步清单,按顺序列出,每条可直接执行;④ 已完成待验收与要求未实现的部分;⑤ 已知问题与已出现的错误。");
395
424
  lines.push("3) 机密只写位置,不写值:密码、token、密钥均写明所在文件。");
396
- lines.push("4) 写完只回一条 peer_send 给全局:`交接已写 <绝对路径>`,附一句状态,不复述全文。");
397
- lines.push("5) 不要压缩上下文,不要新建会话(新建会话属全局权限)。");
425
+ lines.push("4) 写毕只向全局回一条 peer(action=\"send\"):`交接已写 <绝对路径>`,附一句状态,不复述全文。");
426
+ lines.push("5) 不得压缩上下文,不得新建会话(新建会话属全局权限)。");
398
427
  if (extra) lines.push("6) 全局附加:" + extra);
399
428
  return lines.join(NL);
400
429
  }
@@ -405,69 +434,69 @@ export async function sessionOrder(env, args) {
405
434
  if (!sid) throw new Error("order 需要 session_id");
406
435
  if (order !== "handover" && order !== "compact") throw new Error("order 只能是 handover 或 compact");
407
436
  // 给别的会话下「交接、压缩」指令,也要先问用户。
408
- if (args.confirm !== true) throw new Error("下交接、压缩指令需要用户确认:先说明会唤醒目标会话并让它交接或压缩,得到同意后传 confirm=true");
437
+ if (args.confirm !== true) throw new Error("下交接、压缩指令需要当前用户确认:先说明将唤醒目标会话并要求其交接或压缩,取得同意后传 confirm=true");
409
438
  const extra = typeof args.extra === "string" ? args.extra.trim() : "";
410
439
  const hp = env.handoverForSid(sid);
411
440
  const info = env.infoOfSid(sid);
412
441
  const out = [];
413
442
  out.push("目标 " + sid + " · 角色 " + ((info && info.role) || "unknown") + " · 交接 " + ((hp && hp.path) || "(未解析)"));
414
443
  if (!hp || !hp.path) {
415
- out.push("没有交接路径:先给它设置角色(ho_sessions(action=\"role\")),否则它会退回全局交接。");
444
+ out.push("没有交接路径:先为目标会话设定角色(ho_sessions(action=\"role\")),否则该会话会退回全局交接。");
416
445
  return out.join(NL);
417
446
  }
418
447
  if (order === "handover") {
419
448
  const text = orderHandoverText(env, sid, extra);
420
449
  const r = await sendPrompt(env.controller(), sid, text, "queue");
421
- out.push("已投交接指令 accepted=" + String(r && r.accepted) + "(会唤醒它一轮)");
422
- out.push("等它回一条 `交接已写 <路径>` 之后,再用 ho_sessions(action=\"new\") 建接任会话。");
450
+ out.push("已投递交接指令 accepted=" + String(r && r.accepted) + "(将唤醒目标会话一轮)");
451
+ out.push("收到目标会话回的 `交接已写 <路径>` 后,再用 ho_sessions(action=\"new\") 建立接任会话。");
423
452
  return out.join(NL);
424
453
  }
425
454
  // order === compact
426
455
  const agent = typeof env.agentOfSid === "function" ? env.agentOfSid(sid) : null;
427
456
  if (!agent) {
428
- out.push("拒绝排压缩:目标会话不在内存(已休眠)。先让它开一轮(ho_sessions(action=\"order\", order=\"handover\"))再压。");
457
+ out.push("拒绝排压缩:目标会话不在内存(已休眠)。先让目标会话开一轮(ho_sessions(action=\"order\", order=\"handover\")),再排压缩。");
429
458
  return out.join(NL);
430
459
  }
431
460
  const st = typeof env.compactionStateOfSid === "function" ? env.compactionStateOfSid(sid) : null;
432
461
  if (st && st.lock === "open") {
433
- out.push("拒绝排压缩:它的日志里有未闭合的压缩锁(compaction/start 无配对 end)。先等它结束后复查。");
462
+ out.push("拒绝排压缩:目标会话日志中有未闭合的压缩锁(compaction/start 无配对 end)。待其结束后再复查。");
434
463
  return out.join(NL);
435
464
  }
436
465
  const cur = readHandover(hp.path);
437
466
  if (cur.text === null || String(cur.text).trim() === "") {
438
- out.push("拒绝排压缩:磁盘上没有合格交接 " + hp.path + "。压缩会丢历史 —— 先 order=handover。");
467
+ out.push("拒绝排压缩:磁盘上没有合格交接 " + hp.path + "。压缩会丢失历史,先执行 order=handover。");
439
468
  return out.join(NL);
440
469
  }
441
470
  const used = st && typeof st.used === "number" ? st.used : 0;
442
471
  const max = (env.cfg && env.cfg.maxCompactions) || 3;
443
472
  const n = used + 1;
444
473
  if (n > max) {
445
- out.push("已压满 " + used + "/" + max + ":不再压缩。应执行 ho_succeed 自行换任(先请用户发送 /allow-succeed 授权);全局会话自身也可用 ho_sessions(action=\"new\")。新会话读毕交接会先向你报告,等待指派。");
474
+ out.push("已压满 " + used + "/" + max + ":不再压缩。应执行 ho_succeed 自行换任(先请当前用户发送 /allow-succeed 授权);全局会话自身也可用 ho_sessions(action=\"new\")。新会话读毕交接后会先向你报告,等待指派。");
446
475
  return out.join(NL);
447
476
  }
448
477
  const recentSec = st && st.lastSummaryMs ? (Date.now() - st.lastSummaryMs) / 1000 : null;
449
478
  if (recentSec !== null && recentSec < 120 && args.force !== true) {
450
- out.push("拒绝排压缩:" + Math.round(recentSec) + " 秒前它刚压过一次。要再压加 force=true。");
479
+ out.push("拒绝排压缩:" + Math.round(recentSec) + " 秒前目标会话刚压缩过一次。如确需再次压缩,加 force=true。");
451
480
  return out.join(NL);
452
481
  }
453
482
  // 严格闸门:代排也要用户点过授权,AI 不能自行批准。
454
483
  const cs = checkConsent(sid);
455
484
  if (!cs.ok) {
456
485
  out.push("拒绝排压缩:" + cs.reason + "。");
457
- out.push("请用户到设置页的 dsh-handover 中,点「压缩授权」的「允许该会话压缩一次」(5 分钟内有效),再重下这条指令。");
486
+ out.push("请当前用户在设置页的 dsh-handover 中,点击「授权」内的「授权压缩」(5 分钟内有效),再重新下达本指令。");
458
487
  return out.join(NL);
459
488
  }
460
489
  const resume = args.resume === "continue" ? "continue" : "wait";
461
490
  env.schedule(sid, n, { resume });
462
- out.push("用户已授权(" + (cs.atText || '') + " 点击,剩 " + Math.round((cs.leftMs || 0) / 1000) + " 秒)。");
463
- out.push("已登记压缩(它自己第 " + n + "/" + max + " 次):等它空闲后执行。");
491
+ out.push("当前用户已授权(" + (cs.atText || '') + " 点击,剩余 " + Math.round((cs.leftMs || 0) / 1000) + " 秒)。");
492
+ out.push("已登记压缩(目标会话自身第 " + n + "/" + max + " 次),待目标会话空闲后执行。");
464
493
  if (resume === "wait") {
465
- out.push("去向:wait(缺省)—— 压缩后不再唤醒它,等用户派新任务,或由新会话接手。");
494
+ out.push("去向:wait(缺省)—— 压缩后不再唤醒目标会话,等待当前用户派新任务,或由新会话接手。");
466
495
  } else {
467
- out.push("去向:continue —— 压缩后它会自动收到**它自己的**续跑消息(带它自己的交接路径)。");
496
+ out.push("去向:continue —— 压缩后目标会话会自动收到其自身的续跑消息(含其自身交接路径)。");
468
497
  }
469
- out.push("压缩走的是插件调度器(compactNow 只允许空闲时执行),不是给它发消息让它自己压。");
470
- out.push("若它正在跑,调度器会等它空闲(上限 " + Math.round(((env.cfg && env.cfg.waitIdleMs) || 180000) / 1000) + " 秒)。");
498
+ out.push("压缩由插件调度器执行(compactNow 仅在空闲时可用),不向目标会话发消息令其自行压缩。");
499
+ out.push("若目标会话正在运行,调度器会等待其空闲(上限 " + Math.round(((env.cfg && env.cfg.waitIdleMs) || 180000) / 1000) + " 秒)。");
471
500
  return out.join(NL);
472
501
  }
473
502
 
@@ -482,7 +511,7 @@ export async function sessionPlace(env, args) {
482
511
  if (!sid) throw new Error("place 需要 session_id");
483
512
  if (!key) throw new Error("place 需要 workspace(id、标题、路径都行)");
484
513
  const reg = env.registry();
485
- if (!reg || typeof reg.get !== "function") throw new Error("workspaceRegistry 不可用(先跑 ho_diag)");
514
+ if (!reg || typeof reg.get !== "function") throw new Error("workspaceRegistry 不可用(先跑 ho_status(view=diag))");
486
515
  const wid = findWorkspaceId(key);
487
516
  if (!wid) throw new Error("找不到工作区:" + key);
488
517
  const w = reg.get(wid);
@@ -490,6 +519,6 @@ export async function sessionPlace(env, args) {
490
519
  await w.attachSession(sid);
491
520
  const out = [];
492
521
  out.push("已把 " + sid + " 移入工作区「" + (w.title || key) + "」" + (w.path ? "(" + w.path + ")" : ""));
493
- out.push("若报 cwd 不匹配:它的 cwd 与该工作区根不一致 —— 用 ho_sessions(action=list) 看 cwd。");
522
+ out.push("若提示 cwd 不匹配:目标会话的 cwd 与该工作区根不一致,用 ho_sessions(action=list) 查看 cwd。");
494
523
  return out.join(NL);
495
524
  }
@@ -1,4 +1,5 @@
1
1
  /** 会话清点:只读投影缓存,不激活任何会话、不消耗 token。 */
2
+ import fs from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { readJsonDir, pick, isSafeName } from './text.mjs';
@@ -26,6 +27,21 @@ export function isBusy(data) {
26
27
  return pick(data, 'record', 'rows', 'sessionStats', 'val', 'openStep') !== null;
27
28
  }
28
29
 
30
+ /**
31
+ * 会话最近活动时刻(epoch 毫秒):与 DSH 侧栏口径一致,取会话摘要里的最后提示时刻。
32
+ * 投影缓存文件的 mtime 会在批量迁移或重建缓存时被统一改写,不能当作活动时间。
33
+ * 读不到时退回文件 mtime。
34
+ */
35
+ function activityMsOf(data, fallback) {
36
+ const lastPrompt = pick(data, 'record', 'rows', 'sessionListMetadata', 'val', 'lastPromptAt');
37
+ const lastMessage = pick(data, 'record', 'rows', 'timeContext', 'val', 'lastMessageTime');
38
+ let max = Number(fallback) || 0;
39
+ for (const v of [lastPrompt, lastMessage]) {
40
+ if (typeof v === 'number' && v > max) max = v;
41
+ }
42
+ return max;
43
+ }
44
+
29
45
  function cwdOf(data) {
30
46
  return pick(data, 'record', 'identity', 'cwd');
31
47
  }
@@ -37,7 +53,7 @@ function isPlausible(name) {
37
53
 
38
54
  /** 返回 { project, real }:优先看 cwd 里的 Projects/<名>/,退回首条提示里的 Projects/<名>/ 或 「<名>」项目。 */
39
55
  export function projectOf(data) {
40
- // 先认路径:首条提示是自然语言的会话解析不出项目名,会被整行过滤,在任判定就可能落到前任身上。
56
+ // 先认路径:首条提示是自然语言的会话解析不出项目名,会被整行过滤,在任判定就可能落到已交出的旧会话身上。
41
57
  // cwd 由宿主写入,最可靠。
42
58
  const raw = String(cwdOf(data) || '').replace(/\\/g, '/');
43
59
  const ci = raw.toLowerCase().indexOf('/projects/');
@@ -86,10 +102,32 @@ export function readSelfPressure(dir, meId) {
86
102
  return { used: null, win: null };
87
103
  }
88
104
 
105
+ /** 只读一个会话的投影缓存文件,返回水位百分比;读不到返回 null。供逐回合提醒使用。 */
106
+ export function readSessionPressurePct(sessionId) {
107
+ const sid = String(sessionId || '');
108
+ if (!sid) return null;
109
+ const dir = projCacheDir();
110
+ const bare = sid.indexOf('session-') === 0 ? sid.slice(8) : sid;
111
+ const names = [];
112
+ for (const n of [sid, bare]) {
113
+ if (n && names.indexOf(n) === -1) names.push(n);
114
+ }
115
+ for (const stem of names) {
116
+ try {
117
+ const data = JSON.parse(fs.readFileSync(path.join(dir, stem + '.json'), 'utf8'));
118
+ const used = pick(data, 'record', 'rows', 'contextPressure', 'val', 'pressureTokens');
119
+ const win = pick(data, 'record', 'rows', 'contextPressure', 'val', 'contextWindow');
120
+ if (!used || !win) return null;
121
+ return (used / win) * 100;
122
+ } catch { /* 试下一个文件名 */ }
123
+ }
124
+ return null;
125
+ }
126
+
89
127
  /** 扫描:返回 { rows, cutoff, hot, unknown, notify, skip }。 */
90
128
  export function scanSessions(opts) {
91
129
  const dir = opts.dir || projCacheDir();
92
- const warn = opts.warn === undefined ? 32 : opts.warn;
130
+ const warn = opts.warn === undefined ? 35 : opts.warn;
93
131
  const silentHours = opts.silentHours === undefined ? 12 : opts.silentHours;
94
132
  const nowMs = opts.nowMs || Date.now();
95
133
  const meId = opts.meId || null;
@@ -112,7 +150,7 @@ export function scanSessions(opts) {
112
150
  project: pj.project,
113
151
  pct: pressurePct(f.data),
114
152
  state: isBusy(f.data) ? 'running' : 'idle',
115
- mtime: f.mtimeMs,
153
+ mtime: activityMsOf(f.data, f.mtimeMs),
116
154
  });
117
155
  }
118
156
  rows.sort((a, b) => {
@@ -6,7 +6,14 @@ import { spawnSync } from 'node:child_process';
6
6
  const CANDIDATES = [
7
7
  'C:\\Program Files\\7-Zip\\7z.exe',
8
8
  'C:\\Program Files (x86)\\7-Zip\\7z.exe',
9
+ '/usr/bin/7z',
10
+ '/usr/local/bin/7z',
11
+ '/opt/homebrew/bin/7z',
12
+ '/usr/bin/7za',
13
+ '/usr/local/bin/7za',
14
+ '/opt/homebrew/bin/7zz',
9
15
  ];
16
+ const BARE = new Set(['7z', '7zz', '7za']);
10
17
 
11
18
  function run7z(bin, args, opts) {
12
19
  const res = spawnSync(bin, args, { encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024, ...(opts || {}) });
@@ -38,8 +45,10 @@ export function sevenZipBin(configured) {
38
45
  if (configured) cands.push(configured);
39
46
  for (const c of CANDIDATES) cands.push(c);
40
47
  cands.push('7z');
48
+ cands.push('7zz');
49
+ cands.push('7za');
41
50
  for (const c of cands) {
42
- if (c !== '7z' && !fs.existsSync(c)) continue;
51
+ if (!BARE.has(c) && !fs.existsSync(c)) continue;
43
52
  const r = run7z(c, ['i']);
44
53
  if (r.code === 0) { probed = c; return c; }
45
54
  }