@mingmaple/dsh-handover 0.9.6 → 0.9.13

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 (50) hide show
  1. package/README.md +53 -17
  2. package/docs/migrate.md +1 -1
  3. package/docs/releases/0.9.12.md +28 -0
  4. package/docs/releases/0.9.13.md +30 -0
  5. package/docs/troubleshoot.md +43 -6
  6. package/package.json +70 -57
  7. package/scripts/blank-dsh.mjs +6 -5
  8. package/scripts/blank-smoke.mjs +268 -58
  9. package/scripts/build.mjs +3 -0
  10. package/scripts/check-docs.mjs +23 -2
  11. package/scripts/office.mjs +81 -0
  12. package/src/client.js +92 -23
  13. package/src/index.mjs +264 -145
  14. package/src/lib/api.mjs +67 -9
  15. package/src/lib/config.mjs +4 -3
  16. package/src/lib/consent.mjs +59 -20
  17. package/src/lib/facts.mjs +5 -4
  18. package/src/lib/guide.mjs +14 -7
  19. package/src/lib/handover-io.mjs +20 -0
  20. package/src/lib/manual.mjs +23 -12
  21. package/src/lib/names.mjs +2 -2
  22. package/src/lib/office.mjs +272 -0
  23. package/src/lib/pack-ops.mjs +3 -0
  24. package/src/lib/phase.mjs +6 -4
  25. package/src/lib/prompt.mjs +18 -0
  26. package/src/lib/role.mjs +52 -18
  27. package/src/lib/schedule.mjs +10 -14
  28. package/src/lib/session-create.mjs +42 -0
  29. package/src/lib/session-ops.mjs +183 -79
  30. package/src/lib/sessions.mjs +9 -8
  31. package/src/lib/status.mjs +8 -2
  32. package/src/lib/succession.mjs +244 -0
  33. package/src/lib/text.mjs +27 -0
  34. package/src/lib/webroute.mjs +1 -1
  35. package/src/lib/workspace.mjs +1 -1
  36. package/src/selftest.mjs +15 -0
  37. package/src/templates/Charter.skeleton.md +1 -1
  38. package/src/templates/Prompt_Templates.md +9 -17
  39. package/src/templates/Runtime_Manual.md +17 -14
  40. package/src/templates/rules/Handover_Rules.md +3 -2
  41. package/src/templates/rules/Misc_Task_Standard.md +3 -3
  42. package/src/tools/ho_checkpoint.mjs +11 -4
  43. package/src/tools/ho_diag.mjs +5 -3
  44. package/src/tools/ho_handover.mjs +7 -6
  45. package/src/tools/ho_office.mjs +59 -0
  46. package/src/tools/ho_profile.mjs +10 -2
  47. package/src/tools/ho_sessions.mjs +3 -2
  48. package/src/tools/ho_snapshot.mjs +2 -1
  49. package/src/tools/ho_succeed.mjs +24 -0
  50. package/src/tools/ho_workspace.mjs +7 -3
@@ -5,11 +5,14 @@
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { NL, readUtf8, isDir, isFile } from './text.mjs';
8
- import { handoverPathFor } from './role.mjs';
8
+ import { handoverPathFor, assertProjectName, assertTaskName } from './role.mjs';
9
9
  import { readHandover } from './handover-io.mjs';
10
- import { scanSessions } from './sessions.mjs';
10
+ import { workspaceRows } from './registry.mjs';
11
11
  import { checkConsent } from './consent.mjs';
12
12
  import { watermark } from './facts.mjs';
13
+ import { sendPrompt } from './prompt.mjs';
14
+ import { newSessionIn } from './session-create.mjs';
15
+ import { lineageKeyOf, loadOffice, registerHolder } from './office.mjs';
13
16
 
14
17
  const FENCE = String.fromCharCode(96, 96, 96);
15
18
 
@@ -32,17 +35,80 @@ function findWorkspaceId(key) {
32
35
  return null;
33
36
  }
34
37
 
35
- function promptSelf(env, sessionId, text, mode) {
36
- const controller = env.controller();
37
- if (!controller || typeof controller.prompt !== 'function') throw new Error('sessionController.prompt 不可用');
38
- return controller.prompt({
39
- requestId: (globalThis.crypto && globalThis.crypto.randomUUID) ? globalThis.crypto.randomUUID() : String(Date.now()),
40
- sessionId,
41
- mode: mode || 'queue',
42
- content: [{ type: 'text', text }],
43
- }, new AbortController().signal);
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 与该项目的工作区;杂项会话传该项目外的独立工作区。');
44
95
  }
45
96
 
97
+ /** 已注册工作区清单(插件服务优先,退化读 workspace.json):新建会话时用来列出可选项。 */
98
+ function listWorkspaces(env) {
99
+ try {
100
+ const reg = env && typeof env.registry === 'function' ? env.registry() : null;
101
+ const list = reg && typeof reg.list === 'function' ? (reg.list() || []) : [];
102
+ if (list.length) {
103
+ return list.map((w) => ({ id: String((w && w.id) || ''), title: String((w && w.title) || ''), path: String((w && w.path) || '') }));
104
+ }
105
+ } catch { /* 退回读文件 */ }
106
+ try {
107
+ return workspaceRows().map((r) => ({ id: r.id, title: r.title, path: r.path }));
108
+ } catch { return []; }
109
+ }
110
+
111
+
46
112
  /** 取某个 ## 小节之后第一个代码块的内容(找不到返回 null)。 */
47
113
  function blockAfter(text, heading) {
48
114
  const lines = text.split(NL);
@@ -73,79 +139,97 @@ function fill(tpl, map) {
73
139
 
74
140
  // ---------------------------------------------------------------- action: new
75
141
 
142
+ /**
143
+ * 复用判据:同一类别已有在任会话且其水位低于提醒线时,返回中文拒绝理由;否则返回空串。
144
+ * 只读在任登记表与水位缓存,不写任何文件;取不到水位就放行。
145
+ */
146
+ function reuseBlocker(env, ws, kind, key) {
147
+ if (env.cfg.reuseCheck === false) return '';
148
+ if (!ws || !kind || !key) return '';
149
+ const lin = loadOffice(ws).lineages[key];
150
+ const cur = lin && lin.current;
151
+ if (!cur || !cur.sessionId) return '';
152
+ let pct = null;
153
+ try { pct = typeof env.selfPct === 'function' ? env.selfPct(cur.sessionId) : null; } catch { pct = null; }
154
+ if (pct === null || pct === undefined || !(Number(pct) < watermark(env.cfg).warnValue)) return '';
155
+ const label = kind === 'global' ? '全局类别' : kind === 'project' ? '项目「' + key + '」' : '杂项任务「' + key + '」';
156
+ return '拒绝新建:' + label + '已有在任会话 ' + (cur.name || '(未命名)') + '(' + cur.sessionId + ')水位 '
157
+ + Number(pct).toFixed(1) + '%,低于 ' + watermark(env.cfg).warn + ';建议用 peer_send 复用,确需另建请传 force=true。';
158
+ }
159
+
76
160
  export async function sessionNew(env, args, exec) {
77
161
  const ws = env.resolveWs(exec);
78
162
  const controller = env.controller();
79
163
  // 换任(新建接班会话)先问用户。
80
164
  if (args.confirm !== true) throw new Error('新建会话(换任)需要用户确认:先向用户说明要建接班会话并得到同意,再传 confirm=true');
81
165
  if (!controller) throw new Error('ctx.sessionController 不可用');
166
+ // 名字要落成目录:先校验,别等会话建完、写角色表时才失败(那样会留下一个没有角色的会话)。
167
+ if (args.project) assertProjectName(String(args.project));
168
+ if (args.task) assertTaskName(String(args.task));
82
169
  const out = [];
83
170
  const title = String(args.title || '');
84
- if (args.project && title.indexOf('全局') === -1 && args.force !== true && env.cfg.reuseCheck !== false) {
85
- const scan = scanSessions({
86
- dir: env.cacheDir(),
87
- meId: env.selfId(exec),
88
- warn: watermark(env.cfg).warnValue,
89
- silentHours: env.cfg.sessionScanHours,
90
- });
91
- const live = scan.rows.filter((r) => r.project === args.project && r.kind === '平级' && r.pct !== null);
92
- if (live.length) {
93
- const best = live.slice().sort((a, b) => b.mtime - a.mtime)[0];
94
- if (best.pct < watermark(env.cfg).warnValue) {
95
- throw new Error('拒绝新建:项目「' + args.project + '」在任会话 ' + best.sid + ' 水位 ' + best.pct.toFixed(1) + '%(<' + watermark(env.cfg).warn + '),按 Charter §7.2.1 应复用现有会话;确实要建请加 force=true');
96
- }
97
- }
98
- }
99
- let created = null;
100
- if (args.cwd && !args.workspace) {
101
- if (!isDir(args.cwd)) throw new Error('cwd 不存在:' + args.cwd);
102
- // cwd 若正好是已注册工作区的根,必须按 workspaceId 建;否则会话会落进「未分组」。
103
- let wid = null;
104
- try { wid = findWorkspaceId(args.cwd); } catch { wid = null; }
105
- created = wid ? await controller.create({ workspaceId: wid }) : await controller.create({ cwd: args.cwd });
106
- } else {
107
- const key = args.workspace || ws;
108
- const wid = findWorkspaceId(key);
109
- if (!wid) throw new Error('找不到工作区:' + key + '(给 id、标题、路径都行)');
110
- created = await controller.create({ workspaceId: wid });
111
- }
112
- const sessionId = created && created.sessionId;
113
- if (!sessionId) throw new Error('create 没有返回 sessionId');
114
- out.push('已建会话 ' + sessionId);
115
- try { if (controller.rename) await controller.rename({ sessionId, title: args.title }); } catch (e) { out.push('改名失败(可事后 peer_rename):' + e.message); }
116
-
117
- // 角色:按 kind/project/task 记住,子会话一起来就收窄;同时标记已引导,避免二份指令。
171
+ // 角色先算好:新建会话按它做在任判据、登记并当场装配。
118
172
  let role = args.role || (args.project ? 'project' : args.kind || '');
119
173
  if (role === 'session') role = '';
120
174
  const project = args.project || '';
121
175
  const task = args.task || '';
122
- const looksGlobal = String(args.title || '').indexOf('全局') !== -1;
123
- const finalRole = looksGlobal ? 'global' : (role === 'project' || role === 'misc' ? role : (project ? 'project' : ''));
124
- if (finalRole && typeof env.writeRole === 'function') {
125
- try { env.writeRole(sessionId, { role: finalRole, project, task, source: 'create' }); out.push('已登记角色:' + finalRole + (project ? '(' + project + ')' : '')); }
126
- catch (e) { out.push('角色登记失败(不影响建会话):' + e.message); }
176
+ const finalRole = title.indexOf('全局') !== -1 ? 'global'
177
+ : (role === 'project' || role === 'misc' ? role : (project ? 'project' : ''));
178
+ const officeKind = finalRole === 'project' || finalRole === 'misc' || finalRole === 'global' ? finalRole : '';
179
+
180
+ // 建会话之前先读在任登记表:同类已有低水位在任会话时,复用比新建更省。校验在一切写入之前。
181
+ if (args.force !== true && officeKind) {
182
+ const block = reuseBlocker(env, ws, officeKind, lineageKeyOf(officeKind, project, task));
183
+ if (block) throw new Error(block);
127
184
  }
128
185
 
186
+ // ---- 工作区解析:新建会话必须落到明确的工作区,不允许静默落进调用者所在的工作区。
187
+ const workspaceId = resolveWorkspaceFor(env, exec, finalRole, project, task, {
188
+ workspace: args.workspace,
189
+ cwd: args.cwd,
190
+ wantGlobal: title.indexOf('全局') !== -1 || args.role === 'global' || args.kind === 'global',
191
+ });
192
+
193
+ const made = await newSessionIn(env, { workspaceId, title, role: finalRole, project, task });
194
+ const sessionId = made.sessionId || '';
195
+ if (!sessionId) throw new Error(made.error || 'create 没有返回 sessionId');
196
+ out.push('已建会话 ' + sessionId + (finalRole ? ',角色 ' + finalRole + (project ? '(' + project + ')' : '') : ''));
197
+ // 建会话即登记在任:全局手工建/换会话与自服务换任共用一张登记表,不让注册表漏记或与实况分叉。
198
+ try {
199
+ if (officeKind) {
200
+ const reg = registerHolder(ws, { kind: officeKind, project, task, name: title, sessionId, workspace: ws });
201
+ out.push('在任登记:' + reg.key + ' → ' + title + (reg.previous ? '(原 ' + reg.previous.name + ' 进历史)' : ''));
202
+ }
203
+ } catch (e) {
204
+ out.push('在任登记失败:' + String(e && e.message ? e.message : e) + '(不影响会话本身)');
205
+ }
129
206
  const meId = env.selfId(exec) || '(未知)';
130
207
  // 交接路径按**接任者自身的角色**取用:项目换任读取项目交接,不再退回全局交接。
131
208
  const hp = handoverPathFor(ws, finalRole === 'project' && project ? { role: 'project', project } : { role: 'global' });
132
209
  const handover = env.cfg.handoverPath || (hp.path || '');
133
210
  const text = args.message || [
134
- '读一下 ' + handover + ' 。',
135
- '读毕先向用户报告,不得自行开展作业:其一,当前停在哪一步;其二,交接列有哪些待办;其三,你建议先做哪一条。待用户指派后再动手;交接中无待办者,如实说明即可,不得自行编造任务。',
211
+ '先读交接:' + handover + '。',
212
+ '读毕先向用户报告,不得自行开展作业:一、当前停在哪一步;二、交接列有哪些待办;三、建议先做哪一条。待用户指派后再动手。交接无待办时如实说明,不得编造任务。',
136
213
  '本会话 id:' + sessionId,
137
- (finalRole === 'project' ? '现任全局 id:' : '上任全局 id:') + meId,
138
- '默认沉默:仅在四种时机各发一条 peer_send 至现任全局——任务达成、≥' + watermark(env.cfg).warn + ' 且已写交接、需用户决定、发现全局口径有误;不得每轮回执,亦不得发送「收到」「已完成」一类确认(全局只看文件末态)。',
214
+ (finalRole === 'project' ? '全局会话 id:' : '创建者会话 id:') + meId,
215
+ '默认沉默:仅在三种时机各发一条 peer_send 给全局:水位不低于 ' + watermark(env.cfg).warn + ' 且已写交接、需用户决定、全局口径有误。任务完成不报全局,任务属本会话内部事务。会话间可直接 peer_send,不必经全局转述;不得每轮回执,不得发送「收到」「已完成」一类确认,全局以文件末态为准。',
139
216
  ].join(NL);
140
- const r = await promptSelf(env, sessionId, text, 'queue');
217
+ const r = await sendPrompt(env.controller(), sessionId, text, 'queue');
141
218
  out.push('已投启动提示词 accepted=' + String(r && r.accepted) + '(' + text.length + ' 字)');
142
219
  return out.join(NL);
143
220
  }
144
221
 
145
222
  // ---------------------------------------------------------------- action: prompt
146
223
 
147
- export function sessionPrompt(env, args, exec) {
148
- const ws = env.resolveWs(exec);
224
+ /**
225
+ * 生成某角色会话的启动提示词:工作区没有模板就退回插件自带那份(dist/templates/Prompt_Templates.md)。
226
+ * @param {object} env 插件环境
227
+ * @param {object} opts { ws, kind, project, task, title, sessionId, globalSessionId, extra }
228
+ * @returns {string} 可直接投递的提示词正文
229
+ */
230
+ export function promptFor(env, opts) {
231
+ const o = opts || {};
232
+ const ws = String(o.ws || env.resolveWs());
149
233
  const file = path.join(ws, '_Control_Center', 'templates', 'Prompt_Templates.md');
150
234
  // 工作区没有模板就退回插件自带那份(dist/templates/Prompt_Templates.md),功能不打折。
151
235
  let tpl = '';
@@ -158,13 +242,13 @@ export function sessionPrompt(env, args, exec) {
158
242
  fromBundle = true;
159
243
  }
160
244
  if (fromBundle) {
161
- tpl = '> (插件内置模板 · 要本地化就写到 ' + file + ')' + NL + NL + tpl;
245
+ tpl = '> (插件内置模板;本地覆盖写入 ' + file + ')' + NL + NL + tpl;
162
246
  }
163
247
  const skeleton = blockAfter(tpl, '1 共用骨架');
164
248
  if (!skeleton) throw new Error('模板缺 §1 共用骨架,拒绝渲染:' + file);
165
- const kind = args.kind || 'project';
166
- const project = args.project || '';
167
- const task = args.task || '';
249
+ const kind = o.kind || 'project';
250
+ const project = o.project || '';
251
+ const task = o.task || '';
168
252
  const target = handoverPathFor(ws, kind === 'project'
169
253
  ? { role: 'project', project }
170
254
  : kind === 'misc' && task ? { role: 'misc', task } : { role: 'global' });
@@ -174,7 +258,7 @@ export function sessionPrompt(env, args, exec) {
174
258
  : kind === 'misc' ? (target.path || path.join(ws, '_Control_Center', 'rules', 'Misc_Task_Standard.md'))
175
259
  : path.join(ws, '_Control_Center', 'rules', 'Migration_Rules.md');
176
260
  if (!firstPath) throw new Error('kind=' + kind + ' 需要 project、task 参数才能算交接路径');
177
- const numMatch = String(args.title || '').match(/([0-9]+)/);
261
+ const numMatch = String(o.title || '').match(/([0-9]+)/);
178
262
  const ordinal = numMatch ? numMatch[1] : 'N';
179
263
  const roleText = kind === 'global'
180
264
  ? '第 ' + ordinal + ' 任全局(管理会话)'
@@ -187,8 +271,8 @@ export function sessionPrompt(env, args, exec) {
187
271
  const filled = fill(skeleton, {
188
272
  '<角色>': roleText,
189
273
  '<绝对路径>': firstPath,
190
- '<本会话 id>': args.sessionId || '(建完后填)',
191
- '<全局 id>': args.globalSessionId || '(现任全局 id)',
274
+ '<本会话 id>': o.sessionId || '(建完后填)',
275
+ '<全局 id>': o.globalSessionId || '(现任全局 id)',
192
276
  '<范围>': scopeText,
193
277
  });
194
278
  const stripHint = (l) => {
@@ -202,16 +286,30 @@ export function sessionPrompt(env, args, exec) {
202
286
  const body = filled.split(NL).map(stripHint).join(NL);
203
287
  const extraLines = [];
204
288
  if (kind === 'project' && project) {
205
- extraLines.push('库根:Projects/' + project + '/ ;元数据:_Project_Meta/' + project + '/ ;快照:_Temp/' + project + '/Snapshots/');
206
- extraLines.push('开始作业前应先读:_Project_Meta/' + project + '/Handover.md,然后读库根 SKILL.md;修改文档时再读 _Control_Center/rules/ 对应规范。');
207
- extraLines.push('换任由全局执行:进入预警区后只写交接、通知全局并提醒用户,不得自行建立会话。');
289
+ extraLines.push('库根:Projects/' + project + '/;元数据:_Project_Meta/' + project + '/;快照:_Temp/' + project + '/Snapshots/');
290
+ extraLines.push('作业前读库根 SKILL.md;修改文档时另读 _Control_Center/rules/ 对应规范。');
291
+ extraLines.push('进入预警区后写交接;请用户在聊天框发送 /allow-succeed 授权,再由本会话调用 ho_succeed 完成换任,不向全局请求。');
208
292
  }
209
- if (kind === 'misc') extraLines.push('标准见 rules/Misc_Task_Standard.md;换任可自行以 peer_new 落到 Misc。');
210
- if (kind === 'migration') extraLines.push('迁移口径见 rules/Migration_Rules.md;应先通过顶层闸门再开始作业。');
211
- if (args.extra) extraLines.push(String(args.extra));
293
+ if (kind === 'misc') extraLines.push('标准见 rules/Misc_Task_Standard.md;换任须用户授权,再由本会话调用 ho_succeed。');
294
+ if (kind === 'migration') extraLines.push('迁移口径见 rules/Migration_Rules.md;通过顶层闸门后再开始作业。');
295
+ if (o.extra) extraLines.push(String(o.extra));
212
296
  return [body].concat(extraLines.length ? ['', ...extraLines] : []).join(NL);
213
297
  }
214
298
 
299
+ /** ho_sessions(action="prompt") 的实现:按调用者角色的工作区生成提示词。 */
300
+ export function sessionPrompt(env, args, exec) {
301
+ return promptFor(env, {
302
+ ws: env.resolveWs(exec),
303
+ kind: args.kind,
304
+ project: args.project,
305
+ task: args.task,
306
+ title: args.title,
307
+ sessionId: args.sessionId,
308
+ globalSessionId: args.globalSessionId,
309
+ extra: args.extra,
310
+ });
311
+ }
312
+
215
313
  // ---------------------------------------------------------------- action: archive / unarchive
216
314
 
217
315
  export async function sessionArchive(env, args, exec) {
@@ -263,11 +361,11 @@ function orderHandoverText(env, sid, extra) {
263
361
  lines.push("【全局指令:执行交接流程】将当前工作写成交接,写毕即停止操作,等待换任。本轮不再进行项目作业。");
264
362
  lines.push("");
265
363
  lines.push("1) 目标文件:" + (hp && hp.path ? hp.path : "(角色未定,须先由全局设定角色)"));
266
- lines.push(" 直接 ho_handover(action=\"write\", text=...)——不要传 path,路径已按你的角色(" + (info && info.role ? info.role : "unknown") + ")解析好。");
267
- lines.push("2) 必写:① 现在在做什么、到哪一步(版本、产物、是否通过)② 环境与路径(版本号、安装位置、用的下载源)③ 下一步清单:按顺序、每条可直接执行 ④ 已完成待验收、要求了没落地 ⑤ 已知坑与踩过的错。");
268
- lines.push("3) 机密只写位置不写值(密码、token、密钥一律写「在哪个文件」)。");
269
- lines.push("4) 写完只回一条 peer_send 给全局:`交接已写 <绝对路径>` + 一句话状态;不要复述全文。");
270
- lines.push("5) 不要压缩上下文、不要建会话(建会话是全局的权限)。");
364
+ lines.push(" 直接调用 ho_handover(action=\"write\", text=...),不要传 path:路径已按你的角色(" + (info && info.role ? info.role : "unknown") + ")解析。");
365
+ lines.push("2) 必写:① 当前进度(版本、产物、是否通过)② 环境与路径(版本号、安装位置、下载源)③ 下一步清单,按顺序列出,每条可直接执行 ④ 已完成待验收、要求未实现的部分 ⑤ 已知问题与已踩的错。");
366
+ lines.push("3) 机密只写位置,不写值:密码、token、密钥均写明所在文件。");
367
+ lines.push("4) 写完只回一条 peer_send 给全局:`交接已写 <绝对路径>`,附一句状态,不复述全文。");
368
+ lines.push("5) 不要压缩上下文,不要新建会话(新建会话属全局权限)。");
271
369
  if (extra) lines.push("6) 全局附加:" + extra);
272
370
  return lines.join(NL);
273
371
  }
@@ -290,7 +388,7 @@ export async function sessionOrder(env, args) {
290
388
  }
291
389
  if (order === "handover") {
292
390
  const text = orderHandoverText(env, sid, extra);
293
- const r = await promptSelf(env, sid, text, "queue");
391
+ const r = await sendPrompt(env.controller(), sid, text, "queue");
294
392
  out.push("已投交接指令 accepted=" + String(r && r.accepted) + "(会唤醒它一轮)");
295
393
  out.push("等它回一条 `交接已写 <路径>` 之后,再用 ho_sessions(action=\"new\") 建接任会话。");
296
394
  return out.join(NL);
@@ -315,7 +413,7 @@ export async function sessionOrder(env, args) {
315
413
  const max = (env.cfg && env.cfg.maxCompactions) || 3;
316
414
  const n = used + 1;
317
415
  if (n > max) {
318
- out.push("已压满 " + used + "/" + max + ":不再压缩。应执行 ho_sessions(action=\"new\") 换任;新会话读毕交接会先向你报告,等待指派。");
416
+ out.push("已压满 " + used + "/" + max + ":不再压缩。应执行 ho_succeed 自行换任(先请用户发送 /allow-succeed 授权);全局会话自身也可用 ho_sessions(action=\"new\")。新会话读毕交接会先向你报告,等待指派。");
319
417
  return out.join(NL);
320
418
  }
321
419
  const recentSec = st && st.lastSummaryMs ? (Date.now() - st.lastSummaryMs) / 1000 : null;
@@ -330,9 +428,15 @@ export async function sessionOrder(env, args) {
330
428
  out.push("请用户到设置页的 dsh-handover 中,点「压缩授权」的「允许该会话压缩一次」(5 分钟内有效),再重下这条指令。");
331
429
  return out.join(NL);
332
430
  }
333
- env.schedule(sid, n);
431
+ const resume = args.resume === "continue" ? "continue" : "wait";
432
+ env.schedule(sid, n, { resume });
334
433
  out.push("用户已授权(" + (cs.atText || '') + " 点击,剩 " + Math.round((cs.leftMs || 0) / 1000) + " 秒)。");
335
- out.push("已登记压缩(它自己第 " + n + "/" + max + " 次):等它空闲后执行,完成后它会自动收到**它自己的**续跑消息(带它自己的交接路径)。");
434
+ out.push("已登记压缩(它自己第 " + n + "/" + max + " 次):等它空闲后执行。");
435
+ if (resume === "wait") {
436
+ out.push("去向:wait(缺省)—— 压缩后不再唤醒它,等用户派新任务,或由新会话接手。");
437
+ } else {
438
+ out.push("去向:continue —— 压缩后它会自动收到**它自己的**续跑消息(带它自己的交接路径)。");
439
+ }
336
440
  out.push("压缩走的是插件调度器(compactNow 只允许空闲时执行),不是给它发消息让它自己压。");
337
441
  out.push("若它正在跑,调度器会等它空闲(上限 " + Math.round(((env.cfg && env.cfg.waitIdleMs) || 180000) / 1000) + " 秒)。");
338
442
  return out.join(NL);
@@ -1,7 +1,7 @@
1
1
  /** 会话清点:只读投影缓存,不激活任何会话、不消耗 token。 */
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { readJsonDir, pick } from './text.mjs';
4
+ import { readJsonDir, pick, isSafeName } from './text.mjs';
5
5
 
6
6
  export function projCacheDir() {
7
7
  return path.join(os.homedir(), '.dsh', 'storages', 'session_projcache', 'sessions');
@@ -30,12 +30,9 @@ function cwdOf(data) {
30
30
  return pick(data, 'record', 'identity', 'cwd');
31
31
  }
32
32
 
33
- const BAD_CHARS = [60, 62, 47, 58, 42, 63, 124, 92, 34].map((c) => String.fromCharCode(c));
34
-
33
+ /** 名字要当目录名用:统一走 text.mjs 的跨平台校验。 */
35
34
  function isPlausible(name) {
36
- if (!name || name.length > 24) return false;
37
- for (const ch of BAD_CHARS) if (name.indexOf(ch) !== -1) return false;
38
- return true;
35
+ return isSafeName(name);
39
36
  }
40
37
 
41
38
  /** 返回 { project, real }:优先看 cwd 里的 Projects/<名>/,退回首条提示里的 Projects/<名>/ 或 「<名>」项目。 */
@@ -59,13 +56,17 @@ export function projectOf(data) {
59
56
  if (ch === " " || ch === String.fromCharCode(9) || ch === String.fromCharCode(10) || ch === String.fromCharCode(13)) { bad = true; break; }
60
57
  name += ch;
61
58
  }
62
- if (!bad && name !== "") return { project: name, real: true };
59
+ // 首条提示常带 Markdown 通配写法(`Projects/**`),解析出的名字必须过名称校验才算项目。
60
+ if (!bad && isPlausible(name)) return { project: name, real: true };
63
61
  }
64
62
  const q = text.indexOf(String.fromCharCode(12300));
65
63
  if (q !== -1) {
66
64
  const rest = text.slice(q + 1);
67
65
  const j = rest.indexOf(String.fromCharCode(12301) + '项目');
68
- if (j > 0) return { project: rest.slice(0, j), real: true };
66
+ if (j > 0) {
67
+ const name = rest.slice(0, j);
68
+ if (isPlausible(name)) return { project: name, real: true };
69
+ }
69
70
  }
70
71
  const title = pick(data, 'record', 'rows', 'title', 'val');
71
72
  return { project: (typeof title === 'string' && title.trim() !== '') ? title : '-', real: false };
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * 设置页用的状态快照(只读):插件自身状态 + 闸门 + 交接体检 + 会话水位 + 最近一次守卫判定。
3
3
  * 全部读本地文件,不唤醒任何会话、不花任何会话的 token。
4
4
  * 任何一段出错都只让该段带 error 字段,不让整页 500。
@@ -64,7 +64,7 @@ export function buildStatus(env, extra) {
64
64
  const map = {};
65
65
  for (const sid of Object.keys(store.sessions)) {
66
66
  const e = store.sessions[sid] || {};
67
- map[sid] = { role: String(e.role || ''), project: String(e.project || ''), task: String(e.task || ''), setAt: String(e.setAt || ''), bootstrappedAt: String(e.bootstrappedAt || '') };
67
+ map[sid] = { role: String(e.role || ''), project: String(e.project || ''), task: String(e.task || ''), setAt: String(e.setAt || '') };
68
68
  }
69
69
  return map;
70
70
  }, {}),
@@ -165,6 +165,12 @@ export function buildStatus(env, extra) {
165
165
  };
166
166
  }, { rows: [], notify: [], hot: 0, unknown: 0 });
167
167
 
168
+ // 设置页用:已接入工作区但没有全局会话时,给出补建入口。
169
+ out.globalSessions = guard(() => {
170
+ const all = (out.sessions && Array.isArray(out.sessions.all)) ? out.sessions.all : [];
171
+ return all.filter((r) => r && r.role === 'global').length;
172
+ }, 0);
173
+
168
174
  if (out.gate && !out.gate.ok && !out.gate.error) out.notes.push('通用约束条闸门未过:向对应项目会话派发,全局不代写项目库内文件。');
169
175
  if (out.handover && out.handover.fail) out.notes.push(out.handover.fail + ' 个项目交接体检不合格,交接前必须先补。');
170
176
  return out;