@mingmaple/dsh-handover 0.0.0-stage → 0.9.12

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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/docs/migrate.md +61 -0
  4. package/docs/troubleshoot.md +77 -0
  5. package/package.json +70 -5
  6. package/scripts/blank-dsh.mjs +203 -0
  7. package/scripts/blank-smoke.mjs +981 -0
  8. package/scripts/build.mjs +61 -0
  9. package/scripts/check-docs.mjs +74 -0
  10. package/scripts/e2e-migration.mjs +127 -0
  11. package/scripts/lib/register.mjs +103 -0
  12. package/src/client.js +945 -0
  13. package/src/cordis.patch.yml +16 -0
  14. package/src/index.mjs +726 -0
  15. package/src/lib/absrefs.mjs +112 -0
  16. package/src/lib/api.mjs +291 -0
  17. package/src/lib/archive.mjs +198 -0
  18. package/src/lib/brief.mjs +74 -0
  19. package/src/lib/charter.mjs +28 -0
  20. package/src/lib/config.mjs +37 -0
  21. package/src/lib/consent.mjs +84 -0
  22. package/src/lib/drift.mjs +65 -0
  23. package/src/lib/events.mjs +89 -0
  24. package/src/lib/extra-prompt.mjs +41 -0
  25. package/src/lib/facts.mjs +70 -0
  26. package/src/lib/gates.mjs +148 -0
  27. package/src/lib/guide.mjs +103 -0
  28. package/src/lib/handover-io.mjs +69 -0
  29. package/src/lib/lint.mjs +152 -0
  30. package/src/lib/manual.mjs +124 -0
  31. package/src/lib/mover.mjs +663 -0
  32. package/src/lib/names.mjs +33 -0
  33. package/src/lib/output.mjs +11 -0
  34. package/src/lib/pack-ops.mjs +332 -0
  35. package/src/lib/peer.mjs +260 -0
  36. package/src/lib/phase.mjs +36 -0
  37. package/src/lib/profile.mjs +204 -0
  38. package/src/lib/prompt.mjs +18 -0
  39. package/src/lib/registry.mjs +53 -0
  40. package/src/lib/retarget-helper.tmpl +46 -0
  41. package/src/lib/role.mjs +251 -0
  42. package/src/lib/rpc-carrier.mjs +65 -0
  43. package/src/lib/scaffold.mjs +118 -0
  44. package/src/lib/schedule.mjs +223 -0
  45. package/src/lib/session-create.mjs +42 -0
  46. package/src/lib/session-ops.mjs +408 -0
  47. package/src/lib/sessions.mjs +143 -0
  48. package/src/lib/sevenzip.mjs +48 -0
  49. package/src/lib/status.mjs +177 -0
  50. package/src/lib/text.mjs +101 -0
  51. package/src/lib/webroute.mjs +130 -0
  52. package/src/lib/workspace.mjs +23 -0
  53. package/src/lib/zstd-log.mjs +65 -0
  54. package/src/selftest.mjs +84 -0
  55. package/src/templates/Changelog.skeleton.md +10 -0
  56. package/src/templates/Charter.skeleton.md +53 -0
  57. package/src/templates/Handover.global.skeleton.md +56 -0
  58. package/src/templates/Prompt_Templates.md +96 -0
  59. package/src/templates/Runtime_Manual.md +166 -0
  60. package/src/templates/rules/Handover_Rules.md +44 -0
  61. package/src/templates/rules/Migration_Rules.md +32 -0
  62. package/src/templates/rules/Misc_Task_Standard.md +23 -0
  63. package/src/templates/rules/Writing_Rules.md +24 -0
  64. package/src/tools/ho_brief.mjs +57 -0
  65. package/src/tools/ho_changelog.mjs +59 -0
  66. package/src/tools/ho_charter.mjs +89 -0
  67. package/src/tools/ho_checkpoint.mjs +132 -0
  68. package/src/tools/ho_diag.mjs +87 -0
  69. package/src/tools/ho_drift.mjs +41 -0
  70. package/src/tools/ho_gate.mjs +69 -0
  71. package/src/tools/ho_handover.mjs +84 -0
  72. package/src/tools/ho_init.mjs +82 -0
  73. package/src/tools/ho_pack.mjs +38 -0
  74. package/src/tools/ho_phase.mjs +37 -0
  75. package/src/tools/ho_profile.mjs +105 -0
  76. package/src/tools/ho_sessions.mjs +115 -0
  77. package/src/tools/ho_snapshot.mjs +104 -0
  78. package/src/tools/ho_tokens.mjs +57 -0
  79. package/src/tools/ho_workspace.mjs +217 -0
  80. package/src/tools/peer_list.mjs +37 -0
  81. package/src/tools/peer_new.mjs +75 -0
  82. package/src/tools/peer_peek.mjs +41 -0
  83. package/src/tools/peer_rename.mjs +31 -0
  84. package/src/tools/peer_send.mjs +52 -0
  85. package/src/tools/peer_status.mjs +46 -0
@@ -0,0 +1,37 @@
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
+ }
@@ -0,0 +1,105 @@
1
+ /** ho_profile —— 项目与杂项的骨架档案:读、注册、校验、取预设。 */
2
+ import { textOutput } from '../lib/output.mjs';
3
+ import { nameOf } from '../lib/names.mjs';
4
+ import { NL } from '../lib/text.mjs';
5
+ import { presets, readProfile, writeProfile, validateProfile, profilePathFor, PROFILE_KINDS } from '../lib/profile.mjs';
6
+ import { assertProjectName, assertTaskName } from '../lib/role.mjs';
7
+ import { stampTz } from '../lib/brief.mjs';
8
+
9
+ function targetOf(env, info, args, exec) {
10
+ if (info && (info.role === 'project' || info.role === 'misc')) {
11
+ const own = info.role === 'project' ? String(info.project || '') : String(info.task || '');
12
+ const given = String(args.project || args.task || '').trim();
13
+ // 项目会话只处理自己的骨架;传了别的名字就明确报错,而不是静默忽略入参(实测踩过)。
14
+ if (given && given !== own) {
15
+ throw new Error('本会话是' + (info.role === 'project' ? '项目「' + own + '」' : '杂项「' + own + '」')
16
+ + ',只能读写自己的骨架;要看「' + given + '」请由全局会话执行 ho_profile。');
17
+ }
18
+ return { role: info.role, project: info.project || '', task: info.task || '', self: true };
19
+ }
20
+ if (info && info.role === 'global') {
21
+ if (args.project) return { role: 'project', project: assertProjectName(String(args.project)), task: '', self: false };
22
+ if (args.task) return { role: 'misc', project: '', task: assertTaskName(String(args.task)), self: false };
23
+ throw new Error('全局会话使用 ho_profile 时须给 project 或 task');
24
+ }
25
+ throw new Error('普通会话不参与骨架注册');
26
+ }
27
+
28
+ function scopeOf(t) {
29
+ return t.role === 'project' ? '项目「' + t.project + '」' : '杂项任务「' + t.task + '」';
30
+ }
31
+
32
+ export function pmProfileTool(env) {
33
+ return {
34
+ name: nameOf('profile'),
35
+ description: '项目或杂项骨架:读取、注册、校验、取预设。只写本角色的 _Project_Meta 或 _Misc 目录。',
36
+ parameters: {
37
+ type: 'object',
38
+ additionalProperties: false,
39
+ properties: {
40
+ action: { type: 'string', enum: ['read', 'register', 'validate', 'presets'], description: '缺省 read' },
41
+ project: { type: 'string', description: '全局会话读取或校验时的项目名' },
42
+ task: { type: 'string', description: '全局会话读取或校验时的任务名' },
43
+ kind: { type: 'string', enum: PROFILE_KINDS, description: 'presets 时取指定预设;缺省返回全部' },
44
+ },
45
+ },
46
+ output: textOutput(),
47
+ async execute(args, exec) {
48
+ const action = typeof args.action === 'string' && args.action ? args.action : 'read';
49
+ const ws = env.resolveWs(exec);
50
+ if (!ws) throw new Error('没有工作区根');
51
+ if (action === 'presets') {
52
+ const all = presets();
53
+ const one = args.kind && all[args.kind] ? { [args.kind]: all[args.kind] } : all;
54
+ return { text: JSON.stringify(one, null, 2) };
55
+ }
56
+ const info = env.roleOf(exec);
57
+ const t = targetOf(env, info, args, exec);
58
+ const scope = { role: t.role, project: t.project, task: t.task };
59
+ const p = profilePathFor(ws, scope);
60
+ const r = readProfile(ws, scope);
61
+ if (action === 'read') {
62
+ const v = validateProfile(ws, scope, r.profile);
63
+ const out = [
64
+ '骨架:' + scopeOf(t) + '(' + (r.exists ? '已注册' : '未注册,按默认骨架运行') + ')',
65
+ '文件:' + (p || '(无)'),
66
+ ];
67
+ out.push(JSON.stringify(r.profile, null, 2));
68
+ out.push('校验:' + (v.ok ? '通过' : '未通过') + (v.missing.length ? ';缺 ' + v.missing.join('、') : ''));
69
+ if (r.errors.length) out.push('结构错误:' + r.errors.join(';'));
70
+ return { text: out.join(NL) };
71
+ }
72
+ if (action === 'validate') {
73
+ const v = validateProfile(ws, scope, r.profile);
74
+ const out = [
75
+ '骨架校验:' + scopeOf(t),
76
+ '文件:' + (p || '(无)'),
77
+ '结论:' + (v.ok ? '通过' : '未通过'),
78
+ ];
79
+ if (v.missing.length) out.push('缺:' + v.missing.join('、'));
80
+ if (r.errors.length || v.errors.length) out.push('结构错误:' + r.errors.concat(v.errors).join(';'));
81
+ return { text: out.join(NL) };
82
+ }
83
+ if (action === 'register') {
84
+ if (!t.self) throw new Error('注册须由该项目或杂项会话本人执行');
85
+ if (!r.exists) throw new Error('未找到骨架文件:' + p + '。先通读本库并写入该文件,再执行注册。');
86
+ if (r.errors.length) throw new Error('骨架结构不合法:' + r.errors.join(';'));
87
+ const saved = writeProfile(ws, scope, r.profile);
88
+ const v = validateProfile(ws, scope, saved.profile);
89
+ const sid = env.selfId(exec);
90
+ if (sid && typeof env.writeRole === 'function') {
91
+ try { env.writeRole(sid, { profileAt: stampTz(Date.now()), profileKind: saved.profile.kind }); } catch { /* 角色表写失败不影响注册 */ }
92
+ }
93
+ const out = [
94
+ '已注册骨架:' + scopeOf(t),
95
+ '文件:' + saved.path,
96
+ '类别:' + saved.profile.kind + ';约束条校验:' + (saved.profile.gate.libraryBlock ? '启用' : '不启用'),
97
+ ];
98
+ if (v.missing.length) out.push('尚缺:' + v.missing.join('、') + '(补齐后 ho_profile(action=validate) 复核)');
99
+ else out.push('校验:通过');
100
+ return { text: out.join(NL) };
101
+ }
102
+ throw new Error('未知 action:' + action);
103
+ },
104
+ };
105
+ }
@@ -0,0 +1,115 @@
1
+ /** ho_sessions —— 会话清点;action 分流:role 设置角色、new 建会话、prompt 出提示词、archive/unarchive 归档。 */
2
+ import { textOutput } from '../lib/output.mjs';
3
+ import { nameOf } from '../lib/names.mjs';
4
+ import { NL } from '../lib/text.mjs';
5
+ import path from 'node:path';
6
+ import { scanSessions } from '../lib/sessions.mjs';
7
+ import { syncCheck } from '../lib/gates.mjs';
8
+ import { stamp } from '../lib/brief.mjs';
9
+ import { sessionNew, sessionPrompt, sessionArchive, sessionUnarchive, sessionOrder, sessionPlace } from '../lib/session-ops.mjs';
10
+ import { readRoles, resolveRole, ROLE_LABEL, sessionMeta } from '../lib/role.mjs';
11
+ import { watermark } from '../lib/facts.mjs';
12
+
13
+ function list(env, args, exec) {
14
+ const warn = typeof args.warn === "number" ? args.warn : watermark(env.cfg).warnValue;
15
+ const limit = typeof args.limit === "number" ? args.limit : 40;
16
+ const scan = scanSessions({
17
+ dir: env.cacheDir(),
18
+ meId: env.selfId(exec),
19
+ meCwd: env.selfCwd(exec),
20
+ warn,
21
+ silentHours: env.cfg.sessionScanHours,
22
+ projectsOnly: args.include_subagents !== true,
23
+ });
24
+ const store = readRoles(env.cfg);
25
+ const roleOf = (r) => {
26
+ const meta = sessionMeta(r.sid);
27
+ return resolveRole({ cfg: env.cfg, roles: store, sessionId: r.sid, cwd: meta.cwd, firstPrompt: meta.firstPrompt, subagent: meta.subagent, title: r.project });
28
+ };
29
+ const out = [];
30
+ out.push("活跃 / 沉默线:最后活动 " + stamp(scan.cutoffMs) + " 之后算活跃(" + scan.silentHours + " 小时;Charter 7.2.3)");
31
+ for (const r of scan.rows.slice(0, limit)) {
32
+ const pct = r.pct === null ? "水位 ?" : "水位 " + r.pct.toFixed(1) + "%";
33
+ const lab = r.mtime >= scan.cutoffMs ? "活跃" : "沉默";
34
+ const flag = r.pct !== null && r.pct >= warn ? " 【进预警区】" : "";
35
+ const info = roleOf(r);
36
+ out.push(r.kind + " " + r.sid + " " + r.project + " " + pct + " " + r.state + " 角色 " + (ROLE_LABEL[info.role] || info.role) + " 最后活动 " + stamp(r.mtime) + " 【" + lab + "】" + flag);
37
+ }
38
+ const notifProjects = new Set(scan.notify.map((r) => r.project));
39
+ const skipProjects = new Set(scan.skip.map((r) => r.project));
40
+ out.push("通知判定:活跃项目 " + notifProjects.size + " 个,需要通知 " + scan.notify.length + " 个会话(仅各项目在任)/ 沉默项目 " + skipProjects.size + " 个,跳过 " + scan.skip.length + " 个会话(沉默项目全部+前任)");
41
+ for (const r of scan.notify) out.push(" 要通知【在任】" + r.project + "(" + r.sid + ") 最后活动 " + stamp(r.mtime));
42
+ if (scan.skip.length) out.push(" 跳过(沉默项目,含其前任):" + scan.skip.map((x) => x.project + "(" + x.sid.slice(-8) + ")").join("、"));
43
+ const gate = syncCheck(env.resolveWs(exec));
44
+ if (gate.error) out.push("通用约束条闸门(Charter §7.2.3):读不到 §8.1.1.4 正文 —— 请人工核 " + gate.path);
45
+ else if (!gate.ok) {
46
+ const bad = gate.libraries.filter((l) => !l.ok).map((l) => l.name + "(缺 " + l.missing.length + " 行)");
47
+ out.push("通用约束条闸门(Charter §7.2.3「闸门」):**未过** —— " + bad.join("、"));
48
+ out.push("判定:通用约束条有库缺,交接前必须补齐(Charter §7.2.3 闸门)");
49
+ return out.join(NL);
50
+ } else out.push("通用约束条闸门(Charter §7.2.3「闸门」):" + gate.total + "/" + gate.total + " 已落位");
51
+ const verdict = scan.hot.length
52
+ ? "判定:" + scan.hot.length + " 个会话已进预警区(>=" + warn + "%),按 Charter 7.2.3 先收齐交接再换会话"
53
+ : scan.unknown.length
54
+ ? "判定:" + scan.unknown.length + " 个会话水位无法读取,需人工判断"
55
+ : "判定:没有会话进入预警区(>=" + warn + "%),可以继续";
56
+ out.push(verdict);
57
+ return out.join(NL);
58
+ }
59
+
60
+ export function pmSessionsTool(env) {
61
+ return {
62
+ name: nameOf('sessions'),
63
+ description: '会话清点与调度;action=role 设置角色、new 建会话、order 下指令(交接、压缩)、place 移入工作区、prompt 出提示词、archive/unarchive 归档。',
64
+ parameters: {
65
+ type: 'object',
66
+ additionalProperties: false,
67
+ properties: {
68
+ action: { type: 'string', enum: ['list', 'role', 'new', 'order', 'place', 'prompt', 'archive', 'unarchive'], description: '缺省 list' },
69
+ order: { type: 'string', enum: ['handover', 'compact'], description: 'order 的动作:handover 让它写交接再换任;compact 给它排压缩(先要有合格交接)' },
70
+ resume: { type: 'string', enum: ['continue', 'wait'], description: 'order=compact 时的去向:缺省 wait(交接后收尾,压缩后不唤醒);continue 表示压缩后按交接继续' },
71
+ include_subagents: { type: 'boolean', description: 'list 时含子会话' },
72
+ limit: { type: 'number', description: 'list 行数(默认 40)' },
73
+ warn: { type: 'number', description: 'list 预警阈值(默认 ' + watermark(env.cfg).warn + ')' },
74
+ session_id: { type: 'string', description: 'role/order/place/archive/unarchive 的目标会话' },
75
+ role: { type: 'string', enum: ['global', 'project', 'misc', 'plain'], description: 'role 要设成的角色;new 时也据此登记接任者角色' },
76
+ project: { type: 'string', description: '项目名(role=project 必填;new 时为换任项目)' },
77
+ task: { type: 'string', description: '任务名(role=misc 必填)' },
78
+ notify: { type: 'boolean', description: 'role 时投整理指令(默认 true,会唤醒它一轮)' },
79
+ title: { type: 'string', description: 'new 的标题;prompt 用于推断代际' },
80
+ workspace: { type: 'string', description: 'new 必填:目标工作区的 id、标题或路径;new 时不给也能按 project 自动匹配同名工作区' },
81
+ cwd: { type: 'string', description: 'new 的工作目录,必须是某个已注册工作区的根(与 workspace 二选一)' },
82
+ message: { type: 'string', description: 'new 的启动提示词;不给则自动生成' },
83
+ force: { type: 'boolean', description: 'new 越过 <' + watermark(env.cfg).warn + ' 复用判据;order=compact 越过 120 秒去抖;archive 允许停掉在跑的' },
84
+ kind: { type: 'string', enum: ['global', 'project', 'misc', 'migration'], description: 'prompt 的类型;new 时用作角色候选' },
85
+ sessionId: { type: 'string', description: 'prompt:新会话自己的 id' },
86
+ globalSessionId: { type: 'string', description: 'prompt:现任全局 id' },
87
+ extra: { type: 'string', description: 'prompt 附加一句;order 时作为「全局附加」拼进指令' },
88
+ confirm: { type: 'boolean', description: 'new、order 需要用户明确同意后传 true;否则只报错、不做任何事' },
89
+ },
90
+ },
91
+ output: textOutput(),
92
+ async execute(args, exec) {
93
+ const action = typeof args.action === 'string' && args.action ? args.action : 'list';
94
+ if (action === 'list') return { text: list(env, args, exec) };
95
+ if (action === 'new') return { text: await sessionNew(env, args, exec) };
96
+ if (action === 'order') return { text: await sessionOrder(env, args) };
97
+ if (action === 'place') return { text: await sessionPlace(env, args) };
98
+ if (action === 'prompt') return { text: sessionPrompt(env, args, exec) };
99
+ if (action === 'archive') return { text: await sessionArchive(env, args, exec) };
100
+ if (action === 'unarchive') return { text: await sessionUnarchive(env, args) };
101
+ if (action === 'role') {
102
+ if (typeof env.applyRole !== 'function') throw new Error('宿主侧没有装配 applyRole');
103
+ const r = await env.applyRole({ sessionId: args.session_id, role: args.role, project: args.project, task: args.task, wake: args.notify !== false });
104
+ const out = [];
105
+ out.push(r.ok ? '已设定角色:' + (ROLE_LABEL[r.role] || r.role) : '设定失败:' + r.error);
106
+ if (r.ok) {
107
+ out.push(' 存活会话当场生效:' + (r.live ? '是(工具集与提示段已换)' : '否(下次开轮时生效)'));
108
+ out.push(' 整理指令:' + (r.delivered ? '已投递(会唤醒它一轮)' : (r.error ? '投递失败:' + r.error : '未投递')));
109
+ }
110
+ return { text: out.join(NL) };
111
+ }
112
+ throw new Error('未知 action:' + action);
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,104 @@
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
+ }
@@ -0,0 +1,57 @@
1
+ /** ho_tokens —— token 体检(替代 token_probe/token_survey 的常用判据)。 */
2
+ import { textOutput } from '../lib/output.mjs';
3
+ import { nameOf } from '../lib/names.mjs';
4
+ import { NL, pick } from '../lib/text.mjs';
5
+ import { findLog, decodeLog, turnStarts } from '../lib/zstd-log.mjs';
6
+
7
+ function analyzeLog(records) {
8
+ const turns = turnStarts(records).length;
9
+ let steps = 0, outTok = 0, maxResult = 0, toolResults = 0;
10
+ const perTurn = new Map();
11
+ for (const r of records) {
12
+ if (!r || !r.type) continue;
13
+ if (r.type === 'step/start') {
14
+ steps++;
15
+ const t = pick(r, 'data', 'turn');
16
+ if (typeof t === 'number') perTurn.set(t, (perTurn.get(t) || 0) + 1);
17
+ } else if (r.type === 'assistant/message') {
18
+ const u = pick(r, 'data', 'usage');
19
+ if (u && typeof u.outputTokens === 'number') outTok += u.outputTokens;
20
+ } else if (r.type === 'tool/result') {
21
+ toolResults++;
22
+ try { const len = JSON.stringify(pick(r, 'data') || '').length; if (len > maxResult) maxResult = len; } catch { /* ignore */ }
23
+ }
24
+ }
25
+ const heavyTurns = Array.from(perTurn.values()).filter((n) => n > 20).length;
26
+ return { turns, steps, outTok, maxResult, toolResults, heavyTurns, perStep: steps ? Math.round(outTok / steps) : 0 };
27
+ }
28
+
29
+ export function pmTokensTool(env) {
30
+ return {
31
+ name: nameOf('tokens'),
32
+ description:
33
+ '会话 token 体检:轮数、步数、每步 token、最贵单条返回。只读。',
34
+ parameters: {
35
+ type: 'object', additionalProperties: false, required: ['sessions'],
36
+ properties: { sessions: { type: 'array', items: { type: 'string' }, description: '会话 id 列表' } },
37
+ },
38
+ output: textOutput(),
39
+ async execute(args, exec) {
40
+ const out = [];
41
+ const list = Array.isArray(args.sessions) ? args.sessions.filter((s) => typeof s === 'string' && s.trim() !== '') : [];
42
+ if (!list.length) throw new Error('sessions 不能为空(给会话 id 数组)');
43
+ out.push('会话 轮 步 输出tok 每步tok 最贵返回 超20步轮 判定');
44
+ for (const sid of list) {
45
+ const full = sid.indexOf('session-') === 0 ? sid : 'session-' + sid;
46
+ const log = findLog(full);
47
+ if (!log) { out.push(sid + ' ERR 日志找不到'); continue; }
48
+ const recs = decodeLog(log);
49
+ if (!recs) { out.push(sid + ' ERR 日志解不出'); continue; }
50
+ const m = analyzeLog(recs);
51
+ const verdict = m.perStep > 2000 ? '偏贵(每步 >2k)' : m.heavyTurns > 0 ? '有超 20 步的轮' : '正常';
52
+ out.push([sid.slice(-8), m.turns, m.steps, m.outTok, m.perStep, Math.round(m.maxResult / 1024) + 'KB', m.heavyTurns, verdict].join(' '));
53
+ }
54
+ return { text: out.join(NL) };
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,217 @@
1
+ /** ho_workspace —— 工作区生命周期:list / create / delete(官方 workspaceRegistry)+ 全局根目录搬迁。 */
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 { workspaceRows } from '../lib/registry.mjs';
8
+ import { probeMove, startMove, moveStatus, cleanupOld, stageSwitch, repairLink, stageRetarget } from '../lib/mover.mjs';
9
+ import { currentRoot } from '../lib/api.mjs';
10
+
11
+ const BAD = '不能删除「全局」工作区(那是管理会话的家)。';
12
+
13
+ function gb(n) {
14
+ const v = Number(n) || 0;
15
+ if (v >= 1024 * 1024 * 1024) return (v / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
16
+ if (v >= 1024 * 1024) return (v / (1024 * 1024)).toFixed(1) + ' MB';
17
+ if (v >= 1024) return (v / 1024).toFixed(1) + ' KB';
18
+ return v + ' B';
19
+ }
20
+
21
+ function renderProbe(p) {
22
+ const out = [];
23
+ out.push('搬迁预检:' + (p.ok ? '通过' : '不通过'));
24
+ out.push(' 源 ' + p.src + (p.srcIsLink ? '(已是链接,指向 ' + p.linkTarget + ')' : ''));
25
+ out.push(' 复制源 ' + p.copyFrom);
26
+ out.push(' 目标 ' + p.dst);
27
+ out.push(' 总大小 ' + p.files + ' 文件 · ' + gb(p.bytes) + '(' + p.dirs + ' 子目录)');
28
+ if (p.freeBytes !== null && p.freeBytes !== undefined) out.push(' 目标盘可用 ' + gb(p.freeBytes));
29
+ for (const w of p.warnings || []) out.push(' 注意:' + w);
30
+ for (const x of p.problems || []) out.push(' 未通过:' + x);
31
+ if (p.ok) out.push('提示:复制完会生成切换脚本;切换那一步需要**完全退出 DSH** 再跑(源目录被占用)。');
32
+ return out.join(NL);
33
+ }
34
+
35
+ export function pmWorkspaceTool(env) {
36
+ return {
37
+ name: nameOf('workspace'),
38
+ description:
39
+ '工作区 list、create、delete;move-* 搬整个全局根目录(探针、执行、状态、清旧副本)。删除只删注册。',
40
+ parameters: {
41
+ type: 'object',
42
+ additionalProperties: false,
43
+ required: ['action'],
44
+ properties: {
45
+ action: {
46
+ type: 'string',
47
+ enum: ['list', 'create', 'delete', 'move-probe', 'move', 'move-switch', 'move-status', 'move-retarget', 'move-repair', 'move-cleanup'],
48
+ description: 'list / create / delete / move-probe / move / move-switch / move-status / move-retarget / move-repair / move-cleanup',
49
+ },
50
+ path: { type: 'string', description: 'create 用:目录绝对路径;默认要求已存在,ensure=true 时自动创建' },
51
+ title: { type: 'string', description: 'create 用:显示标题' },
52
+ ensure: { type: 'boolean', description: 'create 用:目录不存在时自动创建(默认 false,不替你建目录)' },
53
+ workspace: { type: 'string', description: 'delete 用:工作区 id、标题、路径' },
54
+ to: { type: 'string', description: 'move 用:新根绝对路径(父目录可自动建)' },
55
+ confirm: { type: 'boolean', description: 'move-cleanup 用:true 才真的删旧副本' },
56
+ },
57
+ },
58
+ output: textOutput(),
59
+ async execute(args) {
60
+ const reg = env.registry();
61
+ const action = String(args.action || '');
62
+ const out = [];
63
+
64
+ if (action === 'list') {
65
+ if (reg && typeof reg.list === 'function') {
66
+ let list = [];
67
+ try { list = reg.list() || []; } catch { list = []; }
68
+ if (list.length) {
69
+ out.push('工作区(官方注册表,' + list.length + ' 个)');
70
+ for (const w of list) {
71
+ const n = w && w.sessionIds && typeof w.sessionIds.length === 'number' ? w.sessionIds.length : '?';
72
+ out.push(' ' + String(w.id) + ' ' + String(w.title || '-') + ' ' + String(w.path || '-') + ' 会话 ' + n);
73
+ }
74
+ return { text: out.join(NL) };
75
+ }
76
+ }
77
+ const rows = workspaceRows();
78
+ out.push('工作区(退化:读 workspace.json,' + rows.length + ' 个)');
79
+ for (const r of rows) out.push(' ' + r.id + ' ' + r.title + ' ' + r.path + ' 会话 ' + r.sessions);
80
+ return { text: out.join(NL) };
81
+ }
82
+
83
+ if (action === 'create') {
84
+ if (!reg || typeof reg.create !== 'function') throw new Error('workspaceRegistry.create 不可用');
85
+ const p = String(args.path || '').trim();
86
+ if (!p) throw new Error('create 需要 path(目录绝对路径)');
87
+ let st = null;
88
+ try { st = fs.statSync(p); } catch { st = null; }
89
+ if ((!st || !st.isDirectory()) && args.ensure === true) {
90
+ try { fs.mkdirSync(p, { recursive: true }); st = fs.statSync(p); } catch (e) { throw new Error('建目录失败:' + p + '(' + String(e && e.message ? e.message : e) + ')'); }
91
+ }
92
+ if (!st || !st.isDirectory()) throw new Error('目录不存在或不是目录:' + p + '(默认不替你建目录;要建请传 ensure=true,或先手工建好)');
93
+ const title = String(args.title || '').trim() || p.split(/[\\/]/).filter(Boolean).pop() || p;
94
+ let w = null;
95
+ try { w = await reg.create(p, title); } catch (e) { throw new Error('建工作区失败:' + String(e && e.message ? e.message : e)); }
96
+ out.push('已注册工作区 ' + String(w && w.id ? w.id : '?') + ' ' + String(w && w.title ? w.title : title));
97
+ out.push('路径 ' + String(w && w.path ? w.path : p));
98
+ out.push('同一路径重复调用返回原记录、不改标题;新登记的工作区排在列表最前。');
99
+ return { text: out.join(NL) };
100
+ }
101
+
102
+ if (action === 'delete') {
103
+ if (!reg || typeof reg.delete !== 'function') throw new Error('workspaceRegistry.delete 不可用');
104
+ const key = String(args.workspace || '').trim();
105
+ if (!key) throw new Error('delete 需要 workspace(id、标题、路径)');
106
+ let list = [];
107
+ try { list = reg.list() || []; } catch { list = []; }
108
+ const norm = (s) => String(s || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
109
+ const hit = list.find((w) => String(w.id) === key || w.title === key || norm(w.path) === norm(key));
110
+ if (!hit) throw new Error('找不到工作区:' + key);
111
+ if (String(hit.title || '').indexOf('全局') !== -1) throw new Error(BAD);
112
+ const ok = await reg.delete(hit.id);
113
+ out.push((ok ? '已删除工作区注册 ' : '该工作区不存在(无操作)') + String(hit.id) + ' ' + String(hit.title || '-'));
114
+ out.push('注意:只删注册;目录与全部会话日志都保留在原处。');
115
+ return { text: out.join(NL) };
116
+ }
117
+
118
+ if (action === 'move-probe' || action === 'move') {
119
+ const src = currentRoot(env);
120
+ if (!src) throw new Error('未找到全局根(工作区未接入)');
121
+ const to = String(args.to || '').trim();
122
+ if (!to) throw new Error(action === 'move' ? 'move 需要 to(新根绝对路径)' : 'move-probe 需要 to(新根绝对路径)');
123
+ if (action === 'move-probe') {
124
+ const p = await probeMove({ src, to });
125
+ return { text: renderProbe(p) };
126
+ }
127
+ const p = await probeMove({ src, to });
128
+ if (!p.ok) return { text: renderProbe(p) + NL + '未开始复制。' };
129
+ const r = await startMove({ src, to });
130
+ if (!r.ok) {
131
+ out.push('复制完成但**校验有差异**,未生成切换脚本。');
132
+ out.push(' 文件 源 ' + r.report.files + ' / 目标 ' + r.report.copies);
133
+ out.push(' 缺失 ' + r.report.missingCount + ' 条' + (r.report.missing.length ? ':' + r.report.missing.join('、') : ''));
134
+ out.push(' 多出 ' + r.report.extraCount + ' 条' + (r.report.extra.length ? ':' + r.report.extra.join('、') : ''));
135
+ out.push('处置:清空目标目录后重跑,或先停掉在写文件的会话。');
136
+ return { text: out.join(NL) };
137
+ }
138
+ out.push('复制并校验通过:' + r.report.files + ' 文件 · ' + gb(r.report.bytes));
139
+ out.push(' sha256 清单摘要 ' + r.report.digest);
140
+ out.push(' 目标 ' + r.marker.dst);
141
+ out.push(' 切换助手 ' + r.marker.script.helper);
142
+ out.push(' 手工启动器(Windows、POSIX) ' + r.marker.script.cmd + ' 、 ' + r.marker.script.sh);
143
+ out.push('');
144
+ out.push('下一步(三条,顺序不能换):');
145
+ out.push(' ① 完全退出 DSH(源目录被占用,改名会失败)');
146
+ out.push(' ② 执行 ho_workspace(action="move-switch") 派后台助手(推荐);助手会等待 DSH 退出后自动完成');
147
+ out.push(' ③ 重新启动 DSH;确认无误后在设置页点「删除旧副本」');
148
+ return { text: out.join(NL) };
149
+ }
150
+
151
+ if (action === 'move-switch') {
152
+ const r = stageSwitch({ relaunch: args.relaunch === true });
153
+ if (r.already) { out.push('已经是切换态(旧路径就是 junction),无需再切。'); return { text: out.join(NL) }; }
154
+ out.push('已派出后台切换助手(pid ' + r.pid + ')。');
155
+ out.push(' 它会在你**完全退出 DSH** 后自动完成:旧目录改名、原位建 junction、校验指向' + (r.relaunch ? ',然后重新启动 DSH' : '') + '。');
156
+ out.push(' 日志:' + r.log);
157
+ out.push(' 提醒:从现在起不要改这个工作区里的文件(改动只会落在还活着的旧目录上)。');
158
+ return { text: out.join(NL) };
159
+ }
160
+
161
+ if (action === 'move-status') {
162
+ const st = moveStatus(); // 以标记里的源路径为准:修改登记后当前根已是新位置,拿它比对会误报
163
+ const label = { none: '没有搬迁记录', verified: '已复制校验通过,等待切换', switched: '已切换(旧路径是 junction)', partial: '复制有差异,未生成脚本' };
164
+ out.push('搬迁状态:' + (label[st.phase] || st.phase));
165
+ out.push(' 源 ' + st.src + (st.linkTarget ? '(→ ' + st.linkTarget + ')' : ''));
166
+ if (st.dst) out.push(' 目标 ' + st.dst);
167
+ if (st.marker && st.marker.files) out.push(' 清单 ' + st.marker.files + ' 文件 · ' + (st.marker.digest || '').slice(0, 16) + '…');
168
+ if (st.retarget) {
169
+ if (st.retarget.needed) {
170
+ out.push(' DSH 登记仍指着旧路径:' + st.retarget.total + ' 处');
171
+ for (const f of st.retarget.files) out.push(' ' + f.path + ' ×' + f.count);
172
+ out.push(' 修改登记:ho_workspace(action="move-retarget")(退出 DSH 后由助手改写,先备份)');
173
+ } else {
174
+ out.push(' DSH 登记已在新位置(无需修改登记)');
175
+ }
176
+ }
177
+ if (st.olds && st.olds.length) {
178
+ for (const o of st.olds) out.push(' 旧副本 ' + o.path + ' ' + gb(o.bytes));
179
+ out.push(' 删除:ho_workspace(action="move-cleanup", confirm=true)');
180
+ } else if (st.phase === 'switched') {
181
+ out.push(' 没有旧副本残留');
182
+ }
183
+ return { text: out.join(NL) };
184
+ }
185
+
186
+ if (action === 'move-retarget') {
187
+ const r = stageRetarget();
188
+ if (r.already) { out.push('DSH 登记已在新位置,无需修改登记。'); return { text: out.join(NL) }; }
189
+ if (!r.ok) { out.push('派助手失败:' + r.error); out.push(' 手工执行:' + (r.script && r.script.helper)); return { text: out.join(NL) }; }
190
+ out.push('已派出登记修改登记助手(pid ' + r.pid + ')。');
191
+ out.push(' 它会在你完全退出 DSH 后改写这些登记(每份先备份):');
192
+ for (const f of (r.plan.files || [])) if (f.count) out.push(' ' + f.path + ' ×' + f.count);
193
+ out.push(' 只改登记文本,不动任何数据文件;日志:' + (r.script && r.script.log));
194
+ return { text: out.join(NL) };
195
+ }
196
+
197
+ if (action === 'move-repair') {
198
+ const r = repairLink();
199
+ if (r.already) { out.push('原路径已是指向新根的链接,无需重建。'); return { text: out.join(NL) }; }
200
+ out.push('已重建链接:' + r.src + ' → ' + r.dst + '(' + r.kind + ')');
201
+ out.push(' 说明:只建链接,未改动任何文件。');
202
+ return { text: out.join(NL) };
203
+ }
204
+
205
+ if (action === 'move-cleanup') {
206
+ if (args.confirm !== true) throw new Error('move-cleanup 需要 confirm=true(会真删旧副本目录)');
207
+ const r = await cleanupOld({ confirm: true });
208
+ out.push(r.note);
209
+ for (const p of r.removed) out.push(' 已删 ' + p);
210
+ if (r.bytes) out.push(' 释放 ' + gb(r.bytes));
211
+ return { text: out.join(NL) };
212
+ }
213
+
214
+ throw new Error('action 只能是 list / create / delete / move-probe / move / move-switch / move-status / move-retarget / move-repair / move-cleanup');
215
+ },
216
+ };
217
+ }