@mingmaple/dsh-handover 0.9.6 → 0.9.13
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.
- package/README.md +53 -17
- package/docs/migrate.md +1 -1
- package/docs/releases/0.9.12.md +28 -0
- package/docs/releases/0.9.13.md +30 -0
- package/docs/troubleshoot.md +43 -6
- package/package.json +70 -57
- package/scripts/blank-dsh.mjs +6 -5
- package/scripts/blank-smoke.mjs +268 -58
- package/scripts/build.mjs +3 -0
- package/scripts/check-docs.mjs +23 -2
- package/scripts/office.mjs +81 -0
- package/src/client.js +92 -23
- package/src/index.mjs +264 -145
- package/src/lib/api.mjs +67 -9
- package/src/lib/config.mjs +4 -3
- package/src/lib/consent.mjs +59 -20
- package/src/lib/facts.mjs +5 -4
- package/src/lib/guide.mjs +14 -7
- package/src/lib/handover-io.mjs +20 -0
- package/src/lib/manual.mjs +23 -12
- package/src/lib/names.mjs +2 -2
- package/src/lib/office.mjs +272 -0
- package/src/lib/pack-ops.mjs +3 -0
- package/src/lib/phase.mjs +6 -4
- package/src/lib/prompt.mjs +18 -0
- package/src/lib/role.mjs +52 -18
- package/src/lib/schedule.mjs +10 -14
- package/src/lib/session-create.mjs +42 -0
- package/src/lib/session-ops.mjs +183 -79
- package/src/lib/sessions.mjs +9 -8
- package/src/lib/status.mjs +8 -2
- package/src/lib/succession.mjs +244 -0
- package/src/lib/text.mjs +27 -0
- package/src/lib/webroute.mjs +1 -1
- package/src/lib/workspace.mjs +1 -1
- package/src/selftest.mjs +15 -0
- package/src/templates/Charter.skeleton.md +1 -1
- package/src/templates/Prompt_Templates.md +9 -17
- package/src/templates/Runtime_Manual.md +17 -14
- package/src/templates/rules/Handover_Rules.md +3 -2
- package/src/templates/rules/Misc_Task_Standard.md +3 -3
- package/src/tools/ho_checkpoint.mjs +11 -4
- package/src/tools/ho_diag.mjs +5 -3
- package/src/tools/ho_handover.mjs +7 -6
- package/src/tools/ho_office.mjs +59 -0
- package/src/tools/ho_profile.mjs +10 -2
- package/src/tools/ho_sessions.mjs +3 -2
- package/src/tools/ho_snapshot.mjs +2 -1
- package/src/tools/ho_succeed.mjs +24 -0
- package/src/tools/ho_workspace.mjs +7 -3
package/scripts/blank-smoke.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 空白环境冒烟:不依赖任何真实工作区,验「装上就能跑」。
|
|
3
|
-
* 1) 宿主半在假 ctx 上 apply:
|
|
3
|
+
* 1) 宿主半在假 ctx 上 apply:24 个工具、手册段、状态路由(200/404/405/HEAD)、只读工具不抛
|
|
4
4
|
* 2) 浏览器半在假 window/React 上装载:bundle 外形、settings.section 注册、成体视图能渲染
|
|
5
5
|
* 用法:node scripts/blank-smoke.mjs [--dist <dist 目录>]
|
|
6
6
|
*/
|
|
@@ -26,9 +26,14 @@ process.env.HOME = BLANK;
|
|
|
26
26
|
const { apply, schedDepsFor } = await import(pathToFileURL(path.join(dist, 'index.mjs')).href);
|
|
27
27
|
const reg = { tools: [], sections: [], logs: [], listeners: {} };
|
|
28
28
|
const ctx = {
|
|
29
|
-
tools: { register: (t) => reg.tools.push(t), restrict() { }, guard: (fn) => { reg.guardFn = fn; } },
|
|
29
|
+
tools: { register: (t, o) => { reg.tools.push(t); (reg.toolOpts = reg.toolOpts || []).push(o); }, restrict() { }, resolve: () => ({}), guard: (fn) => { reg.guardFn = fn; } },
|
|
30
30
|
systemPrompt: { section: (s) => { reg.sections.push(s); return () => { reg.disposed = (reg.disposed || 0) + 1; }; } },
|
|
31
|
-
sessionController: {
|
|
31
|
+
sessionController: {
|
|
32
|
+
create: async () => ({ sessionId: 'session-deploy-test' }),
|
|
33
|
+
rename: async () => { },
|
|
34
|
+
prompt: async () => ({ accepted: true }),
|
|
35
|
+
},
|
|
36
|
+
|
|
32
37
|
logger: { warn: (m) => reg.logs.push(String(m)), error: (m) => reg.logs.push(String(m)), info() { } },
|
|
33
38
|
effect: (fn) => fn(),
|
|
34
39
|
on: (n, cb) => { (reg.listeners[n] = reg.listeners[n] || []).push(cb); },
|
|
@@ -37,15 +42,45 @@ const ctx = {
|
|
|
37
42
|
};
|
|
38
43
|
apply(ctx, {});
|
|
39
44
|
|
|
40
|
-
ok(reg.tools.length ===
|
|
45
|
+
ok(reg.tools.length === 24, '工具数应为 24', String(reg.tools.length));
|
|
41
46
|
ok(reg.sections.length === 0, '未开启全局前置提示词时不得注册提示段', String(reg.sections.length));
|
|
42
47
|
ok(reg.tools.filter((t) => !t || !t.name || !t.description || !t.execute).length === 0, '每个工具都要有 name/description/execute');
|
|
43
|
-
//
|
|
48
|
+
// 上下文成本实测:工具描述是每次请求都要付的固定开销。参考值只用于展示,不作为通过条件。
|
|
44
49
|
const budget = await import(pathToFileURL(path.join(dist, 'lib', 'facts.mjs')).href);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
{
|
|
51
|
+
const rows = reg.tools.map((t) => ({ name: t.name, n: budget.estimateTokens(t.description) }));
|
|
52
|
+
const widest = rows.slice().sort((a, b) => b.n - a.n)[0];
|
|
53
|
+
console.log('工具描述 token 实测:最宽 ' + widest.name + ' ' + widest.n + '(参考值 ' + budget.TOKEN_BUDGET.toolDescTokens + ',不判失败)');
|
|
54
|
+
}
|
|
55
|
+
ok(reg.tools.filter((t) => t.name.startsWith('ho_')).length === 18, 'ho_* 应为 18 个', String(reg.tools.filter((t) => t.name.startsWith('ho_')).length));
|
|
48
56
|
ok(reg.tools.filter((t) => t.name.startsWith('peer_')).length === 6, 'peer_* 应为 6 个');
|
|
57
|
+
// 每步条件启用:内核 0.2.2 起工具按「步」解析,子会话在解析工具表这一步就要被排除,
|
|
58
|
+
// 否则它的首轮提示词会带着 24 个用不了的 schema(每步白付约 2.5k token)。
|
|
59
|
+
ok((reg.toolOpts || []).length === 24 && (reg.toolOpts || []).every((o) => o && typeof o.when === 'function'), '24 个工具都应带每步启用条件', String((reg.toolOpts || []).length));
|
|
60
|
+
|
|
61
|
+
/** 取某个工具的每步启用条件(0.2.2 起工具按步解析)。 */
|
|
62
|
+
const gateFor = (name) => {
|
|
63
|
+
const i = (reg.tools || []).findIndex((t) => t && t.name === name);
|
|
64
|
+
return i >= 0 && reg.toolOpts && reg.toolOpts[i] ? reg.toolOpts[i].when : null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
// 每步条件同时承担子会话排除与角色过滤:调用方每步重算工具表,换角色下一轮即生效。
|
|
69
|
+
const gate = gateFor('ho_brief');
|
|
70
|
+
ok(typeof gate === 'function', '每步启用条件应可调用');
|
|
71
|
+
if (typeof gate === 'function') {
|
|
72
|
+
ok(gate({}) === true, '没有 agent 的调用应放行(命令行与演练路径)');
|
|
73
|
+
ok(gate({ agent: { session: { header: { id: 'session-sub', origin: 'subagent' } } } }) === false, '子会话的工具条件应拒绝');
|
|
74
|
+
ok(gate({ agent: { session: { header: { id: 'session-child', parentSession: 'session-root' } } } }) === false, '带父会话的会话也应拒绝');
|
|
75
|
+
ok(gate({ agent: { session: { header: { id: 'session-plain', cwd: path.join(BLANK, 'nowhere') } } } }) === false, '未登记角色的会话按普通处理(0 个插件工具)');
|
|
76
|
+
const roleMod = await import(pathToFileURL(path.join(dist, 'lib', 'role.mjs')).href);
|
|
77
|
+
roleMod.writeRole({}, 'session-boss', { role: 'global' });
|
|
78
|
+
ok(gate({ agent: { session: { header: { id: 'session-boss', cwd: path.join(BLANK, 'nowhere') } } } }) === true, '全局角色应可见 ho_brief');
|
|
79
|
+
const gatePlain = gateFor('ho_phase');
|
|
80
|
+
ok(typeof gatePlain === 'function' && gatePlain({ agent: { session: { header: { id: 'session-boss', cwd: path.join(BLANK, 'nowhere') } } } }) === true, '全局角色应可见 ho_phase');
|
|
81
|
+
ok(gatePlain({ agent: { session: { header: { id: 'session-plain', cwd: path.join(BLANK, 'nowhere') } } } }) === false, '普通角色不可见 ho_phase');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
49
84
|
// 可选注入多了 connection(/api 载体)。两个都必须是**可选**的,否则旧宿主整插件不激活。
|
|
50
85
|
const injectNames = (reg.injects || []).map((i) => i.names.join(','));
|
|
51
86
|
ok(injectNames.some((n) => n.indexOf('webServer') !== -1), '应把 webServer 作为可选注入', injectNames.join(' | '));
|
|
@@ -92,6 +127,17 @@ ok(badEnv && badEnv.result && badEnv.result.ok === false && /信封不合法/.te
|
|
|
92
127
|
const rpcGet = await carrier.fetch(new Request('http://127.0.0.1/api/dsh-handover', { method: 'GET' }));
|
|
93
128
|
ok(rpcGet.status === 405, '载体 GET 应 405', String(rpcGet.status));
|
|
94
129
|
|
|
130
|
+
// 一键部署:没有 workspaceRegistry 时仍要建体系并建会话,并把「工作区未注册」如实报出。
|
|
131
|
+
const deployParent = path.join(BLANK, 'deploy');
|
|
132
|
+
const rpcDeploy = await rpcCall('deploy', { parent: deployParent });
|
|
133
|
+
const dv = rpcDeploy && rpcDeploy.result && rpcDeploy.result.ok ? rpcDeploy.result.value : null;
|
|
134
|
+
ok(!!dv, 'deploy 应成功', JSON.stringify(rpcDeploy && rpcDeploy.result && rpcDeploy.result.error));
|
|
135
|
+
if (dv) {
|
|
136
|
+
ok(dv.sessionId === 'session-deploy-test', 'deploy 应建出全局会话', String(dv.sessionId));
|
|
137
|
+
ok(fs.existsSync(path.join(dv.root, '_Control_Center', 'records', 'Global_Handover.md')), 'deploy 应在指定路径铺好文件体系', String(dv.root));
|
|
138
|
+
ok(!dv.workspace && /workspaceRegistry/.test(String(dv.workspaceError || '')), '无 workspaceRegistry 时应如实报告工作区未注册', JSON.stringify({ w: dv.workspace, e: dv.workspaceError }));
|
|
139
|
+
}
|
|
140
|
+
|
|
95
141
|
async function call(method, url) {
|
|
96
142
|
const res = { code: 0, headers: null, body: undefined, writeHead(c, h) { this.code = c; this.headers = h; }, end(b) { this.body = b; } };
|
|
97
143
|
await route.handler({ method, url }, res);
|
|
@@ -103,11 +149,11 @@ let payload = null;
|
|
|
103
149
|
try { payload = JSON.parse(r1.body); } catch (e) { fails.push('返回不是 JSON:' + e.message); }
|
|
104
150
|
if (payload) {
|
|
105
151
|
ok(payload.plugin.version && payload.plugin.version !== '?', '应报出版本号', String(payload.plugin.version));
|
|
106
|
-
ok(payload.plugin.tools ===
|
|
152
|
+
ok(payload.plugin.tools === 24, 'payload 工具数应为 24', String(payload.plugin.tools));
|
|
107
153
|
ok(payload.workspace && payload.workspace.found === false, '空白 HOME 下应判定为没找到工作区', JSON.stringify(payload.workspace));
|
|
108
154
|
ok(Array.isArray(payload.notes) && payload.notes.length > 0, '没找到工作区时应给中文提示');
|
|
109
155
|
ok(payload.guide && Array.isArray(payload.guide.situations) && payload.guide.situations.length >= 8, 'payload 应带「怎么用」的时机表');
|
|
110
|
-
ok(payload.guide && payload.guide.groups.reduce((a, g) => a + g.tools.length, 0) ===
|
|
156
|
+
ok(payload.guide && payload.guide.groups.reduce((a, g) => a + g.tools.length, 0) === 24, '使用说明里应列全 24 个工具', payload.guide && String(payload.guide.groups.reduce((a, g) => a + g.tools.length, 0)));
|
|
111
157
|
ok(payload.plugin.initErrors && payload.plugin.initErrors.length === 0, '初始化错误应为空', JSON.stringify(payload.plugin.initErrors));
|
|
112
158
|
}
|
|
113
159
|
ok((await call('GET', '/dsh-handover/nope.json')).code === 404, '未知路径应 404');
|
|
@@ -241,6 +287,52 @@ fs.mkdirSync(path.join(WS, '_Control_Center'), { recursive: true });
|
|
|
241
287
|
fs.mkdirSync(path.join(WS, 'Projects', 'Demo', 'Demo库'), { recursive: true });
|
|
242
288
|
fs.mkdirSync(path.join(WS, '_Misc', 'tasks', '抢名额'), { recursive: true });
|
|
243
289
|
|
|
290
|
+
// ---------- 在任复用:工具层前置校验(任何写入之前返回) ----------
|
|
291
|
+
{
|
|
292
|
+
const { registerHolder } = await import(DIST('lib/office.mjs'));
|
|
293
|
+
const cacheDir = path.join(BLANK, '.dsh', 'storages', 'session_projcache', 'sessions');
|
|
294
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
295
|
+
fs.writeFileSync(path.join(cacheDir, 'session-hold.json'), JSON.stringify({
|
|
296
|
+
record: { rows: { contextPressure: { val: { pressureTokens: 16000, contextWindow: 160000 } } } },
|
|
297
|
+
}), 'utf8');
|
|
298
|
+
registerHolder(WS, { kind: 'project', project: 'Demo', name: 'Demo1', sessionId: 'session-hold', workspace: WS });
|
|
299
|
+
const sessToolR = reg.tools.find((t) => t.name === 'ho_sessions');
|
|
300
|
+
const makerExec = { name: 'ho_sessions', arguments: {}, agent: { session: { header: { id: 'session-maker', cwd: WS } } } };
|
|
301
|
+
const newErr = async (extra) => {
|
|
302
|
+
try {
|
|
303
|
+
await sessToolR.execute(Object.assign({ action: 'new', confirm: true, title: 'Demo2', project: 'Demo' }, extra), makerExec);
|
|
304
|
+
return '';
|
|
305
|
+
} catch (e) { return String(e && e.message ? e.message : e); }
|
|
306
|
+
};
|
|
307
|
+
const lowErr = await newErr({});
|
|
308
|
+
ok(/已有在任会话 Demo1/.test(lowErr) && /session-hold/.test(lowErr) && /peer_send/.test(lowErr) && /force=true/.test(lowErr), '项目类别有低水位在任时应拒绝新建并给出复用建议', lowErr);
|
|
309
|
+
const forcedErr = await newErr({ force: true });
|
|
310
|
+
ok(forcedErr.indexOf('已有在任会话') === -1, 'force=true 应越过复用判据', forcedErr);
|
|
311
|
+
|
|
312
|
+
// 换任:本类别现任就是本会话且水位未到线时拒绝,force=true 越过。
|
|
313
|
+
const handover = path.join(WS, '_Control_Center', 'records', 'Global_Handover.md');
|
|
314
|
+
fs.mkdirSync(path.dirname(handover), { recursive: true });
|
|
315
|
+
fs.writeFileSync(handover, '# 全局交接' + '\n\n## 下一步\n- 无\n', 'utf8');
|
|
316
|
+
registerHolder(WS, { kind: 'global', name: '全局1', sessionId: 'session-hold', workspace: WS });
|
|
317
|
+
const { grantConsent, consumedConsent } = await import(DIST('lib/consent.mjs'));
|
|
318
|
+
grantConsent('session-hold', 'blank-smoke', 'succeed');
|
|
319
|
+
const { runSuccession } = await import(DIST('lib/succession.mjs'));
|
|
320
|
+
const succEnv = {
|
|
321
|
+
cfg: resolveConfig({}),
|
|
322
|
+
roleOf: () => ({ role: 'global', project: '', task: '' }),
|
|
323
|
+
selfId: () => 'session-hold',
|
|
324
|
+
handoverFor: () => ({ path: handover, reason: '' }),
|
|
325
|
+
resolveWs: () => WS,
|
|
326
|
+
selfPct: () => 10,
|
|
327
|
+
registry: () => ({ list: () => [] }),
|
|
328
|
+
};
|
|
329
|
+
const succLow = await runSuccession(succEnv, null, {});
|
|
330
|
+
ok(/水位 10\.0%/.test(succLow) && /通常无需换任/.test(succLow) && /force=true/.test(succLow), '换任:本会话水位未到线时应拒绝并建议 force', succLow);
|
|
331
|
+
const succForce = await runSuccession(succEnv, null, { force: true });
|
|
332
|
+
ok(succForce.indexOf('尚未到线') === -1, '换任:force=true 应越过水位判据', succForce);
|
|
333
|
+
consumedConsent('session-hold', 'succeed');
|
|
334
|
+
}
|
|
335
|
+
|
|
244
336
|
const rTitle = resolveRole({ sessionId: 'session-t1', title: '全局9' });
|
|
245
337
|
ok(rTitle.role === 'global' && rTitle.source === 'title', '角色判定:标题含「全局」→ global', JSON.stringify(rTitle));
|
|
246
338
|
const rCwd = resolveRole({ sessionId: 'session-t2', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库') });
|
|
@@ -257,6 +349,19 @@ const rStore = resolveRole({ sessionId: 'session-t6', title: '全局10' });
|
|
|
257
349
|
ok(rStore.role === 'misc' && rStore.source === 'store', '角色表优先于标题推断', JSON.stringify(rStore));
|
|
258
350
|
ok(readRoles({}).sessions['session-t6'] && readRoles({}).sessions['session-t6'].task === 'X', '角色表应可读回');
|
|
259
351
|
|
|
352
|
+
// 回归锁:首条提示里的 `Projects/**` 通配曾被当成项目名 `**、_Project_Meta`,
|
|
353
|
+
// 于是交接路径、备份目录、归档目录全落在这个 Windows 非法名字上(实测四类报错)。
|
|
354
|
+
const rGlob = resolveRole({ sessionId: 'session-glob-prompt', title: 'Android AI Agent 3', cwd: 'C:/Users/x/.dsh/profiles/desktop', firstPrompt: '- 动 _Control_Center/**、Projects/**、_Project_Meta/** 前先问用户。' });
|
|
355
|
+
ok(rGlob.role === 'plain', '首条提示里的 Projects/** 通配不得被当成项目名', JSON.stringify(rGlob));
|
|
356
|
+
const hpBad = handoverPathFor(WS, { role: 'project', project: '**、_Project_Meta' });
|
|
357
|
+
ok(hpBad.path === null && /不合法/.test(String(hpBad.reason || '')), '非法项目名不得拼出交接路径', JSON.stringify(hpBad));
|
|
358
|
+
{
|
|
359
|
+
let badWrite = '';
|
|
360
|
+
try { writeRole({}, 'session-bad-name', { role: 'project', project: '**、_Project_Meta' }); } catch (e) { badWrite = e.message; }
|
|
361
|
+
ok(/不合法/.test(badWrite), '角色表不得写入含非法字符的项目名', badWrite);
|
|
362
|
+
ok(readRoles({}).sessions['session-bad-name'] === undefined, '非法项目名不得落盘', JSON.stringify(readRoles({}).sessions['session-bad-name']));
|
|
363
|
+
}
|
|
364
|
+
|
|
260
365
|
const hpG = handoverPathFor(WS, { role: 'global' });
|
|
261
366
|
ok(/Global_Handover\.md$/.test(hpG.path), '全局交接路径', hpG.path);
|
|
262
367
|
const hpP = handoverPathFor(WS, { role: 'project', project: 'Demo' });
|
|
@@ -267,16 +372,16 @@ const hpU = handoverPathFor(WS, { role: 'plain' });
|
|
|
267
372
|
ok(hpU.path === null && !!hpU.reason, '普通会话不得给出交接路径(不退回全局)', JSON.stringify(hpU));
|
|
268
373
|
|
|
269
374
|
const tsG = toolsetFor('global');
|
|
270
|
-
ok(tsG.allow.length ===
|
|
375
|
+
ok(tsG.allow.length === 24 && tsG.deny.length === 0, '全局应可见全部 24 个工具', JSON.stringify([tsG.allow.length, tsG.deny.length]));
|
|
271
376
|
const tsP = toolsetFor('project');
|
|
272
|
-
ok(tsP.allow.length ===
|
|
377
|
+
ok(tsP.allow.length === 11, '项目角色应可见 11 个工具', String(tsP.allow.length));
|
|
273
378
|
ok(tsP.allow.indexOf('ho_handover') !== -1 && tsP.allow.indexOf('ho_checkpoint') !== -1, '项目角色必须能写自己的交接与压缩');
|
|
274
379
|
ok(tsP.allow.indexOf('ho_pack') === -1 && tsP.allow.indexOf('ho_sessions') === -1, '项目角色不该看见全局管理工具');
|
|
275
380
|
ok(tsP.deny.indexOf('ho_handover') === -1 && tsP.deny.indexOf('run_code') === -1, 'deny 名单不得含保留名或本角色已允许的工具');
|
|
276
381
|
const tsM = toolsetFor('misc');
|
|
277
|
-
ok(tsM.allow.length ===
|
|
382
|
+
ok(tsM.allow.length === 11 && tsM.allow.indexOf('peer_new') !== -1 && tsM.allow.indexOf('ho_gate') === -1, '杂项角色:11 个,含 peer_new、不含 ho_gate', String(tsM.allow.length));
|
|
278
383
|
const tsU = toolsetFor('plain');
|
|
279
|
-
ok(tsU.allow.length === 0 && tsU.deny.length ===
|
|
384
|
+
ok(tsU.allow.length === 0 && tsU.deny.length === 24, '普通会话不载入任何插件工具', JSON.stringify([tsU.allow.length, tsU.deny.length]));
|
|
280
385
|
const rSub = resolveRole({ sessionId: 'session-sub', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库'), subagent: true });
|
|
281
386
|
ok(rSub.role === 'plain' && rSub.source === 'subagent', '子会话默认按普通处理,不按 cwd 判项目', JSON.stringify(rSub));
|
|
282
387
|
|
|
@@ -309,6 +414,10 @@ if (profTool) {
|
|
|
309
414
|
ok(/已注册/.test(rr.text) && /application/.test(rr.text), 'ho_profile read 应读到已注册骨架');
|
|
310
415
|
const vv = await profTool.execute({ action: 'validate' }, profExec);
|
|
311
416
|
ok(/结论:通过/.test(vv.text), 'ho_profile validate 应通过', vv.text.slice(0, 120));
|
|
417
|
+
// 项目会话只处理自己的骨架:传别的项目名要报错,不能静默忽略入参(实测踩过)。
|
|
418
|
+
let crossErr = '';
|
|
419
|
+
try { await profTool.execute({ action: 'validate', project: '学习' }, profExec); } catch (e) { crossErr = e.message; }
|
|
420
|
+
ok(/只能读写自己的骨架/.test(crossErr), '项目会话传别的项目名应明确报错', crossErr);
|
|
312
421
|
}
|
|
313
422
|
|
|
314
423
|
// 回归锁:压缩调度器拿的是**会话 id 字符串**。早期版本这里错配了 exec 版函数,
|
|
@@ -333,6 +442,48 @@ const tu = fillResumePrompt(tcfg, { n: 1, handover: '', role: 'plain' });
|
|
|
333
442
|
ok(/普通会话,不参与交接/.test(tu), '普通会话的续跑文案应说明不参与交接', tu.slice(0, 90));
|
|
334
443
|
const tg = fillResumePrompt(tcfg, { n: 3, handover: hpG.path, role: 'global' });
|
|
335
444
|
ok(tg.indexOf('第 3/3 次压缩') !== -1, '续跑文案应填上次数', tg.slice(0, 40));
|
|
445
|
+
ok(tcfg.directPeer === 'on', '会话之间直接对话应默认允许', String(tcfg.directPeer));
|
|
446
|
+
ok(tg.indexOf('收尾并停止') !== -1, '续跑文案应允许交接无待办时收尾停止', tg.slice(0, 120));
|
|
447
|
+
|
|
448
|
+
// 压缩额度用尽优先于水位判定:3/3 之后再压不是「水位没到」,而是该换任。
|
|
449
|
+
// 实测:某项目会话压满 3 次、水位 25.4% 时,ho_checkpoint 只报「水位未到线」,误导它去找 force=true。
|
|
450
|
+
{
|
|
451
|
+
const { decide } = await import(pathToFileURL(path.join(dist, 'lib', 'phase.mjs')).href);
|
|
452
|
+
const d1 = decide({ ratio: 0.25, compactionsUsed: 3, cfg: tcfg, lockState: 'clean' });
|
|
453
|
+
ok(d1.action === 'new_session', '压满 3/3 时水位低也应建议换任', JSON.stringify(d1));
|
|
454
|
+
const d2 = decide({ ratio: 0.9, compactionsUsed: 3, cfg: tcfg, lockState: 'clean' });
|
|
455
|
+
ok(d2.action === 'new_session', '压满 3/3 时到硬顶也应换任', JSON.stringify(d2));
|
|
456
|
+
const d3 = decide({ ratio: 0.4, compactionsUsed: 2, cfg: tcfg, lockState: 'clean' });
|
|
457
|
+
ok(d3.action === 'checkpoint', '未压满时过交接线仍应压缩', JSON.stringify(d3));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 归档命名按 Handover_Rules §4.2:<原文件名>_<YYYYMMDD_HHMMSS>.md,不加链标识;杂项归档在任务目录内。
|
|
461
|
+
{
|
|
462
|
+
const io = await import(pathToFileURL(path.join(dist, 'lib', 'handover-io.mjs')).href);
|
|
463
|
+
const stamp = io.archiveStamp(new Date(2026, 10, 8, 14, 43, 7));
|
|
464
|
+
ok(stamp === '20261108_144307', '归档时间戳应到秒', stamp);
|
|
465
|
+
const projName = io.archiveFileName('E:/ws/_Project_Meta/Demo/Handover.md', stamp);
|
|
466
|
+
ok(projName === 'Handover_' + stamp + '.md', '项目归档名应为 Handover_<时间>.md,不加项目前缀', projName);
|
|
467
|
+
const globName = io.archiveFileName('E:/ws/_Control_Center/records/Global_Handover.md', stamp);
|
|
468
|
+
ok(globName === 'Global_Handover_' + stamp + '.md', '全局归档名应为 Global_Handover_<时间>.md', globName);
|
|
469
|
+
const ad = await import(pathToFileURL(path.join(dist, 'lib', 'role.mjs')).href);
|
|
470
|
+
const miscDir = ad.archiveDirFor(WS, { role: 'misc', task: '抢名额' }).replace(/\\/g, '/');
|
|
471
|
+
ok(miscDir.endsWith('_Misc/tasks/抢名额/_archive'), '杂项归档目录应在任务目录内(_archive)', miscDir);
|
|
472
|
+
const handTool = reg.tools.find((t) => t.name === 'ho_handover');
|
|
473
|
+
ok(!!handTool, '应注册 ho_handover');
|
|
474
|
+
if (handTool) {
|
|
475
|
+
const archExec = { name: 'ho_handover', arguments: { action: 'archive' }, agent: { session: { header: { id: 'session-arch', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库'), title: 'Demo1' } } } };
|
|
476
|
+
const r = await handTool.execute({ action: 'archive' }, archExec);
|
|
477
|
+
const m = /已归档 (.+?\.md)/.exec(r.text || '');
|
|
478
|
+
ok(!!m, '归档应报出目标路径', String(r.text).slice(0, 140));
|
|
479
|
+
if (m) {
|
|
480
|
+
const p = m[1];
|
|
481
|
+
ok(p.replace(/\\/g, '/').indexOf('_Archives/Demo/Handover/') !== -1, '项目归档目录应为 _Archives/Demo/Handover', p);
|
|
482
|
+
ok(/^Handover_\d{8}_\d{6}\.md$/.test(path.basename(p)), '归档文件名不加项目前缀且时间到秒', path.basename(p));
|
|
483
|
+
ok(fs.existsSync(p), '归档文件应存在', p);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
336
487
|
|
|
337
488
|
const listeners = (reg.listeners && reg.listeners['agent/created']) || [];
|
|
338
489
|
ok(listeners.length === 1, '应监听 agent/created 且只一处', String(listeners.length));
|
|
@@ -342,26 +493,34 @@ const projAgent = {
|
|
|
342
493
|
session: { header: { id: 'session-proj', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库') } },
|
|
343
494
|
ctx: {
|
|
344
495
|
tools: { restrict: (f) => { calls.restrict.push(f); return () => { calls.lifted++; }; } },
|
|
345
|
-
systemPrompt: { section: (o) => { calls.sections.push(o); return () => { }; } },
|
|
496
|
+
systemPrompt: { section: (o) => { calls.sections.push(o); return () => { calls.lifted++; }; } },
|
|
346
497
|
},
|
|
347
498
|
inject: (m) => calls.injected.push(m),
|
|
348
499
|
};
|
|
349
500
|
for (const cb of listeners) cb({ agent: projAgent, source: 'fresh' });
|
|
350
|
-
ok(calls.restrict.length ===
|
|
351
|
-
|
|
352
|
-
|
|
501
|
+
ok(calls.restrict.length === 0, '按步路径不用 restrict 收窄工具', String(calls.restrict.length));
|
|
502
|
+
const projGatePack = gateFor('ho_pack');
|
|
503
|
+
const projGateHand = gateFor('ho_handover');
|
|
504
|
+
const projExec = { agent: projAgent };
|
|
505
|
+
ok(typeof projGatePack === 'function' && projGatePack(projExec) === false, '项目角色不可见 ho_pack(每步条件)');
|
|
506
|
+
ok(typeof projGateHand === 'function' && projGateHand(projExec) === true, '项目角色可见 ho_handover(每步条件)');
|
|
507
|
+
ok(gateFor('ho_brief')(projExec) === false, '项目角色不可见 ho_brief');
|
|
353
508
|
ok(calls.sections.length === 1 && calls.sections[0].name === 'dsh-handover:manual', '应注册同名提示段遮蔽兜底段');
|
|
354
509
|
const secNorm = String(calls.sections[0].text).replace(/\\/g, '/');
|
|
355
510
|
ok(secNorm.indexOf('Projects/Demo') !== -1 && secNorm.indexOf('_Project_Meta/Demo/Handover.md') !== -1, '项目提示段应含自己的路径', secNorm.slice(0, 200));
|
|
356
|
-
ok(
|
|
511
|
+
ok(secNorm.indexOf('任务完成不报全局') !== -1, '项目提示段应写明任务完成不向全局报告', secNorm.slice(0, 400));
|
|
512
|
+
ok(secNorm.indexOf('任务达成') === -1, '项目提示段不得再出现任务达成');
|
|
513
|
+
ok(secNorm.indexOf('可直接 peer_send') !== -1, '项目提示段应允许会话之间直接对话', secNorm.slice(0, 400));
|
|
514
|
+
ok(secNorm.indexOf('resume=wait') !== -1, '项目提示段应说明收尾压缩不唤醒');
|
|
515
|
+
console.log('项目提示段 token 实测:' + budget.estimateTokens(calls.sections[0].text) + '(参考值 ' + budget.TOKEN_BUDGET.projectTokens + ',不判失败)');
|
|
357
516
|
ok(calls.sections[0].text.indexOf('子智能体') === -1, '项目段不得携带子智能体派发纪律(不属于本插件)');
|
|
358
|
-
// 真实工作区路径 +
|
|
517
|
+
// 真实工作区路径 + 迁移提示是最坏情形,参考值另设一档
|
|
359
518
|
{
|
|
360
519
|
const man = await import(pathToFileURL(path.join(dist, 'lib', 'manual.mjs')).href);
|
|
361
520
|
const longWs = path.join(os.tmpdir(), 'Project_Mate', '_Workspace');
|
|
362
521
|
const otherWs = path.join(os.tmpdir(), 'Project_Mate', '_Workspace_2');
|
|
363
522
|
const worst = man.scopedManual({ role: 'project', project: 'Demo', task: '' }, { ws: longWs, move: { phase: 'switched', dst: otherWs, src: longWs } });
|
|
364
|
-
|
|
523
|
+
console.log('项目最坏情形提示段 token 实测:' + budget.estimateTokens(worst) + '(参考值 ' + budget.TOKEN_BUDGET.projectWorstTokens + ',不判失败)');
|
|
365
524
|
}
|
|
366
525
|
ok(secNorm.indexOf('_Temp/Demo/') !== -1 && secNorm.indexOf('_Archives/Demo/') !== -1, '项目提示段应含临时与归档路径', secNorm.slice(0, 300));
|
|
367
526
|
// 回归锁:早期版本用 agent.inject({source:{kind:'plugin'}}) 投一次性引导,v4 格式准入拒绝该 source,
|
|
@@ -384,8 +543,8 @@ const globAgent = {
|
|
|
384
543
|
for (const cb of listeners) cb({ agent: globAgent, source: 'fresh' });
|
|
385
544
|
ok(gCalls.restrict === 0, '全局角色不应被裁剪', String(gCalls.restrict));
|
|
386
545
|
ok(gCalls.sections.length === 1 && gCalls.sections[0].name === 'dsh-handover:manual', '全局也应遮蔽兜底段');
|
|
387
|
-
//
|
|
388
|
-
|
|
546
|
+
// 常驻段只放每轮判定,一次性流程下沉手册;参考值按加权 token 计,只打印实测值。
|
|
547
|
+
console.log('全局提示段 token 实测:' + budget.estimateTokens(gCalls.sections[0].text) + '(参考值 ' + budget.TOKEN_BUDGET.globalTokens + ',不判失败)');
|
|
389
548
|
ok(gCalls.sections[0].text.indexOf('反模式') === -1, '全局段不得携带反模式纪律(不属于本插件)');
|
|
390
549
|
ok(gCalls.sections[0].text.indexOf('send_message 续用') === -1, '全局段不得携带子智能体续用纪律(不属于本插件)');
|
|
391
550
|
// 真机最坏情形:长路径 + 迁移提示行
|
|
@@ -394,7 +553,7 @@ ok(gCalls.sections[0].text.indexOf('send_message 续用') === -1, '全局段不
|
|
|
394
553
|
const longWs2 = path.join(os.tmpdir(), 'Project_Mate', '_Workspace');
|
|
395
554
|
const otherWs2 = path.join(os.tmpdir(), 'Project_Mate', '_Workspace_2');
|
|
396
555
|
const gWorst = man2.globalManual({ ws: longWs2, move: { phase: 'switched', dst: otherWs2, src: longWs2 } });
|
|
397
|
-
|
|
556
|
+
console.log('全局最坏情形提示段 token 实测:' + budget.estimateTokens(gWorst) + '(参考值 ' + budget.TOKEN_BUDGET.globalWorstTokens + ',不判失败)');
|
|
398
557
|
}
|
|
399
558
|
ok(gCalls.sections[0].text.indexOf('前置提示词') === -1, '插件角色段不得再携带前置提示词(由用户全局前置提示词注入)');
|
|
400
559
|
// 结构只保留会话定位、权限、节奏、交接与按需取用五节
|
|
@@ -425,6 +584,11 @@ ok(gCalls.injected === 0, '全局不再重复注入工作流(提示段里已
|
|
|
425
584
|
// 换任启动提示词不得再写「然后接着干」——没任务时它会抠字眼给自己找活
|
|
426
585
|
const opsSrc = fs.readFileSync(path.join(dist, 'lib', 'session-ops.mjs'), 'utf8');
|
|
427
586
|
ok(opsSrc.indexOf('接着干') === -1, '启动提示词与交接提示不得再出现「接着干」');
|
|
587
|
+
ok(opsSrc.indexOf('任务达成') === -1 && opsSrc.indexOf('三种时机') !== -1, '启动提示词应改为三种时机且不再含任务达成');
|
|
588
|
+
const ckSrc = fs.readFileSync(path.join(dist, 'tools', 'ho_checkpoint.mjs'), 'utf8');
|
|
589
|
+
ok(ckSrc.indexOf('resume') !== -1, 'ho_checkpoint 应支持 resume 选择压缩后去向');
|
|
590
|
+
const idxSrc = fs.readFileSync(path.join(dist, 'index.mjs'), 'utf8');
|
|
591
|
+
ok(idxSrc.indexOf('directPeer') !== -1, '会话守卫应支持直接对话开关');
|
|
428
592
|
// 语体扫描——界面与文档一律标准书面语(口语词、感叹号、图形结论、全角斜杠、时间缺时区)。
|
|
429
593
|
{
|
|
430
594
|
const uiFiles = listFiles(path.join(repo, 'src'), ['.mjs', '.js'], { skip: ['templates'] });
|
|
@@ -440,41 +604,59 @@ ok(gCalls.injected === 0, '全局不再重复注入工作流(提示段里已
|
|
|
440
604
|
ok(opsSrc.indexOf('不得自行开展作业') !== -1 && opsSrc.indexOf('待用户指派') !== -1, '启动提示词应先报告再待指派');
|
|
441
605
|
}
|
|
442
606
|
|
|
443
|
-
|
|
607
|
+
// 按步路径(0.2.2 起):创建即按角色登记提示段;工具可见性由每步条件算,不再调用 restrict。
|
|
608
|
+
const unrCalls = { restrict: 0, section: null };
|
|
444
609
|
const unrAgent = {
|
|
445
610
|
id: 'session-unr',
|
|
446
611
|
session: { header: { id: 'session-unr', title: '随便', cwd: '/tmp/x' } },
|
|
447
612
|
ctx: {
|
|
448
|
-
tools: { restrict: (
|
|
449
|
-
systemPrompt: { section: () => () => { } },
|
|
613
|
+
tools: { restrict: () => { unrCalls.restrict++; return () => { }; } },
|
|
614
|
+
systemPrompt: { section: (o) => { unrCalls.section = o; return () => { }; } },
|
|
450
615
|
},
|
|
451
616
|
inject: () => { throw new Error('未设角色的会话不应写历史'); },
|
|
452
617
|
};
|
|
453
618
|
for (const cb of listeners) cb({ agent: unrAgent, source: 'fresh' });
|
|
454
619
|
ok(true, '未设角色的会话装配不应抛错');
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const lateAgent = {
|
|
463
|
-
id: 'session-late',
|
|
464
|
-
session: { header: { id: 'session-late', title: '随便', cwd: '/tmp/y' } },
|
|
465
|
-
ctx: {
|
|
466
|
-
tools: { restrict: (f) => { lateCalls.restrict++; lateCalls.lastDeny = (f && f.deny) || null; return () => { }; } },
|
|
467
|
-
systemPrompt: { section: () => () => { } },
|
|
468
|
-
},
|
|
469
|
-
inject: () => { throw new Error('未设角色的会话不应写历史'); },
|
|
470
|
-
};
|
|
471
|
-
for (const cb of listeners) cb({ agent: lateAgent, source: 'fresh' });
|
|
472
|
-
ok(lateCalls.restrict === 0, '未设角色出生时也不得对第二个会话当场收窄', String(lateCalls.restrict));
|
|
473
|
-
writeRole({}, 'session-late', { role: 'project', project: 'Demo', source: 'user' });
|
|
474
|
-
await new Promise((res) => setTimeout(res, 1700));
|
|
475
|
-
ok(lateCalls.restrict === 1, '角色后到时延迟复核应补装真角色', String(lateCalls.restrict));
|
|
476
|
-
ok(lateCalls.restrict === 1 && lateCalls.lastDeny && lateCalls.lastDeny.length === 13, '补装后 deny 名单应为项目角色的 13 个', JSON.stringify(lateCalls.lastDeny && lateCalls.lastDeny.length));
|
|
620
|
+
ok(unrCalls.restrict === 0, '按步路径不再用 restrict 收窄工具', String(unrCalls.restrict));
|
|
621
|
+
ok(!unrCalls.section, '普通会话不注册角色提示段');
|
|
622
|
+
|
|
623
|
+
// 开轮前核对:休眠期间改了角色,下一轮开始时补装提示段(工具由每步条件即时生效)。
|
|
624
|
+
writeRole({}, 'session-unr', { role: 'project', project: 'Demo', source: 'user' });
|
|
625
|
+
for (const cb of ((reg.listeners && reg.listeners['agent/status']) || [])) cb({ agent: unrAgent, status: 'running' });
|
|
626
|
+
ok(!!unrCalls.section && /项目/.test(String(unrCalls.section.text)), '开轮前核对应按新角色补装提示段', JSON.stringify(unrCalls.section && unrCalls.section.name));
|
|
477
627
|
|
|
628
|
+
// 旧内核(没有 tools.resolve)才走 restrict:创建时按普通收窄 24 个,改角色后开轮前换成 13 个。
|
|
629
|
+
{
|
|
630
|
+
const legacyCalls = { restrict: 0, lastDeny: null, disposed: 0 };
|
|
631
|
+
const legacyListeners = {};
|
|
632
|
+
const legacyTools = {
|
|
633
|
+
register: () => { },
|
|
634
|
+
restrict: (f) => { legacyCalls.restrict++; legacyCalls.lastDeny = (f && f.deny) || null; return () => { legacyCalls.disposed++; }; },
|
|
635
|
+
guard: () => { },
|
|
636
|
+
};
|
|
637
|
+
const legacyCtx = {
|
|
638
|
+
tools: legacyTools,
|
|
639
|
+
systemPrompt: { section: () => () => { } },
|
|
640
|
+
sessionController: {},
|
|
641
|
+
logger: { warn: () => { }, error: () => { }, info: () => { } },
|
|
642
|
+
effect: (fn) => fn(),
|
|
643
|
+
on: (n, cb) => { (legacyListeners[n] = legacyListeners[n] || []).push(cb); },
|
|
644
|
+
get: () => null,
|
|
645
|
+
inject: () => { },
|
|
646
|
+
};
|
|
647
|
+
apply(legacyCtx, {});
|
|
648
|
+
const legacyAgent = {
|
|
649
|
+
id: 'session-legacy',
|
|
650
|
+
session: { header: { id: 'session-legacy', cwd: '/tmp/l' } },
|
|
651
|
+
ctx: { tools: legacyTools, systemPrompt: legacyCtx.systemPrompt },
|
|
652
|
+
};
|
|
653
|
+
for (const cb of (legacyListeners['agent/created'] || [])) cb({ agent: legacyAgent, source: 'fresh' });
|
|
654
|
+
ok(legacyCalls.restrict === 1 && legacyCalls.lastDeny && legacyCalls.lastDeny.length === 24, '旧内核:创建时按普通收窄 24 个', JSON.stringify(legacyCalls.lastDeny && legacyCalls.lastDeny.length));
|
|
655
|
+
writeRole({}, 'session-legacy', { role: 'project', project: 'Demo', source: 'user' });
|
|
656
|
+
for (const cb of (legacyListeners['agent/status'] || [])) cb({ agent: legacyAgent, status: 'running' });
|
|
657
|
+
ok(legacyCalls.restrict === 2 && legacyCalls.lastDeny && legacyCalls.lastDeny.length === 13, '旧内核:开轮前换成项目角色的 13 个', JSON.stringify(legacyCalls.lastDeny && legacyCalls.lastDeny.length));
|
|
658
|
+
ok(legacyCalls.disposed === 1, '旧内核:换角色应撤销旧限制', String(legacyCalls.disposed));
|
|
659
|
+
}
|
|
478
660
|
// ---------- 角色路由 ----------
|
|
479
661
|
const noRole = await postJson('/dsh-handover/role', { session_id: 'session-r1', role: 'project' });
|
|
480
662
|
ok(noRole.code === 400 && /项目名/.test(String(noRole.json && noRole.json.error)), '设项目角色缺项目名应 400', JSON.stringify(noRole.json));
|
|
@@ -496,9 +678,9 @@ ok(/order 需要 session_id/.test(oNoSid), 'order 缺 session_id 应报错', oNo
|
|
|
496
678
|
const oBad = await orderCall({ action: 'order', order: 'wave', session_id: 'session-r1' });
|
|
497
679
|
ok(/只能是 handover 或 compact/.test(oBad), 'order 取值应受限', oBad);
|
|
498
680
|
// 交接/压缩指令必须先问用户
|
|
499
|
-
const oNoConfirm = await orderCall({ action: 'order', order: 'handover', session_id: 'session-
|
|
681
|
+
const oNoConfirm = await orderCall({ action: 'order', order: 'handover', session_id: 'session-norole' });
|
|
500
682
|
ok(/需要用户确认/.test(oNoConfirm), 'order 未确认应拒绝', oNoConfirm);
|
|
501
|
-
const oUnr = await orderCall({ action: 'order', order: 'handover', session_id: 'session-
|
|
683
|
+
const oUnr = await orderCall({ action: 'order', order: 'handover', session_id: 'session-norole', confirm: true });
|
|
502
684
|
ok(/没有交接路径/.test(oUnr), '未设角色的会话不能下交接指令(避免退回全局交接)', oUnr);
|
|
503
685
|
const oCold = await orderCall({ action: 'order', order: 'compact', session_id: 'session-r1', confirm: true });
|
|
504
686
|
ok(/不在内存/.test(oCold), '休眠会话不能排压缩(compactNow 需要活体 agent)', oCold);
|
|
@@ -520,9 +702,9 @@ if (typeof reg.guardFn === 'function') {
|
|
|
520
702
|
const gReport = reg.guardFn(guardExec('peer_send', { session_id: 'session-glob' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
|
|
521
703
|
ok(gReport === undefined, '项目会话向全局报告应放行', String(gReport));
|
|
522
704
|
const gPeer = reg.guardFn(guardExec('peer_send', { session_id: 'session-proj' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
|
|
523
|
-
ok(
|
|
705
|
+
ok(gPeer === undefined, '会话之间可直接对话,不必经全局转述', String(gPeer));
|
|
524
706
|
const gNew = reg.guardFn(guardExec('peer_new', { workspace: 'Demo库' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
|
|
525
|
-
ok(typeof gNew === 'string', '项目会话不得建平级会话', String(gNew));
|
|
707
|
+
ok(typeof gNew === 'string' && /仅属全局/.test(gNew), '项目会话不得建平级会话', String(gNew));
|
|
526
708
|
const gMisc = reg.guardFn(guardExec('peer_new', { workspace: 'Misc' }, 'session-misc', path.join(WS, '_Misc', 'tasks', '抢名额'), '杂项1'));
|
|
527
709
|
ok(gMisc === undefined, '杂项会话自建 Misc 换任应放行', String(gMisc));
|
|
528
710
|
}
|
|
@@ -558,6 +740,7 @@ const FIXTURE = {
|
|
|
558
740
|
generatedAt: '2026-11-07 21:25 +0800',
|
|
559
741
|
plugin: { name: '@mingmaple/dsh-handover', version: 'test', tools: 22, inject: ['tools', 'systemPrompt'], initErrors: [], scheduler: '就绪' },
|
|
560
742
|
workspace: { root: 'C:/ws', found: true },
|
|
743
|
+
globalSessions: 1,
|
|
561
744
|
guardLast: { allow: true, source: 'title', title: '全局1', sid: 'session-x' },
|
|
562
745
|
guide: payload ? payload.guide : { intro: ['说明'], situations: [{ when: 'w', say: 's', what: 'x' }], groups: [{ title: 'g', tools: [['ho_x', 'y']] }], rules: ['r'] },
|
|
563
746
|
extraPrompt: '',
|
|
@@ -641,7 +824,7 @@ for (const want of ['dsh-handover', '刷新', '读数', '压缩授权', '会话
|
|
|
641
824
|
// 回到重构前的版式——常用区直接展开,只折叠低频项;授权可直接点。
|
|
642
825
|
const sections = tree.kids.filter((k) => k && k.type === 'section');
|
|
643
826
|
const cardTitles = sections.map((sec) => textOf(sec.kids[0]).join(''));
|
|
644
|
-
const wantOrder = ['压缩授权', '移动全局根目录', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '插件', '
|
|
827
|
+
const wantOrder = ['压缩授权', '移动全局根目录', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '插件', '会话间通信守卫', '使用说明'];
|
|
645
828
|
ok(JSON.stringify(cardTitles) === JSON.stringify(wantOrder), '分区应自上而下为授权、迁移、角色、提示词、只读数据、插件、守卫、说明', JSON.stringify(cardTitles));
|
|
646
829
|
|
|
647
830
|
const folds = treeJson.split('"details"').length - 1;
|
|
@@ -688,12 +871,12 @@ const clientSrc = fs.readFileSync(path.join(dist, 'client.js'), 'utf8');
|
|
|
688
871
|
ok(!/[\u2705\u274C\u26A0\u2717]/.test(clientSrc), '界面不得使用图形结论符号');
|
|
689
872
|
ok(clientSrc.indexOf('delWord.trim() !== "删除"') !== -1, '删除旧副本须输入确认词');
|
|
690
873
|
|
|
691
|
-
//
|
|
874
|
+
// 未接入工作区:一键部署卡片置顶。
|
|
692
875
|
const NOT_READY = {
|
|
693
876
|
generatedAt: '2026-11-07 21:25 +0800',
|
|
694
877
|
plugin: { name: '@mingmaple/dsh-handover', version: 'test', tools: 22, inject: ['tools'], initErrors: [], scheduler: '就绪' },
|
|
695
878
|
workspace: { root: '', found: false },
|
|
696
|
-
setup: { steps: ['准备一个长期存在的目录', '
|
|
879
|
+
setup: { steps: ['准备一个长期存在的目录', '粘贴路径后点「一键部署」'] },
|
|
697
880
|
guide: payload ? payload.guide : null,
|
|
698
881
|
notes: ['未找到 _Control_Center:这个实例还没接入工作区。'],
|
|
699
882
|
};
|
|
@@ -704,8 +887,21 @@ const tree3 = Comp3({});
|
|
|
704
887
|
const sections3 = tree3.kids.filter((k) => k && k.type === 'section');
|
|
705
888
|
const titles3 = sections3.map((sec) => textOf(sec.kids[0]).join(''));
|
|
706
889
|
ok(titles3[0] === '首次安装', '未接入工作区时首次安装置顶', JSON.stringify(titles3));
|
|
707
|
-
|
|
708
|
-
ok(
|
|
890
|
+
const text3 = textOf(tree3).join(' ');
|
|
891
|
+
ok(text3.indexOf('一键部署') !== -1, '未接入时应能一键部署(建体系+注册工作区+建全局会话)');
|
|
892
|
+
ok(text3.indexOf('仅建立文件体系') !== -1, '未接入时仍可只建立文件体系');
|
|
893
|
+
ok(text3.indexOf('待处理') !== -1, '未接入时应显示待处理项');
|
|
894
|
+
|
|
895
|
+
// 已接入但没有全局会话:给出补建入口,且排在常规卡片之前。
|
|
896
|
+
const READY_NO_GLOBAL = Object.assign({}, FIXTURE, { globalSessions: 0 });
|
|
897
|
+
const exp4 = loaded.factory((name) => (name === 'react' ? fakeReact(READY_NO_GLOBAL) : (() => { throw new Error('x'); })));
|
|
898
|
+
let Comp4 = null;
|
|
899
|
+
exp4.apply({ slots: { inject: (n, cb) => cb(), register: (o, c) => { Comp4 = c; } } });
|
|
900
|
+
const tree4 = Comp4({});
|
|
901
|
+
const sections4 = tree4.kids.filter((k) => k && k.type === 'section');
|
|
902
|
+
const titles4 = sections4.map((sec) => textOf(sec.kids[0]).join(''));
|
|
903
|
+
ok(titles4[0] === '全局会话', '已接入但无全局会话时应给出补建卡片', JSON.stringify(titles4.slice(0, 3)));
|
|
904
|
+
ok(textOf(tree4).join(' ').indexOf('创建全局会话') !== -1, '补建卡片应提供「创建全局会话」按钮');
|
|
709
905
|
|
|
710
906
|
ok(styles.length === 1, '样式只注入一次(幂等)', String(styles.length));
|
|
711
907
|
|
|
@@ -809,6 +1005,20 @@ const delDeep = await mv.rmTree(path.join(mvRoot, 'deep'));
|
|
|
809
1005
|
ok(delDeep.ok && !fs.existsSync(path.join(mvRoot, 'deep')), 'rmTree 应删掉超长路径树', JSON.stringify(delDeep.fails));
|
|
810
1006
|
fs.rmSync(mvRoot, { recursive: true, force: true });
|
|
811
1007
|
|
|
1008
|
+
// 工作区与会话的显式化:create 支持 ensure;新建会话缺工作区必须报错并列出可选项。
|
|
1009
|
+
ctx.workspaceRegistry = { list: () => [], create: async (p, t) => ({ id: 'ws-test', title: t, path: p }) };
|
|
1010
|
+
const wsToolEnsure = reg.tools.find((t) => t.name === 'ho_workspace');
|
|
1011
|
+
const wsEnsureDir = path.join(BLANK, 'proj', 'Demo');
|
|
1012
|
+
const wsEnsureOut = await wsToolEnsure.execute({ action: 'create', path: wsEnsureDir, title: 'Demo', ensure: true });
|
|
1013
|
+
ok(/已注册工作区/.test(String(wsEnsureOut && wsEnsureOut.text)), 'ho_workspace create 应支持 ensure 建目录', String(wsEnsureOut && wsEnsureOut.text));
|
|
1014
|
+
ok(fs.existsSync(wsEnsureDir), 'ensure=true 应真的建出目录', wsEnsureDir);
|
|
1015
|
+
const sessToolWs = reg.tools.find((t) => t.name === 'ho_sessions');
|
|
1016
|
+
let newErr = '';
|
|
1017
|
+
try {
|
|
1018
|
+
await sessToolWs.execute({ action: 'new', confirm: true, title: '项目1' }, { agent: { session: { header: { id: 'session-x', cwd: BLANK } } } });
|
|
1019
|
+
} catch (e) { newErr = String(e && e.message ? e.message : e); }
|
|
1020
|
+
ok(/工作区/.test(newErr) && /已注册的工作区/.test(newErr), '新建会话缺工作区时应报错并列出可选工作区', newErr.slice(0, 160));
|
|
1021
|
+
|
|
812
1022
|
fs.rmSync(BLANK, { recursive: true, force: true });
|
|
813
1023
|
console.log('');
|
|
814
1024
|
if (fails.length) {
|
package/scripts/build.mjs
CHANGED
|
@@ -45,9 +45,12 @@ const distPkg = {
|
|
|
45
45
|
description: rootPkg.description,
|
|
46
46
|
license: rootPkg.license,
|
|
47
47
|
exports: { ".": "./index.mjs", "./client": "./client.js" },
|
|
48
|
+
...(rootPkg.engines ? { engines: rootPkg.engines } : {}),
|
|
48
49
|
dsh: {
|
|
49
50
|
bundle: { patch: "./cordis.patch.yml" },
|
|
50
51
|
client: rootPkg.dsh && rootPkg.dsh.client ? rootPkg.dsh.client : { platform: "web" },
|
|
52
|
+
// 兼容声明随构建产物走:安装面读的是 dist/package.json,不是对外仓库的根清单。
|
|
53
|
+
...(rootPkg.dsh && rootPkg.dsh.compatibility ? { compatibility: rootPkg.dsh.compatibility } : {}),
|
|
51
54
|
},
|
|
52
55
|
};
|
|
53
56
|
fs.writeFileSync(path.join(dist, "package.json"), JSON.stringify(distPkg, null, 2) + "\n");
|
package/scripts/check-docs.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 文档校验:工具名集合、旧数与旧术语、内部链接、版本、语体,以及版本说明的对外口吻。
|
|
3
3
|
* 只读,不做任何改写。退出码 0 表示全部通过。
|
|
4
4
|
*/
|
|
5
5
|
import fs from 'node:fs';
|
|
@@ -14,6 +14,8 @@ const readme = path.join(root, 'README.md');
|
|
|
14
14
|
const files = [readme]
|
|
15
15
|
.concat(listFiles(path.join(root, 'docs'), ['.md']))
|
|
16
16
|
.filter((f) => fs.existsSync(f));
|
|
17
|
+
const releasesDir = path.join(root, 'docs', 'releases');
|
|
18
|
+
const releaseFiles = files.filter((f) => path.dirname(f) === releasesDir);
|
|
17
19
|
const problems = [];
|
|
18
20
|
|
|
19
21
|
// 1. README 长度(对外首页承载全部说明,上限放宽)
|
|
@@ -22,11 +24,30 @@ if (readmeLines > 200) problems.push('README 应不超过 200 行(当前 ' + r
|
|
|
22
24
|
|
|
23
25
|
// 2. 旧数与旧术语
|
|
24
26
|
const FORBIDDEN = ['21 个工具', '27 个工具', '未分类', '追加系统提示词', '4,028', '4.6 KB', '每库 24 行', 'CHANGELOG'];
|
|
27
|
+
// 审阅语境与内部用语:这些词只应出现在桌面审阅材料里,不能进对外文档与版本说明。
|
|
28
|
+
const REVIEW_WORDS = ['过目', '待验收', '待您', '待你', '请确认', '麻烦您', '麻烦你', '假宿主', '桩服务', '桩实现'];
|
|
25
29
|
for (const file of files) {
|
|
26
30
|
const text = fs.readFileSync(file, 'utf8');
|
|
27
31
|
for (const w of FORBIDDEN) {
|
|
28
32
|
if (text.indexOf(w) !== -1) problems.push(path.relative(root, file) + ' 仍含旧表述「' + w + '」');
|
|
29
33
|
}
|
|
34
|
+
for (const w of REVIEW_WORDS) {
|
|
35
|
+
if (text.indexOf(w) !== -1) problems.push(path.relative(root, file) + ' 含审阅语境或内部用语「' + w + '」');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 2.1 版本说明是对外发布正文:不出现二人体,且每个已发布版本都要有一份。
|
|
40
|
+
for (const file of releaseFiles) {
|
|
41
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
42
|
+
const person = text.match(/[您你]/);
|
|
43
|
+
if (person) problems.push(path.relative(root, file) + ' 版本说明不应出现二人体(命中「' + person[0] + '」)');
|
|
44
|
+
if (!/^#\s+\S/.test(text)) problems.push(path.relative(root, file) + ' 版本说明应以一级标题开头');
|
|
45
|
+
}
|
|
46
|
+
{
|
|
47
|
+
const pkgVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
|
48
|
+
if (!fs.existsSync(path.join(releasesDir, pkgVersion + '.md'))) {
|
|
49
|
+
problems.push('缺少版本说明 docs/releases/' + pkgVersion + '.md(Release 正文从这里取)');
|
|
50
|
+
}
|
|
30
51
|
}
|
|
31
52
|
|
|
32
53
|
// 3. 工具名:文档里出现的 ho_* 与 peer_* 必须在 names.mjs 中
|
|
@@ -70,5 +91,5 @@ if (problems.length) {
|
|
|
70
91
|
for (const p of problems) console.log(' - ' + p);
|
|
71
92
|
process.exitCode = 1;
|
|
72
93
|
} else {
|
|
73
|
-
console.log('CHECK-DOCS OK(' + files.length + '
|
|
94
|
+
console.log('CHECK-DOCS OK(' + files.length + ' 份文档,含版本说明 ' + releaseFiles.length + ' 份,工具名 ' + ALL_TOOL_NAMES.length + ' 个)');
|
|
74
95
|
}
|