@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,272 @@
1
+ /**
2
+ * 在任登记表(office):回答「这条类别现在谁在任」。
3
+ *
4
+ * 记录保存在 <工作区根>/_Control_Center/records/Office.json:每一类会话(全局/项目名/杂项任务名)
5
+ * 记一个在任会话与历史编号。Registry.md 的「在役会话」列由本文件定点改写——只换那一格,
6
+ * 不动人写的状态文字与整表结构。编号由这里分配(历史最大号 + 1),不采信会话自述。
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { NL, readUtf8 } from './text.mjs';
11
+
12
+ export const OFFICE_VERSION = 1;
13
+
14
+ /** 带时区偏移的时刻文本(与交接、登记表同一写法)。 */
15
+ export function stampNow() {
16
+ const d = new Date(Date.now() + 8 * 3600 * 1000);
17
+ const p = (n) => String(n).padStart(2, '0');
18
+ return d.getUTCFullYear() + '-' + p(d.getUTCMonth() + 1) + '-' + p(d.getUTCDate())
19
+ + ' ' + p(d.getUTCHours()) + ':' + p(d.getUTCMinutes()) + ' +0800';
20
+ }
21
+
22
+ export const KIND_LABEL = { global: '全局', project: '项目', misc: '杂项' };
23
+
24
+ export function officePath(ws) {
25
+ return path.join(String(ws || ''), '_Control_Center', 'records', 'Office.json');
26
+ }
27
+
28
+ export function loadOffice(ws) {
29
+ let v = null;
30
+ try { v = JSON.parse(readUtf8(officePath(ws)).replace(/^\uFEFF/, '')); } catch { v = null; }
31
+ if (!v || typeof v !== 'object' || Array.isArray(v)) v = {};
32
+ if (!v.lineages || typeof v.lineages !== 'object' || Array.isArray(v.lineages)) v.lineages = {};
33
+ v.version = OFFICE_VERSION;
34
+ return v;
35
+ }
36
+
37
+ /** 原子写:临时文件 + rename,半截文件不会被下一次读当成事实。 */
38
+ export function saveOffice(ws, store) {
39
+ const p = officePath(ws);
40
+ fs.mkdirSync(path.dirname(p), { recursive: true });
41
+ store.version = OFFICE_VERSION;
42
+ store.updatedAt = stampNow();
43
+ const tmp = p + '.tmp';
44
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2) + NL, 'utf8');
45
+ fs.renameSync(tmp, p);
46
+ return p;
47
+ }
48
+
49
+ /** 类别名:全局固定「全局」,项目用项目名,杂项用任务名。 */
50
+ export function lineageKeyOf(kind, project, task) {
51
+ if (kind === 'global') return '全局';
52
+ if (kind === 'project') return String(project || '').trim();
53
+ if (kind === 'misc') return String(task || '').trim();
54
+ return '';
55
+ }
56
+
57
+ const GEN_RE = /^(.*\D)(\d+)$/;
58
+
59
+ /** 名字去掉尾部代际:全局11 → 全局;没有数字返回空串。 */
60
+ export function baseOf(name) {
61
+ const m = GEN_RE.exec(String(name || '').trim());
62
+ return m ? m[1] : '';
63
+ }
64
+
65
+ /** 名字里的代际号:全局11 → 11;没有数字返回 null。 */
66
+ export function generationOf(name) {
67
+ const m = GEN_RE.exec(String(name || '').trim());
68
+ return m ? Number(m[2]) : null;
69
+ }
70
+
71
+ /** 下一编号:该类别历史上出现过的最大号 + 1;没有历史就从 1 起算。 */
72
+ export function nextName(store, key) {
73
+ const lin = store && store.lineages ? store.lineages[key] : null;
74
+ const names = [];
75
+ if (lin && lin.current && lin.current.name) names.push(lin.current.name);
76
+ for (const h of (lin && Array.isArray(lin.history) ? lin.history : [])) {
77
+ if (h && h.name) names.push(h.name);
78
+ }
79
+ let max = 0;
80
+ for (const n of names) {
81
+ const g = generationOf(n);
82
+ if (g !== null && g > max) max = g;
83
+ }
84
+ return String(key) + String(max + 1);
85
+ }
86
+
87
+ /**
88
+ * 名字 → 类别。用户给的多半是不确定的名字(全局3、全局、Mingent、赢了两次的旧叫法),
89
+ * 按「类别名精确 → 去尾部编号 → 现任名 → 唯一子串」四步收敛;收敛不了就报候选,不瞎猜。
90
+ */
91
+ export function resolveOffice(store, query) {
92
+ const q = String(query || '').trim();
93
+ if (!q) return { ok: false, reason: '空查询' };
94
+ const keys = Object.keys((store && store.lineages) || {});
95
+ const lower = q.toLowerCase();
96
+ let key = '';
97
+ let via = '';
98
+ const exact = keys.filter((k) => k.toLowerCase() === lower);
99
+ if (exact.length === 1) { key = exact[0]; via = 'exact'; }
100
+ if (!key) {
101
+ const base = baseOf(q);
102
+ if (base) {
103
+ const hit = keys.filter((k) => k.toLowerCase() === base.toLowerCase());
104
+ if (hit.length === 1) { key = hit[0]; via = 'generation'; }
105
+ }
106
+ }
107
+ if (!key) {
108
+ const hit = keys.filter((k) => {
109
+ const cur = store.lineages[k] && store.lineages[k].current;
110
+ return cur && cur.name && String(cur.name).toLowerCase() === lower;
111
+ });
112
+ if (hit.length === 1) { key = hit[0]; via = 'current'; }
113
+ }
114
+ if (!key) {
115
+ const hit = keys.filter((k) => {
116
+ const a = k.toLowerCase();
117
+ return a.indexOf(lower) !== -1 || lower.indexOf(a) !== -1;
118
+ });
119
+ if (hit.length === 1) { key = hit[0]; via = 'fuzzy'; }
120
+ else if (hit.length > 1) return { ok: false, reason: '与多个类别相近:' + hit.join('、') + '(请写全名)', candidates: hit };
121
+ }
122
+ if (!key) return { ok: false, reason: '没有名为「' + q + '」的全局、项目或杂项任务' };
123
+ const lin = store.lineages[key];
124
+ const cur = lin && lin.current;
125
+ if (!cur || !cur.name) return { ok: true, key, lineage: lin, via, vacant: true };
126
+ return { ok: true, key, lineage: lin, via, vacant: false };
127
+ }
128
+
129
+ export function rowsOf(store) {
130
+ const lines = (store && store.lineages) || {};
131
+ return Object.keys(lines).map((key) => {
132
+ const lin = lines[key] || {};
133
+ return {
134
+ key,
135
+ kind: String(lin.kind || ''),
136
+ project: String(lin.project || ''),
137
+ task: String(lin.task || ''),
138
+ workspace: String(lin.workspace || ''),
139
+ current: lin.current || null,
140
+ };
141
+ }).sort((a, b) => {
142
+ const order = { global: 0, project: 1, misc: 2 };
143
+ const d = (order[a.kind] === undefined ? 9 : order[a.kind]) - (order[b.kind] === undefined ? 9 : order[b.kind]);
144
+ return d !== 0 ? d : a.key.localeCompare(b.key, 'zh');
145
+ });
146
+ }
147
+
148
+ /** 一行人类可读:名字、会话 id、类别、(提到旧编号时的来处)。 */
149
+ export function formatRow(row, askedAs) {
150
+ const cur = row && row.current;
151
+ if (!cur || !cur.name) return (row && row.key ? row.key : '?') + ' — 当前空缺';
152
+ const label = KIND_LABEL[row.kind] || row.kind || '-';
153
+ // 只有「拿旧编号问现任」时才加来处标注;直接问类别名或现任名就不必重复。
154
+ const tail = askedAs && askedAs !== cur.name && askedAs !== row.key ? '(' + askedAs + ' → ' + row.key + ')' : '';
155
+ return cur.name + ' ' + cur.sessionId + ' ' + label + (cur.since ? ' 自 ' + cur.since : '') + tail;
156
+ }
157
+
158
+ /** 登记一位在任者:原在任者进历史,写盘后同步 Registry.md。 */
159
+ export function registerHolder(ws, opts) {
160
+ const o = opts || {};
161
+ const key = lineageKeyOf(o.kind, o.project, o.task);
162
+ if (!key) throw new Error('类别名缺失:全局用 kind=global,项目给 project,杂项给 task');
163
+ const store = loadOffice(ws);
164
+ const lin = store.lineages[key] || (store.lineages[key] = {
165
+ kind: o.kind, project: String(o.project || ''), task: String(o.task || ''),
166
+ workspace: String(o.workspace || ''), current: null, history: [],
167
+ });
168
+ if (!Array.isArray(lin.history)) lin.history = [];
169
+ const previous = lin.current && lin.current.name ? { ...lin.current } : null;
170
+ if (previous) {
171
+ lin.history.push({
172
+ name: previous.name, sessionId: previous.sessionId,
173
+ from: previous.since || '', to: stampNow(), note: previous.note || '',
174
+ });
175
+ }
176
+ lin.kind = o.kind;
177
+ if (o.project) lin.project = String(o.project);
178
+ if (o.task) lin.task = String(o.task);
179
+ if (o.workspace) lin.workspace = String(o.workspace);
180
+ lin.current = { name: String(o.name || ''), sessionId: String(o.sessionId || ''), since: stampNow(), note: String(o.note || '') };
181
+ if (!lin.current.name || !lin.current.sessionId) throw new Error('在任登记需要 name 与 sessionId');
182
+ saveOffice(ws, store);
183
+ let registry = { changed: [], error: '' };
184
+ try { registry = syncRegistry(ws, store); } catch (e) { registry = { changed: [], error: String(e && e.message ? e.message : e) }; }
185
+ return { key, previous, current: lin.current, registry };
186
+ }
187
+
188
+ /** 类别当前空缺(会话退役、项目暂停)。 */
189
+ export function retireHolder(ws, key, note) {
190
+ const store = loadOffice(ws);
191
+ const lin = store.lineages[String(key || '')];
192
+ if (!lin) throw new Error('没有这条类别:' + key);
193
+ const previous = lin.current && lin.current.name ? { ...lin.current } : null;
194
+ if (previous) {
195
+ lin.history.push({ name: previous.name, sessionId: previous.sessionId, from: previous.since || '', to: stampNow(), note: note || previous.note || '' });
196
+ }
197
+ lin.current = null;
198
+ saveOffice(ws, store);
199
+ let registry = { changed: [], error: '' };
200
+ try { registry = syncRegistry(ws, store); } catch (e) { registry = { changed: [], error: String(e && e.message ? e.message : e) }; }
201
+ return { key, previous, registry };
202
+ }
203
+
204
+ // ---------------------------------------------------------------- Registry.md 定点同步
205
+
206
+ function splitCells(line) {
207
+ // "| a | b |" → ['', ' a ', ' b ', ''];保持两端空格原样,便于回写。
208
+ return line.split('|');
209
+ }
210
+
211
+ function tailOf(cell) {
212
+ const m = /([^()]*)\s*$/.exec(cell) || /\([^()]*\)\s*$/.exec(cell);
213
+ return m ? ' ' + m[0].trim() : '';
214
+ }
215
+
216
+ function currentCellFor(row, withName) {
217
+ const cur = row.current;
218
+ if (!cur || !cur.name) return null;
219
+ return withName ? cur.name + ' `' + cur.sessionId + '`' : '`' + cur.sessionId + '`';
220
+ }
221
+
222
+ /**
223
+ * 把 Registry.md 里的「在役会话」格改成登记表里的现任。
224
+ * 只认两张表(项目清单、未登记但存在的任务),只换那一格;其它文字一律不动。
225
+ */
226
+ export function syncRegistry(ws, store) {
227
+ const file = path.join(String(ws || ''), '_Project_Meta', 'Registry.md');
228
+ let text = '';
229
+ try { text = readUtf8(file); } catch { return { changed: [], error: '读不到 Registry.md' }; }
230
+ const bom = text.charCodeAt(0) === 0xFEFF ? '\uFEFF' : '';
231
+ if (bom) text = text.slice(1);
232
+ const lines = text.split(NL);
233
+ const changed = [];
234
+ let mode = '';
235
+ for (let i = 0; i < lines.length; i++) {
236
+ const line = lines[i];
237
+ if (line.startsWith('## ')) {
238
+ if (line.indexOf('项目清单') !== -1) mode = 'project';
239
+ else if (line.indexOf('未登记但存在的任务') !== -1) mode = 'misc';
240
+ else mode = '';
241
+ continue;
242
+ }
243
+ if (!mode || !line.startsWith('|')) continue;
244
+ const cells = splitCells(line);
245
+ // 表头与分隔行跳过
246
+ if (cells.length < 4) continue;
247
+ const name = String(cells[1] || '').trim();
248
+ if (!name || name === '项目名' || name === '任务短名' || /^-+$/.test(name)) continue;
249
+ const row = rowsOf(store).find((r) => r.key === name);
250
+ if (!row) continue;
251
+ if (mode === 'project') {
252
+ if (cells.length < 9) continue; // 8 列 + 首尾空
253
+ const old = String(cells[6] || '');
254
+ const next = currentCellFor(row, true);
255
+ if (!next) continue;
256
+ const want = ' ' + next + tailOf(old) + ' ';
257
+ if (old === want) continue;
258
+ cells[6] = want;
259
+ } else {
260
+ if (cells.length < 5) continue; // 4 列 + 首尾空
261
+ const next = currentCellFor(row, false);
262
+ if (!next) continue;
263
+ const want = ' ' + next + tailOf(String(cells[3] || '')) + ' ';
264
+ if (String(cells[3] || '') === want) continue;
265
+ cells[3] = want;
266
+ }
267
+ lines[i] = cells.join('|');
268
+ changed.push(name);
269
+ }
270
+ if (changed.length) fs.writeFileSync(file, bom + lines.join(NL), 'utf8');
271
+ return { changed, error: '' };
272
+ }
@@ -13,6 +13,7 @@ import { scanAbsoluteRefs, renderAbsoluteRefs } from './absrefs.mjs';
13
13
  import { stampTz } from './brief.mjs';
14
14
  import { scanSessions } from './sessions.mjs';
15
15
  import { lintProject } from './lint.mjs';
16
+ import { assertProjectName, assertTaskName } from './role.mjs';
16
17
 
17
18
  const SKIP_DIRS = ['__pycache__', '.git', 'node_modules', '.obsidian'];
18
19
  const SKIP_FILES = ['.DS_Store', 'Thumbs.db'];
@@ -152,11 +153,13 @@ export function packNow(env, args, exec) {
152
153
  roots.push(['_Project_Meta', path.join(ws, '_Project_Meta')]);
153
154
  } else if (kind === 'project') {
154
155
  if (!name) throw new Error('kind=project 需要 name');
156
+ assertProjectName(name);
155
157
  roots.push(['Projects/' + name, path.join(ws, 'Projects', name)]);
156
158
  roots.push(['_Project_Meta/' + name, path.join(ws, '_Project_Meta', name)]);
157
159
  roots.push(['_Archives/' + name + '/Handover', path.join(ws, '_Archives', name, 'Handover')]);
158
160
  } else if (kind === 'task') {
159
161
  if (!name) throw new Error('kind=task 需要 name');
162
+ assertTaskName(name);
160
163
  roots.push(['_Misc/tasks/' + name, path.join(ws, '_Misc', 'tasks', name)]);
161
164
  } else throw new Error('未知 kind:' + kind);
162
165
  const entries = [];
package/src/lib/phase.mjs CHANGED
@@ -15,19 +15,21 @@ export function decide(input) {
15
15
  if (input.lockState === 'orphaned') {
16
16
  return { action: 'blocked', reason: '检测到未闭合的压缩锁(compaction/start 无配对 end):先处理再排新压缩' };
17
17
  }
18
+ // 额度用尽优先于水位判定:3 次压满后再压不是「水位没到」,而是该换任了。
19
+ // 放在水位判定之前,force 也就绕不过去(force 只在水位分支里放行)。
20
+ if (used >= maxComp) {
21
+ return { action: 'new_session', reason: '已压缩 ' + used + '/' + maxComp + ' 次:压缩额度用尽,应写交接并建新会话' };
22
+ }
18
23
  if (input.ratio === null || input.ratio === undefined) {
19
24
  return { action: 'unknown', reason: '水位读不到:人工判断,不要盲目压缩' };
20
25
  }
21
26
  if (input.ratio >= hard) {
22
- if (used >= maxComp) return { action: 'new_session', reason: '已到硬顶且压缩额度用尽,应写交接并建新会话' };
23
- return { action: 'force_compact', reason: '已到硬顶(' + Math.round(hard * 100) + '%):强制压缩兜底,哪怕任务中途' };
27
+ return { action: 'force_compact', reason: '已到硬顶(' + Math.round(hard * 100) + '%):强制压缩,哪怕任务中途' };
24
28
  }
25
29
  if (input.ratio >= handover) {
26
- if (used >= maxComp) return { action: 'new_session', reason: '已压缩 ' + used + '/' + maxComp + ' 次:写交接并建新会话(现行流程)' };
27
30
  return { action: 'checkpoint', reason: '过交接线(' + Math.round(handover * 100) + '%):写交接,随后压缩,再读交接续跑' };
28
31
  }
29
32
  if (input.ratio >= warn) {
30
- if (used >= maxComp) return { action: 'new_session', reason: '已压缩 ' + used + '/' + maxComp + ' 次:到线即走换会话' };
31
33
  return { action: 'pause', reason: '过提醒线(' + Math.round(warn * 100) + '%):建议在阶段完成处调 ho_checkpoint(关键步骤返回 pause)' };
32
34
  }
33
35
  return { action: 'continue', reason: '水位正常' };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 会话投递的唯一入口:把一条纯文本作为用户消息排进目标会话。
3
+ * 角色整理指令、换任启动提示词、压缩后继续,三处都用它,避免各自拼一套 requestId 与中止信号。
4
+ */
5
+
6
+ /** 投递一条文本;controller 由调用方按自己的服务句柄给出。 */
7
+ export function sendPrompt(controller, sessionId, text, mode) {
8
+ if (!controller || typeof controller.prompt !== 'function') throw new Error('sessionController.prompt 不可用');
9
+ const requestId = (globalThis.crypto && globalThis.crypto.randomUUID)
10
+ ? globalThis.crypto.randomUUID()
11
+ : String(Date.now());
12
+ return controller.prompt({
13
+ requestId,
14
+ sessionId: String(sessionId),
15
+ mode: mode || 'queue',
16
+ content: [{ type: 'text', text: String(text) }],
17
+ }, new AbortController().signal);
18
+ }
package/src/lib/role.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
- import { readJsonDir, pick } from './text.mjs';
8
+ import { readJsonDir, pick, isSafeName, assertSafeName } from './text.mjs';
9
9
  import { projCacheDir, isSubagent } from './sessions.mjs';
10
10
  import { globalWorkspaceSessionIds } from './registry.mjs';
11
11
  import { TOOL_NAMES, PEER_NAMES, nameOf, peerNameOf } from './names.mjs';
@@ -19,8 +19,8 @@ export const ALL_TOOL_NAMES = TOOL_NAMES.concat(PEER_NAMES);
19
19
  /** 角色 → 可见工具(其余一律 deny)。改这张表就是改成本结构。 */
20
20
  const ROLE_TOOLS = {
21
21
  global: ALL_TOOL_NAMES.slice(),
22
- project: [nameOf('phase'), nameOf('handover'), nameOf('checkpoint'), nameOf('charter'), nameOf('gate'), nameOf('snapshot'), nameOf('profile'), peerNameOf('send'), peerNameOf('status')],
23
- misc: [nameOf('phase'), nameOf('handover'), nameOf('checkpoint'), nameOf('charter'), nameOf('snapshot'), nameOf('profile'), peerNameOf('send'), peerNameOf('status'), peerNameOf('new')],
22
+ project: [nameOf('phase'), nameOf('handover'), nameOf('checkpoint'), nameOf('charter'), nameOf('gate'), nameOf('snapshot'), nameOf('profile'), nameOf('office'), nameOf('succeed'), peerNameOf('send'), peerNameOf('status')],
23
+ misc: [nameOf('phase'), nameOf('handover'), nameOf('checkpoint'), nameOf('charter'), nameOf('snapshot'), nameOf('profile'), nameOf('office'), nameOf('succeed'), peerNameOf('send'), peerNameOf('status'), peerNameOf('new')],
24
24
  plain: [],
25
25
  };
26
26
 
@@ -65,18 +65,15 @@ export function writeRole(cfg, sessionId, patch) {
65
65
  const data = readRoles(cfg);
66
66
  const prev = data.sessions[sid] || {};
67
67
  const next = Object.assign({}, prev, patch || {});
68
+ // 名字要落成目录:不合法就当场拒绝,别等拼路径时才 ENOENT。
69
+ if (next.role === 'project') assertProjectName(next.project);
70
+ if (next.role === 'misc') assertTaskName(next.task);
68
71
  next.setAt = stampTz(Date.now());
69
- // 角色变了就重新引导一次;只是改项目名也重引导,因为路径变了。
70
- if (prev.role !== next.role || prev.project !== next.project || prev.task !== next.task) delete next.bootstrappedAt;
71
72
  data.sessions[sid] = next;
72
73
  writeRolesFile(cfg, data);
73
74
  return next;
74
75
  }
75
76
 
76
- export function bootstrappedAt(cfg, sessionId) {
77
- const cur = readRoles(cfg).sessions[String(sessionId || '')];
78
- return cur && cur.bootstrappedAt ? String(cur.bootstrappedAt) : '';
79
- }
80
77
 
81
78
  // ---------------------------------------------------------------- 判定
82
79
 
@@ -99,24 +96,44 @@ function parts(p) {
99
96
  return String(p || '').replace(/\\/g, '/').split('/').filter(Boolean);
100
97
  }
101
98
 
102
- /** cwd 落在 Projects/<名>/ 或 _Misc/tasks/<任务>/ 里就据此判定。 */
99
+ /** 项目名当目录名用,长度与字符集都比任务名紧一些;任务名放宽到 40 字符。 */
100
+ export const PROJECT_NAME_MAX = 24;
101
+ export const TASK_NAME_MAX = 40;
102
+
103
+ export function projectNameOk(name) {
104
+ return isSafeName(name, PROJECT_NAME_MAX);
105
+ }
106
+
107
+ export function taskNameOk(name) {
108
+ return isSafeName(name, TASK_NAME_MAX);
109
+ }
110
+
111
+ export function assertProjectName(name) {
112
+ return assertSafeName(name, '项目名', PROJECT_NAME_MAX);
113
+ }
114
+
115
+ export function assertTaskName(name) {
116
+ return assertSafeName(name, '任务名', TASK_NAME_MAX);
117
+ }
118
+
119
+ /** cwd 落在 Projects/<名>/ 或 _Misc/tasks/<任务>/ 里就据此判定;名字不合法一律不认。 */
103
120
  function roleFromPath(cwd) {
104
121
  const seg = parts(cwd);
105
122
  for (let i = 0; i < seg.length; i++) {
106
- if (seg[i] === 'Projects' && seg[i + 1] && seg[i + 1] !== '_Project_Meta') {
123
+ if (seg[i] === 'Projects' && projectNameOk(seg[i + 1]) && seg[i + 1] !== '_Project_Meta') {
107
124
  return { role: 'project', project: seg[i + 1], task: '' };
108
125
  }
109
- if (seg[i] === '_Misc' && seg[i + 1] === 'tasks' && seg[i + 2]) {
126
+ if (seg[i] === '_Misc' && seg[i + 1] === 'tasks' && taskNameOk(seg[i + 2])) {
110
127
  return { role: 'misc', project: '', task: seg[i + 2] };
111
128
  }
112
- if (seg[i] === '_Project_Meta' && seg[i + 1]) {
129
+ if (seg[i] === '_Project_Meta' && projectNameOk(seg[i + 1])) {
113
130
  return { role: 'project', project: seg[i + 1], task: '' };
114
131
  }
115
132
  }
116
133
  return null;
117
134
  }
118
135
 
119
- /** 首条提示里能解析出 Projects/<名>/ 或「<名>」项目 就算项目会话。 */
136
+ /** 首条提示里能解析出 Projects/<名>/ 或「<名>」项目 就算项目会话;名字不合法不认。 */
120
137
  function roleFromPrompt(text) {
121
138
  const t = String(text || '').replace(/\\/g, '/'); // Windows 路径也能解析
122
139
  const i = t.indexOf('Projects/');
@@ -127,13 +144,17 @@ function roleFromPrompt(text) {
127
144
  if (ch === '/' || ch === ' ' || ch === String.fromCharCode(9) || ch === String.fromCharCode(10) || ch === String.fromCharCode(13)) break;
128
145
  name += ch;
129
146
  }
130
- if (name) return { role: 'project', project: name, task: '' };
147
+ // 首条提示常把规则写成 `_Control_Center/**、Projects/**` 这样的通配,解析结果必须过名称校验。
148
+ if (projectNameOk(name)) return { role: 'project', project: name, task: '' };
131
149
  }
132
150
  const q = t.indexOf(String.fromCharCode(12300));
133
151
  if (q !== -1) {
134
152
  const rest = t.slice(q + 1);
135
153
  const j = rest.indexOf(String.fromCharCode(12301) + '项目');
136
- if (j > 0) return { role: 'project', project: rest.slice(0, j), task: '' };
154
+ if (j > 0) {
155
+ const name = rest.slice(0, j);
156
+ if (projectNameOk(name)) return { role: 'project', project: name, task: '' };
157
+ }
137
158
  }
138
159
  return null;
139
160
  }
@@ -150,9 +171,15 @@ export function resolveRole(opts) {
150
171
  if (hit) {
151
172
  const role = String(hit.role || '');
152
173
  if (ROLES.indexOf(role) !== -1) {
153
- return { role, project: String(hit.project || ''), task: String(hit.task || ''), source: 'store', sessionId: sid };
174
+ // 角色表里的名字要当目录名用。历史写入或手工编辑留下的非法名字一律不认,继续往下推断,
175
+ // 否则会把整条「写交接 → 备份 → 归档」链路卡在 Windows 的非法路径上。
176
+ const named = role === 'project' ? projectNameOk(hit.project)
177
+ : role === 'misc' ? taskNameOk(hit.task) : true;
178
+ if (named) {
179
+ return { role, project: String(hit.project || ''), task: String(hit.task || ''), source: 'store', sessionId: sid };
180
+ }
154
181
  }
155
- // 旧角色表的 unknown 是兜底态,按新口径并入普通。
182
+ // 旧角色表的 unknown 是备用状态,按新口径并入普通。
156
183
  if (role === 'unknown') {
157
184
  return { role: 'plain', project: String(hit.project || ''), task: String(hit.task || ''), source: 'store', sessionId: sid };
158
185
  }
@@ -188,11 +215,13 @@ export function handoverPathFor(ws, info) {
188
215
  if (role === 'project') {
189
216
  const project = String(info.project || '');
190
217
  if (!project) return { path: null, kind: null, scope: '', role, reason: '项目名缺失' };
218
+ if (!projectNameOk(project)) return { path: null, kind: null, scope: '', role, reason: '项目名不合法(当目录名用):' + JSON.stringify(project) };
191
219
  return { path: path.join(ws, '_Project_Meta', project, 'Handover.md'), kind: 'project', scope: project, role, project };
192
220
  }
193
221
  if (role === 'misc') {
194
222
  const task = String(info.task || '');
195
223
  if (!task) return { path: null, kind: null, scope: '', role, reason: '任务名缺失' };
224
+ if (!taskNameOk(task)) return { path: null, kind: null, scope: '', role, reason: '任务名不合法(当目录名用):' + JSON.stringify(task) };
196
225
  return { path: path.join(ws, '_Misc', 'tasks', task, 'Handover.md'), kind: 'misc', scope: path.join('_Misc', task), role, task };
197
226
  }
198
227
  if (role === 'plain') return { path: null, kind: null, scope: '', role, reason: '普通会话不参与交接' };
@@ -205,6 +234,11 @@ export function snapshotDirFor(ws, info) {
205
234
  }
206
235
 
207
236
  export function archiveDirFor(ws, info) {
237
+ // 规范 §4.1:杂项归档自包含在任务目录里(`_Misc/tasks/<任务>/_archive/`),不进全局归档;
238
+ // 全局与项目归档在 `_Archives/<范围>/Handover/`。
239
+ if (info && info.role === 'misc' && taskNameOk(info.task)) {
240
+ return path.join(ws, '_Misc', 'tasks', String(info.task), '_archive');
241
+ }
208
242
  const t = handoverPathFor(ws, info);
209
243
  return path.join(ws, '_Archives', t.scope || '_Global', 'Handover');
210
244
  }
@@ -1,7 +1,8 @@
1
- import fsx from "node:fs";
1
+ import fsx from "node:fs";
2
2
  import osx from "node:os";
3
3
  import pathx from "node:path";
4
4
  import { checkConsent } from './consent.mjs';
5
+ import { sendPrompt } from './prompt.mjs';
5
6
 
6
7
  /**
7
8
  * 压缩调度:compactNow 只允许 idle,所以 ho_checkpoint 只登记,轮次结束后由这里排程。
@@ -92,16 +93,8 @@ export function createScheduler(deps) {
92
93
  return fillResumePrompt(cfg, { n, handover: hp && hp.path ? hp.path : '', role: info && info.role ? info.role : 'plain' });
93
94
  }
94
95
 
95
- async function promptSelf(sessionId, text) {
96
- const controller = service('sessionController');
97
- if (!controller || typeof controller.prompt !== "function") throw new Error("sessionController.prompt 不可用");
98
- const req = {
99
- requestId: (globalThis.crypto && globalThis.crypto.randomUUID) ? globalThis.crypto.randomUUID() : String(Date.now()),
100
- sessionId,
101
- mode: "queue",
102
- content: [{ type: "text", text }],
103
- };
104
- return controller.prompt(req, new AbortController().signal);
96
+ function promptSelf(sessionId, text) {
97
+ return sendPrompt(service('sessionController'), sessionId, text, 'queue');
105
98
  }
106
99
 
107
100
  /**
@@ -146,7 +139,9 @@ export function createScheduler(deps) {
146
139
  return;
147
140
  }
148
141
  note("compacted " + sessionId + " shadowed=" + (result.shadowedTokenCount || "?"));
149
- if (cfg.autoResume !== false) {
142
+ // resume=wait:任务已收尾,压缩后不唤醒,等用户派新任务。
143
+ // resume=continue(默认):照旧投续跑消息,按交接 §2 继续。
144
+ if (cfg.autoResume !== false && job.resume !== "wait") {
150
145
  const text = resumeText(sessionId, job.n);
151
146
  try {
152
147
  await promptSelf(sessionId, text);
@@ -156,7 +151,8 @@ export function createScheduler(deps) {
156
151
  note("resume prompt failed: " + e.message);
157
152
  }
158
153
  } else {
159
- job.done("ok", result);
154
+ if (job.resume === "wait") note("resume skipped (wait): " + sessionId);
155
+ job.done(job.resume === "wait" ? "ok-wait" : "ok", result);
160
156
  }
161
157
  pending.delete(sessionId);
162
158
  } catch (e) {
@@ -209,7 +205,7 @@ export function createScheduler(deps) {
209
205
  return;
210
206
  }
211
207
  }
212
- pending.set(sessionId, { n: opts.n || 0, attempts: 0, done, startedAt: Date.now() });
208
+ pending.set(sessionId, { n: opts.n || 0, resume: opts.resume === "wait" ? "wait" : "continue", attempts: 0, done, startedAt: Date.now() });
213
209
  retryLater(sessionId, opts.delayMs === undefined ? 600 : opts.delayMs);
214
210
  });
215
211
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 新建会话的唯一落点:建会话、改名、登记角色、当场装配。
3
+ * ho_sessions(new)、设置页一键部署与补建全局会话共用这一段,避免三处各写一遍。
4
+ */
5
+
6
+ /**
7
+ * @param env 插件环境(需要 controller、writeRole、applyScopedLive)
8
+ * @param opts { workspaceId?, cwd?, title?, role?, project?, task? }
9
+ * @returns { sessionId, workspaceId, error }
10
+ */
11
+ export async function newSessionIn(env, opts) {
12
+ const o = opts || {};
13
+ const controller = typeof env.controller === 'function' ? env.controller() : null;
14
+ if (!controller || typeof controller.create !== 'function') {
15
+ return { error: 'sessionController.create 不可用' };
16
+ }
17
+ const workspaceId = String(o.workspaceId || '');
18
+ const cwd = String(o.cwd || '');
19
+ if (!workspaceId && !cwd) return { error: '建会话要么给 workspaceId,要么给 cwd' };
20
+ let created = null;
21
+ try {
22
+ created = workspaceId ? await controller.create({ workspaceId }) : await controller.create({ cwd });
23
+ } catch (e) {
24
+ return { error: '建会话失败:' + String(e && e.message ? e.message : e) };
25
+ }
26
+ const sessionId = String((created && created.sessionId) || '');
27
+ if (!sessionId) return { error: 'create 没有返回 sessionId' };
28
+ const title = String(o.title || '');
29
+ if (title) {
30
+ try { if (typeof controller.rename === 'function') await controller.rename({ sessionId, title }); } catch { /* 改名失败可事后 peer_rename */ }
31
+ }
32
+ const role = String(o.role || '');
33
+ if (role && role !== 'plain' && typeof env.writeRole === 'function') {
34
+ try {
35
+ env.writeRole(sessionId, { role, project: String(o.project || ''), task: String(o.task || ''), source: 'create' });
36
+ } catch { /* 角色登记失败不影响建会话 */ }
37
+ }
38
+ if (typeof env.applyScopedLive === 'function') {
39
+ try { env.applyScopedLive(sessionId, { role: role || 'plain', project: String(o.project || ''), task: String(o.task || '') }); } catch { /* 下一轮的开轮核对会补齐 */ }
40
+ }
41
+ return { sessionId, workspaceId };
42
+ }