@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
@@ -0,0 +1,244 @@
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 } 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
+ /**
26
+ * 找接任指令节:含「## 下一步」或「## 2.」这类标题;没有标题时接受 §2 的写法。
27
+ * 判据与 ho_gate 的交接体检一致:这一节至少要有一个条目(列表、表格或编号行)。
28
+ */
29
+ function nextSectionOf(text) {
30
+ const lines = text.split(NL);
31
+ let start = -1;
32
+ for (let i = 0; i < lines.length; i++) {
33
+ const l = lines[i];
34
+ if (!l.startsWith('## ')) continue;
35
+ if (l.indexOf('下一步') !== -1 || /^##\s*2(?:[.、\s]|$)/.test(l)) { start = i; break; }
36
+ }
37
+ if (start === -1) {
38
+ if (/§\s*2/.test(text)) return { ok: true };
39
+ return { ok: false, reason: '缺少接任指令节:应有「## 下一步」或「## 2.」一节' };
40
+ }
41
+ const rows = [];
42
+ for (let i = start + 1; i < lines.length; i++) {
43
+ if (lines[i].startsWith('## ')) break;
44
+ const t = lines[i].trim();
45
+ if (/^[-*]\s+/.test(t)) rows.push(t);
46
+ else if (/^\|/.test(t) && t.indexOf('---') === -1) rows.push(t);
47
+ else if (/^\d+[.、]\s+/.test(t)) rows.push(t);
48
+ }
49
+ if (!rows.length) return { ok: false, reason: '「下一步」一节没有列出任何条目' };
50
+ return { ok: true };
51
+ }
52
+
53
+ /** 交接结构检查(只判结构):返回缺项列表,空数组表示通过。 */
54
+ export function auditHandover(file) {
55
+ const problems = [];
56
+ try {
57
+ const st = fs.statSync(file);
58
+ if (!st.isFile()) return ['交接路径不是文件:' + file];
59
+ } catch {
60
+ return ['交接文件不存在:' + file];
61
+ }
62
+ let text = '';
63
+ try { text = readUtf8(file).replace(/^\uFEFF/, ''); } catch (e) { return ['交接文件读取失败:' + msgOf(e)]; }
64
+ if (!text.trim()) return ['交接文件为空:' + file];
65
+ const next = nextSectionOf(text);
66
+ if (!next.ok) problems.push(next.reason);
67
+ for (const p of HANDOVER_PLACEHOLDERS) {
68
+ if (text.indexOf(p) !== -1) problems.push('仍有占位符:' + p);
69
+ }
70
+ return problems;
71
+ }
72
+
73
+ /** 在旧会话的交接末尾追加「换任登记」:先备份到同目录 .bak,再追加。 */
74
+ function appendSuccessionBlock(file, lines) {
75
+ const cur = readUtf8(file);
76
+ const bom = cur.charCodeAt(0) === 0xFEFF ? '\uFEFF' : '';
77
+ const body = bom ? cur.slice(1) : cur;
78
+ fs.copyFileSync(file, file + '.bak');
79
+ const gap = body.endsWith(NL) ? NL : NL + NL;
80
+ const block = ['## 换任登记', ''].concat(lines).join(NL);
81
+ fs.writeFileSync(file, bom + body + gap + block + NL, 'utf8');
82
+ return file + '.bak';
83
+ }
84
+
85
+ /**
86
+ * 执行一次换任自服务。
87
+ * @param {object} env 插件环境
88
+ * @param {object} exec 工具执行上下文
89
+ * @param {object} [opts] { note, force }:force 为 true 时越过「水位未到线」判据
90
+ * @returns {Promise<string>} 中文结果或失败原因
91
+ */
92
+ export async function runSuccession(env, exec, opts) {
93
+ const o = opts || {};
94
+ const note = typeof o.note === 'string' ? o.note.trim() : '';
95
+ const reject = (reason) => '换任未执行,未做任何写入。' + NL + reason;
96
+
97
+ // 1. 自身角色与类别:只认已登记的角色。
98
+ const info = env.roleOf(exec) || {};
99
+ const kind = String(info.role || 'plain');
100
+ if (kind === 'plain') return reject('普通会话不参与换任。');
101
+ if (kind !== 'global' && kind !== 'project' && kind !== 'misc') return reject('本会话角色无法用于换任:' + kind);
102
+ const project = String(info.project || '');
103
+ const task = String(info.task || '');
104
+ const key = lineageKeyOf(kind, project, task);
105
+ if (!key) return reject(kind === 'project' ? '项目名缺失,无法确定类别。' : '任务名缺失,无法确定类别。');
106
+
107
+ // 2. 用户授权:AI 不能自行批准。
108
+ const selfId = env.selfId(exec);
109
+ if (!selfId) return reject('拿不到当前会话 id,无法校验授权。');
110
+ const cs = checkConsent(selfId, 'succeed');
111
+ if (!cs.ok) {
112
+ return reject([
113
+ cs.reason + '。',
114
+ '授权方式(任选其一):',
115
+ ' ① 在聊天框发送 /allow-succeed(最快,仅授权本会话);',
116
+ ' ② 在设置页的 dsh-handover 中点击「换任授权」的「允许换任」。',
117
+ '授权有效期 5 分钟,用后失效。',
118
+ ].join(NL));
119
+ }
120
+
121
+ // 3. 交接结构检查(只判结构,不判质量)。
122
+ const hp = env.handoverFor(exec) || {};
123
+ const handoverFile = hp.path ? String(hp.path) : '';
124
+ if (!handoverFile) return reject('没有交接路径:' + (hp.reason || '本会话角色未确定'));
125
+ const problems = auditHandover(handoverFile);
126
+ if (problems.length) {
127
+ return reject(['交接结构未通过:' + handoverFile]
128
+ .concat(problems.map((p) => ' - ' + p))
129
+ .concat(['补齐后重新调用 ho_succeed。'])
130
+ .join(NL));
131
+ }
132
+
133
+ // 4. 在任身份与重复换任:登记表是唯一记录。
134
+ const ws = String(env.resolveWs(exec) || '');
135
+ if (!ws) return reject('无法确定工作区根,读不到在任登记表。');
136
+ const store = loadOffice(ws);
137
+ const lin = store.lineages[key];
138
+ // 只有本类别的在任者本人可换任:既防旧会话复活,也防同项目的旁支会话抢先。
139
+ if (!lin || !lin.current || !lin.current.sessionId) {
140
+ return reject('本类别(' + key + ')在在任登记表里还没有现任记录:先由全局建会话并登记,再换任。');
141
+ }
142
+ if (String(lin.current.sessionId) !== String(selfId)) {
143
+ return reject('本会话不是在任者:本类别现任为 ' + String(lin.current.name || '(未命名)')
144
+ + '(' + String(lin.current.sessionId) + '),不得换任。');
145
+ }
146
+ {
147
+ const history = Array.isArray(lin.history) ? lin.history : [];
148
+ const closed = history.find((h) => h && String(h.sessionId || '') === String(selfId) && h.to);
149
+ if (closed) {
150
+ const curName = lin.current && lin.current.name ? lin.current.name : '(空缺)';
151
+ return reject('本类别已为本会话记过一次换任(' + String(closed.to || '') + ',现任 ' + curName + '),旧会话不得再次换任。');
152
+ }
153
+ }
154
+
155
+ // 4.1 水位未到线:本会话就是在任者且水位低于提醒线时,换任通常是多余的。
156
+ // 放在授权与交接校验之后、任何写入之前;取不到水位就跳过。
157
+ if (o.force !== true) {
158
+ let pct = null;
159
+ try { pct = typeof env.selfPct === 'function' ? env.selfPct(selfId) : null; } catch { pct = null; }
160
+ if (pct !== null && pct !== undefined && Number(pct) < watermark(env.cfg).warnValue) {
161
+ return reject('本会话水位 ' + Number(pct).toFixed(1) + '%,尚未到线,通常无需换任;确需换任请传 force=true。');
162
+ }
163
+ }
164
+
165
+ // 5. 分配编号:登记表历史最大号加一,不采用会话自述。
166
+ const name = nextName(store, key);
167
+
168
+ // 6. 确定工作区:与 ho_sessions(action="new") 同一套解析;杂项落到本会话所在工作区。
169
+ let workspaceId = '';
170
+ try {
171
+ workspaceId = resolveWorkspaceFor(env, exec, kind, project, task, kind === 'misc' ? { workspace: ws } : {});
172
+ } catch (e) {
173
+ return reject(msgOf(e));
174
+ }
175
+
176
+ // 7. 拼启动提示词:末尾追加交接路径与「先读交接、读毕报到」。
177
+ let prompt = '';
178
+ try {
179
+ const base = promptFor(env, { ws, kind, project, task, title: name, sessionId: '', globalSessionId: selfId, extra: '' });
180
+ prompt = base + NL + NL + [
181
+ '接任交接:' + handoverFile,
182
+ '先读这份交接再动手;读毕向用户报告当前停在哪一步、交接列有哪些待办,然后等待用户指派任务。',
183
+ ].join(NL);
184
+ } catch (e) {
185
+ return reject('启动提示词生成失败:' + msgOf(e));
186
+ }
187
+
188
+ // 8. 建会话:失败即停止,不登记。
189
+ const made = await newSessionIn(env, { workspaceId, title: name, role: kind, project, task });
190
+ const sessionId = made && made.sessionId ? String(made.sessionId) : '';
191
+ if (!sessionId) return reject('建会话失败,未做任何登记:' + ((made && made.error) || '原因未知'));
192
+ // 提示词里预留的是「建完后填」,拿到会话 id 后在这里替换成真实 id。
193
+ const text = prompt.split('(建完后填)').join(sessionId);
194
+
195
+ // 9. 登记在任:原在任者自动进历史,Registry.md 在役列同步。
196
+ let reg = null;
197
+ try {
198
+ reg = registerHolder(ws, { kind, project, task, name, sessionId, workspace: ws, note });
199
+ } catch (e) {
200
+ return [
201
+ '会话已建 ' + sessionId + '(' + name + '),但登记在任失败:' + msgOf(e),
202
+ '启动提示词尚未投递;先修好登记表,再用 peer_send 通知它。',
203
+ ].join(NL);
204
+ }
205
+
206
+ // 10. 投递启动提示词:失败要如实报出,会话已经存在。
207
+ let delivered = false;
208
+ let deliveryError = '';
209
+ try {
210
+ const r = await sendPrompt(env.controller(), sessionId, text, 'queue');
211
+ delivered = !!(r && r.accepted);
212
+ if (!delivered) deliveryError = '会话控制器未接受(accepted=false)';
213
+ } catch (e) {
214
+ deliveryError = msgOf(e);
215
+ }
216
+
217
+ // 11. 旧会话的交接末尾追加换任登记;写前备份。
218
+ let appendNote = '';
219
+ try {
220
+ const bak = appendSuccessionBlock(handoverFile, [
221
+ '- 接任者:' + name,
222
+ '- 会话 id:' + sessionId,
223
+ '- 时刻:' + stampNow(),
224
+ ...(note ? ['- 备注:' + note] : []),
225
+ '- 旧会话停止作业。',
226
+ ]);
227
+ appendNote = '已写入交接末尾(备份 ' + bak + ')';
228
+ } catch (e) {
229
+ appendNote = '换任登记写入失败:' + msgOf(e);
230
+ }
231
+
232
+ // 12. 消费授权:一次授权只够换任一次。
233
+ try { consumedConsent(selfId, 'succeed'); } catch { /* 授权文件损坏不影响本次结果 */ }
234
+
235
+ // 13. 结果。
236
+ const out = [];
237
+ out.push('换任完成:' + name + '(' + sessionId + ')');
238
+ out.push('在任登记:' + key + (reg && reg.registry && reg.registry.changed && reg.registry.changed.length
239
+ ? ',Registry.md 已同步在役列' : ''));
240
+ out.push('启动提示词:' + (delivered ? '已投递' : '未送达:' + deliveryError + '。会话已建,可用 peer_send 补投'));
241
+ out.push('交接登记:' + appendNote);
242
+ out.push('本会话到此停止,等待用户在新会话中派任务。');
243
+ return out.join(NL);
244
+ }
package/src/lib/text.mjs CHANGED
@@ -16,6 +16,33 @@ export function isDir(p) {
16
16
  try { return fs.statSync(p).isDirectory(); } catch { return false; }
17
17
  }
18
18
 
19
+ /**
20
+ * 项目名与任务名要当目录名用,必须在 Windows/macOS/Linux 上都合法。
21
+ * 规则:非空、不超过 24 字符、不含控制字符与 < > : " / \ | ? *、不以点或空格结尾、不是设备保留名。
22
+ * 名字来自首条提示等自由文本时尤其要校验:Markdown 里的 `Projects/**` 这类通配写法会被误当成名字(实测踩过)。
23
+ */
24
+ const RESERVED_NAMES = ['con', 'prn', 'aux', 'nul',
25
+ 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
26
+ 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9'];
27
+
28
+ export function isSafeName(name, maxLen) {
29
+ const s = String(name === undefined || name === null ? '' : name);
30
+ const limit = maxLen === undefined ? 24 : maxLen;
31
+ if (s === '' || s.length > limit) return false;
32
+ if (/[\u0000-\u001f\u007f<>:"/\\|?*]/.test(s)) return false;
33
+ if (/[. ]$/.test(s)) return false;
34
+ if (RESERVED_NAMES.indexOf(s.toLowerCase()) !== -1) return false;
35
+ return true;
36
+ }
37
+
38
+ /** 名字不合法时抛出可直接展示的中文错误。 */
39
+ export function assertSafeName(name, label, maxLen) {
40
+ if (isSafeName(name, maxLen)) return String(name);
41
+ const shown = String(name === undefined || name === null ? '' : name);
42
+ throw new Error((label || '名称') + '不合法:' + JSON.stringify(shown)
43
+ + '(不得为空、不超过 ' + (maxLen === undefined ? 24 : maxLen) + ' 个字符、不含 < > : " / \\ | ? * 与控制字符、不以点或空格结尾、不是设备保留名)');
44
+ }
45
+
19
46
  /**
20
47
  * 读目录下全部 *.json,返回 [{ name, path, data, mtimeMs }],读不动的跳过。
21
48
  *
@@ -94,7 +94,7 @@ export function registerStatusRoute(ctx, env, info, apiIn) {
94
94
  return;
95
95
  }
96
96
 
97
- if (tail === '/setup' || tail === '/register' || tail === '/extra-prompt' || tail === '/role') {
97
+ if (tail === '/setup' || tail === '/deploy' || tail === '/global-session' || tail === '/register' || tail === '/extra-prompt' || tail === '/role') {
98
98
  if (method !== 'POST') { res.writeHead(405, { allow: 'POST' }); res.end(); return; }
99
99
  const input = await readBody(req, res);
100
100
  if (input === null) return;
@@ -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) {
package/src/selftest.mjs CHANGED
@@ -10,6 +10,8 @@ import { pmGateTool } from './tools/ho_gate.mjs';
10
10
  import { pmSessionsTool } from './tools/ho_sessions.mjs';
11
11
  import { pmPhaseTool } from './tools/ho_phase.mjs';
12
12
  import { pmCharterTool } from './tools/ho_charter.mjs';
13
+ import { pmOfficeTool } from './tools/ho_office.mjs';
14
+ import { pmSucceedTool } from './tools/ho_succeed.mjs';
13
15
  import path from 'node:path';
14
16
 
15
17
  const argv = process.argv.slice(2);
@@ -54,6 +56,13 @@ const env = {
54
56
  lockState: () => 'unknown',
55
57
  };
56
58
 
59
+ /** 换任只读用例:只验授权判据,不带用户授权时应当返回「没有授权」,而不是抛异常。 */
60
+ const envSucceed = Object.assign({}, env, {
61
+ selfId: () => 'session-selftest-succeed',
62
+ roleOf: () => ({ role: 'project', project: 'Demo', task: '', source: 'selftest' }),
63
+ handoverFor: () => ({ path: path.join(ws, '_Project_Meta', 'Demo', 'Handover.md'), reason: '' }),
64
+ });
65
+
57
66
  const exec = null;
58
67
  const cases = [
59
68
  { t: pmBriefTool(env), a: {}, flag: "brief" },
@@ -61,6 +70,8 @@ const cases = [
61
70
  { t: pmSessionsTool(env), a: {}, flag: "sessions" },
62
71
  { t: pmPhaseTool(env), a: {}, flag: "phase" },
63
72
  { t: pmCharterTool(env), a: { doc: "charter", section: "8.1.1.4", maxLines: 30 }, flag: "charter" },
73
+ { t: pmOfficeTool(env), a: { query: "全局" }, flag: "office" },
74
+ { t: pmSucceedTool(envSucceed), a: {}, flag: "succeed", want: /没有授权/ },
64
75
  ];
65
76
 
66
77
  let failed = 0;
@@ -71,6 +82,10 @@ for (const c of cases) {
71
82
  try {
72
83
  const out = await c.t.execute(c.a, exec);
73
84
  console.log(out.text);
85
+ if (c.want && !c.want.test(String(out && out.text))) {
86
+ failed++;
87
+ console.log("FAILED: 输出不符合预期 " + c.want);
88
+ }
74
89
  } catch (e) {
75
90
  failed++;
76
91
  console.log("FAILED: " + (e && e.message ? e.message : String(e)));
@@ -1,6 +1,6 @@
1
1
  # Workspace Charter(骨架)
2
2
 
3
- > 本文件由 dsh-handover 首次安装向导生成,只含跑起来所需的最小内容;按需继续写。
3
+ > 本文件由 dsh-handover 首次安装向导生成,只含可运行所需的最小内容;按需继续写。
4
4
  > 铁律:§8.1.1.4 那 24 行是唯一正文来源,任何项目库的 RULES.md 都要逐字复制它(外加一行「上下文水位自查」)。改这里就必须同步改所有库。
5
5
 
6
6
  ## 8. 工程约束
@@ -7,7 +7,7 @@
7
7
  ```text
8
8
  你是<角色>,由全局管理对话创建并交接。
9
9
  第一条要读的:<绝对路径>(读毕再开始作业)。| 项目→ _Project_Meta/<项目名>/Handover.md;全局→ records/Global_Handover.md;杂项首任→ rules/Misc_Task_Standard.md
10
- 你是平级会话:跑完一轮不会有任何自动通知;**唤醒权只在全局**。**默认沉默**:只有四种时机各发一条 `peer_send` 给现任全局——任务达成、≥32% 且已写交接、需用户决定、发现全局口径有误;**不得每轮回执,亦不得发送「收到」「已完成」一类确认**(全局不看回执,只看文件末态)。
10
+ 不得每轮回执,也不发送「收到」「已完成」一类确认;全局只看文件末态。
11
11
  你的 session id:<本会话 id>。确需向全局发消息时,以 `peer_send` 发至 <全局 id>。| 不得据 peer_list 推测 id
12
12
  水位:≥32% 应先写毕交接、再通知全局,并提醒用户换会话;>35% 不得新开任务。
13
13
  边界:只读写 <范围>。删除或覆盖前须先留快照,**删除须经用户确认**。对用户可见一律中文(含工具 description 字段)。
@@ -37,7 +37,7 @@
37
37
  库根:Projects/<项目名>/<项目名>库/ ;元数据:_Project_Meta/<项目名>/ ;快照:_Temp/<项目名>/Snapshots/ ;版本冻结:_Archives/<项目名>/Backups/
38
38
  开始作业前应先读:_Project_Meta/<项目名>/Handover.md → 库根 SKILL.md →(修改文档时)_Control_Center/rules/ 下对应规范。
39
39
  两件必查:库内 RULES.md 里必须有「运行期省 token」与「上下文水位自查」两条;缺者应照 Workspace_Charter.md 原文补上(唯一来源,不得自行改动数字)。
40
- 换任由全局执行,不由你执行:进入预警区后只写交接、通知全局并提醒用户,**不得自行调用 peer_new**。
40
+ 换任自办,规则见插件注入的提示段;**不得自行调用 peer_new**,也不向全局请求换任。
41
41
  ```
42
42
 
43
43
  ### 2.3 杂项(只在首任时由全局发,此后自理)
@@ -47,25 +47,17 @@
47
47
  你是杂项任务会话,标题「<任务短名>」,不编号。
48
48
  任务目录:_Misc/tasks/<任务短名>/(产物、脚本、日志都放这里)
49
49
  三件文件:Project_Overview.md(是什么/边界/验收判据)· Changelog.md(环境事实/变更/回滚,只追加)· Handover.md(在途)
50
- 换任自己办:peer_new({ workspace: 'Misc', title: '<自拟名称>' }) → 查重 → 新 id 写进交接 §0 → 不通知全局。
50
+ 换任自办(规则见插件注入的提示段):接任者名字与会话 id 由插件写进登记表并在旧交接末尾追加登记,不通知全局。
51
51
  本任务:<任务正文>| 用户原话照抄,不改写、不缩范围、不替用户补验收判据
52
52
  ```
53
53
 
54
- ## 3 消息二 · 项目 → 全局:交接请求
54
+ ## 3 消息二 · 换任(已停用)
55
55
 
56
- ```text
57
- 【交接请求】<项目名>|<本会话 id>|水位 <xx>%
58
- 已写完 _Project_Meta/<项目名>/Handover.md(<行数> 行,已归档旧版到 _Archives/<项目名>/Handover/)。
59
- 在途:<有/无>;一句话:<现在卡在哪>。
60
- 请建接任会话。
61
- ```
56
+ > 换任由在任会话调用 `ho_succeed` 完成:用户授权、插件体检交接、分配编号、建立接任会话并投递启动提示。不再有「项目 → 全局:交接请求」的消息,也不再需要全局代办。本节保留编号,避免后续节号变动。
62
57
 
63
- ## 4 消息三 · 全局 → 项目:已交接
58
+ ## 4 消息三 · 全局代建(仅备选)
64
59
 
65
- ```text
66
- 【已交接】<项目名>|接任已建:<项目名><N>|session id <新 id>
67
- 你的交接已归档,可以停止操作:不得新开任务,不得修改文件。
68
- ```
60
+ > 仅在自动换任失败、且用户要求时使用:全局用 `ho_sessions(action="new")` 建接任会话,标题按 `<项目名><N>`(N=本机下一个序号),启动提示必须带新会话自己的 session id、现任全局 session id 与旧会话 `Handover.md` 的绝对路径。建完请用户在旧会话里确认停止。
69
61
 
70
62
  ## 5 消息四 · 全局 → 项目:新任务派发
71
63
 
@@ -76,7 +68,7 @@
76
68
  约束:<本次不许碰的东西;无则删本行>
77
69
  ```
78
70
 
79
- > 派活前先 `peer_list`:已有会话且 <32% **必须复用它**,不许新建;≥32% 先让其写完交接再新建。
71
+ > 派活前先查在任(`ho_office` 空参数,零成本);复用准则固定写在插件注入的提示段,本节不重复。
80
72
 
81
73
  ## 6 消息五 · 迁移通知(打包前刷交接)
82
74
 
@@ -91,6 +83,6 @@
91
83
 
92
84
  ## 7 发送纪律
93
85
 
94
- > 时机与纪律(不寒暄、一次一件事、并发 ≤3、默认沉默、未被要求勿唤醒、不转述)见 `rules/Workspace_Charter.md` §7.2.4 第 9 条,本节不重复。
86
+ > 汇报时机见插件注入的提示段;格式与频率纪律(不寒暄、一次一件事、并发 ≤3、未被要求勿唤醒、不转述)见 `rules/Workspace_Charter.md` §7.2.4 第 9 条,本节不重复。
95
87
  >
96
88
  > 本节只留一条**模板自身**的要求:发送前删掉所有 `|` 之后的提示与 `<>` 占位,只留实值。
@@ -2,22 +2,22 @@
2
2
 
3
3
  > 这是**按需取回**的全文:系统提示里只留短段与指针,需要细节时用 `ho_charter(doc="runtime")` 取本文。
4
4
  >
5
- > **内置即默认**:本文、Charter 骨架、交接、台账骨架、提示词模板、四份规则文档都随插件发布(`dist/templates/`)。工作区里的同名文件只是「本地化覆盖」;删掉就自动回到内置,功能不降级。`ho_init` 是把默认口径落盘方便修改,**不是使用前提**。
5
+ > **内置即默认**:本文、Charter 骨架、交接、台账骨架、提示词模板、四份规则文档都随插件发布(`dist/templates/`)。工作区里的同名文件只是「本地化覆盖」;删掉就自动回到内置,功能不降级。`ho_init` 是把默认口径写入磁盘,便于修改,**不是使用前提**。
6
6
  > 口径冲突时以工作区的 `_Control_Center/rules/` 为准;本文只讲「插件怎么用、角色怎么分」。
7
7
 
8
8
  ## 1. 角色四档
9
9
 
10
10
  | 角色 | 判定顺序 | 交接文件 | 可见工具 |
11
11
  |---|---|---|---|
12
- | 全局 | 角色表 → 标题含「全局」 → 全局工作区 | `_Control_Center/records/Global_Handover.md` | 22 个(全部) |
13
- | 项目 | 角色表(带项目名)→ cwd 在 `Projects/<名>/` → 首条提示含 `Projects/<名>/` | `_Project_Meta/<项目>/Handover.md` | 9 个 |
14
- | 杂项 | 角色表(带任务名)→ cwd 在 `_Misc/tasks/<任务>/` | `_Misc/tasks/<任务>/Handover.md` | 9 个 |
12
+ | 全局 | 角色表 → 标题含「全局」 → 全局工作区 | `_Control_Center/records/Global_Handover.md` | 24 个(全部) |
13
+ | 项目 | 角色表(带项目名)→ cwd 在 `Projects/<名>/` → 首条提示含 `Projects/<名>/` | `_Project_Meta/<项目>/Handover.md` | 11 个 |
14
+ | 杂项 | 角色表(带任务名)→ cwd 在 `_Misc/tasks/<任务>/` | `_Misc/tasks/<任务>/Handover.md` | 11 个 |
15
15
  | 普通 | 子会话,或都不中 | 无(不参与交接) | 0 个(不注入提示段) |
16
16
 
17
17
  角色表在 `~/.dsh/storages/dsh-handover.roles.json`,是**本机状态**,不进仓库、不进迁移包。子会话默认普通;需要时由全局在角色表里指定。
18
18
  设定入口:设置 → dsh-handover →「会话角色」;或对全局说「把 Demo1 设成项目角色」,它用 `ho_sessions(action="role")` 办。
19
19
 
20
- ## 2. 工具清单(22 个)
20
+ ## 2. 工具清单(24 个)
21
21
 
22
22
  ### 只读
23
23
  - `ho_brief` —— 起始与收束的单条命令:水位、闸门、会话清点、阶段建议。
@@ -28,17 +28,19 @@
28
28
  - `ho_drift` —— 交接漂移:Δ=当前轮−交接轮,>3 需刷新。
29
29
  - `ho_diag` —— 插件自检:服务、调度器、压缩计数、工作区、角色判定。
30
30
  - `ho_tokens` —— 会话 token 体检:轮数、步数、每步 token、最贵单条返回。
31
+ - `ho_office` —— 查在任:输入「全局」「全局3」「项目名」等,返回现任会话的名字与会话 id;多个名字空格分隔,留空列出全部。
31
32
  - `peer_list` / `peer_status` / `peer_peek` —— 平级会话清点与近期对话(peek 会花本会话 token)。
32
33
 
33
34
  ### 写
34
35
  - `ho_handover` —— 读、写、追加、归档**本会话自己的**交接(写前自动备份、写后按角色体检)。
35
- - `ho_checkpoint` —— 写交接 → 体检 → 登记压缩 → 轮末自动压缩 → 自动续跑。
36
+ - `ho_checkpoint` —— 写交接 → 体检 → 登记压缩 → 轮末自动压缩。`resume=continue`(缺省)压缩后续跑;`resume=wait` 表示任务已收尾,压缩后不唤醒。
36
37
  - `ho_snapshot` —— 库快照:逐字节复制、sha256 清单、来源说明。
37
38
  - `ho_changelog` —— 按固定表格追加一行台账。
38
39
  - `ho_pack` —— `action`:`check`(打包前置闸门)、`pack`(打包,缺省)、`verify`(校验包是否仍是现版)、`restore`(默认预演,覆盖前备份,从不删除目标文件)。`format=auto|7z|tar`:有 7-Zip 出 `.7z`,没有就出 `.tar.gz`(内置归档器,功能等价);解包按魔数识别,包内路径必须是相对路径且不含 `..`,否则整体中止。
39
40
  - `ho_workspace` —— 工作区 list、create、delete(删除只删注册)与换盘动作。
40
41
  - `ho_init` —— 首次安装:在指定目录下建整套全局文件体系,可顺带注册工作区。
41
42
  - `ho_profile` —— 项目或杂项的骨架注册:由所属会话通读自己的库后写入并校验。
43
+ - `ho_succeed` —— 换任:用户授权后校验交接、按记录分配编号、建立接任会话、登记在任并投递启动提示词。
42
44
  - `peer_send` / `peer_new` / `peer_rename` —— 投递消息、建平级会话、只改标题。
43
45
 
44
46
  ### ho_sessions 的 action
@@ -47,7 +49,7 @@
47
49
  | `list`(缺省) | 会话清点 | `limit`、`warn`、`include_subagents` |
48
50
  | `role` | 设定会话角色(写角色表+当场收窄+可选投整理指令) | `session_id`、`role`、`project`、`task`、`notify` |
49
51
  | `new` | 建下一代会话并唤醒,自动投启动提示词(按接任者角色取**接任者自己的**交接路径) | `title`、`workspace`、`cwd`、`message`、`project`、`force` |
50
- | `order` | 远程给某个会话下指令:`order=handover` 让它写交接再换任;`order=compact` 给它排压缩(目标必须在内存、要有合格交接、未压满) | `session_id`、`order`、`extra`、`force` |
52
+ | `order` | 远程给某个会话下指令:`order=handover` 让它写交接再换任;`order=compact` 给它排压缩(目标必须在内存、要有合格交接、未压满) | `session_id`、`order`、`resume`、`extra`、`force` |
51
53
  | `prompt` | 按 `templates/Prompt_Templates.md` 渲染可粘贴的启动提示词 | `kind`、`project`、`title`、`sessionId`、`globalSessionId` |
52
54
  | `place` | 把已存在的会话移进工作区(会话 cwd 必须正是该工作区根) | `session_id`、`workspace` |
53
55
  | `archive` | 归档会话(相当于图形界面的删除) | `session_id`、`force` |
@@ -56,10 +58,11 @@
56
58
  ## 3. 水位与交接协议
57
59
 
58
60
  - 32% 提醒、35% 交接线、85% 硬顶;同一会话最多压缩 3 次(共 4 段使用)。数值来自 `config.mjs`,提示段与设置页由 `lib/facts.mjs` 统一渲染。
59
- - 到线流程:`ho_handover` 写交接(体检不通过就拒绝压缩)→ `ho_checkpoint` 登记 → 轮末自动压缩 → 续跑消息要求先 `ho_handover(action=read)`。
60
- - 第 4 段到线:写交接 + 全局用 `ho_sessions(action="new")` 建下一任;项目会话自己**不要**建会话(换任由全局做)。
61
+ - 到线流程:`ho_handover` 写交接(体检不通过就拒绝压缩)→ `ho_checkpoint` 登记(`resume=continue|wait`)→ 轮末自动压缩 → 继续时投续跑消息;收尾时不唤醒,等用户派新任务。
62
+ - 第 4 段到线或压缩额度用尽:由在任会话自己换任——写交接 → 请用户在聊天框敲 `/allow-succeed` 授权 → 调 `ho_succeed`(校验交接、分配编号、建立接任会话、登记在任、投递提示词)。全局仍可用 `ho_sessions(action="new")` 手动建立会话,作为自动流程失败时的备选方式。
61
63
  - 关键步骤不要压:`ho_checkpoint` 传 `at_risk=true`,它只提醒不压;硬顶 85% 除外。
62
64
  - 压缩不可逆:历史会被摘要替换,所以「先把交接写合格」是硬前提。
65
+ - 在任登记表由插件维护:`_Control_Center/records/Office.json` 记每一类会话的现任与会话 id;查现任用 `ho_office`,不起会话时用 `node scripts/office.mjs 全局 全局3`。
63
66
 
64
67
  ## 4. 交接文件的结构要求
65
68
 
@@ -81,7 +84,7 @@
81
84
 
82
85
  ## 6. 给项目会话发提示词的硬要求
83
86
 
84
- - 必须以 `_Control_Center/templates/Prompt_Templates.md` §1 共用骨架起头,保留「默认沉默·四种时机」那一行。
87
+ - 必须以 `_Control_Center/templates/Prompt_Templates.md` §1 共用骨架起头,保留「默认沉默·三种时机」那一行。
85
88
  - **不要索要回执、不要写「每轮报告」**;写完即为完成,证据看文件末态。
86
89
  - 换任提示词必须带:新会话自己的 session id、现任全局 session id、它自己的交接绝对路径。
87
90
 
@@ -124,14 +127,14 @@
124
127
 
125
128
  ### 12.1 分层(谁在每轮付费)
126
129
 
127
- | 层 | 内容 | 何时进入上下文 | 体积上限(加权 token) |
130
+ | 层 | 内容 | 何时进入上下文 | 实测参考(加权 token) |
128
131
  |---|---|---|---|
129
- | L0 常驻 | 角色段(全局五节;项目、杂项九行) | **每一轮** | 全局 320;项目 300;长路径与迁移提示档 360、340 |
132
+ | L0 常驻 | 角色段(全局五节;项目、杂项各节) | **每一轮** | 全局约 385;项目与杂项约 370;长路径与迁移提示档约 435、419(参考值,不设上限) |
130
133
  | L0 用户内容 | 全局前置提示词(用户开关,默认关闭) | 开启且非空时每轮 | 由用户自管,不计入本插件预算 |
131
134
  | L1 手册 | 本文件 | 按需取回,可**按节**取 | 不设上限(不进每轮) |
132
135
  | L2 规则文档 | 工作区 `_Control_Center/rules/` | 按需 | 由工作区自管 |
133
136
 
134
- > 加权 token 的算法:CJK 与全角标点每字符 1 token,其余每 4 字符 1 token。上限集中在 `lib/facts.mjs` 的 `TOKEN_BUDGET`,冒烟断言直接引用该表。工具描述单条上限 70 token。
137
+ > 加权 token 的算法:CJK 与全角标点每字符 1 token,其余每 4 字符 1 token。实测常量在 `lib/facts.mjs` 的 `TOKEN_BUDGET`,冒烟只打印实测值,**不设上限**,也不用于拒绝或截断。
135
138
  >
136
139
  > L0 是成本主体:它按轮次乘算,所以只放「每轮都要用到的判定」。任何「一个会话生命周期里只用几次」的流程,一律以指针落到 L1。
137
140
 
@@ -159,7 +162,7 @@
159
162
 
160
163
  1. 体积:全局段 ≤320、项目段 ≤300(含迁移提示与真实长路径时按 360、340 档)。
161
164
  2. 结构:全局五节、项目九行各出现一次,顺序不变。
162
- 3. 一处一规则:唤醒权、压缩授权、交接线各只出现一次。
165
+ 3. 一处一规则:会话间通信规则、压缩授权、交接线各只出现一次。
163
166
  4. 禁止回灌:工作流细节(`ho_pack`、`move-probe`、具体命令步骤)不得出现在 L0。
164
167
  5. 手册同步:凡 L0 指向的 section 必须在本文中存在且标题可匹配。
165
168
 
@@ -15,10 +15,10 @@
15
15
 
16
16
  ## 2. 什么时候写
17
17
 
18
- - 水位 **32%**:开始收口,别开新活。
18
+ - 水位 **32%**:开始收尾,不要开新任务。
19
19
  - 水位 **35%**:必须写完交接 → `ho_checkpoint` 登记 → 轮末自动压缩 → 续跑。
20
20
  - 水位 **85%**:硬顶,不压也得压。
21
- - 同一会话最多压 3 次;第 4 段写交接并通知全局换任(**项目会话不要自己建会话**)。
21
+ - 同一会话最多压 3 次;第 4 段写交接,用户授权后由在任会话调用 `ho_succeed` 换任(**不得自行新建会话**)。
22
22
 
23
23
  ## 3. 必须写清(缺一项就会在换任时丢信息)
24
24
 
@@ -35,6 +35,7 @@
35
35
  - 时间一律带偏移(例 `2026-10-24 22:40 +0800`)。
36
36
  - 不写「大概」「应该」这类没核过的话;没核就写「未核,待确认」。
37
37
  - 写交接用 `ho_handover(action="write")`、`action="append"`,不要手改文件(会绕过备份与体检)。
38
+ - 归档命名:`_Archives/<范围>/Handover/<原文件名>_<YYYYMMDD_HHMMSS>.md`(例 `Handover_20261108_144307.md`);杂项归档自包含在 `_Misc/tasks/<任务>/_archive/`。
38
39
 
39
40
  ## 5. 体检判据(插件自动跑)
40
41
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## 1. 什么算杂项
4
4
 
5
- 一次性或短期的活:不建项目库、不进 `_Project_Meta/Registry.md`。落点 `_Misc/tasks/<任务短名>/`。
5
+ 一次性或短期任务:不建项目库、不进 `_Project_Meta/Registry.md`。落点 `_Misc/tasks/<任务短名>/`。
6
6
 
7
7
  ## 2. 目录约定
8
8
 
@@ -14,8 +14,8 @@
14
14
 
15
15
  ## 3. 边界
16
16
 
17
- - 只写上面三处;`_Control_Center`、`Projects`、其他任务目录都不碰。
18
- - 接任:杂项可以自建 Misc 换任(`ho_sessions(action="new")` 指向 Misc 工作区),项目会话不许。
17
+ - 只写上面三处;`_Control_Center`、`Projects`、其他任务目录都不得写入。
18
+ - 接任:杂项须用户授权(`/allow-succeed` 或设置页),再由本会话调用 `ho_succeed`,由插件按登记表建立接任会话;项目会话不得自行新建会话。
19
19
  - 交接与水位协议同项目(32% 提醒 / 35% 交接线 / 85% 硬顶)。
20
20
 
21
21
  ## 4. 升级为项目
@@ -11,13 +11,14 @@ export function pmCheckpointTool(env) {
11
11
  return {
12
12
  name: nameOf('checkpoint'),
13
13
  description:
14
- '写交接、登记压缩;回合结束后自动压缩并续跑。',
14
+ '写交接、登记压缩;回合结束后自动压缩。resume=continue(默认)压缩后续跑;resume=wait 表示任务已收尾,压缩后不唤醒。',
15
15
  parameters: {
16
16
  type: 'object',
17
17
  additionalProperties: false,
18
18
  properties: {
19
19
  handover_text: { type: 'string', description: '要写入的交接正文;不给则要求磁盘上已有合格交接' },
20
20
  handover_path: { type: 'string', description: '覆盖交接路径(演练用)' },
21
+ resume: { type: 'string', enum: ['continue', 'wait'], description: '压缩后的去向:continue(默认)按交接 §2 继续;wait 表示本轮任务已收尾,压缩后不唤醒,等用户派新任务' },
21
22
  at_risk: { type: 'boolean', description: '当前处在关键步骤:为 true 时,水位在提醒线~交接线之间只提醒、不压缩(过交接线仍会压)' },
22
23
  force: { type: 'boolean', description: '忽略关键步骤判定,直接排压缩' },
23
24
  confirm: { type: 'boolean', description: '判定为「可压缩」后须经用户同意才传 true;还需用户在设置页点「允许压缩」(5 分钟一次性授权),否则不排压缩' },
@@ -115,10 +116,16 @@ export function pmCheckpointTool(env) {
115
116
  }
116
117
  consumedConsent(meId);
117
118
  const n = count.used + 1;
118
- env.schedule(meId, n);
119
+ const resume = args.resume === 'wait' ? 'wait' : 'continue';
120
+ env.schedule(meId, n, { resume });
119
121
  out.push('用户已授权(' + cs.atText + ' 点击,剩 ' + Math.round(cs.leftMs / 1000) + ' 秒)。');
120
- out.push("已登记压缩(第 " + n + "/" + env.cfg.maxCompactions + " 次)。本回合结束后执行,完成后会自动收到续跑消息。");
121
- out.push("压缩期间不要结束会话;如果 60 秒内没有续跑消息,用 ho_phase 看状态。");
122
+ out.push("已登记压缩(第 " + n + "/" + env.cfg.maxCompactions + " 次)。本回合结束后执行。");
123
+ if (resume === 'wait') {
124
+ out.push("去向:wait —— 压缩完成后不会唤醒本会话,等你派新任务。");
125
+ } else {
126
+ out.push("去向:continue —— 压缩完成后自动收到续跑消息;若 60 秒内没有续跑消息,用 ho_phase 看状态。");
127
+ }
128
+ out.push("压缩期间不要结束会话。");
122
129
  return { text: out.join(NL) };
123
130
  },
124
131
  };
@@ -17,6 +17,7 @@ export function pmDiagTool(env, state) {
17
17
  const out = [];
18
18
  const meId = env.selfId(exec);
19
19
  out.push("会话 " + (meId || "?"));
20
+ if (typeof env.version === "function") out.push("版本 " + env.version());
20
21
  out.push("调度器 " + (state.schedulerReady() ? "就绪" : "未就绪" + (state.schedulerError() ? ":" + state.schedulerError() : "(稍后重试或重启)")));
21
22
  if (typeof env.injectList === 'function') out.push('inject ' + env.injectList().join(' / '));
22
23
  if (typeof env.initErrors === 'function') {
@@ -43,7 +44,8 @@ export function pmDiagTool(env, state) {
43
44
  ? env.compactionState(exec)
44
45
  : (() => { const c = countCompactions(eventsOf(exec)); return { used: c.used, lock: c.lock, source: 'events' }; })();
45
46
  out.push("压缩 " + st.used + "/" + env.cfg.maxCompactions + " 次;锁 " + st.lock + ";来源 " + st.source);
46
- out.push("水位 " + (env.selfPct(meId) === null ? "读不到" : env.selfPct(meId).toFixed(1) + "%") + (typeof env.staleNote === "function" ? env.staleNote(exec) : ""));
47
+ const pct = env.selfPct(meId);
48
+ out.push("水位 " + (pct === null ? "读不到" : pct.toFixed(1) + "%") + (typeof env.staleNote === "function" ? env.staleNote(exec) : ""));
47
49
  const ws = env.resolveWs(exec);
48
50
  let wsOk = false;
49
51
  try { wsOk = fs.existsSync(path.join(ws, '_Control_Center')); } catch { wsOk = false; }
@@ -54,8 +56,8 @@ export function pmDiagTool(env, state) {
54
56
  out.push("角色 " + info.role + (info.project ? "(项目 " + info.project + ")" : info.task ? "(任务 " + info.task + ")" : "") + " · 依据 " + info.source + (ts ? " · 可见工具 " + ts.allow.length + "/" + (ts.allow.length + ts.deny.length) : ""));
55
57
  if (typeof env.roleState === "function") {
56
58
  const rs = env.roleState();
57
- out.push("角色收窄 " + (rs.gating || "未触发") + (rs.last ? " · 最近:" + rs.last.sid + " → " + rs.last.role + "(" + rs.last.at + ")" : ""));
58
- if (rs.bootstrap) out.push("一次性引导 " + rs.bootstrap);
59
+ const cnt = typeof rs.count === "number" ? " · 已装配 " + rs.count + " 个会话" : "";
60
+ out.push("角色装配 " + (rs.mode || "未知") + cnt + (rs.last ? " · 最近:" + rs.last.sid + " → " + rs.last.role + "(" + rs.last.at + ")" : ""));
59
61
  }
60
62
  } catch (e) {
61
63
  out.push("角色 判定失败:" + (e && e.message ? e.message : e));