@mingmaple/dsh-handover 0.9.13 → 0.9.22

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 (65) hide show
  1. package/README.md +46 -19
  2. package/docs/migrate.md +6 -6
  3. package/docs/releases/0.9.14.md +18 -0
  4. package/docs/releases/0.9.22.md +34 -0
  5. package/docs/troubleshoot.md +34 -10
  6. package/package.json +71 -71
  7. package/scripts/blank-dsh.mjs +29 -23
  8. package/scripts/blank-smoke.mjs +261 -78
  9. package/scripts/e2e-migration.mjs +2 -2
  10. package/scripts/office.mjs +3 -3
  11. package/src/client.js +159 -120
  12. package/src/index.mjs +65 -58
  13. package/src/lib/api.mjs +9 -7
  14. package/src/lib/brief.mjs +2 -2
  15. package/src/lib/config.mjs +3 -3
  16. package/src/lib/consent.mjs +54 -1
  17. package/src/lib/docmap.mjs +143 -0
  18. package/src/lib/gates.mjs +25 -1
  19. package/src/lib/guide.mjs +35 -44
  20. package/src/lib/lint.mjs +3 -2
  21. package/src/lib/manual.mjs +48 -9
  22. package/src/lib/mover.mjs +10 -10
  23. package/src/lib/names.mjs +14 -19
  24. package/src/lib/office.mjs +22 -14
  25. package/src/lib/pack-ops.mjs +2 -2
  26. package/src/lib/peer.mjs +4 -4
  27. package/src/lib/role.mjs +8 -5
  28. package/src/lib/scaffold.mjs +42 -20
  29. package/src/lib/schedule.mjs +1 -2
  30. package/src/lib/session-create.mjs +1 -1
  31. package/src/lib/session-ops.mjs +49 -20
  32. package/src/lib/sessions.mjs +1 -1
  33. package/src/lib/status.mjs +205 -45
  34. package/src/lib/succession.mjs +27 -11
  35. package/src/lib/zstd-log.mjs +1 -1
  36. package/src/selftest.mjs +6 -6
  37. package/src/templates/Prompt_Templates.md +13 -13
  38. package/src/templates/Runtime_Manual.md +56 -30
  39. package/src/templates/rules/Handover_Rules.md +2 -1
  40. package/src/templates/rules/Migration_Rules.md +1 -1
  41. package/src/templates/standards/README.md +25 -0
  42. package/src/tools/ho_backup.mjs +135 -0
  43. package/src/tools/ho_checkpoint.mjs +12 -11
  44. package/src/tools/ho_docmap.mjs +153 -0
  45. package/src/tools/ho_gate.mjs +2 -1
  46. package/src/tools/ho_office.mjs +1 -1
  47. package/src/tools/ho_sessions.mjs +3 -4
  48. package/src/tools/ho_status.mjs +246 -0
  49. package/src/tools/ho_succeed.mjs +1 -1
  50. package/src/tools/ho_workspace.mjs +86 -14
  51. package/src/tools/peer.mjs +205 -0
  52. package/src/tools/ho_brief.mjs +0 -57
  53. package/src/tools/ho_diag.mjs +0 -87
  54. package/src/tools/ho_drift.mjs +0 -41
  55. package/src/tools/ho_init.mjs +0 -82
  56. package/src/tools/ho_pack.mjs +0 -38
  57. package/src/tools/ho_phase.mjs +0 -37
  58. package/src/tools/ho_snapshot.mjs +0 -104
  59. package/src/tools/ho_tokens.mjs +0 -57
  60. package/src/tools/peer_list.mjs +0 -37
  61. package/src/tools/peer_new.mjs +0 -75
  62. package/src/tools/peer_peek.mjs +0 -41
  63. package/src/tools/peer_rename.mjs +0 -31
  64. package/src/tools/peer_send.mjs +0 -52
  65. package/src/tools/peer_status.mjs +0 -46
@@ -12,7 +12,7 @@ import { lineageKeyOf, loadOffice, nextName, registerHolder, stampNow } from './
12
12
  import { checkConsent, consumedConsent } from './consent.mjs';
13
13
  import { newSessionIn } from './session-create.mjs';
14
14
  import { sendPrompt } from './prompt.mjs';
15
- import { resolveWorkspaceFor, promptFor } from './session-ops.mjs';
15
+ import { resolveWorkspaceFor, promptFor, workspaceIdOfPath, workspacePathOf } from './session-ops.mjs';
16
16
  import { watermark } from './facts.mjs';
17
17
 
18
18
  /** 交接里不得残留的占位符。 */
@@ -22,6 +22,14 @@ function msgOf(e) {
22
22
  return String(e && e.message ? e.message : e);
23
23
  }
24
24
 
25
+ /** 现任全局会话 id(登记表是唯一事实源);没有记录或读不到时返回空串。 */
26
+ function currentGlobalId(ws) {
27
+ try {
28
+ const lin = loadOffice(ws).lineages['全局'];
29
+ return lin && lin.current && lin.current.sessionId ? String(lin.current.sessionId) : '';
30
+ } catch { return ''; }
31
+ }
32
+
25
33
  /**
26
34
  * 找接任指令节:含「## 下一步」或「## 2.」这类标题;没有标题时接受 §2 的写法。
27
35
  * 判据与 ho_gate 的交接体检一致:这一节至少要有一个条目(列表、表格或编号行)。
@@ -106,14 +114,14 @@ export async function runSuccession(env, exec, opts) {
106
114
 
107
115
  // 2. 用户授权:AI 不能自行批准。
108
116
  const selfId = env.selfId(exec);
109
- if (!selfId) return reject('拿不到当前会话 id,无法校验授权。');
117
+ if (!selfId) return reject('无法获取当前会话 id,无法校验授权。');
110
118
  const cs = checkConsent(selfId, 'succeed');
111
119
  if (!cs.ok) {
112
120
  return reject([
113
121
  cs.reason + '。',
114
122
  '授权方式(任选其一):',
115
123
  ' ① 在聊天框发送 /allow-succeed(最快,仅授权本会话);',
116
- ' ② 在设置页的 dsh-handover 中点击「换任授权」的「允许换任」。',
124
+ ' ② 在设置页的 dsh-handover 中,点击「授权」里的「授权换任」。',
117
125
  '授权有效期 5 分钟,用后失效。',
118
126
  ].join(NL));
119
127
  }
@@ -165,21 +173,29 @@ export async function runSuccession(env, exec, opts) {
165
173
  // 5. 分配编号:登记表历史最大号加一,不采用会话自述。
166
174
  const name = nextName(store, key);
167
175
 
168
- // 6. 确定工作区:与 ho_sessions(action="new") 同一套解析;杂项落到本会话所在工作区。
176
+ // 6. 确定工作区:与 ho_sessions(action="new") 同一套解析。
177
+ // 杂项接任者落回**本会话所在的注册工作区**(按会话 cwd 反查);反查不到才退回工作区根。
169
178
  let workspaceId = '';
170
179
  try {
171
- workspaceId = resolveWorkspaceFor(env, exec, kind, project, task, kind === 'misc' ? { workspace: ws } : {});
180
+ let wsOpt = {};
181
+ if (kind === 'misc') {
182
+ const selfCwd = typeof env.selfCwd === 'function' ? String(env.selfCwd(exec) || '') : '';
183
+ wsOpt = selfCwd && workspaceIdOfPath(env, selfCwd) ? { cwd: selfCwd } : { workspace: ws };
184
+ }
185
+ workspaceId = resolveWorkspaceFor(env, exec, kind, project, task, wsOpt);
172
186
  } catch (e) {
173
187
  return reject(msgOf(e));
174
188
  }
175
189
 
176
190
  // 7. 拼启动提示词:末尾追加交接路径与「先读交接、读毕报到」。
191
+ // 「全局 id」填**现任全局**:项目与杂项换任时,已交出的旧会话不是全局,不能顶替这个位置。
177
192
  let prompt = '';
178
193
  try {
179
- const base = promptFor(env, { ws, kind, project, task, title: name, sessionId: '', globalSessionId: selfId, extra: '' });
194
+ const globalId = kind === 'global' ? selfId : (currentGlobalId(ws) || selfId);
195
+ const base = promptFor(env, { ws, kind, project, task, title: name, sessionId: '', globalSessionId: globalId, extra: '' });
180
196
  prompt = base + NL + NL + [
181
197
  '接任交接:' + handoverFile,
182
- '先读这份交接再动手;读毕向用户报告当前停在哪一步、交接列有哪些待办,然后等待用户指派任务。',
198
+ '先读这份交接再动手;读毕向当前用户报告当前停在哪一步、交接列有哪些待办,然后等待当前用户指派任务。',
183
199
  ].join(NL);
184
200
  } catch (e) {
185
201
  return reject('启动提示词生成失败:' + msgOf(e));
@@ -195,11 +211,11 @@ export async function runSuccession(env, exec, opts) {
195
211
  // 9. 登记在任:原在任者自动进历史,Registry.md 在役列同步。
196
212
  let reg = null;
197
213
  try {
198
- reg = registerHolder(ws, { kind, project, task, name, sessionId, workspace: ws, note });
214
+ reg = registerHolder(ws, { kind, project, task, name, sessionId, workspace: workspacePathOf(env, workspaceId) || ws, note });
199
215
  } catch (e) {
200
216
  return [
201
217
  '会话已建 ' + sessionId + '(' + name + '),但登记在任失败:' + msgOf(e),
202
- '启动提示词尚未投递;先修好登记表,再用 peer_send 通知它。',
218
+ '启动提示词尚未投递;先修好登记表,再用 peer(action="send")通知它。',
203
219
  ].join(NL);
204
220
  }
205
221
 
@@ -237,8 +253,8 @@ export async function runSuccession(env, exec, opts) {
237
253
  out.push('换任完成:' + name + '(' + sessionId + ')');
238
254
  out.push('在任登记:' + key + (reg && reg.registry && reg.registry.changed && reg.registry.changed.length
239
255
  ? ',Registry.md 已同步在役列' : ''));
240
- out.push('启动提示词:' + (delivered ? '已投递' : '未送达:' + deliveryError + '。会话已建,可用 peer_send 补投'));
256
+ out.push('启动提示词:' + (delivered ? '已投递' : '未送达:' + deliveryError + '。会话已建,可用 peer(action="send")补投'));
241
257
  out.push('交接登记:' + appendNote);
242
- out.push('本会话到此停止,等待用户在新会话中派任务。');
258
+ out.push('本会话到此停止,等待当前用户在新会话中派任务。');
243
259
  return out.join(NL);
244
260
  }
@@ -1,4 +1,4 @@
1
- /** DSH 会话日志:定位 + 多帧 zstd 解码(与 handover_check.mjs 同一做法)。 */
1
+ /** DSH 会话日志:定位 + 多帧 zstd 解码(坏帧跳过)。 */
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
package/src/selftest.mjs CHANGED
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * 脱离 DSH 的自测台:用假 env 直接跑工具 execute,打印只读结果,供人工比对;不参与发布门禁。
3
- * 用法:node selftest.mjs [--ws <工作区根>] [--session <session id>] [--tool brief|gate|sessions|phase|charter|all]
3
+ * 用法:node selftest.mjs [--ws <工作区根>] [--session <session id>] [--tool status|gate|sessions|charter|office|succeed|all]
4
4
  */
5
5
  import { resolveConfig } from './lib/config.mjs';
6
6
  import { projCacheDir, pressurePct } from './lib/sessions.mjs';
7
7
  import { readJsonDir, isDir } from './lib/text.mjs';
8
- import { pmBriefTool } from './tools/ho_brief.mjs';
8
+ import { pmStatusTool } from './tools/ho_status.mjs';
9
9
  import { pmGateTool } from './tools/ho_gate.mjs';
10
10
  import { pmSessionsTool } from './tools/ho_sessions.mjs';
11
- import { pmPhaseTool } from './tools/ho_phase.mjs';
12
11
  import { pmCharterTool } from './tools/ho_charter.mjs';
13
12
  import { pmOfficeTool } from './tools/ho_office.mjs';
14
13
  import { pmSucceedTool } from './tools/ho_succeed.mjs';
@@ -65,10 +64,11 @@ const envSucceed = Object.assign({}, env, {
65
64
 
66
65
  const exec = null;
67
66
  const cases = [
68
- { t: pmBriefTool(env), a: {}, flag: "brief" },
67
+ { t: pmStatusTool(env, { schedulerReady: () => false, schedulerError: () => 'selftest' }), a: { view: "brief" }, flag: "status" },
68
+ { t: pmStatusTool(env, { schedulerReady: () => false, schedulerError: () => 'selftest' }), a: { view: "phase" }, flag: "status" },
69
+ { t: pmStatusTool(env, { schedulerReady: () => false, schedulerError: () => 'selftest' }), a: { view: "diag" }, flag: "status" },
69
70
  { t: pmGateTool(env), a: { scope: "all" }, flag: "gate" },
70
71
  { t: pmSessionsTool(env), a: {}, flag: "sessions" },
71
- { t: pmPhaseTool(env), a: {}, flag: "phase" },
72
72
  { t: pmCharterTool(env), a: { doc: "charter", section: "8.1.1.4", maxLines: 30 }, flag: "charter" },
73
73
  { t: pmOfficeTool(env), a: { query: "全局" }, flag: "office" },
74
74
  { t: pmSucceedTool(envSucceed), a: {}, flag: "succeed", want: /没有授权/ },
@@ -77,7 +77,7 @@ const cases = [
77
77
  let failed = 0;
78
78
  for (const c of cases) {
79
79
  if (only !== "all" && only !== c.flag) continue;
80
- const bar = "==== ho_" + c.flag + " ====";
80
+ const bar = "==== " + c.t.name + " " + JSON.stringify(c.a) + " ====";
81
81
  console.log(bar);
82
82
  try {
83
83
  const out = await c.t.execute(c.a, exec);
@@ -5,24 +5,24 @@
5
5
  ## 1 共用骨架(所有消息都由它起头)
6
6
 
7
7
  ```text
8
- 你是<角色>,由全局管理对话创建并交接。
8
+ 你是<角色>,由全局管理会话创建并交接。
9
9
  第一条要读的:<绝对路径>(读毕再开始作业)。| 项目→ _Project_Meta/<项目名>/Handover.md;全局→ records/Global_Handover.md;杂项首任→ rules/Misc_Task_Standard.md
10
10
  不得每轮回执,也不发送「收到」「已完成」一类确认;全局只看文件末态。
11
- 你的 session id:<本会话 id>。确需向全局发消息时,以 `peer_send` 发至 <全局 id>。| 不得据 peer_list 推测 id
12
- 水位:≥32% 应先写毕交接、再通知全局,并提醒用户换会话;>35% 不得新开任务。
13
- 边界:只读写 <范围>。删除或覆盖前须先留快照,**删除须经用户确认**。对用户可见一律中文(含工具 description 字段)。
11
+ 你的 session id:<本会话 id>。确需向全局发消息时,以 `peer`(action="send")发至 <全局 id>。| 不得据 `peer`(action="list")推测 id
12
+ 水位:≥32% 应先写毕交接、再通知全局,并提醒当前用户换会话;>35% 不得新开任务。
13
+ 边界:只读写 <范围>。删除或覆盖前须先留快照,**删除须经当前用户确认**。对用户可见一律中文(含工具 description 字段)。
14
14
  时间一律**带时区偏移**书写(如 2026-10-21 07:58 +0800),不写「今天、几点」这种会漂移的说法;本机时区 Asia/Shanghai(+08:00),历史记录里的 +00:00 与新记录按**绝对时刻**比较。
15
15
  简单任务直接执行、直接答复;工具正常返回即予采信,不重复核验。子智能体按成本使用:单个子智能体自身亦需十余步,五个约等于另开一整个会话。仅当单块 ≥15 步且可机械验收、或确需隔离时方可派发,并发 ≤3;一文件一派、将机械批处理拆成 N 份均属反模式;可脚本批量并抽样验收者应先写脚本。判断不清者默认自行完成(Charter §十一)。
16
16
  ```
17
17
 
18
- > 7 行是**上限**,不是起点:宜少不宜多,只保留「不写就会做错」的条目。
18
+ > 现行骨架 8 行,是**上限**而不是起点:宜少不宜多,只保留「不写就会做错」的条目。
19
19
 
20
20
  ## 2 消息一 · 新会话启动
21
21
 
22
22
  ### 2.1 全局(接任或首任)
23
23
 
24
24
  ```text
25
- <骨架 6 行>
25
+ <骨架(见 §1)>
26
26
  你是第 <N> 任全局,标题「全局<N>」,链内代数第 <M> 代。
27
27
  作业顺序:读交接 → 核对 §6 的路径与命令是否仍然有效 → 不一致时先对齐再行动。
28
28
  阅读范围(按需取用,不要求预先通读):先读交接;全局流程与自己这个角色的硬边界见 Workspace_Charter.md(§7.2 会话与交接、§8 边界与铁律);**写文档时才读** user/Writing_Rules.md 与 templates/ 下对应文件。
@@ -32,23 +32,23 @@
32
32
  ### 2.2 项目(首任或接任)
33
33
 
34
34
  ```text
35
- <骨架 6 行>
36
- 你是「<项目名>」项目的执行对话,标题「<项目名><N>」。
35
+ <骨架(见 §1)>
36
+ 你是「<项目名>」项目的执行会话,标题「<项目名><N>」。
37
37
  库根:Projects/<项目名>/<项目名>库/ ;元数据:_Project_Meta/<项目名>/ ;快照:_Temp/<项目名>/Snapshots/ ;版本冻结:_Archives/<项目名>/Backups/
38
38
  开始作业前应先读:_Project_Meta/<项目名>/Handover.md → 库根 SKILL.md →(修改文档时)_Control_Center/rules/ 下对应规范。
39
39
  两件必查:库内 RULES.md 里必须有「运行期省 token」与「上下文水位自查」两条;缺者应照 Workspace_Charter.md 原文补上(唯一来源,不得自行改动数字)。
40
- 换任自办,规则见插件注入的提示段;**不得自行调用 peer_new**,也不向全局请求换任。
40
+ 换任自办,规则见插件注入的提示段;**不得自行调用 `peer`(action="new")**,也不向全局请求换任。
41
41
  ```
42
42
 
43
43
  ### 2.3 杂项(只在首任时由全局发,此后自理)
44
44
 
45
45
  ```text
46
- <骨架 6 行>
46
+ <骨架(见 §1)>
47
47
  你是杂项任务会话,标题「<任务短名>」,不编号。
48
48
  任务目录:_Misc/tasks/<任务短名>/(产物、脚本、日志都放这里)
49
49
  三件文件:Project_Overview.md(是什么/边界/验收判据)· Changelog.md(环境事实/变更/回滚,只追加)· Handover.md(在途)
50
50
  换任自办(规则见插件注入的提示段):接任者名字与会话 id 由插件写进登记表并在旧交接末尾追加登记,不通知全局。
51
- 本任务:<任务正文>| 用户原话照抄,不改写、不缩范围、不替用户补验收判据
51
+ 本任务:<任务正文>| 当前用户原话照抄,不改写、不缩范围、不替当前用户补验收判据
52
52
  ```
53
53
 
54
54
  ## 3 消息二 · 换任(已停用)
@@ -73,12 +73,12 @@
73
73
  ## 6 消息五 · 迁移通知(打包前刷交接)
74
74
 
75
75
  ```text
76
- 【平台迁移通知】用户要把整个工作区搬到另一个平台,打包前需要你刷一次交接。
76
+ 【平台迁移通知】当前用户要把整个工作区搬到另一个平台,打包前需要你刷一次交接。
77
77
  只做三件事,1 分钟内完成;**不要开新任务、不要跑全量自检、不要改库内文件**:
78
78
  1) 把 _Project_Meta/<项目名>/Handover.md 的「在途、下一步、关键路径」刷成最新;
79
79
  2) 绝对路径改成 <工作区根>/... 的相对写法(新平台的根不同);
80
80
  3) 回一行:迁移交接完成|<项目名>|在途:<有/无>|<一句话>
81
- 搬库不带对话记录,Handover.md 是唯一的连续性来源。
81
+ 搬库不带会话记录,Handover.md 是唯一的连续性来源。
82
82
  ```
83
83
 
84
84
  ## 7 发送纪律
@@ -2,46 +2,42 @@
2
2
 
3
3
  > 这是**按需取回**的全文:系统提示里只留短段与指针,需要细节时用 `ho_charter(doc="runtime")` 取本文。
4
4
  >
5
- > **内置即默认**:本文、Charter 骨架、交接、台账骨架、提示词模板、四份规则文档都随插件发布(`dist/templates/`)。工作区里的同名文件只是「本地化覆盖」;删掉就自动回到内置,功能不降级。`ho_init` 是把默认口径写入磁盘,便于修改,**不是使用前提**。
5
+ > **内置即默认**:本文、Charter 骨架、交接、台账骨架、提示词模板、四份规则文档都随插件发布(`dist/templates/`)。工作区里的同名文件只是「本地化覆盖」;删掉就自动回到内置,功能不降级。`ho_workspace(action="init")` 是把默认口径写入磁盘,便于修改,**不是使用前提**。
6
6
  > 口径冲突时以工作区的 `_Control_Center/rules/` 为准;本文只讲「插件怎么用、角色怎么分」。
7
7
 
8
8
  ## 1. 角色四档
9
9
 
10
10
  | 角色 | 判定顺序 | 交接文件 | 可见工具 |
11
11
  |---|---|---|---|
12
- | 全局 | 角色表 → 标题含「全局」 → 全局工作区 | `_Control_Center/records/Global_Handover.md` | 24 个(全部) |
12
+ | 全局 | 角色表 → 标题含「全局」 → 全局工作区 | `_Control_Center/records/Global_Handover.md` | 14 个(全部) |
13
13
  | 项目 | 角色表(带项目名)→ cwd 在 `Projects/<名>/` → 首条提示含 `Projects/<名>/` | `_Project_Meta/<项目>/Handover.md` | 11 个 |
14
- | 杂项 | 角色表(带任务名)→ cwd 在 `_Misc/tasks/<任务>/` | `_Misc/tasks/<任务>/Handover.md` | 11 个 |
14
+ | 杂项 | 角色表(带任务名)→ cwd 在 `_Misc/tasks/<任务>/` | `_Misc/tasks/<任务>/Handover.md` | 10 个 |
15
15
  | 普通 | 子会话,或都不中 | 无(不参与交接) | 0 个(不注入提示段) |
16
16
 
17
17
  角色表在 `~/.dsh/storages/dsh-handover.roles.json`,是**本机状态**,不进仓库、不进迁移包。子会话默认普通;需要时由全局在角色表里指定。
18
18
  设定入口:设置 → dsh-handover →「会话角色」;或对全局说「把 Demo1 设成项目角色」,它用 `ho_sessions(action="role")` 办。
19
19
 
20
- ## 2. 工具清单(24 个)
20
+ ## 2. 工具清单(14 个)
21
21
 
22
22
  ### 只读
23
- - `ho_brief` —— 起始与收束的单条命令:水位、闸门、会话清点、阶段建议。
24
- - `ho_gate` —— 通用约束条闸门(按骨架档案的库型判定)+交接文档体检。
23
+ - `ho_status` —— 会话状态,`view` 分流:`phase`(缺省;水位、压缩次数、锁、建议动作,并列出当前授权)、`brief`(起始简报:水位、闸门、会话清点、漂移、阶段建议)、`diag`(插件自检:服务、调度器、初始化错误、角色与工具集)、`drift`(交接漂移:Δ=当前轮−交接轮,>3 需刷新)、`tokens`(token 体检,需 `sessions`)。
24
+ - `ho_gate` —— 通用约束条闸门(按骨架档案的库型判定)+交接文档体检+文档定位 map 的路径检查。
25
25
  - `ho_sessions` —— 会话清点;也用来设定角色、建会话、出提示词、归档、解归档(`action` 分流)。
26
- - `ho_phase` —— 本会话水位、已压缩次数、锁状态、建议动作。
27
26
  - `ho_charter` —— 按节取规则正文:`doc`=`charter`(缺省)、`handover_rules`、`migration_rules`、`misc_standard`、`writing_rules`、`runtime`(本文全文);`section` 留空只列目录。**工作区没有对应规则文件时退回插件内置默认口径**(输出会注明),所以换到空白机器也不会变空壳。
28
- - `ho_drift` —— 交接漂移:Δ=当前轮−交接轮,>3 需刷新。
29
- - `ho_diag` —— 插件自检:服务、调度器、压缩计数、工作区、角色判定。
30
- - `ho_tokens` —— 会话 token 体检:轮数、步数、每步 token、最贵单条返回。
31
27
  - `ho_office` —— 查在任:输入「全局」「全局3」「项目名」等,返回现任会话的名字与会话 id;多个名字空格分隔,留空列出全部。
32
- - `peer_list` / `peer_status` / `peer_peek` —— 平级会话清点与近期对话(peek 会花本会话 token)。
28
+ - `ho_docmap` —— 本会话的文档定位:`action=read|write|check`。写入范围由本会话角色决定,不接受传入项目名;写入者必须是在任会话,已交出的旧会话只读。
33
29
 
34
30
  ### 写
35
31
  - `ho_handover` —— 读、写、追加、归档**本会话自己的**交接(写前自动备份、写后按角色体检)。
36
32
  - `ho_checkpoint` —— 写交接 → 体检 → 登记压缩 → 轮末自动压缩。`resume=continue`(缺省)压缩后续跑;`resume=wait` 表示任务已收尾,压缩后不唤醒。
37
- - `ho_snapshot` —— 库快照:逐字节复制、sha256 清单、来源说明。
38
33
  - `ho_changelog` —— 按固定表格追加一行台账。
39
- - `ho_pack` —— `action`:`check`(打包前置闸门)、`pack`(打包,缺省)、`verify`(校验包是否仍是现版)、`restore`(默认预演,覆盖前备份,从不删除目标文件)。`format=auto|7z|tar`:有 7-Zip 出 `.7z`,没有就出 `.tar.gz`(内置归档器,功能等价);解包按魔数识别,包内路径必须是相对路径且不含 `..`,否则整体中止。
40
- - `ho_workspace` —— 工作区 list、create、delete(删除只删注册)与换盘动作。
41
- - `ho_init` —— 首次安装:在指定目录下建整套全局文件体系,可顺带注册工作区。
34
+ - `ho_backup` —— `action`:`snapshot`(缺省;库快照,逐字节复制、sha256 清单、来源说明)、`check`(打包前置闸门)、`pack`(打包)、`verify`(校验包是否仍是现版)、`restore`(默认预演,覆盖前备份,从不删除目标文件)。`format=auto|7z|tar`:有 7-Zip 出 `.7z`,没有就出 `.tar.gz`(内置归档器,功能等价)。
35
+ - `ho_workspace` —— `action`:`init`(首次安装:在指定目录下建整套全局文件体系,可顺带注册工作区)、`scaffold`(在已部署的全局根上补齐缺失的目录与说明文件,只新建、不覆盖)、`list`、`create`、`delete`(删除只删注册)、`move-*`(搬迁全局根目录)。
42
36
  - `ho_profile` —— 项目或杂项的骨架注册:由所属会话通读自己的库后写入并校验。
43
37
  - `ho_succeed` —— 换任:用户授权后校验交接、按记录分配编号、建立接任会话、登记在任并投递启动提示词。
44
- - `peer_send` / `peer_new` / `peer_rename` —— 投递消息、建平级会话、只改标题。
38
+
39
+ ### 平级会话
40
+ - `peer` —— `action`:`send` 投消息(唯一消耗对方 token 的动作)、`status` 单会话详情、`list` 一览、`peek` 近期会话历史(耗本会话 token)、`rename` 只改标题、`new` 新建。`list`、`peek`、`rename` 仅全局可用;`send`、`status` 各角色可用;`new` 全局可用,杂项可建到 Misc 下。
45
41
 
46
42
  ### ho_sessions 的 action
47
43
  | action | 作用 | 关键参数 |
@@ -59,7 +55,7 @@
59
55
 
60
56
  - 32% 提醒、35% 交接线、85% 硬顶;同一会话最多压缩 3 次(共 4 段使用)。数值来自 `config.mjs`,提示段与设置页由 `lib/facts.mjs` 统一渲染。
61
57
  - 到线流程:`ho_handover` 写交接(体检不通过就拒绝压缩)→ `ho_checkpoint` 登记(`resume=continue|wait`)→ 轮末自动压缩 → 继续时投续跑消息;收尾时不唤醒,等用户派新任务。
62
- - 第 4 段到线或压缩额度用尽:由在任会话自己换任——写交接 → 请用户在聊天框敲 `/allow-succeed` 授权 → 调 `ho_succeed`(校验交接、分配编号、建立接任会话、登记在任、投递提示词)。全局仍可用 `ho_sessions(action="new")` 手动建立会话,作为自动流程失败时的备选方式。
58
+ - 第 4 段到线或压缩额度用尽:由在任会话自己换任——写交接 → 请用户在聊天框发送 `/allow-succeed` 授权 → 调 `ho_succeed`(校验交接、分配编号、建立接任会话、登记在任、投递提示词)。全局仍可用 `ho_sessions(action="new")` 手动建立会话,作为自动流程失败时的备选方式。
63
59
  - 关键步骤不要压:`ho_checkpoint` 传 `at_risk=true`,它只提醒不压;硬顶 85% 除外。
64
60
  - 压缩不可逆:历史会被摘要替换,所以「先把交接写合格」是硬前提。
65
61
  - 在任登记表由插件维护:`_Control_Center/records/Office.json` 记每一类会话的现任与会话 id;查现任用 `ho_office`,不起会话时用 `node scripts/office.mjs 全局 全局3`。
@@ -67,8 +63,9 @@
67
63
  ## 4. 交接文件的结构要求
68
64
 
69
65
  - **全局**(`_Control_Center/records/Global_Handover.md`):必须有 §0 身份与寻址(含 7 个字段)、§1 正在做的事、§2 下一步、§3 待用户决定(早期文档沿用旧标题,亦判通过)、§4 用户要求逐条落位、§5 坑点与禁区、§6 关键路径、§7 会话 id 对照(至少一行带 session id,且不能有「待回填」);不得残留模板占位符。
70
- - **项目、杂项**:必须有「正在做的事」(实质内容 ≥30 字)、「下一步待办」(至少一条表格行或列表项);不得残留占位符。
66
+ - **项目、杂项**:必须有「正在做的事」(空节不算,只判有无实质内容,不设字数下限)、「下一步待办」(至少一条表格行或列表项);不得残留占位符。
71
67
  - 体检命令即工具:`ho_gate`(全部)、`ho_handover` 写后自动体检。
68
+ - **交接类文档不设体积上限**(不规定字节、行数、页数):体积由结构(只写在途与指针)、书面语与归档整篇重写控制;删减依据是「过期即删」,不是「太长就砍」。
72
69
 
73
70
  ## 5. 派发与成本纪律(判据来源 Charter §十一)
74
71
 
@@ -96,10 +93,10 @@
96
93
 
97
94
  ## 8. 迁移与恢复
98
95
 
99
- 1. `ho_pack({action:"check", items:[...]})` 先过前置闸门(会话静止、交接到位)。
100
- 2. `ho_pack({action:"pack", kind:"project"|"task"|"global", name:"…"})` 出包,含 manifest、CHECKSUMS、MIGRATION.md 与绝对路径体检。
101
- 3. 换机:解压包 → 装插件 → `ho_pack({action:"verify", pack:"<包>"})` 看是否仍是现版。
102
- 4. `ho_pack({action:"restore", pack:"<包>", dry_run:true})` 预演,确认后 `apply=true`。
96
+ 1. `ho_backup({action:"check", items:[...]})` 先过前置闸门(会话静止、交接到位)。
97
+ 2. `ho_backup({action:"pack", kind:"project"|"task"|"global", name:"…"})` 出包,含 manifest、CHECKSUMS、MIGRATION.md 与绝对路径体检。
98
+ 3. 换机:解压包 → 装插件 → `ho_backup({action:"verify", pack:"<包>"})` 看是否仍是现版。
99
+ 4. `ho_backup({action:"restore", pack:"<包>", dry_run:true})` 预演,确认后 `apply=true`。
103
100
  5. **会话本身不迁移**:跨机连续性只靠交接文档。
104
101
 
105
102
  ## 9. 设置页与搬迁
@@ -117,9 +114,10 @@
117
114
 
118
115
  ## 11. 首次安装与工作区
119
116
 
120
- - 两个入口**等价**:设置页「首次安装」(粘贴已存在目录 → 建立全局文件体系 → 注册为工作区);或在对话里执行 `ho_init(parent=…, register=true)`。
117
+ - 两个入口**等价**:设置页「首次安装」(粘贴已存在目录 → 建立全局文件体系 → 注册为工作区);或在会话里执行 `ho_workspace(action="init", parent=…, register=true)`。
121
118
  - 未接入工作区时,闸门、交接体检、会话水位三项无数据;插件本体仍可用。
122
119
  - 注册工作区亦可单独执行 `ho_workspace(action="create", path=…, title=…)`;删除只删注册,目录与日志不动。
120
+ - 已部署的工作区在插件新增目录之后,用 `ho_workspace(action="scaffold")` 一次补齐:只建缺失项,已有文件不覆盖、内容一字不改。
123
121
  - 项目与杂项接入后先读自己的库,再写 `project.profile.json`、`task.profile.json` 并 `ho_profile(action=register)` 注册骨架。
124
122
  - 会话标题规范(全局、项目、杂项)见工作区 Charter §6.2;新建会话必须当场给标题。
125
123
 
@@ -129,7 +127,7 @@
129
127
 
130
128
  | 层 | 内容 | 何时进入上下文 | 实测参考(加权 token) |
131
129
  |---|---|---|---|
132
- | L0 常驻 | 角色段(全局五节;项目、杂项各节) | **每一轮** | 全局约 385;项目与杂项约 370;长路径与迁移提示档约 435、419(参考值,不设上限) |
130
+ | L0 常驻 | 角色段(全局五节;项目、杂项各节) | **每一轮** | 全局约 526;项目与杂项约 514;长路径与迁移提示档约 576、563(参考值,不设上限) |
133
131
  | L0 用户内容 | 全局前置提示词(用户开关,默认关闭) | 开启且非空时每轮 | 由用户自管,不计入本插件预算 |
134
132
  | L1 手册 | 本文件 | 按需取回,可**按节**取 | 不设上限(不进每轮) |
135
133
  | L2 规则文档 | 工作区 `_Control_Center/rules/` | 按需 | 由工作区自管 |
@@ -141,7 +139,7 @@
141
139
  ### 12.2 布局(改动必须落在既有骨架内)
142
140
 
143
141
  - **全局段**:五节固定——身份、权限、节奏、交接、按需取用。
144
- - **项目、杂项段**:九行固定——写入范围、库根、骨架、临时与归档、交接、水位与压缩、对外通信、指针;有迁移时另加一行。
142
+ - **项目、杂项段**:十一行固定(含标题行)——写入范围、库根、骨架、临时与归档、交接、水位与压缩、对外通信、流程指针、通用规范指针、通用规范写入范围;有迁移时另加一行。
145
143
  - **普通段**:不注册任何提示段。
146
144
  - 新增内容先在既有节内找位置;找不到位置,说明该内容属于 L1,而不是新增一节。
147
145
 
@@ -156,14 +154,42 @@
156
154
 
157
155
  - **增**:判定是否每轮需要 → 若否,写进 L1 并在「按需取用」加一行指针 → 若必须进 L0,指明节次 → 跑 `blank-smoke`(体积与结构断言)→ CHANGELOG 记「加了什么、为什么不能是指针、体积变化」。
158
156
  - **删**:先查引用(冒烟断言、手册同项、其他角色段)→ 同步删除 L1 与 L0 的重复表述,避免「常驻删了、文档没补」→ 再跑冒烟。
159
- - **改**:一次改一件事;改完对比体积,超上限即回退,不允许「先合并、下次再压」。
157
+ - **改**:一次改一件事;改完对比体积并记录参考值,不设上限(提示词效果优先)。
160
158
 
161
159
  ### 12.5 检查清单(每次动提示词后照着过)
162
160
 
163
- 1. 体积:全局段 ≤320、项目段 ≤300(含迁移提示与真实长路径时按 360、340 档)。
164
- 2. 结构:全局五节、项目九行各出现一次,顺序不变。
161
+ 1. 体积:只记录实测参考值(全局与项目段各约 400 上下),不判上限。
162
+ 2. 结构:全局五节、项目十一行各出现一次,顺序不变。
165
163
  3. 一处一规则:会话间通信规则、压缩授权、交接线各只出现一次。
166
- 4. 禁止回灌:工作流细节(`ho_pack`、`move-probe`、具体命令步骤)不得出现在 L0。
164
+ 4. 禁止回灌:工作流细节(`ho_backup`、`move-probe`、具体命令步骤)不得出现在 L0。
167
165
  5. 手册同步:凡 L0 指向的 section 必须在本文中存在且标题可匹配。
168
166
 
169
- > 增长护栏由测试执行,不靠自觉:断言失败即视为缺陷,不允许带病提交。
167
+ > 结构护栏由测试执行(节次齐全、只出现一次),不靠自觉;体积只测量、不判失败。
168
+
169
+ ## 13. 文档定位与授权提示
170
+
171
+ ### 13.1 文档定位(docmap)
172
+
173
+ - 每个会话(全局、每个项目、每个杂项任务)一份文件:`_Control_Center/records/docmap/global.md`、`project-<项目名>.md`、`misc-<任务名>.md`,随工作区迁移。
174
+ - 三列:路径(工作区相对路径)、一句话介绍、什么时候该读。只指路,读不读由会话自己判断,不要求通读。
175
+ - 写:`ho_docmap(action="write", entries=[…])` 整份替换,逐条校验路径存在,缺项整条拒绝;写入者必须是在任会话(以 `Office.json` 为准),已交出的旧会话只读;全局不代写项目与杂项的 map。
176
+ - 生效:写盘后当场重装该会话提示段,下一轮即注入;空 map 不注入;不设字数上限。
177
+ - 闸门:`ho_gate` 检查所有 map 的路径存在性,失效即报,由对应会话的在任者更新。
178
+ - 外部承接:`_Control_Center/templates/External_Takeover_Prompt.md` 生成提示词时,把本项目 map 的表格原样嵌入「文件地图」之后并保留判定句;没有 map 则省略这一段,不得编造。
179
+
180
+ ### 13.2 授权提示(压缩、换任)
181
+
182
+ - 授权由用户产生,写入 `~/.dsh/storages/dsh-handover.consent.json`,5 分钟、一次性、按会话。
183
+ - 授权一到,目标会话的提示段立刻多一行:「用户已授权<压缩|换任>,至 <时刻>(一次性,用后失效)」;过期或用掉后消失,不占位。
184
+ - `ho_status(view="phase")` 与 `view="brief"` 的输出里有一行「授权」,写明剩余分钟与到期时刻;向用户索要授权后先查它,再等回话。
185
+ - 两种授权可以并存,各占一行。
186
+
187
+ ## 14. 通用规范库
188
+
189
+ - 位置 `_Control_Center/standards/`:一条规范一个子目录,目录名即规范名;正文入口为子目录内的 `README.md`;规范自带的脚本与附件放在同一子目录,不混入 `_Control_Center/tools/`。
190
+ - 作用域:任何角色的会话都可读;新增与改写由全局会话执行,项目与杂项只读。
191
+ - 写入范围:用户提到「把某某写成规范」时,未指明范围的写本库(本会话所在的工作区);说「写入通用规范库」「写成通用规范」「写成共用规范」的才写本目录,项目与杂项不得写本目录、报全局。
192
+ - 引用:用户提到「按某某规范进行」一类要求时,先看本库有无同名规范,再看本目录;两处都有时按本库那份执行,本目录那份作为通用版本。三档角色的常驻段各有一行指针,细节在本节。
193
+ - 与相邻位置的分工:`_Control_Center/rules/` 是工作区自身的运行规则,`_Control_Center/user/` 是用户内容与写作规范,本目录是可复用的做法,按名字被引用。
194
+ - 补齐:插件新增目录后,已部署的工作区执行 `ho_workspace(action="scaffold")`;该动作只建缺失项,已有文件不覆盖。
195
+ - 正文只写做法、判据与验收条件,不记某次作业的进度;进度归交接与台账。
@@ -27,7 +27,7 @@
27
27
  3. **环境与路径**:工具版本、安装位置、用的源。
28
28
  4. **已完成待验收** 与 **要求了没落地** 分开写,不要混。
29
29
  5. **坑点与禁区**:踩过的错、不能再动的文件。
30
- 6. **会话 id 对照**:在任会话、已归档的前任。
30
+ 6. **会话 id 对照**:在任会话、已归档的旧会话。
31
31
 
32
32
  ## 4. 硬规矩
33
33
 
@@ -36,6 +36,7 @@
36
36
  - 不写「大概」「应该」这类没核过的话;没核就写「未核,待确认」。
37
37
  - 写交接用 `ho_handover(action="write")`、`action="append"`,不要手改文件(会绕过备份与体检)。
38
38
  - 归档命名:`_Archives/<范围>/Handover/<原文件名>_<YYYYMMDD_HHMMSS>.md`(例 `Handover_20261108_144307.md`);杂项归档自包含在 `_Misc/tasks/<任务>/_archive/`。
39
+ - **篇幅不设上限**:不规定字节、行数、页数。体积由结构控制——只写在途与指针、状态用表格承载;过期即删,过一段时间整篇按归档重写。不得给交接或同类文档设上限。
39
40
 
40
41
  ## 5. 体检判据(插件自动跑)
41
42
 
@@ -1,6 +1,6 @@
1
1
  # 迁移规范(插件自带默认口径)
2
2
 
3
- > 迁移靠 `ho_pack` 四件套:`check` → `pack` → `verify` → `restore`。
3
+ > 迁移靠 `ho_backup` 四件套:`check` → `pack` → `verify` → `restore`。
4
4
 
5
5
  ## 1. 前置检查 `action="check"`
6
6
 
@@ -0,0 +1,25 @@
1
+ # 通用规范
2
+
3
+ 本目录存放跨会话共用的文档规范。任何角色的会话都可读取;新增与改写只由全局会话执行。
4
+
5
+ ## 结构
6
+
7
+ 一条规范一个子目录,目录名即规范名,例如 `发布流程/`、`文档结构/`。
8
+
9
+ - 正文放在子目录内,入口文件用 `README.md`;篇幅长时拆成多份,并在入口列出清单。
10
+ - 规范自带的脚本与附件放在同一子目录内,不放进 `_Control_Center/tools/`:脚本与正文同生共死,一起搬走才不会失效。
11
+ - 规范只写做法、判据与验收条件,不写某一次作业的进度与结果;进度属于交接与台账。
12
+
13
+ ## 查找
14
+
15
+ 用户提到「按某某规范进行」一类要求时,两处都属查找范围:先看本库有无同名规范,再看本目录。本库指会话自己所在的工作区;两处都有时按本库那份执行,本目录那份作为通用版本。
16
+
17
+ 正文取到后按其中的步骤与判据执行,不自行增删步骤;规范本身需要改动时,先改规范再作业。
18
+
19
+ ## 写入
20
+
21
+ 用户提到「把某某写成规范」时:未指明范围就写本库(该会话所在的工作区);说「写入通用规范库」「写成通用规范」「写成共用规范」的才写入本目录。
22
+
23
+ 本目录的新增与改写由全局会话执行,项目与杂项会话只读;本库内的文档由本库会话自己写。规范里的路径与工具名要与插件当时的实际口径一致,改名或换入口时同步改本目录。
24
+
25
+ 目录内只有本说明是合法状态:尚无共用规范时,各库按自己的文档执行。
@@ -0,0 +1,135 @@
1
+ /**
2
+ * ho_backup —— 备份与迁移:一个入口,按 action 分流。
3
+ * snapshot(默认):库快照,逐字节复制 + sha256 清单 + PROVENANCE
4
+ * check:迁移包前置闸门
5
+ * pack:打包
6
+ * verify:校包
7
+ * restore:恢复(默认仅预演)
8
+ * 原 ho_snapshot、ho_pack 合并于此。
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import crypto from 'node:crypto';
13
+ import { textOutput } from '../lib/output.mjs';
14
+ import { nameOf } from '../lib/names.mjs';
15
+ import { NL, isDir } from '../lib/text.mjs';
16
+ import { assertProjectName } from '../lib/role.mjs';
17
+ import { packCheck, packNow, packVerify, packRestore } from '../lib/pack-ops.mjs';
18
+
19
+ export const ACTIONS = ['snapshot', 'check', 'pack', 'verify', 'restore'];
20
+
21
+ const SKIP_DIRS = ['__pycache__', '.git', 'node_modules', '.obsidian'];
22
+ const SKIP_FILES = ['.DS_Store', 'Thumbs.db'];
23
+
24
+ function stamp() {
25
+ const d = new Date();
26
+ const p = (n) => (n < 10 ? '0' + n : '' + n);
27
+ return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes());
28
+ }
29
+
30
+ function walk(root, rel, rows) {
31
+ for (const name of fs.readdirSync(path.join(root, rel))) {
32
+ const full = path.join(root, rel, name);
33
+ const st = fs.statSync(full);
34
+ const r = rel ? rel + '/' + name : name;
35
+ if (st.isDirectory()) {
36
+ if (SKIP_DIRS.indexOf(name) !== -1) continue;
37
+ walk(root, r, rows);
38
+ } else if (st.isFile()) {
39
+ if (SKIP_FILES.indexOf(name) !== -1) continue;
40
+ if (name.endsWith('.pyc')) continue;
41
+ rows.push({ rel: r, size: st.size });
42
+ }
43
+ }
44
+ return rows;
45
+ }
46
+
47
+ function snapshotText(env, args, exec) {
48
+ const ws = env.resolveWs(exec);
49
+ const project = assertProjectName(typeof args.project === 'string' ? args.project.trim() : '');
50
+ if (!project) throw new Error('action="snapshot" 需要 project(写项目名,如 Demo)');
51
+ const projDir = path.join(ws, 'Projects', project);
52
+ if (!isDir(projDir)) throw new Error('找不到项目:' + projDir);
53
+ let lib = null;
54
+ for (const name of fs.readdirSync(projDir)) {
55
+ const p = path.join(projDir, name);
56
+ if (isDir(p) && fs.existsSync(path.join(p, 'RULES.md'))) { lib = p; break; }
57
+ }
58
+ if (!lib) throw new Error('该项目下没有带 RULES.md 的库根:' + projDir);
59
+ const rows = walk(lib, '', []);
60
+ const total = rows.reduce((a, b) => a + b.size, 0);
61
+ const out = [];
62
+ out.push('源库 ' + lib);
63
+ out.push('文件 ' + rows.length + ' 个,共 ' + (total / 1048576).toFixed(2) + ' MB');
64
+ if (args.dry_run) { out.push('dry_run:不落盘。'); return out.join(NL); }
65
+ const tag = args.tag || '行为检查';
66
+ const dest = path.join(ws, '_Temp', 'Checks', project + '_' + tag + '_' + stamp());
67
+ if (fs.existsSync(dest)) throw new Error('目标已存在,不覆盖:' + dest);
68
+ const libDest = path.join(dest, 'lib');
69
+ fs.mkdirSync(libDest, { recursive: true });
70
+ const manifest = ['# 源库: ' + lib, '# 文件: ' + rows.length + ' 字节: ' + total];
71
+ let drift = 0;
72
+ for (const r of rows) {
73
+ const src = path.join(lib, r.rel);
74
+ const dst = path.join(libDest, r.rel);
75
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
76
+ fs.copyFileSync(src, dst);
77
+ const h1 = crypto.createHash('sha256').update(fs.readFileSync(src)).digest('hex');
78
+ const h2 = crypto.createHash('sha256').update(fs.readFileSync(dst)).digest('hex');
79
+ if (h1 !== h2) drift++;
80
+ manifest.push(r.rel + String.fromCharCode(9) + r.size + String.fromCharCode(9) + h1);
81
+ }
82
+ fs.writeFileSync(path.join(dest, 'MANIFEST.tsv'), manifest.join(NL), 'utf8');
83
+ const prov = [
84
+ '# PROVENANCE',
85
+ '- 源库: ' + lib,
86
+ '- 生成时间: ' + new Date().toISOString(),
87
+ '- 发起会话: ' + (env.selfId(exec) || '?'),
88
+ '- 文件数/字节: ' + rows.length + ' / ' + total,
89
+ '- 排除: __pycache__ .git node_modules .obsidian *.pyc .DS_Store Thumbs.db',
90
+ '- 校验: 逐文件 sha256 比对,drift=' + drift,
91
+ '- 含个人数据,不得外发、不得进 _Archives',
92
+ ].join(NL);
93
+ fs.writeFileSync(path.join(dest, 'PROVENANCE.md'), prov, 'utf8');
94
+ out.push('已落盘 ' + dest);
95
+ out.push('校验 drift=' + drift + (drift === 0 ? '(逐文件一致)' : '(**不一致,判失败**)'));
96
+ return out.join(NL);
97
+ }
98
+
99
+ export function pmBackupTool(env) {
100
+ return {
101
+ name: nameOf('backup'),
102
+ description:
103
+ '备份与迁移:action=snapshot 库快照(默认)、check 前置闸门、pack 打包、verify 校包、restore 恢复(默认预演)。',
104
+ parameters: {
105
+ type: 'object',
106
+ additionalProperties: false,
107
+ properties: {
108
+ action: { type: 'string', enum: ACTIONS, description: '缺省 snapshot' },
109
+ project: { type: 'string', description: 'snapshot:项目名(Projects/<项目>/ 下)' },
110
+ tag: { type: 'string', description: 'snapshot:标签,默认 行为检查' },
111
+ kind: { type: 'string', enum: ['project', 'task', 'global'], description: 'pack:目标类型' },
112
+ name: { type: 'string', description: 'pack:项目名或任务名' },
113
+ out: { type: 'string', description: 'pack:输出目录' },
114
+ note: { type: 'string', description: 'pack:写进 MIGRATION.md 的说明' },
115
+ items: { type: 'array', items: { type: 'string' }, description: 'check:检查的项目名(global 表示全局)' },
116
+ min_age_min: { type: 'number', description: 'check:要求静止分钟(默认 90)' },
117
+ pack: { type: 'string', description: 'verify、restore:包路径' },
118
+ target: { type: 'string', description: 'restore:目标根(默认当前)' },
119
+ apply: { type: 'boolean', description: 'restore:true 才写入' },
120
+ dry_run: { type: 'boolean', description: 'snapshot:只统计不落盘;pack、restore:只列清单' },
121
+ format: { type: 'string', enum: ['auto', '7z', 'tar'], description: 'pack:auto=有 7-Zip 用 7z,否则内置 tar.gz(默认)' },
122
+ },
123
+ },
124
+ output: textOutput(),
125
+ async execute(args, exec) {
126
+ const action = typeof args.action === 'string' && args.action ? args.action : 'snapshot';
127
+ if (ACTIONS.indexOf(action) === -1) throw new Error('action 只能是 ' + ACTIONS.join(' | ') + '(收到 ' + String(action) + ')');
128
+ if (action === 'snapshot') return { text: snapshotText(env, args, exec) };
129
+ if (action === 'check') return { text: packCheck(env, args, exec) };
130
+ if (action === 'verify') return { text: packVerify(env, args, exec) };
131
+ if (action === 'restore') return { text: packRestore(env, args, exec) };
132
+ return { text: packNow(env, args, exec) };
133
+ },
134
+ };
135
+ }