@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.
- package/README.md +53 -17
- package/docs/migrate.md +1 -1
- package/docs/releases/0.9.12.md +28 -0
- package/docs/releases/0.9.13.md +30 -0
- package/docs/troubleshoot.md +43 -6
- package/package.json +70 -57
- package/scripts/blank-dsh.mjs +6 -5
- package/scripts/blank-smoke.mjs +268 -58
- package/scripts/build.mjs +3 -0
- package/scripts/check-docs.mjs +23 -2
- package/scripts/office.mjs +81 -0
- package/src/client.js +92 -23
- package/src/index.mjs +264 -145
- package/src/lib/api.mjs +67 -9
- package/src/lib/config.mjs +4 -3
- package/src/lib/consent.mjs +59 -20
- package/src/lib/facts.mjs +5 -4
- package/src/lib/guide.mjs +14 -7
- package/src/lib/handover-io.mjs +20 -0
- package/src/lib/manual.mjs +23 -12
- package/src/lib/names.mjs +2 -2
- package/src/lib/office.mjs +272 -0
- package/src/lib/pack-ops.mjs +3 -0
- package/src/lib/phase.mjs +6 -4
- package/src/lib/prompt.mjs +18 -0
- package/src/lib/role.mjs +52 -18
- package/src/lib/schedule.mjs +10 -14
- package/src/lib/session-create.mjs +42 -0
- package/src/lib/session-ops.mjs +183 -79
- package/src/lib/sessions.mjs +9 -8
- package/src/lib/status.mjs +8 -2
- package/src/lib/succession.mjs +244 -0
- package/src/lib/text.mjs +27 -0
- package/src/lib/webroute.mjs +1 -1
- package/src/lib/workspace.mjs +1 -1
- package/src/selftest.mjs +15 -0
- package/src/templates/Charter.skeleton.md +1 -1
- package/src/templates/Prompt_Templates.md +9 -17
- package/src/templates/Runtime_Manual.md +17 -14
- package/src/templates/rules/Handover_Rules.md +3 -2
- package/src/templates/rules/Misc_Task_Standard.md +3 -3
- package/src/tools/ho_checkpoint.mjs +11 -4
- package/src/tools/ho_diag.mjs +5 -3
- package/src/tools/ho_handover.mjs +7 -6
- package/src/tools/ho_office.mjs +59 -0
- package/src/tools/ho_profile.mjs +10 -2
- package/src/tools/ho_sessions.mjs +3 -2
- package/src/tools/ho_snapshot.mjs +2 -1
- package/src/tools/ho_succeed.mjs +24 -0
- package/src/tools/ho_workspace.mjs +7 -3
package/src/lib/session-ops.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
'
|
|
135
|
-
'
|
|
211
|
+
'先读交接:' + handover + '。',
|
|
212
|
+
'读毕先向用户报告,不得自行开展作业:一、当前停在哪一步;二、交接列有哪些待办;三、建议先做哪一条。待用户指派后再动手。交接无待办时如实说明,不得编造任务。',
|
|
136
213
|
'本会话 id:' + sessionId,
|
|
137
|
-
(finalRole === 'project' ? '
|
|
138
|
-
'
|
|
214
|
+
(finalRole === 'project' ? '全局会话 id:' : '创建者会话 id:') + meId,
|
|
215
|
+
'默认沉默:仅在三种时机各发一条 peer_send 给全局:水位不低于 ' + watermark(env.cfg).warn + ' 且已写交接、需用户决定、全局口径有误。任务完成不报全局,任务属本会话内部事务。会话间可直接 peer_send,不必经全局转述;不得每轮回执,不得发送「收到」「已完成」一类确认,全局以文件末态为准。',
|
|
139
216
|
].join(NL);
|
|
140
|
-
const r = await
|
|
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
|
-
|
|
148
|
-
|
|
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 = '>
|
|
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 =
|
|
166
|
-
const project =
|
|
167
|
-
const 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(
|
|
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>':
|
|
191
|
-
'<全局 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 + '
|
|
206
|
-
extraLines.push('
|
|
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
|
|
210
|
-
if (kind === 'migration') extraLines.push('迁移口径见 rules/Migration_Rules.md
|
|
211
|
-
if (
|
|
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("
|
|
267
|
-
lines.push("2) 必写:①
|
|
268
|
-
lines.push("3)
|
|
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
|
|
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
|
-
|
|
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);
|
package/src/lib/sessions.mjs
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
33
|
+
/** 名字要当目录名用:统一走 text.mjs 的跨平台校验。 */
|
|
35
34
|
function isPlausible(name) {
|
|
36
|
-
|
|
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
|
-
|
|
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)
|
|
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 };
|
package/src/lib/status.mjs
CHANGED
|
@@ -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 || '')
|
|
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;
|