@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.
- package/README.md +46 -19
- package/docs/migrate.md +6 -6
- package/docs/releases/0.9.22.md +34 -0
- package/docs/troubleshoot.md +34 -10
- package/package.json +71 -71
- package/scripts/blank-dsh.mjs +29 -23
- package/scripts/blank-smoke.mjs +205 -87
- package/scripts/e2e-migration.mjs +2 -2
- package/scripts/office.mjs +3 -3
- package/src/client.js +159 -120
- package/src/index.mjs +65 -58
- package/src/lib/api.mjs +9 -7
- package/src/lib/brief.mjs +2 -2
- package/src/lib/config.mjs +3 -3
- package/src/lib/consent.mjs +54 -1
- package/src/lib/docmap.mjs +143 -0
- package/src/lib/gates.mjs +25 -1
- package/src/lib/guide.mjs +35 -44
- package/src/lib/lint.mjs +3 -2
- package/src/lib/manual.mjs +48 -9
- package/src/lib/mover.mjs +10 -10
- package/src/lib/names.mjs +14 -19
- package/src/lib/office.mjs +22 -14
- package/src/lib/pack-ops.mjs +2 -2
- package/src/lib/peer.mjs +4 -4
- package/src/lib/role.mjs +8 -5
- package/src/lib/scaffold.mjs +42 -20
- package/src/lib/schedule.mjs +1 -2
- package/src/lib/session-create.mjs +1 -1
- package/src/lib/session-ops.mjs +16 -16
- package/src/lib/sessions.mjs +1 -1
- package/src/lib/status.mjs +205 -45
- package/src/lib/succession.mjs +7 -7
- package/src/lib/zstd-log.mjs +1 -1
- package/src/selftest.mjs +6 -6
- package/src/templates/Prompt_Templates.md +13 -13
- package/src/templates/Runtime_Manual.md +56 -30
- package/src/templates/rules/Handover_Rules.md +2 -1
- package/src/templates/rules/Migration_Rules.md +1 -1
- package/src/templates/standards/README.md +25 -0
- package/src/tools/ho_backup.mjs +135 -0
- package/src/tools/ho_checkpoint.mjs +12 -11
- package/src/tools/ho_docmap.mjs +153 -0
- package/src/tools/ho_gate.mjs +2 -1
- package/src/tools/ho_office.mjs +1 -1
- package/src/tools/ho_sessions.mjs +3 -4
- package/src/tools/ho_status.mjs +246 -0
- package/src/tools/ho_succeed.mjs +1 -1
- package/src/tools/ho_workspace.mjs +86 -14
- package/src/tools/peer.mjs +205 -0
- package/src/tools/ho_brief.mjs +0 -57
- package/src/tools/ho_diag.mjs +0 -87
- package/src/tools/ho_drift.mjs +0 -41
- package/src/tools/ho_init.mjs +0 -82
- package/src/tools/ho_pack.mjs +0 -38
- package/src/tools/ho_phase.mjs +0 -37
- package/src/tools/ho_snapshot.mjs +0 -104
- package/src/tools/ho_tokens.mjs +0 -57
- package/src/tools/peer_list.mjs +0 -37
- package/src/tools/peer_new.mjs +0 -75
- package/src/tools/peer_peek.mjs +0 -41
- package/src/tools/peer_rename.mjs +0 -31
- package/src/tools/peer_send.mjs +0 -52
- package/src/tools/peer_status.mjs +0 -46
package/scripts/blank-smoke.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 空白环境冒烟:不依赖任何真实工作区,验「装上就能跑」。
|
|
3
|
-
* 1) 宿主半在假 ctx 上 apply:
|
|
3
|
+
* 1) 宿主半在假 ctx 上 apply:14 个工具、手册段、状态路由(200/404/405/HEAD)、只读工具不抛
|
|
4
4
|
* 2) 浏览器半在假 window/React 上装载:bundle 外形、settings.section 注册、成体视图能渲染
|
|
5
5
|
* 用法:node scripts/blank-smoke.mjs [--dist <dist 目录>]
|
|
6
6
|
*/
|
|
@@ -42,7 +42,7 @@ const ctx = {
|
|
|
42
42
|
};
|
|
43
43
|
apply(ctx, {});
|
|
44
44
|
|
|
45
|
-
ok(reg.tools.length ===
|
|
45
|
+
ok(reg.tools.length === 14, '工具数应为 14', String(reg.tools.length));
|
|
46
46
|
ok(reg.sections.length === 0, '未开启全局前置提示词时不得注册提示段', String(reg.sections.length));
|
|
47
47
|
ok(reg.tools.filter((t) => !t || !t.name || !t.description || !t.execute).length === 0, '每个工具都要有 name/description/execute');
|
|
48
48
|
// 上下文成本实测:工具描述是每次请求都要付的固定开销。参考值只用于展示,不作为通过条件。
|
|
@@ -52,11 +52,11 @@ const budget = await import(pathToFileURL(path.join(dist, 'lib', 'facts.mjs')).h
|
|
|
52
52
|
const widest = rows.slice().sort((a, b) => b.n - a.n)[0];
|
|
53
53
|
console.log('工具描述 token 实测:最宽 ' + widest.name + ' ' + widest.n + '(参考值 ' + budget.TOKEN_BUDGET.toolDescTokens + ',不判失败)');
|
|
54
54
|
}
|
|
55
|
-
ok(reg.tools.filter((t) => t.name.startsWith('ho_')).length ===
|
|
56
|
-
ok(reg.tools.filter((t) => t.name
|
|
55
|
+
ok(reg.tools.filter((t) => t.name.startsWith('ho_')).length === 13, 'ho_* 应为 13 个', String(reg.tools.filter((t) => t.name.startsWith('ho_')).length));
|
|
56
|
+
ok(reg.tools.filter((t) => t.name === 'peer').length === 1, '平级会话工具应合并为一个 peer');
|
|
57
57
|
// 每步条件启用:内核 0.2.2 起工具按「步」解析,子会话在解析工具表这一步就要被排除,
|
|
58
|
-
// 否则它的首轮提示词会带着
|
|
59
|
-
ok((reg.toolOpts || []).length ===
|
|
58
|
+
// 否则它的首轮提示词会带着 14 个用不了的 schema(每步白付约 1.5k token)。
|
|
59
|
+
ok((reg.toolOpts || []).length === 14 && (reg.toolOpts || []).every((o) => o && typeof o.when === 'function'), '14 个工具都应带每步启用条件', String((reg.toolOpts || []).length));
|
|
60
60
|
|
|
61
61
|
/** 取某个工具的每步启用条件(0.2.2 起工具按步解析)。 */
|
|
62
62
|
const gateFor = (name) => {
|
|
@@ -66,7 +66,7 @@ const gateFor = (name) => {
|
|
|
66
66
|
|
|
67
67
|
{
|
|
68
68
|
// 每步条件同时承担子会话排除与角色过滤:调用方每步重算工具表,换角色下一轮即生效。
|
|
69
|
-
const gate = gateFor('
|
|
69
|
+
const gate = gateFor('ho_status');
|
|
70
70
|
ok(typeof gate === 'function', '每步启用条件应可调用');
|
|
71
71
|
if (typeof gate === 'function') {
|
|
72
72
|
ok(gate({}) === true, '没有 agent 的调用应放行(命令行与演练路径)');
|
|
@@ -75,10 +75,10 @@ const gateFor = (name) => {
|
|
|
75
75
|
ok(gate({ agent: { session: { header: { id: 'session-plain', cwd: path.join(BLANK, 'nowhere') } } } }) === false, '未登记角色的会话按普通处理(0 个插件工具)');
|
|
76
76
|
const roleMod = await import(pathToFileURL(path.join(dist, 'lib', 'role.mjs')).href);
|
|
77
77
|
roleMod.writeRole({}, 'session-boss', { role: 'global' });
|
|
78
|
-
ok(gate({ agent: { session: { header: { id: 'session-boss', cwd: path.join(BLANK, 'nowhere') } } } }) === true, '全局角色应可见
|
|
79
|
-
const gatePlain = gateFor('
|
|
80
|
-
ok(typeof gatePlain === 'function' && gatePlain({ agent: { session: { header: { id: 'session-boss', cwd: path.join(BLANK, 'nowhere') } } } }) === true, '全局角色应可见
|
|
81
|
-
ok(gatePlain({ agent: { session: { header: { id: 'session-plain', cwd: path.join(BLANK, 'nowhere') } } } }) === false, '普通角色不可见
|
|
78
|
+
ok(gate({ agent: { session: { header: { id: 'session-boss', cwd: path.join(BLANK, 'nowhere') } } } }) === true, '全局角色应可见 ho_status');
|
|
79
|
+
const gatePlain = gateFor('ho_office');
|
|
80
|
+
ok(typeof gatePlain === 'function' && gatePlain({ agent: { session: { header: { id: 'session-boss', cwd: path.join(BLANK, 'nowhere') } } } }) === true, '全局角色应可见 ho_office');
|
|
81
|
+
ok(gatePlain({ agent: { session: { header: { id: 'session-plain', cwd: path.join(BLANK, 'nowhere') } } } }) === false, '普通角色不可见 ho_office');
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
// 可选注入多了 connection(/api 载体)。两个都必须是**可选**的,否则旧宿主整插件不激活。
|
|
@@ -149,11 +149,11 @@ let payload = null;
|
|
|
149
149
|
try { payload = JSON.parse(r1.body); } catch (e) { fails.push('返回不是 JSON:' + e.message); }
|
|
150
150
|
if (payload) {
|
|
151
151
|
ok(payload.plugin.version && payload.plugin.version !== '?', '应报出版本号', String(payload.plugin.version));
|
|
152
|
-
ok(payload.plugin.tools ===
|
|
152
|
+
ok(payload.plugin.tools === 14, 'payload 工具数应为 14', String(payload.plugin.tools));
|
|
153
153
|
ok(payload.workspace && payload.workspace.found === false, '空白 HOME 下应判定为没找到工作区', JSON.stringify(payload.workspace));
|
|
154
154
|
ok(Array.isArray(payload.notes) && payload.notes.length > 0, '没找到工作区时应给中文提示');
|
|
155
155
|
ok(payload.guide && Array.isArray(payload.guide.situations) && payload.guide.situations.length >= 8, 'payload 应带「怎么用」的时机表');
|
|
156
|
-
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) === 14, '工具明细里应列全 14 个工具', payload.guide && String(payload.guide.groups.reduce((a, g) => a + g.tools.length, 0)));
|
|
157
157
|
ok(payload.plugin.initErrors && payload.plugin.initErrors.length === 0, '初始化错误应为空', JSON.stringify(payload.plugin.initErrors));
|
|
158
158
|
}
|
|
159
159
|
ok((await call('GET', '/dsh-handover/nope.json')).code === 404, '未知路径应 404');
|
|
@@ -198,6 +198,23 @@ ok(fs.existsSync(path.join(root, '_Control_Center', 'rules', 'Workspace_Charter.
|
|
|
198
198
|
ok(fs.existsSync(path.join(root, '_Control_Center', 'templates', 'Prompt_Templates.md')), '建全局根:提示词模板应在');
|
|
199
199
|
ok(fs.existsSync(path.join(root, 'Projects')) && fs.existsSync(path.join(root, '_Project_Meta')), '建全局根:项目目录应在');
|
|
200
200
|
|
|
201
|
+
// 通用规范库:init 要铺出目录与说明;既有工作区靠 scaffold 补齐,且不得覆盖已有内容。
|
|
202
|
+
if (root) {
|
|
203
|
+
const standardsDir = path.join(root, '_Control_Center', 'standards');
|
|
204
|
+
const standardsReadme = path.join(standardsDir, 'README.md');
|
|
205
|
+
ok(fs.existsSync(standardsReadme), '建全局根:通用规范库的说明应在', standardsReadme);
|
|
206
|
+
const scafMod = await import(pathToFileURL(path.join(dist, 'lib', 'scaffold.mjs')).href);
|
|
207
|
+
const keep = '# 本库自己的说明' + '\n';
|
|
208
|
+
fs.writeFileSync(standardsReadme, keep, 'utf8');
|
|
209
|
+
fs.rmSync(path.join(root, '_Control_Center', 'tools'), { recursive: true, force: true });
|
|
210
|
+
const sc1 = scafMod.ensureScaffold(root);
|
|
211
|
+
ok(sc1.ok && sc1.created.indexOf('_Control_Center/tools') !== -1, 'scaffold 应补齐缺失的目录', JSON.stringify(sc1.created));
|
|
212
|
+
ok(fs.readFileSync(standardsReadme, 'utf8') === keep, 'scaffold 不得覆盖已有文件');
|
|
213
|
+
const sc2 = scafMod.ensureScaffold(root);
|
|
214
|
+
ok(sc2.ok && sc2.created.length === 0 && sc2.written.length === 0, 'scaffold 二次执行应无改动', JSON.stringify([sc2.created.length, sc2.written.length]));
|
|
215
|
+
ok(scafMod.ensureScaffold(path.join(BLANK, 'nowhere-at-all')).ok === false, 'scaffold 对不存在的根应报错');
|
|
216
|
+
}
|
|
217
|
+
|
|
201
218
|
// cwd 已失效(旧路径被删)时,根解析必须退回登记表,不能在幽灵路径上重建 _Control_Center
|
|
202
219
|
if (root) {
|
|
203
220
|
const regDir0 = path.join(BLANK, '.dsh', 'storages');
|
|
@@ -252,18 +269,18 @@ ok(e3.code === 200 && e3.json && e3.json.ok && e3.json.chars === 0 && e3.json.en
|
|
|
252
269
|
const s4 = await postJson('/dsh-handover/register', { path: root || PARENT });
|
|
253
270
|
ok(s4.code === 501 && s4.json && s4.json.ok === false && String(s4.json.hint || '').indexOf(root || PARENT) !== -1, '无 workspaceRegistry 时应回退给「手工粘贴路径」的提示', JSON.stringify(s4.json));
|
|
254
271
|
|
|
255
|
-
const initTool = reg.tools.find((t) => t.name === '
|
|
256
|
-
ok(!!initTool, '应注册
|
|
272
|
+
const initTool = reg.tools.find((t) => t.name === 'ho_workspace');
|
|
273
|
+
ok(!!initTool, '应注册 ho_workspace(含首次安装 action=init)');
|
|
257
274
|
if (initTool) {
|
|
258
275
|
const PARENT2 = path.join(BLANK, 'mother2');
|
|
259
276
|
fs.mkdirSync(PARENT2, { recursive: true });
|
|
260
|
-
const ri = await initTool.execute({ parent: PARENT2 }, null);
|
|
261
|
-
ok(/已建立全局文件体系/.test(ri.text), '
|
|
262
|
-
const ri2 = await initTool.execute({ parent: PARENT2 }, null);
|
|
263
|
-
ok(/未建立/.test(ri2.text), '
|
|
277
|
+
const ri = await initTool.execute({ action: 'init', parent: PARENT2 }, null);
|
|
278
|
+
ok(/已建立全局文件体系/.test(ri.text), 'ho_workspace(action=init) 应建树并报出路径', ri.text.split('\n')[0]);
|
|
279
|
+
const ri2 = await initTool.execute({ action: 'init', parent: PARENT2 }, null);
|
|
280
|
+
ok(/未建立/.test(ri2.text), 'ho_workspace(action=init) 对非空目标应拒绝且不改动', ri2.text.split('\n')[0]);
|
|
264
281
|
}
|
|
265
282
|
|
|
266
|
-
const readonly = ['
|
|
283
|
+
const readonly = ['ho_status', 'ho_sessions', 'ho_gate', 'ho_charter', 'ho_workspace'];
|
|
267
284
|
for (const t of reg.tools) {
|
|
268
285
|
if (readonly.indexOf(t.name) === -1) continue;
|
|
269
286
|
try {
|
|
@@ -285,7 +302,7 @@ const { resolveConfig } = await import(DIST('lib/config.mjs'));
|
|
|
285
302
|
const WS = path.join(BLANK, 'ws');
|
|
286
303
|
fs.mkdirSync(path.join(WS, '_Control_Center'), { recursive: true });
|
|
287
304
|
fs.mkdirSync(path.join(WS, 'Projects', 'Demo', 'Demo库'), { recursive: true });
|
|
288
|
-
fs.mkdirSync(path.join(WS, '_Misc', 'tasks', '
|
|
305
|
+
fs.mkdirSync(path.join(WS, '_Misc', 'tasks', '示例任务'), { recursive: true });
|
|
289
306
|
|
|
290
307
|
// ---------- 在任复用:工具层前置校验(任何写入之前返回) ----------
|
|
291
308
|
{
|
|
@@ -305,7 +322,7 @@ fs.mkdirSync(path.join(WS, '_Misc', 'tasks', '抢名额'), { recursive: true });
|
|
|
305
322
|
} catch (e) { return String(e && e.message ? e.message : e); }
|
|
306
323
|
};
|
|
307
324
|
const lowErr = await newErr({});
|
|
308
|
-
ok(/已有在任会话 Demo1/.test(lowErr) && /session-hold/.test(lowErr) && /
|
|
325
|
+
ok(/已有在任会话 Demo1/.test(lowErr) && /session-hold/.test(lowErr) && /peer/.test(lowErr) && /force=true/.test(lowErr), '项目类别有低水位在任时应拒绝新建并给出复用建议', lowErr);
|
|
309
326
|
const forcedErr = await newErr({ force: true });
|
|
310
327
|
ok(forcedErr.indexOf('已有在任会话') === -1, 'force=true 应越过复用判据', forcedErr);
|
|
311
328
|
|
|
@@ -337,11 +354,11 @@ const rTitle = resolveRole({ sessionId: 'session-t1', title: '全局9' });
|
|
|
337
354
|
ok(rTitle.role === 'global' && rTitle.source === 'title', '角色判定:标题含「全局」→ global', JSON.stringify(rTitle));
|
|
338
355
|
const rCwd = resolveRole({ sessionId: 'session-t2', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库') });
|
|
339
356
|
ok(rCwd.role === 'project' && rCwd.project === 'Demo', '角色判定:cwd 在 Projects/<名>/ → project', JSON.stringify(rCwd));
|
|
340
|
-
const rMisc = resolveRole({ sessionId: 'session-t3', title: '杂项1', cwd: path.join(WS, '_Misc', 'tasks', '
|
|
341
|
-
ok(rMisc.role === 'misc' && rMisc.task === '
|
|
342
|
-
const rPrompt = resolveRole({ sessionId: 'session-t4', title: '', firstPrompt: '先看 ' + path.join(WS, 'Projects', '
|
|
343
|
-
ok(rPrompt.role === 'project' && rPrompt.project === '
|
|
344
|
-
const rPlain = resolveRole({ sessionId: 'session-t5', title: '
|
|
357
|
+
const rMisc = resolveRole({ sessionId: 'session-t3', title: '杂项1', cwd: path.join(WS, '_Misc', 'tasks', '示例任务') });
|
|
358
|
+
ok(rMisc.role === 'misc' && rMisc.task === '示例任务', '角色判定:cwd 在 _Misc/tasks/<任务>/ → misc', JSON.stringify(rMisc));
|
|
359
|
+
const rPrompt = resolveRole({ sessionId: 'session-t4', title: '', firstPrompt: '先看 ' + path.join(WS, 'Projects', 'Demo2', 'Demo2库') + ' 里的内容' });
|
|
360
|
+
ok(rPrompt.role === 'project' && rPrompt.project === 'Demo2', '角色判定:首条提示里的 Projects/<名> → project', JSON.stringify(rPrompt));
|
|
361
|
+
const rPlain = resolveRole({ sessionId: 'session-t5', title: '未命名', cwd: '/tmp' });
|
|
345
362
|
ok(rPlain.role === 'plain', '角色判定:都不中 → plain(普通会话)', JSON.stringify(rPlain));
|
|
346
363
|
|
|
347
364
|
writeRole({}, 'session-t6', { role: 'misc', task: 'X', source: 'user' });
|
|
@@ -351,7 +368,7 @@ ok(readRoles({}).sessions['session-t6'] && readRoles({}).sessions['session-t6'].
|
|
|
351
368
|
|
|
352
369
|
// 回归锁:首条提示里的 `Projects/**` 通配曾被当成项目名 `**、_Project_Meta`,
|
|
353
370
|
// 于是交接路径、备份目录、归档目录全落在这个 Windows 非法名字上(实测四类报错)。
|
|
354
|
-
const rGlob = resolveRole({ sessionId: 'session-glob-prompt', title: '
|
|
371
|
+
const rGlob = resolveRole({ sessionId: 'session-glob-prompt', title: '普通会话', cwd: 'C:/Users/x/.dsh/profiles/desktop', firstPrompt: '- 动 _Control_Center/**、Projects/**、_Project_Meta/** 前先问用户。' });
|
|
355
372
|
ok(rGlob.role === 'plain', '首条提示里的 Projects/** 通配不得被当成项目名', JSON.stringify(rGlob));
|
|
356
373
|
const hpBad = handoverPathFor(WS, { role: 'project', project: '**、_Project_Meta' });
|
|
357
374
|
ok(hpBad.path === null && /不合法/.test(String(hpBad.reason || '')), '非法项目名不得拼出交接路径', JSON.stringify(hpBad));
|
|
@@ -366,25 +383,79 @@ const hpG = handoverPathFor(WS, { role: 'global' });
|
|
|
366
383
|
ok(/Global_Handover\.md$/.test(hpG.path), '全局交接路径', hpG.path);
|
|
367
384
|
const hpP = handoverPathFor(WS, { role: 'project', project: 'Demo' });
|
|
368
385
|
ok(/_Project_Meta/.test(hpP.path) && /Demo/.test(hpP.path) && /Handover\.md$/.test(hpP.path), '项目交接路径应落 _Project_Meta/<项目>/Handover.md', hpP.path);
|
|
369
|
-
const hpM = handoverPathFor(WS, { role: 'misc', task: '
|
|
370
|
-
ok(/_Misc/.test(hpM.path) &&
|
|
386
|
+
const hpM = handoverPathFor(WS, { role: 'misc', task: '示例任务' });
|
|
387
|
+
ok(/_Misc/.test(hpM.path) && /示例任务/.test(hpM.path) && /Handover\.md$/.test(hpM.path), '杂项交接路径应落 _Misc/tasks/<任务>/Handover.md', hpM.path);
|
|
371
388
|
const hpU = handoverPathFor(WS, { role: 'plain' });
|
|
372
389
|
ok(hpU.path === null && !!hpU.reason, '普通会话不得给出交接路径(不退回全局)', JSON.stringify(hpU));
|
|
373
390
|
|
|
374
391
|
const tsG = toolsetFor('global');
|
|
375
|
-
ok(tsG.allow.length ===
|
|
392
|
+
ok(tsG.allow.length === 14 && tsG.deny.length === 0, '全局应可见全部 14 个工具', JSON.stringify([tsG.allow.length, tsG.deny.length]));
|
|
376
393
|
const tsP = toolsetFor('project');
|
|
377
394
|
ok(tsP.allow.length === 11, '项目角色应可见 11 个工具', String(tsP.allow.length));
|
|
378
395
|
ok(tsP.allow.indexOf('ho_handover') !== -1 && tsP.allow.indexOf('ho_checkpoint') !== -1, '项目角色必须能写自己的交接与压缩');
|
|
379
|
-
ok(tsP.allow.indexOf('
|
|
396
|
+
ok(tsP.allow.indexOf('ho_sessions') === -1 && tsP.allow.indexOf('ho_changelog') === -1, '项目角色不该看见全局管理工具');
|
|
380
397
|
ok(tsP.deny.indexOf('ho_handover') === -1 && tsP.deny.indexOf('run_code') === -1, 'deny 名单不得含保留名或本角色已允许的工具');
|
|
381
398
|
const tsM = toolsetFor('misc');
|
|
382
|
-
ok(tsM.allow.length ===
|
|
399
|
+
ok(tsM.allow.length === 10 && tsM.allow.indexOf('peer') !== -1 && tsM.allow.indexOf('ho_gate') === -1, '杂项角色:10 个,含 peer、不含 ho_gate', String(tsM.allow.length));
|
|
383
400
|
const tsU = toolsetFor('plain');
|
|
384
|
-
ok(tsU.allow.length === 0 && tsU.deny.length ===
|
|
401
|
+
ok(tsU.allow.length === 0 && tsU.deny.length === 14, '普通会话不载入任何插件工具', JSON.stringify([tsU.allow.length, tsU.deny.length]));
|
|
385
402
|
const rSub = resolveRole({ sessionId: 'session-sub', title: 'Demo1', cwd: path.join(WS, 'Projects', 'Demo', 'Demo库'), subagent: true });
|
|
386
403
|
ok(rSub.role === 'plain' && rSub.source === 'subagent', '子会话默认按普通处理,不按 cwd 判项目', JSON.stringify(rSub));
|
|
387
404
|
|
|
405
|
+
// ---------- 文档定位(docmap)与授权提示:写入权限、路径校验、空 map、注入 ----------
|
|
406
|
+
{
|
|
407
|
+
const { pmDocmapTool } = await import(DIST('tools/ho_docmap.mjs'));
|
|
408
|
+
const { registerHolder } = await import(DIST('lib/office.mjs'));
|
|
409
|
+
const { docmapBlock, docmapRelPath, readDocmap } = await import(DIST('lib/docmap.mjs'));
|
|
410
|
+
const manDm = await import(DIST('lib/manual.mjs'));
|
|
411
|
+
const { grantConsent, consumedConsent, activeConsentsFor, consentPromptLines } = await import(DIST('lib/consent.mjs'));
|
|
412
|
+
|
|
413
|
+
const dmInfo = { role: 'project', project: 'Demo', task: '' };
|
|
414
|
+
const dmEnv = {
|
|
415
|
+
cfg: resolveConfig({ workspaceRoot: WS }),
|
|
416
|
+
resolveWs: () => WS,
|
|
417
|
+
selfId: (exec) => (exec && exec.agent && exec.agent.session ? exec.agent.session.header.id : null),
|
|
418
|
+
roleOf: () => dmInfo,
|
|
419
|
+
refreshPromptFor: () => true,
|
|
420
|
+
};
|
|
421
|
+
const dmExec = (sid) => ({ agent: { session: { header: { id: sid, cwd: path.join(WS, 'Projects', 'Demo', 'Demo库') } } } });
|
|
422
|
+
fs.writeFileSync(path.join(WS, 'Projects', 'Demo', 'README.md'), 'x', 'utf8');
|
|
423
|
+
registerHolder(WS, { kind: 'project', project: 'Demo', name: 'Demo1', sessionId: 'session-dm', workspace: WS });
|
|
424
|
+
const dm = pmDocmapTool(dmEnv);
|
|
425
|
+
const wroteDm = await dm.execute({ action: 'write', entries: [{ path: 'Projects/Demo/README.md', about: '项目简介', when: '动手前' }] }, dmExec('session-dm'));
|
|
426
|
+
ok(/已写入/.test(wroteDm.text), 'docmap:在任者写自己的 map 应成功', wroteDm.text.split('\n')[0]);
|
|
427
|
+
const readBack = readDocmap(WS, dmInfo);
|
|
428
|
+
ok(readBack.exists && readBack.entries.length === 1 && readBack.entries[0].path === 'Projects/Demo/README.md', 'docmap:写盘后可解析回一条', JSON.stringify(readBack.entries));
|
|
429
|
+
const dmBlock = docmapBlock(WS, dmInfo);
|
|
430
|
+
ok(/文档定位/.test(dmBlock) && /Projects\/Demo\/README\.md/.test(dmBlock) && /动手前/.test(dmBlock), 'docmap:注入块含路径与时机', dmBlock);
|
|
431
|
+
const injectedDm = manDm.scopedManual(dmInfo, { ws: WS, docmap: dmBlock });
|
|
432
|
+
ok(injectedDm.indexOf('文档定位') !== -1 && injectedDm.split('文档定位').length === 2, 'docmap:注入段只出现一次', String(injectedDm.split('文档定位').length - 1));
|
|
433
|
+
let missErr = '';
|
|
434
|
+
try { await dm.execute({ action: 'write', entries: [{ path: 'Projects/Demo/nope.md', about: 'x' }] }, dmExec('session-dm')); } catch (e) { missErr = String(e && e.message ? e.message : e); }
|
|
435
|
+
ok(/未写入/.test(missErr) && /nope\.md/.test(missErr), 'docmap:路径不存在应整条拒绝并点名', missErr);
|
|
436
|
+
ok(readDocmap(WS, dmInfo).entries.length === 1, 'docmap:拒绝后原 map 不得被改');
|
|
437
|
+
registerHolder(WS, { kind: 'project', project: 'Demo', name: 'Demo2', sessionId: 'session-dm2', workspace: WS });
|
|
438
|
+
let oldErr = '';
|
|
439
|
+
try { await dm.execute({ action: 'write', entries: [{ path: 'Projects/Demo/README.md', about: 'x' }] }, dmExec('session-dm')); } catch (e) { oldErr = String(e && e.message ? e.message : e); }
|
|
440
|
+
ok(/只有在任会话可以写/.test(oldErr), 'docmap:已交出的旧会话只读', oldErr);
|
|
441
|
+
const emptyDm = await dm.execute({ action: 'write', entries: [] }, dmExec('session-dm2'));
|
|
442
|
+
ok(/空 map/.test(emptyDm.text), 'docmap:空 entries 应写明不注入', emptyDm.text);
|
|
443
|
+
ok(docmapBlock(WS, dmInfo) === '', 'docmap:空 map 注入块为空串');
|
|
444
|
+
ok(docmapRelPath(dmInfo).replace(/\\/g, '/') === '_Control_Center/records/docmap/project-Demo.md', 'docmap:文件路径按会话命名', docmapRelPath(dmInfo));
|
|
445
|
+
|
|
446
|
+
grantConsent('session-dm2', 'blank-smoke', 'compact');
|
|
447
|
+
const actDm = activeConsentsFor('session-dm2');
|
|
448
|
+
const consentLines = consentPromptLines(actDm);
|
|
449
|
+
ok(actDm.length === 1 && consentLines.length === 1 && /用户已授权压缩/.test(consentLines[0]), '授权:有效期内应生成一行提示', JSON.stringify(consentLines));
|
|
450
|
+
ok(activeConsentsFor('session-other').length === 0, '授权:别的会话看不到', JSON.stringify(activeConsentsFor('session-other')));
|
|
451
|
+
const withConsent = manDm.scopedManual(dmInfo, { ws: WS, docmap: '', consents: actDm });
|
|
452
|
+
ok(withConsent.indexOf('用户已授权压缩') !== -1, '授权:提示段应带那一行', withConsent.slice(-160));
|
|
453
|
+
consumedConsent('session-dm2', 'compact');
|
|
454
|
+
ok(consentPromptLines(activeConsentsFor('session-dm2')).length === 0, '授权:用掉后提示消失(不占位)');
|
|
455
|
+
const afterUse = manDm.scopedManual(dmInfo, { ws: WS, docmap: '', consents: activeConsentsFor('session-dm2') });
|
|
456
|
+
ok(afterUse.indexOf('已授权') === -1, '授权:用掉后提示段不再有该行');
|
|
457
|
+
}
|
|
458
|
+
|
|
388
459
|
// ---------- 骨架档案:预设、默认骨架、注册、校验 ----------
|
|
389
460
|
const prof = await import(DIST('lib/profile.mjs'));
|
|
390
461
|
const pjs = prof.presets();
|
|
@@ -467,8 +538,8 @@ ok(tg.indexOf('收尾并停止') !== -1, '续跑文案应允许交接无待办
|
|
|
467
538
|
const globName = io.archiveFileName('E:/ws/_Control_Center/records/Global_Handover.md', stamp);
|
|
468
539
|
ok(globName === 'Global_Handover_' + stamp + '.md', '全局归档名应为 Global_Handover_<时间>.md', globName);
|
|
469
540
|
const ad = await import(pathToFileURL(path.join(dist, 'lib', 'role.mjs')).href);
|
|
470
|
-
const miscDir = ad.archiveDirFor(WS, { role: 'misc', task: '
|
|
471
|
-
ok(miscDir.endsWith('_Misc/tasks
|
|
541
|
+
const miscDir = ad.archiveDirFor(WS, { role: 'misc', task: '示例任务' }).replace(/\\/g, '/');
|
|
542
|
+
ok(miscDir.endsWith('_Misc/tasks/示例任务/_archive'), '杂项归档目录应在任务目录内(_archive)', miscDir);
|
|
472
543
|
const handTool = reg.tools.find((t) => t.name === 'ho_handover');
|
|
473
544
|
ok(!!handTool, '应注册 ho_handover');
|
|
474
545
|
if (handTool) {
|
|
@@ -499,19 +570,21 @@ const projAgent = {
|
|
|
499
570
|
};
|
|
500
571
|
for (const cb of listeners) cb({ agent: projAgent, source: 'fresh' });
|
|
501
572
|
ok(calls.restrict.length === 0, '按步路径不用 restrict 收窄工具', String(calls.restrict.length));
|
|
502
|
-
const projGatePack = gateFor('
|
|
573
|
+
const projGatePack = gateFor('ho_sessions');
|
|
503
574
|
const projGateHand = gateFor('ho_handover');
|
|
504
575
|
const projExec = { agent: projAgent };
|
|
505
|
-
ok(typeof projGatePack === 'function' && projGatePack(projExec) === false, '项目角色不可见
|
|
576
|
+
ok(typeof projGatePack === 'function' && projGatePack(projExec) === false, '项目角色不可见 ho_sessions(每步条件)');
|
|
506
577
|
ok(typeof projGateHand === 'function' && projGateHand(projExec) === true, '项目角色可见 ho_handover(每步条件)');
|
|
507
|
-
ok(gateFor('
|
|
578
|
+
ok(gateFor('ho_changelog')(projExec) === false, '项目角色不可见 ho_changelog');
|
|
508
579
|
ok(calls.sections.length === 1 && calls.sections[0].name === 'dsh-handover:manual', '应注册同名提示段遮蔽兜底段');
|
|
509
580
|
const secNorm = String(calls.sections[0].text).replace(/\\/g, '/');
|
|
510
581
|
ok(secNorm.indexOf('Projects/Demo') !== -1 && secNorm.indexOf('_Project_Meta/Demo/Handover.md') !== -1, '项目提示段应含自己的路径', secNorm.slice(0, 200));
|
|
511
582
|
ok(secNorm.indexOf('任务完成不报全局') !== -1, '项目提示段应写明任务完成不向全局报告', secNorm.slice(0, 400));
|
|
512
583
|
ok(secNorm.indexOf('任务达成') === -1, '项目提示段不得再出现任务达成');
|
|
513
|
-
ok(secNorm.indexOf('可直接
|
|
584
|
+
ok(secNorm.indexOf('可直接 peer') !== -1, '项目提示段应允许会话之间直接对话', secNorm.slice(0, 400));
|
|
514
585
|
ok(secNorm.indexOf('resume=wait') !== -1, '项目提示段应说明收尾压缩不唤醒');
|
|
586
|
+
ok(secNorm.indexOf('_Control_Center/standards/') !== -1, '项目提示段应给出通用规范的查找范围', secNorm.slice(-240));
|
|
587
|
+
ok(secNorm.indexOf('把某某写成规范') !== -1 && secNorm.indexOf('本会话所在的工作区') !== -1 && secNorm.indexOf('写入通用规范库') !== -1 && secNorm.indexOf('报全局') !== -1, '项目提示段应写明未指明写本库、通用规范库报全局', secNorm.slice(-400));
|
|
515
588
|
console.log('项目提示段 token 实测:' + budget.estimateTokens(calls.sections[0].text) + '(参考值 ' + budget.TOKEN_BUDGET.projectTokens + ',不判失败)');
|
|
516
589
|
ok(calls.sections[0].text.indexOf('子智能体') === -1, '项目段不得携带子智能体派发纪律(不属于本插件)');
|
|
517
590
|
// 真实工作区路径 + 迁移提示是最坏情形,参考值另设一档
|
|
@@ -572,8 +645,10 @@ ok(gCalls.injected === 0, '全局不再重复注入工作流(提示段里已
|
|
|
572
645
|
// 分层护栏——常驻段只留判定,工作流细节必须留在手册里
|
|
573
646
|
{
|
|
574
647
|
const g0 = String(gCalls.sections[0].text);
|
|
575
|
-
ok(g0.indexOf('
|
|
648
|
+
ok(g0.indexOf('ho_backup') === -1 && g0.indexOf('move-probe') === -1, '常驻段不得回灌工作流细节(应放手册)');
|
|
576
649
|
ok(g0.indexOf('ho_charter(doc="runtime", section=…') !== -1, '常驻段应给出按节取用的指针');
|
|
650
|
+
ok(g0.indexOf('_Control_Center/standards/') !== -1, '全局段应给出通用规范的查找范围', g0.slice(-240));
|
|
651
|
+
ok(g0.indexOf('把某某写成规范') !== -1 && g0.indexOf('写入通用规范库') !== -1 && g0.indexOf('本会话所在的工作区') !== -1, '全局段应写明规范的写入范围', g0.slice(-400));
|
|
577
652
|
const rm = fs.readFileSync(path.join(dist, 'templates', 'Runtime_Manual.md'), 'utf8');
|
|
578
653
|
ok(rm.indexOf('## 11. 首次安装') !== -1 && rm.indexOf('## 12. 提示词预算') !== -1, '手册应含首次安装与提示词预算两节');
|
|
579
654
|
ok(rm.indexOf('续用优先') !== -1, '手册 §5 应含子智能体续用纪律');
|
|
@@ -601,14 +676,14 @@ ok(gCalls.injected === 0, '全局不再重复注入工作流(提示段里已
|
|
|
601
676
|
ok(uiHits.length === 0, '界面文案应一律使用标准书面语(词表与符号扫描)', fmt(uiHits));
|
|
602
677
|
ok(docHits.length === 0, '文档文案应一律使用标准书面语(词表与符号扫描)', fmt(docHits));
|
|
603
678
|
}
|
|
604
|
-
ok(opsSrc.indexOf('不得自行开展作业') !== -1 && opsSrc.indexOf('
|
|
679
|
+
ok(opsSrc.indexOf('不得自行开展作业') !== -1 && opsSrc.indexOf('待当前用户指派') !== -1, '启动提示词应先报告再待指派');
|
|
605
680
|
}
|
|
606
681
|
|
|
607
682
|
// 按步路径(0.2.2 起):创建即按角色登记提示段;工具可见性由每步条件算,不再调用 restrict。
|
|
608
683
|
const unrCalls = { restrict: 0, section: null };
|
|
609
684
|
const unrAgent = {
|
|
610
685
|
id: 'session-unr',
|
|
611
|
-
session: { header: { id: 'session-unr', title: '
|
|
686
|
+
session: { header: { id: 'session-unr', title: '未命名', cwd: '/tmp/x' } },
|
|
612
687
|
ctx: {
|
|
613
688
|
tools: { restrict: () => { unrCalls.restrict++; return () => { }; } },
|
|
614
689
|
systemPrompt: { section: (o) => { unrCalls.section = o; return () => { }; } },
|
|
@@ -625,7 +700,7 @@ writeRole({}, 'session-unr', { role: 'project', project: 'Demo', source: 'user'
|
|
|
625
700
|
for (const cb of ((reg.listeners && reg.listeners['agent/status']) || [])) cb({ agent: unrAgent, status: 'running' });
|
|
626
701
|
ok(!!unrCalls.section && /项目/.test(String(unrCalls.section.text)), '开轮前核对应按新角色补装提示段', JSON.stringify(unrCalls.section && unrCalls.section.name));
|
|
627
702
|
|
|
628
|
-
// 旧内核(没有 tools.resolve)才走 restrict:创建时按普通收窄
|
|
703
|
+
// 旧内核(没有 tools.resolve)才走 restrict:创建时按普通收窄 14 个,改角色后开轮前换成 3 个。
|
|
629
704
|
{
|
|
630
705
|
const legacyCalls = { restrict: 0, lastDeny: null, disposed: 0 };
|
|
631
706
|
const legacyListeners = {};
|
|
@@ -651,10 +726,10 @@ ok(!!unrCalls.section && /项目/.test(String(unrCalls.section.text)), '开轮
|
|
|
651
726
|
ctx: { tools: legacyTools, systemPrompt: legacyCtx.systemPrompt },
|
|
652
727
|
};
|
|
653
728
|
for (const cb of (legacyListeners['agent/created'] || [])) cb({ agent: legacyAgent, source: 'fresh' });
|
|
654
|
-
ok(legacyCalls.restrict === 1 && legacyCalls.lastDeny && legacyCalls.lastDeny.length ===
|
|
729
|
+
ok(legacyCalls.restrict === 1 && legacyCalls.lastDeny && legacyCalls.lastDeny.length === 14, '旧内核:创建时按普通收窄 14 个', JSON.stringify(legacyCalls.lastDeny && legacyCalls.lastDeny.length));
|
|
655
730
|
writeRole({}, 'session-legacy', { role: 'project', project: 'Demo', source: 'user' });
|
|
656
731
|
for (const cb of (legacyListeners['agent/status'] || [])) cb({ agent: legacyAgent, status: 'running' });
|
|
657
|
-
ok(legacyCalls.restrict === 2 && legacyCalls.lastDeny && legacyCalls.lastDeny.length ===
|
|
732
|
+
ok(legacyCalls.restrict === 2 && legacyCalls.lastDeny && legacyCalls.lastDeny.length === 3, '旧内核:开轮前换成项目角色的 3 个', JSON.stringify(legacyCalls.lastDeny && legacyCalls.lastDeny.length));
|
|
658
733
|
ok(legacyCalls.disposed === 1, '旧内核:换角色应撤销旧限制', String(legacyCalls.disposed));
|
|
659
734
|
}
|
|
660
735
|
// ---------- 角色路由 ----------
|
|
@@ -679,7 +754,7 @@ const oBad = await orderCall({ action: 'order', order: 'wave', session_id: 'sess
|
|
|
679
754
|
ok(/只能是 handover 或 compact/.test(oBad), 'order 取值应受限', oBad);
|
|
680
755
|
// 交接/压缩指令必须先问用户
|
|
681
756
|
const oNoConfirm = await orderCall({ action: 'order', order: 'handover', session_id: 'session-norole' });
|
|
682
|
-
ok(
|
|
757
|
+
ok(/需要当前用户确认/.test(oNoConfirm), 'order 未确认应拒绝', oNoConfirm);
|
|
683
758
|
const oUnr = await orderCall({ action: 'order', order: 'handover', session_id: 'session-norole', confirm: true });
|
|
684
759
|
ok(/没有交接路径/.test(oUnr), '未设角色的会话不能下交接指令(避免退回全局交接)', oUnr);
|
|
685
760
|
const oCold = await orderCall({ action: 'order', order: 'compact', session_id: 'session-r1', confirm: true });
|
|
@@ -697,15 +772,15 @@ const guardExec = (name, argsObj, sid, cwd, title) => ({
|
|
|
697
772
|
});
|
|
698
773
|
ok(typeof reg.guardFn === 'function', '应注册工具守卫', typeof reg.guardFn);
|
|
699
774
|
if (typeof reg.guardFn === 'function') {
|
|
700
|
-
const gGlobal = reg.guardFn(guardExec('
|
|
701
|
-
ok(gGlobal === undefined, '全局角色
|
|
702
|
-
const gReport = reg.guardFn(guardExec('
|
|
775
|
+
const gGlobal = reg.guardFn(guardExec('peer', { action: 'send', session_id: 'session-proj-x' }, 'session-glob', WS, '全局9'));
|
|
776
|
+
ok(gGlobal === undefined, '全局角色 peer(action=send) 应放行', String(gGlobal));
|
|
777
|
+
const gReport = reg.guardFn(guardExec('peer', { action: 'send', session_id: 'session-glob' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
|
|
703
778
|
ok(gReport === undefined, '项目会话向全局报告应放行', String(gReport));
|
|
704
|
-
const gPeer = reg.guardFn(guardExec('
|
|
779
|
+
const gPeer = reg.guardFn(guardExec('peer', { action: 'send', session_id: 'session-proj' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
|
|
705
780
|
ok(gPeer === undefined, '会话之间可直接对话,不必经全局转述', String(gPeer));
|
|
706
|
-
const gNew = reg.guardFn(guardExec('
|
|
781
|
+
const gNew = reg.guardFn(guardExec('peer', { action: 'new', workspace: 'Demo库' }, 'session-proj', path.join(WS, 'Projects', 'Demo', 'Demo库'), 'Demo1'));
|
|
707
782
|
ok(typeof gNew === 'string' && /仅属全局/.test(gNew), '项目会话不得建平级会话', String(gNew));
|
|
708
|
-
const gMisc = reg.guardFn(guardExec('
|
|
783
|
+
const gMisc = reg.guardFn(guardExec('peer', { action: 'new', workspace: 'Misc' }, 'session-misc', path.join(WS, '_Misc', 'tasks', '示例任务'), '杂项1'));
|
|
709
784
|
ok(gMisc === undefined, '杂项会话自建 Misc 换任应放行', String(gMisc));
|
|
710
785
|
}
|
|
711
786
|
|
|
@@ -732,7 +807,7 @@ const plainRows = [];
|
|
|
732
807
|
for (let i = 0; i < 10; i++) {
|
|
733
808
|
plainRows.push({
|
|
734
809
|
sid: 'session-p' + i, title: '旧会话' + i, role: 'plain', roleLabel: '普通', source: 'none',
|
|
735
|
-
project: '', task: '', pct: i + 1, state: 'idle', active: false, seen: '2026-10-21 20:19 +0800',
|
|
810
|
+
project: '', task: '', wsKey: '', wsTitle: '', wsPath: '', pct: i + 1, state: 'idle', active: false, onduty: false, seen: '2026-10-21 20:19 +0800',
|
|
736
811
|
});
|
|
737
812
|
}
|
|
738
813
|
|
|
@@ -745,22 +820,25 @@ const FIXTURE = {
|
|
|
745
820
|
guide: payload ? payload.guide : { intro: ['说明'], situations: [{ when: 'w', say: 's', what: 'x' }], groups: [{ title: 'g', tools: [['ho_x', 'y']] }], rules: ['r'] },
|
|
746
821
|
extraPrompt: '',
|
|
747
822
|
extraPromptEnabled: false,
|
|
748
|
-
gate: { ok: true, verdict: '全部落位。', total: 1, bodyLines: 24, libraries: [{ name: '
|
|
749
|
-
handover: { global: { verdict: '通过', reasons: [] }, projects: [{ name: '
|
|
823
|
+
gate: { ok: true, verdict: '全部落位。', total: 1, bodyLines: 24, libraries: [{ name: 'Demo1', ok: true, titleOk: true, waterOk: true, missing: 0, lines: 24 }] },
|
|
824
|
+
handover: { global: { verdict: '通过', reasons: [] }, projects: [{ name: 'Demo1', verdict: '通过', reasons: [] }, { name: 'Idle', verdict: '缺节', reasons: ['缺 ## 1. 正在做的事(2026-10-12 起的强制项,本项目还没补)'] }], misc: [{ name: '示例任务', verdict: '通过', reasons: [] }], fail: 0 },
|
|
750
825
|
sessions: {
|
|
826
|
+
workspaces: [{ key: 'w1', title: '全局', path: 'C:/ws' }],
|
|
751
827
|
rows: [
|
|
752
|
-
{ sid: 'session-y', project: '
|
|
753
|
-
{ sid: 'session-
|
|
828
|
+
{ sid: 'session-y', title: 'Demo1', project: 'Demo1', role: 'project', roleLabel: '项目', dutyKind: '项目', dutyKey: 'Demo1', pct: 34.6, state: 'idle', active: true, onduty: true, seen: '2026-10-23 23:19 +0800' },
|
|
829
|
+
{ sid: 'session-g1', title: '全局1', role: 'global', roleLabel: '全局', dutyKind: '全局', dutyKey: '全局1', pct: 40, state: 'idle', active: true, onduty: true, seen: '2026-11-07 21:00 +0800' },
|
|
830
|
+
{ sid: 'session-misc', title: '示例任务', role: 'misc', roleLabel: '杂项', dutyKind: '杂项', dutyKey: '示例任务', pct: 5, state: 'idle', active: false, onduty: true, seen: '2026-10-20 09:00 +0800' },
|
|
831
|
+
{ sid: 'session-l', project: 'Demo2', title: 'Demo2', role: 'project', roleLabel: '项目', dutyKind: '项目', dutyKey: 'Demo2', pct: 50, state: 'idle', active: false, onduty: false, seen: '2026-10-22 20:00 +0800' },
|
|
754
832
|
],
|
|
755
833
|
all: [
|
|
756
|
-
{ sid: 'session-g1', title: '全局1', role: 'global', roleLabel: '全局', source: 'title', project: '', task: '', pct: 40, state: 'idle', active: true, seen: '2026-11-07 21:00 +0800' },
|
|
757
|
-
{ sid: 'session-y', title: '
|
|
758
|
-
{ sid: 'session-l', title: '
|
|
834
|
+
{ sid: 'session-g1', title: '全局1', role: 'global', roleLabel: '全局', source: 'title', project: '', task: '', wsKey: 'w1', wsTitle: '全局', wsPath: 'C:/ws', pct: 40, state: 'idle', active: true, onduty: true, seen: '2026-11-07 21:00 +0800' },
|
|
835
|
+
{ sid: 'session-y', title: 'Demo1', role: 'project', roleLabel: '项目', source: 'store', project: 'Demo1', task: '', wsKey: 'w1', wsTitle: '全局', wsPath: 'C:/ws', pct: 34.6, state: 'idle', active: true, onduty: true, seen: '2026-10-23 23:19 +0800' },
|
|
836
|
+
{ sid: 'session-l', title: 'Demo2', role: 'project', roleLabel: '项目', source: 'cwd', project: 'Demo2', task: '', wsKey: 'w1', wsTitle: '全局', wsPath: 'C:/ws', pct: 50, state: 'idle', active: false, onduty: false, seen: '2026-10-22 20:00 +0800' },
|
|
759
837
|
].concat(plainRows),
|
|
760
|
-
notify: [{ sid: 'session-y',
|
|
838
|
+
notify: [{ sid: 'session-y', key: 'Demo1', name: 'Demo1' }], hot: 2, unknown: 0, cutoff: '2026-11-07 09:25 +0800', silentHours: 12,
|
|
761
839
|
verdict: '2 个会话已进预警区(≥32%),应先收齐交接再换会话',
|
|
762
840
|
},
|
|
763
|
-
consent: [{ sessionId: 'session-y', leftMs: 120000 }],
|
|
841
|
+
consent: [{ sessionId: 'session-y', leftMs: 120000, kind: 'compact', onduty: true }],
|
|
764
842
|
notes: [],
|
|
765
843
|
};
|
|
766
844
|
globalThis.fetch = async () => ({ ok: true, json: async () => FIXTURE });
|
|
@@ -817,29 +895,50 @@ function textOf(node, out = []) {
|
|
|
817
895
|
}
|
|
818
896
|
const text = textOf(tree).join(' ');
|
|
819
897
|
const treeJson = JSON.stringify(tree);
|
|
820
|
-
for (const want of ['dsh-handover', '刷新', '读数', '
|
|
898
|
+
for (const want of ['dsh-handover', '刷新', '读数', '授权', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '工具明细', '使用说明']) {
|
|
821
899
|
ok(text.indexOf(want) !== -1, '成体视图应出现「' + want + '」');
|
|
822
900
|
}
|
|
823
901
|
|
|
824
902
|
// 回到重构前的版式——常用区直接展开,只折叠低频项;授权可直接点。
|
|
825
903
|
const sections = tree.kids.filter((k) => k && k.type === 'section');
|
|
826
904
|
const cardTitles = sections.map((sec) => textOf(sec.kids[0]).join(''));
|
|
827
|
-
const wantOrder = ['
|
|
828
|
-
ok(JSON.stringify(cardTitles) === JSON.stringify(wantOrder), '
|
|
905
|
+
const wantOrder = ['授权', '移动全局根目录', '会话角色', '全局前置提示词', '会话水位', '通用约束条闸门', '交接文档体检', '插件', '最近一次会话间通信判定', '工具明细', '使用说明'];
|
|
906
|
+
ok(JSON.stringify(cardTitles) === JSON.stringify(wantOrder), '分区应自上而下为授权、迁移、角色、提示词、只读数据、插件、判定、说明', JSON.stringify(cardTitles));
|
|
829
907
|
|
|
830
908
|
const folds = treeJson.split('"details"').length - 1;
|
|
831
|
-
ok(folds ===
|
|
909
|
+
ok(folds === 6, '折叠项应为迁移、前置提示词、工具明细、使用说明与两个工作区组', String(folds));
|
|
832
910
|
|
|
833
|
-
//
|
|
834
|
-
const
|
|
911
|
+
// 会话角色:按 DSH 工作区分组、默认折叠,已归档会话不列出。
|
|
912
|
+
const roleCard = sections.find((sec) => textOf(sec.kids[0]).join('') === '会话角色');
|
|
913
|
+
const roleDetails = [];
|
|
914
|
+
(function walk(n) {
|
|
915
|
+
if (!n || typeof n !== 'object') return;
|
|
916
|
+
if (Array.isArray(n)) { n.forEach(walk); return; }
|
|
917
|
+
if (n.type === 'details') roleDetails.push(n);
|
|
918
|
+
(n.kids || []).forEach(walk);
|
|
919
|
+
})(roleCard);
|
|
920
|
+
ok(roleDetails.length === 2, '会话角色应按工作区分组(全局+未归属)', String(roleDetails.length));
|
|
921
|
+
ok(roleDetails.every((d) => d.props.open !== true), '工作区分组默认收起');
|
|
922
|
+
const roleText = textOf(roleCard).join(' ');
|
|
923
|
+
ok(roleText.indexOf('全局(3 个会话)') !== -1 && roleText.indexOf('未归属工作区') !== -1, '分组标题应带会话数', roleText.slice(0, 200));
|
|
924
|
+
ok(roleText.indexOf('已归档会话不列出') === -1 && roleText.indexOf('点击「设置角色」当场生效') === -1, '会话角色卡不再堆说明文字,只留标题与分组');
|
|
925
|
+
|
|
926
|
+
// 授权:只列在任会话,两组各一列按钮,用户不必手填 id。
|
|
927
|
+
const grantBtns = [];
|
|
835
928
|
(function walk(n) {
|
|
836
929
|
if (!n || typeof n !== 'object') return;
|
|
837
930
|
if (Array.isArray(n)) { n.forEach(walk); return; }
|
|
838
|
-
if (n.type === '
|
|
931
|
+
if (typeof n.type === 'function') { walk(n.type(n.props || {})); return; }
|
|
932
|
+
if (n.type === 'button') {
|
|
933
|
+
const label = textOf(n).join('');
|
|
934
|
+
if (label === '授权压缩' || label === '授权换任') grantBtns.push(label);
|
|
935
|
+
}
|
|
839
936
|
(n.kids || []).forEach(walk);
|
|
840
937
|
})(tree);
|
|
841
|
-
ok(
|
|
842
|
-
ok(
|
|
938
|
+
ok(grantBtns.filter((x) => x === '授权压缩').length === 3, '授权压缩:两张在任会话按钮+输入行按钮', JSON.stringify(grantBtns));
|
|
939
|
+
ok(grantBtns.filter((x) => x === '授权换任').length === 3, '授权换任:两张在任会话按钮+输入行按钮', JSON.stringify(grantBtns));
|
|
940
|
+
ok(text.indexOf('有授权') !== -1, '有有效授权时应显示「有授权」');
|
|
941
|
+
ok(text.indexOf('当前无有效授权') === -1, '不再显示无授权占位');
|
|
843
942
|
|
|
844
943
|
// 角色下拉含普通,不再回退到空值。
|
|
845
944
|
ok(text.indexOf('普通') !== -1, '角色下拉应含普通选项');
|
|
@@ -860,23 +959,42 @@ ok(migrateDetail && migrateDetail.type === 'details' && migrateDetail.props.open
|
|
|
860
959
|
// 交接体检只写问题,不带括注。
|
|
861
960
|
ok(text.indexOf('缺 ## 1. 正在做的事') !== -1 && text.indexOf('强制项') === -1, '交接体检的问题列不应带括注说明');
|
|
862
961
|
|
|
863
|
-
//
|
|
864
|
-
|
|
962
|
+
// 会话水位只列在任会话,全局、项目、杂项都要有,并给出本轮需通知。
|
|
963
|
+
const waterCard = sections.find((sec) => textOf(sec.kids[0]).join('') === '会话水位');
|
|
964
|
+
const waterText = textOf(waterCard).join(' ');
|
|
965
|
+
ok(waterText.indexOf('Demo1') !== -1 && waterText.indexOf('34.6%') !== -1, '会话水位应列出项目在任会话与水位');
|
|
966
|
+
ok(waterText.indexOf('全局1') !== -1 && waterText.indexOf('示例任务') !== -1, '会话水位应含全局与杂项的在任会话', waterText.slice(0, 240));
|
|
967
|
+
ok(waterText.indexOf('Demo2') === -1, '会话水位不列已交出的会话', waterText.slice(0, 240));
|
|
865
968
|
ok(text.indexOf('本轮需通知') !== -1, '水位段应给出本轮需通知');
|
|
866
|
-
ok(text.indexOf('
|
|
969
|
+
ok(text.indexOf('Demo2') !== -1, '角色表应列出平级会话');
|
|
970
|
+
const hoCard = sections.find((sec) => textOf(sec.kids[0]).join('') === '交接文档体检');
|
|
971
|
+
const hoText = textOf(hoCard).join(' ');
|
|
972
|
+
ok(hoText.indexOf('杂项') !== -1 && hoText.indexOf('示例任务') !== -1, '交接体检应覆盖已登记的杂项任务', hoText.slice(0, 240));
|
|
867
973
|
|
|
868
974
|
// 版式约束:无进度条、无图形结论符号。
|
|
869
975
|
ok(treeJson.indexOf('dshh-bar') === -1, '不得使用进度条');
|
|
870
976
|
const clientSrc = fs.readFileSync(path.join(dist, 'client.js'), 'utf8');
|
|
871
977
|
ok(!/[\u2705\u274C\u26A0\u2717]/.test(clientSrc), '界面不得使用图形结论符号');
|
|
872
|
-
ok(clientSrc.indexOf('delWord.trim() !== "删除"') !== -1, '
|
|
978
|
+
ok(clientSrc.indexOf('delWord.trim() !== "删除"') !== -1, '删除旧备份须输入确认词');
|
|
979
|
+
// 保留的旧备份不算待处理:迁移卡只在妨碍使用的情形下提示。
|
|
980
|
+
ok(clientSrc.indexOf('(st.phase === "switched" && (st.olds || []).length)') === -1, '保留的旧备份不得计入待处理');
|
|
981
|
+
ok(clientSrc.indexOf('旧备份保留,不影响使用') !== -1, '迁移完成后应注明旧备份不影响使用');
|
|
982
|
+
// 角色顺序:全局、项目、杂项、普通(表格与下拉同序)。
|
|
983
|
+
{
|
|
984
|
+
const g = clientSrc.indexOf('value: "global"');
|
|
985
|
+
const p = clientSrc.indexOf('value: "project"');
|
|
986
|
+
const m = clientSrc.indexOf('value: "misc"');
|
|
987
|
+
const n = clientSrc.indexOf('value: "plain"');
|
|
988
|
+
ok(g !== -1 && p !== -1 && m !== -1 && n !== -1 && g < p && p < m && m < n, '角色下拉应为全局、项目、杂项、普通', JSON.stringify({ g, p, m, n }));
|
|
989
|
+
ok(clientSrc.indexOf('ROLE_ORDER') !== -1, '会话角色应按角色顺序排列');
|
|
990
|
+
}
|
|
873
991
|
|
|
874
992
|
// 未接入工作区:一键部署卡片置顶。
|
|
875
993
|
const NOT_READY = {
|
|
876
994
|
generatedAt: '2026-11-07 21:25 +0800',
|
|
877
995
|
plugin: { name: '@mingmaple/dsh-handover', version: 'test', tools: 22, inject: ['tools'], initErrors: [], scheduler: '就绪' },
|
|
878
996
|
workspace: { root: '', found: false },
|
|
879
|
-
setup: { steps: ['准备一个长期存在的目录', '
|
|
997
|
+
setup: { steps: ['准备一个长期存在的目录', '粘贴路径后点击「一键部署」'] },
|
|
880
998
|
guide: payload ? payload.guide : null,
|
|
881
999
|
notes: ['未找到 _Control_Center:这个实例还没接入工作区。'],
|
|
882
1000
|
};
|
|
@@ -890,7 +1008,7 @@ ok(titles3[0] === '首次安装', '未接入工作区时首次安装置顶', JSO
|
|
|
890
1008
|
const text3 = textOf(tree3).join(' ');
|
|
891
1009
|
ok(text3.indexOf('一键部署') !== -1, '未接入时应能一键部署(建体系+注册工作区+建全局会话)');
|
|
892
1010
|
ok(text3.indexOf('仅建立文件体系') !== -1, '未接入时仍可只建立文件体系');
|
|
893
|
-
ok(text3.indexOf('
|
|
1011
|
+
ok(text3.indexOf('未找到 _Control_Center') !== -1, '未接入工作区时应给出中文提示');
|
|
894
1012
|
|
|
895
1013
|
// 已接入但没有全局会话:给出补建入口,且排在常规卡片之前。
|
|
896
1014
|
const READY_NO_GLOBAL = Object.assign({}, FIXTURE, { globalSessions: 0 });
|
|
@@ -1020,7 +1138,7 @@ try {
|
|
|
1020
1138
|
ok(/工作区/.test(newErr) && /已注册的工作区/.test(newErr), '新建会话缺工作区时应报错并列出可选工作区', newErr.slice(0, 160));
|
|
1021
1139
|
|
|
1022
1140
|
// 回归锁(0.9.14):① 新建杂项会话的启动提示读本任务交接,不退回全局交接;
|
|
1023
|
-
// ② 杂项换任的接任者落回本会话所在的注册工作区;③ 接任提示里的「全局 id
|
|
1141
|
+
// ② 杂项换任的接任者落回本会话所在的注册工作区;③ 接任提示里的「全局 id」是现任全局,不是已交出的旧会话。
|
|
1024
1142
|
{
|
|
1025
1143
|
const { registerHolder } = await import(DIST('lib/office.mjs'));
|
|
1026
1144
|
const storages = path.join(BLANK, '.dsh', 'storages');
|
|
@@ -1045,26 +1163,26 @@ ok(/工作区/.test(newErr) && /已注册的工作区/.test(newErr), '新建会
|
|
|
1045
1163
|
{
|
|
1046
1164
|
let createdOut = '';
|
|
1047
1165
|
try {
|
|
1048
|
-
const r = await sessT.execute({ action: 'new', confirm: true, title: '杂项2', kind: 'misc', role: 'misc', task: '
|
|
1166
|
+
const r = await sessT.execute({ action: 'new', confirm: true, title: '杂项2', kind: 'misc', role: 'misc', task: '示例任务', workspace: 'ws-misc' }, makerExec2);
|
|
1049
1167
|
createdOut = String((r && r.text) || '');
|
|
1050
1168
|
} catch (e) { createdOut = 'ERR ' + String(e && e.message ? e.message : e); }
|
|
1051
1169
|
const p = seen.prompts[seen.prompts.length - 1] || '';
|
|
1052
1170
|
ok(/已建会话/.test(createdOut), '新建杂项会话应成功', createdOut.slice(0, 200));
|
|
1053
|
-
ok(/_Misc[\\/]tasks[\\/]
|
|
1171
|
+
ok(/_Misc[\\/]tasks[\\/]示例任务[\\/]Handover\.md/.test(p), '新建杂项会话的启动提示应读本任务交接', p.slice(0, 200));
|
|
1054
1172
|
ok(p.indexOf('Global_Handover') === -1, '新建杂项会话的启动提示不得指向全局交接', p.slice(0, 200));
|
|
1055
1173
|
}
|
|
1056
1174
|
{
|
|
1057
1175
|
const { runSuccession } = await import(DIST('lib/succession.mjs'));
|
|
1058
1176
|
const { grantConsent } = await import(DIST('lib/consent.mjs'));
|
|
1059
|
-
const miscHandover = path.join(miscWs, 'tasks', '
|
|
1177
|
+
const miscHandover = path.join(miscWs, 'tasks', '示例任务', 'Handover.md');
|
|
1060
1178
|
fs.mkdirSync(path.dirname(miscHandover), { recursive: true });
|
|
1061
|
-
fs.writeFileSync(miscHandover, '#
|
|
1179
|
+
fs.writeFileSync(miscHandover, '# 示例任务交接' + '\n\n## 下一步\n- 等用户指派下一步\n', 'utf8');
|
|
1062
1180
|
registerHolder(WS, { kind: 'global', name: '全局1', sessionId: 'session-global-cur', workspace: WS });
|
|
1063
|
-
registerHolder(WS, { kind: 'misc', task: '
|
|
1181
|
+
registerHolder(WS, { kind: 'misc', task: '示例任务', name: '杂项1', sessionId: 'session-misc-old', workspace: miscWs });
|
|
1064
1182
|
grantConsent('session-misc-old', 'blank-smoke', 'succeed');
|
|
1065
1183
|
const succOut = await runSuccession({
|
|
1066
1184
|
cfg: resolveConfig({ workspaceRoot: WS }),
|
|
1067
|
-
roleOf: () => ({ role: 'misc', task: '
|
|
1185
|
+
roleOf: () => ({ role: 'misc', task: '示例任务', project: '' }),
|
|
1068
1186
|
selfId: () => 'session-misc-old',
|
|
1069
1187
|
selfCwd: () => miscWs,
|
|
1070
1188
|
handoverFor: () => ({ path: miscHandover, reason: '' }),
|
|
@@ -1078,9 +1196,9 @@ ok(/工作区/.test(newErr) && /已注册的工作区/.test(newErr), '新建会
|
|
|
1078
1196
|
const madeArgs = seen.creates[seen.creates.length - 1] || {};
|
|
1079
1197
|
ok(madeArgs.workspaceId === 'ws-misc', '杂项接任者应落回本会话所在的工作区', JSON.stringify(madeArgs));
|
|
1080
1198
|
const p = seen.prompts[seen.prompts.length - 1] || '';
|
|
1081
|
-
ok(new RegExp('.{0,1}
|
|
1199
|
+
ok(new RegExp('.{0,1}示例任务[\\\\/]Handover\\.md').test(p), '接任提示末尾应带本任务交接路径', p.slice(-320));
|
|
1082
1200
|
ok(p.indexOf('session-global-cur') !== -1, '接任提示的「全局 id」应是现任全局', p.slice(-320));
|
|
1083
|
-
ok(p.indexOf('session-misc-old') === -1, '
|
|
1201
|
+
ok(p.indexOf('session-misc-old') === -1, '接任提示不得把旧会话当全局', p.slice(-320));
|
|
1084
1202
|
}
|
|
1085
1203
|
}
|
|
1086
1204
|
|