@mingmaple/dsh-handover 0.9.22 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +117 -113
  2. package/docs/migrate.md +52 -41
  3. package/docs/releases/0.9.12.md +2 -3
  4. package/docs/releases/0.9.14.md +1 -5
  5. package/docs/releases/0.9.22.md +1 -1
  6. package/docs/releases/1.0.0.md +33 -0
  7. package/docs/troubleshoot.md +221 -61
  8. package/package.json +5 -2
  9. package/scripts/behavior.mjs +398 -0
  10. package/scripts/blank-dsh.mjs +12 -2
  11. package/scripts/blank-smoke.mjs +95 -44
  12. package/scripts/check-docs.mjs +7 -0
  13. package/scripts/portability.mjs +71 -0
  14. package/scripts/style-gate.mjs +571 -0
  15. package/src/client.js +1028 -994
  16. package/src/index.mjs +273 -109
  17. package/src/lib/absrefs.mjs +1 -1
  18. package/src/lib/api.mjs +9 -3
  19. package/src/lib/archive.mjs +1 -1
  20. package/src/lib/brief.mjs +8 -6
  21. package/src/lib/config.mjs +21 -6
  22. package/src/lib/docmap.mjs +5 -8
  23. package/src/lib/drift.mjs +1 -1
  24. package/src/lib/events.mjs +1 -1
  25. package/src/lib/facts.mjs +19 -12
  26. package/src/lib/gates.mjs +12 -12
  27. package/src/lib/global-task.mjs +69 -0
  28. package/src/lib/guide.mjs +80 -75
  29. package/src/lib/handover-io.mjs +69 -6
  30. package/src/lib/identity.mjs +330 -0
  31. package/src/lib/legacy.mjs +89 -0
  32. package/src/lib/lint.mjs +14 -11
  33. package/src/lib/manual.mjs +117 -80
  34. package/src/lib/mover.mjs +18 -14
  35. package/src/lib/office.mjs +42 -2
  36. package/src/lib/pack-ops.mjs +10 -10
  37. package/src/lib/pathkey.mjs +24 -0
  38. package/src/lib/phase.mjs +8 -13
  39. package/src/lib/profile.mjs +1 -1
  40. package/src/lib/reminder.mjs +110 -0
  41. package/src/lib/role.mjs +1 -1
  42. package/src/lib/scaffold.mjs +30 -10
  43. package/src/lib/schedule.mjs +5 -5
  44. package/src/lib/session-create.mjs +2 -2
  45. package/src/lib/session-ops.mjs +76 -47
  46. package/src/lib/sessions.mjs +40 -2
  47. package/src/lib/sevenzip.mjs +10 -1
  48. package/src/lib/status.mjs +337 -336
  49. package/src/lib/succession.mjs +14 -14
  50. package/src/lib/system.mjs +364 -0
  51. package/src/templates/Changelog.skeleton.md +3 -3
  52. package/src/templates/Charter.skeleton.md +13 -9
  53. package/src/templates/Handover.global.skeleton.md +20 -13
  54. package/src/templates/Handover.template.md +89 -0
  55. package/src/templates/Plan.skeleton.md +34 -0
  56. package/src/templates/Prompt_Templates.md +91 -10
  57. package/src/templates/Runtime_Manual.md +254 -139
  58. package/src/templates/System.skeleton.md +31 -0
  59. package/src/templates/Systems.global.skeleton.md +13 -0
  60. package/src/templates/rules/Delegation_Rules.md +45 -0
  61. package/src/templates/rules/Handover_Rules.md +46 -24
  62. package/src/templates/rules/Migration_Rules.md +7 -7
  63. package/src/templates/rules/Misc_Task_Standard.md +17 -10
  64. package/src/templates/rules/Writing_Rules.md +33 -16
  65. package/src/templates/standards/README.md +19 -7
  66. package/src/templates/standards//345/206/231/344/275/234/350/247/204/350/214/203/README.md +120 -0
  67. package/src/templates/standards//345/206/231/344/275/234/350/247/204/350/214/203//344/276/235/346/215/256_/350/256/272/346/226/207.md +389 -0
  68. package/src/tools/ho_backup.mjs +8 -8
  69. package/src/tools/ho_changelog.mjs +4 -4
  70. package/src/tools/ho_charter.mjs +7 -6
  71. package/src/tools/ho_checkpoint.mjs +34 -31
  72. package/src/tools/ho_docmap.mjs +135 -49
  73. package/src/tools/ho_gate.mjs +9 -4
  74. package/src/tools/ho_handover.mjs +61 -35
  75. package/src/tools/ho_office.mjs +45 -4
  76. package/src/tools/ho_profile.mjs +3 -3
  77. package/src/tools/ho_sessions.mjs +43 -22
  78. package/src/tools/ho_status.mjs +14 -9
  79. package/src/tools/ho_succeed.mjs +3 -3
  80. package/src/tools/ho_workspace.mjs +83 -51
  81. package/src/tools/peer.mjs +219 -8
@@ -26,7 +26,23 @@ process.env.HOME = BLANK;
26
26
  const { apply, schedDepsFor } = await import(pathToFileURL(path.join(dist, 'index.mjs')).href);
27
27
  const reg = { tools: [], sections: [], logs: [], listeners: {} };
28
28
  const ctx = {
29
- tools: { register: (t, o) => { reg.tools.push(t); (reg.toolOpts = reg.toolOpts || []).push(o); }, restrict() { }, resolve: () => ({}), guard: (fn) => { reg.guardFn = fn; } },
29
+ tools: {
30
+ register: (t, o) => { reg.tools.push(t); (reg.toolOpts = reg.toolOpts || []).push(o); },
31
+ restrict() { },
32
+ resolve: () => ({}),
33
+ // 宿主允许注册多个守卫:按注册顺序依次判,任意一个拒绝即拒绝。
34
+ guard: (fn) => {
35
+ reg.guardFns = (reg.guardFns || []).concat([fn]);
36
+ reg.guardFn = (exec) => {
37
+ for (const g of reg.guardFns) {
38
+ let r;
39
+ try { r = g(exec); } catch (e) { return String(e && e.message ? e.message : e); }
40
+ if (r) return r;
41
+ }
42
+ return undefined;
43
+ };
44
+ },
45
+ },
30
46
  systemPrompt: { section: (s) => { reg.sections.push(s); return () => { reg.disposed = (reg.disposed || 0) + 1; }; } },
31
47
  sessionController: {
32
48
  create: async () => ({ sessionId: 'session-deploy-test' }),
@@ -402,11 +418,11 @@ ok(tsU.allow.length === 0 && tsU.deny.length === 14, '普通会话不载入任
402
418
  const rSub = resolveRole({ sessionId: 'session-sub', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库'), subagent: true });
403
419
  ok(rSub.role === 'plain' && rSub.source === 'subagent', '子会话默认按普通处理,不按 cwd 判项目', JSON.stringify(rSub));
404
420
 
405
- // ---------- 文档定位(docmap)与授权提示:写入权限、路径校验、空 map、注入 ----------
421
+ // ---------- 体系注册与授权提示:写入权限、路径校验、空注册、注入、计划 ----------
406
422
  {
407
423
  const { pmDocmapTool } = await import(DIST('tools/ho_docmap.mjs'));
408
424
  const { registerHolder } = await import(DIST('lib/office.mjs'));
409
- const { docmapBlock, docmapRelPath, readDocmap } = await import(DIST('lib/docmap.mjs'));
425
+ const { systemBlock, registryRelPath, readRegistry, planRelPath } = await import(DIST('lib/system.mjs'));
410
426
  const manDm = await import(DIST('lib/manual.mjs'));
411
427
  const { grantConsent, consumedConsent, activeConsentsFor, consentPromptLines } = await import(DIST('lib/consent.mjs'));
412
428
 
@@ -422,37 +438,42 @@ ok(rSub.role === 'plain' && rSub.source === 'subagent', '子会话默认按普
422
438
  fs.writeFileSync(path.join(WS, 'Projects', 'Demo', 'README.md'), 'x', 'utf8');
423
439
  registerHolder(WS, { kind: 'project', project: 'Demo', name: 'Demo1', sessionId: 'session-dm', workspace: WS });
424
440
  const dm = pmDocmapTool(dmEnv);
425
- const wroteDm = await dm.execute({ action: 'write', entries: [{ path: 'Projects/Demo/README.md', about: '项目简介', when: '动手前' }] }, dmExec('session-dm'));
426
- ok(/已写入/.test(wroteDm.text), 'docmap:在任者写自己的 map 应成功', wroteDm.text.split('\n')[0]);
427
- const readBack = readDocmap(WS, dmInfo);
428
- ok(readBack.exists && readBack.entries.length === 1 && readBack.entries[0].path === 'Projects/Demo/README.md', 'docmap:写盘后可解析回一条', JSON.stringify(readBack.entries));
429
- const dmBlock = docmapBlock(WS, dmInfo);
430
- ok(/文档定位/.test(dmBlock) && /Projects\/Demo\/README\.md/.test(dmBlock) && /动手前/.test(dmBlock), 'docmap:注入块含路径与时机', dmBlock);
431
- const injectedDm = manDm.scopedManual(dmInfo, { ws: WS, docmap: dmBlock });
432
- ok(injectedDm.indexOf('文档定位') !== -1 && injectedDm.split('文档定位').length === 2, 'docmap:注入段只出现一次', String(injectedDm.split('文档定位').length - 1));
441
+ const wroteDm = await dm.execute({ action: 'write', entries: [{ path: 'Projects/Demo/README.md', about: '项目简介', when: '动手前', section: 'files' }] }, dmExec('session-dm'));
442
+ ok(/已写入/.test(wroteDm.text), '体系注册:在任者写自己的注册应成功', wroteDm.text.split('\n')[0]);
443
+ const readBack = readRegistry(WS, dmInfo);
444
+ ok(readBack.exists && readBack.data.files.length === 1 && readBack.data.files[0].path === 'Projects/Demo/README.md', '体系注册:写盘后可解析回一条', JSON.stringify(readBack.data.files));
445
+ const sysBlock = systemBlock(WS, dmInfo);
446
+ ok(/体系注册/.test(sysBlock) && /Projects\/Demo\/README\.md/.test(sysBlock) && /动手前/.test(sysBlock), '体系注册:注入块含路径与时机', sysBlock);
447
+ const injectedDm = manDm.scopedManual(dmInfo, { ws: WS, system: sysBlock });
448
+ ok(injectedDm.indexOf('体系注册') !== -1 && injectedDm.split('体系注册').length === 2, '体系注册:注入段只出现一次', String(injectedDm.split('体系注册').length - 1));
433
449
  let missErr = '';
434
450
  try { await dm.execute({ action: 'write', entries: [{ path: 'Projects/Demo/nope.md', about: 'x' }] }, dmExec('session-dm')); } catch (e) { missErr = String(e && e.message ? e.message : e); }
435
- ok(/未写入/.test(missErr) && /nope\.md/.test(missErr), 'docmap:路径不存在应整条拒绝并点名', missErr);
436
- ok(readDocmap(WS, dmInfo).entries.length === 1, 'docmap:拒绝后原 map 不得被改');
451
+ ok(/未写入/.test(missErr) && /nope\.md/.test(missErr), '体系注册:路径不存在应整条拒绝并点名', missErr);
452
+ ok(readRegistry(WS, dmInfo).data.files.length === 1, '体系注册:拒绝后原注册不得被改');
453
+ const planWrote = await dm.execute({ action: 'plan', mode: 'write', text: '# 行动计划\n\n## 1 目标\n试跑\n' }, dmExec('session-dm'));
454
+ ok(/已写入计划/.test(planWrote.text), '行动计划:可写入任务根', planWrote.text.split('\n')[0]);
455
+ ok(fs.existsSync(path.join(WS, planRelPath(WS, dmInfo))), '行动计划:落在任务根 PLAN.md');
456
+ const planRead = await dm.execute({ action: 'plan', mode: 'read' }, dmExec('session-dm'));
457
+ ok(/行动计划/.test(planRead.text), '行动计划:可读回');
437
458
  registerHolder(WS, { kind: 'project', project: 'Demo', name: 'Demo2', sessionId: 'session-dm2', workspace: WS });
438
459
  let oldErr = '';
439
460
  try { await dm.execute({ action: 'write', entries: [{ path: 'Projects/Demo/README.md', about: 'x' }] }, dmExec('session-dm')); } catch (e) { oldErr = String(e && e.message ? e.message : e); }
440
- ok(/只有在任会话可以写/.test(oldErr), 'docmap:已交出的旧会话只读', oldErr);
461
+ ok(/只有在任会话可以写/.test(oldErr), '体系注册:已交出的旧会话只读', oldErr);
441
462
  const emptyDm = await dm.execute({ action: 'write', entries: [] }, dmExec('session-dm2'));
442
- ok(/空 map/.test(emptyDm.text), 'docmap:空 entries 应写明不注入', emptyDm.text);
443
- ok(docmapBlock(WS, dmInfo) === '', 'docmap:空 map 注入块为空串');
444
- ok(docmapRelPath(dmInfo).replace(/\\/g, '/') === '_Control_Center/records/docmap/project-Demo.md', 'docmap:文件路径按会话命名', docmapRelPath(dmInfo));
463
+ ok(/已写入/.test(emptyDm.text) && /目录 0 条、规范 0 条/.test(emptyDm.text), '体系注册:空 entries 写入空注册', emptyDm.text.split('\n')[0]);
464
+ ok(systemBlock(WS, dmInfo).indexOf('体系注册(项目「Demo」)') !== -1, '体系注册:空注册只注入标题一行');
465
+ ok(registryRelPath(dmInfo).replace(/\\/g, '/') === '_Project_Meta/Demo/System.md', '体系注册:文件按任务独立存放', registryRelPath(dmInfo));
445
466
 
446
467
  grantConsent('session-dm2', 'blank-smoke', 'compact');
447
468
  const actDm = activeConsentsFor('session-dm2');
448
469
  const consentLines = consentPromptLines(actDm);
449
470
  ok(actDm.length === 1 && consentLines.length === 1 && /用户已授权压缩/.test(consentLines[0]), '授权:有效期内应生成一行提示', JSON.stringify(consentLines));
450
471
  ok(activeConsentsFor('session-other').length === 0, '授权:别的会话看不到', JSON.stringify(activeConsentsFor('session-other')));
451
- const withConsent = manDm.scopedManual(dmInfo, { ws: WS, docmap: '', consents: actDm });
472
+ const withConsent = manDm.scopedManual(dmInfo, { ws: WS, system: '', consents: actDm });
452
473
  ok(withConsent.indexOf('用户已授权压缩') !== -1, '授权:提示段应带那一行', withConsent.slice(-160));
453
474
  consumedConsent('session-dm2', 'compact');
454
475
  ok(consentPromptLines(activeConsentsFor('session-dm2')).length === 0, '授权:用掉后提示消失(不占位)');
455
- const afterUse = manDm.scopedManual(dmInfo, { ws: WS, docmap: '', consents: activeConsentsFor('session-dm2') });
476
+ const afterUse = manDm.scopedManual(dmInfo, { ws: WS, system: '', consents: activeConsentsFor('session-dm2') });
456
477
  ok(afterUse.indexOf('已授权') === -1, '授权:用掉后提示段不再有该行');
457
478
  }
458
479
 
@@ -576,8 +597,11 @@ const projExec = { agent: projAgent };
576
597
  ok(typeof projGatePack === 'function' && projGatePack(projExec) === false, '项目角色不可见 ho_sessions(每步条件)');
577
598
  ok(typeof projGateHand === 'function' && projGateHand(projExec) === true, '项目角色可见 ho_handover(每步条件)');
578
599
  ok(gateFor('ho_changelog')(projExec) === false, '项目角色不可见 ho_changelog');
579
- ok(calls.sections.length === 1 && calls.sections[0].name === 'dsh-handover:manual', '应注册同名提示段遮蔽兜底段');
580
- const secNorm = String(calls.sections[0].text).replace(/\\/g, '/');
600
+ ok(calls.sections.length >= 1 && calls.sections.some((s) => s.name === 'dsh-handover:manual'), '应注册同名提示段遮蔽兜底段', JSON.stringify(calls.sections.map((s) => s.name)));
601
+ const projSec = calls.sections.find((s) => s.name === 'dsh-handover:manual');
602
+ const projText = String(projSec && projSec.text || '');
603
+ ok(!calls.sections.some((s) => s.name === 'dsh-handover:reminder'), '不再注册动态提醒段(提醒改走工具结果尾注入)');
604
+ const secNorm = projText.replace(/\\/g, '/');
581
605
  ok(secNorm.indexOf('Projects/Demo') !== -1 && secNorm.indexOf('_Project_Meta/Demo/Handover.md') !== -1, '项目提示段应含自己的路径', secNorm.slice(0, 200));
582
606
  ok(secNorm.indexOf('任务完成不报全局') !== -1, '项目提示段应写明任务完成不向全局报告', secNorm.slice(0, 400));
583
607
  ok(secNorm.indexOf('任务达成') === -1, '项目提示段不得再出现任务达成');
@@ -585,8 +609,11 @@ ok(secNorm.indexOf('可直接 peer') !== -1, '项目提示段应允许会话之
585
609
  ok(secNorm.indexOf('resume=wait') !== -1, '项目提示段应说明收尾压缩不唤醒');
586
610
  ok(secNorm.indexOf('_Control_Center/standards/') !== -1, '项目提示段应给出通用规范的查找范围', secNorm.slice(-240));
587
611
  ok(secNorm.indexOf('把某某写成规范') !== -1 && secNorm.indexOf('本会话所在的工作区') !== -1 && secNorm.indexOf('写入通用规范库') !== -1 && secNorm.indexOf('报全局') !== -1, '项目提示段应写明未指明写本库、通用规范库报全局', secNorm.slice(-400));
588
- console.log('项目提示段 token 实测:' + budget.estimateTokens(calls.sections[0].text) + '(参考值 ' + budget.TOKEN_BUDGET.projectTokens + ',不判失败)');
589
- ok(calls.sections[0].text.indexOf('子智能体') === -1, '项目段不得携带子智能体派发纪律(不属于本插件)');
612
+ console.log('项目提示段 token 实测:' + budget.estimateTokens(projText) + '(参考值 ' + budget.TOKEN_BUDGET.projectTokens + ',不判失败)');
613
+ ok(projText.indexOf('纯窗口是否可能超过 40%') !== -1, '项目段应携带 1.0 子智能体判定线(纯窗口 40%)');
614
+ ok(secNorm.indexOf('写计划就把拆子智能体安排进去') !== -1, '项目段应写明超过 40% 时计划里引入子智能体', secNorm.slice(0, 400));
615
+ ok(secNorm.indexOf('超过时,写计划就把拆子智能体安排进去') < secNorm.indexOf('写行动计划 PLAN.md(任务根)'), '项目段应是先判纯窗口、再写一次计划', secNorm.slice(0, 400));
616
+ ok(secNorm.indexOf('子智能体') !== -1 && secNorm.indexOf('并发 ≤3') !== -1 && secNorm.indexOf('交回原作者续用') !== -1, '项目段应携带子智能体硬线(并发与续用)', secNorm.slice(0, 500));
590
617
  // 真实工作区路径 + 迁移提示是最坏情形,参考值另设一档
591
618
  {
592
619
  const man = await import(pathToFileURL(path.join(dist, 'lib', 'manual.mjs')).href);
@@ -615,11 +642,16 @@ const globAgent = {
615
642
  };
616
643
  for (const cb of listeners) cb({ agent: globAgent, source: 'fresh' });
617
644
  ok(gCalls.restrict === 0, '全局角色不应被裁剪', String(gCalls.restrict));
618
- ok(gCalls.sections.length === 1 && gCalls.sections[0].name === 'dsh-handover:manual', '全局也应遮蔽兜底段');
645
+ ok(gCalls.sections.some((s) => s.name === 'dsh-handover:manual'), '全局也应遮蔽兜底段', JSON.stringify(gCalls.sections.map((s) => s.name)));
646
+ const globSec = gCalls.sections.find((s) => s.name === 'dsh-handover:manual');
647
+ const globText = String(globSec && globSec.text || '');
648
+ ok(!gCalls.sections.some((s) => s.name === 'dsh-handover:reminder'), '全局同样不注册动态提醒段');
619
649
  // 常驻段只放每轮判定,一次性流程下沉手册;参考值按加权 token 计,只打印实测值。
620
- console.log('全局提示段 token 实测:' + budget.estimateTokens(gCalls.sections[0].text) + '(参考值 ' + budget.TOKEN_BUDGET.globalTokens + ',不判失败)');
621
- ok(gCalls.sections[0].text.indexOf('反模式') === -1, '全局段不得携带反模式纪律(不属于本插件)');
622
- ok(gCalls.sections[0].text.indexOf('send_message 续用') === -1, '全局段不得携带子智能体续用纪律(不属于本插件)');
650
+ console.log('全局提示段 token 实测:' + budget.estimateTokens(globText) + '(参考值 ' + budget.TOKEN_BUDGET.globalTokens + ',不判失败)');
651
+ ok(globText.indexOf('纯窗口是否可能超过 40%') !== -1, '全局段应携带 1.0 子智能体判定线(纯窗口 40%)');
652
+ ok(globText.indexOf('写计划就把拆子智能体安排进去') < globText.indexOf('写行动计划 PLAN.md(任务根)'), '全局段应是先判纯窗口、再写一次计划');
653
+ ok(globText.indexOf('并发 ≤3') !== -1 && globText.indexOf('交回原作者续用') !== -1, '全局段应携带子智能体硬线(并发与续用)');
654
+ ok(globText.indexOf('list_agents') === -1, '全局段不携带子智能体检索细节(细则在运行时手册)');
623
655
  // 真机最坏情形:长路径 + 迁移提示行
624
656
  {
625
657
  const man2 = await import(pathToFileURL(path.join(dist, 'lib', 'manual.mjs')).href);
@@ -628,23 +660,23 @@ ok(gCalls.sections[0].text.indexOf('send_message 续用') === -1, '全局段不
628
660
  const gWorst = man2.globalManual({ ws: longWs2, move: { phase: 'switched', dst: otherWs2, src: longWs2 } });
629
661
  console.log('全局最坏情形提示段 token 实测:' + budget.estimateTokens(gWorst) + '(参考值 ' + budget.TOKEN_BUDGET.globalWorstTokens + ',不判失败)');
630
662
  }
631
- ok(gCalls.sections[0].text.indexOf('前置提示词') === -1, '插件角色段不得再携带前置提示词(由用户全局前置提示词注入)');
632
- // 结构只保留会话定位、权限、节奏、交接与按需取用五节
663
+ ok(globText.indexOf('前置提示词') === -1, '插件角色段不得再携带前置提示词(由用户全局前置提示词注入)');
664
+ // 结构:1.0 为六节(身份、权限、任务流程、上下文与压缩、交接与体系、按需取用)
633
665
  {
634
- const g = gCalls.sections[0].text;
666
+ const g = globText;
635
667
  const cnt = (k) => g.split(k).length - 1;
636
- const secs = ['一、身份', '二、权限', '三、节奏', '四、交接', '五、按需取用'];
668
+ const secs = ['一、身份', '二、权限', '三、任务流程', '四、上下文与压缩', '五、交接与体系', '六、按需取用'];
637
669
  const bad = secs.filter((k) => cnt(k) !== 1);
638
- ok(bad.length === 0, '全局段五节骨架应各出现一次', bad.join(','));
639
- const absent = ['子智能体', '反模式', '自检'];
670
+ ok(bad.length === 0, '全局段六节骨架应各出现一次', bad.join(','));
671
+ const absent = ['反模式', '自检'];
640
672
  const still = absent.filter((k) => g.indexOf(k) !== -1);
641
673
  ok(still.length === 0, '插件角色段不得携带非会话约束', still.join(','));
642
- ok(cnt('换任') <= 2, '「换任」在全局段出现不应超过两处', String(cnt('换任')));
674
+ ok(cnt('换任') <= 4, '「换任」在全局段出现不应超过四处', String(cnt('换任')));
643
675
  }
644
676
  ok(gCalls.injected === 0, '全局不再重复注入工作流(提示段里已有)', String(gCalls.injected));
645
677
  // 分层护栏——常驻段只留判定,工作流细节必须留在手册里
646
678
  {
647
- const g0 = String(gCalls.sections[0].text);
679
+ const g0 = globText;
648
680
  ok(g0.indexOf('ho_backup') === -1 && g0.indexOf('move-probe') === -1, '常驻段不得回灌工作流细节(应放手册)');
649
681
  ok(g0.indexOf('ho_charter(doc="runtime", section=…') !== -1, '常驻段应给出按节取用的指针');
650
682
  ok(g0.indexOf('_Control_Center/standards/') !== -1, '全局段应给出通用规范的查找范围', g0.slice(-240));
@@ -669,7 +701,8 @@ ok(gCalls.injected === 0, '全局不再重复注入工作流(提示段里已
669
701
  const uiFiles = listFiles(path.join(repo, 'src'), ['.mjs', '.js'], { skip: ['templates'] });
670
702
  const docFiles = [path.join(repo, 'README.md')]
671
703
  .concat(listFiles(path.join(repo, 'src', 'templates'), ['.md']))
672
- .filter((f) => f.indexOf('Writing_Rules.md') === -1);
704
+ // 写作规范正文与论文属规范性文本,条款本身会引用被禁词;Writing_Rules 是用户内容。
705
+ .filter((f) => f.indexOf('Writing_Rules.md') === -1 && f.indexOf(path.join('templates', 'standards')) === -1);
673
706
  const uiHits = scanFiles(uiFiles, { ui: true });
674
707
  const docHits = scanFiles(docFiles, { ui: false });
675
708
  const fmt = (hs) => hs.slice(0, 6).map((h) => path.relative(repo, h.file) + ':' + h.line + ' ' + h.rules.join('、')).join(' | ');
@@ -680,13 +713,13 @@ ok(gCalls.injected === 0, '全局不再重复注入工作流(提示段里已
680
713
  }
681
714
 
682
715
  // 按步路径(0.2.2 起):创建即按角色登记提示段;工具可见性由每步条件算,不再调用 restrict。
683
- const unrCalls = { restrict: 0, section: null };
716
+ const unrCalls = { restrict: 0, section: null, sections: [] };
684
717
  const unrAgent = {
685
718
  id: 'session-unr',
686
719
  session: { header: { id: 'session-unr', title: '未命名', cwd: '/tmp/x' } },
687
720
  ctx: {
688
721
  tools: { restrict: () => { unrCalls.restrict++; return () => { }; } },
689
- systemPrompt: { section: (o) => { unrCalls.section = o; return () => { }; } },
722
+ systemPrompt: { section: (o) => { unrCalls.section = o; unrCalls.sections.push(o); return () => { }; } },
690
723
  },
691
724
  inject: () => { throw new Error('未设角色的会话不应写历史'); },
692
725
  };
@@ -698,7 +731,7 @@ ok(!unrCalls.section, '普通会话不注册角色提示段');
698
731
  // 开轮前核对:休眠期间改了角色,下一轮开始时补装提示段(工具由每步条件即时生效)。
699
732
  writeRole({}, 'session-unr', { role: 'project', project: 'Demo', source: 'user' });
700
733
  for (const cb of ((reg.listeners && reg.listeners['agent/status']) || [])) cb({ agent: unrAgent, status: 'running' });
701
- ok(!!unrCalls.section && /项目/.test(String(unrCalls.section.text)), '开轮前核对应按新角色补装提示段', JSON.stringify(unrCalls.section && unrCalls.section.name));
734
+ ok(unrCalls.sections.some((s) => s.name === 'dsh-handover:manual' && /项目/.test(String(s.text))), '开轮前核对应按新角色补装提示段', JSON.stringify(unrCalls.sections.map((s) => s.name)));
702
735
 
703
736
  // 旧内核(没有 tools.resolve)才走 restrict:创建时按普通收窄 14 个,改角色后开轮前换成 3 个。
704
737
  {
@@ -836,7 +869,7 @@ const FIXTURE = {
836
869
  { sid: 'session-l', title: 'Demo2', role: 'project', roleLabel: '项目', source: 'cwd', project: 'Demo2', task: '', wsKey: 'w1', wsTitle: '全局', wsPath: 'C:/ws', pct: 50, state: 'idle', active: false, onduty: false, seen: '2026-10-22 20:00 +0800' },
837
870
  ].concat(plainRows),
838
871
  notify: [{ sid: 'session-y', key: 'Demo1', name: 'Demo1' }], hot: 2, unknown: 0, cutoff: '2026-11-07 09:25 +0800', silentHours: 12,
839
- verdict: '2 个会话已进预警区(≥32%),应先收齐交接再换会话',
872
+ verdict: '2 个会话已过 35% 线,应先收齐交接再换会话',
840
873
  },
841
874
  consent: [{ sessionId: 'session-y', leftMs: 120000, kind: 'compact', onduty: true }],
842
875
  notes: [],
@@ -917,10 +950,10 @@ const roleDetails = [];
917
950
  if (n.type === 'details') roleDetails.push(n);
918
951
  (n.kids || []).forEach(walk);
919
952
  })(roleCard);
920
- ok(roleDetails.length === 2, '会话角色应按工作区分组(全局+未归属)', String(roleDetails.length));
953
+ ok(roleDetails.length === 2, '会话角色应按工作区分组(全局+未分组)', String(roleDetails.length));
921
954
  ok(roleDetails.every((d) => d.props.open !== true), '工作区分组默认收起');
922
955
  const roleText = textOf(roleCard).join(' ');
923
- ok(roleText.indexOf('全局(3 个会话)') !== -1 && roleText.indexOf('未归属工作区') !== -1, '分组标题应带会话数', roleText.slice(0, 200));
956
+ ok(roleText.indexOf('全局(3 个会话)') !== -1 && roleText.indexOf('未分组(10 个会话)') !== -1, '分组标题应带会话数,未登记会话归入未分组', roleText.slice(0, 200));
924
957
  ok(roleText.indexOf('已归档会话不列出') === -1 && roleText.indexOf('点击「设置角色」当场生效') === -1, '会话角色卡不再堆说明文字,只留标题与分组');
925
958
 
926
959
  // 授权:只列在任会话,两组各一列按钮,用户不必手填 id。
@@ -979,14 +1012,32 @@ ok(clientSrc.indexOf('delWord.trim() !== "删除"') !== -1, '删除旧备份须
979
1012
  // 保留的旧备份不算待处理:迁移卡只在妨碍使用的情形下提示。
980
1013
  ok(clientSrc.indexOf('(st.phase === "switched" && (st.olds || []).length)') === -1, '保留的旧备份不得计入待处理');
981
1014
  ok(clientSrc.indexOf('旧备份保留,不影响使用') !== -1, '迁移完成后应注明旧备份不影响使用');
982
- // 角色顺序:全局、项目、杂项、普通(表格与下拉同序)。
1015
+ // 角色下拉顺序保持全局、项目、杂项、普通;会话行本身按时间倒序,不再按角色排列。
983
1016
  {
984
1017
  const g = clientSrc.indexOf('value: "global"');
985
1018
  const p = clientSrc.indexOf('value: "project"');
986
1019
  const m = clientSrc.indexOf('value: "misc"');
987
1020
  const n = clientSrc.indexOf('value: "plain"');
988
1021
  ok(g !== -1 && p !== -1 && m !== -1 && n !== -1 && g < p && p < m && m < n, '角色下拉应为全局、项目、杂项、普通', JSON.stringify({ g, p, m, n }));
989
- ok(clientSrc.indexOf('ROLE_ORDER') !== -1, '会话角色应按角色顺序排列');
1022
+ ok(clientSrc.indexOf('ROLE_ORDER') === -1 && clientSrc.indexOf('byTime') !== -1, '会话角色组内应按时间倒序,不再按角色排列');
1023
+ }
1024
+
1025
+ // 组内顺序实测:夹具中全局组按时间应为 全局1、Demo1、Demo2。
1026
+ {
1027
+ const first = roleDetails[0];
1028
+ const rows = [];
1029
+ (function collect(n) {
1030
+ if (!n || typeof n !== 'object') return;
1031
+ if (Array.isArray(n)) { n.forEach(collect); return; }
1032
+ if (n.type === 'tr') {
1033
+ const cells = (n.kids || []).filter((k) => k && k.type === 'td');
1034
+ if (cells.length >= 2) rows.push(textOf(cells[0]).join(''));
1035
+ return;
1036
+ }
1037
+ if (typeof n.type === 'function') { collect(n.type(n.props || {})); return; }
1038
+ (n.kids || []).forEach(collect);
1039
+ })(first);
1040
+ ok(JSON.stringify(rows.slice(0, 3)) === JSON.stringify(['全局1', 'Demo1', 'Demo2']), '角色组内会话应按时间倒序排列', JSON.stringify(rows.slice(0, 3)));
990
1041
  }
991
1042
 
992
1043
  // 未接入工作区:一键部署卡片置顶。
@@ -22,6 +22,13 @@ const problems = [];
22
22
  const readmeLines = fs.readFileSync(readme, 'utf8').split(/\r?\n/).length;
23
23
  if (readmeLines > 200) problems.push('README 应不超过 200 行(当前 ' + readmeLines + ' 行)');
24
24
 
25
+ // 1.1 README「为什么需要」一节为用户 2026-11-13 与 2026-11-15 两次指定的正文(例外 E-01):逐字比对,改动即失败
26
+ const WHY_PINNED = '单个会话的上下文越用越多,用到一定程度之后,出错的可能也会随之上升;可若这时压缩上下文,或者写好交接换一个会话,又会丢掉已经积累的信息。面对这样的两难,有没有更好的取舍?\n\n本插件一定能很好地帮助你。来试试吧;不满意,随时关闭插件并删除即可。';
27
+ {
28
+ const m = fs.readFileSync(readme, 'utf8').match(/## 为什么需要\s*\n+([\s\S]*?)\n+## /);
29
+ const got = m ? m[1].trim() : '';
30
+ if (got !== WHY_PINNED) problems.push('README「为什么需要」一节为用户指定正文(E-01),不得改写');
31
+ }
25
32
  // 2. 旧数与旧术语
26
33
  const FORBIDDEN = ['21 个工具', '27 个工具', '未分类', '追加系统提示词', '4,028', '4.6 KB', '每库 24 行', 'CHANGELOG'];
27
34
  // 审阅语境与内部用语:这些词只应出现在桌面审阅材料里,不能进对外文档与版本说明。
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 跨平台可移植性测试(1.0):
3
+ * 1. 路径等价键按平台折叠大小写:Windows 与 macOS 折叠,Linux 保留;
4
+ * 2. 7-Zip 探测覆盖 Windows、macOS、Linux 的常见位置与 PATH;
5
+ * 3. 工作区相对路径一律用 path.sep 组装,不写死分隔符;
6
+ * 4. 迁移助手不依赖 PowerShell 或 bash,链接类型按平台选择;
7
+ * 5. 路径比较不再无条件折叠大小写。
8
+ *
9
+ * 全程只读源码与构建产物,不碰真实工作区与真实 HOME。
10
+ * 用法:node scripts/portability.mjs [--dist <dist 目录>]
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath, pathToFileURL } from 'node:url';
15
+
16
+ const here = path.dirname(fileURLToPath(import.meta.url));
17
+ const repo = path.resolve(here, '..');
18
+ const dist = (() => {
19
+ const i = process.argv.indexOf('--dist');
20
+ return i !== -1 && process.argv[i + 1] ? path.resolve(process.argv[i + 1]) : path.join(repo, 'dist');
21
+ })();
22
+
23
+ const fails = [];
24
+ function ok(cond, msg, extra) {
25
+ if (cond) console.log(' 通过 ' + msg);
26
+ else { fails.push(msg + (extra ? ' → ' + extra : '')); console.log(' 未通过 ' + msg + (extra ? ' → ' + extra : '')); }
27
+ }
28
+ function section(t) { console.log(''); console.log('== ' + t + ' =='); }
29
+ const imp = (rel) => import(pathToFileURL(path.join(dist, rel)).href);
30
+ const read = (rel) => fs.readFileSync(path.join(repo, rel), 'utf8');
31
+
32
+ const { pathKey, samePath, relToAbs } = await imp('lib/pathkey.mjs');
33
+
34
+ section('1 路径等价键');
35
+ ok(pathKey('C:\\Data\\WS\\', 'win32') === 'c:/data/ws', 'Windows 折叠大小写并去分隔符', pathKey('C:\\Data\\WS\\', 'win32'));
36
+ ok(pathKey('C:\\Data\\WS', 'darwin') === 'c:/data/ws', 'macOS 折叠大小写', pathKey('C:\\Data\\WS', 'darwin'));
37
+ ok(pathKey('/data/WS', 'linux') === '/data/WS', 'Linux 保留大小写', pathKey('/data/WS', 'linux'));
38
+ ok(samePath('C:\\A\\b', 'c:/a/B', 'win32') === true, 'Windows 两种写法视为同一路径');
39
+ ok(samePath('/data/WS', '/data/ws', 'linux') === false, 'Linux 大小写不同视为不同路径');
40
+ ok(relToAbs('/root', 'a/b') === path.join('/root', 'a', 'b'), '相对路径按平台分隔符组装');
41
+
42
+ section('2 7-Zip 探测');
43
+ const seven = read('src/lib/sevenzip.mjs');
44
+ for (const p of ['/usr/bin/7z', '/usr/local/bin/7z', '/opt/homebrew/bin/7z', '/usr/bin/7za', '/opt/homebrew/bin/7zz', '7z.exe']) {
45
+ ok(seven.indexOf(p) !== -1, '候选含 ' + p);
46
+ }
47
+ ok(seven.indexOf('BARE') !== -1, 'PATH 上的 7z、7zz、7za 也探测');
48
+
49
+ section('3 路径组装');
50
+ ok(read('src/lib/scaffold.mjs').indexOf(".split('/').join(path.sep)") !== -1, '脚手架用 path.sep 组装相对路径');
51
+ ok(read('src/lib/archive.mjs').indexOf(".split('/').join(path.sep)") !== -1, '归档恢复用 path.sep 组装相对路径');
52
+
53
+ section('4 迁移与链接');
54
+ const mover = read('src/lib/mover.mjs');
55
+ ok(mover.indexOf("process.platform === 'win32' ? 'junction' : 'dir'") !== -1, '链接类型按平台选择');
56
+ ok(mover.indexOf('不依赖 PowerShell') !== -1, '迁移助手不依赖外部 shell');
57
+ ok(mover.indexOf("process.platform === 'win32' ? t.toLowerCase() : t") !== -1, '链接目标比较按平台折叠大小写');
58
+
59
+ section('5 路径比较不再无条件折叠');
60
+ const files = ['src/lib/mover.mjs', 'src/lib/api.mjs', 'src/lib/session-ops.mjs', 'src/lib/status.mjs', 'src/lib/scaffold.mjs', 'src/tools/ho_workspace.mjs'];
61
+ const bad = files.filter((f) => read(f).indexOf(".replace(/\\/+$/, '').toLowerCase()") !== -1);
62
+ ok(bad.length === 0, '以上文件不再写死「转斜杠 + 去尾 + 小写」', bad.join(', '));
63
+ ok(read('src/lib/pathkey.mjs').indexOf('process.platform') !== -1, '大小写折叠集中在 pathkey.mjs');
64
+
65
+ console.log('');
66
+ if (fails.length) {
67
+ console.log('PORTABILITY FAIL ' + fails.length + ' 项:');
68
+ for (const f of fails) console.log(' - ' + f);
69
+ process.exit(1);
70
+ }
71
+ console.log('PORTABILITY OK(跨平台检查全过)');