@mingmaple/dsh-handover 0.9.14 → 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 (64) hide show
  1. package/README.md +46 -19
  2. package/docs/migrate.md +6 -6
  3. package/docs/releases/0.9.22.md +34 -0
  4. package/docs/troubleshoot.md +34 -10
  5. package/package.json +71 -71
  6. package/scripts/blank-dsh.mjs +29 -23
  7. package/scripts/blank-smoke.mjs +205 -87
  8. package/scripts/e2e-migration.mjs +2 -2
  9. package/scripts/office.mjs +3 -3
  10. package/src/client.js +159 -120
  11. package/src/index.mjs +65 -58
  12. package/src/lib/api.mjs +9 -7
  13. package/src/lib/brief.mjs +2 -2
  14. package/src/lib/config.mjs +3 -3
  15. package/src/lib/consent.mjs +54 -1
  16. package/src/lib/docmap.mjs +143 -0
  17. package/src/lib/gates.mjs +25 -1
  18. package/src/lib/guide.mjs +35 -44
  19. package/src/lib/lint.mjs +3 -2
  20. package/src/lib/manual.mjs +48 -9
  21. package/src/lib/mover.mjs +10 -10
  22. package/src/lib/names.mjs +14 -19
  23. package/src/lib/office.mjs +22 -14
  24. package/src/lib/pack-ops.mjs +2 -2
  25. package/src/lib/peer.mjs +4 -4
  26. package/src/lib/role.mjs +8 -5
  27. package/src/lib/scaffold.mjs +42 -20
  28. package/src/lib/schedule.mjs +1 -2
  29. package/src/lib/session-create.mjs +1 -1
  30. package/src/lib/session-ops.mjs +16 -16
  31. package/src/lib/sessions.mjs +1 -1
  32. package/src/lib/status.mjs +205 -45
  33. package/src/lib/succession.mjs +7 -7
  34. package/src/lib/zstd-log.mjs +1 -1
  35. package/src/selftest.mjs +6 -6
  36. package/src/templates/Prompt_Templates.md +13 -13
  37. package/src/templates/Runtime_Manual.md +56 -30
  38. package/src/templates/rules/Handover_Rules.md +2 -1
  39. package/src/templates/rules/Migration_Rules.md +1 -1
  40. package/src/templates/standards/README.md +25 -0
  41. package/src/tools/ho_backup.mjs +135 -0
  42. package/src/tools/ho_checkpoint.mjs +12 -11
  43. package/src/tools/ho_docmap.mjs +153 -0
  44. package/src/tools/ho_gate.mjs +2 -1
  45. package/src/tools/ho_office.mjs +1 -1
  46. package/src/tools/ho_sessions.mjs +3 -4
  47. package/src/tools/ho_status.mjs +246 -0
  48. package/src/tools/ho_succeed.mjs +1 -1
  49. package/src/tools/ho_workspace.mjs +86 -14
  50. package/src/tools/peer.mjs +205 -0
  51. package/src/tools/ho_brief.mjs +0 -57
  52. package/src/tools/ho_diag.mjs +0 -87
  53. package/src/tools/ho_drift.mjs +0 -41
  54. package/src/tools/ho_init.mjs +0 -82
  55. package/src/tools/ho_pack.mjs +0 -38
  56. package/src/tools/ho_phase.mjs +0 -37
  57. package/src/tools/ho_snapshot.mjs +0 -104
  58. package/src/tools/ho_tokens.mjs +0 -57
  59. package/src/tools/peer_list.mjs +0 -37
  60. package/src/tools/peer_new.mjs +0 -75
  61. package/src/tools/peer_peek.mjs +0 -41
  62. package/src/tools/peer_rename.mjs +0 -31
  63. package/src/tools/peer_send.mjs +0 -52
  64. package/src/tools/peer_status.mjs +0 -46
package/src/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * dsh-handover —— 全局运行期插件。
3
3
  * 判据进代码、流程进工具;规则正文留磁盘,需要时用 ho_charter 取节(完整流程 doc="runtime")。
4
- * 按会话角色载入工具与提示段:全局 24 个工具、项目 11、杂项 11、普通 0。
4
+ * 按会话角色载入工具与提示段:全局 14 个工具、项目 11、杂项 10、普通 0。
5
5
  * 源码不热加载:改完要重启 DSH。
6
6
  */
7
7
  import fs from 'node:fs';
@@ -11,35 +11,25 @@ import { resolveConfig } from './lib/config.mjs';
11
11
  import { projCacheDir, pressurePct } from './lib/sessions.mjs';
12
12
  import { createScheduler } from './lib/schedule.mjs';
13
13
  import { compactionStateOf, stalePressureNote } from './lib/events.mjs';
14
- import { pmBriefTool } from './tools/ho_brief.mjs';
14
+ import { pmStatusTool } from './tools/ho_status.mjs';
15
15
  import { pmGateTool } from './tools/ho_gate.mjs';
16
16
  import { pmSessionsTool } from './tools/ho_sessions.mjs';
17
- import { pmPhaseTool } from './tools/ho_phase.mjs';
18
17
  import { pmCharterTool } from './tools/ho_charter.mjs';
19
18
  import { pmHandoverTool } from './tools/ho_handover.mjs';
20
19
  import { pmCheckpointTool } from './tools/ho_checkpoint.mjs';
21
- import { pmSnapshotTool } from './tools/ho_snapshot.mjs';
22
- import { pmDiagTool } from './tools/ho_diag.mjs';
23
- import { pmDriftTool } from './tools/ho_drift.mjs';
24
20
  import { pmChangelogTool } from './tools/ho_changelog.mjs';
25
- import { pmTokensTool } from './tools/ho_tokens.mjs';
26
- import { pmPackTool } from './tools/ho_pack.mjs';
21
+ import { pmBackupTool } from './tools/ho_backup.mjs';
27
22
  import { pmWorkspaceTool } from './tools/ho_workspace.mjs';
28
- import { pmInitTool } from './tools/ho_init.mjs';
29
23
  import { pmProfileTool } from './tools/ho_profile.mjs';
30
24
  import { pmOfficeTool } from './tools/ho_office.mjs';
31
25
  import { pmSucceedTool } from './tools/ho_succeed.mjs';
32
- import { pmPeerListTool } from './tools/peer_list.mjs';
33
- import { pmPeerStatusTool } from './tools/peer_status.mjs';
34
- import { pmPeerSendTool } from './tools/peer_send.mjs';
35
- import { pmPeerNewTool } from './tools/peer_new.mjs';
36
- import { pmPeerRenameTool } from './tools/peer_rename.mjs';
37
- import { pmPeerPeekTool } from './tools/peer_peek.mjs';
26
+ import { pmDocmapTool } from './tools/ho_docmap.mjs';
27
+ import { pmPeerTool } from './tools/peer.mjs';
38
28
  import { resolveRegistry } from './lib/registry.mjs';
39
29
  import { registerStatusRoute } from './lib/webroute.mjs';
40
30
  import { createApi, statusSnapshot } from './lib/api.mjs';
41
31
  import { registerRpcCarrier } from './lib/rpc-carrier.mjs';
42
- import { NAMES } from './lib/names.mjs';
32
+ import { NAMES, PEER_NAME } from './lib/names.mjs';
43
33
  import { readExtraPrompt, writeExtraPrompt, EXTRA_PROMPT_ORDER, EXTRA_PROMPT_SECTION } from './lib/extra-prompt.mjs';
44
34
  import {
45
35
  ROLES, resolveRole, sessionMeta, handoverPathFor, toolsetFor,
@@ -50,12 +40,14 @@ import { sendPrompt } from './lib/prompt.mjs';
50
40
  import { resolveShortSid } from './lib/api.mjs';
51
41
  import { routeWorkspace } from './lib/workspace.mjs';
52
42
  import { stampTz } from './lib/brief.mjs';
53
- import { grantConsent } from './lib/consent.mjs';
43
+ import { grantConsent, activeConsentsFor } from './lib/consent.mjs';
44
+ import { docmapBlock } from './lib/docmap.mjs';
45
+ import { moveStatus } from './lib/mover.mjs';
54
46
 
55
47
  export const name = 'dsh-handover';
56
48
  // 必需服务(名字已核对:dsh-agent→agents、dsh-compaction→compaction、dsh-api-session-controller→sessionController)。
57
49
  // 只 inject 一定存在的服务:多写一个不存在的名字,cordis 会让整个插件静默不激活。
58
- // agents / compaction 用运行时解析(ctx[name] → ctx.get(name)),无法获取由 ho_diag 报出来、ho_checkpoint 明确报错。
50
+ // agents / compaction 用运行时解析(ctx[name] → ctx.get(name)),无法获取由 ho_status(view="diag")报出来、ho_checkpoint 明确报错。
59
51
  export const inject = ['tools', 'systemPrompt', 'sessionController'];
60
52
 
61
53
  /** 自身包信息:dist 里读 ./package.json,源码树里退回 ../package.json。 */
@@ -110,6 +102,7 @@ export function apply(ctx, config) {
110
102
  // 更早的内核忽略注册选项,只能退回 agent 作用域的 restrict;两条路径按能力二选一,不并行。
111
103
  const perStep = (() => { try { return !!(ctx.tools && typeof ctx.tools.resolve === 'function'); } catch { return false; } })();
112
104
  const sectionDispose = new Map(); // sessionId → 撤销提示段
105
+ const consentSig = new Map(); // sessionId → 上次装配时的授权签名(授权出现或过期时重装)
113
106
  const legacyRestrict = new Map(); // sessionId → 撤销限制(仅无按步解析的内核)
114
107
  const appliedRole = new Map(); // sessionId → 上次装配的角色
115
108
  const roleCache = new Map(); // sessionId → { at, info }(每步过滤用,短 TTL)
@@ -153,7 +146,7 @@ export function apply(ctx, config) {
153
146
  scheduler.request(sessionId, Object.assign({ n, consent: 'checked' }, opts || {})).catch(() => {});
154
147
  },
155
148
  registry() { return resolveRegistry(ctx); },
156
- /** 迁移感知:根目录是否已搬到别处(旧路径变 junction)。给提示段与 ho_diag 用。 */
149
+ /** 迁移感知:根目录是否已搬到别处(旧路径变 junction)。给提示段与 ho_status(view="diag")用。 */
157
150
  moveInfo() {
158
151
  try {
159
152
  const src = cfg.workspaceRoot || walkUpToWorkspace(process.cwd());
@@ -202,7 +195,7 @@ export function apply(ctx, config) {
202
195
  staleNote(exec) {
203
196
  try { return stalePressureNote(this.selfId(exec)); } catch { return ''; }
204
197
  },
205
- /** 自身版本:从 dist/package.json 读,设置页与 ho_diag 共用同一值。 */
198
+ /** 自身版本:从 dist/package.json 读,设置页与 ho_status(view="diag")共用同一值。 */
206
199
  version() {
207
200
  try { return pkgInfo().version; } catch { return '?'; }
208
201
  },
@@ -288,7 +281,7 @@ export function apply(ctx, config) {
288
281
  return saved;
289
282
  };
290
283
 
291
- /** 当前生效的裁剪机制,供 ho_diag 报出。 */
284
+ /** 当前生效的裁剪机制,供 ho_status(view="diag")报出。 */
292
285
  function roleMode() {
293
286
  if (cfg.roleGating === 'off') return '关闭(roleGating=off,所有会话可见全部工具)';
294
287
  return perStep
@@ -337,7 +330,11 @@ export function apply(ctx, config) {
337
330
  let text = '';
338
331
  try {
339
332
  const ws = workspaceForAgent(agent);
340
- text = manualFor(info, { ws, cfg, move: typeof env.moveInfo === 'function' ? env.moveInfo() : null });
333
+ // 文档定位与授权状态都是「按会话现算」的:写盘后重装一次即可生效,不另开注入点。
334
+ const docmap = docmapBlock(ws, info);
335
+ const consents = activeConsentsFor(sid);
336
+ text = manualFor(info, { ws, cfg, move: typeof env.moveInfo === 'function' ? env.moveInfo() : null, docmap, consents });
337
+ consentSig.set(sid, consents.map((r) => r.kind + ':' + r.until).join('|'));
341
338
  } catch (e) {
342
339
  initErrors.push('角色提示段:' + msgOf(e));
343
340
  return;
@@ -432,6 +429,18 @@ export function apply(ctx, config) {
432
429
  } catch { return false; }
433
430
  };
434
431
 
432
+ /** 重装某会话的提示段:文档定位写盘后、授权到达后各调一次;不唤醒、不投消息。 */
433
+ env.refreshPromptFor = (sid) => {
434
+ try {
435
+ const id = String(sid || '');
436
+ if (!id) return false;
437
+ const a = agentOf(id);
438
+ if (!a) return false;
439
+ applyRoleTo(a);
440
+ return true;
441
+ } catch { return false; }
442
+ };
443
+
435
444
  // ---------------- 调度器(续跑文案要按角色取交接路径)
436
445
  // 注意:调度器拿到的是会话 id 字符串,不是 exec;这里用按 id 解析的版本,直接读角色表与磁盘。
437
446
  const schedDeps = { ctx, cfg, logger: (ctx.logger || null), roleOf: null, handoverFor: null };
@@ -472,7 +481,7 @@ export function apply(ctx, config) {
472
481
  };
473
482
  try { applyExtraPrompt(readExtraPrompt()); } catch (e) { initErrors.push('全局前置提示词:' + msgOf(e)); }
474
483
 
475
- // ---------------- 工具注册(18 个 ho_* + 6 个 peer_*)
484
+ // ---------------- 工具注册(13 个 ho_* + 1 个 peer)
476
485
  // 0.2.2 起工具按「步」解析:可用工具集合每一步重算。子会话排除与角色过滤都在这里一次做完,
477
486
  // 不再另用 agent 作用域 restrict —— 两套机制并行时,换角色要么等到重启、要么互相覆盖。
478
487
  const roleToolsets = new Map(ROLES.map((r) => [r, new Set(toolsetFor(r).allow)]));
@@ -498,26 +507,21 @@ export function apply(ctx, config) {
498
507
  };
499
508
  }
500
509
  const tools = [
501
- pmBriefTool(env),
510
+ pmStatusTool(env, { schedulerReady: () => scheduler !== null, schedulerError: () => schedulerError }),
502
511
  pmGateTool(env),
503
512
  pmSessionsTool(env),
504
- pmPhaseTool(env),
505
513
  pmCharterTool(env),
506
514
  pmHandoverTool(env),
507
515
  pmCheckpointTool(env),
508
- pmSnapshotTool(env),
509
- pmDiagTool(env, { schedulerReady: () => scheduler !== null, schedulerError: () => schedulerError }),
510
- pmDriftTool(env),
511
516
  pmChangelogTool(env),
512
- pmTokensTool(env),
513
- pmPackTool(env),
517
+ pmBackupTool(env),
514
518
  pmWorkspaceTool(env),
515
- pmInitTool(env),
516
519
  pmProfileTool(env),
517
520
  pmOfficeTool(env),
518
521
  pmSucceedTool(env),
522
+ pmDocmapTool(env),
519
523
  ];
520
- const keys = ['brief', 'gate', 'sessions', 'phase', 'charter', 'handover', 'checkpoint', 'snapshot', 'diag', 'drift', 'changelog', 'tokens', 'pack', 'workspace', 'init', 'profile', 'office', 'succeed'];
524
+ const keys = ['status', 'gate', 'sessions', 'charter', 'handover', 'checkpoint', 'changelog', 'backup', 'workspace', 'profile', 'office', 'succeed', 'docmap'];
521
525
  for (let i = 0; i < tools.length; i++) {
522
526
  const key = keys[i];
523
527
  try {
@@ -531,54 +535,52 @@ export function apply(ctx, config) {
531
535
  }
532
536
  }
533
537
 
534
- const peerTools = [
535
- pmPeerListTool(env),
536
- pmPeerStatusTool(env),
537
- pmPeerSendTool(env),
538
- pmPeerNewTool(env),
539
- pmPeerRenameTool(env),
540
- pmPeerPeekTool(env),
541
- ];
538
+ const peerTools = [pmPeerTool(env)];
542
539
  for (const t of peerTools) {
543
540
  try {
544
541
  ctx.tools.register(t, { when: visibleTo(t && t.name ? String(t.name) : '') });
545
542
  if (t && t.name) registered.add(t.name);
546
543
  } catch (e) {
547
- initErrors.push((t && t.name ? String(t.name) : 'peer_?') + ':' + msgOf(e));
544
+ initErrors.push((t && t.name ? String(t.name) : 'peer') + ':' + msgOf(e));
548
545
  }
549
546
  }
550
547
 
551
548
  // ---------------- 会话守卫(Charter §7.2.4):新建会话与设定角色仅属全局;
552
- // 会话之间允许直接 peer_send(不必经全局转述),directPeer=off 时退回「只能向全局报告」的旧口径。
549
+ // 会话之间允许直接 peer(action=send)通信(不必经全局转述),directPeer=off 时退回「只能向全局报告」的旧口径。
553
550
  if (cfg.wakeAuthority !== 'off' && typeof ctx.tools.guard === 'function') {
554
551
  try {
555
552
  ctx.tools.guard((exec) => {
556
553
  const nm = exec && exec.name;
557
- if (nm !== 'peer_send' && nm !== 'peer_new') return undefined;
554
+ if (nm !== PEER_NAME) return undefined;
555
+ let args = (exec && exec.arguments) || {};
556
+ if (typeof args === 'string') { try { args = JSON.parse(args); } catch { args = {}; } }
557
+ const action = String(args.action || '');
558
+ // list/status/peek/rename 的权限由工具内部按角色判,这里只管两件有外部影响的事。
559
+ if (action !== 'send' && action !== 'new') return undefined;
558
560
  const sid = env.selfId(exec) || '';
559
- const args = (exec && exec.arguments) || {};
560
561
  let info;
561
562
  try { info = env.roleOf(exec); } catch { info = { role: 'plain', source: 'error' }; }
562
563
  const mk = (allow, why) => {
563
564
  guardLast = { sid, allow, source: why, title: titleOf(sid) };
564
565
  if (allow) return undefined;
565
- if (nm === 'peer_new') return '会话创建仅属全局:非全局会话不得新建平级会话(杂项落到 Misc 除外)。';
566
+ if (action === 'new') return '会话创建仅属全局:非全局会话不得新建平级会话(杂项落到 Misc 除外)。';
566
567
  return '会话间通信受限(directPeer=off):非全局会话只能向现任全局报告,不得唤醒其他会话。';
567
568
  };
568
- if (info.role === 'global') return mk(true, 'global/' + info.source);
569
- if (nm === 'peer_send') {
570
- // 会话之间可直接对话;向全局报告也在此路。directPeer=off 才退回旧口径。
569
+ if (info.role === 'global') return mk(true, '全局角色');
570
+ if (action === 'send') {
571
+ // 会话之间可直接通信;向全局报告也在此路。directPeer=off 才退回旧口径。
571
572
  const target = String(args.session_id || '');
572
573
  let t = null;
573
574
  try { t = target ? infoFor(target, {}) : null; } catch { t = null; }
574
- if (t && t.role === 'global') return mk(true, 'report-to-global/' + (t.source || ''));
575
- if (cfg.directPeer !== 'off') return mk(true, 'direct/' + info.role + ' → ' + (t ? t.role : 'unknown'));
576
- return mk(false, info.source + '/' + info.role + ' → ' + (t ? t.role : 'unknown'));
575
+ const tRole = t ? t.role : '未知';
576
+ if (t && t.role === 'global') return mk(true, '向现任全局报告');
577
+ if (cfg.directPeer !== 'off') return mk(true, '会话之间直接通信(' + info.role + ' → ' + tRole + ')');
578
+ return mk(false, 'directPeer=off:仅允许向全局报告(' + info.role + ' → ' + tRole + ')');
577
579
  }
578
- // peer_new:只有杂项可以落到 Misc
580
+ // action=new:只有杂项可以落到 Misc
579
581
  const target = String(args.workspace || args.cwd || '').toLowerCase();
580
- if (info.role === 'misc' && target.indexOf('misc') !== -1) return mk(true, 'misc/' + info.source);
581
- return mk(false, info.source + '/' + info.role);
582
+ if (info.role === 'misc' && target.indexOf('misc') !== -1) return mk(true, '杂项会话新建到 Misc');
583
+ return mk(false, '非全局角色不得新建平级会话(' + info.role + ')');
582
584
  });
583
585
  } catch { /* 老版本没有 guard:退化为规则约束 */ }
584
586
  }
@@ -629,7 +631,10 @@ export function apply(ctx, config) {
629
631
  const sid = String((h && h.id) || agent.id || '');
630
632
  if (!sid) return;
631
633
  const info = infoFor(sid, scopedMetaOf(h, sid));
632
- if (appliedRole.get(sid) === info.role) return;
634
+ // 角色没变也要看授权签名:用户刚从设置页或斜杠命令授权时,下一轮就该看见那一行。
635
+ let sig = '';
636
+ try { sig = activeConsentsFor(sid).map((r) => r.kind + ':' + r.until).join('|'); } catch { sig = ''; }
637
+ if (appliedRole.get(sid) === info.role && consentSig.get(sid) === sig) return;
633
638
  env.log('开轮前换装:' + sid + ' · 已装 ' + (appliedRole.get(sid) || '-') + ' · 目标 ' + info.role);
634
639
  applyRoleTo(agent, info);
635
640
  } catch (e) { env.log('开轮前装配失败:' + msgOf(e)); }
@@ -700,7 +705,7 @@ export function apply(ctx, config) {
700
705
  const allowCommands = [
701
706
  {
702
707
  name: 'allow-compact',
703
- description: '允许本会话压缩一次(用户授权,5 分钟有效;可带会话短号授权别的会话)',
708
+ description: '允许本会话压缩一次(当前用户授权,5 分钟有效;可带会话短号授权别的会话)',
704
709
  hint: '可留空=本会话;填 8 位短号或 session-id 则授权那个会话',
705
710
  kind: 'compact',
706
711
  note: '聊天页命令 /allow-compact',
@@ -708,7 +713,7 @@ export function apply(ctx, config) {
708
713
  },
709
714
  {
710
715
  name: 'allow-succeed',
711
- description: '允许本会话换任一次(用户授权,5 分钟有效;可带会话短号授权别的会话)',
716
+ description: '允许本会话换任一次(当前用户授权,5 分钟有效;可带会话短号授权别的会话)',
712
717
  hint: '可留空=本会话;填 8 位短号或 session-id 则授权那个会话',
713
718
  kind: 'succeed',
714
719
  note: '聊天页命令 /allow-succeed',
@@ -725,8 +730,10 @@ export function apply(ctx, config) {
725
730
  handler: ({ agent, rawInput }) => {
726
731
  const arg = String(rawInput || '').trim();
727
732
  const sid = arg ? resolveShortSid(env, arg) : (agent && agent.id ? String(agent.id) : '');
728
- if (!sid) return { kind: 'error', text: '拿不到会话 id:请写成 /' + cmd.name + ' session-xxxx' };
733
+ if (!sid) return { kind: 'error', text: '无法获取会话 id:请写成 /' + cmd.name + ' session-xxxx' };
729
734
  const g = grantConsent(sid, cmd.note, cmd.kind);
735
+ // 授权一到就重装提示段:目标会话下一轮(哪怕现在空闲)就能看见那一行。
736
+ try { env.refreshPromptFor(sid); } catch { /* 装配失败不影响授权本身 */ }
730
737
  try { env.log(cmd.action + '授权(命令):' + sid); } catch { /* ignore */ }
731
738
  return { kind: 'success', text: '已允许 ' + sid + ' 在 ' + g.minutes + ' 分钟内' + cmd.action + '一次(用掉即失效)。' };
732
739
  },
package/src/lib/api.mjs CHANGED
@@ -218,21 +218,21 @@ export function createApi(env, info) {
218
218
  if (method === 'move.start') {
219
219
  const src = currentRoot(env);
220
220
  if (!src) return bad('no-workspace', '未找到全局根(工作区未接入)');
221
- env.log('搬迁:开始复制 ' + src + ' → ' + String(p.to || ''));
221
+ env.log('迁移:开始复制 ' + src + ' → ' + String(p.to || ''));
222
222
  const startedAt = Date.now();
223
223
  const r = await startMove({ src, to: p.to });
224
- env.log('搬迁:' + (r.ok ? '校验通过' : '校验有差异') + ',用时 ' + Math.round((Date.now() - startedAt) / 1000) + ' s');
224
+ env.log('迁移:' + (r.ok ? '校验通过' : '校验有差异') + ',用时 ' + Math.round((Date.now() - startedAt) / 1000) + ' s');
225
225
  return r.ok ? ok(r) : { ok: false, error: { code: 'verify-failed', message: '复制完成但校验有差异,未生成切换脚本', details: r.report }, http: 500 };
226
226
  }
227
227
 
228
228
  if (method === 'move.switch') {
229
229
  const r = stageSwitch({ relaunch: p.relaunch === true });
230
- env.log('搬迁:后台切换助手 ' + (r.already ? '无需再派(已切换)' : '已派出 pid=' + r.pid));
230
+ env.log('迁移:后台切换助手 ' + (r.already ? '无需再派(已切换)' : '已派出 pid=' + r.pid));
231
231
  return ok(r);
232
232
  }
233
233
 
234
234
  if (method === 'move.status') {
235
- // 一律以搬迁标记里的源路径为准:修改登记生效后当前工作区根已经变成新位置,
235
+ // 一律以迁移标记里的源路径为准:修改登记生效后当前工作区根已经变成新位置,
236
236
  // 再拿它去比对会把「已经是新位置」误报成「还指着旧路径」。
237
237
  return ok(moveStatus());
238
238
  }
@@ -242,6 +242,8 @@ export function createApi(env, info) {
242
242
  if (!sid) return bad('bad-request', '缺少 session_id');
243
243
  const kind = p.kind === 'succeed' ? 'succeed' : 'compact';
244
244
  const g = grantConsent(sid, kind === 'succeed' ? '设置页点击换任' : '设置页点击', kind);
245
+ // 授权一到就重装目标会话的提示段:空闲会话下一轮即可看见,不必等它自己去查。
246
+ if (typeof env.refreshPromptFor === 'function') { try { env.refreshPromptFor(sid); } catch { /* ignore */ } }
245
247
  env.log((kind === 'succeed' ? '换任' : '压缩') + '授权:' + sid + '(5 分钟)');
246
248
  invalidateStatus(env);
247
249
  return ok(g);
@@ -265,20 +267,20 @@ export function createApi(env, info) {
265
267
 
266
268
  if (method === 'move.retarget-stage') {
267
269
  const r = stageRetarget();
268
- env.log('搬迁:登记修改登记助手 ' + (r.already ? '无需(已在新位置)' : '已派出 pid=' + r.pid));
270
+ env.log('迁移:登记修改登记助手 ' + (r.already ? '无需(已在新位置)' : '已派出 pid=' + r.pid));
269
271
  return ok(r);
270
272
  }
271
273
 
272
274
  if (method === 'move.repair') {
273
275
  const r = repairLink();
274
- env.log('搬迁:重建链接 ' + (r.already ? '已存在' : r.src + ' → ' + r.dst));
276
+ env.log('迁移:重建链接 ' + (r.already ? '已存在' : r.src + ' → ' + r.dst));
275
277
  invalidateStatus(env);
276
278
  return ok(r);
277
279
  }
278
280
 
279
281
  if (method === 'move.cleanup') {
280
282
  const r = await cleanupOld({ confirm: p.confirm === true });
281
- env.log('搬迁:清理旧副本 ' + (r.removed || []).length + ' 个');
283
+ env.log('迁移:清理旧备份 ' + (r.removed || []).length + ' 个');
282
284
  return ok(r);
283
285
  }
284
286
 
package/src/lib/brief.mjs CHANGED
@@ -59,7 +59,7 @@ export function composeBrief(input) {
59
59
  const skipProjects = new Set(scan.skip.map((r) => r.project));
60
60
  out.push("活跃 " + notifProjects.size + " 个项目,需要通知 " + scan.notify.length + " 个会话:" + (scan.notify.length ? scan.notify.map((r) => r.project + "(" + sidShort(r.sid) + ",在任)").join("、") : "无"));
61
61
  out.push("沉默 " + skipProjects.size + " 个项目,跳过 " + scan.skip.length + " 个会话(不唤醒,省 token)");
62
- if (scan.hot.length) out.push("预警 " + scan.hot.length + " 个会话已进预警区(≥" + Math.round(scan.warn) + "%)—— 全为退场、前任则可忽略");
62
+ if (scan.hot.length) out.push("预警 " + scan.hot.length + " 个会话已进预警区(≥" + Math.round(scan.warn) + "%)—— 全为已交出的旧会话则可忽略");
63
63
  }
64
64
  if (input.drift) {
65
65
  out.push('Δ交接 ' + driftVerdict(input.drift));
@@ -69,6 +69,6 @@ export function composeBrief(input) {
69
69
  out.push('Δ交接 读不到(会话日志不可用)');
70
70
  }
71
71
  if (input.advice && input.advice.text) out.push('阶段 ' + input.advice.text);
72
- out.push('提示 ' + (input.ok ? '按「活跃」名单逐个 peer_send 广播本会话 id 并记回 §3;可正常开始作业' : '本会话已过阈值或闸门异常,应先收齐交接,再开展新任务'));
72
+ out.push('提示 ' + (input.ok ? '按「活跃」名单逐个用 peer(action="send")广播本会话 id 并记回 §3;可正常开始作业' : '本会话已过阈值或闸门异常,应先收齐交接,再开展新任务'));
73
73
  return out;
74
74
  }
@@ -11,14 +11,14 @@ export const DEFAULTS = {
11
11
  rolesPath: '', // 留空 = ~/.dsh/storages/dsh-handover.roles.json
12
12
  // 续跑提示:{n} {max} {handover} {role} 会被替换成真值;{handover} 是本会话自己的交接绝对路径。
13
13
  // 只在助手登记压缩时选 resume=continue(默认)才投递;resume=wait 表示已收尾,不唤醒。
14
- resumePrompt: '【dsh-handover 自动续跑·第 {n}/{max} 次压缩】压缩已完成,历史已被摘要替换。第一步:ho_handover(action=read) 取回本会话的交接({handover});然后按 §2「下一步」继续(若 §2 已无待办,向用户一句话收尾并停止)。不要复述交接内容,不要重做已完成的事。完整流程:ho_charter(doc="runtime")。',
14
+ resumePrompt: '【dsh-handover 自动续跑·第 {n}/{max} 次压缩】压缩已完成,历史已被摘要替换。第一步:ho_handover(action=read) 取回本会话的交接({handover});然后按 §2「下一步」继续(若 §2 已无待办,向当前用户一句话收尾并停止)。不要复述交接内容,不要重做已完成的事。完整流程:ho_charter(doc="runtime")。',
15
15
  resumePromptUnknown: '【dsh-handover 自动续跑·第 {n}/{max} 次压缩】压缩已完成,历史已被摘要替换。本会话是普通会话,不参与交接;如需继续,请由全局会话为其指定角色。',
16
16
  compactTimeoutMs: 120000,
17
17
  compactAttempts: 3,
18
18
  waitIdleMs: 180000, // 等会话空闲的上限(超过就放弃并回告)
19
19
  driftSlack: 3, // Δ交接容忍轮数(超过即需刷交接)
20
- wakeAuthority: 'on', // 会话守卫硬拦:on / off(off=连 peer_new 也不拦)
21
- directPeer: 'on', // 会话之间直接 peer_send:on 允许(不经全局转述);off 退回「只能向全局报告」的旧口径
20
+ wakeAuthority: 'on', // 会话守卫硬拦:on / off(off=连 peer 的 new 动作也不拦)
21
+ directPeer: 'on', // 会话之间直接 peer(action="send"):on 允许(不经全局转述);off 退回「只能向全局报告」的旧口径
22
22
  roleGating: 'on', // 按角色裁剪工具与提示段:on / off(off=所有会话都看得见全部工具)
23
23
  reuseCheck: true, // 建新会话前查在任会话水位(低于 warnRatio 拒绝)
24
24
  prepackMinAgeMin: 90, // 打包前要求会话静止分钟数
@@ -96,7 +96,7 @@ export function checkConsent(sessionId, kind) {
96
96
  return {
97
97
  ok: false,
98
98
  kind: k,
99
- reason: k === 'succeed' ? '用户还没有授权换任(聊天页命令 /allow-succeed)' : '用户还没有在设置页点「允许压缩」',
99
+ reason: k === 'succeed' ? '当前用户还没有授权换任(聊天页命令 /allow-succeed)' : '当前用户还没有在设置页点击「授权压缩」',
100
100
  };
101
101
  }
102
102
  return { ok: true, kind: k, until: hit.until, leftMs: hit.until - now, atText: hit.atText || '' };
@@ -121,3 +121,56 @@ export function listConsent() {
121
121
  })).filter((r) => r.leftMs > 0);
122
122
  return rows.sort((a, b) => a.sessionId.localeCompare(b.sessionId, 'zh') || a.kind.localeCompare(b.kind));
123
123
  }
124
+
125
+ /**
126
+ * 某会话当前有效的授权(只读,不清过期条目)。
127
+ * 提示段与 ho_status 共用:授权一旦写入,下一轮重建提示段时就能看见;过期自然消失。
128
+ */
129
+ export function activeConsentsFor(sessionId) {
130
+ const sid = String(sessionId || '');
131
+ if (!sid) return [];
132
+ const all = readAll();
133
+ const now = Date.now();
134
+ const rows = [];
135
+ for (const key of Object.keys(all)) {
136
+ const entry = all[key];
137
+ if (!entry || !entry.until || entry.until <= now) continue;
138
+ if (sidOfKey(key) !== sid) continue;
139
+ rows.push({
140
+ kind: kindOfEntry(key, entry),
141
+ until: entry.until,
142
+ leftMs: entry.until - now,
143
+ atText: entry.atText || '',
144
+ note: entry.note || '',
145
+ });
146
+ }
147
+ return rows.sort((a, b) => a.kind.localeCompare(b.kind));
148
+ }
149
+
150
+ const KIND_LABEL = { compact: '压缩', succeed: '换任' };
151
+
152
+ function untilText(ms) {
153
+ const d = new Date(ms);
154
+ const off = -d.getTimezoneOffset();
155
+ const pad = (n) => String(n).padStart(2, '0');
156
+ const a = Math.abs(off);
157
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' '
158
+ + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ' ' + (off >= 0 ? '+' : '-') + pad(Math.floor(a / 60)) + pad(a % 60);
159
+ }
160
+
161
+ /** 提示段用:每种有效授权一行,极短;无授权返回空数组(不占位)。 */
162
+ export function consentPromptLines(rows) {
163
+ return (rows || []).map((r) => '当前用户已授权' + KIND_LABEL[r.kind] + ',至 ' + untilText(r.until) + '(一次性,用后失效)。');
164
+ }
165
+
166
+ /** ho_status 用:一行状态,压缩与换任各说一句,未授权的写「无授权」。 */
167
+ export function formatConsentLine(rows) {
168
+ const hit = new Map((rows || []).map((r) => [r.kind, r]));
169
+ const parts = CONSENT_KINDS.map((k) => {
170
+ const r = hit.get(k);
171
+ if (!r) return KIND_LABEL[k] + ' 无授权';
172
+ const min = Math.max(1, Math.ceil(r.leftMs / 60000));
173
+ return KIND_LABEL[k] + ' 剩余 ' + min + ' 分钟(至 ' + untilText(r.until) + ',一次性)';
174
+ });
175
+ return parts.join(';');
176
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * 文档定位 map(docmap):每个会话(全局、每个项目、每个杂项任务)一份文件,写「本类别有哪些文档、各自一句话介绍、什么时候该读」。
3
+ *
4
+ * 定位:只指路,不规定读多少。是否读正文由被注入的会话自己判断;
5
+ * 空 map 不注入,提示段其余文字一字不动。文件放在 _Control_Center/records/docmap/,
6
+ * 随工作区迁移;写入者必须是在任会话(由 tools/ho_docmap.mjs 用在任登记表判定)。
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { NL, readUtf8, isFile, isDir } from './text.mjs';
11
+
12
+ export const DOCMAP_DIR = path.join('_Control_Center', 'records', 'docmap');
13
+ export const COLUMNS = ['路径', '一句话介绍', '何时读'];
14
+
15
+ /** 由会话角色推导所属的登记(全局/项目/杂项);普通会话没有 map。 */
16
+ export function lineageOf(info) {
17
+ const role = info && info.role ? info.role : '';
18
+ if (role === 'global') return { kind: 'global', project: '', task: '', key: '全局' };
19
+ if (role === 'project' && info.project) return { kind: 'project', project: String(info.project), task: '', key: String(info.project) };
20
+ if (role === 'misc' && info.task) return { kind: 'misc', project: '', task: String(info.task), key: String(info.task) };
21
+ return null;
22
+ }
23
+
24
+ export function lineageLabel(info) {
25
+ const lin = lineageOf(info);
26
+ if (!lin) return '';
27
+ if (lin.kind === 'project') return '项目「' + lin.project + '」';
28
+ if (lin.kind === 'misc') return '杂项任务「' + lin.task + '」';
29
+ return '全局';
30
+ }
31
+
32
+ /** 文件名:global.md / project-<项目名>.md / misc-<任务名>.md(相对工作区根)。 */
33
+ export function docmapRelPath(info) {
34
+ const lin = lineageOf(info);
35
+ if (!lin) return null;
36
+ const stem = lin.kind === 'global' ? 'global' : lin.kind === 'project' ? 'project-' + lin.project : 'misc-' + lin.task;
37
+ return path.join(DOCMAP_DIR, stem + '.md');
38
+ }
39
+
40
+ export function docmapAbsPath(ws, info) {
41
+ const rel = docmapRelPath(info);
42
+ return rel ? path.join(String(ws || ''), rel) : null;
43
+ }
44
+
45
+ function stripTicks(s) {
46
+ return String(s || '').replace(/`/g, '').trim();
47
+ }
48
+
49
+ /** 解析表格行:跳过表头与分隔行;三列之外的多余列合并进第三列。 */
50
+ export function parseDocmap(text) {
51
+ const entries = [];
52
+ for (const raw of String(text || '').split(/\r?\n/)) {
53
+ const line = raw.trim();
54
+ if (!line.startsWith('|')) continue;
55
+ const cells = line.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => stripTicks(c));
56
+ if (!cells.length) continue;
57
+ if (cells.length < 2) continue;
58
+ if (cells[0] === COLUMNS[0]) continue; // 表头
59
+ if (cells.every((c) => /^[-:\s]*$/.test(c))) continue; // 分隔行
60
+ const p = cells[0];
61
+ if (!p) continue;
62
+ const about = cells[1] || '';
63
+ const when = cells.length > 3 ? cells.slice(2).join(',') : (cells[2] || '');
64
+ entries.push({ path: p, about, when });
65
+ }
66
+ return entries;
67
+ }
68
+
69
+ export function readDocmap(ws, info) {
70
+ const file = docmapAbsPath(ws, info);
71
+ if (!file) return { file: null, exists: false, entries: [] };
72
+ if (!isFile(file)) return { file, exists: false, entries: [] };
73
+ let text = '';
74
+ try { text = readUtf8(file); } catch { return { file, exists: true, entries: [], error: '读取失败' }; }
75
+ return { file, exists: true, entries: parseDocmap(text) };
76
+ }
77
+
78
+ /** 渲染文件正文(写盘用):标题一行、表格、一句判定。 */
79
+ export function renderDocmap(info, entries) {
80
+ const label = lineageLabel(info);
81
+ const lines = ['# 文档定位(' + label + ')', '', '| ' + COLUMNS.join(' | ') + ' |', '|' + COLUMNS.map(() => '---').join('|') + '|'];
82
+ for (const e of entries) lines.push('| `' + e.path + '` | ' + e.about + ' | ' + (e.when || '') + ' |');
83
+ lines.push('');
84
+ lines.push('只指路:是否读正文由你判断,不要无目的通读。');
85
+ return lines.join(NL) + NL;
86
+ }
87
+
88
+ /**
89
+ * 注入提示段的文本:空 map 返回空串(不占位)。
90
+ * 形如「文档定位(项目「X」;只指路…)」+ 每条一行,路径加反引号。
91
+ */
92
+ export function docmapBlock(ws, info) {
93
+ const map = readDocmap(ws, info);
94
+ if (!map.entries.length) return '';
95
+ const lines = ['文档定位(' + lineageLabel(info) + ';只指路,是否读正文由你判断,不要无目的通读)'];
96
+ for (const e of map.entries) {
97
+ lines.push('- ' + e.path + (e.about ? ' —— ' + e.about : '') + (e.when ? '(' + e.when + ')' : ''));
98
+ }
99
+ return lines.join(NL);
100
+ }
101
+
102
+ /** 路径是否合法且存在:只收工作区相对路径,不得越出工作区。 */
103
+ export function pathProblem(ws, p) {
104
+ const raw = String(p || '').trim();
105
+ if (!raw) return '路径为空';
106
+ if (/^[a-zA-Z]:/.test(raw) || raw.startsWith('/') || raw.startsWith('\\')) return '要写工作区相对路径,不写绝对路径';
107
+ const norm = path.normalize(raw).replace(/\\/g, '/');
108
+ if (norm === '..' || norm.startsWith('../')) return '不得指向工作区之外';
109
+ const abs = path.join(String(ws || ''), norm);
110
+ if (!isFile(abs) && !isDir(abs)) return '不存在:' + norm;
111
+ return '';
112
+ }
113
+
114
+ /** 逐条校验:返回 [{ path, problem }]。 */
115
+ export function validateEntries(ws, entries) {
116
+ const bad = [];
117
+ for (const e of entries) {
118
+ const problem = pathProblem(ws, e.path);
119
+ if (problem) bad.push({ path: e.path, problem });
120
+ }
121
+ return bad;
122
+ }
123
+
124
+ /** 闸门用:检查所有 map 的路径存在性(只判存在,不判字数)。 */
125
+ export function checkDocmaps(ws) {
126
+ const dir = path.join(String(ws || ''), DOCMAP_DIR);
127
+ const rows = [];
128
+ let files = [];
129
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort(); } catch { return { dir, rows, missing: 0 }; }
130
+ for (const name of files) {
131
+ const file = path.join(dir, name);
132
+ const stem = name.slice(0, -3);
133
+ let info = null;
134
+ if (stem === 'global') info = { role: 'global' };
135
+ else if (stem.startsWith('project-')) info = { role: 'project', project: stem.slice(8) };
136
+ else if (stem.startsWith('misc-')) info = { role: 'misc', task: stem.slice(5) };
137
+ let entries = [];
138
+ try { entries = parseDocmap(readUtf8(file)); } catch { entries = []; }
139
+ const bad = validateEntries(ws, entries);
140
+ rows.push({ name, label: info ? lineageLabel(info) : '(文件名不符合规范)', entries: entries.length, bad });
141
+ }
142
+ return { dir, rows, missing: rows.reduce((a, r) => a + r.bad.length, 0) };
143
+ }
package/src/lib/gates.mjs CHANGED
@@ -4,6 +4,7 @@ import { readUtf8, isFile, isDir } from './text.mjs';
4
4
  import fs from 'node:fs';
5
5
  import { charterPath, canonicalBlock, TITLE_KEY, WATER_KEY } from './charter.mjs';
6
6
  import { readProfile, listProfiles } from './profile.mjs';
7
+ import { checkDocmaps } from './docmap.mjs';
7
8
 
8
9
  /**
9
10
  * 读 Registry.md 的「形态」列 → { 项目名: 形态 }。
@@ -61,7 +62,6 @@ function libraryRoots(ws) {
61
62
  return out;
62
63
  }
63
64
 
64
- /** 同步复核:返回 { libraries, total, missing, verdict, ok }。 */
65
65
  /** 插件内置的 Charter 骨架(dist/templates/Charter.skeleton.md);工作区那份只是覆盖。 */
66
66
  function bundledCharter() {
67
67
  try {
@@ -72,6 +72,7 @@ function bundledCharter() {
72
72
  }
73
73
  }
74
74
 
75
+ /** 同步复核:返回 { libraries, total, missing, verdict, ok }。 */
75
76
  export function syncCheck(ws) {
76
77
  const charter = charterPath(ws);
77
78
  // 口径:**插件自带的这套默认规则才是本体**,工作区里的同名文件是「本地化覆盖」。
@@ -145,4 +146,27 @@ export function renderSyncCheck(res) {
145
146
  out.push('');
146
147
  out.push('判定:' + res.verdict);
147
148
  return out;
149
+ }
150
+
151
+ /** 文档定位 map:只检查表内路径是否仍然存在(不判字数、不判内容)。 */
152
+ export function docmapReport(ws) {
153
+ return checkDocmaps(ws);
154
+ }
155
+
156
+ export function renderDocmapCheck(res) {
157
+ const out = [];
158
+ out.push('文档定位 map 目录 ' + res.dir);
159
+ if (!res.rows.length) {
160
+ out.push(' 还没有任何 map(空 map 不注入提示段,属正常)');
161
+ out.push('判定:无需检查');
162
+ return out;
163
+ }
164
+ for (const r of res.rows) {
165
+ out.push(' ' + r.name + ' ' + r.label + ' ' + r.entries + ' 条 ' + (r.bad.length ? '失效 ' + r.bad.length + ' 条' : '路径全部存在'));
166
+ for (const b of r.bad.slice(0, 3)) out.push(' 失效:' + b.path + ' —— ' + b.problem);
167
+ }
168
+ out.push('判定:' + (res.missing === 0
169
+ ? '全部落位。'
170
+ : res.missing + ' 条路径失效 —— 由该类别在任会话用 ho_docmap(action="write") 更新。'));
171
+ return out;
148
172
  }