metame-cli 1.4.17 → 1.4.19

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.
@@ -106,6 +106,59 @@ function createSessionStore(deps) {
106
106
 
107
107
  function invalidateSessionCache() { _sessionCache = null; }
108
108
 
109
+ // 监听 ~/.claude/projects 目录,手机端新建 session 后桌面端无需重启即可感知
110
+ let _watcher = null;
111
+ let _invalidateDebounce = null;
112
+
113
+ function _debouncedInvalidate() {
114
+ if (_invalidateDebounce) return;
115
+ _invalidateDebounce = setTimeout(() => {
116
+ _sessionCache = null;
117
+ _invalidateDebounce = null;
118
+ }, 500);
119
+ }
120
+
121
+ function watchSessionFiles() {
122
+ // 先关闭旧 watcher,防止热重载时叠加
123
+ if (_watcher) { try { _watcher.close(); } catch (_) {} _watcher = null; }
124
+ if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) return;
125
+ try {
126
+ _watcher = fs.watch(CLAUDE_PROJECTS_DIR, { recursive: true }, (evt, filename) => {
127
+ if (filename && filename.endsWith('.jsonl')) _debouncedInvalidate();
128
+ });
129
+ _watcher.on('error', (e) => {
130
+ log('WARN', '[session-store] fs.watch error: ' + e.message);
131
+ _watcher = null;
132
+ });
133
+ log('INFO', '[session-store] fs.watch active on ' + CLAUDE_PROJECTS_DIR);
134
+ } catch (e) {
135
+ log('WARN', '[session-store] fs.watch failed, fallback to TTL cache: ' + e.message);
136
+ }
137
+ }
138
+
139
+ function stopWatchingSessionFiles() {
140
+ if (_watcher) { try { _watcher.close(); } catch (_) {} _watcher = null; }
141
+ }
142
+
143
+ // [M3] 共享辅助:从 reversed JSONL 行数组中提取最后一条外部用户消息(统一规则)
144
+ function extractLastUserFromLines(lines) {
145
+ for (const line of lines) {
146
+ if (!line) continue;
147
+ try {
148
+ const d = JSON.parse(line);
149
+ if (d.type === 'user' && d.message && d.userType !== 'internal') {
150
+ const content = d.message.content;
151
+ let raw = typeof content === 'string' ? content
152
+ : Array.isArray(content) ? (content.find(c => c.type === 'text') || {}).text || '' : '';
153
+ raw = raw.replace(/\[System hints[\s\S]*/i, '')
154
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
155
+ if (raw.length > 2) return raw.slice(0, 80);
156
+ }
157
+ } catch { /* skip */ }
158
+ }
159
+ return '';
160
+ }
161
+
109
162
  function scanAllSessions() {
110
163
  if (_sessionCache && (Date.now() - _sessionCacheTime < SESSION_CACHE_TTL)) return _sessionCache;
111
164
  try {
@@ -162,49 +215,58 @@ function createSessionStore(deps) {
162
215
  const ENRICH_LIMIT = 20;
163
216
  for (let i = 0; i < Math.min(all.length, ENRICH_LIMIT); i++) {
164
217
  const s = all[i];
165
- if (s.firstPrompt && s.customTitle) continue;
218
+ // [M1] _enriched 标志替代三字段联合判断
219
+ // customTitle 是可选的,无命名 session 合法值为 undefined,不能作为 skip 条件
220
+ if (s._enriched) continue;
166
221
  try {
167
222
  const sessionFile = findSessionFile(s.sessionId);
168
223
  if (!sessionFile) continue;
169
224
  const fd = fs.openSync(sessionFile, 'r');
170
- const headBuf = Buffer.alloc(8192);
171
- const headBytes = fs.readSync(fd, headBuf, 0, 8192, 0);
172
- const headStr = headBuf.toString('utf8', 0, headBytes);
173
- if (!s.firstPrompt) {
174
- for (const line of headStr.split('\n')) {
175
- if (!line) continue;
176
- try {
177
- const d = JSON.parse(line);
178
- if (d.type === 'user' && d.message && d.userType === 'external') {
179
- const content = d.message.content;
180
- let raw = '';
181
- if (typeof content === 'string') raw = content;
182
- else if (Array.isArray(content)) {
183
- const txt = content.find(c => c.type === 'text');
184
- if (txt) raw = txt.text;
225
+ try {
226
+ if (!s.firstPrompt) {
227
+ const headBuf = Buffer.alloc(8192);
228
+ const headBytes = fs.readSync(fd, headBuf, 0, 8192, 0);
229
+ const headStr = headBuf.toString('utf8', 0, headBytes);
230
+ for (const line of headStr.split('\n')) {
231
+ if (!line) continue;
232
+ try {
233
+ const d = JSON.parse(line);
234
+ if (d.type === 'user' && d.message && d.userType !== 'internal') {
235
+ const content = d.message.content;
236
+ let raw = '';
237
+ if (typeof content === 'string') raw = content;
238
+ else if (Array.isArray(content)) {
239
+ const txt = content.find(c => c.type === 'text');
240
+ if (txt) raw = txt.text;
241
+ }
242
+ raw = raw.replace(/\n?\[System hints[\s\S]*/i, '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
243
+ if (raw && raw.length > 2) { s.firstPrompt = raw.slice(0, 120); break; }
185
244
  }
186
- raw = raw.replace(/\n?\[System hints[\s\S]*/i, '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
187
- if (raw && raw.length > 2) { s.firstPrompt = raw.slice(0, 120); break; }
188
- }
189
- } catch { /* skip line */ }
245
+ } catch { /* skip line */ }
246
+ }
190
247
  }
191
- }
192
- if (!s.customTitle) {
248
+ // 从尾部读取:customTitle + lastUser(256KB 覆盖 tool-heavy session)
193
249
  const stat = fs.fstatSync(fd);
194
- const tailSize = Math.min(4096, stat.size);
250
+ const tailSize = Math.min(262144, stat.size);
195
251
  const tailBuf = Buffer.alloc(tailSize);
196
252
  fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
197
- const tailStr = tailBuf.toString('utf8');
198
- const tailLines = tailStr.split('\n').reverse();
199
- for (const line of tailLines) {
200
- if (!line) continue;
201
- try {
202
- const d = JSON.parse(line);
203
- if (d.type === 'custom-title' && d.customTitle) { s.customTitle = d.customTitle; break; }
204
- } catch { /* skip */ }
253
+ const tailLines = tailBuf.toString('utf8').split('\n').reverse();
254
+ if (!s.customTitle) {
255
+ for (const line of tailLines) {
256
+ if (!line) continue;
257
+ try {
258
+ const d = JSON.parse(line);
259
+ if (d.type === 'custom-title' && d.customTitle) { s.customTitle = d.customTitle; break; }
260
+ } catch { /* skip */ }
261
+ }
262
+ }
263
+ if (!s.lastUser) {
264
+ s.lastUser = extractLastUserFromLines(tailLines);
205
265
  }
266
+ } finally {
267
+ fs.closeSync(fd);
206
268
  }
207
- fs.closeSync(fd);
269
+ s._enriched = true; // [M1] 标记已完成富化,下次跳过
208
270
  } catch { /* non-fatal */ }
209
271
  }
210
272
 
@@ -265,20 +327,34 @@ function createSessionStore(deps) {
265
327
  .replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F\uFFFD\uD800-\uDFFF]/g, '')
266
328
  .replace(/\s+/g, ' ')
267
329
  .trim();
330
+
331
+ // 优先级:name > summary > tags > firstPrompt > sessionId 前缀
268
332
  if (s.customTitle) return sanitize(s.customTitle).slice(0, maxLen);
333
+
334
+ if (s.summary) {
335
+ const t = sanitize(s.summary);
336
+ if (t.length > 2) return t.slice(0, maxLen);
337
+ }
338
+
269
339
  const tagEntry = sessionTags && sessionTags[s.sessionId];
270
340
  if (tagEntry && tagEntry.name) return sanitize(tagEntry.name).slice(0, maxLen);
341
+
271
342
  if (s.firstPrompt) {
272
343
  const clean = s.firstPrompt
273
344
  .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
274
- .replace(/^<[^>]+>.*?<\/[^>]+>\s*/s, '')
275
- .replace(/\[System hints[\s\S]*/i, '');
276
- const firstLine = clean.split('\n').map(l => l.trim()).find(l => l.length > 2) || '';
345
+ .replace(/\[System hints[\s\S]*/i, '')
346
+ .replace(/^[\s\S]*?<\/[^>]+>\s*/s, '') // 剥离 XML 头部标签
347
+ .trim();
348
+ // 取第一行非空、有实际内容(非纯符号/空格)的行
349
+ const firstLine = clean.split('\n')
350
+ .map(l => l.trim())
351
+ .find(l => l.length > 4 && /\p{L}/u.test(l)) || '';
277
352
  const sanitized = sanitize(firstLine);
278
353
  if (sanitized && sanitized.length > 2) return sanitized.slice(0, maxLen);
279
354
  }
280
- if (s.summary) return sanitize(s.summary).slice(0, maxLen);
281
- return '';
355
+
356
+ // 最终兜底:显示 session ID 前缀而非空白
357
+ return s.sessionId ? s.sessionId.slice(0, 8) : '';
282
358
  }
283
359
 
284
360
  function sessionRichLabel(s, index, sessionTags) {
@@ -291,11 +367,17 @@ function createSessionStore(deps) {
291
367
  const shortId = s.sessionId.slice(0, 8);
292
368
  const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 3);
293
369
 
294
- let line = `${index}. `;
295
- if (title) line += `${title}${title.length >= 50 ? '..' : ''}`;
296
- else line += '(unnamed)';
370
+ // [M2] 转义 markdown 特殊字符,防止用户历史消息破坏渲染
371
+ const escapeMd = (t) => t.replace(/[_*`\\]/g, '\\$&');
372
+ // fallback to firstPrompt when lastUser not found in tail
373
+ const snippetRaw = s.lastUser || (s.firstPrompt || '').replace(/<[^>]+>/g, '').replace(/\[System hints[\s\S]*/i, '').trim().slice(0, 80);
374
+ let line = `${index}. ${title}${title.length >= 50 ? '..' : ''}`; // [M4] title 已有 sessionId 兜底,不会为空
297
375
  if (tags.length) line += ` ${tags.map(t => `#${t}`).join(' ')}`;
298
376
  line += `\n 📁${proj} · ${ago}`;
377
+ if (snippetRaw && snippetRaw.length > 2) {
378
+ const snippet = escapeMd(snippetRaw.replace(/\n/g, ' ').slice(0, 60));
379
+ line += `\n 💬 ${snippet}${snippetRaw.length > 60 ? '…' : ''}`;
380
+ }
299
381
  line += `\n /resume ${shortId}`;
300
382
  return line;
301
383
  }
@@ -312,8 +394,15 @@ function createSessionStore(deps) {
312
394
  const ago = formatRelativeTime(new Date(timeMs).toISOString());
313
395
  const shortId = s.sessionId.slice(0, 6);
314
396
  const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 4);
315
- let desc = `**${i + 1}. ${title || '(unnamed)'}**\n📁${proj} · ${ago}`;
397
+ // [M2] 转义 markdown 特殊字符;[M4] title 已有 sessionId 兜底
398
+ const escapeMd = (t) => t.replace(/[_*`\\]/g, '\\$&');
399
+ const snippetRaw = s.lastUser || (s.firstPrompt || '').replace(/<[^>]+>/g, '').replace(/\[System hints[\s\S]*/i, '').trim().slice(0, 80);
400
+ let desc = `**${i + 1}. ${title}**\n📁${proj} · ${ago}`;
316
401
  if (tags.length) desc += `\n${tags.map(t => `\`${t}\``).join(' ')}`;
402
+ if (snippetRaw && snippetRaw.length > 2) {
403
+ const snippet = escapeMd(snippetRaw.replace(/\n/g, ' ').slice(0, 60));
404
+ desc += `\n💬 ${snippet}${snippetRaw.length > 60 ? '…' : ''}`;
405
+ }
317
406
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: desc } });
318
407
  elements.push({ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: `▶️ Switch #${shortId}` }, type: 'primary', value: { cmd: `/resume ${s.sessionId}` } }] });
319
408
  });
@@ -393,6 +482,49 @@ function createSessionStore(deps) {
393
482
  }
394
483
  }
395
484
 
485
+ /**
486
+ * 读取 session 最近一条用户消息 + 最近一条 AI 回复
487
+ * 用于 /resume 后帮助确认切换到正确 session
488
+ */
489
+ function getSessionRecentContext(sessionId) {
490
+ try {
491
+ const sessionFile = findSessionFile(sessionId);
492
+ if (!sessionFile) return null;
493
+ const stat = fs.statSync(sessionFile);
494
+ const tailSize = Math.min(262144, stat.size); // 256KB for better coverage of tool-heavy sessions
495
+ const buf = Buffer.alloc(tailSize);
496
+ const fd = fs.openSync(sessionFile, 'r');
497
+ try {
498
+ fs.readSync(fd, buf, 0, tailSize, stat.size - tailSize);
499
+ } finally {
500
+ fs.closeSync(fd);
501
+ }
502
+ const lines = buf.toString('utf8').split('\n').reverse();
503
+ // [M3] 复用共享函数,统一截取逻辑
504
+ const lastUser = extractLastUserFromLines(lines);
505
+ let lastAssistant = '';
506
+ for (const line of lines) {
507
+ if (!line.trim()) continue;
508
+ try {
509
+ const d = JSON.parse(line);
510
+ if (!lastAssistant && d.type === 'assistant' && d.message) {
511
+ const content = d.message.content;
512
+ if (Array.isArray(content)) {
513
+ for (const c of content) {
514
+ if (c.type === 'text' && c.text && c.text.trim().length > 2) {
515
+ lastAssistant = c.text.trim().slice(0, 80);
516
+ break;
517
+ }
518
+ }
519
+ }
520
+ }
521
+ if (lastAssistant) break;
522
+ } catch { /* skip bad line */ }
523
+ }
524
+ return (lastUser || lastAssistant) ? { lastUser, lastAssistant } : null;
525
+ } catch { return null; }
526
+ }
527
+
396
528
  function markSessionStarted(chatId) {
397
529
  const state = loadState();
398
530
  if (state.sessions[chatId]) {
@@ -405,6 +537,8 @@ function createSessionStore(deps) {
405
537
  findSessionFile,
406
538
  clearSessionFileCache,
407
539
  truncateSessionToCheckpoint,
540
+ watchSessionFiles,
541
+ stopWatchingSessionFiles,
408
542
  listRecentSessions,
409
543
  loadSessionTags,
410
544
  getSessionFileMtime,
@@ -417,6 +551,7 @@ function createSessionStore(deps) {
417
551
  getSessionName,
418
552
  writeSessionName,
419
553
  markSessionStarted,
554
+ getSessionRecentContext,
420
555
  };
421
556
  }
422
557
 
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+
3
+ const ALLOWED_STATUS = new Set(['queued', 'running', 'blocked', 'done', 'failed']);
4
+ const ALLOWED_PRIORITY = new Set(['low', 'normal', 'high', 'urgent']);
5
+ const ALLOWED_TASK_KIND = new Set(['team', 'heartbeat']);
6
+
7
+ function sanitizeText(input, maxLen = 800) {
8
+ return String(input || '').replace(/[\x00-\x1F\x7F]/g, ' ').trim().slice(0, maxLen);
9
+ }
10
+
11
+ function normalizeScopeId(value, fallbackTaskId) {
12
+ const raw = sanitizeText(value, 120) || sanitizeText(fallbackTaskId, 120);
13
+ if (!raw) return '';
14
+ const cleaned = raw
15
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
16
+ .replace(/_+/g, '_')
17
+ .replace(/^_+|_+$/g, '');
18
+ return cleaned.slice(0, 120);
19
+ }
20
+
21
+ function sanitizeStringArray(values, maxItems = 20, maxItemLen = 300) {
22
+ if (!Array.isArray(values)) return [];
23
+ const out = [];
24
+ const seen = new Set();
25
+ for (const item of values) {
26
+ const v = sanitizeText(item, maxItemLen);
27
+ if (!v || seen.has(v)) continue;
28
+ seen.add(v);
29
+ out.push(v);
30
+ if (out.length >= maxItems) break;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function normalizeInputs(input) {
36
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return {};
37
+ const out = {};
38
+ for (const [k, v] of Object.entries(input)) {
39
+ const key = sanitizeText(k, 80);
40
+ if (!key) continue;
41
+ if (typeof v === 'string') out[key] = sanitizeText(v, 500);
42
+ else if (typeof v === 'number' || typeof v === 'boolean') out[key] = v;
43
+ else if (Array.isArray(v)) out[key] = sanitizeStringArray(v, 12, 240);
44
+ else if (v && typeof v === 'object') out[key] = JSON.parse(JSON.stringify(v));
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function normalizeDefinitionOfDone(raw) {
50
+ if (Array.isArray(raw)) return sanitizeStringArray(raw, 12, 240);
51
+ const text = sanitizeText(raw, 1200);
52
+ if (!text) return [];
53
+ return sanitizeStringArray(
54
+ text.split(/\r?\n|;|;/g).map(s => s.trim()).filter(Boolean),
55
+ 12,
56
+ 240
57
+ );
58
+ }
59
+
60
+ function newTaskId(now = new Date()) {
61
+ const y = now.getUTCFullYear();
62
+ const m = String(now.getUTCMonth() + 1).padStart(2, '0');
63
+ const d = String(now.getUTCDate()).padStart(2, '0');
64
+ const rand = Math.random().toString(36).slice(2, 8);
65
+ return `t_${y}${m}${d}_${rand}`;
66
+ }
67
+
68
+ function newHandoffId() {
69
+ const rand = Math.random().toString(36).slice(2, 8);
70
+ return `h_${Date.now()}_${rand}`;
71
+ }
72
+
73
+ function normalizeTaskEnvelope(raw, overrides = {}) {
74
+ const nowIso = new Date().toISOString();
75
+ const src = (raw && typeof raw === 'object') ? raw : {};
76
+ const merged = { ...src, ...overrides };
77
+
78
+ const taskId = sanitizeText(merged.task_id, 80) || newTaskId();
79
+ const parentTaskId = sanitizeText(merged.parent_task_id, 80) || null;
80
+ const fromAgent = sanitizeText(merged.from_agent, 80) || 'unknown';
81
+ const toAgent = sanitizeText(merged.to_agent, 80);
82
+ const scopeId = normalizeScopeId(merged.scope_id, taskId) || taskId;
83
+ const goal = sanitizeText(merged.goal, 500);
84
+ const definitionOfDone = normalizeDefinitionOfDone(merged.definition_of_done);
85
+ const artifacts = sanitizeStringArray(merged.artifacts, 30, 500);
86
+ const ownedPaths = sanitizeStringArray(merged.owned_paths, 30, 500);
87
+ const participantBase = sanitizeStringArray(merged.participants, 30, 80);
88
+ if (fromAgent) participantBase.push(fromAgent);
89
+ if (toAgent) participantBase.push(toAgent);
90
+ const participants = sanitizeStringArray(participantBase, 30, 80);
91
+ const statusRaw = sanitizeText(merged.status, 20).toLowerCase();
92
+ const priorityRaw = sanitizeText(merged.priority, 20).toLowerCase();
93
+ const kindRaw = sanitizeText(merged.task_kind, 20).toLowerCase();
94
+ const status = ALLOWED_STATUS.has(statusRaw) ? statusRaw : 'queued';
95
+ const priority = ALLOWED_PRIORITY.has(priorityRaw) ? priorityRaw : 'normal';
96
+ const taskKind = ALLOWED_TASK_KIND.has(kindRaw) ? kindRaw : 'team';
97
+ const createdAt = sanitizeText(merged.created_at, 64) || nowIso;
98
+ const updatedAt = sanitizeText(merged.updated_at, 64) || nowIso;
99
+ const inputs = normalizeInputs(merged.inputs);
100
+
101
+ return {
102
+ task_id: taskId,
103
+ scope_id: scopeId,
104
+ parent_task_id: parentTaskId,
105
+ from_agent: fromAgent,
106
+ to_agent: toAgent,
107
+ participants,
108
+ goal,
109
+ definition_of_done: definitionOfDone,
110
+ inputs,
111
+ artifacts,
112
+ owned_paths: ownedPaths,
113
+ task_kind: taskKind,
114
+ priority,
115
+ status,
116
+ created_at: createdAt,
117
+ updated_at: updatedAt,
118
+ };
119
+ }
120
+
121
+ function validateTaskEnvelope(env) {
122
+ if (!env || typeof env !== 'object') return { ok: false, error: 'envelope_missing' };
123
+ if (!sanitizeText(env.task_id, 80)) return { ok: false, error: 'task_id_required' };
124
+ if (!normalizeScopeId(env.scope_id, env.task_id)) return { ok: false, error: 'scope_id_required' };
125
+ if (!sanitizeText(env.from_agent, 80)) return { ok: false, error: 'from_agent_required' };
126
+ if (!sanitizeText(env.to_agent, 80)) return { ok: false, error: 'to_agent_required' };
127
+ if (!sanitizeText(env.goal, 500)) return { ok: false, error: 'goal_required' };
128
+ if (!ALLOWED_TASK_KIND.has(String(env.task_kind || '').toLowerCase())) return { ok: false, error: 'invalid_task_kind' };
129
+ if (!ALLOWED_STATUS.has(String(env.status || '').toLowerCase())) return { ok: false, error: 'invalid_status' };
130
+ if (!ALLOWED_PRIORITY.has(String(env.priority || '').toLowerCase())) return { ok: false, error: 'invalid_priority' };
131
+ return { ok: true };
132
+ }
133
+
134
+ module.exports = {
135
+ ALLOWED_STATUS,
136
+ ALLOWED_PRIORITY,
137
+ ALLOWED_TASK_KIND,
138
+ newTaskId,
139
+ newHandoffId,
140
+ normalizeScopeId,
141
+ normalizeTaskEnvelope,
142
+ validateTaskEnvelope,
143
+ };
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+
6
+ const {
7
+ normalizeTaskEnvelope,
8
+ validateTaskEnvelope,
9
+ newTaskId,
10
+ newHandoffId,
11
+ } = require('./daemon-task-envelope');
12
+
13
+ test('normalizeTaskEnvelope sets defaults for team task', () => {
14
+ const env = normalizeTaskEnvelope({
15
+ from_agent: 'assistant',
16
+ to_agent: 'coder',
17
+ goal: 'run tests',
18
+ });
19
+ assert.ok(env.task_id.startsWith('t_'));
20
+ assert.equal(env.scope_id, env.task_id);
21
+ assert.equal(env.task_kind, 'team');
22
+ assert.equal(env.status, 'queued');
23
+ assert.equal(env.priority, 'normal');
24
+ assert.equal(env.from_agent, 'assistant');
25
+ assert.equal(env.to_agent, 'coder');
26
+ assert.equal(env.goal, 'run tests');
27
+ assert.deepEqual(env.participants.sort(), ['assistant', 'coder'].sort());
28
+ });
29
+
30
+ test('validateTaskEnvelope rejects missing goal', () => {
31
+ const env = normalizeTaskEnvelope({
32
+ from_agent: 'assistant',
33
+ to_agent: 'coder',
34
+ goal: '',
35
+ });
36
+ const v = validateTaskEnvelope(env);
37
+ assert.equal(v.ok, false);
38
+ assert.equal(v.error, 'goal_required');
39
+ });
40
+
41
+ test('id generators return expected prefixes', () => {
42
+ const taskId = newTaskId(new Date('2026-02-25T00:00:00.000Z'));
43
+ const handoffId = newHandoffId();
44
+ assert.ok(taskId.startsWith('t_20260225_'));
45
+ assert.ok(handoffId.startsWith('h_'));
46
+ });
47
+
48
+ test('normalizeTaskEnvelope keeps explicit scope and merges participants', () => {
49
+ const env = normalizeTaskEnvelope({
50
+ task_id: 't_1',
51
+ scope_id: 'scope#A/1',
52
+ from_agent: 'assistant',
53
+ to_agent: 'reviewer',
54
+ participants: ['assistant', 'coder'],
55
+ goal: 'review changes',
56
+ });
57
+ assert.equal(env.scope_id, 'scope_A_1');
58
+ assert.deepEqual(env.participants.sort(), ['assistant', 'coder', 'reviewer'].sort());
59
+ });