@mingmaple/dsh-handover 0.9.12 → 0.9.14

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.
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * 会话管理的实现(原 ho_session_new / ho_prompt / ho_session_archive / ho_session_unarchive 的代码搬到这里)。
3
3
  * 对外只经 ho_sessions(action=…) 暴露,少 4 份 schema 的固定开销。
4
4
  */
@@ -7,12 +7,12 @@ import path from 'node:path';
7
7
  import { NL, readUtf8, isDir, isFile } from './text.mjs';
8
8
  import { handoverPathFor, assertProjectName, assertTaskName } from './role.mjs';
9
9
  import { readHandover } from './handover-io.mjs';
10
- import { scanSessions } from './sessions.mjs';
11
10
  import { workspaceRows } from './registry.mjs';
12
11
  import { checkConsent } from './consent.mjs';
13
12
  import { watermark } from './facts.mjs';
14
13
  import { sendPrompt } from './prompt.mjs';
15
14
  import { newSessionIn } from './session-create.mjs';
15
+ import { lineageKeyOf, loadOffice, registerHolder } from './office.mjs';
16
16
 
17
17
  const FENCE = String.fromCharCode(96, 96, 96);
18
18
 
@@ -35,6 +35,87 @@ function findWorkspaceId(key) {
35
35
  return null;
36
36
  }
37
37
 
38
+ /**
39
+ * 新建会话落哪个工作区:用户指定的 workspace/cwd 优先,项目按标题或路径自动匹配,全局落到当前工作区。
40
+ * 返回 workspaceId;找不到时抛出可直接展示的中文错误,并列出已注册的工作区。
41
+ * @param {object} env 插件环境
42
+ * @param {object} exec 工具执行上下文(用来取当前工作区根)
43
+ * @param {string} kind 角色:global、project、misc
44
+ * @param {string} project 项目名
45
+ * @param {string} task 任务名
46
+ * @param {object} [opts] { workspace, cwd, wantGlobal }:前两项是用户明确指定的落点
47
+ * @returns {string} workspaceId
48
+ */
49
+ export function resolveWorkspaceFor(env, exec, kind, project, task, opts) {
50
+ const o = opts || {};
51
+ const ws = env.resolveWs(exec);
52
+ const rows = listWorkspaces(env);
53
+ const choices = rows.length
54
+ ? rows.map((r) => ' ' + r.id + ' ' + (r.title || '-') + ' ' + (r.path || '-')).join(NL)
55
+ : ' (还没有注册任何工作区)';
56
+ const pickWorkspace = (key) => {
57
+ const wid = findWorkspaceId(key);
58
+ if (!wid) {
59
+ throw new Error('找不到工作区:' + key + NL + '已注册的工作区:' + NL + choices + NL + '传 id、标题或路径都行;要先建工作区,用 ho_workspace(action="create", path="<目录>")。');
60
+ }
61
+ return wid;
62
+ };
63
+ const wantGlobalWs = o.wantGlobal === true || kind === 'global';
64
+ if (o.workspace) return pickWorkspace(o.workspace);
65
+ if (o.cwd) {
66
+ if (!isDir(o.cwd)) throw new Error('cwd 不存在:' + o.cwd);
67
+ // cwd 若正好是已注册工作区的根,必须按 workspaceId 建;否则会话会落进「未分组」。
68
+ let wid = null;
69
+ try { wid = findWorkspaceId(o.cwd); } catch { wid = null; }
70
+ if (!wid) {
71
+ throw new Error('cwd 不在任何已注册工作区里:' + o.cwd + NL + '已注册的工作区:' + NL + choices
72
+ + NL + '要么把 cwd 换成某个工作区的根,要么先注册它:ho_workspace(action="create", path="' + o.cwd + '")。');
73
+ }
74
+ return wid;
75
+ }
76
+ if (project) {
77
+ // 项目会话:按项目名找同名工作区,或路径落在 Projects/<项目>、_Project_Meta/<项目> 下的工作区。
78
+ const want = String(project).toLowerCase();
79
+ const hit = rows.find((r) => {
80
+ if (String(r.title || '') === String(project)) return true;
81
+ const p = normPath(r.path);
82
+ return p.indexOf('/projects/' + want) !== -1 || p.indexOf('/_project_meta/' + want) !== -1;
83
+ });
84
+ if (!hit) {
85
+ throw new Error('项目「' + project + '」还没有对应的工作区。' + NL + '已注册的工作区:' + NL + choices
86
+ + NL + '做法一:ho_workspace(action="create", path="<该项目目录>", title="' + project + '") 后再建会话;'
87
+ + NL + '做法二:把 workspace 传成上面某个工作区。');
88
+ }
89
+ return String(hit.id);
90
+ }
91
+ if (wantGlobalWs) return pickWorkspace(ws);
92
+ throw new Error('新建会话必须指明工作区:传 workspace(id、标题或路径),或 cwd(某个已注册工作区的根)。' + NL
93
+ + '已注册的工作区:' + NL + choices + NL
94
+ + '全局会话落在标题含「全局」的工作区;项目会话同时传 project 与该项目的工作区;杂项会话传该项目外的独立工作区。');
95
+ }
96
+
97
+ /**
98
+ * 某个目录是不是已注册工作区的根:是则返回 workspaceId,否则返回空串(不抛错)。
99
+ * 换任时用来让接任者落回本会话所在的那个工作区,而不是工作区根。
100
+ */
101
+ export function workspaceIdOfPath(env, p) {
102
+ const want = normPath(p);
103
+ if (!want || want === '-') return '';
104
+ const hit = listWorkspaces(env).find((r) => {
105
+ const rp = normPath(String(r.path || ''));
106
+ return rp && rp !== '-' && rp === want;
107
+ });
108
+ return hit ? String(hit.id || '') : '';
109
+ }
110
+
111
+ /** workspaceId 对应的根路径;查不到返回空串(登记表的工作区一栏用它写成实际落点)。 */
112
+ export function workspacePathOf(env, id) {
113
+ const key = String(id || '');
114
+ if (!key) return '';
115
+ const hit = listWorkspaces(env).find((r) => String(r.id || '') === key);
116
+ return hit && hit.path ? String(hit.path) : '';
117
+ }
118
+
38
119
  /** 已注册工作区清单(插件服务优先,退化读 workspace.json):新建会话时用来列出可选项。 */
39
120
  function listWorkspaces(env) {
40
121
  try {
@@ -80,6 +161,24 @@ function fill(tpl, map) {
80
161
 
81
162
  // ---------------------------------------------------------------- action: new
82
163
 
164
+ /**
165
+ * 复用判据:同一类别已有在任会话且其水位低于提醒线时,返回中文拒绝理由;否则返回空串。
166
+ * 只读在任登记表与水位缓存,不写任何文件;取不到水位就放行。
167
+ */
168
+ function reuseBlocker(env, ws, kind, key) {
169
+ if (env.cfg.reuseCheck === false) return '';
170
+ if (!ws || !kind || !key) return '';
171
+ const lin = loadOffice(ws).lineages[key];
172
+ const cur = lin && lin.current;
173
+ if (!cur || !cur.sessionId) return '';
174
+ let pct = null;
175
+ try { pct = typeof env.selfPct === 'function' ? env.selfPct(cur.sessionId) : null; } catch { pct = null; }
176
+ if (pct === null || pct === undefined || !(Number(pct) < watermark(env.cfg).warnValue)) return '';
177
+ const label = kind === 'global' ? '全局类别' : kind === 'project' ? '项目「' + key + '」' : '杂项任务「' + key + '」';
178
+ return '拒绝新建:' + label + '已有在任会话 ' + (cur.name || '(未命名)') + '(' + cur.sessionId + ')水位 '
179
+ + Number(pct).toFixed(1) + '%,低于 ' + watermark(env.cfg).warn + ';建议用 peer_send 复用,确需另建请传 force=true。';
180
+ }
181
+
83
182
  export async function sessionNew(env, args, exec) {
84
183
  const ws = env.resolveWs(exec);
85
184
  const controller = env.controller();
@@ -91,91 +190,58 @@ export async function sessionNew(env, args, exec) {
91
190
  if (args.task) assertTaskName(String(args.task));
92
191
  const out = [];
93
192
  const title = String(args.title || '');
94
- if (args.project && title.indexOf('全局') === -1 && args.force !== true && env.cfg.reuseCheck !== false) {
95
- const scan = scanSessions({
96
- dir: env.cacheDir(),
97
- meId: env.selfId(exec),
98
- warn: watermark(env.cfg).warnValue,
99
- silentHours: env.cfg.sessionScanHours,
100
- });
101
- const live = scan.rows.filter((r) => r.project === args.project && r.kind === '平级' && r.pct !== null);
102
- if (live.length) {
103
- const best = live.slice().sort((a, b) => b.mtime - a.mtime)[0];
104
- if (best.pct < watermark(env.cfg).warnValue) {
105
- throw new Error('拒绝新建:项目「' + args.project + '」在任会话 ' + best.sid + ' 水位 ' + best.pct.toFixed(1) + '%(<' + watermark(env.cfg).warn + '),按 Charter §7.2.1 应复用现有会话;确实要建请加 force=true');
106
- }
107
- }
108
- }
109
- // 角色先算好:新建会话按它登记并当场装配。
193
+ // 角色先算好:新建会话按它做在任判据、登记并当场装配。
110
194
  let role = args.role || (args.project ? 'project' : args.kind || '');
111
195
  if (role === 'session') role = '';
112
196
  const project = args.project || '';
113
197
  const task = args.task || '';
114
198
  const finalRole = title.indexOf('全局') !== -1 ? 'global'
115
199
  : (role === 'project' || role === 'misc' ? role : (project ? 'project' : ''));
200
+ const officeKind = finalRole === 'project' || finalRole === 'misc' || finalRole === 'global' ? finalRole : '';
116
201
 
117
- // ---- 工作区解析:新建会话必须落到明确的工作区,不允许静默落进调用者所在的工作区。
118
- const rows = listWorkspaces(env);
119
- const choices = rows.length
120
- ? rows.map((r) => ' ' + r.id + ' ' + (r.title || '-') + ' ' + (r.path || '-')).join(NL)
121
- : ' (还没有注册任何工作区)';
122
- const pickWorkspace = (key) => {
123
- const wid = findWorkspaceId(key);
124
- if (!wid) {
125
- throw new Error('找不到工作区:' + key + NL + '已注册的工作区:' + NL + choices + NL + '传 id、标题或路径都行;要先建工作区,用 ho_workspace(action="create", path="<目录>")。');
126
- }
127
- return wid;
128
- };
129
- const wantGlobalWs = title.indexOf('全局') !== -1 || args.role === 'global' || args.kind === 'global';
130
- let workspaceId = '';
131
- if (args.workspace) {
132
- workspaceId = pickWorkspace(args.workspace);
133
- } else if (args.cwd) {
134
- if (!isDir(args.cwd)) throw new Error('cwd 不存在:' + args.cwd);
135
- // cwd 若正好是已注册工作区的根,必须按 workspaceId 建;否则会话会落进「未分组」。
136
- let wid = null;
137
- try { wid = findWorkspaceId(args.cwd); } catch { wid = null; }
138
- if (!wid) {
139
- throw new Error('cwd 不在任何已注册工作区里:' + args.cwd + NL + '已注册的工作区:' + NL + choices
140
- + NL + '要么把 cwd 换成某个工作区的根,要么先注册它:ho_workspace(action="create", path="' + args.cwd + '")。');
141
- }
142
- workspaceId = wid;
143
- } else if (args.project) {
144
- // 项目会话:按项目名找同名工作区,或路径落在 Projects/<项目>、_Project_Meta/<项目> 下的工作区。
145
- const want = String(args.project).toLowerCase();
146
- const hit = rows.find((r) => {
147
- if (String(r.title || '') === String(args.project)) return true;
148
- const p = normPath(r.path);
149
- return p.indexOf('/projects/' + want) !== -1 || p.indexOf('/_project_meta/' + want) !== -1;
150
- });
151
- if (!hit) {
152
- throw new Error('项目「' + args.project + '」还没有对应的工作区。' + NL + '已注册的工作区:' + NL + choices
153
- + NL + '做法一:ho_workspace(action="create", path="<该项目目录>", title="' + args.project + '") 后再建会话;'
154
- + NL + '做法二:把 workspace 传成上面某个工作区。');
155
- }
156
- workspaceId = String(hit.id);
157
- } else if (wantGlobalWs) {
158
- workspaceId = pickWorkspace(ws);
159
- } else {
160
- throw new Error('新建会话必须指明工作区:传 workspace(id、标题或路径),或 cwd(某个已注册工作区的根)。' + NL
161
- + '已注册的工作区:' + NL + choices + NL
162
- + '全局会话落在标题含「全局」的工作区;项目会话同时传 project 与该项目的工作区;杂项会话传该项目外的独立工作区。');
202
+ // 建会话之前先读在任登记表:同类已有低水位在任会话时,复用比新建更省。校验在一切写入之前。
203
+ if (args.force !== true && officeKind) {
204
+ const block = reuseBlocker(env, ws, officeKind, lineageKeyOf(officeKind, project, task));
205
+ if (block) throw new Error(block);
163
206
  }
164
207
 
208
+ // ---- 工作区解析:新建会话必须落到明确的工作区,不允许静默落进调用者所在的工作区。
209
+ const workspaceId = resolveWorkspaceFor(env, exec, finalRole, project, task, {
210
+ workspace: args.workspace,
211
+ cwd: args.cwd,
212
+ wantGlobal: title.indexOf('全局') !== -1 || args.role === 'global' || args.kind === 'global',
213
+ });
214
+
165
215
  const made = await newSessionIn(env, { workspaceId, title, role: finalRole, project, task });
166
216
  const sessionId = made.sessionId || '';
167
217
  if (!sessionId) throw new Error(made.error || 'create 没有返回 sessionId');
168
218
  out.push('已建会话 ' + sessionId + (finalRole ? ',角色 ' + finalRole + (project ? '(' + project + ')' : '') : ''));
219
+ // 建会话即登记在任:全局手工建/换会话与自服务换任共用一张登记表,不让注册表漏记或与实况分叉。
220
+ try {
221
+ if (officeKind) {
222
+ // 工作区一栏写**实际落点**(用户指定的那个工作区),不是调用者自己所在的工作区根。
223
+ const reg = registerHolder(ws, {
224
+ kind: officeKind, project, task, name: title, sessionId,
225
+ workspace: workspacePathOf(env, workspaceId) || ws,
226
+ });
227
+ out.push('在任登记:' + reg.key + ' → ' + title + (reg.previous ? '(原 ' + reg.previous.name + ' 进历史)' : ''));
228
+ }
229
+ } catch (e) {
230
+ out.push('在任登记失败:' + String(e && e.message ? e.message : e) + '(不影响会话本身)');
231
+ }
169
232
  const meId = env.selfId(exec) || '(未知)';
170
- // 交接路径按**接任者自身的角色**取用:项目换任读取项目交接,不再退回全局交接。
171
- const hp = handoverPathFor(ws, finalRole === 'project' && project ? { role: 'project', project } : { role: 'global' });
172
- const handover = env.cfg.handoverPath || (hp.path || '');
233
+ // 交接路径按**新会话自身的角色**取用:项目读项目交接、杂项读任务交接,都不退回全局交接。
234
+ // cfg.handoverPath 是「强制全局交接路径」的演练开关,只对全局角色生效。
235
+ const hp = handoverPathFor(ws, finalRole === 'project' && project ? { role: 'project', project }
236
+ : finalRole === 'misc' && task ? { role: 'misc', task } : { role: 'global' });
237
+ const handover = (finalRole === 'global' ? env.cfg.handoverPath : '') || hp.path
238
+ || (finalRole === 'misc' ? path.join(ws, '_Control_Center', 'rules', 'Misc_Task_Standard.md') : '');
173
239
  const text = args.message || [
174
- '读一下 ' + handover + ' 。',
175
- '读毕先向用户报告,不得自行开展作业:其一,当前停在哪一步;其二,交接列有哪些待办;其三,你建议先做哪一条。待用户指派后再动手;交接中无待办者,如实说明即可,不得自行编造任务。',
240
+ '先读交接:' + handover + '。',
241
+ '读毕先向用户报告,不得自行开展作业:一、当前停在哪一步;二、交接列有哪些待办;三、建议先做哪一条。待用户指派后再动手。交接无待办时如实说明,不得编造任务。',
176
242
  '本会话 id:' + sessionId,
177
- (finalRole === 'project' ? '现任全局 id:' : '上任全局 id:') + meId,
178
- '默认沉默:仅在三种时机各发一条 peer_send 至现任全局——≥' + watermark(env.cfg).warn + ' 且已写交接、需用户决定、发现全局口径有误;任务完成不向全局报告(任务属本会话内部事务)。会话之间可直接 peer_send,不必经全局转述;不得每轮回执,亦不得发送「收到」「已完成」一类确认(全局只看文件末态)。',
243
+ (finalRole === 'project' ? '全局会话 id:' : '创建者会话 id:') + meId,
244
+ '默认沉默:仅在三种时机各发一条 peer_send 给全局:水位不低于 ' + watermark(env.cfg).warn + ' 且已写交接、需用户决定、全局口径有误。任务完成不报全局,任务属本会话内部事务。会话间可直接 peer_send,不必经全局转述;不得每轮回执,不得发送「收到」「已完成」一类确认,全局以文件末态为准。',
179
245
  ].join(NL);
180
246
  const r = await sendPrompt(env.controller(), sessionId, text, 'queue');
181
247
  out.push('已投启动提示词 accepted=' + String(r && r.accepted) + '(' + text.length + ' 字)');
@@ -184,8 +250,15 @@ export async function sessionNew(env, args, exec) {
184
250
 
185
251
  // ---------------------------------------------------------------- action: prompt
186
252
 
187
- export function sessionPrompt(env, args, exec) {
188
- const ws = env.resolveWs(exec);
253
+ /**
254
+ * 生成某角色会话的启动提示词:工作区没有模板就退回插件自带那份(dist/templates/Prompt_Templates.md)。
255
+ * @param {object} env 插件环境
256
+ * @param {object} opts { ws, kind, project, task, title, sessionId, globalSessionId, extra }
257
+ * @returns {string} 可直接投递的提示词正文
258
+ */
259
+ export function promptFor(env, opts) {
260
+ const o = opts || {};
261
+ const ws = String(o.ws || env.resolveWs());
189
262
  const file = path.join(ws, '_Control_Center', 'templates', 'Prompt_Templates.md');
190
263
  // 工作区没有模板就退回插件自带那份(dist/templates/Prompt_Templates.md),功能不打折。
191
264
  let tpl = '';
@@ -198,13 +271,13 @@ export function sessionPrompt(env, args, exec) {
198
271
  fromBundle = true;
199
272
  }
200
273
  if (fromBundle) {
201
- tpl = '> (插件内置模板 · 要本地化就写到 ' + file + ')' + NL + NL + tpl;
274
+ tpl = '> (插件内置模板;本地覆盖写入 ' + file + ')' + NL + NL + tpl;
202
275
  }
203
276
  const skeleton = blockAfter(tpl, '1 共用骨架');
204
277
  if (!skeleton) throw new Error('模板缺 §1 共用骨架,拒绝渲染:' + file);
205
- const kind = args.kind || 'project';
206
- const project = args.project || '';
207
- const task = args.task || '';
278
+ const kind = o.kind || 'project';
279
+ const project = o.project || '';
280
+ const task = o.task || '';
208
281
  const target = handoverPathFor(ws, kind === 'project'
209
282
  ? { role: 'project', project }
210
283
  : kind === 'misc' && task ? { role: 'misc', task } : { role: 'global' });
@@ -214,7 +287,7 @@ export function sessionPrompt(env, args, exec) {
214
287
  : kind === 'misc' ? (target.path || path.join(ws, '_Control_Center', 'rules', 'Misc_Task_Standard.md'))
215
288
  : path.join(ws, '_Control_Center', 'rules', 'Migration_Rules.md');
216
289
  if (!firstPath) throw new Error('kind=' + kind + ' 需要 project、task 参数才能算交接路径');
217
- const numMatch = String(args.title || '').match(/([0-9]+)/);
290
+ const numMatch = String(o.title || '').match(/([0-9]+)/);
218
291
  const ordinal = numMatch ? numMatch[1] : 'N';
219
292
  const roleText = kind === 'global'
220
293
  ? '第 ' + ordinal + ' 任全局(管理会话)'
@@ -227,8 +300,8 @@ export function sessionPrompt(env, args, exec) {
227
300
  const filled = fill(skeleton, {
228
301
  '<角色>': roleText,
229
302
  '<绝对路径>': firstPath,
230
- '<本会话 id>': args.sessionId || '(建完后填)',
231
- '<全局 id>': args.globalSessionId || '(现任全局 id)',
303
+ '<本会话 id>': o.sessionId || '(建完后填)',
304
+ '<全局 id>': o.globalSessionId || '(现任全局 id)',
232
305
  '<范围>': scopeText,
233
306
  });
234
307
  const stripHint = (l) => {
@@ -242,16 +315,30 @@ export function sessionPrompt(env, args, exec) {
242
315
  const body = filled.split(NL).map(stripHint).join(NL);
243
316
  const extraLines = [];
244
317
  if (kind === 'project' && project) {
245
- extraLines.push('库根:Projects/' + project + '/ ;元数据:_Project_Meta/' + project + '/ ;快照:_Temp/' + project + '/Snapshots/');
246
- extraLines.push('开始作业前应先读:_Project_Meta/' + project + '/Handover.md,然后读库根 SKILL.md;修改文档时再读 _Control_Center/rules/ 对应规范。');
247
- extraLines.push('换任由全局执行:进入预警区后只写交接、通知全局并提醒用户,不得自行建立会话。');
318
+ extraLines.push('库根:Projects/' + project + '/;元数据:_Project_Meta/' + project + '/;快照:_Temp/' + project + '/Snapshots/');
319
+ extraLines.push('作业前读库根 SKILL.md;修改文档时另读 _Control_Center/rules/ 对应规范。');
320
+ extraLines.push('进入预警区后写交接;请用户在聊天框发送 /allow-succeed 授权,再由本会话调用 ho_succeed 完成换任,不向全局请求。');
248
321
  }
249
- if (kind === 'misc') extraLines.push('标准见 rules/Misc_Task_Standard.md;换任可自行以 peer_new 落到 Misc。');
250
- if (kind === 'migration') extraLines.push('迁移口径见 rules/Migration_Rules.md;应先通过顶层闸门再开始作业。');
251
- if (args.extra) extraLines.push(String(args.extra));
322
+ if (kind === 'misc') extraLines.push('标准见 rules/Misc_Task_Standard.md;换任须用户授权,再由本会话调用 ho_succeed。');
323
+ if (kind === 'migration') extraLines.push('迁移口径见 rules/Migration_Rules.md;通过顶层闸门后再开始作业。');
324
+ if (o.extra) extraLines.push(String(o.extra));
252
325
  return [body].concat(extraLines.length ? ['', ...extraLines] : []).join(NL);
253
326
  }
254
327
 
328
+ /** ho_sessions(action="prompt") 的实现:按调用者角色的工作区生成提示词。 */
329
+ export function sessionPrompt(env, args, exec) {
330
+ return promptFor(env, {
331
+ ws: env.resolveWs(exec),
332
+ kind: args.kind,
333
+ project: args.project,
334
+ task: args.task,
335
+ title: args.title,
336
+ sessionId: args.sessionId,
337
+ globalSessionId: args.globalSessionId,
338
+ extra: args.extra,
339
+ });
340
+ }
341
+
255
342
  // ---------------------------------------------------------------- action: archive / unarchive
256
343
 
257
344
  export async function sessionArchive(env, args, exec) {
@@ -303,11 +390,11 @@ function orderHandoverText(env, sid, extra) {
303
390
  lines.push("【全局指令:执行交接流程】将当前工作写成交接,写毕即停止操作,等待换任。本轮不再进行项目作业。");
304
391
  lines.push("");
305
392
  lines.push("1) 目标文件:" + (hp && hp.path ? hp.path : "(角色未定,须先由全局设定角色)"));
306
- lines.push(" 直接 ho_handover(action=\"write\", text=...)——不要传 path,路径已按你的角色(" + (info && info.role ? info.role : "unknown") + ")解析好。");
307
- lines.push("2) 必写:① 现在在做什么、到哪一步(版本、产物、是否通过)② 环境与路径(版本号、安装位置、用的下载源)③ 下一步清单:按顺序、每条可直接执行 ④ 已完成待验收、要求了没落地 ⑤ 已知坑与踩过的错。");
308
- lines.push("3) 机密只写位置不写值(密码、token、密钥一律写「在哪个文件」)。");
309
- lines.push("4) 写完只回一条 peer_send 给全局:`交接已写 <绝对路径>` + 一句话状态;不要复述全文。");
310
- lines.push("5) 不要压缩上下文、不要建会话(建会话是全局的权限)。");
393
+ lines.push(" 直接调用 ho_handover(action=\"write\", text=...),不要传 path:路径已按你的角色(" + (info && info.role ? info.role : "unknown") + ")解析。");
394
+ lines.push("2) 必写:① 当前进度(版本、产物、是否通过)② 环境与路径(版本号、安装位置、下载源)③ 下一步清单,按顺序列出,每条可直接执行 ④ 已完成待验收、要求未实现的部分 ⑤ 已知问题与已踩的错。");
395
+ lines.push("3) 机密只写位置,不写值:密码、token、密钥均写明所在文件。");
396
+ lines.push("4) 写完只回一条 peer_send 给全局:`交接已写 <绝对路径>`,附一句状态,不复述全文。");
397
+ lines.push("5) 不要压缩上下文,不要新建会话(新建会话属全局权限)。");
311
398
  if (extra) lines.push("6) 全局附加:" + extra);
312
399
  return lines.join(NL);
313
400
  }
@@ -355,7 +442,7 @@ export async function sessionOrder(env, args) {
355
442
  const max = (env.cfg && env.cfg.maxCompactions) || 3;
356
443
  const n = used + 1;
357
444
  if (n > max) {
358
- out.push("已压满 " + used + "/" + max + ":不再压缩。应执行 ho_sessions(action=\"new\") 换任;新会话读毕交接会先向你报告,等待指派。");
445
+ out.push("已压满 " + used + "/" + max + ":不再压缩。应执行 ho_succeed 自行换任(先请用户发送 /allow-succeed 授权);全局会话自身也可用 ho_sessions(action=\"new\")。新会话读毕交接会先向你报告,等待指派。");
359
446
  return out.join(NL);
360
447
  }
361
448
  const recentSec = st && st.lastSummaryMs ? (Date.now() - st.lastSummaryMs) / 1000 : null;
@@ -0,0 +1,260 @@
1
+ /**
2
+ * 换任自服务:在任会话在用户授权后,自己校验交接、建接任会话、登记在任并投递启动提示词。
3
+ *
4
+ * 越权防线:建什么角色的会话只认会话自身已登记的角色(env.roleOf),不采信调用参数;
5
+ * 名字只认登记表的历史最大号(nextName),不采信会话自述。
6
+ * 全部校验(角色、授权、交接结构、在任身份、工作区、提示词)都在第一次写盘之前完成:
7
+ * 任何一项不通过,整体不落任何写操作,只返回中文原因。
8
+ */
9
+ import fs from 'node:fs';
10
+ import { NL, readUtf8 } from './text.mjs';
11
+ import { lineageKeyOf, loadOffice, nextName, registerHolder, stampNow } from './office.mjs';
12
+ import { checkConsent, consumedConsent } from './consent.mjs';
13
+ import { newSessionIn } from './session-create.mjs';
14
+ import { sendPrompt } from './prompt.mjs';
15
+ import { resolveWorkspaceFor, promptFor, workspaceIdOfPath, workspacePathOf } from './session-ops.mjs';
16
+ import { watermark } from './facts.mjs';
17
+
18
+ /** 交接里不得残留的占位符。 */
19
+ export const HANDOVER_PLACEHOLDERS = ['待填', 'TODO', '(建完后填)', '<填'];
20
+
21
+ function msgOf(e) {
22
+ return String(e && e.message ? e.message : e);
23
+ }
24
+
25
+ /** 现任全局会话 id(登记表是唯一事实源);没有记录或读不到时返回空串。 */
26
+ function currentGlobalId(ws) {
27
+ try {
28
+ const lin = loadOffice(ws).lineages['全局'];
29
+ return lin && lin.current && lin.current.sessionId ? String(lin.current.sessionId) : '';
30
+ } catch { return ''; }
31
+ }
32
+
33
+ /**
34
+ * 找接任指令节:含「## 下一步」或「## 2.」这类标题;没有标题时接受 §2 的写法。
35
+ * 判据与 ho_gate 的交接体检一致:这一节至少要有一个条目(列表、表格或编号行)。
36
+ */
37
+ function nextSectionOf(text) {
38
+ const lines = text.split(NL);
39
+ let start = -1;
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const l = lines[i];
42
+ if (!l.startsWith('## ')) continue;
43
+ if (l.indexOf('下一步') !== -1 || /^##\s*2(?:[.、\s]|$)/.test(l)) { start = i; break; }
44
+ }
45
+ if (start === -1) {
46
+ if (/§\s*2/.test(text)) return { ok: true };
47
+ return { ok: false, reason: '缺少接任指令节:应有「## 下一步」或「## 2.」一节' };
48
+ }
49
+ const rows = [];
50
+ for (let i = start + 1; i < lines.length; i++) {
51
+ if (lines[i].startsWith('## ')) break;
52
+ const t = lines[i].trim();
53
+ if (/^[-*]\s+/.test(t)) rows.push(t);
54
+ else if (/^\|/.test(t) && t.indexOf('---') === -1) rows.push(t);
55
+ else if (/^\d+[.、]\s+/.test(t)) rows.push(t);
56
+ }
57
+ if (!rows.length) return { ok: false, reason: '「下一步」一节没有列出任何条目' };
58
+ return { ok: true };
59
+ }
60
+
61
+ /** 交接结构检查(只判结构):返回缺项列表,空数组表示通过。 */
62
+ export function auditHandover(file) {
63
+ const problems = [];
64
+ try {
65
+ const st = fs.statSync(file);
66
+ if (!st.isFile()) return ['交接路径不是文件:' + file];
67
+ } catch {
68
+ return ['交接文件不存在:' + file];
69
+ }
70
+ let text = '';
71
+ try { text = readUtf8(file).replace(/^\uFEFF/, ''); } catch (e) { return ['交接文件读取失败:' + msgOf(e)]; }
72
+ if (!text.trim()) return ['交接文件为空:' + file];
73
+ const next = nextSectionOf(text);
74
+ if (!next.ok) problems.push(next.reason);
75
+ for (const p of HANDOVER_PLACEHOLDERS) {
76
+ if (text.indexOf(p) !== -1) problems.push('仍有占位符:' + p);
77
+ }
78
+ return problems;
79
+ }
80
+
81
+ /** 在旧会话的交接末尾追加「换任登记」:先备份到同目录 .bak,再追加。 */
82
+ function appendSuccessionBlock(file, lines) {
83
+ const cur = readUtf8(file);
84
+ const bom = cur.charCodeAt(0) === 0xFEFF ? '\uFEFF' : '';
85
+ const body = bom ? cur.slice(1) : cur;
86
+ fs.copyFileSync(file, file + '.bak');
87
+ const gap = body.endsWith(NL) ? NL : NL + NL;
88
+ const block = ['## 换任登记', ''].concat(lines).join(NL);
89
+ fs.writeFileSync(file, bom + body + gap + block + NL, 'utf8');
90
+ return file + '.bak';
91
+ }
92
+
93
+ /**
94
+ * 执行一次换任自服务。
95
+ * @param {object} env 插件环境
96
+ * @param {object} exec 工具执行上下文
97
+ * @param {object} [opts] { note, force }:force 为 true 时越过「水位未到线」判据
98
+ * @returns {Promise<string>} 中文结果或失败原因
99
+ */
100
+ export async function runSuccession(env, exec, opts) {
101
+ const o = opts || {};
102
+ const note = typeof o.note === 'string' ? o.note.trim() : '';
103
+ const reject = (reason) => '换任未执行,未做任何写入。' + NL + reason;
104
+
105
+ // 1. 自身角色与类别:只认已登记的角色。
106
+ const info = env.roleOf(exec) || {};
107
+ const kind = String(info.role || 'plain');
108
+ if (kind === 'plain') return reject('普通会话不参与换任。');
109
+ if (kind !== 'global' && kind !== 'project' && kind !== 'misc') return reject('本会话角色无法用于换任:' + kind);
110
+ const project = String(info.project || '');
111
+ const task = String(info.task || '');
112
+ const key = lineageKeyOf(kind, project, task);
113
+ if (!key) return reject(kind === 'project' ? '项目名缺失,无法确定类别。' : '任务名缺失,无法确定类别。');
114
+
115
+ // 2. 用户授权:AI 不能自行批准。
116
+ const selfId = env.selfId(exec);
117
+ if (!selfId) return reject('拿不到当前会话 id,无法校验授权。');
118
+ const cs = checkConsent(selfId, 'succeed');
119
+ if (!cs.ok) {
120
+ return reject([
121
+ cs.reason + '。',
122
+ '授权方式(任选其一):',
123
+ ' ① 在聊天框发送 /allow-succeed(最快,仅授权本会话);',
124
+ ' ② 在设置页的 dsh-handover 中点击「换任授权」的「允许换任」。',
125
+ '授权有效期 5 分钟,用后失效。',
126
+ ].join(NL));
127
+ }
128
+
129
+ // 3. 交接结构检查(只判结构,不判质量)。
130
+ const hp = env.handoverFor(exec) || {};
131
+ const handoverFile = hp.path ? String(hp.path) : '';
132
+ if (!handoverFile) return reject('没有交接路径:' + (hp.reason || '本会话角色未确定'));
133
+ const problems = auditHandover(handoverFile);
134
+ if (problems.length) {
135
+ return reject(['交接结构未通过:' + handoverFile]
136
+ .concat(problems.map((p) => ' - ' + p))
137
+ .concat(['补齐后重新调用 ho_succeed。'])
138
+ .join(NL));
139
+ }
140
+
141
+ // 4. 在任身份与重复换任:登记表是唯一记录。
142
+ const ws = String(env.resolveWs(exec) || '');
143
+ if (!ws) return reject('无法确定工作区根,读不到在任登记表。');
144
+ const store = loadOffice(ws);
145
+ const lin = store.lineages[key];
146
+ // 只有本类别的在任者本人可换任:既防旧会话复活,也防同项目的旁支会话抢先。
147
+ if (!lin || !lin.current || !lin.current.sessionId) {
148
+ return reject('本类别(' + key + ')在在任登记表里还没有现任记录:先由全局建会话并登记,再换任。');
149
+ }
150
+ if (String(lin.current.sessionId) !== String(selfId)) {
151
+ return reject('本会话不是在任者:本类别现任为 ' + String(lin.current.name || '(未命名)')
152
+ + '(' + String(lin.current.sessionId) + '),不得换任。');
153
+ }
154
+ {
155
+ const history = Array.isArray(lin.history) ? lin.history : [];
156
+ const closed = history.find((h) => h && String(h.sessionId || '') === String(selfId) && h.to);
157
+ if (closed) {
158
+ const curName = lin.current && lin.current.name ? lin.current.name : '(空缺)';
159
+ return reject('本类别已为本会话记过一次换任(' + String(closed.to || '') + ',现任 ' + curName + '),旧会话不得再次换任。');
160
+ }
161
+ }
162
+
163
+ // 4.1 水位未到线:本会话就是在任者且水位低于提醒线时,换任通常是多余的。
164
+ // 放在授权与交接校验之后、任何写入之前;取不到水位就跳过。
165
+ if (o.force !== true) {
166
+ let pct = null;
167
+ try { pct = typeof env.selfPct === 'function' ? env.selfPct(selfId) : null; } catch { pct = null; }
168
+ if (pct !== null && pct !== undefined && Number(pct) < watermark(env.cfg).warnValue) {
169
+ return reject('本会话水位 ' + Number(pct).toFixed(1) + '%,尚未到线,通常无需换任;确需换任请传 force=true。');
170
+ }
171
+ }
172
+
173
+ // 5. 分配编号:登记表历史最大号加一,不采用会话自述。
174
+ const name = nextName(store, key);
175
+
176
+ // 6. 确定工作区:与 ho_sessions(action="new") 同一套解析。
177
+ // 杂项接任者落回**本会话所在的注册工作区**(按会话 cwd 反查);反查不到才退回工作区根。
178
+ let workspaceId = '';
179
+ try {
180
+ let wsOpt = {};
181
+ if (kind === 'misc') {
182
+ const selfCwd = typeof env.selfCwd === 'function' ? String(env.selfCwd(exec) || '') : '';
183
+ wsOpt = selfCwd && workspaceIdOfPath(env, selfCwd) ? { cwd: selfCwd } : { workspace: ws };
184
+ }
185
+ workspaceId = resolveWorkspaceFor(env, exec, kind, project, task, wsOpt);
186
+ } catch (e) {
187
+ return reject(msgOf(e));
188
+ }
189
+
190
+ // 7. 拼启动提示词:末尾追加交接路径与「先读交接、读毕报到」。
191
+ // 「全局 id」填**现任全局**:项目与杂项换任时,前任不是全局,不能顶替这个位置。
192
+ let prompt = '';
193
+ try {
194
+ const globalId = kind === 'global' ? selfId : (currentGlobalId(ws) || selfId);
195
+ const base = promptFor(env, { ws, kind, project, task, title: name, sessionId: '', globalSessionId: globalId, extra: '' });
196
+ prompt = base + NL + NL + [
197
+ '接任交接:' + handoverFile,
198
+ '先读这份交接再动手;读毕向用户报告当前停在哪一步、交接列有哪些待办,然后等待用户指派任务。',
199
+ ].join(NL);
200
+ } catch (e) {
201
+ return reject('启动提示词生成失败:' + msgOf(e));
202
+ }
203
+
204
+ // 8. 建会话:失败即停止,不登记。
205
+ const made = await newSessionIn(env, { workspaceId, title: name, role: kind, project, task });
206
+ const sessionId = made && made.sessionId ? String(made.sessionId) : '';
207
+ if (!sessionId) return reject('建会话失败,未做任何登记:' + ((made && made.error) || '原因未知'));
208
+ // 提示词里预留的是「建完后填」,拿到会话 id 后在这里替换成真实 id。
209
+ const text = prompt.split('(建完后填)').join(sessionId);
210
+
211
+ // 9. 登记在任:原在任者自动进历史,Registry.md 在役列同步。
212
+ let reg = null;
213
+ try {
214
+ reg = registerHolder(ws, { kind, project, task, name, sessionId, workspace: workspacePathOf(env, workspaceId) || ws, note });
215
+ } catch (e) {
216
+ return [
217
+ '会话已建 ' + sessionId + '(' + name + '),但登记在任失败:' + msgOf(e),
218
+ '启动提示词尚未投递;先修好登记表,再用 peer_send 通知它。',
219
+ ].join(NL);
220
+ }
221
+
222
+ // 10. 投递启动提示词:失败要如实报出,会话已经存在。
223
+ let delivered = false;
224
+ let deliveryError = '';
225
+ try {
226
+ const r = await sendPrompt(env.controller(), sessionId, text, 'queue');
227
+ delivered = !!(r && r.accepted);
228
+ if (!delivered) deliveryError = '会话控制器未接受(accepted=false)';
229
+ } catch (e) {
230
+ deliveryError = msgOf(e);
231
+ }
232
+
233
+ // 11. 旧会话的交接末尾追加换任登记;写前备份。
234
+ let appendNote = '';
235
+ try {
236
+ const bak = appendSuccessionBlock(handoverFile, [
237
+ '- 接任者:' + name,
238
+ '- 会话 id:' + sessionId,
239
+ '- 时刻:' + stampNow(),
240
+ ...(note ? ['- 备注:' + note] : []),
241
+ '- 旧会话停止作业。',
242
+ ]);
243
+ appendNote = '已写入交接末尾(备份 ' + bak + ')';
244
+ } catch (e) {
245
+ appendNote = '换任登记写入失败:' + msgOf(e);
246
+ }
247
+
248
+ // 12. 消费授权:一次授权只够换任一次。
249
+ try { consumedConsent(selfId, 'succeed'); } catch { /* 授权文件损坏不影响本次结果 */ }
250
+
251
+ // 13. 结果。
252
+ const out = [];
253
+ out.push('换任完成:' + name + '(' + sessionId + ')');
254
+ out.push('在任登记:' + key + (reg && reg.registry && reg.registry.changed && reg.registry.changed.length
255
+ ? ',Registry.md 已同步在役列' : ''));
256
+ out.push('启动提示词:' + (delivered ? '已投递' : '未送达:' + deliveryError + '。会话已建,可用 peer_send 补投'));
257
+ out.push('交接登记:' + appendNote);
258
+ out.push('本会话到此停止,等待用户在新会话中派任务。');
259
+ return out.join(NL);
260
+ }
@@ -9,7 +9,7 @@ export function hasCenter(p) {
9
9
  }
10
10
 
11
11
  /**
12
- * 没有 exec 时的兜底:配置 workspaceRoot → 标题含「全局」且带 _Control_Center 的工作区
12
+ * 没有 exec 时的备用做法:配置 workspaceRoot → 标题含「全局」且带 _Control_Center 的工作区
13
13
  * → 任一带 _Control_Center 的工作区 → 进程 cwd。
14
14
  */
15
15
  export function routeWorkspace(env) {