@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
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 迁移兼容模块(1.0):把旧版文档定位(docmap)并入体系注册。
3
+ *
4
+ * 本模块可整体删除:删除后不影响 1.0 的主实现;用户应先升级到 1.0.0 完成迁移,再升级后续版本。
5
+ * 迁移不删除旧文件:旧目录整体移入 _Archives/_Global/Backups/ 下并带时间标注。
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { NL, readUtf8, isFile, isDir } from './text.mjs';
10
+ import { planRelPath } from './system.mjs';
11
+ import { parseDocmap } from './docmap.mjs';
12
+
13
+ export const LEGACY_DIR = path.join('_Control_Center', 'records', 'docmap');
14
+
15
+ function stamp() {
16
+ const d = new Date();
17
+ const p = (n) => String(n).padStart(2, '0');
18
+ return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '_' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
19
+ }
20
+
21
+ /** 旧目录是否存在且含 .md 文件。 */
22
+ export function legacyInfo(ws) {
23
+ const dir = path.join(String(ws || ''), LEGACY_DIR);
24
+ let files = [];
25
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')); } catch { return { dir, exists: false, files: [] }; }
26
+ return { dir, exists: files.length > 0, files };
27
+ }
28
+
29
+ function taskInfoFromStem(stem) {
30
+ if (stem.indexOf('project-') === 0) return { role: 'project', project: stem.slice(8) };
31
+ if (stem.indexOf('misc-') === 0) return { role: 'misc', task: stem.slice(5) };
32
+ return null;
33
+ }
34
+
35
+ function renderSystem(info, rows, plan) {
36
+ const label = info.role === 'project' ? '项目「' + info.project + '」' : '杂项任务「' + info.task + '」';
37
+ const lines = ['# 体系注册(' + label + ')', '', '## 目录结构', '', '| 路径 | 用途 |', '|---|---|', '', '## 规范文件', '', '| 路径 | 用途 | 何时读 |', '|---|---|---|'];
38
+ for (const r of rows) lines.push('| `' + r.path + '` | ' + r.about + ' | ' + (r.when || '') + ' |');
39
+ lines.push('', '## 计划', '');
40
+ if (plan) lines.push('- `' + plan + '`:当前行动计划');
41
+ lines.push('', '本文件由 1.0 迁移从旧文档定位转换而来;后续由本任务在任会话用 ho_docmap 维护。');
42
+ return lines.join(NL) + NL;
43
+ }
44
+
45
+ function writeIfAbsent(file, body) {
46
+ if (isFile(file)) return false;
47
+ fs.mkdirSync(path.dirname(file), { recursive: true });
48
+ fs.writeFileSync(file, body, 'utf8');
49
+ return true;
50
+ }
51
+
52
+ /**
53
+ * 执行迁移。返回 { ok, dir, converted, kept, archived, problems }。
54
+ * @param {string} ws 工作区根
55
+ */
56
+ export function migrateLegacyDocmaps(ws) {
57
+ const root = String(ws || '');
58
+ const info = legacyInfo(root);
59
+ if (!info.exists) return { ok: true, dir: info.dir, converted: [], kept: [], archived: '', problems: [] };
60
+ const archiveRoot = path.join(root, '_Archives', '_Global', 'Backups', '1.0-docmap_' + stamp());
61
+ const problems = [];
62
+ const converted = [];
63
+ const kept = [];
64
+ for (const name of info.files) {
65
+ const file = path.join(info.dir, name);
66
+ let text = '';
67
+ try { text = readUtf8(file); } catch { problems.push('读取失败:' + name); continue; }
68
+ const stem = name.replace(/\.md$/, '');
69
+ const rows = parseDocmap(text);
70
+ if (stem === 'global') {
71
+ // 全局旧内容不是任务体系引用,保留为工作区文档清单,不注入提示段。
72
+ const dst = path.join(root, '_Control_Center', 'records', 'global-docs.md');
73
+ if (writeIfAbsent(dst, text)) kept.push('_Control_Center/records/global-docs.md');
74
+ continue;
75
+ }
76
+ const t = taskInfoFromStem(stem);
77
+ if (!t) { problems.push('文件名不符合旧规范,未转换:' + name); continue; }
78
+ const dst = t.role === 'project'
79
+ ? path.join(root, '_Project_Meta', t.project, 'System.md')
80
+ : path.join(root, '_Misc', 'tasks', t.task, 'System.md');
81
+ const plan = planRelPath(root, t);
82
+ if (writeIfAbsent(dst, renderSystem(t, rows, plan))) converted.push(path.relative(root, dst));
83
+ }
84
+ // 归档旧目录:整体移入备份位,不删除。
85
+ fs.mkdirSync(archiveRoot, { recursive: true });
86
+ const archived = path.join(archiveRoot, 'docmap');
87
+ fs.renameSync(info.dir, archived);
88
+ return { ok: problems.length === 0, dir: info.dir, converted, kept, archived, problems };
89
+ }
package/src/lib/lint.mjs CHANGED
@@ -40,7 +40,7 @@ const GLOBAL_SECTIONS = [
40
40
  '2. 下一步',
41
41
  '3. 待用户决定',
42
42
  '4. 用户要求逐条落位',
43
- '5. 坑点与禁区',
43
+ '5. 风险与禁区',
44
44
  '6. 关键路径',
45
45
  '7. 会话 id 对照',
46
46
  ];
@@ -69,8 +69,11 @@ export function lintGlobal(file) {
69
69
  try { text = readUtf8(file); } catch (e) { return { verdict: "读不到", reasons: ["读取失败:" + e.message] }; }
70
70
  const lines = text.split(String.fromCharCode(10));
71
71
  const bad = [];
72
- // §3 标题有新旧两种写法,旧的「等用户拍板」也按通过处理。
73
- const SEC_ALIAS = { '3. 待用户决定': ['3. 等用户拍板'] }; // 语体豁免:旧文档标题原文,只作兼容匹配
72
+ // §3 标题有新旧两种写法;§5 旧标题作兼容匹配(旧字按码点拼接,避免语体扫描误判)。
73
+ const SEC_ALIAS = {
74
+ '3. 待用户决定': ['3. 等用户' + String.fromCharCode(25293, 26495)],
75
+ '5. 风险与禁区': ['5. ' + String.fromCharCode(22353) + '点与禁区'],
76
+ };
74
77
  for (const sec of GLOBAL_SECTIONS) {
75
78
  const alt = SEC_ALIAS[sec] || [];
76
79
  if (!hasHeading(lines, sec) && !alt.some((a) => hasHeading(lines, a))) bad.push("缺一节:## " + sec);
@@ -85,16 +88,16 @@ export function lintGlobal(file) {
85
88
  if (!chainLine) bad.push('§0 缺「链内代数」行');
86
89
  else {
87
90
  const hasDi = chainLine.indexOf('第') !== -1 && chainLine.indexOf('代') !== -1;
88
- if (!hasDi) bad.push('链内代数不是「第 N 代」格式:' + chainLine.slice(0, 60));
91
+ if (!hasDi) bad.push('链内代数不符合「第 N 代」格式:' + chainLine.slice(0, 60));
89
92
  }
90
93
  const s2 = sectionLines(text, '下一步');
91
94
  if (!s2) bad.push('缺 ## 2. 下一步');
92
- else if (tableRows(s2).length <= 1 && bulletRows(s2).length === 0) bad.push('§2 没列出任何条目');
95
+ else if (tableRows(s2).length <= 1 && bulletRows(s2).length === 0) bad.push('§2 未列出任何条目');
93
96
  const s7 = sectionLines(text, '会话 id 对照');
94
97
  if (!s7) bad.push('缺 ## 7. 会话 id 对照');
95
98
  else {
96
- if (!s7.some((l) => l.trim().startsWith('|') && l.indexOf('session-') !== -1)) bad.push('§7 没有一行带 session id');
97
- if (s7.some((l) => l.indexOf('待回填') !== -1)) bad.push('§7 还有「待回填」');
99
+ if (!s7.some((l) => l.trim().startsWith('|') && l.indexOf('session-') !== -1)) bad.push('§7 无任何带 session id 的行');
100
+ if (s7.some((l) => l.indexOf('待回填') !== -1)) bad.push('§7 仍留有「待回填」');
98
101
  }
99
102
  const ph = firstPlaceholder(text);
100
103
  if (ph) bad.push("残留模板占位符:" + ph);
@@ -109,15 +112,15 @@ export function lintProject(file) {
109
112
  const bad = [];
110
113
  const gap = [];
111
114
  const body = sectionLines(text, '正在做的事');
112
- if (!body) gap.push("缺 ## 1. 正在做的事 一节(2026-10-12 起的强制项,本项目还没补)");
115
+ if (!body) gap.push("缺 ## 1. 正在做的事 一节");
113
116
  else {
114
117
  const core = body.filter((l) => l.trim() !== '' && !l.trim().startsWith('>'));
115
118
  // 只判「有没有实质内容」;不设字数下限(2026-11-11 口径:交接类文档不设体积上限)。
116
- if (core.length === 0) bad.push("正在做的事 是空节(或只有引用块说明),没写实质内容");
119
+ if (core.length === 0) bad.push("正在做的事 为空节(或仅有引用块说明),无实质内容");
117
120
  }
118
121
  const nxt = sectionLines(text, '下一步') || sectionLines(text, '待办');
119
122
  if (!nxt) bad.push("缺「下一步待办」一节");
120
- else if (tableRows(nxt).length <= 1 && bulletRows(nxt).length === 0) bad.push("「下一步待办」没列出任何条目");
123
+ else if (tableRows(nxt).length <= 1 && bulletRows(nxt).length === 0) bad.push("「下一步待办」未列出任何条目");
121
124
  const ph = firstPlaceholder(text);
122
125
  if (ph) bad.push("残留模板占位符:" + ph);
123
126
  if (bad.length) return { verdict: "不合格", reasons: bad };
@@ -148,6 +151,6 @@ export function renderLintProject(name, rows) {
148
151
  if (it.res.verdict === "不合格") fails++;
149
152
  else if (it.res.verdict === "缺节") gaps++;
150
153
  }
151
- out.push("判定:" + list.length + " 个项目 —— 不合格 " + fails + " 个(要改)、缺节 " + gaps + " 个(新规范未铺开,属正常)");
154
+ out.push("判定:" + list.length + " 个项目 —— 不合格 " + fails + " 个(须修正)、缺节 " + gaps + " 个(新规范尚未铺开,属正常)");
152
155
  return out;
153
156
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * 逐角色的系统提示段与一次性引导。
3
- * 职责边界:本文件只写会话定位、写入范围、交接路径、水位与换任、向全局汇报。
4
- * 其他行为约束(不虚构、最小改动、文风等)不属于本插件,由用户按需通过「全局前置提示词」注入。
5
- * 文字一律标准书面语;代码里不用反引号拼提示词。
2
+ * 逐角色的系统提示段与一次性引导(1.0)。
3
+ * 职责边界:本文件只写会话定位、写入范围、任务流程、水位与压缩、交接与体系注册、暂任与协作。
4
+ * 其他行为约束由用户通过「全局前置提示词」注入。
5
+ * 所有角色共用同一份任务流程文本,避免同一规则多处维护。
6
6
  */
7
7
  import { handoverPathFor, scopeLabel } from './role.mjs';
8
8
  import { handoverRuleText, watermark } from './facts.mjs';
@@ -10,61 +10,100 @@ import { consentPromptLines } from './consent.mjs';
10
10
 
11
11
  const NL = String.fromCharCode(10);
12
12
 
13
- /** 完整流程的指针句(三档角色共用)。 */
13
+ /** 插件完整手册的指针(各档角色共用)。 */
14
14
  const RUNTIME_POINTER = '细节按节取:ho_charter(doc="runtime", section=…)。';
15
15
 
16
- /**
17
- * 通用规范的位置与查找范围(三档角色共用,必须同一句)。
18
- * 规范按名字被引用,本库(会话所在的工作区)与全局规范库都在范围内;细节写在规范库的 README 里。
19
- */
20
- const STANDARDS_POINTER = '通用规范在 _Control_Center/standards/(每条一个子目录);当前用户提到「按某某规范进行」时,本库(本会话所在的工作区)与该目录都算查找范围。';
16
+ /** 通用规范的位置与查找范围(各档角色共用,必须同一句)。 */
17
+ const STANDARDS_POINTER = '通用规范在 _Control_Center/standards/(每条一个子目录)。当前用户提到「按某某规范进行」时,查找范围包括本库(本会话所在的工作区)与该目录。';
21
18
 
22
- /**
23
- * 写入范围(三档角色共用前一句):未指明写本库;明确「写入通用规范库」一类要求才写规范库。
24
- * 后一句按角色分叉:全局直接写,项目与杂项报全局。
25
- */
26
- const STANDARDS_WRITE_PREFIX = '当前用户提到「把某某写成规范」时:未指明范围就写本库;';
19
+ /** 写入范围:未指明写本库;明确写通用规范才写规范库。 */
20
+ const STANDARDS_WRITE_PREFIX = '当前用户提到「把某某写成规范」时:未指明范围则写本库;';
27
21
  const STANDARDS_WRITE_TARGET = '说「写入通用规范库」「写成通用规范」「写成共用规范」的';
28
22
  const STANDARDS_WRITE_GLOBAL = STANDARDS_WRITE_PREFIX + STANDARDS_WRITE_TARGET + ',写 _Control_Center/standards/。';
29
- const STANDARDS_WRITE_SCOPED = STANDARDS_WRITE_PREFIX + STANDARDS_WRITE_TARGET + ',不得写 _Control_Center,报全局。';
23
+ const STANDARDS_WRITE_SCOPED = STANDARDS_WRITE_PREFIX + STANDARDS_WRITE_TARGET + ',不得写 _Control_Center,只报全局。';
30
24
 
31
- /**
32
- * 复用准则:全局、项目、杂项三处必须同一句。
33
- * 派活与新建会话前先查在任,低水位在任直接复用;确需另建才新建。
34
- */
25
+ /** 写作规范指针:只有一行,不注入正文。 */
26
+ const WRITING_POINTER = '写作规范见 _Control_Center/standards/写作规范/README.md(W-01 至 W-42);全部产出按它执行。';
27
+
28
+ /** 暂任与协作的落点:规范与提示词随任务库独立。 */
29
+ const DELEGATION_POINTER = '暂任与协作:规范在任务库 rules/Delegation_Rules.md,协作模板在 templates/Prompt_Templates.md。';
30
+
31
+ /** 协作收结果的口径:被派方不发消息,协调方按完成顺序收集最终回复。 */
32
+ const COLLAB_LINE = '协作收结果:进入协作前先以 peer(action="collab", sessions=[…]) 注册。协作期间被交办方不得向交办者或同组成员发送明文消息,工具端会拒绝,DSH 自带的 send_message 亦由插件守卫拦截;普通对话无需注册。协调方以 peer(action="collect", sessions=[…], timeout_ms=…) 取先完成者的最终回复;timeout_ms 按任务预估设置,最低 120000。collect 取回全部结果后,限制自动解除。';
33
+
34
+ /** 写交接的固定流程(各档角色共用):只写这一行,细则在规范与模板里。 */
35
+ const HANDOVER_FLOW = '写交接按固定流程:先读上一份交接,再归档上一份,然后读模板 _Control_Center/templates/Handover.md,最后以 ho_handover(action="new") 新建空白写入;不得在旧文件上续写。';
36
+
37
+ /** 复用准则:全局、项目、杂项各处同一句。 */
35
38
  function reuseRule(cfg) {
36
- return '指派任务或新建会话前,先用 ho_office(空参数)查一次在任;同一类别已有在任且其水位低于 '
37
- + watermark(cfg).warn + ' 时,直接用 peer(action="send")复用,不新建;确需另建(原会话不可用、需并行、当前用户明确要求)时才新建。';
39
+ return '指派任务或新建会话前,先用 ho_office(空参数)查询一次在任。同一类别已有在任会话且其水位低于 '
40
+ + watermark(cfg).handover + ' 时,直接用 peer(action="send")复用,不新建;确需另建(原会话不可用、需并行、当前用户明确要求)时才新建。';
41
+ }
42
+
43
+ /** 两级任务流程全文(各档角色共用)。 */
44
+ function workflowLines(cfg) {
45
+ return [
46
+ '三、任务流程',
47
+ '- 层级一(轻量):单文件修改、局部命名、语法修复、格式化、内容读取,直接执行,跳过计划与查证。',
48
+ '- 当前用户说「讨论一下」,或已就流程向当前用户确认过时,执行前须再次确认;当前用户明确「直接做」的除外。',
49
+ '- 层级二(繁重):新增功能、多文件联动、重构、架构或接口变更,按下述顺序执行。',
50
+ '- 先判断纯窗口是否可能超过 40%(不计当前占用)。超过时,写计划就把拆子智能体安排进去,本会话只做统筹与收尾;不超过时,计划按本会话自行完成来写。',
51
+ '- 写行动计划 PLAN.md(任务根),随后回看上下文核对;发现缺口时给出方案询问当前用户,优劣不明时并列全部方案。',
52
+ '- 子智能体(细则见 Charter §十一 与运行时手册 §12.4):仅在单块 ≥15 步或需读 ≥10 个文件、确需隔离、且结果可机械验收时派出,并发 ≤3;单个子智能体自身亦需十余步,结论还需回读。能先写成脚本再抽检的,先写脚本。同一批文件的下一道工序交回原作者续用,不另开。提示词由本会话派发时,放入规范文件地址与体系说明;插件不向其上下文注入内容。',
53
+ '- 当前用户确认后执行。余量不足时先刷新交接,再请求压缩或换任;恢复后按「先读交接、再读计划、后执行」。',
54
+ '- 每轮只做与当前任务直接相关的事;未经要求的检查不设立,工具正常返回即予采信。',
55
+ ];
38
56
  }
39
57
 
40
- /** 全局会话的常驻段:五节骨架(身份、权限、节奏、交接、按需取用)。 */
58
+ /** 上下文与压缩段落(各档角色共用)。 */
59
+ function rhythmLines(cfg) {
60
+ const w = watermark(cfg);
61
+ return [
62
+ '四、上下文与压缩',
63
+ '- 水位跨 ' + w.remind + ' 与 ' + w.remind2 + ' 时,由插件在本会话的工具结果末尾各追加一次提醒;不修改系统提示段、不生成用户消息、不独立成轮。',
64
+ '- 判断标准:' + w.remind + ' 档为完成剩余流程后不超过 ' + Math.round((cfg && cfg.margin1 !== undefined ? cfg.margin1 : 0.45) * 100) + '%;'
65
+ + w.remind2 + ' 档为不超过 ' + Math.round((cfg && cfg.margin2 !== undefined ? cfg.margin2 : 0.55) * 100) + '%。',
66
+ '- ' + handoverRuleText(cfg) + '当前用户可随时要求换任。',
67
+ '- 压缩前先刷新交接;压缩后先读交接、再读计划、后执行。',
68
+ '- 压缩须由当前用户点击「授权压缩」,随后写交接并调用 ho_checkpoint(confirm=true);任务未完成传 resume=continue,已收尾传 resume=wait(不再唤醒)。',
69
+ '- 关键步骤传 at_risk=true 暂缓;85% 硬上限仍然生效。',
70
+ ];
71
+ }
72
+
73
+ /** 全局会话的常驻段:身份、权限、流程、节奏、交接与体系、按需取用。 */
41
74
  export function globalManual(opts) {
42
75
  const o = opts || {};
43
76
  const lines = [
44
77
  'dsh-handover 运行期(全局)',
45
78
  '',
46
79
  '一、身份',
47
- '本会话为全局管理会话,协调会话、维护规则与交接。',
80
+ '本会话为全局管理会话,协调会话、维护登记与交接。',
48
81
  '',
49
82
  '二、权限',
50
- '可写 _Control_Center、_Project_Meta 与项目结构;Projects 及各项目库内文件只读,不得代写;项目内作业不代办。',
51
- '新建会话与设定角色仅属全局;会话间可直接 peer(action="send");换任须当前用户授权(/allow-succeed 或设置页)后由在任会话调用 ho_succeed,不再请求全局。',
83
+ '可写 _Control_Center、_Project_Meta 与项目结构;Projects 及各项目库内文件只读,不得代写;项目内作业不由全局代办。',
84
+ '新建会话与设定角色仅属全局;会话间可直接 peer(action="send")。换任须当前用户授权(/allow-succeed 或设置页)后,由在任会话调用 ho_succeed。',
52
85
  reuseRule(o.cfg),
53
86
  '',
54
- '三、节奏',
55
- handoverRuleText(o.cfg),
56
- '压缩须当前用户点击「授权压缩」,再写交接并 ho_checkpoint(confirm=true);任务未完成传 resume=continue,已收尾传 resume=wait,不再唤醒。confirm 仅表意图,授权以点击为准。关键步骤传 at_risk=true 暂缓。',
87
+ ...workflowLines(o.cfg),
88
+ '',
89
+ ...rhythmLines(o.cfg),
57
90
  '',
58
- '四、交接',
91
+ '五、交接与体系',
59
92
  '交接:_Control_Center/records/Global_Handover.md。',
93
+ HANDOVER_FLOW,
94
+ '体系注册:_Control_Center/records/systems.md 只列各任务体系文件的路径;本段只注入这些引用,不注入各任务正文。',
95
+ '全局任务库:_Control_Center/tasks/<任务名>/;每个任务有独立交接与计划,任务可一键独立为项目或杂项会话。',
96
+ COLLAB_LINE,
97
+ WRITING_POINTER,
98
+ DELEGATION_POINTER,
60
99
  '',
61
- '五、按需取用',
100
+ '六、按需取用',
62
101
  RUNTIME_POINTER,
63
102
  STANDARDS_POINTER,
64
103
  STANDARDS_WRITE_GLOBAL,
65
104
  ];
66
- const dm = docmapBlock(o);
67
- if (dm.length) lines.push('', ...dm);
105
+ const sys = o.system || '';
106
+ if (sys) lines.push('', sys);
68
107
  const cs = consentBlock(o);
69
108
  if (cs.length) lines.push('', ...cs);
70
109
  const mv = moveLine(o);
@@ -72,13 +111,15 @@ export function globalManual(opts) {
72
111
  return lines.join(NL);
73
112
  }
74
113
 
75
- /** 项目与杂项会话的提示段:只写本角色自己的写入范围、交接与水位。 */
114
+ /** 项目与杂项会话的提示段:写入范围、交接、流程、体系注册内容、规范指针。 */
76
115
  export function scopedManual(info, opts) {
77
116
  const o = opts || {};
78
117
  const hp = handoverPathFor(o.ws, info);
79
118
  const label = scopeLabel(info);
80
119
  const lines = [
81
120
  'dsh-handover 运行期(' + label + ')',
121
+ '',
122
+ '一、身份与边界',
82
123
  info.role === 'project'
83
124
  ? '只可写本库与元数据目录,不得写 _Control_Center 及其他项目。'
84
125
  : '只可写本任务目录与元数据目录,不得写 _Control_Center、Projects 及其他任务。',
@@ -86,23 +127,34 @@ export function scopedManual(info, opts) {
86
127
  ? '库根:Projects/' + info.project + '/;元数据:_Project_Meta/' + info.project + '/。'
87
128
  : '任务目录:_Misc/tasks/' + info.task + '/。',
88
129
  info.role === 'project'
89
- ? '骨架:_Project_Meta/' + info.project + '/project.profile.json;未注册时先通读本库并 ho_profile(action=register)。'
90
- : '骨架:_Misc/tasks/' + info.task + '/task.profile.json;未注册时先通读本任务并 ho_profile(action=register)。',
130
+ ? '骨架:_Project_Meta/' + info.project + '/project.profile.json;未注册时先通读本库并调用 ho_profile(action=register)。'
131
+ : '骨架:_Misc/tasks/' + info.task + '/task.profile.json;未注册时先通读本任务并调用 ho_profile(action=register)。',
91
132
  info.role === 'project'
92
133
  ? '临时与归档:_Temp/' + info.project + '/{Snapshots,Scratch}、_Archives/' + info.project + '/。'
93
134
  : '临时与归档:_Temp/_Misc/' + info.task + '/Snapshots/、_Archives/_Misc/' + info.task + '/。',
94
135
  hp.path
95
136
  ? '交接:' + hp.path + '(ho_handover 默认)。'
96
- : '交接路径未解析(缺角色或项目名),请先在设置页设定角色。',
97
- handoverRuleText(o.cfg) + '压缩须当前用户点击「授权压缩」,再由本会话执行 ho_checkpoint(confirm=true);收尾时传 resume=wait,不再唤醒。',
98
- '会话间可直接 peer(action="send");默认沉默:仅在水位 ' + watermark(o.cfg).warn + ' 且已写交接、需当前用户决定、全局口径有误时向全局报告;任务完成不报全局。',
99
- reuseRule(o.cfg),
137
+ : '交接路径未解析(缺角色或名称),请先在设置页设定角色。',
138
+ HANDOVER_FLOW,
139
+ '名称已注册,不得自行修改;需要改名由全局处理。',
140
+ '',
141
+ ...workflowLines(o.cfg),
142
+ '',
143
+ ...rhythmLines(o.cfg),
144
+ '',
145
+ '五、交接与体系',
146
+ '会话间可直接 peer(action="send")。默认沉默:仅在水位达到 ' + watermark(o.cfg).remind + ' 且已写交接、需当前用户决定、全局口径有误 3 种情形向全局报告;任务完成不报全局。',
147
+ WRITING_POINTER,
148
+ DELEGATION_POINTER,
149
+ COLLAB_LINE,
150
+ '',
151
+ '六、按需取用',
100
152
  RUNTIME_POINTER,
101
153
  STANDARDS_POINTER,
102
154
  STANDARDS_WRITE_SCOPED,
103
155
  ];
104
- const dm = docmapBlock(o);
105
- if (dm.length) lines.push('', ...dm);
156
+ const sys = o.system || '';
157
+ if (sys) lines.push('', sys);
106
158
  const cs = consentBlock(o);
107
159
  if (cs.length) lines.push('', ...cs);
108
160
  const mv = moveLine(o);
@@ -117,12 +169,6 @@ function moveLine(opts) {
117
169
  return '已迁移至 ' + (mv.dst || '?') + ';' + (mv.src || '?') + ' 现为链接,勿再手工迁移。';
118
170
  }
119
171
 
120
- /** 文档定位(docmap)注入:空 map 不占位。 */
121
- function docmapBlock(opts) {
122
- const m = opts && opts.docmap;
123
- return m ? [m] : [];
124
- }
125
-
126
172
  /** 用户已授权的提示行:每种有效授权一行,无授权不占位。 */
127
173
  function consentBlock(opts) {
128
174
  try { return consentPromptLines(opts && opts.consents); } catch { return []; }
@@ -135,40 +181,31 @@ export function manualFor(info, opts) {
135
181
  return '';
136
182
  }
137
183
 
138
- /** 「整理所属文件」一次性指令:设定项目或杂项角色后投递,唤醒它自己做。 */
139
- export function organizeMessageFor(info, opts) {
184
+ /**
185
+ * 设定角色后投递的一次性首轮消息:讨论体系、注册名称;互转时先说明迁移结果。
186
+ * 由 applyRole 在写角色表之后投递,不改变角色本身。
187
+ */
188
+ export function firstRoundMessageFor(info, opts) {
140
189
  const o = opts || {};
141
190
  const role = info && info.role ? info.role : 'plain';
142
191
  if (role === 'plain') return '';
143
- const head = '【dsh-handover 角色设定】本会话已设定为「' + scopeLabel(info) + '」。请按下列结构核对自身文件:缺失的目录应建立,位置不当的文件应归位;既有文件就地保留,不得删除或修改内容。';
144
- if (role === 'project') {
145
- const p = info.project;
146
- return [
147
- head,
148
- '先通读本库,确定本项目的骨架:代码与文档主体、必需文件、临时与归档位置。构建型、动画型等项目不必套用插件类项目的文档集。',
149
- '把结论写入 _Project_Meta/' + p + '/project.profile.json;字段格式见 ho_profile(action=presets)。',
150
- '写入后调用 ho_profile(action=register) 校验并登记;返回缺失项时按清单补齐。',
151
- '约束:仅可改动本库与 _Project_Meta/' + p + '/;不得改动 _Control_Center 及其他项目;移动较大文件前先执行 ho_backup(action="snapshot")。可用工具:ho_backup、ho_handover、ho_status、ho_checkpoint、ho_profile、peer(action="send")。',
152
- '完成后以一句话向当前用户说明「注册了哪些骨架项、仍缺哪些」,无需每轮回执。',
153
- ].join(NL);
154
- }
155
- if (role === 'misc') {
156
- const t = info.task;
157
- return [
158
- head,
159
- '先通读本任务目录,确定本任务的骨架:工作文件、规则文件、交接、快照与归档位置。',
160
- '把结论写入 _Misc/tasks/' + t + '/task.profile.json;字段格式见 ho_profile(action=presets)。',
161
- '写入后调用 ho_profile(action=register) 校验并登记。',
162
- '约束:仅可改动 _Misc/tasks/' + t + '/、_Temp/_Misc/' + t + '/、_Archives/_Misc/' + t + '/;_Control_Center、Projects 与其他任务不得写入。工具:ho_backup、ho_handover、ho_status、ho_checkpoint、ho_profile、peer(action="send")。',
163
- '完成后以一句话说明改动即可。',
164
- ].join(NL);
192
+ const label = scopeLabel(info);
193
+ const lines = [];
194
+ lines.push('【dsh-handover 角色设定】本会话已设为「' + label + '」。');
195
+ if (o.previousRole === 'project' || o.previousRole === 'misc') {
196
+ if (o.previousRole !== role) {
197
+ lines.push(o.migrate === 'direct'
198
+ ? '已按「直接建」处理:旧文件未搬动,请按体系模板建立本任务体系。'
199
+ : '已按标准流程完成迁移:' + (o.moveNote || '旧库已快照后移动,位置见交接。'));
200
+ lines.push('若当前用户希望改用另一种方式,请说明差异,不得自行再次迁移。');
201
+ } else {
202
+ lines.push('角色未变:沿用既有体系与注册名。');
203
+ }
204
+ } else {
205
+ lines.push('首轮固定事项:一、了解本会话既有产出与任务;二、读体系模板并初拟体系;三、与当前用户确认一种方式:由本会话直接建成并注册,或先讨论定稿再建成并注册。');
165
206
  }
166
- return [
167
- head,
168
- '- _Control_Center/{records,rules,standards,user,templates,tools} — 交接、规则、通用规范、写作规范、模板、脚本。',
169
- '- _Project_Meta/Registry.md 与各项目元数据目录。',
170
- '- Projects/、_Misc/tasks/、_Archives/、_Temp/ — 只维护结构,不代写项目库内文件。',
171
- '约束:项目库内文件由该项目会话自行整理;骨架由项目自己注册,缺则派发,不代写。',
172
- '完成后以一句话汇报即可。',
173
- ].join(NL);
207
+ lines.push('名称:' + (o.name ? '本会话注册名为「' + o.name + '」,不得自行修改。' : '本会话尚无注册名。与当前用户确认名称后,用 ho_office(action="register", name="…") 注册;注册后不得自行修改,改名须找全局。'));
208
+ lines.push('体系注册:用 ho_docmap(action="write", entries=[…]) 写入目录结构、规范文件与计划;模板见任务库 rules 与 templates 目录。');
209
+ lines.push('约束:只改动本会话所属范围;不每轮回执,不发送确认类消息。');
210
+ return lines.join(NL);
174
211
  }
package/src/lib/mover.mjs CHANGED
@@ -16,6 +16,7 @@ import os from 'node:os';
16
16
  import path from 'node:path';
17
17
  import crypto from 'node:crypto';
18
18
  import { spawn } from 'node:child_process';
19
+ import { pathKey } from './pathkey.mjs';
19
20
 
20
21
  const MARKER_NAME = 'dsh-handover.move.json';
21
22
  const HELPER_NAME = 'dsh-handover.move.mjs';
@@ -25,7 +26,10 @@ const STAMP_REL = path.join('_Temp', '_Global', 'Scratch', '.dsh-handover-verifi
25
26
 
26
27
  function msgOf(e) { return String(e && e.message ? e.message : e); }
27
28
  function tail(p) { return String(p || '').replace(/[\\/]+$/, ''); }
28
- function canon(p) { return tail(path.resolve(String(p || ''))).toLowerCase(); }
29
+ function canon(p) {
30
+ const t = tail(path.resolve(String(p || '')));
31
+ return process.platform === 'win32' ? t.toLowerCase() : t;
32
+ }
29
33
  function samePath(a, b) { return !!a && !!b && canon(a) === canon(b); }
30
34
  function isInside(parent, child) {
31
35
  const p = canon(parent) + path.sep;
@@ -168,15 +172,15 @@ export async function probeMove({ src, to }) {
168
172
  if (asLink) {
169
173
  try { copyFrom = tail(fs.realpathSync(s)); } catch { copyFrom = tail(asLink); }
170
174
  }
171
- if (!fs.existsSync(path.join(copyFrom, '_Control_Center'))) warnings.push('源目录里没有 _Control_Center:确认这是全局根吗?');
175
+ if (!fs.existsSync(path.join(copyFrom, '_Control_Center'))) warnings.push('源目录中无 _Control_Center:请确认该目录是否为全局根。');
172
176
 
173
177
  const raw = String(to || '').trim();
174
178
  if (!raw) throw new Error('缺少目标路径(to)');
175
- if (!path.isAbsolute(raw)) problems.push('目标必须是绝对路径:' + raw);
179
+ if (!path.isAbsolute(raw)) problems.push('目标须为绝对路径:' + raw);
176
180
  // 语义:to=目标**父目录**(把整个根目录搬进去);basename 与当前根同名则直接当新根。
177
181
  const toRaw = tail(path.resolve(raw));
178
182
  const rootName = path.basename(s);
179
- const t = path.basename(toRaw).toLowerCase() === rootName.toLowerCase() ? toRaw : path.join(toRaw, rootName);
183
+ const t = pathKey(path.basename(toRaw)) === pathKey(rootName) ? toRaw : path.join(toRaw, rootName);
180
184
  if (samePath(t, s)) problems.push('目标与当前根相同');
181
185
  else if (isInside(s, t)) problems.push('目标在当前根内部:' + t);
182
186
  else if (isInside(t, s)) problems.push('目标是当前根的上级目录:' + t);
@@ -197,7 +201,7 @@ export async function probeMove({ src, to }) {
197
201
  if (freeBytes !== null && freeBytes < srcSize.bytes * 1.15) {
198
202
  problems.push('目标盘空间不足:可用 ' + fmtBytes(freeBytes) + ',需要约 ' + fmtBytes(srcSize.bytes * 1.15));
199
203
  }
200
- if (!freeBytes && freeBytes !== 0) warnings.push('无法获取目标盘可用空间(继续前请自行确认)');
204
+ if (!freeBytes && freeBytes !== 0) warnings.push('无法取得目标盘可用空间(继续前请自行确认)');
201
205
 
202
206
  return {
203
207
  ok: problems.length === 0,
@@ -275,7 +279,7 @@ function alive(pid) {
275
279
  if (!pid) return false;
276
280
  try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === 'EPERM'; }
277
281
  }
278
- function stamp(text) { return String(text || '').replace(/[\\/]+$/, '').toLowerCase(); }
282
+ function stamp(text) { const t = String(text || '').replace(/[\\/]+$/, ''); return process.platform === 'win32' ? t.toLowerCase() : t; }
279
283
  function stop(m) { log('[中止] ' + m); process.exit(1); }
280
284
 
281
285
  (async () => {
@@ -295,7 +299,7 @@ function stop(m) { log('[中止] ' + m); process.exit(1); }
295
299
  if (!fs.existsSync(dst)) stop('目标不存在:' + dst);
296
300
  if (!fs.existsSync(src)) stop('源不存在:' + src);
297
301
  const stampFile = path.join(dst, '_Temp', '_Global', 'Scratch', '.dsh-handover-verified.json');
298
- if (!fs.existsSync(stampFile)) stop('目标里没有校验印章,它可能不是这次复制出来的');
302
+ if (!fs.existsSync(stampFile)) stop('目标中无校验印章,可能并非本次复制所得');
299
303
  let sp2 = null;
300
304
  try { sp2 = JSON.parse(fs.readFileSync(stampFile, 'utf8')); } catch { stop('校验印章读不出来'); }
301
305
  if (String(sp2.digest) !== String(m.digest)) stop('校验印章与标记不一致,目标可能被改动过');
@@ -313,7 +317,7 @@ function stop(m) { log('[中止] ' + m); process.exit(1); }
313
317
  let target = '';
314
318
  try { target = fs.readlinkSync(src); } catch { target = ''; }
315
319
  if (!target || stamp(target) !== stamp(dst)) {
316
- log('链接指向不对:' + target + '(旧备份在 ' + old + ')');
320
+ log('链接指向不符:' + target + '(旧备份在 ' + old + ')');
317
321
  stop('切换校验失败');
318
322
  }
319
323
  m.phase = 'switched';
@@ -352,7 +356,7 @@ function stop(m) { log('[中止] ' + m); process.exit(1); }
352
356
  /** 派后台助手:等本进程(DSH)退出后自动切换。 */
353
357
  export function stageSwitch({ relaunch, waitPid } = {}) {
354
358
  const m = readMarker();
355
- if (!m || !m.src || !m.dst) throw new Error('没有可用迁移标记,先跑一次 move');
359
+ if (!m || !m.src || !m.dst) throw new Error('无可用迁移标记,请先执行一次 move');
356
360
  const st = moveStatus({ src: m.src });
357
361
  if (st.phase === 'switched' || st.phase === 'switched+cleaned') return { ok: true, already: true, phase: st.phase, log: switchLogPath() };
358
362
  if (st.phase !== 'verified') throw new Error('当前不是「已复制并校验通过」(phase=' + st.phase + '),不能切换');
@@ -381,7 +385,7 @@ export function stageSwitch({ relaunch, waitPid } = {}) {
381
385
  child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', windowsHide: true, env });
382
386
  child.unref();
383
387
  } catch (e) {
384
- return { ok: false, spawned: false, error: '派助手失败:' + msgOf(e), helper: sp.helper, cmd: sp.cmd, sh: sp.sh, log: sp.log };
388
+ return { ok: false, spawned: false, error: '派出助手失败:' + msgOf(e), helper: sp.helper, cmd: sp.cmd, sh: sp.sh, log: sp.log };
385
389
  }
386
390
  return { ok: true, spawned: true, pid: child.pid, waitPid: pid, helper: sp.helper, cmd: sp.cmd, sh: sp.sh, log: sp.log, relaunch: relaunch === true };
387
391
  }
@@ -394,7 +398,7 @@ export function stageSwitch({ relaunch, waitPid } = {}) {
394
398
  */
395
399
  export function repairLink() {
396
400
  const m = readMarker();
397
- if (!m || !m.src || !m.dst) throw new Error('没有迁移记录,无法重建链接');
401
+ if (!m || !m.src || !m.dst) throw new Error('无迁移记录,无法重建链接');
398
402
  if (!fs.existsSync(m.dst)) throw new Error('目标不存在(' + m.dst + '),先确认数据在哪里,本工具不动文件');
399
403
  const kind = linkKind();
400
404
  let st = null;
@@ -531,7 +535,7 @@ export function stageRetarget() {
531
535
  child = spawn(process.execPath, [sp.helper, String(process.pid)], { detached: true, stdio: 'ignore', windowsHide: true, env });
532
536
  child.unref();
533
537
  } catch (e) {
534
- return { ok: false, spawned: false, error: '派助手失败:' + msgOf(e), plan, script: sp };
538
+ return { ok: false, spawned: false, error: '派出助手失败:' + msgOf(e), plan, script: sp };
535
539
  }
536
540
  return { ok: true, spawned: true, pid: child.pid, waitPid: process.pid, plan, script: sp };
537
541
  }
@@ -639,7 +643,7 @@ export async function cleanupOld({ src, confirm } = {}) {
639
643
  const st = moveStatus({ src });
640
644
  if (st.phase !== 'switched') throw new Error('当前不是已切换状态(phase=' + st.phase + '),拒绝删除任何目录');
641
645
  if (confirm !== true) throw new Error('删除旧备份需要明确确认(confirm=true)');
642
- if (!st.olds.length) return { ok: true, removed: [], bytes: 0, note: '没有找到旧备份目录' };
646
+ if (!st.olds.length) return { ok: true, removed: [], bytes: 0, note: '未找到旧备份目录' };
643
647
  const removed = []; const failed = []; let bytes = 0;
644
648
  const base = path.basename(st.src) + '.old-';
645
649
  for (const o of st.olds) {
@@ -658,6 +662,6 @@ export async function cleanupOld({ src, confirm } = {}) {
658
662
  ok: failed.length === 0,
659
663
  removed, failed, bytes,
660
664
  note: failed.length ? ('有 ' + failed.length + ' 个旧备份未删净,可重试')
661
- : (removed.length ? '已删除旧备份' : '没有可删的旧备份'),
665
+ : (removed.length ? '已删除旧备份' : '无可删的旧备份'),
662
666
  };
663
667
  }
@@ -90,7 +90,7 @@ export function nextName(store, key) {
90
90
  */
91
91
  export function resolveOffice(store, query) {
92
92
  const q = String(query || '').trim();
93
- if (!q) return { ok: false, reason: '空查询' };
93
+ if (!q) return { ok: false, reason: '查询为空' };
94
94
  const keys = Object.keys((store && store.lineages) || {});
95
95
  const lower = q.toLowerCase();
96
96
  let key = '';
@@ -151,7 +151,7 @@ export function formatRow(row, askedAs) {
151
151
  if (!cur || !cur.name) return (row && row.key ? row.key : '?') + ' — 当前空缺';
152
152
  const label = KIND_LABEL[row.kind] || row.kind || '-';
153
153
  // 只有「拿旧编号问现任」时才加来处标注;直接问类别名或现任名就不必重复。
154
- const tail = askedAs && askedAs !== cur.name && askedAs !== row.key ? '(' + askedAs + ' → ' + row.key + ')' : '';
154
+ const tail = askedAs && askedAs !== cur.name && askedAs !== row.key ? '(' + askedAs + ' 指向 ' + row.key + ')' : '';
155
155
  return cur.name + ' ' + cur.sessionId + ' ' + label + (cur.since ? ' 自 ' + cur.since : '') + tail;
156
156
  }
157
157
 
@@ -165,6 +165,11 @@ export function registerHolder(ws, opts) {
165
165
  kind: o.kind, project: String(o.project || ''), task: String(o.task || ''),
166
166
  workspace: String(o.workspace || ''), current: null, history: [],
167
167
  });
168
+ // 同名类别互转(杂项与项目同名)必须改类别字段,否则名册一直停在旧类别。
169
+ lin.kind = o.kind;
170
+ lin.project = String(o.project || '');
171
+ lin.task = String(o.task || '');
172
+ if (o.workspace) lin.workspace = String(o.workspace);
168
173
  if (!Array.isArray(lin.history)) lin.history = [];
169
174
  const previous = lin.current && lin.current.name ? { ...lin.current } : null;
170
175
  if (previous) {
@@ -185,6 +190,41 @@ export function registerHolder(ws, opts) {
185
190
  return { key, previous, current: lin.current, registry };
186
191
  }
187
192
 
193
+ /**
194
+ * 只同步类别字段:现任是同一会话时更新 kind、project、task,不写历史。
195
+ * 返回是否更新。
196
+ */
197
+ export function syncHolderKind(ws, opts) {
198
+ const o = opts || {};
199
+ const key = lineageKeyOf(o.kind, o.project, o.task);
200
+ if (!key) return false;
201
+ const store = loadOffice(ws);
202
+ const lin = store.lineages[key];
203
+ if (!lin || !lin.current || String(lin.current.sessionId) !== String(o.sessionId || '')) return false;
204
+ lin.kind = o.kind;
205
+ lin.project = String(o.project || '');
206
+ lin.task = String(o.task || '');
207
+ saveOffice(ws, store);
208
+ return true;
209
+ }
210
+
211
+ /**
212
+ * 注销登记:现任是该会话时删除整条世系(历史一并移除,文件体系不动)。
213
+ * 返回是否删除。
214
+ */
215
+ export function removeHolder(ws, opts) {
216
+ const o = opts || {};
217
+ const key = lineageKeyOf(o.kind, o.project, o.task);
218
+ if (!key) return false;
219
+ const store = loadOffice(ws);
220
+ const lin = store.lineages[key];
221
+ if (!lin) return false;
222
+ if (o.sessionId && lin.current && String(lin.current.sessionId) !== String(o.sessionId)) return false;
223
+ delete store.lineages[key];
224
+ saveOffice(ws, store);
225
+ return true;
226
+ }
227
+
188
228
  /**
189
229
  * 读 Registry.md「项目清单」里的项目名(按表内顺序)。
190
230
  * 只认那一张表:表头、分隔行与空行一律跳过;读不到文件时返回空数组。