@mingmaple/dsh-handover 0.9.14 → 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 (102) hide show
  1. package/README.md +123 -92
  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 +34 -0
  6. package/docs/releases/1.0.0.md +33 -0
  7. package/docs/troubleshoot.md +230 -46
  8. package/package.json +74 -71
  9. package/scripts/behavior.mjs +398 -0
  10. package/scripts/blank-dsh.mjs +41 -25
  11. package/scripts/blank-smoke.mjs +278 -109
  12. package/scripts/check-docs.mjs +7 -0
  13. package/scripts/e2e-migration.mjs +2 -2
  14. package/scripts/office.mjs +3 -3
  15. package/scripts/portability.mjs +71 -0
  16. package/scripts/style-gate.mjs +571 -0
  17. package/src/client.js +1028 -955
  18. package/src/index.mjs +313 -142
  19. package/src/lib/absrefs.mjs +1 -1
  20. package/src/lib/api.mjs +18 -10
  21. package/src/lib/archive.mjs +1 -1
  22. package/src/lib/brief.mjs +8 -6
  23. package/src/lib/config.mjs +23 -8
  24. package/src/lib/consent.mjs +54 -1
  25. package/src/lib/docmap.mjs +140 -0
  26. package/src/lib/drift.mjs +1 -1
  27. package/src/lib/events.mjs +1 -1
  28. package/src/lib/facts.mjs +19 -12
  29. package/src/lib/gates.mjs +30 -6
  30. package/src/lib/global-task.mjs +69 -0
  31. package/src/lib/guide.mjs +81 -85
  32. package/src/lib/handover-io.mjs +69 -6
  33. package/src/lib/identity.mjs +330 -0
  34. package/src/lib/legacy.mjs +89 -0
  35. package/src/lib/lint.mjs +17 -13
  36. package/src/lib/manual.mjs +135 -59
  37. package/src/lib/mover.mjs +23 -19
  38. package/src/lib/names.mjs +14 -19
  39. package/src/lib/office.mjs +63 -15
  40. package/src/lib/pack-ops.mjs +11 -11
  41. package/src/lib/pathkey.mjs +24 -0
  42. package/src/lib/peer.mjs +4 -4
  43. package/src/lib/phase.mjs +8 -13
  44. package/src/lib/profile.mjs +1 -1
  45. package/src/lib/reminder.mjs +110 -0
  46. package/src/lib/role.mjs +9 -6
  47. package/src/lib/scaffold.mjs +68 -26
  48. package/src/lib/schedule.mjs +5 -6
  49. package/src/lib/session-create.mjs +3 -3
  50. package/src/lib/session-ops.mjs +80 -51
  51. package/src/lib/sessions.mjs +41 -3
  52. package/src/lib/sevenzip.mjs +10 -1
  53. package/src/lib/status.mjs +337 -176
  54. package/src/lib/succession.mjs +16 -16
  55. package/src/lib/system.mjs +364 -0
  56. package/src/lib/zstd-log.mjs +1 -1
  57. package/src/selftest.mjs +6 -6
  58. package/src/templates/Changelog.skeleton.md +3 -3
  59. package/src/templates/Charter.skeleton.md +13 -9
  60. package/src/templates/Handover.global.skeleton.md +20 -13
  61. package/src/templates/Handover.template.md +89 -0
  62. package/src/templates/Plan.skeleton.md +34 -0
  63. package/src/templates/Prompt_Templates.md +101 -20
  64. package/src/templates/Runtime_Manual.md +263 -122
  65. package/src/templates/System.skeleton.md +31 -0
  66. package/src/templates/Systems.global.skeleton.md +13 -0
  67. package/src/templates/rules/Delegation_Rules.md +45 -0
  68. package/src/templates/rules/Handover_Rules.md +46 -23
  69. package/src/templates/rules/Migration_Rules.md +7 -7
  70. package/src/templates/rules/Misc_Task_Standard.md +17 -10
  71. package/src/templates/rules/Writing_Rules.md +33 -16
  72. package/src/templates/standards/README.md +37 -0
  73. package/src/templates/standards//345/206/231/344/275/234/350/247/204/350/214/203/README.md +120 -0
  74. 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
  75. package/src/tools/ho_backup.mjs +135 -0
  76. package/src/tools/ho_changelog.mjs +4 -4
  77. package/src/tools/ho_charter.mjs +7 -6
  78. package/src/tools/ho_checkpoint.mjs +36 -32
  79. package/src/tools/ho_docmap.mjs +239 -0
  80. package/src/tools/ho_gate.mjs +11 -5
  81. package/src/tools/ho_handover.mjs +61 -35
  82. package/src/tools/ho_office.mjs +46 -5
  83. package/src/tools/ho_profile.mjs +3 -3
  84. package/src/tools/ho_sessions.mjs +46 -26
  85. package/src/tools/ho_status.mjs +251 -0
  86. package/src/tools/ho_succeed.mjs +3 -3
  87. package/src/tools/ho_workspace.mjs +146 -42
  88. package/src/tools/peer.mjs +416 -0
  89. package/src/tools/ho_brief.mjs +0 -57
  90. package/src/tools/ho_diag.mjs +0 -87
  91. package/src/tools/ho_drift.mjs +0 -41
  92. package/src/tools/ho_init.mjs +0 -82
  93. package/src/tools/ho_pack.mjs +0 -38
  94. package/src/tools/ho_phase.mjs +0 -37
  95. package/src/tools/ho_snapshot.mjs +0 -104
  96. package/src/tools/ho_tokens.mjs +0 -57
  97. package/src/tools/peer_list.mjs +0 -37
  98. package/src/tools/peer_new.mjs +0 -75
  99. package/src/tools/peer_peek.mjs +0 -41
  100. package/src/tools/peer_rename.mjs +0 -31
  101. package/src/tools/peer_send.mjs +0 -52
  102. package/src/tools/peer_status.mjs +0 -46
@@ -99,7 +99,7 @@ export function renderAbsoluteRefs(res, opts) {
99
99
  if (!res.totalHits) {
100
100
  out.push(' 未发现写死的本机绝对路径');
101
101
  } else {
102
- out.push(' 发现 ' + res.hits.length + ' 个文件里有 ' + res.totalHits + ' 处绝对路径(**插件不改写这些内容**):');
102
+ out.push(' 发现 ' + res.hits.length + ' 个文件中有 ' + res.totalHits + ' 处绝对路径(插件不改写这些内容):');
103
103
  for (const h of res.hits.slice(0, o.max || 10)) {
104
104
  out.push(' ' + h.path);
105
105
  for (const it of h.items) out.push(' L' + it.line + ' [' + it.kind + '] ' + it.sample);
package/src/lib/api.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
+ import { pathKey } from './pathkey.mjs';
8
9
  import { isDir } from './text.mjs';
9
10
  import { buildStatus } from './status.mjs';
10
11
  import { createGlobalWorkspace } from './scaffold.mjs';
@@ -79,8 +80,12 @@ async function createGlobalSession(env, rootPath, title) {
79
80
  try {
80
81
  const reg = env.registry && env.registry();
81
82
  const list = reg && typeof reg.list === 'function' ? (reg.list() || []) : [];
82
- const norm = (s) => String(s || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
83
- const hit = list.find((w) => w && norm(w.path) === norm(rootPath));
83
+ const norm = (s) => pathKey(String(s || ''));
84
+ const same = (a, b) => {
85
+ if (norm(a) === norm(b)) return true;
86
+ try { return norm(fs.realpathSync(String(a))) === norm(fs.realpathSync(String(b))); } catch { return false; }
87
+ };
88
+ const hit = list.find((w) => w && same(w.path, rootPath));
84
89
  if (hit) workspaceId = String(hit.id || '');
85
90
  } catch { workspaceId = ''; }
86
91
  const made = await newSessionIn(env, {
@@ -157,7 +162,7 @@ export function createApi(env, info) {
157
162
  if (!st || !st.isDirectory()) return bad('bad-request', '目录不存在或不是目录:' + target);
158
163
  const reg = env.registry && env.registry();
159
164
  if (!reg || typeof reg.create !== 'function') {
160
- return { ok: false, error: { code: 'no-registry', message: 'workspaceRegistry 不可用', details: { hint: '请手工把这个路径粘进 DSH 的新建工作区:' + target } }, http: 501 };
165
+ return { ok: false, error: { code: 'no-registry', message: 'workspaceRegistry 不可用', details: { hint: '请手工将该路径粘贴到 DSH 的新建工作区:' + target } }, http: 501 };
161
166
  }
162
167
  const title = String(p.title || '').trim() || target.split(/[\\/]/).filter(Boolean).pop() || target;
163
168
  try {
@@ -203,6 +208,7 @@ export function createApi(env, info) {
203
208
  project: p.project,
204
209
  task: p.task,
205
210
  wake: p.wake !== false,
211
+ migrate: p.migrate === 'direct' ? 'direct' : 'auto',
206
212
  });
207
213
  env.log('会话角色:' + (r.ok ? (r.role + ' · 存活=' + r.live + ' · 已投递=' + r.delivered) : ('失败 ' + r.error)));
208
214
  invalidateStatus(env);
@@ -218,21 +224,21 @@ export function createApi(env, info) {
218
224
  if (method === 'move.start') {
219
225
  const src = currentRoot(env);
220
226
  if (!src) return bad('no-workspace', '未找到全局根(工作区未接入)');
221
- env.log('搬迁:开始复制 ' + src + ' → ' + String(p.to || ''));
227
+ env.log('迁移:开始复制 ' + src + ' → ' + String(p.to || ''));
222
228
  const startedAt = Date.now();
223
229
  const r = await startMove({ src, to: p.to });
224
- env.log('搬迁:' + (r.ok ? '校验通过' : '校验有差异') + ',用时 ' + Math.round((Date.now() - startedAt) / 1000) + ' s');
230
+ env.log('迁移:' + (r.ok ? '校验通过' : '校验有差异') + ',用时 ' + Math.round((Date.now() - startedAt) / 1000) + ' s');
225
231
  return r.ok ? ok(r) : { ok: false, error: { code: 'verify-failed', message: '复制完成但校验有差异,未生成切换脚本', details: r.report }, http: 500 };
226
232
  }
227
233
 
228
234
  if (method === 'move.switch') {
229
235
  const r = stageSwitch({ relaunch: p.relaunch === true });
230
- env.log('搬迁:后台切换助手 ' + (r.already ? '无需再派(已切换)' : '已派出 pid=' + r.pid));
236
+ env.log('迁移:后台切换助手 ' + (r.already ? '无需再派(已切换)' : '已派出 pid=' + r.pid));
231
237
  return ok(r);
232
238
  }
233
239
 
234
240
  if (method === 'move.status') {
235
- // 一律以搬迁标记里的源路径为准:修改登记生效后当前工作区根已经变成新位置,
241
+ // 一律以迁移标记里的源路径为准:修改登记生效后当前工作区根已经变成新位置,
236
242
  // 再拿它去比对会把「已经是新位置」误报成「还指着旧路径」。
237
243
  return ok(moveStatus());
238
244
  }
@@ -242,6 +248,8 @@ export function createApi(env, info) {
242
248
  if (!sid) return bad('bad-request', '缺少 session_id');
243
249
  const kind = p.kind === 'succeed' ? 'succeed' : 'compact';
244
250
  const g = grantConsent(sid, kind === 'succeed' ? '设置页点击换任' : '设置页点击', kind);
251
+ // 授权一到就重装目标会话的提示段:空闲会话下一轮即可看见,不必等它自己去查。
252
+ if (typeof env.refreshPromptFor === 'function') { try { env.refreshPromptFor(sid); } catch { /* ignore */ } }
245
253
  env.log((kind === 'succeed' ? '换任' : '压缩') + '授权:' + sid + '(5 分钟)');
246
254
  invalidateStatus(env);
247
255
  return ok(g);
@@ -265,20 +273,20 @@ export function createApi(env, info) {
265
273
 
266
274
  if (method === 'move.retarget-stage') {
267
275
  const r = stageRetarget();
268
- env.log('搬迁:登记修改登记助手 ' + (r.already ? '无需(已在新位置)' : '已派出 pid=' + r.pid));
276
+ env.log('迁移:登记修改登记助手 ' + (r.already ? '无需(已在新位置)' : '已派出 pid=' + r.pid));
269
277
  return ok(r);
270
278
  }
271
279
 
272
280
  if (method === 'move.repair') {
273
281
  const r = repairLink();
274
- env.log('搬迁:重建链接 ' + (r.already ? '已存在' : r.src + ' → ' + r.dst));
282
+ env.log('迁移:重建链接 ' + (r.already ? '已存在' : r.src + ' → ' + r.dst));
275
283
  invalidateStatus(env);
276
284
  return ok(r);
277
285
  }
278
286
 
279
287
  if (method === 'move.cleanup') {
280
288
  const r = await cleanupOld({ confirm: p.confirm === true });
281
- env.log('搬迁:清理旧副本 ' + (r.removed || []).length + ' 个');
289
+ env.log('迁移:清理旧备份 ' + (r.removed || []).length + ' 个');
282
290
  return ok(r);
283
291
  }
284
292
 
@@ -188,7 +188,7 @@ export function extractTarGz(file, destDir) {
188
188
  dirs++;
189
189
  return;
190
190
  }
191
- if (e.type === '1' || e.type === '2') { skipped.push('链接条目不解:' + rel); return; }
191
+ if (e.type === '1' || e.type === '2') { skipped.push('链接条目不处理:' + rel); return; }
192
192
  if (e.type !== '0' && e.type !== '') { skipped.push('未知条目类型 ' + e.type + ':' + rel); return; }
193
193
  fs.mkdirSync(path.dirname(dest), { recursive: true });
194
194
  fs.writeFileSync(dest, e.body);
package/src/lib/brief.mjs CHANGED
@@ -41,13 +41,15 @@ export function composeBrief(input) {
41
41
  out.push('水位 无法读取,需人工判断');
42
42
  } else {
43
43
  const pct = (input.selfUsed / input.selfWin) * 100;
44
- const warn = cfg.warnRatio === undefined ? 0.32 : cfg.warnRatio;
45
- out.push("水位 " + pct.toFixed(1) + "%(" + thousands(input.selfUsed) + " / " + thousands(input.selfWin) + ")→ " + (pct >= warn * 100 ? "已过阈值,该写交接" : "可继续"));
44
+ const r1 = (cfg.remindRatio === undefined ? 0.35 : cfg.remindRatio) * 100;
45
+ const r2 = (cfg.remind2Ratio === undefined ? 0.5 : cfg.remind2Ratio) * 100;
46
+ const tail = pct >= r2 ? '已过 50% 档,完成当前小步骤后停止' : pct >= r1 ? '已过 35% 档,应收束并刷新交接' : '可继续';
47
+ out.push("水位 " + pct.toFixed(1) + "%(" + thousands(input.selfUsed) + " / " + thousands(input.selfWin) + ")→ " + tail);
46
48
  }
47
49
  const gate = input.gate;
48
50
  if (!gate || gate.error) {
49
51
  out.push('闸门 ' + (gate && gate.error ? gate.error : '读不到') + ' · 退出码 ? · ?');
50
- if (gate && gate.noWorkspace) out.push(' 首次安装?把工作区接入即可:ho_workspace(action="create", path="<绝对目录>", title="全局"),再开一个标题含「全局」的会话。');
52
+ if (gate && gate.noWorkspace) out.push(' 尚未接入工作区:用 ho_workspace(action="create", path="<绝对目录>", title="全局") 接入,再新建一个标题含「全局」的会话。');
51
53
  } else {
52
54
  out.push("闸门 " + gate.total + " 库 · 正文 " + gate.bodyLines + " 行 · 退出码 " + (gate.ok ? 0 : 10) + " · 判定:" + gate.verdict);
53
55
  }
@@ -58,8 +60,8 @@ export function composeBrief(input) {
58
60
  const notifProjects = new Set(scan.notify.map((r) => r.project));
59
61
  const skipProjects = new Set(scan.skip.map((r) => r.project));
60
62
  out.push("活跃 " + notifProjects.size + " 个项目,需要通知 " + scan.notify.length + " 个会话:" + (scan.notify.length ? scan.notify.map((r) => r.project + "(" + sidShort(r.sid) + ",在任)").join("、") : "无"));
61
- out.push("沉默 " + skipProjects.size + " 个项目,跳过 " + scan.skip.length + " 个会话(不唤醒,省 token)");
62
- if (scan.hot.length) out.push("预警 " + scan.hot.length + " 个会话已进预警区(≥" + Math.round(scan.warn) + "%)—— 全为退场、前任则可忽略");
63
+ out.push("沉默 " + skipProjects.size + " 个项目,跳过 " + scan.skip.length + " 个会话(不唤醒,以节省 token)");
64
+ if (scan.hot.length) out.push("预警 " + scan.hot.length + " 个会话已过 35% 线,全为已交出的旧会话时可忽略");
63
65
  }
64
66
  if (input.drift) {
65
67
  out.push('Δ交接 ' + driftVerdict(input.drift));
@@ -69,6 +71,6 @@ export function composeBrief(input) {
69
71
  out.push('Δ交接 读不到(会话日志不可用)');
70
72
  }
71
73
  if (input.advice && input.advice.text) out.push('阶段 ' + input.advice.text);
72
- out.push('提示 ' + (input.ok ? '按「活跃」名单逐个 peer_send 广播本会话 id 并记回 §3;可正常开始作业' : '本会话已过阈值或闸门异常,应先收齐交接,再开展新任务'));
74
+ out.push('提示 ' + (input.ok ? '按「活跃」名单逐个用 peer(action="send")广播本会话 id,并记回 §3;可正常开始作业' : '本会话已过 35% 线或闸门异常,应先收齐交接,再开展新任务'));
73
75
  return out;
74
76
  }
@@ -1,8 +1,12 @@
1
1
  /** dsh-handover 配置:全部可被 profile patch 的 config 覆盖。 */
2
2
  export const DEFAULTS = {
3
3
  workspaceRoot: '', // 留空则从会话 cwd 推导(向上找 _Control_Center 目录)
4
- warnRatio: 0.32,
5
- handoverRatio: 0.35,
4
+ remindRatio: 0.35, // 35% 一次性后台提醒(不再设 32% 提醒线)
5
+ remind2Ratio: 0.50, // 50% 一次性后台提醒
6
+ margin1: 0.45, // 35% 档判断余量:完成剩余流程后不超过 45%
7
+ margin2: 0.55, // 50% 档判断余量:完成剩余流程后不超过 55%
8
+ pureWindowRatio: 0.40, // 纯窗口线:繁重任务预计超过 40% 时引入子智能体
9
+ handoverRatio: 0.35, // 35% 后可压缩;更早仅助手建议或用户主动并授权
6
10
  hardRatio: 0.85,
7
11
  maxCompactions: 3,
8
12
  autoResume: true,
@@ -11,19 +15,30 @@ export const DEFAULTS = {
11
15
  rolesPath: '', // 留空 = ~/.dsh/storages/dsh-handover.roles.json
12
16
  // 续跑提示:{n} {max} {handover} {role} 会被替换成真值;{handover} 是本会话自己的交接绝对路径。
13
17
  // 只在助手登记压缩时选 resume=continue(默认)才投递;resume=wait 表示已收尾,不唤醒。
14
- resumePrompt: '【dsh-handover 自动续跑·第 {n}/{max} 次压缩】压缩已完成,历史已被摘要替换。第一步:ho_handover(action=read) 取回本会话的交接({handover});然后按 §2「下一步」继续(若 §2 已无待办,向用户一句话收尾并停止)。不要复述交接内容,不要重做已完成的事。完整流程:ho_charter(doc="runtime")。',
15
- resumePromptUnknown: '【dsh-handover 自动续跑·第 {n}/{max} 次压缩】压缩已完成,历史已被摘要替换。本会话是普通会话,不参与交接;如需继续,请由全局会话为其指定角色。',
18
+ resumePrompt: [
19
+ '【dsh-handover 自动续跑·第 {n}/{max} 次压缩】压缩已完成,历史已由摘要替换。',
20
+ '按以下顺序执行:',
21
+ '一、调用 ho_handover(action=read),取回本会话交接({handover})。',
22
+ '二、调用 ho_docmap(action="plan", mode="read"),读取行动计划;未建立计划时跳过本步。',
23
+ '三、按交接 §2 与行动计划继续执行;§2 无待办时,向当前用户作一句总结后收尾并停止。',
24
+ '约束:不复述交接内容;不重做已完成事项。',
25
+ '完整流程:ho_charter(doc="runtime")。',
26
+ ].join(String.fromCharCode(10)),
27
+ resumePromptUnknown: [
28
+ '【dsh-handover 自动续跑·第 {n}/{max} 次压缩】压缩已完成,历史已由摘要替换。',
29
+ '本会话为普通会话,不参与交接;如需继续,由全局会话为其指定角色。',
30
+ ].join(String.fromCharCode(10)),
16
31
  compactTimeoutMs: 120000,
17
32
  compactAttempts: 3,
18
33
  waitIdleMs: 180000, // 等会话空闲的上限(超过就放弃并回告)
19
34
  driftSlack: 3, // Δ交接容忍轮数(超过即需刷交接)
20
- wakeAuthority: 'on', // 会话守卫硬拦:on / off(off=连 peer_new 也不拦)
21
- directPeer: 'on', // 会话之间直接 peer_send:on 允许(不经全局转述);off 退回「只能向全局报告」的旧口径
35
+ wakeAuthority: 'on', // 会话守卫硬拦:on / off(off=连 peer 的 new 动作也不拦)
36
+ directPeer: 'on', // 会话之间直接 peer(action="send"):on 允许(不经全局转述);off 退回「只能向全局报告」的旧口径
22
37
  roleGating: 'on', // 按角色裁剪工具与提示段:on / off(off=所有会话都看得见全部工具)
23
- reuseCheck: true, // 建新会话前查在任会话水位(低于 warnRatio 拒绝)
38
+ reuseCheck: true, // 建新会话前查在任会话水位(低于 handoverRatio 拒绝,建议复用)
24
39
  prepackMinAgeMin: 90, // 打包前要求会话静止分钟数
25
40
  packFormat: 'auto', // 归档格式:auto(有 7-Zip 用 7z,否则内置 tar.gz)、7z、tar
26
- sevenZipPath: '', // 留空则自动探测 C:\Program Files\7-Zip\7z.exe
41
+ sevenZipPath: '', // 留空则自动探测:常见安装位置(Windows、macOS、Linux)与 PATH 上的 7z
27
42
  };
28
43
 
29
44
  export function resolveConfig(raw) {
@@ -96,7 +96,7 @@ export function checkConsent(sessionId, kind) {
96
96
  return {
97
97
  ok: false,
98
98
  kind: k,
99
- reason: k === 'succeed' ? '用户还没有授权换任(聊天页命令 /allow-succeed)' : '用户还没有在设置页点「允许压缩」',
99
+ reason: k === 'succeed' ? '当前用户还没有授权换任(聊天页命令 /allow-succeed)' : '当前用户还没有在设置页点击「授权压缩」',
100
100
  };
101
101
  }
102
102
  return { ok: true, kind: k, until: hit.until, leftMs: hit.until - now, atText: hit.atText || '' };
@@ -121,3 +121,56 @@ export function listConsent() {
121
121
  })).filter((r) => r.leftMs > 0);
122
122
  return rows.sort((a, b) => a.sessionId.localeCompare(b.sessionId, 'zh') || a.kind.localeCompare(b.kind));
123
123
  }
124
+
125
+ /**
126
+ * 某会话当前有效的授权(只读,不清过期条目)。
127
+ * 提示段与 ho_status 共用:授权一旦写入,下一轮重建提示段时就能看见;过期自然消失。
128
+ */
129
+ export function activeConsentsFor(sessionId) {
130
+ const sid = String(sessionId || '');
131
+ if (!sid) return [];
132
+ const all = readAll();
133
+ const now = Date.now();
134
+ const rows = [];
135
+ for (const key of Object.keys(all)) {
136
+ const entry = all[key];
137
+ if (!entry || !entry.until || entry.until <= now) continue;
138
+ if (sidOfKey(key) !== sid) continue;
139
+ rows.push({
140
+ kind: kindOfEntry(key, entry),
141
+ until: entry.until,
142
+ leftMs: entry.until - now,
143
+ atText: entry.atText || '',
144
+ note: entry.note || '',
145
+ });
146
+ }
147
+ return rows.sort((a, b) => a.kind.localeCompare(b.kind));
148
+ }
149
+
150
+ const KIND_LABEL = { compact: '压缩', succeed: '换任' };
151
+
152
+ function untilText(ms) {
153
+ const d = new Date(ms);
154
+ const off = -d.getTimezoneOffset();
155
+ const pad = (n) => String(n).padStart(2, '0');
156
+ const a = Math.abs(off);
157
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' '
158
+ + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ' ' + (off >= 0 ? '+' : '-') + pad(Math.floor(a / 60)) + pad(a % 60);
159
+ }
160
+
161
+ /** 提示段用:每种有效授权一行,极短;无授权返回空数组(不占位)。 */
162
+ export function consentPromptLines(rows) {
163
+ return (rows || []).map((r) => '当前用户已授权' + KIND_LABEL[r.kind] + ',至 ' + untilText(r.until) + '(一次性,用后失效)。');
164
+ }
165
+
166
+ /** ho_status 用:一行状态,压缩与换任各说一句,未授权的写「无授权」。 */
167
+ export function formatConsentLine(rows) {
168
+ const hit = new Map((rows || []).map((r) => [r.kind, r]));
169
+ const parts = CONSENT_KINDS.map((k) => {
170
+ const r = hit.get(k);
171
+ if (!r) return KIND_LABEL[k] + ' 无授权';
172
+ const min = Math.max(1, Math.ceil(r.leftMs / 60000));
173
+ return KIND_LABEL[k] + ' 剩余 ' + min + ' 分钟(至 ' + untilText(r.until) + ',一次性)';
174
+ });
175
+ return parts.join(';');
176
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * 旧版文档定位(docmap)解析:1.0 起只由迁移兼容模块 `legacy.mjs` 使用,用于把旧表并入体系注册。
3
+ * 新写入一律走 `system.mjs`;本模块不再参与提示段注入。
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { NL, readUtf8, isFile, isDir } from './text.mjs';
8
+
9
+ export const DOCMAP_DIR = path.join('_Control_Center', 'records', 'docmap');
10
+ export const COLUMNS = ['路径', '一句话介绍', '何时读'];
11
+
12
+ /** 由会话角色推导所属的登记(全局/项目/杂项);普通会话没有 map。 */
13
+ export function lineageOf(info) {
14
+ const role = info && info.role ? info.role : '';
15
+ if (role === 'global') return { kind: 'global', project: '', task: '', key: '全局' };
16
+ if (role === 'project' && info.project) return { kind: 'project', project: String(info.project), task: '', key: String(info.project) };
17
+ if (role === 'misc' && info.task) return { kind: 'misc', project: '', task: String(info.task), key: String(info.task) };
18
+ return null;
19
+ }
20
+
21
+ export function lineageLabel(info) {
22
+ const lin = lineageOf(info);
23
+ if (!lin) return '';
24
+ if (lin.kind === 'project') return '项目「' + lin.project + '」';
25
+ if (lin.kind === 'misc') return '杂项任务「' + lin.task + '」';
26
+ return '全局';
27
+ }
28
+
29
+ /** 文件名:global.md / project-<项目名>.md / misc-<任务名>.md(相对工作区根)。 */
30
+ export function docmapRelPath(info) {
31
+ const lin = lineageOf(info);
32
+ if (!lin) return null;
33
+ const stem = lin.kind === 'global' ? 'global' : lin.kind === 'project' ? 'project-' + lin.project : 'misc-' + lin.task;
34
+ return path.join(DOCMAP_DIR, stem + '.md');
35
+ }
36
+
37
+ export function docmapAbsPath(ws, info) {
38
+ const rel = docmapRelPath(info);
39
+ return rel ? path.join(String(ws || ''), rel) : null;
40
+ }
41
+
42
+ function stripTicks(s) {
43
+ return String(s || '').replace(/`/g, '').trim();
44
+ }
45
+
46
+ /** 解析表格行:跳过表头与分隔行;三列之外的多余列合并进第三列。 */
47
+ export function parseDocmap(text) {
48
+ const entries = [];
49
+ for (const raw of String(text || '').split(/\r?\n/)) {
50
+ const line = raw.trim();
51
+ if (!line.startsWith('|')) continue;
52
+ const cells = line.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => stripTicks(c));
53
+ if (!cells.length) continue;
54
+ if (cells.length < 2) continue;
55
+ if (cells[0] === COLUMNS[0]) continue; // 表头
56
+ if (cells.every((c) => /^[-:\s]*$/.test(c))) continue; // 分隔行
57
+ const p = cells[0];
58
+ if (!p) continue;
59
+ const about = cells[1] || '';
60
+ const when = cells.length > 3 ? cells.slice(2).join(',') : (cells[2] || '');
61
+ entries.push({ path: p, about, when });
62
+ }
63
+ return entries;
64
+ }
65
+
66
+ export function readDocmap(ws, info) {
67
+ const file = docmapAbsPath(ws, info);
68
+ if (!file) return { file: null, exists: false, entries: [] };
69
+ if (!isFile(file)) return { file, exists: false, entries: [] };
70
+ let text = '';
71
+ try { text = readUtf8(file); } catch { return { file, exists: true, entries: [], error: '读取失败' }; }
72
+ return { file, exists: true, entries: parseDocmap(text) };
73
+ }
74
+
75
+ /** 渲染文件正文(写盘用):标题一行、表格、一句判定。 */
76
+ export function renderDocmap(info, entries) {
77
+ const label = lineageLabel(info);
78
+ const lines = ['# 文档定位(' + label + ')', '', '| ' + COLUMNS.join(' | ') + ' |', '|' + COLUMNS.map(() => '---').join('|') + '|'];
79
+ for (const e of entries) lines.push('| `' + e.path + '` | ' + e.about + ' | ' + (e.when || '') + ' |');
80
+ lines.push('');
81
+ lines.push('只指路:是否读正文由会话判断,不得无目的通读。');
82
+ return lines.join(NL) + NL;
83
+ }
84
+
85
+ /**
86
+ * 注入提示段的文本:空 map 返回空串(不占位)。
87
+ * 形如「文档定位(项目「X」;只指路…)」+ 每条一行,路径加反引号。
88
+ */
89
+ export function docmapBlock(ws, info) {
90
+ const map = readDocmap(ws, info);
91
+ if (!map.entries.length) return '';
92
+ const lines = ['文档定位(' + lineageLabel(info) + ';只指路,是否读正文由会话判断,不得无目的通读)'];
93
+ for (const e of map.entries) {
94
+ lines.push('- ' + e.path + (e.about ? ' —— ' + e.about : '') + (e.when ? '(' + e.when + ')' : ''));
95
+ }
96
+ return lines.join(NL);
97
+ }
98
+
99
+ /** 路径是否合法且存在:只收工作区相对路径,不得越出工作区。 */
100
+ export function pathProblem(ws, p) {
101
+ const raw = String(p || '').trim();
102
+ if (!raw) return '路径为空';
103
+ if (/^[a-zA-Z]:/.test(raw) || raw.startsWith('/') || raw.startsWith('\\')) return '须写工作区相对路径,不得写绝对路径';
104
+ const norm = path.normalize(raw).replace(/\\/g, '/');
105
+ if (norm === '..' || norm.startsWith('../')) return '不得指向工作区之外';
106
+ const abs = path.join(String(ws || ''), norm);
107
+ if (!isFile(abs) && !isDir(abs)) return '不存在:' + norm;
108
+ return '';
109
+ }
110
+
111
+ /** 逐条校验:返回 [{ path, problem }]。 */
112
+ export function validateEntries(ws, entries) {
113
+ const bad = [];
114
+ for (const e of entries) {
115
+ const problem = pathProblem(ws, e.path);
116
+ if (problem) bad.push({ path: e.path, problem });
117
+ }
118
+ return bad;
119
+ }
120
+
121
+ /** 闸门用:检查所有 map 的路径存在性(只判存在,不判字数)。 */
122
+ export function checkDocmaps(ws) {
123
+ const dir = path.join(String(ws || ''), DOCMAP_DIR);
124
+ const rows = [];
125
+ let files = [];
126
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort(); } catch { return { dir, rows, missing: 0 }; }
127
+ for (const name of files) {
128
+ const file = path.join(dir, name);
129
+ const stem = name.slice(0, -3);
130
+ let info = null;
131
+ if (stem === 'global') info = { role: 'global' };
132
+ else if (stem.startsWith('project-')) info = { role: 'project', project: stem.slice(8) };
133
+ else if (stem.startsWith('misc-')) info = { role: 'misc', task: stem.slice(5) };
134
+ let entries = [];
135
+ try { entries = parseDocmap(readUtf8(file)); } catch { entries = []; }
136
+ const bad = validateEntries(ws, entries);
137
+ rows.push({ name, label: info ? lineageLabel(info) : '(文件名不符合规范)', entries: entries.length, bad });
138
+ }
139
+ return { dir, rows, missing: rows.reduce((a, r) => a + r.bad.length, 0) };
140
+ }
package/src/lib/drift.mjs CHANGED
@@ -61,5 +61,5 @@ export function computeDrift(opts) {
61
61
  export function driftVerdict(res) {
62
62
  return '判定:' + res.fresh + ' 个已最新、' + res.need + ' 个需刷交接' +
63
63
  (res.unknown ? '、' + res.unknown + ' 个需人工确认' : '') +
64
- (res.need || res.unknown ? ';先让需刷的会话写交接,再搬' : ';可以直接搬');
64
+ (res.need || res.unknown ? ';先令需刷交接的会话写交接,再迁移' : ';可直接迁移');
65
65
  }
@@ -85,5 +85,5 @@ export function stalePressureNote(sessionId) {
85
85
  if (!st.lastSummaryMs) return '';
86
86
  const m = projCacheMtimeOf(sessionId);
87
87
  if (m === null || m >= st.lastSummaryMs) return '';
88
- return '(压缩刚完成,投影缓存尚未刷新,水位读数滞后一轮;下一次请求后自动正常)';
88
+ return '(压缩刚完成,投影缓存尚未刷新,水位读数滞后一轮;下一次请求后恢复正常)';
89
89
  }
package/src/lib/facts.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { DEFAULTS } from './config.mjs';
7
7
 
8
- /** 比例转百分比文本:0.32 → "32%"。 */
8
+ /** 比例转百分比文本:0.35 → "35%"。 */
9
9
  function pctOf(ratio) {
10
10
  const n = Math.round(Number(ratio) * 1000) / 10;
11
11
  return (Number.isInteger(n) ? String(n) : n.toFixed(1)) + '%';
@@ -14,24 +14,31 @@ function pctOf(ratio) {
14
14
  /** 水位与压缩口径:全部由配置推导。 */
15
15
  export function watermark(cfg) {
16
16
  const c = cfg || DEFAULTS;
17
- const warn = pctOf(c.warnRatio !== undefined ? c.warnRatio : DEFAULTS.warnRatio);
18
- const handover = pctOf(c.handoverRatio !== undefined ? c.handoverRatio : DEFAULTS.handoverRatio);
19
- const hard = pctOf(c.hardRatio !== undefined ? c.hardRatio : DEFAULTS.hardRatio);
20
- const max = c.maxCompactions !== undefined ? c.maxCompactions : DEFAULTS.maxCompactions;
21
- return { warn, handover, hard, max, warnValue: Math.round((c.warnRatio !== undefined ? c.warnRatio : DEFAULTS.warnRatio) * 100) };
17
+ const pick = (k) => (c[k] !== undefined ? c[k] : DEFAULTS[k]);
18
+ const remind = pctOf(pick('remindRatio'));
19
+ const remind2 = pctOf(pick('remind2Ratio'));
20
+ const handover = pctOf(pick('handoverRatio'));
21
+ const hard = pctOf(pick('hardRatio'));
22
+ const max = pick('maxCompactions');
23
+ return {
24
+ remind, remind2, handover, hard, max,
25
+ remindValue: Math.round(pick('remindRatio') * 100),
26
+ remind2Value: Math.round(pick('remind2Ratio') * 100),
27
+ reuseValue: Math.round(pick('handoverRatio') * 100),
28
+ };
22
29
  }
23
30
 
24
31
  /** 一句式交接规则(全局与项目提示段共用)。 */
25
32
  export function handoverRuleText(cfg) {
26
33
  const w = watermark(cfg);
27
- return '水位 ' + w.warn + ' 提醒,' + w.handover + ' 交接线,' + w.hard + ' 硬上限;至多压缩 '
34
+ return w.handover + ' 后可压缩,' + w.hard + ' 为硬上限;至多压缩 '
28
35
  + w.max + ' 次,第 ' + (w.max + 1) + ' 次换任。';
29
36
  }
30
37
 
31
38
  /** 设置页使用说明里的紧写形式。 */
32
39
  export function compactSummaryText(cfg) {
33
40
  const w = watermark(cfg);
34
- return w.warn + ' 提醒、' + w.handover + ' 交接线、' + w.hard + ' 硬上限;同一会话最多压缩 ' + w.max + ' 次';
41
+ return w.remind + ' 与 ' + w.remind2 + ' 各提醒一次,' + w.handover + ' 后可压缩,' + w.hard + ' 硬上限;同一会话最多压缩 ' + w.max + ' 次';
35
42
  }
36
43
 
37
44
  /** 提示段与工具描述的加权 token 估算:CJK 与全角标点每字符 1 token,其余每 4 字符 1 token。 */
@@ -63,9 +70,9 @@ function isWide(cp) {
63
70
  * 提示词效果优先于字数,实测值超出参考值时如实展示即可。
64
71
  */
65
72
  export const TOKEN_BUDGET = {
66
- globalTokens: 320,
67
- globalWorstTokens: 360,
68
- projectTokens: 300,
69
- projectWorstTokens: 340,
73
+ globalTokens: 900,
74
+ globalWorstTokens: 1000,
75
+ projectTokens: 800,
76
+ projectWorstTokens: 900,
70
77
  toolDescTokens: 70,
71
78
  };
package/src/lib/gates.mjs CHANGED
@@ -4,6 +4,7 @@ import { readUtf8, isFile, isDir } from './text.mjs';
4
4
  import fs from 'node:fs';
5
5
  import { charterPath, canonicalBlock, TITLE_KEY, WATER_KEY } from './charter.mjs';
6
6
  import { readProfile, listProfiles } from './profile.mjs';
7
+ import { checkRegistries } from './system.mjs';
7
8
 
8
9
  /**
9
10
  * 读 Registry.md 的「形态」列 → { 项目名: 形态 }。
@@ -61,7 +62,6 @@ function libraryRoots(ws) {
61
62
  return out;
62
63
  }
63
64
 
64
- /** 同步复核:返回 { libraries, total, missing, verdict, ok }。 */
65
65
  /** 插件内置的 Charter 骨架(dist/templates/Charter.skeleton.md);工作区那份只是覆盖。 */
66
66
  function bundledCharter() {
67
67
  try {
@@ -72,6 +72,7 @@ function bundledCharter() {
72
72
  }
73
73
  }
74
74
 
75
+ /** 同步复核:返回 { libraries, total, missing, verdict, ok }。 */
75
76
  export function syncCheck(ws) {
76
77
  const charter = charterPath(ws);
77
78
  // 口径:**插件自带的这套默认规则才是本体**,工作区里的同名文件是「本地化覆盖」。
@@ -86,7 +87,7 @@ export function syncCheck(ws) {
86
87
  builtin = true;
87
88
  } else {
88
89
  return {
89
- error: '这个实例还没接入工作区:读不到 ' + charter,
90
+ error: '本实例尚未接入工作区:读不到 ' + charter,
90
91
  path: charter,
91
92
  ok: false,
92
93
  noWorkspace: true,
@@ -94,7 +95,7 @@ export function syncCheck(ws) {
94
95
  }
95
96
  const blk = canonicalBlock(text);
96
97
  if (!blk || !blk.body || blk.body.length === 0) {
97
- return { error: '无法获取 §8.1.1.4 的正文(结构可能变了)', path: charter, ok: false };
98
+ return { error: '无法取得 §8.1.1.4 的正文(结构可能已变更)', path: charter, ok: false };
98
99
  }
99
100
  const rows = [];
100
101
  let bad = 0;
@@ -118,19 +119,19 @@ export function syncCheck(ws) {
118
119
  verdict: bad === 0
119
120
 
120
121
  ? "全部落位。"
121
- : bad + ' 个库有缺 —— 向对应项目会话派发(由它自己写进本库 RULES.md,全局不得改项目库内文件)。',
122
+ : bad + ' 个库有缺,向对应项目会话派发(由该会话写进本库 RULES.md;全局不得修改项目库内文件)。',
122
123
  };
123
124
  }
124
125
 
125
126
  export function renderSyncCheck(res) {
126
127
  const out = [];
127
128
  if (res.error) { out.push(res.error + ':' + res.path); return out; }
128
- out.push('通用约束条同步复核 库数 ' + res.total + ' 正文 ' + res.bodyLines + ' 行 来源 §8.1.1.4' + (res.builtin ? '(工作区没有 Workspace_Charter.md,用插件内置口径)' : ''));
129
+ out.push('通用约束条同步复核 库数 ' + res.total + ' 正文 ' + res.bodyLines + ' 行 来源 §8.1.1.4' + (res.builtin ? '(工作区无 Workspace_Charter.md,采用插件内置口径)' : ''));
129
130
  for (const r of res.libraries) {
130
131
  const flag = r.ok ? '通过' : '未通过';
131
132
  out.push(' ' + r.name + ' 标题 ' + (r.titleOk ? '通过' : '缺') + ' 正文 ' + (r.total - r.missing.length) + '/' + r.total +
132
133
  ' 水位自查 ' + (r.waterOk ? '通过' : '缺') + ' ' + flag);
133
- for (const m of r.missing.slice(0, 3)) out.push(" 缺: " + m.slice(0, 70));
134
+ for (const m of r.missing.slice(0, 3)) out.push(" 缺:" + m.slice(0, 70));
134
135
  }
135
136
  const ps = res.profiles || [];
136
137
  if (ps.length) {
@@ -145,4 +146,27 @@ export function renderSyncCheck(res) {
145
146
  out.push('');
146
147
  out.push('判定:' + res.verdict);
147
148
  return out;
149
+ }
150
+
151
+ /** 体系注册:只检查表内路径是否仍然存在(不判字数、不判内容)。 */
152
+ export function docmapReport(ws) {
153
+ return checkRegistries(ws);
154
+ }
155
+
156
+ export function renderDocmapCheck(res) {
157
+ const out = [];
158
+ out.push('体系注册 任务 ' + res.rows.length + ' 个');
159
+ if (!res.rows.length) {
160
+ out.push(' 尚无任何任务注册(空注册不注入正文,属正常)');
161
+ out.push('判定:无需检查');
162
+ return out;
163
+ }
164
+ for (const r of res.rows) {
165
+ out.push(' ' + r.label + ' ' + r.rel + ' ' + (r.exists ? r.entries + ' 条' : '未设置') + ' ' + (r.bad.length ? '失效 ' + r.bad.length + ' 条' : '路径全部存在'));
166
+ for (const b of r.bad.slice(0, 3)) out.push(' 失效:' + b.path + ' —— ' + b.problem);
167
+ }
168
+ out.push('判定:' + (res.missing === 0
169
+ ? '全部落位。'
170
+ : res.missing + ' 条路径失效,由该类别在任会话用 ho_docmap(action="write") 更新。'));
171
+ return out;
148
172
  }