@mingmaple/dsh-handover 0.9.14 → 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 (64) hide show
  1. package/README.md +46 -19
  2. package/docs/migrate.md +6 -6
  3. package/docs/releases/0.9.22.md +34 -0
  4. package/docs/troubleshoot.md +34 -10
  5. package/package.json +71 -71
  6. package/scripts/blank-dsh.mjs +29 -23
  7. package/scripts/blank-smoke.mjs +205 -87
  8. package/scripts/e2e-migration.mjs +2 -2
  9. package/scripts/office.mjs +3 -3
  10. package/src/client.js +159 -120
  11. package/src/index.mjs +65 -58
  12. package/src/lib/api.mjs +9 -7
  13. package/src/lib/brief.mjs +2 -2
  14. package/src/lib/config.mjs +3 -3
  15. package/src/lib/consent.mjs +54 -1
  16. package/src/lib/docmap.mjs +143 -0
  17. package/src/lib/gates.mjs +25 -1
  18. package/src/lib/guide.mjs +35 -44
  19. package/src/lib/lint.mjs +3 -2
  20. package/src/lib/manual.mjs +48 -9
  21. package/src/lib/mover.mjs +10 -10
  22. package/src/lib/names.mjs +14 -19
  23. package/src/lib/office.mjs +22 -14
  24. package/src/lib/pack-ops.mjs +2 -2
  25. package/src/lib/peer.mjs +4 -4
  26. package/src/lib/role.mjs +8 -5
  27. package/src/lib/scaffold.mjs +42 -20
  28. package/src/lib/schedule.mjs +1 -2
  29. package/src/lib/session-create.mjs +1 -1
  30. package/src/lib/session-ops.mjs +16 -16
  31. package/src/lib/sessions.mjs +1 -1
  32. package/src/lib/status.mjs +205 -45
  33. package/src/lib/succession.mjs +7 -7
  34. package/src/lib/zstd-log.mjs +1 -1
  35. package/src/selftest.mjs +6 -6
  36. package/src/templates/Prompt_Templates.md +13 -13
  37. package/src/templates/Runtime_Manual.md +56 -30
  38. package/src/templates/rules/Handover_Rules.md +2 -1
  39. package/src/templates/rules/Migration_Rules.md +1 -1
  40. package/src/templates/standards/README.md +25 -0
  41. package/src/tools/ho_backup.mjs +135 -0
  42. package/src/tools/ho_checkpoint.mjs +12 -11
  43. package/src/tools/ho_docmap.mjs +153 -0
  44. package/src/tools/ho_gate.mjs +2 -1
  45. package/src/tools/ho_office.mjs +1 -1
  46. package/src/tools/ho_sessions.mjs +3 -4
  47. package/src/tools/ho_status.mjs +246 -0
  48. package/src/tools/ho_succeed.mjs +1 -1
  49. package/src/tools/ho_workspace.mjs +86 -14
  50. package/src/tools/peer.mjs +205 -0
  51. package/src/tools/ho_brief.mjs +0 -57
  52. package/src/tools/ho_diag.mjs +0 -87
  53. package/src/tools/ho_drift.mjs +0 -41
  54. package/src/tools/ho_init.mjs +0 -82
  55. package/src/tools/ho_pack.mjs +0 -38
  56. package/src/tools/ho_phase.mjs +0 -37
  57. package/src/tools/ho_snapshot.mjs +0 -104
  58. package/src/tools/ho_tokens.mjs +0 -57
  59. package/src/tools/peer_list.mjs +0 -37
  60. package/src/tools/peer_new.mjs +0 -75
  61. package/src/tools/peer_peek.mjs +0 -41
  62. package/src/tools/peer_rename.mjs +0 -31
  63. package/src/tools/peer_send.mjs +0 -52
  64. package/src/tools/peer_status.mjs +0 -46
@@ -0,0 +1,205 @@
1
+ /**
2
+ * peer —— 平级会话:一个入口,按 action 分流。
3
+ * list 会话一览(只读缓存,不激活任何会话)
4
+ * status 单会话详情
5
+ * send 投一条消息(唯一会消耗对方 token 的动作)
6
+ * peek 读取目标会话近期会话历史(内容进本会话上下文)
7
+ * rename 只改显示标题
8
+ * new 新建平级会话
9
+ * 原 peer_list、peer_status、peer_send、peer_peek、peer_rename、peer_new 合并于此。
10
+ * 动作级权限在这里再判一次:角色矩阵只决定「这个工具可不可见」,不细分动作。
11
+ */
12
+ import { randomUUID } from 'node:crypto';
13
+ import { textOutput } from '../lib/output.mjs';
14
+ import { PEER_NAME } from '../lib/names.mjs';
15
+ import { NL } from '../lib/text.mjs';
16
+ import {
17
+ readProjCache, rowLine, sessionRows, stamp, pressurePct, isSubagent, isBusy,
18
+ projectName, pick, peerMarker, resolveWorkspaceId, findSessionLog, decodeSessionLog, conversation,
19
+ } from '../lib/peer.mjs';
20
+
21
+ export const ACTIONS = ['list', 'status', 'send', 'peek', 'rename', 'new'];
22
+ const GLOBAL_ONLY = ['list', 'peek', 'rename'];
23
+
24
+ function roleOf(env, exec) {
25
+ try { return env.roleOf(exec) || { role: 'plain' }; } catch { return { role: 'plain' }; }
26
+ }
27
+
28
+ function denyUnless(ok, action, why) {
29
+ if (!ok) throw new Error('peer(action="' + action + '"):' + why);
30
+ }
31
+
32
+ async function listText(env, args) {
33
+ const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(200, Math.trunc(args.limit))) : 40;
34
+ const controller = env.controller();
35
+ if (!controller || typeof controller.list !== 'function') {
36
+ throw new Error('peer(action="list"):sessionController 不可用(插件未注入该服务)');
37
+ }
38
+ const rows = await sessionRows(controller, args.include_subagents === true);
39
+ if (rows.length === 0) return 'no sessions';
40
+ return rows.slice(0, limit).map((row) => {
41
+ const cache = readProjCache(row.sessionId);
42
+ const state = row.running ? 'running' : row.agentAvailable ? 'idle' : 'cold';
43
+ return rowLine(row.sessionId, cache, state) + ' | ' + stamp(row.updatedAt);
44
+ }).join(NL);
45
+ }
46
+
47
+ async function statusText(env, args) {
48
+ const controller = env.controller();
49
+ if (!controller || typeof controller.list !== 'function') {
50
+ throw new Error('peer(action="status"):sessionController 不可用(插件未注入该服务)');
51
+ }
52
+ const sid = String(args.session_id || '').trim();
53
+ if (!sid) throw new Error('peer(action="status"):session_id 不能为空');
54
+ const cache = readProjCache(sid);
55
+ const rows = await sessionRows(controller, true);
56
+ const row = rows.find((r) => r.sessionId === sid) ?? null;
57
+ if (cache === null && row === null) return 'unknown session: ' + sid;
58
+ const stats = pick(cache, 'record', 'rows', 'sessionStats', 'val') ?? {};
59
+ const pct = cache === null ? null : pressurePct(cache);
60
+ return [
61
+ 'session ' + sid,
62
+ 'kind ' + (cache === null ? '?' : isSubagent(cache) ? '子会话(subagent)' : '平级会话(peer)'),
63
+ 'project ' + (cache === null ? '-' : projectName(cache)),
64
+ 'pressure ' + (pct === null ? '?' : pct.toFixed(1) + '%') + (pct !== null && pct >= 32 ? ' 已进入预警区' : ''),
65
+ 'state ' + (isBusy(cache) === true ? 'running' : row === null ? 'cold' : row.agentAvailable ? 'idle' : 'cold'),
66
+ 'turns ' + String(stats.turns ?? '?') + ' steps ' + String(stats.steps ?? '?'),
67
+ 'cwd ' + String(row?.cwd ?? pick(cache, 'record', 'identity', 'cwd') ?? '-'),
68
+ 'preset ' + String(pick(cache, 'record', 'rows', 'agentPreset', 'val') ?? '-'),
69
+ 'updated ' + stamp(row?.updatedAt ?? pick(cache, 'record', 'identity', 'createdAt')),
70
+ ].join(NL);
71
+ }
72
+
73
+ async function sendText(env, args, exec, info) {
74
+ const controller = env.controller();
75
+ if (!controller || typeof controller.prompt !== 'function') {
76
+ throw new Error('peer(action="send"):sessionController.prompt 不可用(插件未注入该服务)');
77
+ }
78
+ const target = String(args.session_id || '').trim();
79
+ if (!target) throw new Error('peer(action="send"):session_id 不能为空');
80
+ const message = typeof args.message === 'string' ? args.message : '';
81
+ if (message.trim() === '') throw new Error('peer(action="send"):message 不能为空');
82
+ const mode = args.mode === 'steer' ? 'steer' : 'queue';
83
+ // 来源标记:dsh 把宿主投递的消息记成 source.kind === "user",不加标记的话,
84
+ // 平级会话的消息在日志和界面上与用户消息完全无法区分。
85
+ const sender = exec?.agent?.session?.header?.id ?? 'unknown';
86
+ const marker = peerMarker(sender);
87
+ const body = message.startsWith(marker) ? message : marker + NL + message;
88
+ const receipt = await controller.prompt(
89
+ { requestId: randomUUID(), sessionId: target, mode, content: [{ type: 'text', text: body }] },
90
+ new AbortController().signal,
91
+ );
92
+ return 'accepted=' + String(receipt.accepted) + ' mode=' + mode + ' target=' + target + ' from=' + sender;
93
+ }
94
+
95
+ async function peekText(args) {
96
+ const sid = String(args.session_id || '').trim();
97
+ if (!sid) throw new Error('peer(action="peek"):session_id 不能为空');
98
+ const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(40, Math.trunc(args.limit))) : 8;
99
+ const file = findSessionLog(sid);
100
+ if (file === null) return 'no session log found for ' + sid;
101
+ const events = decodeSessionLog(file);
102
+ if (events === null) return 'this Node build has no zstd support; cannot read the session log';
103
+ const items = conversation(events, limit);
104
+ const head = 'session ' + sid + ' | 最近 ' + String(items.length) + ' 条 | ' + file;
105
+ if (items.length === 0) return head + NL + '(没有可读的会话历史)';
106
+ const body = items.map((it) => {
107
+ const one = it.text.replace(/\s+/g, ' ').trim();
108
+ const cut = one.length > 400 ? one.slice(0, 400) + ' …' : one;
109
+ return '[' + stamp(it.t) + ' ' + it.who + '] ' + cut;
110
+ }).join(NL);
111
+ return head + NL + body;
112
+ }
113
+
114
+ async function renameText(env, args) {
115
+ const controller = env.controller();
116
+ if (!controller || typeof controller.rename !== 'function') {
117
+ throw new Error('peer(action="rename"):sessionController.rename 不可用(插件未注入该服务)');
118
+ }
119
+ const sid = String(args.session_id || '').trim();
120
+ if (!sid) throw new Error('peer(action="rename"):session_id 不能为空');
121
+ const title = String(args.title ?? '').trim();
122
+ if (title === '') throw new Error('peer(action="rename"):title 不能为空');
123
+ await controller.rename({ sessionId: sid, title });
124
+ return 'renamed ' + sid + ' -> ' + title;
125
+ }
126
+
127
+ async function newText(env, args, info) {
128
+ const controller = env.controller();
129
+ if (!controller || typeof controller.create !== 'function') {
130
+ throw new Error('peer(action="new"):sessionController.create 不可用(插件未注入该服务)');
131
+ }
132
+ // 会话创建仅属全局;杂项可以把会话建在 Misc 下(落到本任务体系内)。
133
+ const wantsWorkspace = typeof args.workspace === 'string' && args.workspace.trim() !== '';
134
+ if (wantsWorkspace && args.cwd !== undefined) {
135
+ throw new Error('peer(action="new"):传 workspace 时不要再传 cwd —— session.create 只接受其中之一(cwd 会自动取该工作区的 path)');
136
+ }
137
+ const targetText = String(args.workspace || args.cwd || '').toLowerCase();
138
+ const allowed = info.role === 'global' || (info.role === 'misc' && targetText.indexOf('misc') !== -1);
139
+ denyUnless(allowed, 'new', '会话创建仅属全局(杂项可建到 Misc 下)');
140
+ let request = {};
141
+ if (wantsWorkspace) {
142
+ const workspaceId = resolveWorkspaceId(args.workspace);
143
+ if (workspaceId === null || workspaceId === undefined) {
144
+ throw new Error('peer(action="new"):找不到工作区「' + args.workspace + '」(可用 id / 标题 / 路径;数据源 storages/workspace.json)');
145
+ }
146
+ request = { workspaceId };
147
+ } else if (args.cwd !== undefined) {
148
+ request = { cwd: args.cwd };
149
+ }
150
+ const created = await controller.create(request);
151
+ const id = created.sessionId;
152
+ if (typeof args.title === 'string' && args.title.trim() !== '') {
153
+ try {
154
+ await controller.rename({ sessionId: id, title: args.title.trim() });
155
+ } catch (error) {
156
+ if (typeof env.log === 'function') env.log('peer(action="new"):改标题失败:' + String(error));
157
+ }
158
+ }
159
+ let tail = '';
160
+ if (typeof args.message === 'string' && args.message.trim() !== '') {
161
+ await controller.prompt(
162
+ { requestId: randomUUID(), sessionId: id, mode: 'queue', content: [{ type: 'text', text: args.message }] },
163
+ new AbortController().signal,
164
+ );
165
+ tail = ' (opening message delivered)';
166
+ }
167
+ return 'created ' + id + tail;
168
+ }
169
+
170
+ export function pmPeerTool(env) {
171
+ return {
172
+ name: PEER_NAME,
173
+ description:
174
+ '平级会话:action=list 一览、status 详情、send 投消息(耗对方 token)、peek 读近期会话历史(耗本会话 token)、rename 改标题、new 新建。list、peek、rename 与 new 仅全局会话可用。',
175
+ parameters: {
176
+ type: 'object',
177
+ additionalProperties: false,
178
+ required: ['action'],
179
+ properties: {
180
+ action: { type: 'string', enum: ACTIONS, description: '要做的动作' },
181
+ session_id: { type: 'string', description: 'status、send、peek、rename:目标会话 id' },
182
+ message: { type: 'string', description: 'send:要投递的正文' },
183
+ mode: { type: 'string', enum: ['queue', 'steer'], description: 'send:queue(默认)或 steer' },
184
+ limit: { type: 'number', description: 'list:最多多少行(默认 40);peek:最近多少条(默认 8,上限 40)' },
185
+ include_subagents: { type: 'boolean', description: 'list:同时列出子会话' },
186
+ title: { type: 'string', description: 'new、rename:显示标题' },
187
+ workspace: { type: 'string', description: 'new:工作区 id、标题或路径(与 cwd 互斥)' },
188
+ cwd: { type: 'string', description: 'new:新会话的工作目录' },
189
+ },
190
+ },
191
+ output: textOutput(),
192
+ async execute(args, exec) {
193
+ const action = String(args.action || '');
194
+ if (ACTIONS.indexOf(action) === -1) throw new Error('peer:action 只能是 ' + ACTIONS.join(' | ') + '(收到 ' + JSON.stringify(action) + ')');
195
+ const info = roleOf(env, exec);
196
+ if (GLOBAL_ONLY.indexOf(action) !== -1) denyUnless(info.role === 'global', action, '该动作仅全局会话可用');
197
+ if (action === 'list') return { text: await listText(env, args) };
198
+ if (action === 'status') return { text: await statusText(env, args) };
199
+ if (action === 'send') return { text: await sendText(env, args, exec, info) };
200
+ if (action === 'peek') return { text: await peekText(args) };
201
+ if (action === 'rename') return { text: await renameText(env, args) };
202
+ return { text: await newText(env, args, info) };
203
+ },
204
+ };
205
+ }
@@ -1,57 +0,0 @@
1
- /** ho_brief —— 一条命令汇总:水位 + 闸门 + 会话清点 + 阶段建议。 */
2
- import { textOutput } from '../lib/output.mjs';
3
- import { nameOf } from '../lib/names.mjs';
4
- import { NL } from '../lib/text.mjs';
5
- import { syncCheck } from '../lib/gates.mjs';
6
- import { scanSessions, readSelfPressure } from '../lib/sessions.mjs';
7
- import { composeBrief } from '../lib/brief.mjs';
8
- import { decide } from '../lib/phase.mjs';
9
- import { computeDrift } from '../lib/drift.mjs';
10
- import { watermark } from '../lib/facts.mjs';
11
-
12
- export function pmBriefTool(env) {
13
- const w = watermark(env.cfg);
14
- return {
15
- name: nameOf('brief'),
16
- description:
17
- '起始与收束的单条命令:水位、闸门、会话清点、阶段建议。只读。',
18
- parameters: {
19
- type: 'object',
20
- additionalProperties: false,
21
- properties: {
22
- threshold: { type: 'number', description: '预警阈值百分比(默认 ' + w.warnValue + ')' },
23
- },
24
- },
25
- output: textOutput(),
26
- async execute(args, exec) {
27
- const ws = env.resolveWs(exec);
28
- const warn = typeof args.threshold === "number" ? args.threshold : w.warnValue;
29
- const gate = syncCheck(ws);
30
- const meId = env.selfId(exec);
31
- const self = readSelfPressure(env.cacheDir(), meId);
32
- const scan = scanSessions({
33
- dir: env.cacheDir(),
34
- meId,
35
- meCwd: env.selfCwd(exec),
36
- warn,
37
- silentHours: env.cfg.sessionScanHours,
38
- });
39
- const drift = computeDrift({ ws, cacheDir: env.cacheDir(), slack: env.cfg.driftSlack, all: false });
40
- const ratio = self.used === null || self.win === null ? null : self.used / self.win;
41
- const adv = decide({ ratio, compactionsUsed: 0, cfg: env.cfg, lockState: "unknown" });
42
- const selfPct = self.used === null || self.win === null ? null : (self.used / self.win) * 100;
43
- const ok = gate.ok && (selfPct === null || selfPct < warn);
44
- const lines = composeBrief({
45
- cfg: env.cfg,
46
- selfUsed: self.used,
47
- selfWin: self.win,
48
- gate,
49
- scan,
50
- drift,
51
- advice: { text: adv.action + " —— " + adv.reason },
52
- ok,
53
- });
54
- return { text: lines.join(NL) };
55
- },
56
- };
57
- }
@@ -1,87 +0,0 @@
1
- /** ho_diag —— 诊断:服务是否可解析、调度器是否就绪、配置是否如预期。不写任何东西。 */
2
- import fs from 'node:fs';
3
- import { nameOf } from '../lib/names.mjs';
4
- import path from 'node:path';
5
- import { textOutput } from '../lib/output.mjs';
6
- import { NL } from '../lib/text.mjs';
7
- import { eventsOf, countCompactions } from '../lib/events.mjs';
8
-
9
- export function pmDiagTool(env, state) {
10
- return {
11
- name: nameOf('diag'),
12
- description:
13
- '插件自检:服务、调度器、压缩计数、工作区是否接入。只读。',
14
- parameters: { type: "object", additionalProperties: false, properties: {} },
15
- output: textOutput(),
16
- async execute(args, exec) {
17
- const out = [];
18
- const meId = env.selfId(exec);
19
- out.push("会话 " + (meId || "?"));
20
- if (typeof env.version === "function") out.push("版本 " + env.version());
21
- out.push("调度器 " + (state.schedulerReady() ? "就绪" : "未就绪" + (state.schedulerError() ? ":" + state.schedulerError() : "(稍后重试或重启)")));
22
- if (typeof env.injectList === 'function') out.push('inject ' + env.injectList().join(' / '));
23
- if (typeof env.initErrors === 'function') {
24
- const errs = env.initErrors();
25
- out.push('初始化 ' + (errs.length ? '有 ' + errs.length + ' 处失败:' + errs.join(';') : '无错误'));
26
- }
27
- if (typeof env.probeServices === "function") {
28
- const svc = env.probeServices();
29
- out.push("服务 " + Object.keys(svc).map((k) => k + "=" + (svc[k] ? "可用" : "—")).join(" "));
30
- }
31
- if (typeof env.guardLast === "function") {
32
- const g = env.guardLast();
33
- out.push("唤醒判定 " + (g
34
- ? (g.allow ? "放行" : "拦截") + " · 依据 " + g.source + (g.title ? " · 标题 " + g.title : "") + " · 会话 " + g.sid
35
- : "本进程还没有 peer_send、peer_new 调用"));
36
- }
37
- if (typeof env.probeAgentScope === "function") {
38
- const a = env.probeAgentScope(exec);
39
- const show = ["compaction", "compactNow", "tokenMeter", "sessions", "llm", "agents"]
40
- .map((k) => k + "=" + (a[k] ? "可用" : "—")).join(" ");
41
- out.push("会话作用域 " + (a.ctx ? show : "无法获取 exec.agent.ctx"));
42
- }
43
- const st = typeof env.compactionState === "function"
44
- ? env.compactionState(exec)
45
- : (() => { const c = countCompactions(eventsOf(exec)); return { used: c.used, lock: c.lock, source: 'events' }; })();
46
- out.push("压缩 " + st.used + "/" + env.cfg.maxCompactions + " 次;锁 " + st.lock + ";来源 " + st.source);
47
- const pct = env.selfPct(meId);
48
- out.push("水位 " + (pct === null ? "读不到" : pct.toFixed(1) + "%") + (typeof env.staleNote === "function" ? env.staleNote(exec) : ""));
49
- const ws = env.resolveWs(exec);
50
- let wsOk = false;
51
- try { wsOk = fs.existsSync(path.join(ws, '_Control_Center')); } catch { wsOk = false; }
52
- if (typeof env.roleOf === "function") {
53
- try {
54
- const info = env.roleOf(exec);
55
- const ts = typeof env.toolsetOf === 'function' ? env.toolsetOf(info.role) : null;
56
- out.push("角色 " + info.role + (info.project ? "(项目 " + info.project + ")" : info.task ? "(任务 " + info.task + ")" : "") + " · 依据 " + info.source + (ts ? " · 可见工具 " + ts.allow.length + "/" + (ts.allow.length + ts.deny.length) : ""));
57
- if (typeof env.roleState === "function") {
58
- const rs = env.roleState();
59
- const cnt = typeof rs.count === "number" ? " · 已装配 " + rs.count + " 个会话" : "";
60
- out.push("角色装配 " + (rs.mode || "未知") + cnt + (rs.last ? " · 最近:" + rs.last.sid + " → " + rs.last.role + "(" + rs.last.at + ")" : ""));
61
- }
62
- } catch (e) {
63
- out.push("角色 判定失败:" + (e && e.message ? e.message : e));
64
- }
65
- }
66
- out.push("工作区 " + ws + (wsOk
67
- ? " 已接入"
68
- : " — 未接入(未找到 _Control_Center):让用户走设置页「首次安装」,或本会话直接 ho_init(parent=\"<目录>\", register=true)"));
69
- try {
70
- const mv = typeof env.moveInfo === "function" ? env.moveInfo() : null;
71
- if (mv) out.push("迁移 " + mv.phase + " · " + mv.src + (mv.dst ? " → " + mv.dst : ""));
72
- } catch { /* ignore */ }
73
- out.push("配置 warn=" + env.cfg.warnRatio + " handover=" + env.cfg.handoverRatio + " hard=" + env.cfg.hardRatio + " max=" + env.cfg.maxCompactions);
74
- if (typeof env.handoverFor === "function") {
75
- try {
76
- const hp = env.handoverFor(exec);
77
- out.push("交接路径 " + (hp && hp.path ? hp.path + "(" + hp.kind + ")" : "无 —— " + ((hp && hp.reason) || "角色未定")));
78
- } catch (e) {
79
- out.push("交接路径 解析失败:" + (e && e.message ? e.message : e));
80
- }
81
- } else {
82
- out.push("交接路径 " + (env.cfg.handoverPath || "(按角色解析)"));
83
- }
84
- return { text: out.join(NL) };
85
- },
86
- };
87
- }
@@ -1,41 +0,0 @@
1
- /** ho_drift —— 交接漂移体检(替代 handover_check.mjs)。 */
2
- import { textOutput } from '../lib/output.mjs';
3
- import { nameOf } from '../lib/names.mjs';
4
- import { NL } from '../lib/text.mjs';
5
- import { computeDrift, driftVerdict } from '../lib/drift.mjs';
6
-
7
- export function pmDriftTool(env) {
8
- return {
9
- name: nameOf('drift'),
10
- description:
11
- '交接漂移体检:Δ=当前轮−交接轮,超过容忍轮数(默认 3)需刷新交接。只读。',
12
- parameters: {
13
- type: 'object',
14
- additionalProperties: false,
15
- properties: {
16
- slack: { type: 'number', description: '容忍轮数(默认 3)' },
17
- all: { type: 'boolean', description: '列全部会话,而不只是每个项目的在任会话' },
18
- },
19
- },
20
- output: textOutput(),
21
- async execute(args, exec) {
22
- const ws = env.resolveWs(exec);
23
- const res = computeDrift({
24
- ws,
25
- cacheDir: env.cacheDir(),
26
- slack: typeof args.slack === 'number' ? args.slack : env.cfg.driftSlack,
27
- all: args.all === true,
28
- });
29
- const out = [];
30
- out.push('交接匹配检查(跟着交接走;冗余 ' + res.slack + ' 轮) 工作区根 ' + ws);
31
- out.push(' 项目 会话类型 会话 当前轮 交接轮 Δ 判定');
32
- for (const r of res.rows) {
33
- const cells = [r.project, r.kind, r.sid.slice(-8), String(r.turns), r.handoverTurn === null ? '?' : String(r.handoverTurn), r.delta === null ? '?' : String(r.delta), r.verdict];
34
- out.push(' ' + cells.join(' '));
35
- }
36
- if (!res.rows.length) out.push(' 读不到任何项目会话(Projects/ 下对应库不存在时属正常)');
37
- out.push(driftVerdict(res));
38
- return { text: out.join(NL) };
39
- },
40
- };
41
- }
@@ -1,82 +0,0 @@
1
- /**
2
- * ho_init —— 首次安装:在用户给的目录里建「全局文件体系」,可选顺带注册为工作区。
3
- * 与设置页的「首次安装向导」同一套实现(lib/scaffold.mjs),因此两条路径行为一致。
4
- */
5
- import fs from 'node:fs';
6
- import { nameOf } from '../lib/names.mjs';
7
- import path from 'node:path';
8
- import { textOutput } from '../lib/output.mjs';
9
- import { NL } from '../lib/text.mjs';
10
- import { createGlobalWorkspace } from '../lib/scaffold.mjs';
11
-
12
- export function pmInitTool(env) {
13
- return {
14
- name: nameOf('init'),
15
- description:
16
- '首次安装:在指定目录(不存在会创建)下建全局文件体系(根为 <目录>/_Workspace),可顺带注册工作区。',
17
- parameters: {
18
- type: 'object',
19
- additionalProperties: false,
20
- required: ['parent'],
21
- properties: {
22
- parent: { type: 'string', description: '目标父目录绝对路径(不存在会创建);全局根建在它里面' },
23
- name: { type: 'string', description: '全局根目录名,默认 _Workspace' },
24
- register: { type: 'boolean', description: '建好后顺带注册为工作区(默认 false)' },
25
- title: { type: 'string', description: 'register 时的工作区标题,默认「全局」' },
26
- },
27
- },
28
- output: textOutput(),
29
- async execute(args) {
30
- const r = createGlobalWorkspace(args.parent, { name: args.name });
31
- const out = [];
32
- if (!r.ok) {
33
- out.push('未建立:' + r.error);
34
- if (r.root) out.push('目标:' + r.root);
35
- out.push('');
36
- out.push('用法:ho_init(parent="<已存在的目录绝对路径>")。插件只新建、不覆盖。');
37
- return { text: out.join(NL) };
38
- }
39
- out.push('已建立全局文件体系:' + r.root);
40
- out.push(' 目录 ' + r.created.length + ' 个:' + r.created.slice(0, 5).join('、') + (r.created.length > 5 ? ' 等' : ''));
41
- out.push(' 文件 ' + r.written.length + ' 个:' + (r.written.length ? r.written.join('、') : '(都已存在,未覆盖)'));
42
- if (r.existed) out.push(' 目标目录原为空目录,直接使用。');
43
- if (r.createdParent) out.push(' 父目录原本不存在,已创建。');
44
- if (r.skipped.length) out.push(' 跳过(已存在、未覆盖):' + r.skipped.join('、'));
45
-
46
- let registered = null;
47
- if (args.register === true) {
48
- const reg = env.registry();
49
- if (!reg || typeof reg.create !== 'function') {
50
- out.push('');
51
- out.push('注册未完成:workspaceRegistry 不可用。请把下面这个路径粘进 DSH 的新建工作区窗口:');
52
- out.push(' ' + r.root);
53
- return { text: out.join(NL) };
54
- }
55
- const title = String(args.title || '').trim() || '全局';
56
- try {
57
- const w = await reg.create(r.root, title);
58
- registered = w;
59
- out.push('');
60
- out.push('已注册工作区:' + title + '(' + String(w && w.id ? w.id : '?') + ')');
61
- } catch (e) {
62
- out.push('');
63
- out.push('注册失败:' + String(e && e.message ? e.message : e));
64
- out.push('可把下面这个路径粘进 DSH 的新建工作区窗口:');
65
- out.push(' ' + r.root);
66
- return { text: out.join(NL) };
67
- }
68
- }
69
-
70
- out.push('');
71
- out.push('下一步:');
72
- if (!registered) {
73
- out.push(' 1. 注册为工作区:ho_workspace(action="create", path="' + r.root + '", title="全局"),或把该路径粘进 DSH 的新建工作区窗口;');
74
- out.push(' 2. 新建一个标题含「全局」的会话,闸门、交接体检、会话水位即接入。');
75
- } else {
76
- out.push(' 新建一个标题含「全局」的会话,闸门、交接体检、会话水位即接入。');
77
- }
78
- out.push('(也可以在设置页的 dsh-handover 中完成「首次安装」,效果相同。)');
79
- return { text: out.join(NL) };
80
- },
81
- };
82
- }
@@ -1,38 +0,0 @@
1
- /** ho_pack —— 打包族:action=check(前置闸门)、pack(打包)、verify(校包)、restore(默认预演)。 */
2
- import { textOutput } from '../lib/output.mjs';
3
- import { nameOf } from '../lib/names.mjs';
4
- import { packCheck, packNow, packVerify, packRestore } from '../lib/pack-ops.mjs';
5
-
6
- export function pmPackTool(env) {
7
- return {
8
- name: nameOf('pack'),
9
- description: '迁移包四合一:check 前置闸门、pack 打包、verify 校包、restore 恢复(默认预演)。',
10
- parameters: {
11
- type: 'object',
12
- additionalProperties: false,
13
- properties: {
14
- action: { type: 'string', enum: ['check', 'pack', 'verify', 'restore'], description: '缺省 pack' },
15
- kind: { type: 'string', enum: ['project', 'task', 'global'], description: 'pack 的目标类型' },
16
- name: { type: 'string', description: 'pack 的项目名、任务名' },
17
- out: { type: 'string', description: 'pack 的输出目录' },
18
- note: { type: 'string', description: 'pack 写进 MIGRATION.md 的说明' },
19
- items: { type: 'array', items: { type: 'string' }, description: 'check 检查的项目名(global 表示全局)' },
20
- min_age_min: { type: 'number', description: 'check 要求静止分钟(默认 90)' },
21
- pack: { type: 'string', description: 'verify/restore 的包路径' },
22
- target: { type: 'string', description: 'restore 的目标根(默认当前)' },
23
- apply: { type: 'boolean', description: 'restore 时 true 才写入' },
24
- dry_run: { type: 'boolean', description: 'pack 只列清单;restore 只预演' },
25
- format: { type: 'string', enum: ['auto', '7z', 'tar'], description: 'pack 的归档格式:auto=有 7-Zip 用 7z,否则内置 tar.gz(默认)' },
26
- },
27
- },
28
- output: textOutput(),
29
- async execute(args, exec) {
30
- const action = typeof args.action === 'string' && args.action ? args.action : 'pack';
31
- if (action === 'check') return { text: packCheck(env, args, exec) };
32
- if (action === 'verify') return { text: packVerify(env, args, exec) };
33
- if (action === 'restore') return { text: packRestore(env, args, exec) };
34
- if (action !== 'pack') throw new Error('未知 action:' + action + '(check、pack、verify、restore)');
35
- return { text: packNow(env, args, exec) };
36
- },
37
- };
38
- }
@@ -1,37 +0,0 @@
1
- /** ho_phase —— 本会话阶段:水位 / 已压缩次数 / 锁状态 / 建议动作(纯只读,不压缩)。 */
2
- import { textOutput } from '../lib/output.mjs';
3
- import { nameOf } from '../lib/names.mjs';
4
- import { NL } from '../lib/text.mjs';
5
- import { decide } from '../lib/phase.mjs';
6
-
7
- export function pmPhaseTool(env) {
8
- return {
9
- name: nameOf('phase'),
10
- description:
11
- '本会话阶段:水位、已压缩次数、锁状态、建议动作。只读。',
12
- parameters: {
13
- type: 'object',
14
- additionalProperties: false,
15
- properties: {
16
- simulateRatio: { type: 'number', description: '演练用:用这个比例替代真实水位(0-1),不写任何状态' },
17
- },
18
- },
19
- output: textOutput(),
20
- async execute(args, exec) {
21
- const meId = env.selfId(exec);
22
- const real = env.selfPct(meId);
23
- const ratio = typeof args.simulateRatio === 'number' ? args.simulateRatio : (real === null ? null : real / 100);
24
- const count = env.compactionCount(exec);
25
- const lock = env.lockState(exec);
26
- const adv = decide({ ratio, compactionsUsed: count.used, cfg: env.cfg, lockState: lock });
27
- const out = [];
28
- out.push('会话 ' + (meId || '未知'));
29
- out.push('水位 ' + (ratio === null ? '读不到' : (Math.round(ratio * 1000) / 10).toFixed(1) + '%') + (typeof args.simulateRatio === 'number' ? ' (演练模拟值)' : '') + (typeof env.staleNote === 'function' ? env.staleNote(exec) : ''));
30
- const usedText = count.used + '/' + (env.cfg.maxCompactions) + ' 次';
31
- out.push('压缩 ' + usedText + (count.note ? ' ' + count.note : ''));
32
- out.push('锁 ' + lock);
33
- out.push('建议 ' + adv.action + ' —— ' + adv.reason);
34
- return { text: out.join(NL) };
35
- },
36
- };
37
- }
@@ -1,104 +0,0 @@
1
- /** ho_snapshot —— 库快照:逐字节复制 + sha256 清单 + PROVENANCE(Charter §4.4)。 */
2
- import fs from 'node:fs';
3
- import { nameOf } from '../lib/names.mjs';
4
- import path from 'node:path';
5
- import crypto from 'node:crypto';
6
- import { textOutput } from '../lib/output.mjs';
7
- import { NL, isDir } from '../lib/text.mjs';
8
- import { assertProjectName } from '../lib/role.mjs';
9
-
10
- const SKIP_DIRS = ['__pycache__', '.git', 'node_modules', '.obsidian'];
11
- const SKIP_FILES = ['.DS_Store', 'Thumbs.db'];
12
-
13
- function stamp() {
14
- const d = new Date();
15
- const p = (n) => (n < 10 ? '0' + n : '' + n);
16
- return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes());
17
- }
18
-
19
- function walk(root, rel, rows) {
20
- for (const name of fs.readdirSync(path.join(root, rel))) {
21
- const full = path.join(root, rel, name);
22
- const st = fs.statSync(full);
23
- const r = rel ? rel + '/' + name : name;
24
- if (st.isDirectory()) {
25
- if (SKIP_DIRS.indexOf(name) !== -1) continue;
26
- walk(root, r, rows);
27
- } else if (st.isFile()) {
28
- if (SKIP_FILES.indexOf(name) !== -1) continue;
29
- if (name.endsWith('.pyc')) continue;
30
- rows.push({ rel: r, size: st.size });
31
- }
32
- }
33
- return rows;
34
- }
35
-
36
- export function pmSnapshotTool(env) {
37
- return {
38
- name: nameOf('snapshot'),
39
- description:
40
- '库快照:逐字节复制、sha256 清单、来源说明。',
41
- parameters: {
42
- type: 'object',
43
- additionalProperties: false,
44
- required: ['project'],
45
- properties: {
46
- project: { type: 'string', description: '项目名(Projects/<项目>/ 下)' },
47
- tag: { type: 'string', description: '标签,默认 行为检查' },
48
- dry_run: { type: 'boolean', description: '只统计不落盘' },
49
- },
50
- },
51
- output: textOutput(),
52
- async execute(args, exec) {
53
- const ws = env.resolveWs(exec);
54
- const project = assertProjectName(typeof args.project === 'string' ? args.project.trim() : '');
55
- if (!project) throw new Error('project 不能为空(写项目名,如 Demo)');
56
- const projDir = path.join(ws, 'Projects', project);
57
- if (!isDir(projDir)) throw new Error('找不到项目:' + projDir);
58
- let lib = null;
59
- for (const name of fs.readdirSync(projDir)) {
60
- const p = path.join(projDir, name);
61
- if (isDir(p) && fs.existsSync(path.join(p, 'RULES.md'))) { lib = p; break; }
62
- }
63
- if (!lib) throw new Error('该项目下没有带 RULES.md 的库根:' + projDir);
64
- const rows = walk(lib, '', []);
65
- const total = rows.reduce((a, b) => a + b.size, 0);
66
- const out = [];
67
- out.push('源库 ' + lib);
68
- out.push('文件 ' + rows.length + ' 个,共 ' + (total / 1048576).toFixed(2) + ' MB');
69
- if (args.dry_run) { out.push('dry_run:不落盘。'); return { text: out.join(NL) }; }
70
- const tag = args.tag || '行为检查';
71
- const dest = path.join(ws, '_Temp', 'Checks', args.project + '_' + tag + '_' + stamp());
72
- if (fs.existsSync(dest)) throw new Error('目标已存在,不覆盖:' + dest);
73
- const libDest = path.join(dest, 'lib');
74
- fs.mkdirSync(libDest, { recursive: true });
75
- const manifest = ['# 源库: ' + lib, '# 文件: ' + rows.length + ' 字节: ' + total];
76
- let drift = 0;
77
- for (const r of rows) {
78
- const src = path.join(lib, r.rel);
79
- const dst = path.join(libDest, r.rel);
80
- fs.mkdirSync(path.dirname(dst), { recursive: true });
81
- fs.copyFileSync(src, dst);
82
- const h1 = crypto.createHash('sha256').update(fs.readFileSync(src)).digest('hex');
83
- const h2 = crypto.createHash('sha256').update(fs.readFileSync(dst)).digest('hex');
84
- if (h1 !== h2) drift++;
85
- manifest.push(r.rel + String.fromCharCode(9) + r.size + String.fromCharCode(9) + h1);
86
- }
87
- fs.writeFileSync(path.join(dest, 'MANIFEST.tsv'), manifest.join(NL), 'utf8');
88
- const prov = [
89
- '# PROVENANCE',
90
- '- 源库: ' + lib,
91
- '- 生成时间: ' + new Date().toISOString(),
92
- '- 发起会话: ' + (env.selfId(exec) || '?'),
93
- '- 文件数/字节: ' + rows.length + ' / ' + total,
94
- '- 排除: __pycache__ .git node_modules .obsidian *.pyc .DS_Store Thumbs.db',
95
- '- 校验: 逐文件 sha256 比对,drift=' + drift,
96
- '- 含个人数据,不得外发、不得进 _Archives',
97
- ].join(NL);
98
- fs.writeFileSync(path.join(dest, 'PROVENANCE.md'), prov, 'utf8');
99
- out.push('已落盘 ' + dest);
100
- out.push('校验 drift=' + drift + (drift === 0 ? '(逐文件一致)' : '(**不一致,判失败**)'));
101
- return { text: out.join(NL) };
102
- },
103
- };
104
- }