@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
@@ -2,26 +2,27 @@
2
2
  import { textOutput } from '../lib/output.mjs';
3
3
  import { nameOf } from '../lib/names.mjs';
4
4
  import { NL } from '../lib/text.mjs';
5
- import { readHandover, writeHandover } from '../lib/handover-io.mjs';
5
+ import { readHandover, rewriteHandover } from '../lib/handover-io.mjs';
6
6
  import { eventsOf, countCompactions } from '../lib/events.mjs';
7
7
  import { decide } from '../lib/phase.mjs';
8
8
  import { checkConsent, consumedConsent } from '../lib/consent.mjs';
9
+ import { markSkipNext } from '../lib/reminder.mjs';
9
10
 
10
11
  export function pmCheckpointTool(env) {
11
12
  return {
12
13
  name: nameOf('checkpoint'),
13
14
  description:
14
- '写交接、登记压缩;回合结束后自动压缩。resume=continue(默认)压缩后续跑;resume=wait 表示任务已收尾,压缩后不唤醒。',
15
+ '写交接、登记压缩;回合结束后自动压缩。resume=continue(缺省)表示压缩后续跑;resume=wait 表示任务已收尾,压缩后不唤醒。',
15
16
  parameters: {
16
17
  type: 'object',
17
18
  additionalProperties: false,
18
19
  properties: {
19
20
  handover_text: { type: 'string', description: '要写入的交接正文;不给则要求磁盘上已有合格交接' },
20
21
  handover_path: { type: 'string', description: '覆盖交接路径(演练用)' },
21
- resume: { type: 'string', enum: ['continue', 'wait'], description: '压缩后的去向:continue(默认)按交接 §2 继续;wait 表示本轮任务已收尾,压缩后不唤醒,等当前用户派新任务' },
22
- at_risk: { type: 'boolean', description: '当前处在关键步骤:为 true 时,水位在提醒线~交接线之间只提醒、不压缩(过交接线仍会压)' },
22
+ resume: { type: 'string', enum: ['continue', 'wait'], description: '压缩后的去向:continue(缺省)按交接 §2 继续;wait 表示本轮任务已收尾,压缩后不唤醒,等待当前用户派新任务' },
23
+ at_risk: { type: 'boolean', description: '当前处在关键步骤:为 true 时,水位在提醒线与交接线之间只提醒、不压缩(过交接线仍压缩)' },
23
24
  force: { type: 'boolean', description: '忽略关键步骤判定,直接排压缩' },
24
- confirm: { type: 'boolean', description: '判定为「可压缩」后须经当前用户同意才传 true;还需当前用户在设置页点击「授权压缩」(5 分钟一次性授权),否则不排压缩' },
25
+ confirm: { type: 'boolean', description: '判定为「可压缩」后,须经当前用户同意方可传 true;同时须当前用户在设置页点击「授权压缩」(5 分钟一次性授权),否则不排压缩' },
25
26
  },
26
27
  },
27
28
  output: textOutput(),
@@ -37,7 +38,7 @@ export function pmCheckpointTool(env) {
37
38
  return { text: [
38
39
  '没有可用的交接路径:' + (target && target.reason ? target.reason : '未知'),
39
40
  '先指定角色(设置页的 dsh-handover 中「会话角色」),或显式传 handover_path="<交接文件绝对路径>"。',
40
- '压缩会丢历史,没有合格交接就不排压缩。',
41
+ '压缩会丢失历史;没有合格交接,不排压缩。',
41
42
  ].join(NL) };
42
43
  }
43
44
  const file = target.path;
@@ -49,53 +50,53 @@ export function pmCheckpointTool(env) {
49
50
  const ratioText = ratio === null ? '读不到' : (Math.round(ratio * 1000) / 10).toFixed(1) + '%';
50
51
  const recentSec = st && st.lastSummaryMs ? (Date.now() - st.lastSummaryMs) / 1000 : null;
51
52
  if (recentSec !== null && recentSec < 120 && args.force !== true) {
52
- out.push("拒绝排压缩:" + Math.round(recentSec) + " 秒前刚完成一次压缩(日志里有 compaction/summary),避免重复排程。");
53
- out.push("等一轮让投影缓存刷新;确实要再压一次请加 force=true。");
53
+ out.push("拒绝排压缩:" + Math.round(recentSec) + " 秒前刚完成一次压缩(日志中有 compaction/summary),以免重复排程。");
54
+ out.push("等待一轮使投影缓存刷新;确需再次压缩,请加 force=true。");
54
55
  return { text: out.join(NL) };
55
56
  }
56
57
  if (count.lock === "open") {
57
58
  out.push("拒绝排压缩:检测到未闭合的压缩锁(compaction/start 无配对 end)。");
58
- out.push("处理:等当前压缩结束后用 ho_status 复查;若长时间不动,说明上次压缩崩在中间。");
59
+ out.push("处置:等当前压缩结束后用 ho_status 复查;若长时间无变化,说明上次压缩中途失败。");
59
60
  return { text: out.join(NL) };
60
61
  }
61
62
  const adv = decide({ ratio, compactionsUsed: count.used, cfg: env.cfg, lockState: count.lock });
62
63
  if (adv.action === "new_session") {
63
64
  out.push("不建议压缩:" + adv.reason);
64
- out.push('下一步:用 ho_handover(action=write) 写交接,再由全局 ho_sessions(action="new") 建新会话。');
65
+ out.push('下一步:按固定流程写交接。① ho_handover(action="read") 读旧交接;② action="archive" 归档;③ 读模板 _Control_Center/templates/Handover.md;④ action="new" 新建空白写入。之后由全局 ho_sessions(action="new") 建立新会话。');
65
66
  return { text: out.join(NL) };
66
67
  }
67
- if (adv.action === "pause" && args.at_risk === true && args.force !== true) {
68
- out.push("已暂停(关键步骤):" + adv.reason);
69
- out.push("到阶段边界再调一次 ho_checkpoint(不带 at_risk 或带 force)。");
68
+ if (args.at_risk === true && args.force !== true && ratio !== null && ratio < (env.cfg.hardRatio || 0.85)) {
69
+ out.push("已暂缓(关键步骤):水位 " + ratioText + ",先完成当前小步骤再压缩;到阶段边界再调用一次 ho_checkpoint(不带 at_risk,或带 force)。");
70
70
  return { text: out.join(NL) };
71
71
  }
72
72
  if (adv.action === "continue" && args.force !== true) {
73
- out.push("水位未到线(" + ratioText + "):不压。");
74
- out.push("需要强制演练可加 force=true。");
73
+ out.push("水位未到可压缩线(" + ratioText + "):不压缩。");
74
+ out.push("需要提前压缩时,先向当前用户说明理由并取得授权,再加 force=true;当前用户主动要求时同样处理。");
75
75
  return { text: out.join(NL) };
76
76
  }
77
77
  // 判断逻辑不动;只有判定可以压缩之后,才停下来问用户。
78
78
  if (args.confirm !== true) {
79
79
  out.push('判定:可以压缩(' + adv.reason + ')。');
80
- out.push('水位 ' + ratioText + ',已压缩 ' + count.used + '/3。');
81
- out.push('压缩会丢历史并自动续跑 —— 先问当前用户「要压缩吗?」,回答同意后再调 ho_checkpoint(confirm=true);不同意就保持原样。');
80
+ out.push('水位 ' + ratioText + ',已压缩 ' + count.used + '/' + env.cfg.maxCompactions + '。');
81
+ out.push('压缩会丢失历史并自动续跑。先向当前用户确认是否压缩,同意后再调用 ho_checkpoint(confirm=true);不同意则保持原状。');
82
82
  return { text: out.join(NL) };
83
83
  }
84
84
  let wrote = null;
85
85
  if (typeof args.handover_text === "string" && args.handover_text.trim() !== "") {
86
- wrote = writeHandover(ws, file, args.handover_text, "write", { kind: target.kind, info });
87
- out.push("交接已写入 " + file + "(" + wrote.bytes + " B,备份:" + (wrote.backup || "无") + ")· 角色 " + info.role);
86
+ wrote = rewriteHandover(ws, file, args.handover_text, { kind: target.kind, info });
87
+ out.push("交接已写入 " + file + "(" + wrote.bytes + " B)· 角色 " + info.role);
88
+ if (wrote.archived) out.push("旧交接已归档 " + wrote.archived.dest + "(sha256 一致)");
88
89
  out.push("体检 " + wrote.verdict + (wrote.reasons.length ? ":" : " · 结构齐备"));
89
90
  for (const r of wrote.reasons) out.push(" - " + r);
90
91
  if (wrote.verdict !== "通过") {
91
- out.push("体检不通过 ⇒ 不排压缩(先补结构,压缩后就没机会改了)。");
92
+ out.push("体检未通过,不排压缩(先补齐结构;压缩后无法再改)。");
92
93
  return { text: out.join(NL) };
93
94
  }
94
95
  } else {
95
96
  const cur = readHandover(file);
96
97
  if (cur.text === null || cur.text.trim() === "") {
97
98
  out.push("没有交接正文,磁盘上也没有:" + file);
98
- out.push("拒绝压缩 —— 压缩会丢历史,必须先有交接。");
99
+ out.push("拒绝压缩:压缩会丢失历史,必须先有交接。");
99
100
  return { text: out.join(NL) };
100
101
  }
101
102
  out.push("沿用磁盘上已有交接:" + file);
@@ -105,28 +106,30 @@ export function pmCheckpointTool(env) {
105
106
  // 会话自行判断水位与询问用户,用户点授权后自行排压缩,不必上报全局。
106
107
  const cs = checkConsent(meId);
107
108
  if (!cs.ok) {
108
- out.push('交接已写好,但**没有排压缩**:' + cs.reason + '。');
109
- out.push('授权方式(任选其一):① 在聊天框发送 /allow-compact(最快,仅授权本会话);② 在设置页的 dsh-handover 中点击「授权压缩」一次。授权有效期 5 分钟,其后须再调用 ho_checkpoint(confirm=true)。');
110
- out.push('如此设限的原因:压缩会丢弃历史,而参数由 AI 自行填写——惟有当前用户在设置页当面点击的那一次,方视为同意。');
109
+ out.push('交接已写好,但未排压缩:' + cs.reason + '。');
110
+ out.push('授权方式(任选其一):① 在聊天框发送 /allow-compact(仅授权本会话,最为快捷);② 在设置页的 dsh-handover 中点击「授权压缩」一次。授权有效期 5 分钟,逾期须重新授权后再调用 ho_checkpoint(confirm=true)。');
111
+ out.push('限制原因:压缩会丢弃历史,而调用参数由 AI 自行填写;只有当前用户亲自点击的授权,才视为同意。');
111
112
  return { text: out.join(NL) };
112
113
  }
113
- // 成本提示:低于交接线强行压缩会白付一次摘要,且本轮前缀缓存失效。
114
+ // 成本提示:低于可压缩线强行压缩会多付一次摘要,且本轮前缀缓存失效。
114
115
  const handoverRatio = (env.cfg && env.cfg.handoverRatio) || 0.35;
115
116
  if (ratio !== null && ratio < handoverRatio) {
116
- out.push('注意成本:当前水位 ' + (Math.round(ratio * 1000) / 10) + '% 低于交接线 ' + Math.round(handoverRatio * 100) + '% —— 属于提前压缩,会白付一次摘要调用,且本轮前缀缓存失效。');
117
+ out.push('成本提示:当前水位 ' + (Math.round(ratio * 1000) / 10) + '% 低于可压缩线 ' + Math.round(handoverRatio * 100) + '%,属提前压缩,会多付一次摘要调用,且本轮前缀缓存失效。');
117
118
  }
118
119
  consumedConsent(meId);
120
+ // 压缩后第一轮的水位读数是压缩前的旧投影,跳过那一轮提醒判定,免得用虚高读数用掉一次性提醒。
121
+ markSkipNext(meId);
119
122
  const n = count.used + 1;
120
123
  const resume = args.resume === 'wait' ? 'wait' : 'continue';
121
124
  env.schedule(meId, n, { resume });
122
- out.push('当前用户已授权(' + cs.atText + ' 点击,剩 ' + Math.round(cs.leftMs / 1000) + ' 秒)。');
123
- out.push("已登记压缩(第 " + n + "/" + env.cfg.maxCompactions + " 次)。本回合结束后执行。");
125
+ out.push('当前用户已授权(' + cs.atText + ' 点击,剩余 ' + Math.round(cs.leftMs / 1000) + ' 秒)。');
126
+ out.push("已登记压缩(第 " + n + "/" + env.cfg.maxCompactions + " 次),本回合结束后执行。");
124
127
  if (resume === 'wait') {
125
- out.push("去向:wait —— 压缩完成后不会唤醒本会话,等你派新任务。");
128
+ out.push("去向:wait —— 压缩完成后不唤醒本会话,等待当前用户派新任务。");
126
129
  } else {
127
- out.push("去向:continue —— 压缩完成后自动收到续跑消息;若 60 秒内没有续跑消息,用 ho_status 看状态。");
130
+ out.push("去向:continue —— 压缩完成后自动收到续跑消息;若 60 秒内未收到续跑消息,用 ho_status 查看状态。");
128
131
  }
129
- out.push("压缩期间不要结束会话。");
132
+ out.push("压缩期间不得结束会话。");
130
133
  return { text: out.join(NL) };
131
134
  },
132
135
  };
@@ -1,10 +1,10 @@
1
1
  /**
2
- * ho_docmap —— 本会话的文档定位 map(只指路,不规定读多少)。
2
+ * ho_docmap —— 体系注册与文档定位(1.0)。
3
3
  *
4
- * 写入范围由会话已登记的角色决定,不接受传入项目名或任务名:全局只写 global.md,
5
- * 项目只写 project-<本项目>.md,杂项只写 misc-<本任务>.md。
4
+ * 动作:read 读、write 写、check 校验路径、plan 读写与归档行动计划。
5
+ * 写入范围由会话已登记的角色决定:全局只写总注册,项目只写本项目注册,杂项只写本任务注册。
6
6
  * 写入者必须是在任会话(以 Office.json 为准);已交出的旧会话只读。
7
- * 不设字数上限;写盘前逐条校验路径存在,缺项整条拒绝并列出。
7
+ * 写入后重装本会话提示段,实现热更新。
8
8
  */
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
@@ -13,11 +13,14 @@ import { nameOf } from '../lib/names.mjs';
13
13
  import { NL } from '../lib/text.mjs';
14
14
  import { loadOffice, lineageKeyOf } from '../lib/office.mjs';
15
15
  import {
16
- lineageOf, lineageLabel, docmapRelPath, docmapAbsPath, readDocmap,
17
- renderDocmap, validateEntries,
18
- } from '../lib/docmap.mjs';
16
+ lineageOf, lineageLabel, registryRelPath, registryAbsPath, readRegistry, readGlobalRegistry,
17
+ validateEntries, checkRegistries, writeRegistry, syncGlobalRefs,
18
+ planRelPath, readPlan, writePlan, archivePlan, pointRegistryToPlan,
19
+ GLOBAL_REGISTRY_REL, REGISTRY_NAME,
20
+ } from '../lib/system.mjs';
21
+ import { taskNameOk } from '../lib/role.mjs';
19
22
 
20
- export const ACTIONS = ['read', 'write', 'check'];
23
+ export const ACTIONS = ['read', 'write', 'check', 'plan'];
21
24
 
22
25
  function ownLineage(env, exec) {
23
26
  let info = null;
@@ -38,15 +41,29 @@ function holderOf(ws, lin) {
38
41
  }
39
42
 
40
43
  function entriesText(entries) {
41
- if (!entries.length) return '(空 map:不注入提示段)';
44
+ if (!entries.length) return '(空:不注入正文)';
42
45
  return entries.map((e) => '- ' + e.path + (e.about ? ' —— ' + e.about : '') + (e.when ? '(' + e.when + ')' : '')).join(NL);
43
46
  }
44
47
 
48
+ function targetInfoFor(info, task) {
49
+ if (info && info.role === 'global') {
50
+ const t = String(task || '').trim();
51
+ if (!t) return null;
52
+ if (!taskNameOk(t)) throw new Error('任务名不合法(当目录名用):' + t);
53
+ return { role: 'global-task', task: t };
54
+ }
55
+ return info;
56
+ }
57
+
58
+ function nodePath(ws, ...parts) {
59
+ return path.join(String(ws || ''), ...parts);
60
+ }
61
+
45
62
  export function pmDocmapTool(env) {
46
63
  return {
47
64
  name: nameOf('docmap'),
48
65
  description:
49
- '本会话的文档定位:action=read 读、write 写(逐条校验路径存在)、check 查自己的 map;只指路,不规定读多少。',
66
+ '体系注册与文档定位:read 读、write 写(逐条校验路径存在)、check 校验、plan 读写并归档行动计划;全局总注册只列路径,全局任务用 task 读写自身注册与计划。',
50
67
  parameters: {
51
68
  type: 'object',
52
69
  additionalProperties: false,
@@ -54,99 +71,168 @@ export function pmDocmapTool(env) {
54
71
  action: { type: 'string', enum: ACTIONS, description: '缺省 read' },
55
72
  entries: {
56
73
  type: 'array',
57
- description: 'write:整份替换。每项 { path: 工作区相对路径, about: 一句话介绍, when: 何时读 }',
74
+ description: 'write:整份替换。每项 { path: 工作区相对路径, about: 用途, when: 读取时机, section: structure|files };section 缺省 files',
58
75
  items: {
59
76
  type: 'object',
60
77
  additionalProperties: false,
61
78
  required: ['path', 'about'],
62
79
  properties: {
63
80
  path: { type: 'string', description: '工作区相对路径,必须存在' },
64
- about: { type: 'string', description: '一句话介绍' },
65
- when: { type: 'string', description: '什么时候该读(可选,建议写)' },
81
+ about: { type: 'string', description: '用途,一句话' },
82
+ when: { type: 'string', description: '读取时机(可选)' },
83
+ section: { type: 'string', enum: ['structure', 'files'], description: '目录结构或规范文件(缺省 files)' },
66
84
  },
67
85
  },
68
86
  },
87
+ mode: { type: 'string', enum: ['read', 'write', 'archive'], description: 'action=plan 时的动作,缺省 read' },
88
+ text: { type: 'string', description: 'action=plan、mode=write 时的行动计划正文' },
89
+ task: { type: 'string', description: '全局会话操作某个任务时给任务名(项目与杂项会话省略)' },
69
90
  },
70
91
  },
71
92
  output: textOutput(),
72
93
  async execute(args, exec) {
73
94
  const ws = env.resolveWs(exec);
74
95
  const { info, lin } = ownLineage(env, exec);
75
- if (!lin) throw new Error('本会话没有角色:文档定位只对全局、项目、杂项会话开放');
96
+ if (!lin) throw new Error('本会话没有角色:体系注册只对全局、项目、杂项会话开放');
76
97
  const action = typeof args.action === 'string' && args.action ? args.action : 'read';
77
98
  if (ACTIONS.indexOf(action) === -1) throw new Error('action 只能是 ' + ACTIONS.join(' | ') + '(收到 ' + String(action) + ')');
78
- const rel = docmapRelPath(info);
79
- const abs = docmapAbsPath(ws, info);
80
99
  const label = lineageLabel(info);
100
+ const meId = String(env.selfId(exec) || '');
81
101
 
82
102
  if (action === 'read') {
83
- const map = readDocmap(ws, info);
84
- const out = [];
85
- out.push('文档定位(' + label + ') ' + rel);
86
- out.push(map.exists ? '状态 已设置,' + map.entries.length + ' 条' : '状态 未设置(空 map 不注入提示段)');
87
- if (map.entries.length) out.push('', entriesText(map.entries));
103
+ const taskName = String(args.task || '').trim();
104
+ if (lin.kind === 'global' && taskName) {
105
+ const target = targetInfoFor(info, taskName);
106
+ const reg = readRegistry(ws, target);
107
+ const rel = registryRelPath(target);
108
+ const out = ['体系注册(' + lineageLabel(target) + ') ' + rel];
109
+ out.push(reg.exists ? '状态 已设置,目录 ' + reg.data.structure.length + ' 条、规范 ' + reg.data.files.length + ' 条' : '状态 未设置');
110
+ if (reg.data.plan) out.push('计划 ' + reg.data.plan);
111
+ if (reg.data.structure.length) out.push('', '目录结构:', entriesText(reg.data.structure));
112
+ if (reg.data.files.length) out.push('', '规范文件:', entriesText(reg.data.files));
113
+ return { text: out.join(NL) };
114
+ }
115
+ if (lin.kind === 'global') {
116
+ const g = readGlobalRegistry(ws);
117
+ const out = ['体系注册(全局) ' + GLOBAL_REGISTRY_REL, g.rows.length ? '任务 ' + g.rows.length + ' 个' : '暂无任务注册'];
118
+ for (const r of g.rows) out.push(' ' + r.kind + '「' + r.task + '」 ' + r.path);
119
+ return { text: out.join(NL) };
120
+ }
121
+ const reg = readRegistry(ws, info);
122
+ const rel = registryRelPath(info);
123
+ const out = ['体系注册(' + label + ') ' + rel];
124
+ out.push(reg.exists ? '状态 已设置,目录 ' + reg.data.structure.length + ' 条、规范 ' + reg.data.files.length + ' 条' : '状态 未设置(空注册只注入一句提示)');
125
+ if (reg.data.plan) out.push('计划 ' + reg.data.plan);
126
+ if (reg.data.structure.length) out.push('', '目录结构:', entriesText(reg.data.structure));
127
+ if (reg.data.files.length) out.push('', '规范文件:', entriesText(reg.data.files));
88
128
  return { text: out.join(NL) };
89
129
  }
90
130
 
91
131
  if (action === 'check') {
92
- const map = readDocmap(ws, info);
93
- const bad = validateEntries(ws, map.entries);
94
- const out = [];
95
- out.push('文档定位体检(' + label + ') ' + rel);
96
- out.push(map.exists ? '条目 ' + map.entries.length + ' 条' : '未设置(空 map 不注入)');
97
- for (const b of bad) out.push(' 失效:' + b.path + ' —— ' + b.problem);
98
- out.push(bad.length ? '判定:' + bad.length + ' 条失效,请更新 map。' : '判定:路径全部存在。');
132
+ const res = lin.kind === 'global' ? checkRegistries(ws) : checkRegistries(ws, info);
133
+ const out = ['体系注册体检 ' + (lin.kind === 'global' ? '全部任务' : label)];
134
+ if (!res.rows.length) out.push(' 尚无任何任务注册(空注册不注入正文,属正常)');
135
+ for (const r of res.rows) {
136
+ out.push(' ' + r.label + ' ' + r.rel + ' ' + (r.exists ? r.entries + ' 条' : '未设置') + ' ' + (r.bad.length ? '失效 ' + r.bad.length + ' 条' : '路径全部存在'));
137
+ for (const b of r.bad.slice(0, 5)) out.push(' 失效:' + b.path + ' —— ' + b.problem);
138
+ }
139
+ out.push(res.missing ? '判定:' + res.missing + ' 条路径失效,由该类别在任会话用 ho_docmap(action="write") 更新。' : '判定:路径全部存在。');
140
+ return { text: out.join(NL) };
141
+ }
142
+
143
+ if (action === 'plan') {
144
+ const mode = typeof args.mode === 'string' && args.mode ? args.mode : 'read';
145
+ let target = null;
146
+ try { target = targetInfoFor(info, args.task); } catch (e) { throw e; }
147
+ if (!target) throw new Error('全局会话读写计划时须给 task(任务名)');
148
+ const rel = planRelPath(ws, target);
149
+ if (!rel) throw new Error('没有可用的计划路径:角色未定');
150
+ if (mode === 'read') {
151
+ const p = readPlan(ws, target);
152
+ if (!p.exists) return { text: '计划未建立:' + rel + NL + '层二任务应先写行动计划,模板见 _Control_Center/templates/Plan.skeleton.md。' };
153
+ if (p.error) throw new Error(p.error);
154
+ return { text: p.text };
155
+ }
156
+ if (mode === 'archive') {
157
+ const archived = archivePlan(ws, target);
158
+ return { text: archived ? '旧计划已归档:' + archived : '没有可归档的旧计划:' + rel };
159
+ }
160
+ const text = String(args.text || '');
161
+ if (!text.trim()) throw new Error('mode=write 需要 text(行动计划正文)');
162
+ const r = writePlan(ws, target, text);
163
+ let pointed = false;
164
+ try { pointed = pointRegistryToPlan(ws, target); } catch { pointed = false; }
165
+ let refreshed = false;
166
+ try { refreshed = env.refreshPromptFor(meId) === true; } catch { refreshed = false; }
167
+ const out = ['已写入计划 ' + r.rel + '(' + r.bytes + ' B)' + (r.archived ? ';旧计划已归档 ' + r.archived : ';无旧计划')];
168
+ out.push(pointed ? '注册已指向该计划。' : '本任务尚无体系注册,写入注册后再指向计划。');
169
+ out.push(refreshed ? '提示段已重装。' : '提示段未能当场重装:下一轮或重启后生效。');
99
170
  return { text: out.join(NL) };
100
171
  }
101
172
 
102
173
  // action=write
103
174
  const holder = holderOf(ws, lin);
104
- const meId = String(env.selfId(exec) || '');
105
175
  if (!holder.current) {
106
- throw new Error('登记表里没有「' + (holder.key || label) + '」的在任记录:以在任记录为写入依据,先由全局完成登记');
176
+ throw new Error('名册里没有「' + (holder.key || label) + '」的在任记录:以在任记录为写入依据,先完成名称注册(ho_office action="register")');
107
177
  }
108
178
  if (!meId || String(holder.current.sessionId || '') !== meId) {
109
179
  throw new Error('只有在任会话可以写:本条登记的在任会话为 ' + (holder.current.name || '?') + '(' + holder.current.sessionId + '),本会话为只读');
110
180
  }
181
+
182
+ if (lin.kind === 'global' && !String(args.task || '').trim()) {
183
+ const g = syncGlobalRefs(ws);
184
+ let refreshed = false;
185
+ try { refreshed = env.refreshPromptFor(meId) === true; } catch { refreshed = false; }
186
+ const out = ['已按磁盘任务重建全局总注册 ' + GLOBAL_REGISTRY_REL + '(' + g.rows.length + ' 个任务)'];
187
+ for (const r of g.rows) out.push(' ' + r.kind + '「' + r.task + '」 ' + r.path);
188
+ out.push(refreshed ? '提示段已重装:下一轮即注入引用清单。' : '提示段未能当场重装:重启 DSH 或让会话重建后生效。');
189
+ return { text: out.join(NL) };
190
+ }
191
+
192
+ // 全局任务:由全局会话直接维护该任务的体系注册。
193
+ const writeInfo = lin.kind === 'global'
194
+ ? targetInfoFor(info, String(args.task || '').trim())
195
+ : info;
196
+ if (!writeInfo) throw new Error('全局会话写任务注册时需要 task(任务名)');
111
197
  const list = Array.isArray(args.entries) ? args.entries : null;
112
- if (!list) throw new Error('write 需要 entries 数组(每项 path、about、when)');
113
- const entries = [];
198
+ if (!list) throw new Error('write 需要 entries 数组(每项 path、about、when、section)');
199
+ const structure = [];
200
+ const files = [];
114
201
  for (const raw of list) {
115
202
  const e = raw || {};
116
203
  const p = String(e.path === undefined || e.path === null ? '' : e.path).trim();
117
204
  const about = String(e.about === undefined || e.about === null ? '' : e.about).trim();
118
205
  const when = String(e.when === undefined || e.when === null ? '' : e.when).trim();
206
+ const section = e.section === 'structure' ? 'structure' : 'files';
119
207
  if (!p) throw new Error('entries 里有空路径');
120
208
  for (const [field, val] of [['path', p], ['about', about], ['when', when]]) {
121
209
  if (val.indexOf('|') !== -1) throw new Error(field + ' 里不能出现竖线「|」(表格分隔符):' + val);
122
210
  if (val.indexOf(String.fromCharCode(10)) !== -1) throw new Error(field + ' 里不能换行');
123
211
  }
124
- entries.push({ path: p, about, when });
212
+ (section === 'structure' ? structure : files).push({ path: p, about, when });
125
213
  }
126
- const bad = validateEntries(ws, entries);
214
+ const bad = validateEntries(ws, structure.concat(files));
127
215
  if (bad.length) {
128
216
  const lines = ['未写入:下列路径不存在或不合法,逐条修正后重试。'];
129
217
  for (const b of bad) lines.push(' - ' + b.path + ':' + b.problem);
130
- lines.push('路径一律写工作区相对路径(例:Projects/<项目>/SKILL.md)。');
218
+ lines.push('路径一律写工作区相对路径(例:Projects/<项目>/README.md)。');
131
219
  throw new Error(lines.join(NL));
132
220
  }
133
- fs.mkdirSync(path.dirname(abs), { recursive: true });
134
- let backup = '';
135
- if (fs.existsSync(abs)) {
136
- backup = abs + '.bak';
137
- try { fs.copyFileSync(abs, backup); } catch { backup = ''; }
221
+ const cur = readRegistry(ws, writeInfo);
222
+ const plan = cur.data.plan || '';
223
+ if (plan) {
224
+ const problem = validateEntries(ws, [{ path: plan }]);
225
+ if (problem.length) throw new Error('注册里的计划路径已失效:' + plan + NL + '先修正计划(ho_docmap action="plan")或删除该行。');
138
226
  }
139
- const body = renderDocmap(info, entries);
140
- const tmp = abs + '.tmp-' + process.pid;
141
- fs.writeFileSync(tmp, body, 'utf8');
142
- fs.renameSync(tmp, abs);
227
+ const r = writeRegistry(ws, writeInfo, { structure, files, plan });
228
+ if (lin.kind === 'global') syncGlobalRefs(ws);
143
229
  let refreshed = false;
144
- try { if (typeof env.refreshPromptFor === 'function') refreshed = env.refreshPromptFor(meId) === true; } catch { refreshed = false; }
230
+ try { refreshed = env.refreshPromptFor(meId) === true; } catch { refreshed = false; }
145
231
  const out = [];
146
- out.push('已写入 ' + rel + '(' + entries.length + ' 条)' + (backup ? ';原文件备份为 ' + path.basename(backup) : ''));
147
- out.push(refreshed ? '提示段已重装:下一轮即按新 map 注入。' : '提示段未能在本进程重装:重启 DSH 或让会话重建后生效。');
148
- out.push('建议精炼:每条一句介绍、一句时机即可;不设字数上限,但太长的介绍会挤占提示段。');
149
- if (!entries.length) out.push('空 map:不注入提示段(相当于撤销)。');
232
+ out.push('已写入 ' + registryRelPath(writeInfo) + '(目录 ' + structure.length + ' 条、规范 ' + files.length + ' 条)'
233
+ + (r.backup ? ';原文件备份为 ' + path.basename(r.backup) : ''));
234
+ out.push(refreshed ? '提示段已重装:下一轮即按新注册注入。' : '提示段未能当场重装:重启 DSH 或让会话重建后生效。');
235
+ out.push('只给路径与用途;不设字数上限,但过长的用途会挤占提示段。');
150
236
  return { text: out.join(NL) };
151
237
  },
152
238
  };
@@ -6,6 +6,7 @@ import { textOutput } from '../lib/output.mjs';
6
6
  import { NL } from '../lib/text.mjs';
7
7
  import { syncCheck, renderSyncCheck, docmapReport, renderDocmapCheck } from '../lib/gates.mjs';
8
8
  import { lintGlobal, lintProject, renderLintGlobal, renderLintProject } from '../lib/lint.mjs';
9
+ import { legacyInfo } from '../lib/legacy.mjs';
9
10
  import { SETUP_STEPS } from '../lib/guide.mjs';
10
11
 
11
12
  function lintAllProjects(ws) {
@@ -26,12 +27,12 @@ export function pmGateTool(env) {
26
27
  return {
27
28
  name: nameOf('gate'),
28
29
  description:
29
- '通用约束条闸门(按库型判定)+交接文档体检。只读。',
30
+ '通用约束条闸门(按库型判定)、交接文档体检与体系注册路径检查。只读。',
30
31
  parameters: {
31
32
  type: 'object',
32
33
  additionalProperties: false,
33
34
  properties: {
34
- scope: { type: 'string', enum: ['all', 'global', 'project'], description: '默认 all' },
35
+ scope: { type: 'string', enum: ['all', 'global', 'project'], description: '缺省 all' },
35
36
  project: { type: 'string', description: 'scope=project 时的项目名' },
36
37
  },
37
38
  },
@@ -47,16 +48,20 @@ export function pmGateTool(env) {
47
48
  const g = syncCheck(ws);
48
49
  if (g.noWorkspace) {
49
50
  out.push('通用约束条闸门:未接入工作区(' + g.path + ')');
50
- out.push('把工作区接入就好,一共四步:');
51
+ out.push('接入工作区共 4 步:');
51
52
  SETUP_STEPS.forEach((s, i) => out.push(' ' + (i + 1) + '. ' + s));
52
53
  return { text: out.join(NL) };
53
54
  }
54
55
  out.push(renderSyncCheck(g).join(NL));
55
56
  out.push(renderLintProject(null, lintAllProjects(ws)).join(NL));
56
57
  out.push(renderDocmapCheck(docmapReport(ws)).join(NL));
58
+ try {
59
+ const legacy = legacyInfo(ws);
60
+ if (legacy.exists) out.push('旧文档定位待迁移:' + legacy.dir + '(ho_workspace action="migrate")');
61
+ } catch { /* 旧目录检查失败不影响闸门 */ }
57
62
  } else if (scope === "project") {
58
63
  const name = args.project;
59
- if (!name) throw new Error("scope=project 时必须给 project 参数");
64
+ if (!name) throw new Error("scope=project 时须给 project 参数");
60
65
  const f = path.join(ws, "_Project_Meta", name, "Handover.md");
61
66
  out.push(renderLintProject(name, [{ name, file: f, res: lintProject(f) }]).join(NL));
62
67
  }