@mingmaple/dsh-handover 0.0.0-stage → 0.9.6

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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +118 -2
  3. package/docs/migrate.md +61 -0
  4. package/docs/troubleshoot.md +48 -0
  5. package/package.json +55 -3
  6. package/scripts/blank-dsh.mjs +203 -0
  7. package/scripts/blank-smoke.mjs +820 -0
  8. package/scripts/build.mjs +58 -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 +887 -0
  13. package/src/cordis.patch.yml +16 -0
  14. package/src/index.mjs +631 -0
  15. package/src/lib/absrefs.mjs +112 -0
  16. package/src/lib/api.mjs +234 -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 +36 -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 +99 -0
  28. package/src/lib/handover-io.mjs +49 -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 +329 -0
  35. package/src/lib/peer.mjs +260 -0
  36. package/src/lib/phase.mjs +34 -0
  37. package/src/lib/profile.mjs +204 -0
  38. package/src/lib/registry.mjs +53 -0
  39. package/src/lib/retarget-helper.tmpl +46 -0
  40. package/src/lib/role.mjs +217 -0
  41. package/src/lib/rpc-carrier.mjs +65 -0
  42. package/src/lib/scaffold.mjs +118 -0
  43. package/src/lib/schedule.mjs +227 -0
  44. package/src/lib/session-ops.mjs +362 -0
  45. package/src/lib/sessions.mjs +142 -0
  46. package/src/lib/sevenzip.mjs +48 -0
  47. package/src/lib/status.mjs +171 -0
  48. package/src/lib/text.mjs +74 -0
  49. package/src/lib/webroute.mjs +130 -0
  50. package/src/lib/workspace.mjs +23 -0
  51. package/src/lib/zstd-log.mjs +65 -0
  52. package/src/selftest.mjs +84 -0
  53. package/src/templates/Changelog.skeleton.md +10 -0
  54. package/src/templates/Charter.skeleton.md +53 -0
  55. package/src/templates/Handover.global.skeleton.md +56 -0
  56. package/src/templates/Prompt_Templates.md +96 -0
  57. package/src/templates/Runtime_Manual.md +166 -0
  58. package/src/templates/rules/Handover_Rules.md +43 -0
  59. package/src/templates/rules/Migration_Rules.md +32 -0
  60. package/src/templates/rules/Misc_Task_Standard.md +23 -0
  61. package/src/templates/rules/Writing_Rules.md +24 -0
  62. package/src/tools/ho_brief.mjs +57 -0
  63. package/src/tools/ho_changelog.mjs +59 -0
  64. package/src/tools/ho_charter.mjs +89 -0
  65. package/src/tools/ho_checkpoint.mjs +125 -0
  66. package/src/tools/ho_diag.mjs +85 -0
  67. package/src/tools/ho_drift.mjs +41 -0
  68. package/src/tools/ho_gate.mjs +69 -0
  69. package/src/tools/ho_handover.mjs +83 -0
  70. package/src/tools/ho_init.mjs +82 -0
  71. package/src/tools/ho_pack.mjs +38 -0
  72. package/src/tools/ho_phase.mjs +37 -0
  73. package/src/tools/ho_profile.mjs +97 -0
  74. package/src/tools/ho_sessions.mjs +114 -0
  75. package/src/tools/ho_snapshot.mjs +103 -0
  76. package/src/tools/ho_tokens.mjs +57 -0
  77. package/src/tools/ho_workspace.mjs +213 -0
  78. package/src/tools/peer_list.mjs +37 -0
  79. package/src/tools/peer_new.mjs +75 -0
  80. package/src/tools/peer_peek.mjs +41 -0
  81. package/src/tools/peer_rename.mjs +31 -0
  82. package/src/tools/peer_send.mjs +52 -0
  83. package/src/tools/peer_status.mjs +46 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * 项目与杂项的骨架档案:由本会话自行判断、写入、注册;插件只校验与引用。
3
+ * 项目:_Project_Meta/<项目>/project.profile.json
4
+ * 杂项:_Misc/tasks/<任务>/task.profile.json
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { isFile } from './text.mjs';
9
+
10
+ const PROFILE_SCHEMA = 1;
11
+ export const PROFILE_KINDS = ['application', 'animation', 'library', 'general'];
12
+ const PROJECT_FILE = 'project.profile.json';
13
+ const TASK_FILE = 'task.profile.json';
14
+
15
+ const PRESETS = {
16
+ application: {
17
+ kind: 'application',
18
+ required: ['README.md', 'src'],
19
+ optional: ['SKILL.md', 'CHANGELOG.md'],
20
+ docs: ['docs/'],
21
+ gate: { libraryBlock: false },
22
+ notes: '构建型项目:不要求插件类项目的文档集。',
23
+ },
24
+ animation: {
25
+ kind: 'animation',
26
+ required: ['README.md'],
27
+ optional: ['src', 'public', 'assets'],
28
+ docs: [],
29
+ gate: { libraryBlock: false },
30
+ notes: '动画与视频项目:不要求代码库式的目录与文档。',
31
+ },
32
+ library: {
33
+ kind: 'library',
34
+ required: ['README.md', 'RULES.md'],
35
+ optional: ['docs/', 'SKILL.md'],
36
+ docs: ['docs/'],
37
+ gate: { libraryBlock: true },
38
+ notes: '库型项目:启用 24 行通用约束条校验。',
39
+ },
40
+ general: {
41
+ kind: 'general',
42
+ required: [],
43
+ optional: [],
44
+ docs: [],
45
+ gate: { libraryBlock: false },
46
+ notes: '通用骨架:只保留最小约束。',
47
+ },
48
+ };
49
+
50
+ export function presets() {
51
+ return JSON.parse(JSON.stringify(PRESETS));
52
+ }
53
+
54
+ export function profilePathFor(ws, info) {
55
+ if (!ws || !info) return null;
56
+ if (info.role === 'project' && info.project) return path.join(ws, '_Project_Meta', info.project, PROJECT_FILE);
57
+ if (info.role === 'misc' && info.task) return path.join(ws, '_Misc', 'tasks', info.task, TASK_FILE);
58
+ return null;
59
+ }
60
+
61
+ /** 未注册时的默认骨架:沿用现有目录约定,24 行约束条默认启用(向后兼容)。 */
62
+ function defaultProfile(info) {
63
+ const base = {
64
+ schema: PROFILE_SCHEMA,
65
+ kind: 'general',
66
+ root: '',
67
+ required: [],
68
+ optional: [],
69
+ docs: [],
70
+ meta: [],
71
+ gate: { libraryBlock: true },
72
+ handover: { extraSections: [] },
73
+ notes: '未注册:按默认骨架运行。',
74
+ };
75
+ if (info && info.role === 'project' && info.project) {
76
+ base.root = 'Projects/' + info.project;
77
+ base.required = [];
78
+ base.optional = ['SKILL.md'];
79
+ base.meta = [];
80
+ base.notes = '未注册:按默认骨架运行,请由本项目会话补注册。';
81
+ } else if (info && info.role === 'misc' && info.task) {
82
+ base.root = '_Misc/tasks/' + info.task;
83
+ base.required = [];
84
+ base.notes = '未注册:按默认骨架运行,请由本任务会话补注册。';
85
+ }
86
+ return base;
87
+ }
88
+
89
+ function cleanList(v, field, errors) {
90
+ if (v === undefined) return [];
91
+ if (!Array.isArray(v)) { errors.push(field + ' 必须是数组'); return []; }
92
+ const out = [];
93
+ for (const x of v) {
94
+ const s = String(x == null ? '' : x).trim().replace(/\\/g, '/');
95
+ if (!s) continue;
96
+ if (path.isAbsolute(s) || /^[A-Za-z]:/.test(s) || s.split('/').indexOf('..') !== -1) {
97
+ errors.push(field + ' 不得使用绝对路径或 ..:' + s);
98
+ continue;
99
+ }
100
+ if (out.indexOf(s) === -1) out.push(s);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /** 规范化并校验结构;返回 { profile, errors }。路径越界与类型错误进 errors。 */
106
+ export function normalizeProfile(raw, info) {
107
+ const errors = [];
108
+ const src = raw && typeof raw === 'object' ? raw : {};
109
+ const kind = PROFILE_KINDS.indexOf(String(src.kind || '')) !== -1 ? String(src.kind) : 'general';
110
+ const rootDefault = info && info.role === 'project' && info.project
111
+ ? 'Projects/' + info.project
112
+ : (info && info.role === 'misc' && info.task ? '_Misc/tasks/' + info.task : '');
113
+ const root = String(src.root || rootDefault).trim().replace(/\\/g, '/');
114
+ if (path.isAbsolute(root) || /^[A-Za-z]:/.test(root) || root.split('/').indexOf('..') !== -1) {
115
+ errors.push('root 不得使用绝对路径或 ..');
116
+ }
117
+ const profile = {
118
+ schema: PROFILE_SCHEMA,
119
+ kind,
120
+ root,
121
+ required: cleanList(src.required, 'required', errors),
122
+ optional: cleanList(src.optional, 'optional', errors),
123
+ docs: cleanList(src.docs, 'docs', errors),
124
+ meta: cleanList(src.meta, 'meta', errors),
125
+ gate: { libraryBlock: !!(src.gate && src.gate.libraryBlock === true) },
126
+ handover: { extraSections: cleanList(src.handover && src.handover.extraSections, 'handover.extraSections', errors) },
127
+ notes: String(src.notes == null ? '' : src.notes).trim(),
128
+ };
129
+ return { profile, errors };
130
+ }
131
+
132
+ export function readProfile(ws, info) {
133
+ const p = profilePathFor(ws, info);
134
+ if (!p) return { exists: false, path: null, profile: defaultProfile(info), errors: [] };
135
+ if (!isFile(p)) return { exists: false, path: p, profile: defaultProfile(info), errors: [] };
136
+ try {
137
+ const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
138
+ const r = normalizeProfile(raw, info);
139
+ return { exists: true, path: p, profile: r.profile, errors: r.errors };
140
+ } catch (e) {
141
+ return { exists: true, path: p, profile: defaultProfile(info), errors: ['读或解析失败:' + (e && e.message ? e.message : String(e))] };
142
+ }
143
+ }
144
+
145
+ export function writeProfile(ws, info, raw) {
146
+ const p = profilePathFor(ws, info);
147
+ if (!p) throw new Error('当前角色没有骨架路径(须为项目或杂项)');
148
+ const r = normalizeProfile(raw, info);
149
+ if (r.errors.length) throw new Error('骨架结构不合法:' + r.errors.join(';'));
150
+ fs.mkdirSync(path.dirname(p), { recursive: true });
151
+ if (isFile(p)) fs.copyFileSync(p, p + '.bak');
152
+ fs.writeFileSync(p, JSON.stringify(r.profile, null, 2) + String.fromCharCode(10), 'utf8');
153
+ return { path: p, profile: r.profile };
154
+ }
155
+
156
+ /** 校验:结构错误与必需项缺失分开报;docs 与 optional 不强制存在。 */
157
+ export function validateProfile(ws, info, profile) {
158
+ const p = profile || readProfile(ws, info).profile;
159
+ const errors = [];
160
+ const missing = [];
161
+ const root = path.join(ws, p.root || '');
162
+ for (const rel of p.required || []) {
163
+ const f = path.join(root, rel);
164
+ if (!fs.existsSync(f)) missing.push(rel);
165
+ }
166
+ const metaRoot = info && info.role === 'project' && info.project
167
+ ? path.join(ws, '_Project_Meta', info.project)
168
+ : (info && info.role === 'misc' && info.task ? path.join(ws, '_Misc', 'tasks', info.task) : null);
169
+ // meta 条目相对档案目录(_Project_Meta/<项目>/ 或 _Misc/tasks/<任务>/),不是库内路径;
170
+ // 报缺时给全路径,避免被读成库内或某个 _meta/ 子目录。
171
+ const metaLabel = info && info.role === 'project' && info.project
172
+ ? '_Project_Meta/' + info.project + '/'
173
+ : (info && info.task ? '_Misc/tasks/' + info.task + '/' : '');
174
+ if (metaRoot) {
175
+ for (const rel of p.meta || []) {
176
+ if (!fs.existsSync(path.join(metaRoot, rel))) missing.push(metaLabel + rel);
177
+ }
178
+ }
179
+ return { ok: errors.length === 0 && missing.length === 0, errors, missing };
180
+ }
181
+
182
+ /** 已注册骨架清单(读文件,不写):项目与杂项各一组。 */
183
+ export function listProfiles(ws) {
184
+ const out = [];
185
+ const scan = (dir, role, nameKey) => {
186
+ let names = [];
187
+ try { names = fs.readdirSync(dir); } catch { return; }
188
+ for (const name of names) {
189
+ const d = path.join(dir, name);
190
+ try { if (!fs.statSync(d).isDirectory()) continue; } catch { continue; }
191
+ const file = path.join(d, role === 'project' ? PROJECT_FILE : TASK_FILE);
192
+ const info = { role, project: role === 'project' ? name : '', task: role === 'misc' ? name : '' };
193
+ const r = readProfile(ws, info);
194
+ const v = validateProfile(ws, info, r.profile);
195
+ out.push({
196
+ role, name, path: file, exists: r.exists,
197
+ kind: r.profile.kind, ok: v.ok, missing: v.missing, errors: r.errors.concat(v.errors),
198
+ });
199
+ }
200
+ };
201
+ scan(path.join(ws, '_Project_Meta'), 'project');
202
+ scan(path.join(ws, '_Misc', 'tasks'), 'misc');
203
+ return out;
204
+ }
@@ -0,0 +1,53 @@
1
+ /** 工作区与会话生命周期:只走 DSH 官方服务(workspaceRegistry),读不到就明确报错,不自己改注册表文件。 */
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { readUtf8 } from './text.mjs';
5
+
6
+ function workspaceJsonPath() {
7
+ return path.join(os.homedir(), '.dsh', 'storages', 'workspace.json');
8
+ }
9
+
10
+ /** 读 workspace.json 的 workspaces 表(只读;读不到返回空表)。 */
11
+ function readWorkspaceTable() {
12
+ try {
13
+ const data = JSON.parse(readUtf8(workspaceJsonPath()));
14
+ return (data && data.tables && data.tables.workspaces) || {};
15
+ } catch { return {}; }
16
+ }
17
+
18
+ /** 官方服务:ctx.workspaceRegistry(session-controller 就是这么用的);无法获取返回 null。 */
19
+ export function resolveRegistry(ctx) {
20
+ let v = null;
21
+ try { v = ctx.workspaceRegistry || null; } catch { /* ignore */ }
22
+ if (!v) { try { v = typeof ctx.get === 'function' ? ctx.get('workspaceRegistry') : null; } catch { /* ignore */ } }
23
+ return v || null;
24
+ }
25
+
26
+ /** 「全局」工作区里的会话 id 集合(判定会话是否属于全局)。 */
27
+ export function globalWorkspaceSessionIds() {
28
+ const out = [];
29
+ const tables = readWorkspaceTable();
30
+ for (const id of Object.keys(tables)) {
31
+ const w = tables[id] || {};
32
+ const title = typeof w.title === 'string' ? w.title : '';
33
+ if (title.indexOf('全局') === -1) continue;
34
+ for (const sid of (Array.isArray(w.sessionIds) ? w.sessionIds : [])) {
35
+ if (out.indexOf(sid) === -1) out.push(sid);
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+
41
+ /** 工作区表投影:id / 标题 / 路径 / 会话数,供 ho_workspace list 用。 */
42
+ export function workspaceRows() {
43
+ const tables = readWorkspaceTable();
44
+ return Object.keys(tables).map((id) => {
45
+ const w = tables[id] || {};
46
+ return {
47
+ id,
48
+ title: typeof w.title === 'string' ? w.title : '-',
49
+ path: typeof w.path === 'string' ? w.path : '-',
50
+ sessions: Array.isArray(w.sessionIds) ? w.sessionIds.length : 0,
51
+ };
52
+ });
53
+ }
@@ -0,0 +1,46 @@
1
+ // dsh-handover 登记改址助手(由插件生成,勿手改)
2
+ // 只改 DSH 的登记文件(workspace.json、聊天集成的归属表),不动任何数据文件。
3
+ import fs from 'node:fs';
4
+
5
+ const LOG = %%LOG%%;
6
+ const WAIT_PID = Number(process.argv[2] || 0) || 0;
7
+ const FROM = %%FROM%%;
8
+ const TO = %%TO%%;
9
+ const FILES = %%FILES%%;
10
+
11
+ function log(m) {
12
+ try { fs.appendFileSync(LOG, new Date().toISOString() + ' ' + m + '\n', 'utf8'); } catch { /* ignore */ }
13
+ try { console.log(m); } catch { /* ignore */ }
14
+ }
15
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
+ function alive(pid) {
17
+ if (!pid) return false;
18
+ try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === 'EPERM'; }
19
+ }
20
+ const esc = (p) => JSON.stringify(String(p)).slice(1, -1);
21
+
22
+ (async () => {
23
+ log('== 登记改址助手 == pid=' + process.pid);
24
+ if (WAIT_PID) {
25
+ log('等待 DSH 退出(最多 30 分钟)…');
26
+ for (let i = 0; i < 900; i++) { if (!alive(WAIT_PID)) break; await sleep(2000); }
27
+ if (alive(WAIT_PID)) { log('[中止] 等 DSH 退出超时,未做任何改动'); process.exit(1); }
28
+ await sleep(1500);
29
+ log('DSH 已退出,开始改址');
30
+ }
31
+ const from = esc(FROM);
32
+ const to = esc(TO);
33
+ let changed = 0;
34
+ for (const p of FILES) {
35
+ let text = '';
36
+ try { text = fs.readFileSync(p, 'utf8'); } catch { log('跳过(读不到):' + p); continue; }
37
+ const n = text.split(from).length - 1;
38
+ if (!n) { log('无需改:' + p); continue; }
39
+ const bak = p + '.bak-' + new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
40
+ try { fs.copyFileSync(p, bak); } catch { log('跳过(备份失败):' + p); continue; }
41
+ try { fs.writeFileSync(p, text.split(from).join(to), 'utf8'); } catch (e) { log('跳过(写入失败):' + p + ' :: ' + e.message); continue; }
42
+ changed += n;
43
+ log('已改 ' + n + ' 处:' + p + ' 备份 ' + bak);
44
+ }
45
+ log(changed ? ('完成:共改 ' + changed + ' 处登记,重启 DSH 后工作区即在新位置。') : '无需改动。');
46
+ })();
@@ -0,0 +1,217 @@
1
+ /**
2
+ * 会话角色:判定、逐角色交接路径、工具集与文案。角色表是本机状态(不进仓库、不进迁移包)。
3
+ * 判定顺序:角色表 → 全局标题 → 全局工作区 → 子会话(默认普通)→ cwd(Projects/<名> 或 _Misc/tasks/<任务>)→ 首条提示 → 普通。
4
+ */
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { readJsonDir, pick } from './text.mjs';
9
+ import { projCacheDir, isSubagent } from './sessions.mjs';
10
+ import { globalWorkspaceSessionIds } from './registry.mjs';
11
+ import { TOOL_NAMES, PEER_NAMES, nameOf, peerNameOf } from './names.mjs';
12
+ import { stampTz } from './brief.mjs';
13
+
14
+ export const ROLES = ['global', 'project', 'misc', 'plain'];
15
+ export const ROLE_LABEL = { global: '全局', project: '项目', misc: '杂项', plain: '普通' };
16
+
17
+ export const ALL_TOOL_NAMES = TOOL_NAMES.concat(PEER_NAMES);
18
+
19
+ /** 角色 → 可见工具(其余一律 deny)。改这张表就是改成本结构。 */
20
+ const ROLE_TOOLS = {
21
+ global: ALL_TOOL_NAMES.slice(),
22
+ project: [nameOf('phase'), nameOf('handover'), nameOf('checkpoint'), nameOf('charter'), nameOf('gate'), nameOf('snapshot'), nameOf('profile'), peerNameOf('send'), peerNameOf('status')],
23
+ misc: [nameOf('phase'), nameOf('handover'), nameOf('checkpoint'), nameOf('charter'), nameOf('snapshot'), nameOf('profile'), peerNameOf('send'), peerNameOf('status'), peerNameOf('new')],
24
+ plain: [],
25
+ };
26
+
27
+ export function toolsetFor(role) {
28
+ const allow = ROLE_TOOLS[role] || ROLE_TOOLS.plain;
29
+ const deny = ALL_TOOL_NAMES.filter((n) => allow.indexOf(n) === -1);
30
+ return { allow: allow.slice(), deny };
31
+ }
32
+
33
+ // ---------------------------------------------------------------- 角色表(本机状态)
34
+
35
+ export function rolesPath(cfg) {
36
+ try {
37
+ if (cfg && cfg.rolesPath) return String(cfg.rolesPath);
38
+ } catch { /* ignore */ }
39
+ return path.join(os.homedir(), '.dsh', 'storages', 'dsh-handover.roles.json');
40
+ }
41
+
42
+ export function readRoles(cfg) {
43
+ try {
44
+ const j = JSON.parse(fs.readFileSync(rolesPath(cfg), 'utf8'));
45
+ const sessions = j && typeof j.sessions === 'object' && j.sessions ? j.sessions : {};
46
+ return { version: 1, sessions };
47
+ } catch {
48
+ return { version: 1, sessions: {} };
49
+ }
50
+ }
51
+
52
+ function writeRolesFile(cfg, data) {
53
+ const file = rolesPath(cfg);
54
+ fs.mkdirSync(path.dirname(file), { recursive: true });
55
+ const tmp = file + '.tmp-' + process.pid;
56
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + String.fromCharCode(10), 'utf8');
57
+ fs.renameSync(tmp, file);
58
+ return file;
59
+ }
60
+
61
+ /** 写入/更新一条角色;返回写入后的条目。 */
62
+ export function writeRole(cfg, sessionId, patch) {
63
+ const sid = String(sessionId || '');
64
+ if (!sid) throw new Error('缺会话 id');
65
+ const data = readRoles(cfg);
66
+ const prev = data.sessions[sid] || {};
67
+ const next = Object.assign({}, prev, patch || {});
68
+ next.setAt = stampTz(Date.now());
69
+ // 角色变了就重新引导一次;只是改项目名也重引导,因为路径变了。
70
+ if (prev.role !== next.role || prev.project !== next.project || prev.task !== next.task) delete next.bootstrappedAt;
71
+ data.sessions[sid] = next;
72
+ writeRolesFile(cfg, data);
73
+ return next;
74
+ }
75
+
76
+ export function bootstrappedAt(cfg, sessionId) {
77
+ const cur = readRoles(cfg).sessions[String(sessionId || '')];
78
+ return cur && cur.bootstrappedAt ? String(cur.bootstrappedAt) : '';
79
+ }
80
+
81
+ // ---------------------------------------------------------------- 判定
82
+
83
+ /** 从投影缓存读会话的 cwd 与首条提示(读不到返回空串)。 */
84
+ export function sessionMeta(sessionId) {
85
+ const sid = String(sessionId || '');
86
+ if (!sid) return { cwd: '', firstPrompt: '', subagent: false };
87
+ for (const f of readJsonDir(projCacheDir())) {
88
+ if (f.name.replace('.json', '') !== sid) continue;
89
+ return {
90
+ cwd: String(pick(f.data, 'record', 'identity', 'cwd') || ''),
91
+ firstPrompt: String(pick(f.data, 'record', 'rows', 'titleInput', 'val', 'first', 'text') || ''),
92
+ subagent: isSubagent(f.data),
93
+ };
94
+ }
95
+ return { cwd: '', firstPrompt: '', subagent: false };
96
+ }
97
+
98
+ function parts(p) {
99
+ return String(p || '').replace(/\\/g, '/').split('/').filter(Boolean);
100
+ }
101
+
102
+ /** cwd 落在 Projects/<名>/ 或 _Misc/tasks/<任务>/ 里就据此判定。 */
103
+ function roleFromPath(cwd) {
104
+ const seg = parts(cwd);
105
+ for (let i = 0; i < seg.length; i++) {
106
+ if (seg[i] === 'Projects' && seg[i + 1] && seg[i + 1] !== '_Project_Meta') {
107
+ return { role: 'project', project: seg[i + 1], task: '' };
108
+ }
109
+ if (seg[i] === '_Misc' && seg[i + 1] === 'tasks' && seg[i + 2]) {
110
+ return { role: 'misc', project: '', task: seg[i + 2] };
111
+ }
112
+ if (seg[i] === '_Project_Meta' && seg[i + 1]) {
113
+ return { role: 'project', project: seg[i + 1], task: '' };
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /** 首条提示里能解析出 Projects/<名>/ 或「<名>」项目 就算项目会话。 */
120
+ function roleFromPrompt(text) {
121
+ const t = String(text || '').replace(/\\/g, '/'); // Windows 路径也能解析
122
+ const i = t.indexOf('Projects/');
123
+ if (i !== -1) {
124
+ const rest = t.slice(i + 'Projects/'.length);
125
+ let name = '';
126
+ for (const ch of rest) {
127
+ if (ch === '/' || ch === ' ' || ch === String.fromCharCode(9) || ch === String.fromCharCode(10) || ch === String.fromCharCode(13)) break;
128
+ name += ch;
129
+ }
130
+ if (name) return { role: 'project', project: name, task: '' };
131
+ }
132
+ const q = t.indexOf(String.fromCharCode(12300));
133
+ if (q !== -1) {
134
+ const rest = t.slice(q + 1);
135
+ const j = rest.indexOf(String.fromCharCode(12301) + '项目');
136
+ if (j > 0) return { role: 'project', project: rest.slice(0, j), task: '' };
137
+ }
138
+ return null;
139
+ }
140
+
141
+ /**
142
+ * 判定一个会话的角色。
143
+ * @param {{ cfg?: object, sessionId?: string, title?: string, cwd?: string, firstPrompt?: string, roles?: object }} opts
144
+ */
145
+ export function resolveRole(opts) {
146
+ const o = opts || {};
147
+ const sid = String(o.sessionId || '');
148
+ const store = o.roles || readRoles(o.cfg);
149
+ const hit = sid ? store.sessions[sid] : null;
150
+ if (hit) {
151
+ const role = String(hit.role || '');
152
+ if (ROLES.indexOf(role) !== -1) {
153
+ return { role, project: String(hit.project || ''), task: String(hit.task || ''), source: 'store', sessionId: sid };
154
+ }
155
+ // 旧角色表的 unknown 是兜底态,按新口径并入普通。
156
+ if (role === 'unknown') {
157
+ return { role: 'plain', project: String(hit.project || ''), task: String(hit.task || ''), source: 'store', sessionId: sid };
158
+ }
159
+ }
160
+ const title = String(o.title || '');
161
+ if (title.indexOf('全局') !== -1) return { role: 'global', project: '', task: '', source: 'title', sessionId: sid };
162
+ try {
163
+ if (sid && globalWorkspaceSessionIds().indexOf(sid) !== -1) {
164
+ return { role: 'global', project: '', task: '', source: 'workspace', sessionId: sid };
165
+ }
166
+ } catch { /* 读不到工作区表就继续往下判 */ }
167
+ // 子会话默认按普通处理,不再按 cwd 注入项目段;项目中显式指定角色的子会话仍以角色表为准。
168
+ if (o.subagent === true) return { role: 'plain', project: '', task: '', source: 'subagent', sessionId: sid };
169
+ const byPath = roleFromPath(o.cwd);
170
+ if (byPath) return Object.assign(byPath, { source: 'cwd', sessionId: sid });
171
+ const byPrompt = roleFromPrompt(o.firstPrompt);
172
+ if (byPrompt) return Object.assign(byPrompt, { source: 'prompt', sessionId: sid });
173
+ return { role: 'plain', project: '', task: '', source: 'none', sessionId: sid };
174
+ }
175
+
176
+ // ---------------------------------------------------------------- 路径
177
+
178
+ /**
179
+ * 逐角色交接路径。
180
+ * @returns {{ path: string|null, kind: string|null, scope: string, role: string, reason?: string }}
181
+ */
182
+ export function handoverPathFor(ws, info) {
183
+ const role = info && info.role ? info.role : 'plain';
184
+ if (!ws) return { path: null, kind: null, scope: '', role, reason: '没有工作区根' };
185
+ if (role === 'global') {
186
+ return { path: path.join(ws, '_Control_Center', 'records', 'Global_Handover.md'), kind: 'global', scope: '_Global', role };
187
+ }
188
+ if (role === 'project') {
189
+ const project = String(info.project || '');
190
+ if (!project) return { path: null, kind: null, scope: '', role, reason: '项目名缺失' };
191
+ return { path: path.join(ws, '_Project_Meta', project, 'Handover.md'), kind: 'project', scope: project, role, project };
192
+ }
193
+ if (role === 'misc') {
194
+ const task = String(info.task || '');
195
+ if (!task) return { path: null, kind: null, scope: '', role, reason: '任务名缺失' };
196
+ return { path: path.join(ws, '_Misc', 'tasks', task, 'Handover.md'), kind: 'misc', scope: path.join('_Misc', task), role, task };
197
+ }
198
+ if (role === 'plain') return { path: null, kind: null, scope: '', role, reason: '普通会话不参与交接' };
199
+ return { path: null, kind: null, scope: '', role, reason: '本会话未设定角色' };
200
+ }
201
+
202
+ export function snapshotDirFor(ws, info) {
203
+ const t = handoverPathFor(ws, info);
204
+ return path.join(ws, '_Temp', t.scope || '_Global', 'Snapshots');
205
+ }
206
+
207
+ export function archiveDirFor(ws, info) {
208
+ const t = handoverPathFor(ws, info);
209
+ return path.join(ws, '_Archives', t.scope || '_Global', 'Handover');
210
+ }
211
+
212
+ export function scopeLabel(info) {
213
+ const role = info && info.role ? info.role : 'plain';
214
+ if (role === 'project') return '项目「' + (info.project || '?') + '」';
215
+ if (role === 'misc') return '杂项任务「' + (info.task || '?') + '」';
216
+ return ROLE_LABEL[role] || role;
217
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * DSH 认证的 /api 载体(客户端设置页取数/下指令的**主通道**)。
3
+ *
4
+ * 为什么不用裸 fetch:本机 GUI 服务器对非浏览器策略的请求直接 403「Browser access is disabled」,
5
+ * 而 DSH 的 /api 载体自带浏览器鉴权与 Host/Origin 信任,dsh-im 的设置页正是走这条。
6
+ * 契约(与 DSH 内置管理 RPC 一致):
7
+ * 入:POST /api/dsh-handover body={type:'client-request', rpcId, method:'dsh-handover', payload:{method, payload}}
8
+ * 出:{type:'server-response', rpcId, result:{ok:true,value} | {ok:false,error:{code,message,details}}}
9
+ * 旧宿主没有 connection 服务时静默跳过,HTTP 路由继续兜底。
10
+ */
11
+ const CARRIER_PATH = '/api/dsh-handover';
12
+ const CARRIER_METHOD = 'dsh-handover';
13
+
14
+ export function registerRpcCarrier(ctx, api, log) {
15
+ const say = typeof log === 'function' ? log : () => {};
16
+ let inner;
17
+ try {
18
+ inner = (cc) => {
19
+ const conn = cc && cc.connection ? cc.connection : null;
20
+ const reg = conn && conn.fetch && typeof conn.fetch.register === 'function' ? conn.fetch : null;
21
+ if (!reg) { say('载体:connection.fetch.register 不可用,退回 HTTP 路由'); return; }
22
+ let dispose = null;
23
+ try {
24
+ dispose = reg.register.call(reg, {
25
+ path: CARRIER_PATH,
26
+ methods: ['POST'],
27
+ requestBody: 'buffered',
28
+ async fetch(request) {
29
+ if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });
30
+ let msg = null;
31
+ try { msg = await request.json(); } catch { return new Response('body is not JSON', { status: 400 }); }
32
+ const rpcId = msg && typeof msg.rpcId === 'string' ? msg.rpcId : 'invalid-request';
33
+ const call = msg && msg.payload;
34
+ const sane = msg && msg.type === 'client-request' && msg.method === CARRIER_METHOD
35
+ && call && typeof call.method === 'string' && Object.prototype.hasOwnProperty.call(call, 'payload');
36
+ if (!sane) {
37
+ return Response.json({ type: 'server-response', rpcId, result: { ok: false, error: { code: 'bad-request', message: '信封不合法', details: {} } } });
38
+ }
39
+ let r;
40
+ try { r = await api.call(call.method, call.payload); }
41
+ catch (e) { r = { ok: false, error: { code: 'failed', message: String(e && e.message ? e.message : e), details: {} } }; }
42
+ say('载体 ' + call.method + (r.ok ? ' ok' : (' 失败:' + String((r.error && r.error.message) || ''))));
43
+ return Response.json({ type: 'server-response', rpcId, result: r });
44
+ },
45
+ });
46
+ say('载体:已注册 ' + CARRIER_PATH);
47
+ } catch (e) {
48
+ say('载体注册失败:' + String(e && e.message ? e.message : e));
49
+ return;
50
+ }
51
+ try {
52
+ if (typeof cc.effect === 'function') cc.effect(() => dispose, 'dsh-handover: /api 载体');
53
+ } catch { /* ignore */ }
54
+ };
55
+ } catch (e) {
56
+ say('载体装配失败:' + String(e && e.message ? e.message : e));
57
+ return;
58
+ }
59
+ try {
60
+ if (typeof ctx.inject === 'function') ctx.inject(['connection'], inner);
61
+ else say('载体:宿主不支持 ctx.inject,退回 HTTP 路由');
62
+ } catch (e) {
63
+ say('载体装配失败:' + String(e && e.message ? e.message : e));
64
+ }
65
+ }