@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
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 打包、校验、预演、恢复的实现(原 ho_pack / ho_pack_verify / ho_pack_restore / ho_pack_precheck 的代码搬到这里)。
3
- * 对外只经 ho_pack(action=…) 暴露,少 3 份 schema 的固定开销。
3
+ * 对外只经 ho_backup(action=…) 暴露:四份旧入口合为一份 schema,省下三份的固定开销。
4
4
  * 共同口径:只报告绝对路径、不改写文件内容;恢复默认预演、覆盖前备份、从不删除目标文件。
5
5
  */
6
6
  import fs from 'node:fs';
@@ -26,7 +26,7 @@ function chooseFormat(args, cfg) {
26
26
  const want = String((args && args.format) || (cfg && cfg.packFormat) || 'auto').toLowerCase();
27
27
  if (want === '7z') {
28
28
  const bin = sevenZipBin(cfg && cfg.sevenZipPath);
29
- if (!bin) throw new Error('指定了 format=7z,但本机没有 7-Zip。去掉 format 用 auto,或写 format="tar" 用内置归档器。');
29
+ if (!bin) throw new Error('指定 format=7z,但本机无 7-Zip。可省略 format 改用 auto,或写 format="tar" 使用内置归档器。');
30
30
  return { fmt: '7z', bin };
31
31
  }
32
32
  if (want === 'tar' || want === 'targz' || want === 'tar.gz') return { fmt: 'tar', bin: '' };
@@ -48,11 +48,11 @@ function unpack(packPath, dir) {
48
48
  }
49
49
  if (fmt === '7z') {
50
50
  const bin = sevenZipBin('');
51
- if (!bin) return { code: -1, out: '这个包是 7z 格式,但本机没有 7-Zip;装一个 7-Zip,或改用 tar.gz 包。' };
51
+ if (!bin) return { code: -1, out: '该包为 7z 格式,但本机无 7-Zip;请安装 7-Zip,或改用 tar.gz 包。' };
52
52
  fs.mkdirSync(dir, { recursive: true });
53
53
  return run7zSafe(bin, packPath, dir);
54
54
  }
55
- return { code: -1, out: '认不出包格式(既不是 7z 魔数也不是 gzip):' + packPath };
55
+ return { code: -1, out: '无法识别包格式(既非 7z 魔数,也非 gzip):' + packPath };
56
56
  }
57
57
 
58
58
  /** 包内路径必须相对且不越界:拒绝绝对路径、盘符、..(防止恢复时写到目标目录之外)。 */
@@ -121,7 +121,7 @@ export function packCheck(env, args, exec) {
121
121
  const live = scan.rows.filter((r) => r.project === item);
122
122
  if (live.length) {
123
123
  const best = live.slice().sort((a, b) => b.mtime - a.mtime)[0];
124
- if (best.state === 'running') why.push('会话 ' + best.sid + ' 正在跑');
124
+ if (best.state === 'running') why.push('会话 ' + best.sid + ' 正在运行');
125
125
  if (now - best.mtime < minAge) why.push('最后活动距今 ' + Math.round((now - best.mtime) / 60000) + ' 分钟 < ' + Math.round(minAge / 60000));
126
126
  }
127
127
  const hand = path.join(ws, '_Project_Meta', item, 'Handover.md');
@@ -170,7 +170,7 @@ export function packNow(env, args, exec) {
170
170
  if (!entries.length) throw new Error('没有可打包的文件:' + roots.map((r) => r[0]).join('、'));
171
171
  const total = entries.reduce((a, b) => a + b.bytes, 0);
172
172
  const out = [];
173
- out.push('目标 ' + kind + (name ? ':' + name : '') + ' → ' + entries.length + ' 个文件 / ' + (total / 1048576).toFixed(2) + ' MB');
173
+ out.push('目标 ' + kind + (name ? ':' + name : '') + ',' + entries.length + ' 个文件 / ' + (total / 1048576).toFixed(2) + ' MB');
174
174
  if (args.dry_run) {
175
175
  for (const e of entries.slice(0, 20)) out.push(' ' + e.rel + ' ' + e.bytes + ' B');
176
176
  const pre = scanAbsoluteRefs(ws, entries.map((e) => e.rel));
@@ -205,7 +205,7 @@ export function packNow(env, args, exec) {
205
205
  fs.writeFileSync(path.join(stagingRoot, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
206
206
  fs.writeFileSync(path.join(stagingRoot, 'CHECKSUMS.sha256'), files.map((f) => f.sha256 + ' ' + f.path).join(NL) + NL, 'utf8');
207
207
  const absLines = renderAbsoluteRefs(abs, { title: '绝对路径体检(包内)', max: 200 });
208
- const md = ['# 迁移包(最简版)', '', '- 目标: ' + kind + (name ? ':' + name : ''), '- 生成: ' + manifest.createdAt, '- 文件: ' + files.length + ' 个 / ' + total + ' B', '- 说明: ' + (args.note || '(无)'), '', ...absLines, '', '## 跨平台注意', '', '- **库内引用一律相对路径**:本包里有 ' + abs.totalHits + ' 处写死的绝对路径,插件**只报告、不改写**文件内容——迁移后需由人或项目会话自己改。', '- 源平台 ' + manifest.sourcePlatform + ';换行 CRLF ' + abs.crlf + ' 个 / LF ' + abs.lf + ' 个' + (abs.nonUtf8.length ? ';非 UTF-8 ' + abs.nonUtf8.length + ' 个' : '') + '。', '- 目标机若是别的系统(Windows ↔ macOS/Linux):盘符、路径分隔符、大小写敏感性、换行、编码都要项目会话自己核一遍。', '', '## 目标环境前置(先做这两件,再做恢复)', '', '1. 安装 dsh-handover 插件(否则本工具不存在);', '2. 打开本包随附的交接文件(_Project_Meta/<项目名>/Handover.md 或全局 _Control_Center/records/Global_Handover.md),按它恢复状态。', '', '## 恢复', '', 'ho_pack(action="restore", pack="<本包绝对路径>", dry_run=true) # 先预演,再加 apply=true', '', '> 会话本身**不迁移**:连续性只靠交接文档。'].join(NL);
208
+ const md = ['# 迁移包(最简版)', '', '- 目标: ' + kind + (name ? ':' + name : ''), '- 生成: ' + manifest.createdAt, '- 文件: ' + files.length + ' 个 / ' + total + ' B', '- 说明: ' + (args.note || '(无)'), '', ...absLines, '', '## 跨平台注意', '', '- **库内引用一律相对路径**:本包内有 ' + abs.totalHits + ' 处写死的绝对路径,插件只报告、不改写文件内容,迁移后须由人工或项目会话修改。', '- 源平台 ' + manifest.sourcePlatform + ';换行 CRLF ' + abs.crlf + ' 个 / LF ' + abs.lf + ' 个' + (abs.nonUtf8.length ? ';非 UTF-8 ' + abs.nonUtf8.length + ' 个' : '') + '。', '- 目标机若为其他系统(Windows ↔ macOS/Linux):盘符、路径分隔符、大小写敏感性、换行与编码均须项目会话自行核对。', '', '## 目标环境前置(先完成这两项,再执行恢复)', '', '1. 安装 dsh-handover 插件(否则本工具不存在);', '2. 打开本包随附的交接文件(_Project_Meta/<项目名>/Handover.md 或全局 _Control_Center/records/Global_Handover.md),按其中内容恢复状态。', '', '## 恢复', '', 'ho_backup(action="restore", pack="<本包绝对路径>", dry_run=true) # 先预演,再加 apply=true', '', '> 会话本身不迁移:连续性只依托交接文档。'].join(NL);
209
209
  fs.writeFileSync(path.join(stagingRoot, 'MIGRATION.md'), md, 'utf8');
210
210
  const outDir = args.out || path.join(ws, '_Archives', '_Global', 'Backups');
211
211
  fs.mkdirSync(outDir, { recursive: true });
@@ -263,7 +263,7 @@ export function packVerify(env, args, exec) {
263
263
  out.push('文件 ' + manifest.files.length + ' 个;一致 ' + same.length + ' / 漂移 ' + drift.length + ' / 缺失 ' + missing.length);
264
264
  for (const p of drift.slice(0, 10)) out.push(' 漂移:' + p);
265
265
  for (const p of missing.slice(0, 10)) out.push(' 缺失:' + p);
266
- out.push(drift.length === 0 && missing.length === 0 ? '判定:包内即现版,可直接用' : '判定:**包已作废**(盘上已变),必须重打');
266
+ out.push(drift.length === 0 && missing.length === 0 ? '判定:包内即现版,可直接使用' : '判定:包已作废(磁盘内容已变更),须重新打包');
267
267
  return out.join(NL);
268
268
  }
269
269
 
@@ -323,10 +323,10 @@ export function packRestore(env, args, exec) {
323
323
  out.push('包 ' + packPath + ',目标 ' + target);
324
324
  out.push('文件 ' + manifest.files.length + ' 个;新增 ' + adds.length + ' / 覆盖 ' + overwrites.length);
325
325
  for (const p of overwrites.slice(0, 10)) out.push(' 覆盖:' + p);
326
- if (crossPlatform) out.push('注意:源平台 ' + manifest.sourcePlatform + ' ≠ 本机 ' + process.platform + ':路径分隔符、盘符、大小写、换行、编码都需要项目会话自己核一遍。');
326
+ if (crossPlatform) out.push('注意:源平台 ' + manifest.sourcePlatform + ' ≠ 本机 ' + process.platform + ';路径分隔符、盘符、大小写、换行与编码均须项目会话自行核对。');
327
327
  out.push(...renderAbsoluteRefs(abs, { title: '绝对路径体检(恢复前)', max: 20 }));
328
- if (abs.totalHits) out.push(' ⇒ 这些位置插件**不会**改;迁移后由人或项目会话按新机器改,库内引用统一用相对路径。');
329
- if (!apply) out.push('**预演**:未写入任何文件。确认无误后用 apply=true 再调一次。');
328
+ if (abs.totalHits) out.push(' 上述位置插件不作改动;迁移后由人工或项目会话按新机器修改,库内引用统一使用相对路径。');
329
+ if (!apply) out.push('预演:未写入任何文件。确认无误后以 apply=true 再次调用。');
330
330
  else out.push('已写入;被覆盖的旧文件备份在 ' + backupDir);
331
331
  return out.join(NL);
332
332
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 路径等价键:跨平台比较同一路径时用。
3
+ * Windows 与 macOS 的默认文件系统不区分大小写,Linux 区分;因此只在 win32 与 darwin 上折叠大小写。
4
+ * 反斜杠一律折成正斜杠,去掉末尾分隔符,便于比较用户输入与登记表里的路径。
5
+ * `platform` 可显式传入,便于按平台写单元测试。
6
+ */
7
+ import path from 'node:path';
8
+
9
+ /** 返回用于比较的路径键。 */
10
+ export function pathKey(p, platform) {
11
+ const plat = platform || process.platform;
12
+ const norm = String(p || '').replace(/\\/g, '/').replace(/\/+$/, '');
13
+ return (plat === 'win32' || plat === 'darwin') ? norm.toLowerCase() : norm;
14
+ }
15
+
16
+ /** 两个路径是否指向同一位置(按当前或指定平台判定)。 */
17
+ export function samePath(a, b, platform) {
18
+ return pathKey(a, platform) === pathKey(b, platform);
19
+ }
20
+
21
+ /** 把工作区相对路径('/' 分隔)转成本机路径。 */
22
+ export function relToAbs(root, rel) {
23
+ return path.join(root, String(rel).split('/').join(path.sep));
24
+ }
package/src/lib/peer.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  * 数据源与 peer-link 完全一致,不引入任何第三方依赖:
4
4
  * ~/.dsh/storages/session_projcache/sessions/<session id>.json 水位 / 运行状态 / 项目名
5
5
  * ~/.dsh/storages/workspace.json 工作区 id / 标题 / 路径
6
- * ~/.dsh/sessions/<slug>/<session id>/session.v4.jsonl.zstd 会话日志(peer_peek 用)
6
+ * ~/.dsh/sessions/<slug>/<session id>/session.v4.jsonl.zstd 会话日志(peer 的 peek 动作用)
7
7
  * 平级会话的判据:投影缓存里 **没有** record.rows.subagent.identity(子会话才有)。
8
8
  */
9
9
  import fs from 'node:fs';
@@ -159,7 +159,7 @@ export function stamp(ms) {
159
159
  return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
160
160
  }
161
161
 
162
- /** 一行式摘要(给 peer_list 用)。 */
162
+ /** 一行式摘要(给 peer 的 list 动作用)。 */
163
163
  export function rowLine(sessionId, cache, fallback) {
164
164
  const pct = cache === null ? null : pressurePct(cache);
165
165
  const state = isBusy(cache) === true ? 'running' : fallback === undefined ? 'idle' : fallback;
@@ -171,14 +171,14 @@ export function rowLine(sessionId, cache, fallback) {
171
171
 
172
172
  /**
173
173
  * 会话清单:controller.list 的结果按 origin 过滤。
174
- * 冷会话在这一步就会被服务端从持久化里恢复成可寻址的行,所以 peer_list 本身不花 token。
174
+ * 冷会话在这一步就会被服务端从持久化里恢复成可寻址的行,所以 peer(action="list")本身不花 token。
175
175
  */
176
176
  export async function sessionRows(controller, includeSubagents) {
177
177
  const value = await controller.list({}, new AbortController().signal);
178
178
  return value.items.filter((row) => includeSubagents === true || row.origin !== 'subagent');
179
179
  }
180
180
 
181
- // ---------------------------------------------------------------- 会话日志(peer_peek)
181
+ // ---------------------------------------------------------------- 会话日志(peer 的 peek 动作)
182
182
 
183
183
  /** 解 zstd **多帧**日志并按行解析成事件数组;本 Node 构建没有 zstd 时返回 null。 */
184
184
  export function decodeSessionLog(file) {
package/src/lib/phase.mjs CHANGED
@@ -3,34 +3,29 @@
3
3
  /**
4
4
  * @param {{ratio:number|null, compactionsUsed:number, cfg:object, lockState:string}} input
5
5
  * @returns {{action:string, reason:string}}
6
- * action ∈ continue | pause | checkpoint | new_session | force_compact | blocked | unknown
6
+ * action ∈ continue | checkpoint | force_compact | new_session | blocked | unknown
7
7
  */
8
8
  export function decide(input) {
9
9
  const cfg = input.cfg || {};
10
- const warn = cfg.warnRatio === undefined ? 0.32 : cfg.warnRatio;
11
10
  const handover = cfg.handoverRatio === undefined ? 0.35 : cfg.handoverRatio;
12
11
  const hard = cfg.hardRatio === undefined ? 0.85 : cfg.hardRatio;
13
12
  const maxComp = cfg.maxCompactions === undefined ? 3 : cfg.maxCompactions;
14
13
  const used = typeof input.compactionsUsed === 'number' ? input.compactionsUsed : 0;
15
14
  if (input.lockState === 'orphaned') {
16
- return { action: 'blocked', reason: '检测到未闭合的压缩锁(compaction/start 无配对 end):先处理再排新压缩' };
15
+ return { action: 'blocked', reason: '检测到未闭合的压缩锁(compaction/start 无配对 end):先处理压缩锁,再排新压缩' };
17
16
  }
18
17
  // 额度用尽优先于水位判定:3 次压满后再压不是「水位没到」,而是该换任了。
19
- // 放在水位判定之前,force 也就绕不过去(force 只在水位分支里放行)。
20
18
  if (used >= maxComp) {
21
- return { action: 'new_session', reason: '已压缩 ' + used + '/' + maxComp + ' 次:压缩额度用尽,应写交接并建新会话' };
19
+ return { action: 'new_session', reason: '已压缩 ' + used + '/' + maxComp + ' 次:压缩额度用尽,应写交接并新建会话' };
22
20
  }
23
21
  if (input.ratio === null || input.ratio === undefined) {
24
- return { action: 'unknown', reason: '水位读不到:人工判断,不要盲目压缩' };
22
+ return { action: 'unknown', reason: '水位读不到:需人工判断,不得盲目压缩' };
25
23
  }
26
24
  if (input.ratio >= hard) {
27
- return { action: 'force_compact', reason: '已到硬顶(' + Math.round(hard * 100) + '%):强制压缩,哪怕任务中途' };
25
+ return { action: 'force_compact', reason: '已到硬顶(' + Math.round(hard * 100) + '%):应强制压缩,任务中途亦同' };
28
26
  }
29
27
  if (input.ratio >= handover) {
30
- return { action: 'checkpoint', reason: '过交接线(' + Math.round(handover * 100) + '%):写交接,随后压缩,再读交接续跑' };
28
+ return { action: 'checkpoint', reason: '已过可压缩线(' + Math.round(handover * 100) + '%):先刷新交接,再登记压缩' };
31
29
  }
32
- if (input.ratio >= warn) {
33
- return { action: 'pause', reason: '过提醒线(' + Math.round(warn * 100) + '%):建议在阶段完成处调 ho_checkpoint(关键步骤返回 pause)' };
34
- }
35
- return { action: 'continue', reason: '水位正常' };
36
- }
30
+ return { action: 'continue', reason: '水位可继续;提前压缩仅在助手建议,或当前用户主动要求并授权时进行' };
31
+ }
@@ -88,7 +88,7 @@ function defaultProfile(info) {
88
88
 
89
89
  function cleanList(v, field, errors) {
90
90
  if (v === undefined) return [];
91
- if (!Array.isArray(v)) { errors.push(field + ' 必须是数组'); return []; }
91
+ if (!Array.isArray(v)) { errors.push(field + ' 须为数组'); return []; }
92
92
  const out = [];
93
93
  for (const x of v) {
94
94
  const s = String(x == null ? '' : x).trim().replace(/\\/g, '/');
@@ -0,0 +1,110 @@
1
+ /**
2
+ * 一次性后台提醒(1.0):35% 与 50% 各投一次。
3
+ *
4
+ * 投递方式(用户定):尾注入(Tail-end Injection)。插件工具返回结果时,把提醒贴在结果的末尾,
5
+ * 模型在下一步立刻读到;不改系统提示词(避免改写前缀缓存)、不拆回合、不另起消息、不打断工具。
6
+ * 不往会话日志写任何事件:宿主对 developer/message 有「必须匹配已打开的轮与步」的强校验,
7
+ * 写事件会把会话写成 SessionFormatError(已实测发生过)。
8
+ *
9
+ * 两处风险:
10
+ * 1) 压缩刚完成时投影缓存还是压缩前的旧水位,跳过恢复后的第一次判定。
11
+ * 2) 模型整轮不调用插件工具时没有注入点;此时它可自行用 ho_status 读水位。
12
+ */
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { readSessionPressurePct } from './sessions.mjs';
17
+
18
+ export function remindersPath() {
19
+ return path.join(os.homedir(), '.dsh', 'storages', 'dsh-handover.reminders.json');
20
+ }
21
+
22
+ export function readReminderState() {
23
+ try {
24
+ const v = JSON.parse(fs.readFileSync(remindersPath(), 'utf8').replace(/^\uFEFF/, ''));
25
+ const sessions = v && typeof v.sessions === 'object' && v.sessions ? v.sessions : {};
26
+ return { version: 1, sessions };
27
+ } catch {
28
+ return { version: 1, sessions: {} };
29
+ }
30
+ }
31
+
32
+ function writeReminderState(state) {
33
+ const file = remindersPath();
34
+ fs.mkdirSync(path.dirname(file), { recursive: true });
35
+ const tmp = file + '.tmp-' + process.pid;
36
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + String.fromCharCode(10), 'utf8');
37
+ fs.renameSync(tmp, file);
38
+ }
39
+
40
+ /** 35% 提醒文本。 */
41
+ export function remindText35() {
42
+ return '当前上下文约占窗口 35%。先把正在做的任务收束到可暂停的位置。'
43
+ + '随后判断剩余流程总量:较少则继续完成;较多则做完当前小步骤后停止,'
44
+ + '向当前用户确认是否继续,或写出剩余流程的行动计划、刷新交接,再压缩或换任。'
45
+ + '判定标准:完成全部剩余流程后,上下文占用不超过 45%。本条为一次性后台提醒。';
46
+ }
47
+
48
+ /** 50% 提醒文本。 */
49
+ export function remindText50() {
50
+ return '当前上下文约占窗口 50%。先把正在做的任务收束到可暂停的位置。'
51
+ + '随后判断剩余流程总量:较少则继续完成;较多则做完当前小步骤后停止,'
52
+ + '向当前用户确认是否继续,或写出剩余流程的行动计划、刷新交接,再压缩或换任。'
53
+ + '判定标准:完成全部剩余流程后,上下文占用不超过 55%。本条为一次性后台提醒。';
54
+ }
55
+
56
+ /**
57
+ * 判定一次:水位到线且该线未提醒过时写状态并返回提醒正文。
58
+ * 只应由「工具返回结果」这条路径调用:那时模型正好在等下一次装配。
59
+ * @param {{ remindRatio?: number, remind2Ratio?: number }} cfg
60
+ * @param {string} sid 会话 id
61
+ * @returns {string} 提醒正文;不需要投时为空串
62
+ */
63
+ export function checkReminder(cfg, sid) {
64
+ const id = String(sid || '');
65
+ if (!id) return '';
66
+ const state = readReminderState();
67
+ const rec = state.sessions[id] || {};
68
+ // 压缩刚完成后的那次判定:投影缓存可能还是压缩前的旧水位,跳过。
69
+ if (rec.skipNext) {
70
+ rec.skipNext = false;
71
+ state.sessions[id] = rec;
72
+ try { writeReminderState(state); } catch { /* 写盘失败不阻断工具 */ }
73
+ return '';
74
+ }
75
+ const pct = readSessionPressurePct(id);
76
+ if (pct === null || pct === undefined) return '';
77
+ const r1 = (cfg && cfg.remindRatio !== undefined ? cfg.remindRatio : 0.35) * 100;
78
+ const r2 = (cfg && cfg.remind2Ratio !== undefined ? cfg.remind2Ratio : 0.5) * 100;
79
+ const parts = [];
80
+ let changed = false;
81
+ if (pct >= r1 && !rec.r35) { rec.r35 = true; changed = true; parts.push(remindText35()); }
82
+ if (pct >= r2 && !rec.r50) { rec.r50 = true; changed = true; parts.push(remindText50()); }
83
+ if (!changed) return '';
84
+ state.sessions[id] = rec;
85
+ try { writeReminderState(state); } catch { /* 写盘失败不阻断工具 */ }
86
+ return parts.join(String.fromCharCode(10, 10));
87
+ }
88
+
89
+ /**
90
+ * 工具结果末尾的提醒尾巴:命中阈值时返回要 append 的文本,否则空串。
91
+ * 形如 `[System Note: …]`,紧贴工具结果,模型下一步立刻读到。
92
+ * @param {{ remindRatio?: number, remind2Ratio?: number }} cfg
93
+ * @param {string} sid 会话 id
94
+ */
95
+ export function reminderTailNote(cfg, sid) {
96
+ const text = checkReminder(cfg, sid);
97
+ if (!text) return '';
98
+ return '[System Note: ' + text + ']';
99
+ }
100
+
101
+ /** 记一次「压缩后跳过下一轮判定」;由 ho_checkpoint 在登记压缩时写入。 */
102
+ export function markSkipNext(sid) {
103
+ const id = String(sid || '');
104
+ if (!id) return;
105
+ const state = readReminderState();
106
+ const rec = state.sessions[id] || {};
107
+ rec.skipNext = true;
108
+ state.sessions[id] = rec;
109
+ try { writeReminderState(state); } catch { /* 写盘失败只影响一次判定 */ }
110
+ }
package/src/lib/role.mjs CHANGED
@@ -8,19 +8,22 @@ import path from 'node:path';
8
8
  import { readJsonDir, pick, isSafeName, assertSafeName } from './text.mjs';
9
9
  import { projCacheDir, isSubagent } from './sessions.mjs';
10
10
  import { globalWorkspaceSessionIds } from './registry.mjs';
11
- import { TOOL_NAMES, PEER_NAMES, nameOf, peerNameOf } from './names.mjs';
11
+ import { TOOL_NAMES, PEER_NAME, nameOf } from './names.mjs';
12
12
  import { stampTz } from './brief.mjs';
13
13
 
14
14
  export const ROLES = ['global', 'project', 'misc', 'plain'];
15
15
  export const ROLE_LABEL = { global: '全局', project: '项目', misc: '杂项', plain: '普通' };
16
16
 
17
- export const ALL_TOOL_NAMES = TOOL_NAMES.concat(PEER_NAMES);
17
+ export const ALL_TOOL_NAMES = TOOL_NAMES.concat([PEER_NAME]);
18
18
 
19
- /** 角色 → 可见工具(其余一律 deny)。改这张表就是改成本结构。 */
19
+ /**
20
+ * 角色 → 可见工具(其余一律 deny)。改这张表就是改成本结构。
21
+ * 合并后一个工具内含多个动作,动作级权限在工具内部再判一次(见 tools/peer.mjs)。
22
+ */
20
23
  const ROLE_TOOLS = {
21
24
  global: ALL_TOOL_NAMES.slice(),
22
- project: [nameOf('phase'), nameOf('handover'), nameOf('checkpoint'), nameOf('charter'), nameOf('gate'), nameOf('snapshot'), nameOf('profile'), nameOf('office'), nameOf('succeed'), peerNameOf('send'), peerNameOf('status')],
23
- misc: [nameOf('phase'), nameOf('handover'), nameOf('checkpoint'), nameOf('charter'), nameOf('snapshot'), nameOf('profile'), nameOf('office'), nameOf('succeed'), peerNameOf('send'), peerNameOf('status'), peerNameOf('new')],
25
+ project: [nameOf('status'), nameOf('handover'), nameOf('checkpoint'), nameOf('charter'), nameOf('gate'), nameOf('backup'), nameOf('profile'), nameOf('office'), nameOf('succeed'), nameOf('docmap'), PEER_NAME],
26
+ misc: [nameOf('status'), nameOf('handover'), nameOf('checkpoint'), nameOf('charter'), nameOf('backup'), nameOf('profile'), nameOf('office'), nameOf('succeed'), nameOf('docmap'), PEER_NAME],
24
27
  plain: [],
25
28
  };
26
29
 
@@ -61,7 +64,7 @@ function writeRolesFile(cfg, data) {
61
64
  /** 写入/更新一条角色;返回写入后的条目。 */
62
65
  export function writeRole(cfg, sessionId, patch) {
63
66
  const sid = String(sessionId || '');
64
- if (!sid) throw new Error('缺会话 id');
67
+ if (!sid) throw new Error('缺少会话 id');
65
68
  const data = readRoles(cfg);
66
69
  const prev = data.sessions[sid] || {};
67
70
  const next = Object.assign({}, prev, patch || {});
@@ -6,6 +6,7 @@
6
6
  import fs from 'node:fs';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
+ import { pathKey } from './pathkey.mjs';
9
10
 
10
11
  /** 插件自带的模板目录(dist/templates 或 src/templates)。 */
11
12
  const TPL = new URL('../templates/', import.meta.url);
@@ -20,8 +21,10 @@ const DEFAULT_ROOT_NAME = '_Workspace';
20
21
  const DIRS = [
21
22
  '_Control_Center/records',
22
23
  '_Control_Center/rules',
24
+ '_Control_Center/standards',
23
25
  '_Control_Center/templates',
24
26
  '_Control_Center/tools',
27
+ '_Control_Center/tasks',
25
28
  '_Control_Center/user',
26
29
  'Projects',
27
30
  '_Project_Meta',
@@ -33,35 +36,90 @@ const DIRS = [
33
36
  const FILES = [
34
37
  ['_Control_Center/records/Global_Handover.md', 'Handover.global.skeleton.md'],
35
38
  ['_Control_Center/records/Global_Changelog.md', 'Changelog.skeleton.md'],
39
+ // 全局总注册:只列各任务体系文件的路径;由 ho_docmap 或 scaffold 维护。
40
+ ['_Control_Center/records/systems.md', 'Systems.global.skeleton.md'],
36
41
  ['_Control_Center/rules/Workspace_Charter.md', 'Charter.skeleton.md'],
37
42
  ['_Control_Center/templates/Prompt_Templates.md', 'Prompt_Templates.md'],
43
+ ['_Control_Center/templates/Handover.md', 'Handover.template.md'],
44
+ ['_Control_Center/templates/Plan.skeleton.md', 'Plan.skeleton.md'],
45
+ ['_Control_Center/templates/System.skeleton.md', 'System.skeleton.md'],
46
+ // 通用规范:一条规范一个子目录,各角色会话都可读,只有全局写。
47
+ ['_Control_Center/standards/README.md', 'standards/README.md'],
48
+ ['_Control_Center/standards/写作规范/README.md', 'standards/写作规范/README.md'],
49
+ ['_Control_Center/standards/写作规范/依据_论文.md', 'standards/写作规范/依据_论文.md'],
38
50
  // 规则类:工作区没有这份时,ho_charter 会退回同名内置版本;这里先铺一份可改的默认口径。
39
51
  ['_Control_Center/rules/Handover_Rules.md', 'rules/Handover_Rules.md'],
40
52
  ['_Control_Center/rules/Migration_Rules.md', 'rules/Migration_Rules.md'],
41
53
  ['_Control_Center/rules/Misc_Task_Standard.md', 'rules/Misc_Task_Standard.md'],
54
+ ['_Control_Center/rules/Delegation_Rules.md', 'rules/Delegation_Rules.md'],
42
55
  ['_Control_Center/user/Writing_Rules.md', 'rules/Writing_Rules.md'],
43
56
  ];
44
57
 
45
58
  /** 规整用户粘贴的路径:去掉引号/尾分隔符、展开 ~、解析成绝对路径。 */
46
59
  function normalizeParent(input) {
47
60
  let s = String(input || '').trim();
48
- if (!s) return { error: '先粘贴一个目录(父目录,全局根会建在它里面)' };
61
+ if (!s) return { error: '请先填写目录(父目录,全局根建于其中)' };
49
62
  s = s.replace(/^["']+|["']+$/g, '').trim();
50
- if (!s) return { error: '先粘贴一个目录' };
63
+ if (!s) return { error: '请先填写目录' };
51
64
  if (s === '~') s = os.homedir();
52
65
  else if (s.startsWith('~/') || s.startsWith('~\\')) s = path.join(os.homedir(), s.slice(2));
53
66
  const abs = path.resolve(s);
54
67
  let st = null;
55
68
  try { st = fs.statSync(abs); } catch { st = null; }
56
- if (st && !st.isDirectory()) return { error: '这不是一个目录:' + abs, path: abs };
69
+ if (st && !st.isDirectory()) return { error: '该路径不是目录:' + abs, path: abs };
57
70
  // 目标父目录不存在就建:允许直接指定尚不存在的路径。
58
71
  if (!st) {
59
72
  try { fs.mkdirSync(abs, { recursive: true }); }
60
- catch (e) { return { error: '建不出这个目录:' + abs + '(' + String(e && e.message ? e.message : e) + ')', path: abs }; }
73
+ catch (e) { return { error: '无法创建该目录:' + abs + '(' + String(e && e.message ? e.message : e) + ')', path: abs }; }
61
74
  }
62
75
  return { parent: abs, createdParent: !st };
63
76
  }
64
77
 
78
+ /**
79
+ * 在既有的全局根上补齐缺失的目录与说明文件:只新建,不覆盖,不删任何东西。
80
+ * 用途:插件新增了目录(例如通用规范库)后,已部署的工作区靠它一次补齐。
81
+ */
82
+ export function ensureScaffold(root) {
83
+ const abs = String(root || '');
84
+ let st = null;
85
+ try { st = fs.statSync(abs); } catch { st = null; }
86
+ if (!st || !st.isDirectory()) return { ok: false, error: '全局根不存在或不是目录:' + (abs || '(空)') };
87
+
88
+ const created = [];
89
+ const written = [];
90
+ const skipped = [];
91
+ const missingTemplates = [];
92
+ for (const d of DIRS) {
93
+ const p = path.join(abs, d.split('/').join(path.sep));
94
+ if (!fs.existsSync(p)) created.push(d);
95
+ try {
96
+ fs.mkdirSync(p, { recursive: true });
97
+ } catch (e) {
98
+ return { ok: false, error: '建目录失败:' + d + '(' + String(e && e.message ? e.message : e) + ')', created, written, skipped };
99
+ }
100
+ }
101
+ for (const [rel, tpl] of FILES) {
102
+ const dst = path.join(abs, rel.split('/').join(path.sep));
103
+ if (fs.existsSync(dst)) { skipped.push(rel); continue; }
104
+ let body = '';
105
+ try {
106
+ body = template(tpl);
107
+ } catch {
108
+ // 内置模板缺失时不写空文件,登记后由调用方报出,避免静默留下半套体系。
109
+ missingTemplates.push(rel);
110
+ continue;
111
+ }
112
+ try {
113
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
114
+ fs.writeFileSync(dst, body, 'utf8');
115
+ written.push(rel);
116
+ } catch (e) {
117
+ return { ok: false, error: '写文件失败(' + rel + '):' + (e && e.message ? e.message : e), created, written, skipped, missingTemplates };
118
+ }
119
+ }
120
+ return { ok: true, created, written, skipped, missingTemplates };
121
+ }
122
+
65
123
  /**
66
124
  * 建全局根。
67
125
  * @param parentInput 用户在设置页粘贴的路径
@@ -74,9 +132,9 @@ export function createGlobalWorkspace(parentInput, opts) {
74
132
 
75
133
  const name = (opts && typeof opts.name === 'string' && opts.name.trim()) || DEFAULT_ROOT_NAME;
76
134
  if (name.indexOf('/') !== -1 || name.indexOf('\\') !== -1 || name === '.' || name === '..') {
77
- return { ok: false, error: '根目录名不能带路径分隔符:' + name };
135
+ return { ok: false, error: '根目录名不得含路径分隔符:' + name };
78
136
  }
79
- const sameName = path.basename(t.parent).toLowerCase() === name.toLowerCase();
137
+ const sameName = pathKey(path.basename(t.parent)) === pathKey(name);
80
138
  const root = sameName ? t.parent : path.join(t.parent, name);
81
139
 
82
140
  let existed = false;
@@ -89,30 +147,14 @@ export function createGlobalWorkspace(parentInput, opts) {
89
147
  existed = true;
90
148
  }
91
149
 
92
- const created = [];
93
150
  try {
94
151
  fs.mkdirSync(root, { recursive: true });
95
- for (const d of DIRS) {
96
- const p = path.join(root, d.split('/').join(path.sep));
97
- fs.mkdirSync(p, { recursive: true });
98
- created.push(d);
99
- }
100
152
  } catch (e) {
101
153
  return { ok: false, root, error: '建目录失败:' + (e && e.message ? e.message : e) };
102
154
  }
103
155
 
104
- const written = [];
105
- const skipped = [];
106
- for (const [rel, tpl] of FILES) {
107
- const dst = path.join(root, rel.split('/').join(path.sep));
108
- if (fs.existsSync(dst)) { skipped.push(rel); continue; }
109
- try {
110
- fs.writeFileSync(dst, template(tpl), 'utf8');
111
- written.push(rel);
112
- } catch (e) {
113
- return { ok: false, root, created, written, error: '写文件失败(' + rel + '):' + (e && e.message ? e.message : e) };
114
- }
115
- }
116
-
117
- return { ok: true, root, existed, created, written, skipped };
156
+ // 铺目录与说明文件走同一套清单:init 与 scaffold 两处不得各写一份。
157
+ const sc = ensureScaffold(root);
158
+ if (!sc.ok) return { ok: false, root, created: sc.created, written: sc.written, error: sc.error };
159
+ return { ok: true, root, existed, created: sc.created, written: sc.written, skipped: sc.skipped };
118
160
  }
@@ -28,7 +28,6 @@ export function createScheduler(deps) {
28
28
  const timers = new Map();
29
29
  let disposed = false;
30
30
 
31
- // 用全局定时器:cordis 的 ctx.setTimeout 绑定调用方 fiber,工具调用返回后可能被一并释放
32
31
  // 用全局定时器:cordis 的 ctx.setTimeout 绑定调用方 fiber,工具调用返回后可能被一并释放。
33
32
  function later(fn, ms) {
34
33
  return setTimeout(fn, ms);
@@ -72,9 +71,9 @@ export function createScheduler(deps) {
72
71
 
73
72
  async function compactOnce(sessionId) {
74
73
  const agent = agentOf(sessionId);
75
- if (!agent) throw new Error("agent 不在内存中(会话可能已休眠)");
74
+ if (!agent) throw new Error("会话不在内存中(可能已休眠)");
76
75
  const compaction = compactionOf(agent);
77
- if (!compaction || typeof compaction.compactNow !== "function") throw new Error("compaction.compactNow 不可用(profile 级与会话作用域都没取到)");
76
+ if (!compaction || typeof compaction.compactNow !== "function") throw new Error("compaction.compactNow 不可用(profile 级与会话作用域均未取到)");
78
77
  const ac = new AbortController();
79
78
  const timer = later(() => ac.abort(new Error("timeout")), cfg.compactTimeoutMs || 120000);
80
79
  try {
@@ -129,7 +128,7 @@ export function createScheduler(deps) {
129
128
  if (!agent) { fail(sessionId, "会话不在内存,无法压缩"); return; }
130
129
  if (agent.status === "running") {
131
130
  const idle = await waitIdle(agent, cfg.waitIdleMs || 180000);
132
- if (!idle) { fail(sessionId, "等了 " + Math.round((cfg.waitIdleMs || 180000) / 1000) + " 秒会话仍未空闲"); return; }
131
+ if (!idle) { fail(sessionId, "等待 " + Math.round((cfg.waitIdleMs || 180000) / 1000) + " 秒后,会话仍未空闲"); return; }
133
132
  }
134
133
  try {
135
134
  const result = await compactOnce(sessionId);
@@ -158,7 +157,7 @@ export function createScheduler(deps) {
158
157
  } catch (e) {
159
158
  const code = e && e.code;
160
159
  if (code === "busy") {
161
- if (Date.now() - job.startedAt > (cfg.waitIdleMs || 180000)) { fail(sessionId, "压缩一直 busy(有别的压缩或排队工作)"); return; }
160
+ if (Date.now() - job.startedAt > (cfg.waitIdleMs || 180000)) { fail(sessionId, "压缩持续处于 busy 状态(存在其他压缩或排队工作)"); return; }
162
161
  retryLater(sessionId, 800);
163
162
  return;
164
163
  }
@@ -182,7 +181,7 @@ export function createScheduler(deps) {
182
181
  pending.delete(sessionId);
183
182
  note("checkpoint failed " + sessionId + ": " + msg);
184
183
  try {
185
- promptSelf(sessionId, "【dsh-handover】压缩未执行:" + msg + "。历史没有被压缩,交接已就绪;请把这条消息原样告诉用户,不要自行重试压缩。").catch(() => {});
184
+ promptSelf(sessionId, "【dsh-handover】压缩未执行:" + msg + "。历史未被压缩,交接已就绪。请将本条消息原样告知当前用户,不得自行重试压缩。").catch(() => {});
186
185
  } catch { /* ignore */ }
187
186
  }
188
187
 
@@ -16,7 +16,7 @@ export async function newSessionIn(env, opts) {
16
16
  }
17
17
  const workspaceId = String(o.workspaceId || '');
18
18
  const cwd = String(o.cwd || '');
19
- if (!workspaceId && !cwd) return { error: '建会话要么给 workspaceId,要么给 cwd' };
19
+ if (!workspaceId && !cwd) return { error: '建会话须给出 workspaceId 或 cwd' };
20
20
  let created = null;
21
21
  try {
22
22
  created = workspaceId ? await controller.create({ workspaceId }) : await controller.create({ cwd });
@@ -24,10 +24,10 @@ export async function newSessionIn(env, opts) {
24
24
  return { error: '建会话失败:' + String(e && e.message ? e.message : e) };
25
25
  }
26
26
  const sessionId = String((created && created.sessionId) || '');
27
- if (!sessionId) return { error: 'create 没有返回 sessionId' };
27
+ if (!sessionId) return { error: 'create 未返回 sessionId' };
28
28
  const title = String(o.title || '');
29
29
  if (title) {
30
- try { if (typeof controller.rename === 'function') await controller.rename({ sessionId, title }); } catch { /* 改名失败可事后 peer_rename */ }
30
+ try { if (typeof controller.rename === 'function') await controller.rename({ sessionId, title }); } catch { /* 改名失败可事后用 peer(action="rename") */ }
31
31
  }
32
32
  const role = String(o.role || '');
33
33
  if (role && role !== 'plain' && typeof env.writeRole === 'function') {