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.
- package/README.md +118 -34
- package/index.js +12 -5
- package/package.json +2 -2
- package/scripts/check-macos-control-capabilities.sh +77 -0
- package/scripts/daemon-admin-commands.js +350 -12
- package/scripts/daemon-admin-commands.test.js +333 -0
- package/scripts/daemon-agent-commands.js +20 -1
- package/scripts/daemon-claude-engine.js +62 -12
- package/scripts/daemon-command-router.js +257 -12
- package/scripts/daemon-default.yaml +10 -3
- package/scripts/daemon-exec-commands.js +248 -13
- package/scripts/daemon-session-store.js +176 -41
- package/scripts/daemon-task-envelope.js +143 -0
- package/scripts/daemon-task-envelope.test.js +59 -0
- package/scripts/daemon-task-scheduler.js +213 -24
- package/scripts/daemon-task-scheduler.test.js +106 -0
- package/scripts/daemon-user-acl.js +399 -0
- package/scripts/daemon.js +376 -26
- package/scripts/distill.js +184 -34
- package/scripts/memory-extract.js +13 -5
- package/scripts/memory.js +239 -60
- package/scripts/providers.js +1 -1
- package/scripts/reliability-core.test.js +268 -0
- package/scripts/session-analytics.js +123 -35
- package/scripts/signal-capture.js +171 -11
- package/scripts/skill-evolution.js +158 -19
- package/scripts/task-board.js +398 -0
- package/scripts/task-board.test.js +83 -0
- package/scripts/usage-classifier.js +139 -0
- package/scripts/utils.js +107 -0
- package/scripts/utils.test.js +61 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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(
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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(
|
|
275
|
-
.replace(
|
|
276
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
+
});
|