@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
package/src/lib/api.mjs CHANGED
@@ -11,6 +11,7 @@ import { createGlobalWorkspace } from './scaffold.mjs';
11
11
  import { probeMove, startMove, moveStatus, cleanupOld, stageSwitch, repairLink, stageRetarget, applyRetarget } from './mover.mjs';
12
12
  import { routeWorkspace } from './workspace.mjs';
13
13
  import { grantConsent, listConsent, consumedConsent } from './consent.mjs';
14
+ import { newSessionIn } from './session-create.mjs';
14
15
 
15
16
  function msgOf(e) { return String(e && e.message ? e.message : e); }
16
17
 
@@ -53,16 +54,15 @@ export function statusSnapshot(env, info, why) {
53
54
  return json;
54
55
  }
55
56
 
56
- /** 允许在设置页填短号:session-xxxx… 前 8 位即可,这里补全成完整 id。 */
57
- function resolveConsentSid(env, input) {
57
+ /** 允许在设置页与聊天命令里填短号:session-xxxx… 前 8 位即可,这里补全成完整 id。 */
58
+ export function resolveShortSid(env, input) {
58
59
  const s = String(input || '').trim();
59
60
  if (!s) return '';
60
61
  if (s.indexOf('session-') === 0) return s;
61
62
  try {
62
63
  const dir = typeof env.cacheDir === 'function' ? env.cacheDir() : '';
63
64
  if (dir) {
64
- const fsMod = fs;
65
- for (const name of fsMod.readdirSync(dir)) {
65
+ for (const name of fs.readdirSync(dir)) {
66
66
  if (!name.endsWith('.json')) continue;
67
67
  const stem = name.replace('.json', '');
68
68
  if (stem.indexOf('session-' + s) === 0) return stem;
@@ -73,6 +73,25 @@ function resolveConsentSid(env, input) {
73
73
  return s;
74
74
  }
75
75
 
76
+ /** 在已注册的工作区里建一个「全局」会话:先按工作区 id,抓不到再用 cwd。 */
77
+ async function createGlobalSession(env, rootPath, title) {
78
+ let workspaceId = '';
79
+ try {
80
+ const reg = env.registry && env.registry();
81
+ const list = reg && typeof reg.list === 'function' ? (reg.list() || []) : [];
82
+ const norm = (s) => String(s || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
83
+ const hit = list.find((w) => w && norm(w.path) === norm(rootPath));
84
+ if (hit) workspaceId = String(hit.id || '');
85
+ } catch { workspaceId = ''; }
86
+ const made = await newSessionIn(env, {
87
+ workspaceId,
88
+ cwd: workspaceId ? '' : rootPath,
89
+ title: title || '全局',
90
+ role: 'global',
91
+ });
92
+ return { sessionId: made.sessionId || '', error: made.error || '', workspaceId };
93
+ }
94
+
76
95
  export function createApi(env, info) {
77
96
  const ok = (value) => ({ ok: true, value });
78
97
  const bad = (code, message, http) => ({ ok: false, error: { code, message, details: {} }, http: http || 400 });
@@ -92,6 +111,44 @@ export function createApi(env, info) {
92
111
  return r && r.ok ? ok(r) : { ok: false, error: { code: 'setup-failed', message: String((r && r.error) || '建立失败'), details: {} }, http: 400 };
93
112
  }
94
113
 
114
+ if (method === 'deploy') {
115
+ // 一键部署:建文件体系 → 注册「全局」工作区 → 建「全局」会话(三件事一次做完)。
116
+ const scaffold = createGlobalWorkspace(p.parent, { name: p.name });
117
+ if (!scaffold || scaffold.ok !== true) {
118
+ return { ok: false, error: { code: 'setup-failed', message: String((scaffold && scaffold.error) || '建立失败'), details: {} }, http: 400 };
119
+ }
120
+ let ws = null;
121
+ let workspaceError = '';
122
+ try {
123
+ const reg = env.registry && env.registry();
124
+ if (reg && typeof reg.create === 'function') ws = await reg.create(scaffold.root, '全局');
125
+ else workspaceError = 'workspaceRegistry 不可用';
126
+ } catch (e) { workspaceError = '注册工作区失败:' + msgOf(e); }
127
+ const made = await createGlobalSession(env, scaffold.root, '全局');
128
+ invalidateStatus(env);
129
+ env.log('一键部署:体系 ' + scaffold.root + ';工作区 ' + (ws ? String(ws.id) : '失败') + ';会话 ' + (made.sessionId || '失败'));
130
+ return ok({
131
+ root: scaffold.root,
132
+ created: scaffold.created || [],
133
+ written: scaffold.written || [],
134
+ workspace: ws ? { id: String(ws.id || ''), title: String(ws.title || '全局'), path: String(ws.path || scaffold.root) } : null,
135
+ workspaceError,
136
+ sessionId: made.sessionId || '',
137
+ sessionError: made.error || '',
138
+ });
139
+ }
140
+
141
+ if (method === 'global-session') {
142
+ // 已接入工作区但没有全局会话时,补建一个(不建体系、不动既有文件)。
143
+ const root = currentRoot(env);
144
+ if (!root) return bad('no-workspace', '未找到全局根(工作区未接入)');
145
+ const made = await createGlobalSession(env, root, p.title || '全局');
146
+ invalidateStatus(env);
147
+ if (!made.sessionId) return bad('session-failed', made.error || '建会话失败');
148
+ env.log('补建全局会话:' + made.sessionId);
149
+ return ok({ root, sessionId: made.sessionId });
150
+ }
151
+
95
152
  if (method === 'register') {
96
153
  const target = String(p.path || '').trim();
97
154
  if (!target) return bad('bad-request', '缺少 path(已存在的绝对目录)');
@@ -181,10 +238,11 @@ export function createApi(env, info) {
181
238
  }
182
239
 
183
240
  if (method === 'consent.grant') {
184
- const sid = resolveConsentSid(env, p.session_id);
241
+ const sid = resolveShortSid(env, p.session_id);
185
242
  if (!sid) return bad('bad-request', '缺少 session_id');
186
- const g = grantConsent(sid, '设置页点击');
187
- env.log('压缩授权:' + sid + '(5 分钟)');
243
+ const kind = p.kind === 'succeed' ? 'succeed' : 'compact';
244
+ const g = grantConsent(sid, kind === 'succeed' ? '设置页点击换任' : '设置页点击', kind);
245
+ env.log((kind === 'succeed' ? '换任' : '压缩') + '授权:' + sid + '(5 分钟)');
188
246
  invalidateStatus(env);
189
247
  return ok(g);
190
248
  }
@@ -193,7 +251,7 @@ export function createApi(env, info) {
193
251
 
194
252
  if (method === 'consent.revoke') {
195
253
  const sid = String(p.session_id || '').trim();
196
- if (sid) consumedConsent(sid);
254
+ if (sid) consumedConsent(sid, p.kind === 'succeed' ? 'succeed' : 'compact');
197
255
  invalidateStatus(env);
198
256
  return ok({ revoked: sid });
199
257
  }
@@ -230,5 +288,5 @@ export function createApi(env, info) {
230
288
  }
231
289
  }
232
290
 
233
- return { call, methods: ['status', 'setup', 'register', 'extra-prompt', 'role', 'move.probe', 'move.start', 'move.switch', 'move.status', 'move.retarget', 'move.retarget-stage', 'move.repair', 'move.cleanup', 'consent.grant', 'consent.list', 'consent.revoke'] };
291
+ return { call, methods: ['status', 'setup', 'deploy', 'global-session', 'register', 'extra-prompt', 'role', 'move.probe', 'move.start', 'move.switch', 'move.status', 'move.retarget', 'move.retarget-stage', 'move.repair', 'move.cleanup', 'consent.grant', 'consent.list', 'consent.revoke'] };
234
292
  }
@@ -7,17 +7,18 @@ export const DEFAULTS = {
7
7
  maxCompactions: 3,
8
8
  autoResume: true,
9
9
  sessionScanHours: 12,
10
- libraries: [],
11
10
  handoverPath: '', // 留空 = 按会话角色解析(全局 records/Global_Handover.md;项目 _Project_Meta/<项目>/Handover.md;杂项 _Misc/tasks/<任务>/Handover.md)。非空则强制该路径(演练用)
12
11
  rolesPath: '', // 留空 = ~/.dsh/storages/dsh-handover.roles.json
13
12
  // 续跑提示:{n} {max} {handover} {role} 会被替换成真值;{handover} 是本会话自己的交接绝对路径。
14
- resumePrompt: '【dsh-handover 自动续跑·第 {n}/{max} 次压缩】压缩已完成,历史已被摘要替换。第一步:ho_handover(action=read) 取回本会话的交接({handover});然后按 §2「下一步」继续。不要复述交接内容,不要重做已完成的事。完整流程:ho_charter(doc="runtime")。',
13
+ // 只在助手登记压缩时选 resume=continue(默认)才投递;resume=wait 表示已收尾,不唤醒。
14
+ resumePrompt: '【dsh-handover 自动续跑·第 {n}/{max} 次压缩】压缩已完成,历史已被摘要替换。第一步:ho_handover(action=read) 取回本会话的交接({handover});然后按 §2「下一步」继续(若 §2 已无待办,向用户一句话收尾并停止)。不要复述交接内容,不要重做已完成的事。完整流程:ho_charter(doc="runtime")。',
15
15
  resumePromptUnknown: '【dsh-handover 自动续跑·第 {n}/{max} 次压缩】压缩已完成,历史已被摘要替换。本会话是普通会话,不参与交接;如需继续,请由全局会话为其指定角色。',
16
16
  compactTimeoutMs: 120000,
17
17
  compactAttempts: 3,
18
18
  waitIdleMs: 180000, // 等会话空闲的上限(超过就放弃并回告)
19
19
  driftSlack: 3, // Δ交接容忍轮数(超过即需刷交接)
20
- wakeAuthority: 'on', // 唤醒权硬拦:on / off
20
+ wakeAuthority: 'on', // 会话守卫硬拦:on / off(off=连 peer_new 也不拦)
21
+ directPeer: 'on', // 会话之间直接 peer_send:on 允许(不经全局转述);off 退回「只能向全局报告」的旧口径
21
22
  roleGating: 'on', // 按角色裁剪工具与提示段:on / off(off=所有会话都看得见全部工具)
22
23
  reuseCheck: true, // 建新会话前查在任会话水位(低于 warnRatio 拒绝)
23
24
  prepackMinAgeMin: 90, // 打包前要求会话静止分钟数
@@ -1,14 +1,17 @@
1
1
  /**
2
- * 压缩授权:压缩会丢历史,所以「同意」必须由用户自己产生,AI 不能自己给自己批。
2
+ * 一次性授权:压缩会丢历史,换任会换掉在任会话,所以「同意」必须由用户自己产生,AI 不能自己给自己批。
3
3
  *
4
- * 做法:唯一的授权入口是设置页上的一次点击(用户在场、只能是用户按),
5
- * 授权一次有效 5 分钟、只对指定会话生效;`ho_checkpoint`、全局代排与调度器三处都校验它。
4
+ * 做法:授权入口只有用户亲手产生的那一次(设置页点击或聊天页斜杠命令),
5
+ * 授权一次有效 5 分钟、只对指定会话与指定种类生效;压缩与换任各有各的授权,互不顶替。
6
+ * 种类取 compact(压缩)或 succeed(换任),缺省为 compact;旧文件里没有 kind 的条目按 compact 处理。
6
7
  */
7
8
  import fs from 'node:fs';
8
9
  import os from 'node:os';
9
10
  import path from 'node:path';
10
11
 
11
12
  export const CONSENT_TTL_MS = 5 * 60 * 1000;
13
+ export const CONSENT_KINDS = ['compact', 'succeed'];
14
+ const DEFAULT_KIND = 'compact';
12
15
 
13
16
  function consentPath() {
14
17
  const home = process.env.USERPROFILE || process.env.HOME || os.homedir();
@@ -37,48 +40,84 @@ function stampNow() {
37
40
  return d.getUTCFullYear() + '-' + p(d.getUTCMonth() + 1) + '-' + p(d.getUTCDate()) + ' ' + p(d.getUTCHours()) + ':' + p(d.getUTCMinutes()) + ' +0800';
38
41
  }
39
42
 
40
- /** 用户点了一次「允许该会话压缩一次」。 */
41
- export function grantConsent(sessionId, note) {
43
+ function kindOf(kind) {
44
+ const k = String(kind === undefined || kind === null ? '' : kind).trim();
45
+ if (!k) return DEFAULT_KIND;
46
+ if (CONSENT_KINDS.indexOf(k) === -1) throw new Error('未知授权种类:' + k + '(只能是 compact 或 succeed)');
47
+ return k;
48
+ }
49
+
50
+ /**
51
+ * 存储键:压缩沿用会话 id 本身(兼容旧文件),换任另加后缀,两种授权可以同时存在。
52
+ */
53
+ function keyFor(sid, kind) {
54
+ return kind === DEFAULT_KIND ? sid : sid + '|' + kind;
55
+ }
56
+
57
+ function sidOfKey(key) {
58
+ return key.indexOf('|') === -1 ? key : key.slice(0, key.indexOf('|'));
59
+ }
60
+
61
+ /** 条目自身的种类优先;旧条目没有 kind,按 compact 处理。 */
62
+ function kindOfEntry(key, entry) {
63
+ const k = entry && entry.kind ? String(entry.kind) : '';
64
+ if (CONSENT_KINDS.indexOf(k) !== -1) return k;
65
+ return key.indexOf('|succeed') === -1 ? DEFAULT_KIND : 'succeed';
66
+ }
67
+
68
+ /** 用户授权一次:kind 缺省为压缩。 */
69
+ export function grantConsent(sessionId, note, kind) {
42
70
  const sid = String(sessionId || '');
43
71
  if (!sid) throw new Error('缺少会话 id');
72
+ const k = kindOf(kind);
44
73
  const all = readAll();
45
74
  const now = Date.now();
46
- all[sid] = { at: now, until: now + CONSENT_TTL_MS, atText: stampNow(), note: String(note || '') };
75
+ const key = keyFor(sid, k);
76
+ all[key] = { at: now, until: now + CONSENT_TTL_MS, atText: stampNow(), note: String(note || ''), kind: k };
47
77
  writeAll(all);
48
- return { sessionId: sid, until: new Date(all[sid].until).toISOString(), minutes: CONSENT_TTL_MS / 60000, atText: all[sid].atText };
78
+ return { sessionId: sid, kind: k, until: new Date(all[key].until).toISOString(), minutes: CONSENT_TTL_MS / 60000, atText: all[key].atText };
49
79
  }
50
80
 
51
81
  /** 授权是否有效(每次读取时一并清除已过期者)。 */
52
- export function checkConsent(sessionId) {
82
+ export function checkConsent(sessionId, kind) {
53
83
  const sid = String(sessionId || '');
84
+ const k = kindOf(kind);
54
85
  if (!sid) return { ok: false, reason: '没有会话 id' };
55
86
  const all = readAll();
56
- const hit = all[sid];
87
+ const key = keyFor(sid, k);
88
+ const hit = all[key];
57
89
  const now = Date.now();
58
90
  let dirty = false;
59
- for (const k of Object.keys(all)) {
60
- if (!all[k] || !all[k].until || all[k].until <= now) { delete all[k]; dirty = true; }
91
+ for (const keyName of Object.keys(all)) {
92
+ if (!all[keyName] || !all[keyName].until || all[keyName].until <= now) { delete all[keyName]; dirty = true; }
61
93
  }
62
94
  if (dirty) { try { writeAll(all); } catch { /* ignore */ } }
63
95
  if (!hit || !hit.until || hit.until <= now) {
64
- return { ok: false, reason: '用户还没有在设置页点「允许压缩」' };
96
+ return {
97
+ ok: false,
98
+ kind: k,
99
+ reason: k === 'succeed' ? '用户还没有授权换任(聊天页命令 /allow-succeed)' : '用户还没有在设置页点「允许压缩」',
100
+ };
65
101
  }
66
- return { ok: true, until: hit.until, leftMs: hit.until - now, atText: hit.atText || '' };
102
+ return { ok: true, kind: k, until: hit.until, leftMs: hit.until - now, atText: hit.atText || '' };
67
103
  }
68
104
 
69
- export function consumedConsent(sessionId) {
105
+ export function consumedConsent(sessionId, kind) {
70
106
  const all = readAll();
71
107
  const sid = String(sessionId || '');
72
- if (all[sid]) { delete all[sid]; try { writeAll(all); } catch { /* ignore */ } }
108
+ const key = keyFor(sid, kindOf(kind));
109
+ if (all[key]) { delete all[key]; try { writeAll(all); } catch { /* ignore */ } }
73
110
  }
74
111
 
75
112
  export function listConsent() {
76
113
  const all = readAll();
77
114
  const now = Date.now();
78
- return Object.keys(all).map((sid) => ({
79
- sessionId: sid,
80
- leftMs: Math.max(0, (all[sid].until || 0) - now),
81
- atText: all[sid].atText || '',
82
- note: all[sid].note || '',
115
+ const rows = Object.keys(all).map((key) => ({
116
+ sessionId: sidOfKey(key),
117
+ kind: kindOfEntry(key, all[key]),
118
+ leftMs: Math.max(0, (all[key].until || 0) - now),
119
+ atText: all[key].atText || '',
120
+ note: all[key].note || '',
83
121
  })).filter((r) => r.leftMs > 0);
122
+ return rows.sort((a, b) => a.sessionId.localeCompare(b.sessionId, 'zh') || a.kind.localeCompare(b.kind));
84
123
  }
package/src/lib/facts.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * 口径单一来源:水位阈值、压缩次数与提示段 token 预算。
3
- * 运行期文本一律由本模块渲染;改数值只改 config.mjs 的默认值或本文件的预算表。
2
+ * 口径单一来源:水位阈值、压缩次数与提示段 token 参考值。
3
+ * 运行期文本一律由本模块渲染;改数值只改 config.mjs 的默认值或本文件的参考表。
4
4
  * 文档与测试引用同一组常量,避免同一事实多处各写一遍。
5
5
  */
6
6
  import { DEFAULTS } from './config.mjs';
@@ -57,9 +57,10 @@ function isWide(cp) {
57
57
  }
58
58
 
59
59
  /**
60
- * 上下文成本上限(加权 token)。
60
+ * 上下文成本参考值(加权 token)。
61
61
  * 全局段按短工作区路径计;worst 档按真实长路径加迁移提示行计。
62
- * 单条工具描述单独设限,避免每次请求的固定开销无界增长。
62
+ * 仅供参考:用于设置页展示与冒烟实测,不作为通过条件,也不参与任何拒绝或截断。
63
+ * 提示词效果优先于字数,实测值超出参考值时如实展示即可。
63
64
  */
64
65
  export const TOKEN_BUDGET = {
65
66
  globalTokens: 320,
package/src/lib/guide.mjs CHANGED
@@ -9,10 +9,11 @@ import { ALL_TOOL_NAMES } from './names.mjs';
9
9
 
10
10
  /** 首次安装、尚未接入工作区时的下一步(设置页与工具输出共用同一份)。 */
11
11
  export const SETUP_STEPS = [
12
- '准备一个长期存在的目录作为工作区的上级目录,不要选临时目录或下载目录。',
13
- '在本页下方的输入框中粘贴该目录的绝对路径,点击「建立全局文件体系」:插件会在其中创建 _Workspace 目录,并铺好 _Control_Center、Projects 等整套结构。',
14
- '点击「注册为工作区」完成注册;也可以把页面上显示的绝对路径复制下来,粘贴到 DSH 的新建工作区窗口。',
15
- '新建一个标题含「全局」的会话。下一轮起,本插件会把该目录接入闸门、交接体检与会话水位三项。',
12
+ '准备一个长期存在的目录作为全局根的上级目录,不要选临时目录或下载目录。',
13
+ '在本页下方的输入框中粘贴该目录的绝对路径,点「一键部署」:插件会在其中建立 _Workspace 与整套结构,注册为「全局」工作区,并新建一个「全局」会话。',
14
+ '在左侧「全局」工作区中打开新会话即可开始。若只想先铺文件,可点「仅建立文件体系」,之后再注册工作区。',
15
+ '工作区已接入但还没有全局会话时,本页会单独出现「全局会话」卡片,可随时补建。',
16
+ '新建项目或杂项会话时,应当指明目标工作区;找不到工作区时,插件会把已注册的工作区列出来,不会静默落到别处。',
16
17
  ];
17
18
 
18
19
  export function buildGuide(cfg) {
@@ -26,13 +27,14 @@ export function buildGuide(cfg) {
26
27
  '本页除首次安装、全局前置提示词与会话角色外均为只读;需要最新读数时,点击右上角的「刷新」。',
27
28
  ],
28
29
  situations: [
30
+ { when: '首次安装', say: '部署全局文件体系', what: '本页「一键部署」可一次完成三件事:建文件体系、注册「全局」工作区、建「全局」会话' },
29
31
  { when: '作业开始与结束', say: '先出一份起始简报', what: '汇总上下文水位、通用约束条闸门、会话清点与阶段建议(ho_brief)' },
30
32
  { when: '查阅规则', say: '把 Charter 第 7.2.3 节取出来', what: '按节返回规则正文,避免整篇读取(ho_charter;完整流程用 doc="runtime")' },
31
- { when: '交接与压缩续跑', say: '先把交接文档写好,再压缩并继续', what: '写自己角色的交接、体检、登记压缩,回合结束后自动压缩并自动续跑(ho_handover、ho_checkpoint)' },
33
+ { when: '交接与压缩续跑', say: '先把交接文档写好,再压缩并继续', what: '写自己角色的交接、体检、登记压缩,并由助手选择压缩后去向:继续工作时用 resume=continue,任务已收尾时用 resume=wait(压缩后不唤醒)(ho_handover、ho_checkpoint)' },
32
34
  { when: '接近上下文上限', say: '查一下本会话的上下文水位', what: compact + '(ho_phase)' },
33
- { when: '换任', say: '建立下一代全局会话', what: '建会话、唤醒并投递启动提示词;在任会话水位低于 ' + w.warn + ' 时拒绝新建(ho_sessions action=new)' },
35
+ { when: '换任', say: '让在任会话自己完成换任', what: '用户先发送 /allow-succeed 授权,在任会话随后校验交接、建接任会话并登记在任(ho_succeed);全局仍可用 ho_sessions action=new 手动新建会话' },
34
36
  { when: '会话角色', say: '把 Demo1 设成项目角色,并让它整理自己的文件', what: '写角色表、当场收窄该会话的工具与提示、投递一次性整理指令(ho_sessions action=role / 本页会话角色)' },
35
- { when: '通知项目会话', say: '通知 Demo 更新交接文档', what: '投递一条消息;会消耗目标会话一整轮 token,应尽量少用(peer_send)' },
37
+ { when: '通知项目会话', say: '通知 Demo 更新交接文档', what: '投递一条消息;会话之间可直接对话,不必经全局转述;会消耗目标会话一整轮 token,应尽量少用(peer_send)' },
36
38
  { when: '全局前置提示词', say: '把这段设为全局前置提示词', what: '保存到本机;仅在你开启且内容非空时注入全部会话,关闭或留空时不注册提示段' },
37
39
  { when: '项目骨架', say: '注册 Demo 的骨架', what: '读取、注册、校验项目或杂项的骨架档案;由该项目会话自己写文件并注册(ho_profile)' },
38
40
  { when: '查看会话', say: '列出当前活跃的会话', what: '水位、运行状态、活跃或沉默、在任或前任(ho_sessions)' },
@@ -42,6 +44,7 @@ export function buildGuide(cfg) {
42
44
  { when: '建立工作区', say: '把这个目录注册为工作区', what: '注册已存在的目录;删除只删注册,目录与会话日志均保留(ho_workspace)' },
43
45
  { when: '首次安装', say: '把某个目录建成全局根目录', what: '在该目录下建立整套全局文件体系,并注册为工作区(ho_init)' },
44
46
  { when: '自检', say: '检查插件当前状态', what: '服务可解析性、压缩调度器、压缩计数、本会话角色与工具集(ho_diag)' },
47
+ { when: '查在任', say: '现在 Mingent 是谁在任', what: '按全局、项目、杂项查现任会话的名字与会话 id(ho_office);不起会话也可用命令行 node scripts/office.mjs 同名查询' },
45
48
  ],
46
49
  groups: [
47
50
  {
@@ -55,6 +58,7 @@ export function buildGuide(cfg) {
55
58
  ['ho_drift', '交接漂移体检:Δ 为当前轮与交接轮之差,大于 3 需刷新'],
56
59
  ['ho_diag', '插件自检:服务、调度器、压缩计数、角色判定与工具集'],
57
60
  ['ho_tokens', '会话 token 体检:轮数、步数、每步 token、最贵的单条返回'],
61
+ ['ho_office', '在任登记表:查全局、项目、杂项现在由哪个会话在任(名字与会话 id)'],
58
62
  ],
59
63
  },
60
64
  {
@@ -68,6 +72,7 @@ export function buildGuide(cfg) {
68
72
  ['ho_workspace', '工作区列表、创建、删除;删除只删注册'],
69
73
  ['ho_init', '首次安装:在指定目录下建立整套全局文件体系,可顺带注册工作区'],
70
74
  ['ho_profile', '项目或杂项骨架:读取、注册、校验、取预设'],
75
+ ['ho_succeed', '换任自服务:在任会话经用户授权后自行校验交接、建接任会话并登记在任'],
71
76
  ],
72
77
  },
73
78
  {
@@ -85,6 +90,8 @@ export function buildGuide(cfg) {
85
90
  rules: [
86
91
  '交接线:上下文水位达到 ' + w.warn + ' 时提醒,达到 ' + w.handover + ' 时写交接并压缩,' + w.hard + ' 为硬上限;同一会话最多压缩 ' + w.max + ' 次,第四段改用新建会话。',
87
92
  '压缩不可逆:历史会被摘要替换,因此必须先写出合格的交接文档;体检未通过时,插件拒绝安排压缩。',
93
+ '会话之间可直接 peer_send 对话,不必经全局转述;默认沉默仍适用:只在到交接线且已写交接、需用户决定、发现全局口径有误时发一条,任务完成不向全局报告。',
94
+ '压缩后的去向由助手在登记时选择:任务未完成用 continue 续跑,任务已收尾用 wait 静默收尾,等用户派新任务。',
88
95
  '交接文件按角色分离:全局写 _Control_Center/records/Global_Handover.md,项目写 _Project_Meta/<项目>/Handover.md,杂项写 _Misc/tasks/<任务>/Handover.md,互不串用。',
89
96
  '会话本身不迁移:跨机器、跨平台的连续性依托交接文档。',
90
97
  '全局前置提示词属于用户内容,不是插件约束;关闭或留空时不占用注入点位,可与其它提示词插件并存。',
@@ -17,6 +17,26 @@ export function readHandover(file) {
17
17
  catch { return { path: file, text: null }; }
18
18
  }
19
19
 
20
+ /**
21
+ * 归档时间戳:`YYYYMMDD_HHMMSS`(到秒)。
22
+ * 规范要求时间戳取归档那一刻;只到分钟会在同一分钟内归档两份时撞名,故到秒。
23
+ */
24
+ export function archiveStamp(d) {
25
+ const x = d || new Date();
26
+ const p = (n) => (n < 10 ? "0" + n : "" + n);
27
+ return x.getFullYear() + p(x.getMonth() + 1) + p(x.getDate()) + "_" + p(x.getHours()) + p(x.getMinutes()) + p(x.getSeconds());
28
+ }
29
+
30
+ /**
31
+ * 归档文件名:`<原文件名>_<YYYYMMDD_HHMMSS>.md`。
32
+ * 「原文件名」取源文件主名(不含扩展名):全局 `Global_Handover_…`、项目与杂项 `Handover_…`;
33
+ * 不加链标识——归档目录本身已按链隔离(规范 §4.1)。
34
+ */
35
+ export function archiveFileName(file, stampText) {
36
+ const base = String(file || "").split(/[\\/]/).pop().replace(/\.md$/i, "");
37
+ return base + "_" + String(stampText) + ".md";
38
+ }
39
+
20
40
  /**
21
41
  * 写交接:先把旧文件复制到 <作用域>/Snapshots/pm-handover-<stamp>/,再原子替换,最后按角色体检。
22
42
  * @param {object} [opts] { info?: 角色信息(决定体检判据与备份目录), kind?: 'global'|'project'|'misc' }
@@ -10,7 +10,16 @@ import { handoverRuleText, watermark } from './facts.mjs';
10
10
  const NL = String.fromCharCode(10);
11
11
 
12
12
  /** 完整流程的指针句(三档角色共用)。 */
13
- const RUNTIME_POINTER = '规则细节按节取:ho_charter(doc="runtime", section=…)。';
13
+ const RUNTIME_POINTER = '细节按节取:ho_charter(doc="runtime", section=…)。';
14
+
15
+ /**
16
+ * 复用准则:全局、项目、杂项三处必须同一句。
17
+ * 派活与新建会话前先查在任,低水位在任直接复用;确需另建才新建。
18
+ */
19
+ function reuseRule(cfg) {
20
+ return '指派任务或新建会话前,先用 ho_office(空参数)查一次在任;同一类别已有在任且其水位低于 '
21
+ + watermark(cfg).warn + ' 时,直接用 peer_send 复用,不新建;确需另建(原会话不可用、需并行、用户明确要求)时才新建。';
22
+ }
14
23
 
15
24
  /** 全局会话的常驻段:五节骨架(身份、权限、节奏、交接、按需取用)。 */
16
25
  export function globalManual(opts) {
@@ -19,18 +28,19 @@ export function globalManual(opts) {
19
28
  'dsh-handover 运行期(全局)',
20
29
  '',
21
30
  '一、身份',
22
- '本会话为「全局」管理会话:协调会话、维护规则与交接。',
31
+ '本会话为全局管理会话,协调会话、维护规则与交接。',
23
32
  '',
24
33
  '二、权限',
25
- '可写 _Control_Center、_Project_Meta 与项目结构本身;Projects 与各项目库内文件只读,不得代改写。',
26
- '唤醒权仅属全局:其余角色只能以 peer_send 向现任全局报告;换任须经用户同意。',
34
+ '可写 _Control_Center、_Project_Meta 与项目结构;Projects 及各项目库内文件只读,不得代写;项目内作业不代办。',
35
+ '新建会话与设定角色仅属全局;会话间可直接 peer_send;换任须用户授权(/allow-succeed 或设置页)后由在任会话调用 ho_succeed,不再请求全局。',
36
+ reuseRule(o.cfg),
27
37
  '',
28
38
  '三、节奏',
29
39
  handoverRuleText(o.cfg),
30
- '压缩须用户点「压缩授权」,随后写交接并 ho_checkpoint(confirm=true),轮末自动压缩并续跑;confirm 仅表意图,插件只认那次点击。关键步骤传 at_risk=true 暂缓。',
40
+ '压缩须用户点「压缩授权」,再写交接并 ho_checkpoint(confirm=true);任务未完成传 resume=continue,已收尾传 resume=wait,不再唤醒。confirm 仅表意图,授权以点击为准。关键步骤传 at_risk=true 暂缓。',
31
41
  '',
32
42
  '四、交接',
33
- '全局交接:_Control_Center/records/Global_Handover.md(ho_handover 默认)。',
43
+ '交接:_Control_Center/records/Global_Handover.md。',
34
44
  '',
35
45
  '五、按需取用',
36
46
  RUNTIME_POINTER,
@@ -62,8 +72,9 @@ export function scopedManual(info, opts) {
62
72
  hp.path
63
73
  ? '交接:' + hp.path + '(ho_handover 默认)。'
64
74
  : '交接路径未解析(缺角色或项目名),请先在设置页设定角色。',
65
- handoverRuleText(o.cfg) + '压缩须用户点「压缩授权」,再自行 ho_checkpoint(confirm=true)。',
66
- '与全局通信仅用 peer_send,且只在水位 ' + watermark(o.cfg).warn + ' 已写交接、需用户决定、口径有误、任务达成时报告。',
75
+ handoverRuleText(o.cfg) + '压缩须用户点「压缩授权」,再由本会话执行 ho_checkpoint(confirm=true);收尾时传 resume=wait,不再唤醒。',
76
+ '会话间可直接 peer_send;默认沉默:仅在水位 ' + watermark(o.cfg).warn + ' 且已写交接、需用户决定、全局口径有误时向全局报告;任务完成不报全局。',
77
+ reuseRule(o.cfg),
67
78
  RUNTIME_POINTER,
68
79
  ];
69
80
  const mv = moveLine(o);
@@ -90,12 +101,12 @@ export function organizeMessageFor(info, opts) {
90
101
  const o = opts || {};
91
102
  const role = info && info.role ? info.role : 'plain';
92
103
  if (role === 'plain') return '';
93
- const head = '【dsh-handover 角色设定】本会话已被设定为「' + scopeLabel(info) + '」。请按下列结构核对自身文件:缺失的目录予以建立,位置不当的文件予以归位;既有文件就地保留,不得删除或修改内容。';
104
+ const head = '【dsh-handover 角色设定】本会话已设定为「' + scopeLabel(info) + '」。请按下列结构核对自身文件:缺失的目录应建立,位置不当的文件应归位;既有文件就地保留,不得删除或修改内容。';
94
105
  if (role === 'project') {
95
106
  const p = info.project;
96
107
  return [
97
108
  head,
98
- '先通读本库,判断本项目的大体骨架:代码与文档主体、必需文件、临时与归档位置。构建型、动画型等项目不必套用插件类项目的文档集。',
109
+ '先通读本库,确定本项目的骨架:代码与文档主体、必需文件、临时与归档位置。构建型、动画型等项目不必套用插件类项目的文档集。',
99
110
  '把结论写入 _Project_Meta/' + p + '/project.profile.json;字段格式见 ho_profile(action=presets)。',
100
111
  '写入后调用 ho_profile(action=register) 校验并登记;返回缺失项时按清单补齐。',
101
112
  '约束:仅可改动本库与 _Project_Meta/' + p + '/;不得改动 _Control_Center 及其他项目;移动较大文件前先执行 ho_snapshot。可用工具:ho_snapshot、ho_handover、ho_phase、ho_checkpoint、ho_profile、peer_send。',
@@ -106,10 +117,10 @@ export function organizeMessageFor(info, opts) {
106
117
  const t = info.task;
107
118
  return [
108
119
  head,
109
- '先通读本任务目录,判断本任务的骨架:工作文件、规则文件、交接、快照与归档位置。',
120
+ '先通读本任务目录,确定本任务的骨架:工作文件、规则文件、交接、快照与归档位置。',
110
121
  '把结论写入 _Misc/tasks/' + t + '/task.profile.json;字段格式见 ho_profile(action=presets)。',
111
122
  '写入后调用 ho_profile(action=register) 校验并登记。',
112
- '约束:仅可改动 _Misc/tasks/' + t + '/、_Temp/_Misc/' + t + '/、_Archives/_Misc/' + t + '/;_Control_Center、Projects 与其他任务不得触碰。工具:ho_snapshot、ho_handover、ho_phase、ho_checkpoint、ho_profile、peer_send。',
123
+ '约束:仅可改动 _Misc/tasks/' + t + '/、_Temp/_Misc/' + t + '/、_Archives/_Misc/' + t + '/;_Control_Center、Projects 与其他任务不得写入。工具:ho_snapshot、ho_handover、ho_phase、ho_checkpoint、ho_profile、peer_send。',
113
124
  '完成后以一句话说明改动即可。',
114
125
  ].join(NL);
115
126
  }
package/src/lib/names.mjs CHANGED
@@ -7,7 +7,7 @@ const PREFIX = 'ho';
7
7
  export const nameOf = (key) => PREFIX + '_' + key;
8
8
 
9
9
  /**
10
- * ho_* 16 个。合并过的家族(刻意保持旧名,参数里用 action 分流):
10
+ * ho_* 18 个。合并过的家族(刻意保持旧名,参数里用 action 分流):
11
11
  * ho_pack —— 打包 / 校验 / 预演 / 恢复(原 ho_pack_verify、ho_pack_restore、ho_pack_precheck)
12
12
  * ho_sessions —— 清点 / 设定角色 / 建会话 / 出提示词 / 归档 / 解归档(原 ho_session_new、ho_prompt、ho_session_archive、ho_session_unarchive)
13
13
  * ho_profile —— 骨架读取 / 注册 / 校验 / 预设
@@ -16,7 +16,7 @@ const KEYS = [
16
16
  'brief', 'gate', 'sessions', 'phase', 'charter',
17
17
  'handover', 'checkpoint', 'snapshot', 'diag',
18
18
  'drift', 'changelog', 'tokens',
19
- 'pack', 'workspace', 'init', 'profile',
19
+ 'pack', 'workspace', 'init', 'profile', 'office', 'succeed',
20
20
  ];
21
21
 
22
22
  export const NAMES = KEYS.reduce((acc, k) => { acc[k] = nameOf(k); return acc; }, {});