@mingmaple/dsh-handover 0.9.13 → 0.9.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +46 -19
  2. package/docs/migrate.md +6 -6
  3. package/docs/releases/0.9.14.md +18 -0
  4. package/docs/releases/0.9.22.md +34 -0
  5. package/docs/troubleshoot.md +34 -10
  6. package/package.json +71 -71
  7. package/scripts/blank-dsh.mjs +29 -23
  8. package/scripts/blank-smoke.mjs +261 -78
  9. package/scripts/e2e-migration.mjs +2 -2
  10. package/scripts/office.mjs +3 -3
  11. package/src/client.js +159 -120
  12. package/src/index.mjs +65 -58
  13. package/src/lib/api.mjs +9 -7
  14. package/src/lib/brief.mjs +2 -2
  15. package/src/lib/config.mjs +3 -3
  16. package/src/lib/consent.mjs +54 -1
  17. package/src/lib/docmap.mjs +143 -0
  18. package/src/lib/gates.mjs +25 -1
  19. package/src/lib/guide.mjs +35 -44
  20. package/src/lib/lint.mjs +3 -2
  21. package/src/lib/manual.mjs +48 -9
  22. package/src/lib/mover.mjs +10 -10
  23. package/src/lib/names.mjs +14 -19
  24. package/src/lib/office.mjs +22 -14
  25. package/src/lib/pack-ops.mjs +2 -2
  26. package/src/lib/peer.mjs +4 -4
  27. package/src/lib/role.mjs +8 -5
  28. package/src/lib/scaffold.mjs +42 -20
  29. package/src/lib/schedule.mjs +1 -2
  30. package/src/lib/session-create.mjs +1 -1
  31. package/src/lib/session-ops.mjs +49 -20
  32. package/src/lib/sessions.mjs +1 -1
  33. package/src/lib/status.mjs +205 -45
  34. package/src/lib/succession.mjs +27 -11
  35. package/src/lib/zstd-log.mjs +1 -1
  36. package/src/selftest.mjs +6 -6
  37. package/src/templates/Prompt_Templates.md +13 -13
  38. package/src/templates/Runtime_Manual.md +56 -30
  39. package/src/templates/rules/Handover_Rules.md +2 -1
  40. package/src/templates/rules/Migration_Rules.md +1 -1
  41. package/src/templates/standards/README.md +25 -0
  42. package/src/tools/ho_backup.mjs +135 -0
  43. package/src/tools/ho_checkpoint.mjs +12 -11
  44. package/src/tools/ho_docmap.mjs +153 -0
  45. package/src/tools/ho_gate.mjs +2 -1
  46. package/src/tools/ho_office.mjs +1 -1
  47. package/src/tools/ho_sessions.mjs +3 -4
  48. package/src/tools/ho_status.mjs +246 -0
  49. package/src/tools/ho_succeed.mjs +1 -1
  50. package/src/tools/ho_workspace.mjs +86 -14
  51. package/src/tools/peer.mjs +205 -0
  52. package/src/tools/ho_brief.mjs +0 -57
  53. package/src/tools/ho_diag.mjs +0 -87
  54. package/src/tools/ho_drift.mjs +0 -41
  55. package/src/tools/ho_init.mjs +0 -82
  56. package/src/tools/ho_pack.mjs +0 -38
  57. package/src/tools/ho_phase.mjs +0 -37
  58. package/src/tools/ho_snapshot.mjs +0 -104
  59. package/src/tools/ho_tokens.mjs +0 -57
  60. package/src/tools/peer_list.mjs +0 -37
  61. package/src/tools/peer_new.mjs +0 -75
  62. package/src/tools/peer_peek.mjs +0 -41
  63. package/src/tools/peer_rename.mjs +0 -31
  64. package/src/tools/peer_send.mjs +0 -52
  65. package/src/tools/peer_status.mjs +0 -46
@@ -20,6 +20,7 @@ const DEFAULT_ROOT_NAME = '_Workspace';
20
20
  const DIRS = [
21
21
  '_Control_Center/records',
22
22
  '_Control_Center/rules',
23
+ '_Control_Center/standards',
23
24
  '_Control_Center/templates',
24
25
  '_Control_Center/tools',
25
26
  '_Control_Center/user',
@@ -35,6 +36,8 @@ const FILES = [
35
36
  ['_Control_Center/records/Global_Changelog.md', 'Changelog.skeleton.md'],
36
37
  ['_Control_Center/rules/Workspace_Charter.md', 'Charter.skeleton.md'],
37
38
  ['_Control_Center/templates/Prompt_Templates.md', 'Prompt_Templates.md'],
39
+ // 通用规范:一条规范一个子目录,各角色会话都可读,只有全局写。这里先铺说明,规范正文由会话按需建立。
40
+ ['_Control_Center/standards/README.md', 'standards/README.md'],
38
41
  // 规则类:工作区没有这份时,ho_charter 会退回同名内置版本;这里先铺一份可改的默认口径。
39
42
  ['_Control_Center/rules/Handover_Rules.md', 'rules/Handover_Rules.md'],
40
43
  ['_Control_Center/rules/Migration_Rules.md', 'rules/Migration_Rules.md'],
@@ -62,6 +65,41 @@ function normalizeParent(input) {
62
65
  return { parent: abs, createdParent: !st };
63
66
  }
64
67
 
68
+ /**
69
+ * 在既有的全局根上补齐缺失的目录与说明文件:只新建,不覆盖,不删任何东西。
70
+ * 用途:插件新增了目录(例如通用规范库)后,已部署的工作区靠它一次补齐。
71
+ */
72
+ export function ensureScaffold(root) {
73
+ const abs = String(root || '');
74
+ let st = null;
75
+ try { st = fs.statSync(abs); } catch { st = null; }
76
+ if (!st || !st.isDirectory()) return { ok: false, error: '全局根不存在或不是目录:' + (abs || '(空)') };
77
+
78
+ const created = [];
79
+ const written = [];
80
+ const skipped = [];
81
+ for (const d of DIRS) {
82
+ const p = path.join(abs, d.split('/').join(path.sep));
83
+ if (!fs.existsSync(p)) created.push(d);
84
+ try {
85
+ fs.mkdirSync(p, { recursive: true });
86
+ } catch (e) {
87
+ return { ok: false, error: '建目录失败:' + d + '(' + String(e && e.message ? e.message : e) + ')', created, written, skipped };
88
+ }
89
+ }
90
+ for (const [rel, tpl] of FILES) {
91
+ const dst = path.join(abs, rel.split('/').join(path.sep));
92
+ if (fs.existsSync(dst)) { skipped.push(rel); continue; }
93
+ try {
94
+ fs.writeFileSync(dst, template(tpl), 'utf8');
95
+ written.push(rel);
96
+ } catch (e) {
97
+ return { ok: false, error: '写文件失败(' + rel + '):' + (e && e.message ? e.message : e), created, written, skipped };
98
+ }
99
+ }
100
+ return { ok: true, created, written, skipped };
101
+ }
102
+
65
103
  /**
66
104
  * 建全局根。
67
105
  * @param parentInput 用户在设置页粘贴的路径
@@ -89,30 +127,14 @@ export function createGlobalWorkspace(parentInput, opts) {
89
127
  existed = true;
90
128
  }
91
129
 
92
- const created = [];
93
130
  try {
94
131
  fs.mkdirSync(root, { recursive: true });
95
- for (const d of DIRS) {
96
- const p = path.join(root, d.split('/').join(path.sep));
97
- fs.mkdirSync(p, { recursive: true });
98
- created.push(d);
99
- }
100
132
  } catch (e) {
101
133
  return { ok: false, root, error: '建目录失败:' + (e && e.message ? e.message : e) };
102
134
  }
103
135
 
104
- const written = [];
105
- const skipped = [];
106
- for (const [rel, tpl] of FILES) {
107
- const dst = path.join(root, rel.split('/').join(path.sep));
108
- if (fs.existsSync(dst)) { skipped.push(rel); continue; }
109
- try {
110
- fs.writeFileSync(dst, template(tpl), 'utf8');
111
- written.push(rel);
112
- } catch (e) {
113
- return { ok: false, root, created, written, error: '写文件失败(' + rel + '):' + (e && e.message ? e.message : e) };
114
- }
115
- }
116
-
117
- return { ok: true, root, existed, created, written, skipped };
136
+ // 铺目录与说明文件走同一套清单:init 与 scaffold 两处不得各写一份。
137
+ const sc = ensureScaffold(root);
138
+ if (!sc.ok) return { ok: false, root, created: sc.created, written: sc.written, error: sc.error };
139
+ return { ok: true, root, existed, created: sc.created, written: sc.written, skipped: sc.skipped };
118
140
  }
@@ -28,7 +28,6 @@ export function createScheduler(deps) {
28
28
  const timers = new Map();
29
29
  let disposed = false;
30
30
 
31
- // 用全局定时器:cordis 的 ctx.setTimeout 绑定调用方 fiber,工具调用返回后可能被一并释放
32
31
  // 用全局定时器:cordis 的 ctx.setTimeout 绑定调用方 fiber,工具调用返回后可能被一并释放。
33
32
  function later(fn, ms) {
34
33
  return setTimeout(fn, ms);
@@ -182,7 +181,7 @@ export function createScheduler(deps) {
182
181
  pending.delete(sessionId);
183
182
  note("checkpoint failed " + sessionId + ": " + msg);
184
183
  try {
185
- promptSelf(sessionId, "【dsh-handover】压缩未执行:" + msg + "。历史没有被压缩,交接已就绪;请把这条消息原样告诉用户,不要自行重试压缩。").catch(() => {});
184
+ promptSelf(sessionId, "【dsh-handover】压缩未执行:" + msg + "。历史没有被压缩,交接已就绪;请把这条消息原样告诉当前用户,不要自行重试压缩。").catch(() => {});
186
185
  } catch { /* ignore */ }
187
186
  }
188
187
 
@@ -27,7 +27,7 @@ export async function newSessionIn(env, opts) {
27
27
  if (!sessionId) return { error: 'create 没有返回 sessionId' };
28
28
  const title = String(o.title || '');
29
29
  if (title) {
30
- try { if (typeof controller.rename === 'function') await controller.rename({ sessionId, title }); } catch { /* 改名失败可事后 peer_rename */ }
30
+ try { if (typeof controller.rename === 'function') await controller.rename({ sessionId, title }); } catch { /* 改名失败可事后用 peer(action="rename") */ }
31
31
  }
32
32
  const role = String(o.role || '');
33
33
  if (role && role !== 'plain' && typeof env.writeRole === 'function') {
@@ -94,6 +94,28 @@ export function resolveWorkspaceFor(env, exec, kind, project, task, opts) {
94
94
  + '全局会话落在标题含「全局」的工作区;项目会话同时传 project 与该项目的工作区;杂项会话传该项目外的独立工作区。');
95
95
  }
96
96
 
97
+ /**
98
+ * 某个目录是不是已注册工作区的根:是则返回 workspaceId,否则返回空串(不抛错)。
99
+ * 换任时用来让接任者落回本会话所在的那个工作区,而不是工作区根。
100
+ */
101
+ export function workspaceIdOfPath(env, p) {
102
+ const want = normPath(p);
103
+ if (!want || want === '-') return '';
104
+ const hit = listWorkspaces(env).find((r) => {
105
+ const rp = normPath(String(r.path || ''));
106
+ return rp && rp !== '-' && rp === want;
107
+ });
108
+ return hit ? String(hit.id || '') : '';
109
+ }
110
+
111
+ /** workspaceId 对应的根路径;查不到返回空串(登记表的工作区一栏用它写成实际落点)。 */
112
+ export function workspacePathOf(env, id) {
113
+ const key = String(id || '');
114
+ if (!key) return '';
115
+ const hit = listWorkspaces(env).find((r) => String(r.id || '') === key);
116
+ return hit && hit.path ? String(hit.path) : '';
117
+ }
118
+
97
119
  /** 已注册工作区清单(插件服务优先,退化读 workspace.json):新建会话时用来列出可选项。 */
98
120
  function listWorkspaces(env) {
99
121
  try {
@@ -154,14 +176,14 @@ function reuseBlocker(env, ws, kind, key) {
154
176
  if (pct === null || pct === undefined || !(Number(pct) < watermark(env.cfg).warnValue)) return '';
155
177
  const label = kind === 'global' ? '全局类别' : kind === 'project' ? '项目「' + key + '」' : '杂项任务「' + key + '」';
156
178
  return '拒绝新建:' + label + '已有在任会话 ' + (cur.name || '(未命名)') + '(' + cur.sessionId + ')水位 '
157
- + Number(pct).toFixed(1) + '%,低于 ' + watermark(env.cfg).warn + ';建议用 peer_send 复用,确需另建请传 force=true。';
179
+ + Number(pct).toFixed(1) + '%,低于 ' + watermark(env.cfg).warn + ';建议用 peer(action="send")复用,确需另建请传 force=true。';
158
180
  }
159
181
 
160
182
  export async function sessionNew(env, args, exec) {
161
183
  const ws = env.resolveWs(exec);
162
184
  const controller = env.controller();
163
185
  // 换任(新建接班会话)先问用户。
164
- if (args.confirm !== true) throw new Error('新建会话(换任)需要用户确认:先向用户说明要建接班会话并得到同意,再传 confirm=true');
186
+ if (args.confirm !== true) throw new Error('新建会话(换任)需要当前用户确认:先向当前用户说明要建接班会话并得到同意,再传 confirm=true');
165
187
  if (!controller) throw new Error('ctx.sessionController 不可用');
166
188
  // 名字要落成目录:先校验,别等会话建完、写角色表时才失败(那样会留下一个没有角色的会话)。
167
189
  if (args.project) assertProjectName(String(args.project));
@@ -197,22 +219,29 @@ export async function sessionNew(env, args, exec) {
197
219
  // 建会话即登记在任:全局手工建/换会话与自服务换任共用一张登记表,不让注册表漏记或与实况分叉。
198
220
  try {
199
221
  if (officeKind) {
200
- const reg = registerHolder(ws, { kind: officeKind, project, task, name: title, sessionId, workspace: ws });
222
+ // 工作区一栏写**实际落点**(用户指定的那个工作区),不是调用者自己所在的工作区根。
223
+ const reg = registerHolder(ws, {
224
+ kind: officeKind, project, task, name: title, sessionId,
225
+ workspace: workspacePathOf(env, workspaceId) || ws,
226
+ });
201
227
  out.push('在任登记:' + reg.key + ' → ' + title + (reg.previous ? '(原 ' + reg.previous.name + ' 进历史)' : ''));
202
228
  }
203
229
  } catch (e) {
204
230
  out.push('在任登记失败:' + String(e && e.message ? e.message : e) + '(不影响会话本身)');
205
231
  }
206
232
  const meId = env.selfId(exec) || '(未知)';
207
- // 交接路径按**接任者自身的角色**取用:项目换任读取项目交接,不再退回全局交接。
208
- const hp = handoverPathFor(ws, finalRole === 'project' && project ? { role: 'project', project } : { role: 'global' });
209
- const handover = env.cfg.handoverPath || (hp.path || '');
233
+ // 交接路径按**新会话自身的角色**取用:项目读项目交接、杂项读任务交接,都不退回全局交接。
234
+ // cfg.handoverPath 是「强制全局交接路径」的演练开关,只对全局角色生效。
235
+ const hp = handoverPathFor(ws, finalRole === 'project' && project ? { role: 'project', project }
236
+ : finalRole === 'misc' && task ? { role: 'misc', task } : { role: 'global' });
237
+ const handover = (finalRole === 'global' ? env.cfg.handoverPath : '') || hp.path
238
+ || (finalRole === 'misc' ? path.join(ws, '_Control_Center', 'rules', 'Misc_Task_Standard.md') : '');
210
239
  const text = args.message || [
211
240
  '先读交接:' + handover + '。',
212
- '读毕先向用户报告,不得自行开展作业:一、当前停在哪一步;二、交接列有哪些待办;三、建议先做哪一条。待用户指派后再动手。交接无待办时如实说明,不得编造任务。',
241
+ '读毕先向当前用户报告,不得自行开展作业:一、当前停在哪一步;二、交接列有哪些待办;三、建议先做哪一条。待当前用户指派后再动手。交接无待办时如实说明,不得编造任务。',
213
242
  '本会话 id:' + sessionId,
214
243
  (finalRole === 'project' ? '全局会话 id:' : '创建者会话 id:') + meId,
215
- '默认沉默:仅在三种时机各发一条 peer_send 给全局:水位不低于 ' + watermark(env.cfg).warn + ' 且已写交接、需用户决定、全局口径有误。任务完成不报全局,任务属本会话内部事务。会话间可直接 peer_send,不必经全局转述;不得每轮回执,不得发送「收到」「已完成」一类确认,全局以文件末态为准。',
244
+ '默认沉默:仅在三种时机各发一条 peer(action="send")给全局:水位不低于 ' + watermark(env.cfg).warn + ' 且已写交接、需当前用户决定、全局口径有误。任务完成不报全局,任务属本会话内部事务。会话间可直接 peer(action="send"),不必经全局转述;不得每轮回执,不得发送「收到」「已完成」一类确认,全局以文件末态为准。',
216
245
  ].join(NL);
217
246
  const r = await sendPrompt(env.controller(), sessionId, text, 'queue');
218
247
  out.push('已投启动提示词 accepted=' + String(r && r.accepted) + '(' + text.length + ' 字)');
@@ -263,7 +292,7 @@ export function promptFor(env, opts) {
263
292
  const roleText = kind === 'global'
264
293
  ? '第 ' + ordinal + ' 任全局(管理会话)'
265
294
  : kind === 'project'
266
- ? '「' + project + '」项目的执行对话'
295
+ ? '「' + project + '」项目的执行会话'
267
296
  : kind === 'misc' ? '杂项任务会话' : '新机器接手会话';
268
297
  const scopeText = kind === 'project'
269
298
  ? '库根 Projects/' + project + ' 与 _Project_Meta/' + project
@@ -288,9 +317,9 @@ export function promptFor(env, opts) {
288
317
  if (kind === 'project' && project) {
289
318
  extraLines.push('库根:Projects/' + project + '/;元数据:_Project_Meta/' + project + '/;快照:_Temp/' + project + '/Snapshots/');
290
319
  extraLines.push('作业前读库根 SKILL.md;修改文档时另读 _Control_Center/rules/ 对应规范。');
291
- extraLines.push('进入预警区后写交接;请用户在聊天框发送 /allow-succeed 授权,再由本会话调用 ho_succeed 完成换任,不向全局请求。');
320
+ extraLines.push('进入预警区后写交接;请当前用户在聊天框发送 /allow-succeed 授权,再由本会话调用 ho_succeed 完成换任,不向全局请求。');
292
321
  }
293
- if (kind === 'misc') extraLines.push('标准见 rules/Misc_Task_Standard.md;换任须用户授权,再由本会话调用 ho_succeed。');
322
+ if (kind === 'misc') extraLines.push('标准见 rules/Misc_Task_Standard.md;换任须当前用户授权,再由本会话调用 ho_succeed。');
294
323
  if (kind === 'migration') extraLines.push('迁移口径见 rules/Migration_Rules.md;通过顶层闸门后再开始作业。');
295
324
  if (o.extra) extraLines.push(String(o.extra));
296
325
  return [body].concat(extraLines.length ? ['', ...extraLines] : []).join(NL);
@@ -315,7 +344,7 @@ export function sessionPrompt(env, args, exec) {
315
344
  export async function sessionArchive(env, args, exec) {
316
345
  const reg = env.registry();
317
346
  if (!reg || typeof reg.archiveSession !== 'function') {
318
- throw new Error('workspaceRegistry.archiveSession 不可用(先跑 ho_diag 看服务可见性)');
347
+ throw new Error('workspaceRegistry.archiveSession 不可用(先跑 ho_status(view=diag) 看服务可见性)');
319
348
  }
320
349
  const sid = String(args.session_id || '').trim();
321
350
  if (!sid) throw new Error('必须给 session_id');
@@ -341,7 +370,7 @@ export async function sessionArchive(env, args, exec) {
341
370
  export async function sessionUnarchive(env, args) {
342
371
  const reg = env.registry();
343
372
  if (!reg || typeof reg.unarchiveSession !== 'function') {
344
- throw new Error('workspaceRegistry.unarchiveSession 不可用(先跑 ho_diag 看服务可见性)');
373
+ throw new Error('workspaceRegistry.unarchiveSession 不可用(先跑 ho_status(view=diag) 看服务可见性)');
345
374
  }
346
375
  const sid = String(args.session_id || '').trim();
347
376
  if (!sid) throw new Error('必须给 session_id');
@@ -364,7 +393,7 @@ function orderHandoverText(env, sid, extra) {
364
393
  lines.push(" 直接调用 ho_handover(action=\"write\", text=...),不要传 path:路径已按你的角色(" + (info && info.role ? info.role : "unknown") + ")解析。");
365
394
  lines.push("2) 必写:① 当前进度(版本、产物、是否通过)② 环境与路径(版本号、安装位置、下载源)③ 下一步清单,按顺序列出,每条可直接执行 ④ 已完成待验收、要求未实现的部分 ⑤ 已知问题与已踩的错。");
366
395
  lines.push("3) 机密只写位置,不写值:密码、token、密钥均写明所在文件。");
367
- lines.push("4) 写完只回一条 peer_send 给全局:`交接已写 <绝对路径>`,附一句状态,不复述全文。");
396
+ lines.push("4) 写完只回一条 peer(action=\"send\")给全局:`交接已写 <绝对路径>`,附一句状态,不复述全文。");
368
397
  lines.push("5) 不要压缩上下文,不要新建会话(新建会话属全局权限)。");
369
398
  if (extra) lines.push("6) 全局附加:" + extra);
370
399
  return lines.join(NL);
@@ -376,7 +405,7 @@ export async function sessionOrder(env, args) {
376
405
  if (!sid) throw new Error("order 需要 session_id");
377
406
  if (order !== "handover" && order !== "compact") throw new Error("order 只能是 handover 或 compact");
378
407
  // 给别的会话下「交接、压缩」指令,也要先问用户。
379
- if (args.confirm !== true) throw new Error("下交接、压缩指令需要用户确认:先说明会唤醒目标会话并让它交接或压缩,得到同意后传 confirm=true");
408
+ if (args.confirm !== true) throw new Error("下交接、压缩指令需要当前用户确认:先说明会唤醒目标会话并让它交接或压缩,得到同意后传 confirm=true");
380
409
  const extra = typeof args.extra === "string" ? args.extra.trim() : "";
381
410
  const hp = env.handoverForSid(sid);
382
411
  const info = env.infoOfSid(sid);
@@ -413,7 +442,7 @@ export async function sessionOrder(env, args) {
413
442
  const max = (env.cfg && env.cfg.maxCompactions) || 3;
414
443
  const n = used + 1;
415
444
  if (n > max) {
416
- out.push("已压满 " + used + "/" + max + ":不再压缩。应执行 ho_succeed 自行换任(先请用户发送 /allow-succeed 授权);全局会话自身也可用 ho_sessions(action=\"new\")。新会话读毕交接会先向你报告,等待指派。");
445
+ out.push("已压满 " + used + "/" + max + ":不再压缩。应执行 ho_succeed 自行换任(先请当前用户发送 /allow-succeed 授权);全局会话自身也可用 ho_sessions(action=\"new\")。新会话读毕交接会先向你报告,等待指派。");
417
446
  return out.join(NL);
418
447
  }
419
448
  const recentSec = st && st.lastSummaryMs ? (Date.now() - st.lastSummaryMs) / 1000 : null;
@@ -425,15 +454,15 @@ export async function sessionOrder(env, args) {
425
454
  const cs = checkConsent(sid);
426
455
  if (!cs.ok) {
427
456
  out.push("拒绝排压缩:" + cs.reason + "。");
428
- out.push("请用户到设置页的 dsh-handover 中,点「压缩授权」的「允许该会话压缩一次」(5 分钟内有效),再重下这条指令。");
457
+ out.push("请当前用户到设置页的 dsh-handover 中,点击「授权」里的「授权压缩」(5 分钟内有效),再重下这条指令。");
429
458
  return out.join(NL);
430
459
  }
431
460
  const resume = args.resume === "continue" ? "continue" : "wait";
432
461
  env.schedule(sid, n, { resume });
433
- out.push("用户已授权(" + (cs.atText || '') + " 点击,剩 " + Math.round((cs.leftMs || 0) / 1000) + " 秒)。");
462
+ out.push("当前用户已授权(" + (cs.atText || '') + " 点击,剩 " + Math.round((cs.leftMs || 0) / 1000) + " 秒)。");
434
463
  out.push("已登记压缩(它自己第 " + n + "/" + max + " 次):等它空闲后执行。");
435
464
  if (resume === "wait") {
436
- out.push("去向:wait(缺省)—— 压缩后不再唤醒它,等用户派新任务,或由新会话接手。");
465
+ out.push("去向:wait(缺省)—— 压缩后不再唤醒它,等当前用户派新任务,或由新会话接手。");
437
466
  } else {
438
467
  out.push("去向:continue —— 压缩后它会自动收到**它自己的**续跑消息(带它自己的交接路径)。");
439
468
  }
@@ -453,7 +482,7 @@ export async function sessionPlace(env, args) {
453
482
  if (!sid) throw new Error("place 需要 session_id");
454
483
  if (!key) throw new Error("place 需要 workspace(id、标题、路径都行)");
455
484
  const reg = env.registry();
456
- if (!reg || typeof reg.get !== "function") throw new Error("workspaceRegistry 不可用(先跑 ho_diag)");
485
+ if (!reg || typeof reg.get !== "function") throw new Error("workspaceRegistry 不可用(先跑 ho_status(view=diag))");
457
486
  const wid = findWorkspaceId(key);
458
487
  if (!wid) throw new Error("找不到工作区:" + key);
459
488
  const w = reg.get(wid);
@@ -37,7 +37,7 @@ function isPlausible(name) {
37
37
 
38
38
  /** 返回 { project, real }:优先看 cwd 里的 Projects/<名>/,退回首条提示里的 Projects/<名>/ 或 「<名>」项目。 */
39
39
  export function projectOf(data) {
40
- // 先认路径:首条提示是自然语言的会话解析不出项目名,会被整行过滤,在任判定就可能落到前任身上。
40
+ // 先认路径:首条提示是自然语言的会话解析不出项目名,会被整行过滤,在任判定就可能落到已交出的旧会话身上。
41
41
  // cwd 由宿主写入,最可靠。
42
42
  const raw = String(cwdOf(data) || '').replace(/\\/g, '/');
43
43
  const ci = raw.toLowerCase().indexOf('/projects/');
@@ -1,11 +1,11 @@
1
- /**
1
+ /**
2
2
  * 设置页用的状态快照(只读):插件自身状态 + 闸门 + 交接体检 + 会话水位 + 最近一次守卫判定。
3
3
  * 全部读本地文件,不唤醒任何会话、不花任何会话的 token。
4
4
  * 任何一段出错都只让该段带 error 字段,不让整页 500。
5
5
  */
6
6
  import fs from 'node:fs';
7
+ import os from 'node:os';
7
8
  import path from 'node:path';
8
- import { isDir } from './text.mjs';
9
9
  import { syncCheck } from './gates.mjs';
10
10
  import { lintGlobal, lintProject } from './lint.mjs';
11
11
  import { scanSessions } from './sessions.mjs';
@@ -15,20 +15,125 @@ import { buildGuide, SETUP_STEPS } from './guide.mjs';
15
15
  import { readRoles, resolveRole, ROLE_LABEL, sessionMeta } from './role.mjs';
16
16
  import { listConsent } from './consent.mjs';
17
17
  import { listProfiles } from './profile.mjs';
18
+ import { loadOffice, readRegistryProjects, KIND_LABEL } from './office.mjs';
18
19
 
19
20
  function guard(fn, fallback) {
20
21
  try { return fn(); } catch (e) { return { ...fallback, error: String(e && e.message ? e.message : e) }; }
21
22
  }
22
23
 
23
- /** 项目交接体检:_Project_Meta/<项目>/Handover.md。 */
24
+ /**
25
+ * DSH 工作区索引:读 `~/.dsh/storages/workspace.json`(只读)。
26
+ * 返回 { list, bySession, archived }:
27
+ * - list:按登记顺序的工作区(key、title、path);
28
+ * - bySession:sessionId → 工作区;
29
+ * - archived:已归档会话 id,设置页不再罗列。
30
+ * 读不到时全部为空,调用方据此退化为「按会话目录分组、只按归档表过滤」。
31
+ */
32
+ function loadWorkspaceIndex() {
33
+ const out = { list: [], bySession: new Map(), archived: new Set() };
34
+ let v = null;
35
+ try {
36
+ const file = path.join(os.homedir(), '.dsh', 'storages', 'workspace.json');
37
+ v = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
38
+ } catch { return out; }
39
+ if (!v || typeof v !== 'object') return out;
40
+ const tables = v.tables && v.tables.workspaces ? v.tables.workspaces : {};
41
+ const order = v.global && Array.isArray(v.global.workspaceIds) && v.global.workspaceIds.length
42
+ ? v.global.workspaceIds : Object.keys(tables);
43
+ for (const key of order) {
44
+ const w = tables[key];
45
+ if (!w) continue;
46
+ const item = { key: String(key), title: String(w.title || ''), path: String(w.path || '') };
47
+ out.list.push(item);
48
+ for (const sid of (Array.isArray(w.sessionIds) ? w.sessionIds : [])) out.bySession.set(String(sid), item);
49
+ }
50
+ for (const sid of (v.global && Array.isArray(v.global.archivedSessionIds) ? v.global.archivedSessionIds : [])) {
51
+ out.archived.add(String(sid));
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** 仍然存在的会话目录:`~/.dsh/sessions` 下以 session- 开头的目录名(含已归档里恢复不了的会话就自然不在)。 */
57
+ function liveSessionDirs() {
58
+ const set = new Set();
59
+ try {
60
+ const walk = (dir, depth) => {
61
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
62
+ if (!e.isDirectory()) continue;
63
+ if (e.name.indexOf('session-') === 0) set.add(e.name);
64
+ else if (depth < 4) walk(path.join(dir, e.name), depth + 1);
65
+ }
66
+ };
67
+ walk(path.join(os.homedir(), '.dsh', 'sessions'), 0);
68
+ } catch { /* 读不到就返回空集 */ }
69
+ return set;
70
+ }
71
+
72
+ /** 在任登记:Office.json 中每条登记(全局、各项目、各杂项任务)的现任。
73
+ * 返回 sessionId → { key, kind, kindLabel, name };读不到登记表时为空表,调用方据此退化为「不过滤」。
74
+ * 普通会话不登记,因此不会被当成在任会话。
75
+ */
76
+ function loadDuty(ws) {
77
+ const map = new Map();
78
+ let office = null;
79
+ try { office = loadOffice(ws); } catch { office = null; }
80
+ const lineages = (office && office.lineages) || {};
81
+ for (const key of Object.keys(lineages)) {
82
+ const lin = lineages[key] || {};
83
+ const cur = lin.current;
84
+ if (!cur || !cur.sessionId) continue;
85
+ map.set(String(cur.sessionId), {
86
+ key,
87
+ kind: String(lin.kind || ''),
88
+ kindLabel: KIND_LABEL[lin.kind] || '',
89
+ project: String(lin.project || ''),
90
+ task: String(lin.task || ''),
91
+ name: String(cur.name || key),
92
+ });
93
+ }
94
+ return { map, office };
95
+ }
96
+
97
+ /** 项目交接体检:只查 Registry.md 已登记的项目,未登记的旧目录不在此列。 */
24
98
  function lintProjects(ws) {
25
99
  const out = [];
26
- let dirs = [];
27
- try { dirs = fs.readdirSync(path.join(ws, '_Project_Meta')).sort(); } catch { return out; }
28
- for (const name of dirs) {
29
- const dir = path.join(ws, '_Project_Meta', name);
30
- try { if (!isDir(dir)) continue; } catch { continue; }
31
- const r = lintProject(path.join(dir, 'Handover.md'));
100
+ let names = [];
101
+ try { names = readRegistryProjects(ws); } catch { return out; }
102
+ for (const name of names) {
103
+ const r = lintProject(path.join(ws, '_Project_Meta', name, 'Handover.md'));
104
+ out.push({ name, verdict: r.verdict, reasons: Array.isArray(r.reasons) ? r.reasons.slice(0, 5) : [] });
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** 杂项目录:规范位置 `_Misc/tasks/<任务>/`,兼容旧布局 `_Misc/<任务>/`;两处都没有就返回空串。 */
110
+ function miscDir(ws, task) {
111
+ const name = String(task || '').trim();
112
+ if (!name) return '';
113
+ const cands = [path.join(ws, '_Misc', 'tasks', name), path.join(ws, '_Misc', name)];
114
+ for (const dir of cands) {
115
+ try { if (fs.statSync(dir).isDirectory()) return dir; } catch { /* 继续看下一个 */ }
116
+ }
117
+ return '';
118
+ }
119
+
120
+ /**
121
+ * 杂项交接体检:按 Office.json 里已登记的杂项条目找任务目录。
122
+ * 登记名与目录名不一致(例如登记「示例任务」、目录名不同)时报「未找到」,
123
+ * 不猜近似目录:猜错会把别人的交接算到这条任务头上。
124
+ */
125
+ function lintMisc(ws, office) {
126
+ const out = [];
127
+ const lineages = (office && office.lineages) || {};
128
+ for (const key of Object.keys(lineages)) {
129
+ const lin = lineages[key] || {};
130
+ if (String(lin.kind || '') !== 'misc') continue;
131
+ const name = String(lin.task || key).trim();
132
+ if (!name) continue;
133
+ const dir = miscDir(ws, name);
134
+ const r = dir
135
+ ? lintProject(path.join(dir, 'Handover.md'))
136
+ : { verdict: '未找到', reasons: ['按登记名找不到任务目录:_Misc/tasks/' + name + ' 或 _Misc/' + name] };
32
137
  out.push({ name, verdict: r.verdict, reasons: Array.isArray(r.reasons) ? r.reasons.slice(0, 5) : [] });
33
138
  }
34
139
  return out;
@@ -39,6 +144,9 @@ export function buildStatus(env, extra) {
39
144
  const ws = routeWorkspace(env);
40
145
  const found = hasCenter(ws);
41
146
  const warn = Math.round((env.cfg.warnRatio || 0.32) * 100);
147
+ const duty = loadDuty(ws);
148
+ const onDuty = (sid) => (duty.map.size ? duty.map.has(String(sid || '')) : true);
149
+ const dutyOf = (sid) => duty.map.get(String(sid || '')) || null;
42
150
  const out = {
43
151
  generatedAt: stampTz(Date.now()),
44
152
  plugin: {
@@ -68,7 +176,7 @@ export function buildStatus(env, extra) {
68
176
  }
69
177
  return map;
70
178
  }, {}),
71
- consent: guard(() => listConsent(), []),
179
+ consent: guard(() => listConsent().map((r) => ({ ...r, onduty: onDuty(r.sessionId) })), []),
72
180
  profiles: guard(() => listProfiles(ws), []),
73
181
  guide: guard(() => buildGuide(env.cfg), { intro: [], situations: [], groups: [], rules: [] }),
74
182
  gate: null,
@@ -102,66 +210,120 @@ export function buildStatus(env, extra) {
102
210
  out.handover = guard(() => {
103
211
  const g = lintGlobal(path.join(ws, '_Control_Center', 'records', 'Global_Handover.md'));
104
212
  const projects = lintProjects(ws);
213
+ const misc = lintMisc(ws, duty.office);
214
+ const bad = projects.concat(misc).filter((p) => p.verdict === '不合格');
105
215
  return {
106
216
  global: { verdict: g.verdict, reasons: Array.isArray(g.reasons) ? g.reasons.slice(0, 5) : [] },
107
217
  projects,
108
- fail: projects.filter((p) => p.verdict === '不合格').length,
218
+ misc,
219
+ fail: bad.length,
109
220
  };
110
- }, { global: { verdict: '读不到', reasons: [] }, projects: [], fail: 0 });
221
+ }, { global: { verdict: '读不到', reasons: [] }, projects: [], misc: [], fail: 0 });
111
222
 
112
223
  out.sessions = guard(() => {
113
- const scan = scanSessions({
114
- dir: env.cacheDir(),
115
- meId: null,
116
- meCwd: null,
117
- warn,
118
- silentHours: env.cfg.sessionScanHours,
119
- projectsOnly: true,
224
+ const wide = scanSessions({
225
+ dir: env.cacheDir(), meId: null, meCwd: null, warn,
226
+ silentHours: env.cfg.sessionScanHours, projectsOnly: false,
120
227
  });
121
228
  const store = readRoles(env.cfg);
122
229
  const roleOf = (sid, cwd, firstPrompt, title) => {
123
230
  const meta = sessionMeta(sid);
124
231
  return resolveRole({ cfg: env.cfg, roles: store, sessionId: sid, cwd, firstPrompt, subagent: meta.subagent, title });
125
232
  };
126
- const wide = scanSessions({
127
- dir: env.cacheDir(), meId: null, meCwd: null, warn, silentHours: env.cfg.sessionScanHours, projectsOnly: false,
128
- });
129
- const all = wide.rows.filter((r) => r.kind === '平级').map((r) => {
233
+ // 工作区与归档:设置页按工作区分组罗列,已归档与目录已不存在的会话不再出现。
234
+ const wsIndex = loadWorkspaceIndex();
235
+ const live = liveSessionDirs();
236
+ // 会话 id 在缓存里有带与不带 session- 前缀两种写法,统一成带前缀再比。
237
+ const sidKey = (sid) => (/^session-/.test(String(sid)) ? String(sid) : 'session-' + String(sid));
238
+ const archived = (sid) => wsIndex.archived.has(sidKey(sid));
239
+ const stillThere = (sid) => live.size === 0 || live.has(sidKey(sid));
240
+ // 真实路径:旧根现为链接,靠 realpath 才能把老会话归到当前工作区。
241
+ const realCache = new Map();
242
+ const realOf = (p) => {
243
+ const key = String(p || '').toLowerCase();
244
+ if (realCache.has(key)) return realCache.get(key);
245
+ let v = '';
246
+ try { v = fs.realpathSync(String(p || '')); } catch { v = ''; }
247
+ realCache.set(key, v);
248
+ return v;
249
+ };
250
+ const wsReal = wsIndex.list.map((w) => ({
251
+ w,
252
+ real: String(realOf(w.path) || w.path || '').replace(/\\/g, '/').toLowerCase(),
253
+ }));
254
+ const wsOf = (sid, cwd) => {
255
+ const direct = wsIndex.bySession.get(sidKey(sid));
256
+ if (direct) return direct;
257
+ // 未登记到工作区的会话:按 cwd(先取真实路径)最长前缀归到工作区。
258
+ const c = String(realOf(cwd) || cwd || '').replace(/\\/g, '/').toLowerCase();
259
+ if (!c) return null;
260
+ let best = null;
261
+ for (const item of wsReal) {
262
+ const wp = item.real;
263
+ if (!wp) continue;
264
+ if ((c === wp || c.indexOf(wp + '/') === 0) && (!best || wp.length > best.real.length)) best = item;
265
+ }
266
+ return best ? best.w : null;
267
+ };
268
+ const mapRow = (r) => {
130
269
  const meta = sessionMeta(r.sid);
131
270
  const info = roleOf(r.sid, meta.cwd, meta.firstPrompt, r.project);
271
+ const d = dutyOf(r.sid);
272
+ const w = wsOf(r.sid, meta.cwd);
273
+ // 目录已不存在的老会话(旧树里删掉的项目库等)不再罗列。
274
+ const cwdOk = !meta.cwd || !!realOf(meta.cwd);
132
275
  return {
133
276
  sid: r.sid,
134
277
  title: r.project === '-' ? '' : r.project,
278
+ project: r.project,
135
279
  role: info.role,
136
280
  roleLabel: ROLE_LABEL[info.role] || info.role,
137
281
  source: info.source,
138
- project: info.project,
282
+ projectName: info.project,
139
283
  task: info.task,
284
+ cwdOk,
285
+ // 工作区分组:没有登记归属时留空,客户端归入「未归属工作区」。
286
+ wsKey: w ? w.key : '',
287
+ wsTitle: w ? w.title : '',
288
+ wsPath: w ? w.path : String(meta.cwd || ''),
289
+ // 类别与名称以在任登记为准:登记过的会话才能说清自己是全局、项目还是杂项。
290
+ dutyKind: d ? d.kindLabel : '',
291
+ dutyKey: d ? d.key : '',
292
+ dutyName: d ? d.name : '',
140
293
  pct: r.pct,
141
294
  state: r.state,
142
- active: r.mtime >= scan.cutoffMs,
295
+ active: r.mtime >= wide.cutoffMs,
296
+ onduty: !!d,
297
+ mtime: r.mtime,
143
298
  seen: stampTz(r.mtime),
144
299
  };
145
- });
300
+ };
301
+ const all = wide.rows
302
+ .filter((r) => r.kind === '平级' && !archived(r.sid) && stillThere(r.sid))
303
+ .map(mapRow)
304
+ .filter((r) => r.wsKey || r.cwdOk);
305
+ // 在任集合:全局、各项目、各杂项任务的现任各一行;普通会话不在登记表里,因此不出现。
306
+ const rows = all.filter((r) => r.onduty).slice(0, 60);
307
+ // 通知:每条登记的在任会话,若本轮仍活跃;已交出的会话即使更活跃也不通知。
308
+ const notify = [];
309
+ for (const sid of duty.map.keys()) {
310
+ const hit = rows.find((r) => r.sid === sid);
311
+ if (hit && hit.mtime >= wide.cutoffMs) notify.push({ sid: hit.sid, key: hit.dutyKey, name: hit.dutyName });
312
+ }
313
+ const hot = rows.filter((r) => r.pct !== null && r.pct >= warn).length;
146
314
  return {
147
- rows: scan.rows.slice(0, 60).map((r) => {
148
- const meta = sessionMeta(r.sid);
149
- const info = roleOf(r.sid, meta.cwd, meta.firstPrompt, r.project);
150
- return {
151
- sid: r.sid, project: r.project, pct: r.pct, state: r.state,
152
- role: info.role, roleLabel: ROLE_LABEL[info.role] || info.role,
153
- active: r.mtime >= scan.cutoffMs, seen: stampTz(r.mtime),
154
- };
155
- }),
315
+ rows,
156
316
  all,
157
- notify: scan.notify.map((r) => ({ sid: r.sid, project: r.project })),
158
- hot: scan.hot.length,
159
- unknown: scan.unknown.length,
160
- cutoff: stampTz(scan.cutoffMs),
161
- silentHours: scan.silentHours,
162
- verdict: scan.hot.length
163
- ? scan.hot.length + ' 个会话已进预警区(不低于 ' + warn + '%),应先收齐交接,再换会话'
164
- : '没有会话进入预警区。',
317
+ workspaces: wsIndex.list.map((w) => ({ key: w.key, title: w.title || w.path, path: w.path })),
318
+ notify,
319
+ onduty: duty.map.size,
320
+ hot,
321
+ unknown: wide.unknown.length,
322
+ cutoff: stampTz(wide.cutoffMs),
323
+ silentHours: wide.silentHours,
324
+ verdict: hot
325
+ ? hot + ' 个在任会话已进预警区(不低于 ' + warn + '%),应先收齐交接,再换会话'
326
+ : '没有在任会话进入预警区。',
165
327
  };
166
328
  }, { rows: [], notify: [], hot: 0, unknown: 0 });
167
329
 
@@ -171,7 +333,5 @@ export function buildStatus(env, extra) {
171
333
  return all.filter((r) => r && r.role === 'global').length;
172
334
  }, 0);
173
335
 
174
- if (out.gate && !out.gate.ok && !out.gate.error) out.notes.push('通用约束条闸门未过:向对应项目会话派发,全局不代写项目库内文件。');
175
- if (out.handover && out.handover.fail) out.notes.push(out.handover.fail + ' 个项目交接体检不合格,交接前必须先补。');
176
336
  return out;
177
337
  }