@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,36 @@
1
+ /** 阶段决策:纯函数,便于单测。 */
2
+
3
+ /**
4
+ * @param {{ratio:number|null, compactionsUsed:number, cfg:object, lockState:string}} input
5
+ * @returns {{action:string, reason:string}}
6
+ * action ∈ continue | pause | checkpoint | new_session | force_compact | blocked | unknown
7
+ */
8
+ export function decide(input) {
9
+ const cfg = input.cfg || {};
10
+ const warn = cfg.warnRatio === undefined ? 0.32 : cfg.warnRatio;
11
+ const handover = cfg.handoverRatio === undefined ? 0.35 : cfg.handoverRatio;
12
+ const hard = cfg.hardRatio === undefined ? 0.85 : cfg.hardRatio;
13
+ const maxComp = cfg.maxCompactions === undefined ? 3 : cfg.maxCompactions;
14
+ const used = typeof input.compactionsUsed === 'number' ? input.compactionsUsed : 0;
15
+ if (input.lockState === 'orphaned') {
16
+ return { action: 'blocked', reason: '检测到未闭合的压缩锁(compaction/start 无配对 end):先处理再排新压缩' };
17
+ }
18
+ // 额度用尽优先于水位判定:3 次压满后再压不是「水位没到」,而是该换任了。
19
+ // 放在水位判定之前,force 也就绕不过去(force 只在水位分支里放行)。
20
+ if (used >= maxComp) {
21
+ return { action: 'new_session', reason: '已压缩 ' + used + '/' + maxComp + ' 次:压缩额度用尽,应写交接并建新会话' };
22
+ }
23
+ if (input.ratio === null || input.ratio === undefined) {
24
+ return { action: 'unknown', reason: '水位读不到:人工判断,不要盲目压缩' };
25
+ }
26
+ if (input.ratio >= hard) {
27
+ return { action: 'force_compact', reason: '已到硬顶(' + Math.round(hard * 100) + '%):强制压缩兜底,哪怕任务中途' };
28
+ }
29
+ if (input.ratio >= handover) {
30
+ return { action: 'checkpoint', reason: '过交接线(' + Math.round(handover * 100) + '%):写交接,随后压缩,再读交接续跑' };
31
+ }
32
+ if (input.ratio >= warn) {
33
+ return { action: 'pause', reason: '过提醒线(' + Math.round(warn * 100) + '%):建议在阶段完成处调 ho_checkpoint(关键步骤返回 pause)' };
34
+ }
35
+ return { action: 'continue', reason: '水位正常' };
36
+ }
@@ -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,18 @@
1
+ /**
2
+ * 会话投递的唯一入口:把一条纯文本作为用户消息排进目标会话。
3
+ * 角色整理指令、换任启动提示词、压缩后继续,三处都用它,避免各自拼一套 requestId 与中止信号。
4
+ */
5
+
6
+ /** 投递一条文本;controller 由调用方按自己的服务句柄给出。 */
7
+ export function sendPrompt(controller, sessionId, text, mode) {
8
+ if (!controller || typeof controller.prompt !== 'function') throw new Error('sessionController.prompt 不可用');
9
+ const requestId = (globalThis.crypto && globalThis.crypto.randomUUID)
10
+ ? globalThis.crypto.randomUUID()
11
+ : String(Date.now());
12
+ return controller.prompt({
13
+ requestId,
14
+ sessionId: String(sessionId),
15
+ mode: mode || 'queue',
16
+ content: [{ type: 'text', text: String(text) }],
17
+ }, new AbortController().signal);
18
+ }
@@ -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,251 @@
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, isSafeName, assertSafeName } 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
+ // 名字要落成目录:不合法就当场拒绝,别等拼路径时才 ENOENT。
69
+ if (next.role === 'project') assertProjectName(next.project);
70
+ if (next.role === 'misc') assertTaskName(next.task);
71
+ next.setAt = stampTz(Date.now());
72
+ data.sessions[sid] = next;
73
+ writeRolesFile(cfg, data);
74
+ return next;
75
+ }
76
+
77
+
78
+ // ---------------------------------------------------------------- 判定
79
+
80
+ /** 从投影缓存读会话的 cwd 与首条提示(读不到返回空串)。 */
81
+ export function sessionMeta(sessionId) {
82
+ const sid = String(sessionId || '');
83
+ if (!sid) return { cwd: '', firstPrompt: '', subagent: false };
84
+ for (const f of readJsonDir(projCacheDir())) {
85
+ if (f.name.replace('.json', '') !== sid) continue;
86
+ return {
87
+ cwd: String(pick(f.data, 'record', 'identity', 'cwd') || ''),
88
+ firstPrompt: String(pick(f.data, 'record', 'rows', 'titleInput', 'val', 'first', 'text') || ''),
89
+ subagent: isSubagent(f.data),
90
+ };
91
+ }
92
+ return { cwd: '', firstPrompt: '', subagent: false };
93
+ }
94
+
95
+ function parts(p) {
96
+ return String(p || '').replace(/\\/g, '/').split('/').filter(Boolean);
97
+ }
98
+
99
+ /** 项目名当目录名用,长度与字符集都比任务名紧一些;任务名放宽到 40 字符。 */
100
+ export const PROJECT_NAME_MAX = 24;
101
+ export const TASK_NAME_MAX = 40;
102
+
103
+ export function projectNameOk(name) {
104
+ return isSafeName(name, PROJECT_NAME_MAX);
105
+ }
106
+
107
+ export function taskNameOk(name) {
108
+ return isSafeName(name, TASK_NAME_MAX);
109
+ }
110
+
111
+ export function assertProjectName(name) {
112
+ return assertSafeName(name, '项目名', PROJECT_NAME_MAX);
113
+ }
114
+
115
+ export function assertTaskName(name) {
116
+ return assertSafeName(name, '任务名', TASK_NAME_MAX);
117
+ }
118
+
119
+ /** cwd 落在 Projects/<名>/ 或 _Misc/tasks/<任务>/ 里就据此判定;名字不合法一律不认。 */
120
+ function roleFromPath(cwd) {
121
+ const seg = parts(cwd);
122
+ for (let i = 0; i < seg.length; i++) {
123
+ if (seg[i] === 'Projects' && projectNameOk(seg[i + 1]) && seg[i + 1] !== '_Project_Meta') {
124
+ return { role: 'project', project: seg[i + 1], task: '' };
125
+ }
126
+ if (seg[i] === '_Misc' && seg[i + 1] === 'tasks' && taskNameOk(seg[i + 2])) {
127
+ return { role: 'misc', project: '', task: seg[i + 2] };
128
+ }
129
+ if (seg[i] === '_Project_Meta' && projectNameOk(seg[i + 1])) {
130
+ return { role: 'project', project: seg[i + 1], task: '' };
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /** 首条提示里能解析出 Projects/<名>/ 或「<名>」项目 就算项目会话;名字不合法不认。 */
137
+ function roleFromPrompt(text) {
138
+ const t = String(text || '').replace(/\\/g, '/'); // Windows 路径也能解析
139
+ const i = t.indexOf('Projects/');
140
+ if (i !== -1) {
141
+ const rest = t.slice(i + 'Projects/'.length);
142
+ let name = '';
143
+ for (const ch of rest) {
144
+ if (ch === '/' || ch === ' ' || ch === String.fromCharCode(9) || ch === String.fromCharCode(10) || ch === String.fromCharCode(13)) break;
145
+ name += ch;
146
+ }
147
+ // 首条提示常把规则写成 `_Control_Center/**、Projects/**` 这样的通配,解析结果必须过名称校验。
148
+ if (projectNameOk(name)) return { role: 'project', project: name, task: '' };
149
+ }
150
+ const q = t.indexOf(String.fromCharCode(12300));
151
+ if (q !== -1) {
152
+ const rest = t.slice(q + 1);
153
+ const j = rest.indexOf(String.fromCharCode(12301) + '项目');
154
+ if (j > 0) {
155
+ const name = rest.slice(0, j);
156
+ if (projectNameOk(name)) return { role: 'project', project: name, task: '' };
157
+ }
158
+ }
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * 判定一个会话的角色。
164
+ * @param {{ cfg?: object, sessionId?: string, title?: string, cwd?: string, firstPrompt?: string, roles?: object }} opts
165
+ */
166
+ export function resolveRole(opts) {
167
+ const o = opts || {};
168
+ const sid = String(o.sessionId || '');
169
+ const store = o.roles || readRoles(o.cfg);
170
+ const hit = sid ? store.sessions[sid] : null;
171
+ if (hit) {
172
+ const role = String(hit.role || '');
173
+ if (ROLES.indexOf(role) !== -1) {
174
+ // 角色表里的名字要当目录名用。历史写入或手工编辑留下的非法名字一律不认,继续往下推断,
175
+ // 否则会把整条「写交接 → 备份 → 归档」链路卡在 Windows 的非法路径上。
176
+ const named = role === 'project' ? projectNameOk(hit.project)
177
+ : role === 'misc' ? taskNameOk(hit.task) : true;
178
+ if (named) {
179
+ return { role, project: String(hit.project || ''), task: String(hit.task || ''), source: 'store', sessionId: sid };
180
+ }
181
+ }
182
+ // 旧角色表的 unknown 是兜底态,按新口径并入普通。
183
+ if (role === 'unknown') {
184
+ return { role: 'plain', project: String(hit.project || ''), task: String(hit.task || ''), source: 'store', sessionId: sid };
185
+ }
186
+ }
187
+ const title = String(o.title || '');
188
+ if (title.indexOf('全局') !== -1) return { role: 'global', project: '', task: '', source: 'title', sessionId: sid };
189
+ try {
190
+ if (sid && globalWorkspaceSessionIds().indexOf(sid) !== -1) {
191
+ return { role: 'global', project: '', task: '', source: 'workspace', sessionId: sid };
192
+ }
193
+ } catch { /* 读不到工作区表就继续往下判 */ }
194
+ // 子会话默认按普通处理,不再按 cwd 注入项目段;项目中显式指定角色的子会话仍以角色表为准。
195
+ if (o.subagent === true) return { role: 'plain', project: '', task: '', source: 'subagent', sessionId: sid };
196
+ const byPath = roleFromPath(o.cwd);
197
+ if (byPath) return Object.assign(byPath, { source: 'cwd', sessionId: sid });
198
+ const byPrompt = roleFromPrompt(o.firstPrompt);
199
+ if (byPrompt) return Object.assign(byPrompt, { source: 'prompt', sessionId: sid });
200
+ return { role: 'plain', project: '', task: '', source: 'none', sessionId: sid };
201
+ }
202
+
203
+ // ---------------------------------------------------------------- 路径
204
+
205
+ /**
206
+ * 逐角色交接路径。
207
+ * @returns {{ path: string|null, kind: string|null, scope: string, role: string, reason?: string }}
208
+ */
209
+ export function handoverPathFor(ws, info) {
210
+ const role = info && info.role ? info.role : 'plain';
211
+ if (!ws) return { path: null, kind: null, scope: '', role, reason: '没有工作区根' };
212
+ if (role === 'global') {
213
+ return { path: path.join(ws, '_Control_Center', 'records', 'Global_Handover.md'), kind: 'global', scope: '_Global', role };
214
+ }
215
+ if (role === 'project') {
216
+ const project = String(info.project || '');
217
+ if (!project) return { path: null, kind: null, scope: '', role, reason: '项目名缺失' };
218
+ if (!projectNameOk(project)) return { path: null, kind: null, scope: '', role, reason: '项目名不合法(当目录名用):' + JSON.stringify(project) };
219
+ return { path: path.join(ws, '_Project_Meta', project, 'Handover.md'), kind: 'project', scope: project, role, project };
220
+ }
221
+ if (role === 'misc') {
222
+ const task = String(info.task || '');
223
+ if (!task) return { path: null, kind: null, scope: '', role, reason: '任务名缺失' };
224
+ if (!taskNameOk(task)) return { path: null, kind: null, scope: '', role, reason: '任务名不合法(当目录名用):' + JSON.stringify(task) };
225
+ return { path: path.join(ws, '_Misc', 'tasks', task, 'Handover.md'), kind: 'misc', scope: path.join('_Misc', task), role, task };
226
+ }
227
+ if (role === 'plain') return { path: null, kind: null, scope: '', role, reason: '普通会话不参与交接' };
228
+ return { path: null, kind: null, scope: '', role, reason: '本会话未设定角色' };
229
+ }
230
+
231
+ export function snapshotDirFor(ws, info) {
232
+ const t = handoverPathFor(ws, info);
233
+ return path.join(ws, '_Temp', t.scope || '_Global', 'Snapshots');
234
+ }
235
+
236
+ export function archiveDirFor(ws, info) {
237
+ // 规范 §4.1:杂项归档自包含在任务目录里(`_Misc/tasks/<任务>/_archive/`),不进全局归档;
238
+ // 全局与项目归档在 `_Archives/<范围>/Handover/`。
239
+ if (info && info.role === 'misc' && taskNameOk(info.task)) {
240
+ return path.join(ws, '_Misc', 'tasks', String(info.task), '_archive');
241
+ }
242
+ const t = handoverPathFor(ws, info);
243
+ return path.join(ws, '_Archives', t.scope || '_Global', 'Handover');
244
+ }
245
+
246
+ export function scopeLabel(info) {
247
+ const role = info && info.role ? info.role : 'plain';
248
+ if (role === 'project') return '项目「' + (info.project || '?') + '」';
249
+ if (role === 'misc') return '杂项任务「' + (info.task || '?') + '」';
250
+ return ROLE_LABEL[role] || role;
251
+ }